修改前端技术栈后,改为react技术
This commit is contained in:
@@ -5,20 +5,19 @@ FROM python:3.11-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制依赖文件
|
# 复制依赖文件
|
||||||
# 注意:这里的 requirements.txt 在 backend/ 目录下
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||||
|
|
||||||
# 复制所有代码
|
# 复制所有代码
|
||||||
# 关键修改:把 backend/ 目录下的内容复制到 /app/backend/ 下
|
# 修改点:把当前目录(即 backend/)的内容复制到 /app/backend/ 下
|
||||||
# 这样镜像内的结构就是 /app/backend/app/...
|
# 这样镜像内的结构就是 /app/backend/api/route.py
|
||||||
COPY . ./backend/
|
COPY . /app/backend/
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# 启动命令
|
# 启动命令
|
||||||
# 关键修改:路径改为 backend.app.api.route
|
# 修改点:路径改为 backend.api.route (对应 /app/backend/api/route.py)
|
||||||
CMD ["uvicorn", "backend.app.api.route:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "backend.api.route:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ async def save_input_to_json(chat_request: ChatRequest):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
# 2. 从本地jsonl中读取历史对话
|
# 2. 从本地jsonl中读取历史对话
|
||||||
@app.get("/get_all_role_and_chat")
|
@app.get("/api/get_all_role_and_chat")
|
||||||
def get_all_role_and_chat():
|
def get_all_role_and_chat():
|
||||||
# 直接调用导入的函数
|
# 直接调用导入的函数
|
||||||
result = get_chat_file()
|
result = get_chat_file()
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
|
# 启动命令:明确指定 backend.api.route
|
||||||
command: uvicorn backend.api.route:app --host 0.0.0.0 --port 8000 --reload
|
command: uvicorn backend.api.route:app --host 0.0.0.0 --port 8000 --reload
|
||||||
ports:
|
ports:
|
||||||
- "3001:8000"
|
- "3001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
# 关键点:挂载到 /app/backend
|
||||||
|
# 这样容器内就是 /app/backend/api/route.py
|
||||||
|
- ./backend:/app/backend
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./outputs:/outputs
|
- ./outputs:/outputs
|
||||||
environment:
|
environment:
|
||||||
@@ -15,15 +18,23 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build:
|
||||||
command: streamlit run app.py --server.port=8501 --server.address=0.0.0.0 --server.fileWatcherType=poll
|
context: ./frontend-react
|
||||||
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "3000:8501"
|
- "3000:5173"
|
||||||
environment:
|
|
||||||
- BACKEND_URL=http://backend:8000
|
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
volumes:
|
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:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
26
frontend-react/Dockerfile
Normal file
26
frontend-react/Dockerfile
Normal file
@@ -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"]
|
||||||
13
frontend-react/index.html
Normal file
13
frontend-react/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI WorkFlow Engine</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
frontend-react/package.json
Normal file
38
frontend-react/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend-react/postcss.config.js
Normal file
6
frontend-react/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
121
frontend-react/src/App.tsx
Normal file
121
frontend-react/src/App.tsx
Normal file
@@ -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<string, string[]>)
|
||||||
|
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>
|
||||||
|
{/* --- 左侧栏 --- */}
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
{/* --- 中间栏 --- */}
|
||||||
|
{/* 注意:在 Layout.tsx 中,中间栏被分为了 ChatWindow 和 InputBox 两部分 */}
|
||||||
|
{/* 这里我们假设 Layout 的 children 会自动处理,或者我们需要调整 Layout 结构 */}
|
||||||
|
{/* 根据 Layout.tsx 的实现,我们可能需要这样传递: */}
|
||||||
|
{/* <Layout middleSlot={<><ChatWindow /><InputBox /></>} /> */}
|
||||||
|
{/* 但为了简化,我们假设 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) --- */}
|
||||||
|
{/*
|
||||||
|
<Layout
|
||||||
|
left={<Sidebar />}
|
||||||
|
middle={
|
||||||
|
<>
|
||||||
|
<ChatWindow />
|
||||||
|
<InputBox />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
right={<RightPanel />}
|
||||||
|
/>
|
||||||
|
*/}
|
||||||
|
|
||||||
|
{/* --- 备用方案:如果 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} 替换 <SidebarPlaceholder />。
|
||||||
|
*/}
|
||||||
|
|
||||||
|
{/* 以下是 App.tsx 的最终渲染代码 */}
|
||||||
|
<Layout
|
||||||
|
left={<Sidebar />}
|
||||||
|
middle={
|
||||||
|
<>
|
||||||
|
<ChatWindow />
|
||||||
|
<InputBox />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
right={<RightPanel />}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
65
frontend-react/src/api/client.ts
Normal file
65
frontend-react/src/api/client.ts
Normal file
@@ -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<DatasetResponse> => {
|
||||||
|
const response = await apiClient.get<DatasetResponse>('/get_all_role_and_chat');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息并获取回复 (普通 POST,非流式)
|
||||||
|
* 对应后端: POST /generate_reply
|
||||||
|
*/
|
||||||
|
export const generateReply = async (payload: GenerateRequest): Promise<GenerateResponse> => {
|
||||||
|
const response = await apiClient.post<GenerateResponse>('/generate_reply', payload);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本地图片列表
|
||||||
|
* 对应后端: GET /local_images
|
||||||
|
*/
|
||||||
|
export const getLocalImages = async (path: string): Promise<string[]> => {
|
||||||
|
const response = await apiClient.get<string[]>('/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<void> => {
|
||||||
|
// TODO: 待实现 SSE 逻辑
|
||||||
|
console.log('SSE stream logic placeholder', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
137
frontend-react/src/components/ChatWindow.tsx
Normal file
137
frontend-react/src/components/ChatWindow.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={`flex w-full mb-4 ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
max-w-[80%] rounded-lg p-3 shadow-sm
|
||||||
|
${isUser
|
||||||
|
? 'bg-blue-50 text-gray-800 border border-blue-100'
|
||||||
|
: 'bg-white text-gray-800 border border-gray-200'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* 消息头部:角色名(可选) */}
|
||||||
|
<div className="text-xs font-bold mb-1 text-gray-500">
|
||||||
|
{isUser ? 'User' : 'Assistant'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息内容 */}
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
{renderHtml && !isUser ? (
|
||||||
|
// 如果开启 HTML 渲染且是 AI,使用 ReactMarkdown 解析
|
||||||
|
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
// 否则纯文本显示(注意:实际项目中应防范 XSS,这里仅作演示)
|
||||||
|
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 主组件:聊天窗口 ---
|
||||||
|
const ChatWindow: React.FC = () => {
|
||||||
|
// 1. 从 Store 获取状态
|
||||||
|
const { messages, currentMessageId, renderHtml } = useChatStore();
|
||||||
|
|
||||||
|
// 2. 滚动容器引用
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className="flex flex-col h-full bg-[#F0F4F8]">
|
||||||
|
|
||||||
|
{/* --- 顶部控制栏 (数据集/文件选择) --- */}
|
||||||
|
{/* 注意:这部分逻辑可能需要移到 Layout 或单独的 Header 组件中 */}
|
||||||
|
<div className="flex-none h-12 border-b border-[#D1D9E6] bg-white flex items-center px-4 justify-between shrink-0">
|
||||||
|
<div className="flex items-center space-x-2 text-sm">
|
||||||
|
<span className="text-gray-500">当前会话:</span>
|
||||||
|
<span className="font-semibold text-gray-700">Active_Session_01</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HTML 渲染开关 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs text-gray-500">HTML 渲染</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {/* TODO: 调用 store.toggleRenderHtml() */}}
|
||||||
|
className={`w-10 h-5 rounded-full p-1 transition-colors duration-200 ease-in-out ${
|
||||||
|
renderHtml ? 'bg-blue-600' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`bg-white w-3 h-3 rounded-full shadow-md transform transition-transform duration-200 ease-in-out ${
|
||||||
|
renderHtml ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- 消息列表区域 (可滚动) --- */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 overflow-y-auto p-4 custom-scrollbar scroll-smooth"
|
||||||
|
>
|
||||||
|
{displayMessages.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||||
|
<p>暂无消息,请开始对话</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{displayMessages.map((msg) => (
|
||||||
|
<MessageBubble
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
renderHtml={renderHtml}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatWindow;
|
||||||
131
frontend-react/src/components/InputBox.tsx
Normal file
131
frontend-react/src/components/InputBox.tsx
Normal file
@@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-none bg-[#F0F4F8] p-4 border-t border-[#D1D9E6]">
|
||||||
|
<div className="relative flex items-end w-full bg-white border border-[#0056B3] rounded-lg shadow-sm focus-within:ring-2 focus-within:ring-blue-200 transition-all">
|
||||||
|
|
||||||
|
{/* 文本输入区域 */}
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="输入消息... (支持 /命令,Ctrl+Enter 发送)"
|
||||||
|
className="w-full max-h-48 min-h-[60px] p-3 pr-12 bg-transparent resize-none outline-none text-[#1A1A1A] text-sm custom-scrollbar"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 发送按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={isLoading || !inputValue.trim()}
|
||||||
|
className={`
|
||||||
|
absolute right-2 bottom-2 p-2 rounded-md transition-colors
|
||||||
|
${isLoading || !inputValue.trim()
|
||||||
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-blue-50 text-blue-600 hover:bg-blue-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title="发送 (Ctrl+Enter)"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
// 加载图标 (简单的 CSS 动画)
|
||||||
|
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部提示 */}
|
||||||
|
<div className="mt-2 text-xs text-gray-400 text-right">
|
||||||
|
{isLoading ? 'AI 正在思考...' : 'Enter 换行,Ctrl+Enter 发送'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InputBox;
|
||||||
119
frontend-react/src/components/Layout.tsx
Normal file
119
frontend-react/src/components/Layout.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// --- 1. 定义 Props 接口 ---
|
||||||
|
interface LayoutProps {
|
||||||
|
left?: ReactNode;
|
||||||
|
middle?: ReactNode;
|
||||||
|
right?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. 占位符组件 (当未传入 props 时显示) ---
|
||||||
|
const SidebarPlaceholder = () => (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-3/4 animate-pulse"></div>
|
||||||
|
<div className="h-32 bg-gray-100 rounded border border-gray-300"></div>
|
||||||
|
<div className="h-32 bg-gray-100 rounded border border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChatPlaceholder = () => (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-gray-400 space-y-2">
|
||||||
|
<div className="w-12 h-12 border-4 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
|
||||||
|
<p>聊天区域加载中...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InputPlaceholder = () => (
|
||||||
|
<div className="h-full w-full border border-[#0056B3] rounded-lg bg-white p-2 opacity-50">
|
||||||
|
<div className="h-full bg-gray-100 rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RightPanelPlaceholder = () => (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="h-32 bg-gray-100 rounded border border-gray-300"></div>
|
||||||
|
<div className="h-32 bg-gray-100 rounded border border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- 3. 主布局组件 ---
|
||||||
|
const Layout: React.FC<LayoutProps> = ({ left, middle, right }) => {
|
||||||
|
return (
|
||||||
|
// 1. 最外层容器:全屏高度,浅蓝灰背景,Flex 列布局
|
||||||
|
<div className="flex flex-col h-screen bg-[#F0F4F8] text-[#1A1A1A] font-sans overflow-hidden">
|
||||||
|
|
||||||
|
{/* --- 顶部工具栏 --- */}
|
||||||
|
<header className="h-14 bg-white border-b border-[#D1D9E6] flex items-center justify-between px-4 shrink-0 z-20 shadow-sm">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button className="text-[#0056B3] font-semibold hover:bg-blue-50 px-3 py-1 rounded transition">
|
||||||
|
📂 打开
|
||||||
|
</button>
|
||||||
|
<button className="text-[#0056B3] font-semibold hover:bg-blue-50 px-3 py-1 rounded transition">
|
||||||
|
💾 保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-lg font-bold text-[#0056B3]">AI WorkFlow Engine</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button className="text-[#0056B3] font-semibold hover:bg-blue-50 px-3 py-1 rounded transition">
|
||||||
|
⚙️ 设置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* --- 主体区域:三栏布局 --- */}
|
||||||
|
<main className="flex-1 flex overflow-hidden">
|
||||||
|
|
||||||
|
{/* --- 左侧栏 (1份) --- */}
|
||||||
|
<aside className="w-1/4 bg-white border-r border-[#D1D9E6] overflow-y-auto shrink-0 custom-scrollbar">
|
||||||
|
{left || <SidebarPlaceholder />}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* --- 中间栏 (3份) --- */}
|
||||||
|
{/* 注意:这里我们假设 middle prop 包含了 ChatWindow 和 InputBox */}
|
||||||
|
{/* 如果 middle 为空,我们显示占位符 */}
|
||||||
|
<section className="flex-1 flex flex-col border-r border-[#D1D9E6] bg-white relative">
|
||||||
|
{middle ? (
|
||||||
|
middle
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 bg-[#F0F4F8] custom-scrollbar scroll-smooth">
|
||||||
|
<ChatPlaceholder />
|
||||||
|
</div>
|
||||||
|
<div className="flex-none h-32 border-t border-[#D1D9E6] bg-[#F0F4F8] p-4 shrink-0">
|
||||||
|
<InputPlaceholder />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --- 右侧栏 (1份) --- */}
|
||||||
|
<aside className="w-1/4 bg-white overflow-y-auto shrink-0 custom-scrollbar">
|
||||||
|
{right || <RightPanelPlaceholder />}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* --- 全局滚动条样式 --- */}
|
||||||
|
<style jsx global>{`
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
175
frontend-react/src/components/RightPanel.tsx
Normal file
175
frontend-react/src/components/RightPanel.tsx
Normal file
@@ -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<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
|
<ImageIcon size={16} />
|
||||||
|
<h3 className="text-sm font-bold text-gray-700">🖼️ 本地图库</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar pr-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-xs text-gray-400 py-4">加载中...</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<div className="text-center text-xs text-gray-400 py-4">文件夹为空</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{images.map((img, idx) => (
|
||||||
|
<div key={idx} className="bg-white p-1 border border-gray-200 rounded shadow-sm">
|
||||||
|
{/* 图片占位符 */}
|
||||||
|
<div className="aspect-square bg-gray-100 rounded mb-1 flex items-center justify-center text-gray-400 text-xs">
|
||||||
|
{img}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-center text-gray-500 truncate">{img}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 子组件:骰子检定 ---
|
||||||
|
const DiceTool: React.FC = () => {
|
||||||
|
const [result, setResult] = useState<number | null>(null);
|
||||||
|
const [rollType, setRollType] = useState<'difficulty' | 'opposed'>('difficulty');
|
||||||
|
const [difficulty, setDifficulty] = useState<number>(50); // 默认普通难度
|
||||||
|
|
||||||
|
const handleRoll = () => {
|
||||||
|
// 生成 1-100 随机数
|
||||||
|
const res = Math.floor(Math.random() * 100) + 1;
|
||||||
|
setResult(res);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full pt-4 border-t border-gray-100">
|
||||||
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
|
<Dice1 size={16} />
|
||||||
|
<h3 className="text-sm font-bold text-gray-700">🎲 检定工具</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 切换 */}
|
||||||
|
<div className="flex border-b border-gray-200 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setRollType('difficulty')}
|
||||||
|
className={`flex-1 py-1 text-xs font-medium transition-colors ${
|
||||||
|
rollType === 'difficulty'
|
||||||
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
难度检定
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRollType('opposed')}
|
||||||
|
className={`flex-1 py-1 text-xs font-medium transition-colors ${
|
||||||
|
rollType === 'opposed'
|
||||||
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
对抗骰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 难度选择 (仅在难度检定模式下显示) */}
|
||||||
|
{rollType === 'difficulty' && (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
<label className="text-xs text-gray-500">选择难度</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ label: '极难', val: 95 },
|
||||||
|
{ label: '困难', val: 75 },
|
||||||
|
{ label: '普通', val: 50 }
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.val}
|
||||||
|
onClick={() => setDifficulty(opt.val)}
|
||||||
|
className={`text-xs py-1 px-2 rounded border transition-colors ${
|
||||||
|
difficulty === opt.val
|
||||||
|
? 'bg-blue-50 border-blue-500 text-blue-700'
|
||||||
|
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label} ({opt.val})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 投掷区域 */}
|
||||||
|
<div className="flex items-center justify-between bg-white p-3 rounded border border-gray-200 shadow-sm">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs text-gray-400 mb-1">
|
||||||
|
{rollType === 'difficulty' ? `目标: ${difficulty}` : '对抗检定'}
|
||||||
|
</div>
|
||||||
|
{result !== null && (
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
rollType === 'difficulty' && result > difficulty
|
||||||
|
? 'text-red-500' // 失败
|
||||||
|
: 'text-green-500' // 成功
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRoll}
|
||||||
|
className="ml-4 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 transition shadow-sm active:transform active:scale-95"
|
||||||
|
>
|
||||||
|
投掷
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 主组件:右侧面板 ---
|
||||||
|
const RightPanel: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full p-4 overflow-y-auto custom-scrollbar">
|
||||||
|
{/* 上半部分:图库 */}
|
||||||
|
<div className="flex-1 min-h-0 mb-4">
|
||||||
|
<LocalGallery />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下半部分:骰子 */}
|
||||||
|
<div className="flex-none h-auto">
|
||||||
|
<DiceTool />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RightPanel;
|
||||||
221
frontend-react/src/components/Sidebar.tsx
Normal file
221
frontend-react/src/components/Sidebar.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col h-full p-4 space-y-6 overflow-y-auto custom-scrollbar">
|
||||||
|
|
||||||
|
{/* --- 标题:全局预设 --- */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-gray-700 mb-3 flex items-center">
|
||||||
|
📜 全局预设
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select className="flex-1 p-2 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-500">
|
||||||
|
<option>Default</option>
|
||||||
|
<option>A.U.T.O. v1.47</option>
|
||||||
|
<option>Roleplay Pro</option>
|
||||||
|
</select>
|
||||||
|
<button className="px-3 py-2 bg-white border border-gray-300 rounded hover:bg-gray-50 text-sm">
|
||||||
|
📥
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- 标题:生成参数 --- */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-gray-700">⚙️ 生成参数</h3>
|
||||||
|
|
||||||
|
{/* 参数网格布局 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* 温度 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<label>温度</label>
|
||||||
|
<span>{genParams.temperature.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
value={genParams.temperature}
|
||||||
|
onChange={(e) => handleParamChange('temperature', parseFloat(e.target.value))}
|
||||||
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top P */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<label>Top P</label>
|
||||||
|
<span>{genParams.top_p.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={genParams.top_p}
|
||||||
|
onChange={(e) => handleParamChange('top_p', parseFloat(e.target.value))}
|
||||||
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 频率惩罚 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<label>频率惩罚</label>
|
||||||
|
<span>{genParams.frequency_penalty.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
value={genParams.frequency_penalty}
|
||||||
|
onChange={(e) => handleParamChange('frequency_penalty', parseFloat(e.target.value))}
|
||||||
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 存在惩罚 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<label>存在惩罚</label>
|
||||||
|
<span>{genParams.presence_penalty.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
value={genParams.presence_penalty}
|
||||||
|
onChange={(e) => handleParamChange('presence_penalty', parseFloat(e.target.value))}
|
||||||
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上下文长度 */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<label className="text-xs text-gray-500">上下文长度</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={genParams.context_length}
|
||||||
|
onChange={(e) => handleParamChange('context_length', parseInt(e.target.value))}
|
||||||
|
className="w-full mt-1 p-1 text-sm border border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最大回复 */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
<label className="text-xs text-gray-500">最大回复</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={genParams.max_tokens}
|
||||||
|
onChange={(e) => handleParamChange('max_tokens', parseInt(e.target.value))}
|
||||||
|
className="w-full mt-1 p-1 text-sm border border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 流式传输开关 */}
|
||||||
|
<div className="flex items-center space-x-2 pt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="stream-check"
|
||||||
|
checked={genParams.stream}
|
||||||
|
onChange={(e) => handleParamChange('stream', e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="stream-check" className="text-sm text-gray-700 select-none">
|
||||||
|
✅ 流式传输
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- 标题:内容拼接块 --- */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
<h3 className="text-sm font-bold text-gray-700 mb-2">🧩 内容拼接块</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">控制发送至后端的上下文组成</p>
|
||||||
|
|
||||||
|
{/* 拼接块列表 */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||||
|
{spliceBlocks.map((block) => (
|
||||||
|
<div
|
||||||
|
key={block.id}
|
||||||
|
className={`flex items-center justify-between p-2 rounded border transition-colors ${
|
||||||
|
block.active
|
||||||
|
? 'bg-white border-[#D1D9E6] shadow-sm'
|
||||||
|
: 'bg-gray-50 border-gray-200 opacity-70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 左侧:图标和名称 */}
|
||||||
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
|
<span className="text-lg shrink-0">
|
||||||
|
{block.type === 'world' ? '🌍' : block.type === 'history' ? '💬' : '📄'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm truncate ${
|
||||||
|
block.active ? 'text-gray-900 font-medium' : 'text-gray-500 line-through'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{block.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:操作按钮 */}
|
||||||
|
<div className="flex items-center space-x-2 shrink-0">
|
||||||
|
{/* 编辑按钮 (如果不可编辑则置灰) */}
|
||||||
|
<button
|
||||||
|
className={`text-xs p-1 rounded ${
|
||||||
|
block.editable === false ? 'text-gray-300 cursor-not-allowed' : 'text-gray-500 hover:text-blue-600'
|
||||||
|
}`}
|
||||||
|
disabled={block.editable === false}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 激活开关 */}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={block.active}
|
||||||
|
onChange={() => toggleSpliceBlock(block.id)}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 添加按钮 */}
|
||||||
|
<button className="mt-3 w-full py-2 text-sm text-blue-600 border border-blue-200 rounded hover:bg-blue-50 transition">
|
||||||
|
+ 添加拼接块
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
20
frontend-react/src/index.css
Normal file
20
frontend-react/src/index.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
11
frontend-react/src/main.tsx
Normal file
11
frontend-react/src/main.tsx
Normal file
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
128
frontend-react/src/store/useChatStore.ts
Normal file
128
frontend-react/src/store/useChatStore.ts
Normal file
@@ -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<ChatState>((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<GenerationParams>) =>
|
||||||
|
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 }),
|
||||||
|
}));
|
||||||
72
frontend-react/src/types/index.ts
Normal file
72
frontend-react/src/types/index.ts
Normal file
@@ -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<string, Message>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
13
frontend-react/tailwind.config.js
Normal file
13
frontend-react/tailwind.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
// --- 关键点在这里 ---
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}", // 确保这行存在且路径正确
|
||||||
|
],
|
||||||
|
// -------------------
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
31
frontend-react/vite.config.ts
Normal file
31
frontend-react/vite.config.ts
Normal file
@@ -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/, '')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,18 +1,39 @@
|
|||||||
# 使用 Python 3.11 基础镜像
|
# 1. 基础镜像变更
|
||||||
FROM python:3.11-slim
|
# 原本是 Python 3.11,现在改为 Node.js 18 (Alpine 版本体积更小)
|
||||||
|
# 分为两个阶段:builder (构建阶段) 和 production (运行阶段)
|
||||||
|
FROM node:18-alpine as builder
|
||||||
|
|
||||||
# 设置工作目录
|
# 2. 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制依赖文件并安装
|
# 3. 依赖安装变更
|
||||||
COPY requirements.txt .
|
# 原本是复制 requirements.txt 并用 pip 安装 Python 库
|
||||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
# 现在是复制 package.json 并用 npm 安装 Node.js 库
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
# 复制所有代码
|
# 4. 复制源代码
|
||||||
|
# 保持不变,复制当前目录所有文件到容器
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 暴露端口
|
# 5. 构建步骤
|
||||||
EXPOSE 8501
|
# 原本没有这一步,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"]
|
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;"]
|
||||||
|
|||||||
Reference in New Issue
Block a user