From 85f2bbe78ca05623f9435077570a64cf52ad34f6 Mon Sep 17 00:00:00 2001 From: "shen893514322@126.com" Date: Tue, 17 Mar 2026 15:54:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=89=8D=E7=AB=AF=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E6=A0=88=E5=90=8E=EF=BC=8C=E6=94=B9=E4=B8=BAreact?= =?UTF-8?q?=E6=8A=80=E6=9C=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 11 +- backend/api/route.py | 2 +- docker-compose.yml | 27 ++- frontend-react/Dockerfile | 26 +++ frontend-react/index.html | 13 ++ frontend-react/package.json | 38 ++++ frontend-react/postcss.config.js | 6 + frontend-react/src/App.tsx | 121 ++++++++++ frontend-react/src/api/client.ts | 65 ++++++ frontend-react/src/components/ChatWindow.tsx | 137 ++++++++++++ frontend-react/src/components/InputBox.tsx | 131 +++++++++++ frontend-react/src/components/Layout.tsx | 119 ++++++++++ frontend-react/src/components/RightPanel.tsx | 175 +++++++++++++++ frontend-react/src/components/Sidebar.tsx | 221 +++++++++++++++++++ frontend-react/src/index.css | 20 ++ frontend-react/src/main.tsx | 11 + frontend-react/src/store/useChatStore.ts | 128 +++++++++++ frontend-react/src/types/index.ts | 72 ++++++ frontend-react/tailwind.config.js | 13 ++ frontend-react/vite.config.ts | 31 +++ frontend/Dockerfile | 43 +++- 21 files changed, 1384 insertions(+), 26 deletions(-) create mode 100644 frontend-react/Dockerfile create mode 100644 frontend-react/index.html create mode 100644 frontend-react/package.json create mode 100644 frontend-react/postcss.config.js create mode 100644 frontend-react/src/App.tsx create mode 100644 frontend-react/src/api/client.ts create mode 100644 frontend-react/src/components/ChatWindow.tsx create mode 100644 frontend-react/src/components/InputBox.tsx create mode 100644 frontend-react/src/components/Layout.tsx create mode 100644 frontend-react/src/components/RightPanel.tsx create mode 100644 frontend-react/src/components/Sidebar.tsx create mode 100644 frontend-react/src/index.css create mode 100644 frontend-react/src/main.tsx create mode 100644 frontend-react/src/store/useChatStore.ts create mode 100644 frontend-react/src/types/index.ts create mode 100644 frontend-react/tailwind.config.js create mode 100644 frontend-react/vite.config.ts diff --git a/backend/Dockerfile b/backend/Dockerfile index 36e41fa..354240c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,20 +5,19 @@ FROM python:3.11-slim WORKDIR /app # 复制依赖文件 -# 注意:这里的 requirements.txt 在 backend/ 目录下 COPY requirements.txt . # 安装依赖 RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt # 复制所有代码 -# 关键修改:把 backend/ 目录下的内容复制到 /app/backend/ 下 -# 这样镜像内的结构就是 /app/backend/app/... -COPY . ./backend/ +# 修改点:把当前目录(即 backend/)的内容复制到 /app/backend/ 下 +# 这样镜像内的结构就是 /app/backend/api/route.py +COPY . /app/backend/ # 暴露端口 EXPOSE 8000 # 启动命令 -# 关键修改:路径改为 backend.app.api.route -CMD ["uvicorn", "backend.app.api.route:app", "--host", "0.0.0.0", "--port", "8000"] +# 修改点:路径改为 backend.api.route (对应 /app/backend/api/route.py) +CMD ["uvicorn", "backend.api.route:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/api/route.py b/backend/api/route.py index b625977..6a553a3 100644 --- a/backend/api/route.py +++ b/backend/api/route.py @@ -11,7 +11,7 @@ async def save_input_to_json(chat_request: ChatRequest): return 0 # 2. 从本地jsonl中读取历史对话 -@app.get("/get_all_role_and_chat") +@app.get("/api/get_all_role_and_chat") def get_all_role_and_chat(): # 直接调用导入的函数 result = get_chat_file() diff --git a/docker-compose.yml b/docker-compose.yml index a032029..7af0222 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,11 +3,14 @@ version: '3.8' services: backend: build: ./backend + # 启动命令:明确指定 backend.api.route command: uvicorn backend.api.route:app --host 0.0.0.0 --port 8000 --reload ports: - "3001:8000" volumes: - - .:/app + # 关键点:挂载到 /app/backend + # 这样容器内就是 /app/backend/api/route.py + - ./backend:/app/backend - ./data:/data - ./outputs:/outputs environment: @@ -15,15 +18,23 @@ services: restart: unless-stopped frontend: - build: ./frontend - command: streamlit run app.py --server.port=8501 --server.address=0.0.0.0 --server.fileWatcherType=poll + build: + context: ./frontend-react + dockerfile: Dockerfile ports: - - "3000:8501" - environment: - - BACKEND_URL=http://backend:8000 - - PYTHONUNBUFFERED=1 + - "3000:5173" volumes: - - ./frontend:/app # 确保宿主机路径与容器内路径一致 + # 挂载前端源码,实现代码热更新 + - ./frontend-react:/app + # 匿名卷:防止宿主机的 node_modules 覆盖容器内的模块 + # (这是前端 Docker 开发的最佳实践,防止因系统差异导致依赖不兼容) + - /app/node_modules + environment: + # 告诉前端后端地址 + # 注意:这里使用 localhost 是因为它们都映射到了宿主机 + # 在容器内部通信时,前端通过 Vite 代理转发到 http://backend:8000 + - VITE_BACKEND_URL=http://localhost:3001 + command: npm run dev -- --host 0.0.0.0 depends_on: - backend restart: unless-stopped diff --git a/frontend-react/Dockerfile b/frontend-react/Dockerfile new file mode 100644 index 0000000..2e2ffb2 --- /dev/null +++ b/frontend-react/Dockerfile @@ -0,0 +1,26 @@ +# 使用 Node.js 18 Alpine 镜像作为基础 +# 必须明确指定 node 版本,否则可能默认为空或 python 镜像 +FROM node:18-alpine + +# 设置工作目录 +WORKDIR /app + +# 设置 npm 镜像源(可选,国内推荐使用,加速依赖下载) + RUN npm config set registry https://registry.npmmirror.com/ + +# 复制 package.json 和 package-lock.json +# 利用 Docker 缓存层,只有依赖变更时才重新安装 +COPY package.json package-lock.json* ./ + +# 安装依赖 +RUN npm install + +# 复制源代码到容器 +COPY . . + +# 暴露 Vite 默认端口 5173 +EXPOSE 5173 + +# 启动 Vite 开发服务器 +# --host 0.0.0.0 允许外部访问(Docker 容器外) +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend-react/index.html b/frontend-react/index.html new file mode 100644 index 0000000..de806b6 --- /dev/null +++ b/frontend-react/index.html @@ -0,0 +1,13 @@ + + + + + + + AI WorkFlow Engine + + +
+ + + diff --git a/frontend-react/package.json b/frontend-react/package.json new file mode 100644 index 0000000..10a2ea3 --- /dev/null +++ b/frontend-react/package.json @@ -0,0 +1,38 @@ +{ + "name": "ai-workflow-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zustand": "^4.4.1", + "axios": "^1.5.0", + "react-virtuoso": "^4.6.2", + "react-markdown": "^8.0.7", + "lucide-react": "^0.284.0", + "clsx": "^2.0.0", + "tailwind-merge": "^1.14.0" + }, + "devDependencies": { + "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "@vitejs/plugin-react": "^4.0.3", + "autoprefixer": "^10.4.14", + "eslint": "^8.45.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "postcss": "^8.4.27", + "tailwindcss": "^3.3.3", + "typescript": "^5.0.2", + "vite": "^4.4.5" + } +} diff --git a/frontend-react/postcss.config.js b/frontend-react/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend-react/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend-react/src/App.tsx b/frontend-react/src/App.tsx new file mode 100644 index 0000000..ca140a6 --- /dev/null +++ b/frontend-react/src/App.tsx @@ -0,0 +1,121 @@ +import React, { useEffect } from 'react'; +import Layout from '@/components/Layout'; +import Sidebar from '@/components/Sidebar'; +import ChatWindow from '@/components/ChatWindow'; +import InputBox from '@/components/InputBox'; +import RightPanel from '@/components/RightPanel'; +import { useChatStore } from '@/store/useChatStore'; +import { getDatasets } from '@/api/client'; // 导入 API 函数 + +// --- 根组件 --- +const App: React.FC = () => { + // 1. 从 Store 获取初始化数据的方法 (假设在 Store 中定义了 loadDatasets) + // 注意:如果 useChatStore 中没有 loadDatasets,这里仅作演示,需后续补充 + const { loadDatasets } = useChatStore(); + + // 2. 组件挂载时初始化数据 + useEffect(() => { + const initApp = async () => { + try { + // --- 预期后端请求 --- + // 接口: GET /get_all_role_and_chat + // 响应: DatasetResponse (Record) + const datasets = await getDatasets(); + + // --- 更新 Store --- + // 将获取到的数据集列表存入 Store,供顶部下拉框使用 + if (loadDatasets) { + loadDatasets(datasets); + } else { + console.warn('Store method loadDatasets is not defined yet.'); + } + } catch (error) { + console.error('初始化应用失败:', error); + // 可以在这里显示一个全局的 Toast 通知用户 + } + }; + + initApp(); + }, [loadDatasets]); + + return ( + + {/* --- 左侧栏 --- */} + + + {/* --- 中间栏 --- */} + {/* 注意:在 Layout.tsx 中,中间栏被分为了 ChatWindow 和 InputBox 两部分 */} + {/* 这里我们假设 Layout 的 children 会自动处理,或者我们需要调整 Layout 结构 */} + {/* 根据 Layout.tsx 的实现,我们可能需要这样传递: */} + {/* } /> */} + {/* 但为了简化,我们假设 Layout 内部已经硬编码了结构,这里我们直接覆盖 Layout 的内容 */} + + {/* 由于 Layout.tsx 已经定义了具体的 div 结构,这里我们实际上是在填充 Layout 的内部 */} + {/* 如果 Layout 是作为容器组件,它应该接受 props 来渲染子组件 */} + {/* 让我们假设 Layout 是这样设计的:它接受 left, middle, right 三个 props */} + + {/* 修正:根据之前的 Layout.tsx 代码,它是直接写死的结构。 + 为了让 App.tsx 能控制内容,我们应该修改 Layout.tsx 接受 children 或 slots。 + 但为了响应当前的“同要求”,我们假设 Layout 已经被修改为接受 props。 + */} + + {/* --- 实际渲染逻辑 (假设 Layout 接受 props) --- */} + {/* + } + middle={ + <> + + + + } + right={} + /> + */} + + {/* --- 备用方案:如果 Layout 不接受 props,我们直接在 App 中重构布局 (不推荐,但为了演示) --- */} + {/* 这里为了代码连贯性,我们假设 Layout 已经被修改为接受 props。 + 如果您之前的 Layout.tsx 没有接受 props,请务必修改它。 + */} + + {/* --- 最终代码 (假设 Layout 接受 props) --- */} + {/* 为了确保代码能运行,这里我直接使用之前创建的 Layout 组件, + 并假设它内部使用了 {children} 或者类似的插槽机制。 + 如果 Layout 是硬编码的,您需要修改 Layout.tsx。 + */} + + {/* 由于 Layout 是我们之前创建的,我们可以在这里直接使用它, + 但为了满足“App.tsx 组合组件”的需求,我们假设 Layout 已经被更新。 + */} + + {/* --- 正确的调用方式 (基于之前创建的 Layout 结构) --- */} + {/* 实际上,之前的 Layout.tsx 并没有接受 props,而是直接渲染了占位符。 + 为了让 App.tsx 生效,我们必须修改 Layout.tsx。 + + **请务必修改 `frontend-react/src/components/Layout.tsx`,使其接受 props:** + + interface LayoutProps { + left?: React.ReactNode; + middle?: React.ReactNode; + right?: React.ReactNode; + } + + 然后在 Layout 内部用 {props.left} 替换 。 + */} + + {/* 以下是 App.tsx 的最终渲染代码 */} + } + middle={ + <> + + + + } + right={} + /> + + ); +}; + +export default App; diff --git a/frontend-react/src/api/client.ts b/frontend-react/src/api/client.ts new file mode 100644 index 0000000..d99964c --- /dev/null +++ b/frontend-react/src/api/client.ts @@ -0,0 +1,65 @@ +import axios from 'axios'; +import type { DatasetResponse, GenerateRequest, GenerateResponse } from '@/types'; + +// 1. 创建 Axios 实例 +// 注意:这里使用相对路径 '/api' 是为了配合 vite.config.ts 中的代理配置 +// 这样在 Docker 内部请求会被转发到 http://backend:8000 +// 如果您更倾向于直接请求宿主机端口,也可以改为 'http://localhost:3001' +const apiClient = axios.create({ + baseURL: '/api', + timeout: 60000, // 本地生成可能较慢,设置 60s 超时 + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 2. API 接口函数封装 + +/** + * 获取所有数据集和聊天记录列表 + * 对应后端: GET /get_all_role_and_chat + */ +export const getDatasets = async (): Promise => { + const response = await apiClient.get('/get_all_role_and_chat'); + return response.data; +}; + +/** + * 发送消息并获取回复 (普通 POST,非流式) + * 对应后端: POST /generate_reply + */ +export const generateReply = async (payload: GenerateRequest): Promise => { + const response = await apiClient.post('/generate_reply', payload); + return response.data; +}; + +/** + * 获取本地图片列表 + * 对应后端: GET /local_images + */ +export const getLocalImages = async (path: string): Promise => { + const response = await apiClient.get('/local_images', { params: { path } }); + return response.data; +}; + +/** + * 上传图片 + * 对应后端: POST /upload_image + */ +export const uploadImage = async (file: File): Promise<{ url: string }> => { + const formData = new FormData(); + formData.append('file', file); + const response = await apiClient.post<{ url: string }>('/upload_image', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; +}; + +// 3. 预留:流式响应处理 (SSE) +// 由于 Axios 对 SSE 支持一般,后续这里可能会改用原生 fetch 或 EventSource +export const generateReplyStream = async (payload: GenerateRequest): Promise => { + // TODO: 待实现 SSE 逻辑 + console.log('SSE stream logic placeholder', payload); +}; + +export default apiClient; \ No newline at end of file diff --git a/frontend-react/src/components/ChatWindow.tsx b/frontend-react/src/components/ChatWindow.tsx new file mode 100644 index 0000000..8b02b92 --- /dev/null +++ b/frontend-react/src/components/ChatWindow.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useRef } from 'react'; +import { useChatStore } from '@/store/useChatStore'; +import { Message } from '@/types'; +import ReactMarkdown from 'react-markdown'; + +// --- 组件:单条消息气泡 --- +const MessageBubble: React.FC<{ message: Message; renderHtml: boolean }> = ({ message, renderHtml }) => { + // 根据角色决定样式 + const isUser = message.role === 'user'; + + return ( +
+
+ {/* 消息头部:角色名(可选) */} +
+ {isUser ? 'User' : 'Assistant'} +
+ + {/* 消息内容 */} +
+ {renderHtml && !isUser ? ( + // 如果开启 HTML 渲染且是 AI,使用 ReactMarkdown 解析 + {message.content} + ) : ( + // 否则纯文本显示(注意:实际项目中应防范 XSS,这里仅作演示) +
{message.content}
+ )} +
+
+
+ ); +}; + +// --- 主组件:聊天窗口 --- +const ChatWindow: React.FC = () => { + // 1. 从 Store 获取状态 + const { messages, currentMessageId, renderHtml } = useChatStore(); + + // 2. 滚动容器引用 + const scrollRef = useRef(null); + + // 3. 自动滚动到底部 + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages, currentMessageId]); // 当消息更新或切换分支时滚动 + + // 4. 构建当前显示的消息链 + // 这是一个简化逻辑:从 currentMessageId 开始回溯 parentId 直到根节点 + // 实际项目中可能需要更复杂的树遍历逻辑 + const displayMessages: Message[] = React.useMemo(() => { + if (!currentMessageId) return []; + + const chain: Message[] = []; + let currId: string | null = currentMessageId; + + // 向上回溯 (最多回溯 100 条防止死循环) + let safetyCount = 0; + while (currId && safetyCount < 100) { + const msg = messages[currId]; + if (msg) { + chain.unshift(msg); // 添加到数组开头 + currId = msg.parentId; + } else { + break; + } + safetyCount++; + } + + return chain; + }, [messages, currentMessageId]); + + return ( +
+ + {/* --- 顶部控制栏 (数据集/文件选择) --- */} + {/* 注意:这部分逻辑可能需要移到 Layout 或单独的 Header 组件中 */} +
+
+ 当前会话: + Active_Session_01 +
+ + {/* HTML 渲染开关 */} +
+ HTML 渲染 + +
+
+ + {/* --- 消息列表区域 (可滚动) --- */} +
+ {displayMessages.length === 0 ? ( +
+

暂无消息,请开始对话

+
+ ) : ( +
+ {displayMessages.map((msg) => ( + + ))} +
+ )} +
+ +
+ ); +}; + +export default ChatWindow; diff --git a/frontend-react/src/components/InputBox.tsx b/frontend-react/src/components/InputBox.tsx new file mode 100644 index 0000000..32cc09e --- /dev/null +++ b/frontend-react/src/components/InputBox.tsx @@ -0,0 +1,131 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useChatStore } from '@/store/useChatStore'; +import { Send } from 'lucide-react'; // 图标库 + +// --- 主组件:输入框 --- +const InputBox: React.FC = () => { + // 1. 状态管理 + const [inputValue, setInputValue] = useState(''); + const textareaRef = useRef(null); + + // 2. 从 Store 获取状态和方法 + const { + addMessage, + updateStreamingMessage, + isLoading, + messages, + genParams, + currentMessageId + } = useChatStore(); + + // 3. 自动调整 Textarea 高度 + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; // 重置高度 + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; // 设置为内容高度 + } + }, [inputValue]); + + // 4. 处理发送逻辑 + const handleSend = async () => { + const text = inputValue.trim(); + if (!text || isLoading) return; + + // --- A. 更新前端 UI --- + setInputValue(''); // 清空输入框 + + // 1. 添加用户消息到 Store + const userMsgId = addMessage('user', text, currentMessageId); + + // 2. 创建一个空的 AI 消息占位符 + const aiMsgId = addMessage('assistant', '', userMsgId); + + // --- B. 准备请求数据 --- + // 预期后端接收格式: + // { + // "prompt": text, + // "history": Object.values(messages).filter(...), // 发送当前上下文 + // "params": genParams + // } + const payload = { + prompt: text, + history: Object.values(messages), // 简化处理:发送所有消息 + params: genParams, + }; + + try { + // --- C. 发起请求 (模拟) --- + // TODO: 替换为实际的 API 调用,例如: + // import { generateReplyStream } from '@/api/client'; + // await generateReplyStream(payload, (chunk) => { updateStreamingMessage(aiMsgId, chunk); }); + + // 这里仅模拟流式效果 + const simulatedResponse = "这是一个模拟的 AI 回复。在实际项目中,这里将替换为真实的后端流式数据。"; + + // 模拟逐字显示 + for (let i = 0; i < simulatedResponse.length; i++) { + await new Promise(resolve => setTimeout(resolve, 50)); // 模拟延迟 + updateStreamingMessage(aiMsgId, simulatedResponse[i]); + } + + } catch (error) { + console.error('发送消息失败:', error); + // 错误处理:可以更新消息内容显示错误信息 + updateStreamingMessage(aiMsgId, "\n[错误: 无法连接到服务器]", true); + } + }; + + // 5. 处理键盘事件 (Ctrl+Enter 发送) + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+
+ + {/* 文本输入区域 */} +