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 (
+
+
+
+ {/* 底部提示 */}
+
+ {isLoading ? 'AI 正在思考...' : 'Enter 换行,Ctrl+Enter 发送'}
+
+
+ );
+};
+
+export default InputBox;
diff --git a/frontend-react/src/components/Layout.tsx b/frontend-react/src/components/Layout.tsx
new file mode 100644
index 0000000..1f89388
--- /dev/null
+++ b/frontend-react/src/components/Layout.tsx
@@ -0,0 +1,119 @@
+import React, { ReactNode } from 'react';
+
+// --- 1. 定义 Props 接口 ---
+interface LayoutProps {
+ left?: ReactNode;
+ middle?: ReactNode;
+ right?: ReactNode;
+}
+
+// --- 2. 占位符组件 (当未传入 props 时显示) ---
+const SidebarPlaceholder = () => (
+
+);
+
+const ChatPlaceholder = () => (
+
+);
+
+const InputPlaceholder = () => (
+
+);
+
+const RightPanelPlaceholder = () => (
+
+);
+
+// --- 3. 主布局组件 ---
+const Layout: React.FC = ({ left, middle, right }) => {
+ return (
+ // 1. 最外层容器:全屏高度,浅蓝灰背景,Flex 列布局
+
+
+ {/* --- 顶部工具栏 --- */}
+
+
+
+
+
+
+ AI WorkFlow Engine
+
+
+
+
+
+
+ {/* --- 主体区域:三栏布局 --- */}
+
+
+ {/* --- 左侧栏 (1份) --- */}
+
+
+ {/* --- 中间栏 (3份) --- */}
+ {/* 注意:这里我们假设 middle prop 包含了 ChatWindow 和 InputBox */}
+ {/* 如果 middle 为空,我们显示占位符 */}
+
+ {middle ? (
+ middle
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+ {/* --- 右侧栏 (1份) --- */}
+
+
+
+
+ {/* --- 全局滚动条样式 --- */}
+
+
+ );
+};
+
+export default Layout;
diff --git a/frontend-react/src/components/RightPanel.tsx b/frontend-react/src/components/RightPanel.tsx
new file mode 100644
index 0000000..1a7c57b
--- /dev/null
+++ b/frontend-react/src/components/RightPanel.tsx
@@ -0,0 +1,175 @@
+import React, { useState, useEffect } from 'react';
+import { Dice1, Image as ImageIcon, Table } from 'lucide-react';
+import { useChatStore } from '@/store/useChatStore';
+
+// --- 子组件:本地图库 ---
+const LocalGallery: React.FC = () => {
+ const [images, setImages] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ // 模拟加载图片数据
+ useEffect(() => {
+ setLoading(true);
+ // TODO: 替换为真实的 API 调用
+ // import { getLocalImages } from '@/api/client';
+ // const data = await getLocalImages('/assets/images');
+ // setImages(data);
+
+ // 模拟延迟
+ setTimeout(() => {
+ setImages([
+ 'placeholder1.jpg',
+ 'placeholder2.jpg',
+ 'placeholder3.jpg',
+ 'placeholder4.jpg'
+ ]);
+ setLoading(false);
+ }, 500);
+ }, []);
+
+ return (
+
+
+
+
🖼️ 本地图库
+
+
+
+ {loading ? (
+
加载中...
+ ) : images.length === 0 ? (
+
文件夹为空
+ ) : (
+
+ {images.map((img, idx) => (
+
+ {/* 图片占位符 */}
+
+ {img}
+
+
{img}
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+// --- 子组件:骰子检定 ---
+const DiceTool: React.FC = () => {
+ const [result, setResult] = useState(null);
+ const [rollType, setRollType] = useState<'difficulty' | 'opposed'>('difficulty');
+ const [difficulty, setDifficulty] = useState(50); // 默认普通难度
+
+ const handleRoll = () => {
+ // 生成 1-100 随机数
+ const res = Math.floor(Math.random() * 100) + 1;
+ setResult(res);
+ };
+
+ return (
+
+
+
+
🎲 检定工具
+
+
+ {/* Tab 切换 */}
+
+
+
+
+
+ {/* 难度选择 (仅在难度检定模式下显示) */}
+ {rollType === 'difficulty' && (
+
+
+
+ {[
+ { label: '极难', val: 95 },
+ { label: '困难', val: 75 },
+ { label: '普通', val: 50 }
+ ].map((opt) => (
+
+ ))}
+
+
+ )}
+
+ {/* 投掷区域 */}
+
+
+
+ {rollType === 'difficulty' ? `目标: ${difficulty}` : '对抗检定'}
+
+ {result !== null && (
+
difficulty
+ ? 'text-red-500' // 失败
+ : 'text-green-500' // 成功
+ }`}
+ >
+ {result}
+
+ )}
+
+
+
+
+ );
+};
+
+// --- 主组件:右侧面板 ---
+const RightPanel: React.FC = () => {
+ return (
+
+ {/* 上半部分:图库 */}
+
+
+
+
+ {/* 下半部分:骰子 */}
+
+
+
+
+ );
+};
+
+export default RightPanel;
diff --git a/frontend-react/src/components/Sidebar.tsx b/frontend-react/src/components/Sidebar.tsx
new file mode 100644
index 0000000..7927171
--- /dev/null
+++ b/frontend-react/src/components/Sidebar.tsx
@@ -0,0 +1,221 @@
+import React from 'react';
+import { useChatStore } from '@/store/useChatStore';
+import { Slider } from '@/components/ui/slider'; // 假设您有这个组件,或者用原生 input[type=range]
+import { Switch } from '@/components/ui/switch'; // 假设您有这个组件,或者用原生 input[type=checkbox]
+import { Label } from '@/components/ui/label'; // 假设您有这个组件,或者用原生 label
+
+// --- 类型定义 ---
+// 这里复用 Store 中的类型,或者从 types/index.ts 导入
+type SpliceBlockType = 'system' | 'world' | 'history' | 'character';
+
+const Sidebar: React.FC = () => {
+ // 1. 从 Store 获取状态和方法
+ const {
+ genParams,
+ spliceBlocks,
+ updateGenParams,
+ toggleSpliceBlock,
+ } = useChatStore();
+
+ // 2. 处理参数变更
+ const handleParamChange = (key: keyof typeof genParams, value: number | boolean) => {
+ updateGenParams({ [key]: value });
+ };
+
+ return (
+
+
+ {/* --- 标题:全局预设 --- */}
+
+
+ 📜 全局预设
+
+
+
+
+
+
+
+ {/* --- 标题:生成参数 --- */}
+
+
⚙️ 生成参数
+
+ {/* 参数网格布局 */}
+
+ {/* 温度 */}
+
+
+
+ {genParams.temperature.toFixed(1)}
+
+
handleParamChange('temperature', parseFloat(e.target.value))}
+ className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
+ />
+
+
+ {/* Top P */}
+
+
+
+ {genParams.top_p.toFixed(1)}
+
+
handleParamChange('top_p', parseFloat(e.target.value))}
+ className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
+ />
+
+
+ {/* 频率惩罚 */}
+
+
+
+ {genParams.frequency_penalty.toFixed(1)}
+
+
handleParamChange('frequency_penalty', parseFloat(e.target.value))}
+ className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
+ />
+
+
+ {/* 存在惩罚 */}
+
+
+
+ {genParams.presence_penalty.toFixed(1)}
+
+
handleParamChange('presence_penalty', parseFloat(e.target.value))}
+ className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
+ />
+
+
+ {/* 上下文长度 */}
+
+
+ handleParamChange('context_length', parseInt(e.target.value))}
+ className="w-full mt-1 p-1 text-sm border border-gray-300 rounded"
+ />
+
+
+ {/* 最大回复 */}
+
+
+ handleParamChange('max_tokens', parseInt(e.target.value))}
+ className="w-full mt-1 p-1 text-sm border border-gray-300 rounded"
+ />
+
+
+
+ {/* 流式传输开关 */}
+
+ handleParamChange('stream', e.target.checked)}
+ className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
+ />
+
+
+
+
+ {/* --- 标题:内容拼接块 --- */}
+
+
🧩 内容拼接块
+
控制发送至后端的上下文组成
+
+ {/* 拼接块列表 */}
+
+ {spliceBlocks.map((block) => (
+
+ {/* 左侧:图标和名称 */}
+
+
+ {block.type === 'world' ? '🌍' : block.type === 'history' ? '💬' : '📄'}
+
+
+ {block.name}
+
+
+
+ {/* 右侧:操作按钮 */}
+
+ {/* 编辑按钮 (如果不可编辑则置灰) */}
+
+
+ {/* 激活开关 */}
+ toggleSpliceBlock(block.id)}
+ className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 cursor-pointer"
+ />
+
+
+ ))}
+
+
+ {/* 添加按钮 */}
+
+
+
+ );
+};
+
+export default Sidebar;
diff --git a/frontend-react/src/index.css b/frontend-react/src/index.css
new file mode 100644
index 0000000..1c01463
--- /dev/null
+++ b/frontend-react/src/index.css
@@ -0,0 +1,20 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* --- 全局样式补充 --- */
+body {
+ margin: 0;
+ padding: 0;
+ /* 确保背景色填充整个窗口 */
+ background-color: #F0F4F8;
+ color: #1A1A1A;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+}
+
+/* 确保 React 根节点占满全屏 */
+#root {
+ height: 100vh;
+ width: 100vw;
+ overflow: hidden;
+}
diff --git a/frontend-react/src/main.tsx b/frontend-react/src/main.tsx
new file mode 100644
index 0000000..f3d47c2
--- /dev/null
+++ b/frontend-react/src/main.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+import './index.css'; // 引入 Tailwind CSS 的基础样式
+
+// --- 渲染根组件 ---
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/frontend-react/src/store/useChatStore.ts b/frontend-react/src/store/useChatStore.ts
new file mode 100644
index 0000000..0452868
--- /dev/null
+++ b/frontend-react/src/store/useChatStore.ts
@@ -0,0 +1,128 @@
+import { create } from 'zustand';
+import { Message, ChatState, SpliceBlock, GenerationParams } from '@/types';
+
+// --- 辅助函数:生成唯一 ID ---
+const generateId = (): string => {
+ return Date.now().toString(36) + Math.random().toString(36).substr(2);
+};
+
+// --- 初始状态定义 ---
+const initialSpliceBlocks: SpliceBlock[] = [
+ { id: 1, name: "[必看] 系统指令", active: true, type: "system" },
+ { id: 2, name: "A.U.T.O. 预设设置", active: true, type: "system" },
+ { id: 3, name: "世界书:人物关系", active: false, type: "world" },
+ { id: 4, name: "Chat History (自动)", active: true, type: "history", editable: false },
+];
+
+const initialGenParams: GenerationParams = {
+ temperature: 1.0,
+ top_p: 0.9,
+ frequency_penalty: 1.0,
+ presence_penalty: 0.0,
+ max_tokens: 500,
+ context_length: 30000,
+ stream: true,
+};
+
+// --- Store 定义 ---
+export const useChatStore = create((set, get) => ({
+ // --- 状态初始化 ---
+ messages: {},
+ currentMessageId: null,
+
+ isLoading: false,
+ inputMessage: "",
+
+ renderHtml: true, // 默认开启 HTML 渲染,对应原 app.py
+ selectedFile: null,
+
+ spliceBlocks: initialSpliceBlocks,
+ genParams: initialGenParams,
+
+ // --- Actions (操作方法) ---
+
+ // 1. 设置输入框内容
+ setInputMessage: (text: string) => set({ inputMessage: text }),
+
+ // 2. 切换 HTML 渲染开关
+ toggleRenderHtml: () => set((state) => ({ renderHtml: !state.renderHtml })),
+
+ // 3. 更新生成参数 (支持部分更新)
+ updateGenParams: (newParams: Partial) =>
+ set((state) => ({
+ genParams: { ...state.genParams, ...newParams },
+ })),
+
+ // 4. 切换拼接块状态 (即时生效)
+ toggleSpliceBlock: (id: string | number) =>
+ set((state) => ({
+ spliceBlocks: state.spliceBlocks.map((block) =>
+ block.id === id ? { ...block, active: !block.active } : block
+ ),
+ })),
+
+ // 5. 添加新消息 (用户发送或 AI 完整回复后调用)
+ addMessage: (role: 'user' | 'assistant', content: string, parentId: string | null = null) => {
+ const newMessage: Message = {
+ id: generateId(),
+ parentId,
+ childrenIds: [],
+ role,
+ content,
+ timestamp: Date.now(),
+ isStreaming: false, // 初始为非流式状态
+ };
+
+ set((state) => {
+ const newMessages = { ...state.messages, [newMessage.id]: newMessage };
+
+ // 如果有父节点,更新父节点的 childrenIds
+ if (parentId) {
+ const parent = state.messages[parentId];
+ if (parent) {
+ newMessages[parentId] = {
+ ...parent,
+ childrenIds: [...parent.childrenIds, newMessage.id],
+ };
+ }
+ }
+
+ return {
+ messages: newMessages,
+ currentMessageId: newMessage.id,
+ };
+ });
+
+ return newMessage.id; // 返回新消息 ID,方便后续操作
+ },
+
+ // 6. 更新流式消息内容 (打字机效果)
+ updateStreamingMessage: (messageId: string, contentChunk: string, isComplete: boolean = false) => {
+ set((state) => {
+ const currentMsg = state.messages[messageId];
+ if (!currentMsg) return state;
+
+ return {
+ messages: {
+ ...state.messages,
+ [messageId]: {
+ ...currentMsg,
+ content: currentMsg.content + contentChunk,
+ isStreaming: !isComplete, // 如果完成,则结束流式状态
+ },
+ },
+ };
+ });
+ },
+
+ // 7. 切换当前消息分支 (点击历史消息时)
+ switchBranch: (messageId: string) => {
+ set({ currentMessageId: messageId });
+ },
+
+ // 8. 设置加载状态
+ setLoading: (loading: boolean) => set({ isLoading: loading }),
+
+ // 9. 设置当前选中的文件
+ setSelectedFile: (filePath: string | null) => set({ selectedFile: filePath }),
+}));
diff --git a/frontend-react/src/types/index.ts b/frontend-react/src/types/index.ts
new file mode 100644
index 0000000..6487e1e
--- /dev/null
+++ b/frontend-react/src/types/index.ts
@@ -0,0 +1,72 @@
+// --- 1. 消息与聊天状态 (核心树形结构) ---
+
+export interface Message {
+ id: string;
+ parentId: string | null;
+ childrenIds: string[];
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+ timestamp: number;
+ // 新增:流式状态标记,对应原 app.py 的 message_placeholder 逻辑
+ isStreaming?: boolean;
+ // 新增:是否允许 HTML 渲染(虽然通常由全局开关控制,但单条消息也可以强制)
+ allowHtml?: boolean;
+}
+
+export interface ChatState {
+ messages: Record;
+ currentMessageId: string | null;
+
+ // UI 状态
+ isLoading: boolean;
+ inputMessage: string;
+
+ // 设置
+ renderHtml: boolean; // 对应原 app.py 的 st.toggle("HTML 渲染")
+ selectedFile: string | null; // 对应原 app.py 的 selected_file_path
+
+ // 左侧栏状态
+ spliceBlocks: SpliceBlock[]; // 对应原 app.py 的 st.session_state.splice_blocks
+ genParams: GenerationParams; // 对应原 app.py 的滑块参数
+}
+
+// --- 2. 左侧栏:预设与参数 ---
+
+export interface SpliceBlock {
+ id: string | number;
+ name: string;
+ active: boolean;
+ type: 'system' | 'world' | 'history' | 'character';
+ editable?: boolean;
+ content?: string;
+}
+
+export interface GenerationParams {
+ temperature: number; // 对应 slider_temp
+ top_p: number; // 对应 slider_top_p
+ frequency_penalty: number; // 对应 slider_freq
+ presence_penalty: number; // 对应 slider_pres
+ max_tokens: number; // 对应 input_max
+ context_length: number; // 对应 input_ctx
+ stream: boolean; // 对应 chk_stream
+}
+
+// --- 3. API 请求/响应 ---
+
+// 对应 GET /get_all_role_and_chat
+// 结构: { "DatasetName": ["file1.jsonl", "file2.jsonl"] }
+export interface DatasetResponse {
+ [datasetName: string]: string[];
+}
+
+// 对应 POST /generate_reply (预留)
+export interface GenerateRequest {
+ prompt: string; // 组装后的完整 Prompt
+ history: Message[]; // 当前上下文
+ params: GenerationParams;
+}
+
+export interface GenerateResponse {
+ content: string;
+ finish_reason: string;
+}
diff --git a/frontend-react/tailwind.config.js b/frontend-react/tailwind.config.js
new file mode 100644
index 0000000..82f20b7
--- /dev/null
+++ b/frontend-react/tailwind.config.js
@@ -0,0 +1,13 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ // --- 关键点在这里 ---
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}", // 确保这行存在且路径正确
+ ],
+ // -------------------
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/frontend-react/vite.config.ts b/frontend-react/vite.config.ts
new file mode 100644
index 0000000..9acfc35
--- /dev/null
+++ b/frontend-react/vite.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+
+ server: {
+ host: '0.0.0.0',
+ port: 5173,
+
+ proxy: {
+ // 修改点:增加 '^/(?!api|assets)' 排除根路径
+ // 解释:只代理以 /api 或 /assets 开头的请求
+ // 其他请求(如 /)由 Vite 处理
+ '^/(api|assets)': {
+ target: 'http://backend:8000',
+ changeOrigin: true,
+ // 如果后端路由没有 /api 前缀,需要 rewrite
+ // rewrite: (path) => path.replace(/^\/api/, '')
+ },
+ }
+ }
+})
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 77f9504..14c31fd 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -1,18 +1,39 @@
-# 使用 Python 3.11 基础镜像
-FROM python:3.11-slim
+# 1. 基础镜像变更
+# 原本是 Python 3.11,现在改为 Node.js 18 (Alpine 版本体积更小)
+# 分为两个阶段:builder (构建阶段) 和 production (运行阶段)
+FROM node:18-alpine as builder
-# 设置工作目录
+# 2. 设置工作目录
WORKDIR /app
-# 复制依赖文件并安装
-COPY requirements.txt .
-RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
+# 3. 依赖安装变更
+# 原本是复制 requirements.txt 并用 pip 安装 Python 库
+# 现在是复制 package.json 并用 npm 安装 Node.js 库
+COPY package.json package-lock.json* ./
+RUN npm ci
-# 复制所有代码
+# 4. 复制源代码
+# 保持不变,复制当前目录所有文件到容器
COPY . .
-# 暴露端口
-EXPOSE 8501
+# 5. 构建步骤
+# 原本没有这一步,Streamlit 是解释型语言,直接运行
+# React 需要编译打包成静态文件 (HTML/CSS/JS)
+RUN npm run build
-# 启动命令(使用 8501 端口,与 docker-compose 映射保持一致)
-CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
\ No newline at end of file
+# --- 生产环境运行阶段 ---
+FROM nginx:alpine
+
+# 6. 部署静态文件
+# 将上面构建好的 /app/build (React 默认构建目录) 复制到 Nginx 的默认目录
+COPY --from=builder /app/build /usr/share/nginx/html
+
+# 7. 端口变更
+# 原本是 Streamlit 的 8501 端口
+# 现在改为 Nginx 的标准 80 端口
+EXPOSE 80
+
+# 8. 启动命令变更
+# 原本是 streamlit run app.py
+# 现在是启动 Nginx 服务器
+CMD ["nginx", "-g", "daemon off;"]