diff --git a/backend/services/chat.py b/backend/services/chat.py index 858e5ba..50fa1ef 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -56,13 +56,14 @@ class ChatService: all_steps = [] # Collect all ordered steps for DB storage (thinking/text/tool_call/tool_result) step_index = 0 # Track global step index for ordering total_completion_tokens = 0 # Accumulated across all iterations - total_prompt_tokens = 0 # Accumulated across all iterations + prompt_tokens = 0 # Not accumulated — last iteration's value is sufficient + # (each iteration re-sends the full context, so earlier + # prompts are strict subsets of the final one) for iteration in range(self.MAX_ITERATIONS): full_content = "" full_thinking = "" token_count = 0 - prompt_tokens = 0 msg_id = str(uuid.uuid4()) tool_calls_list = [] @@ -114,6 +115,7 @@ class ChatService: reasoning = delta.get("reasoning_content", "") if reasoning: full_thinking += reasoning + yield f"event: thinking\ndata: {json.dumps({'content': reasoning}, ensure_ascii=False)}\n\n" # Accumulate text content for this iteration text = delta.get("content", "") @@ -128,37 +130,33 @@ class ChatService: yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n" return - # --- Tool calls exist: emit finalized steps, execute tools, continue loop --- + # --- Finalize thinking/text steps for this iteration (common to both paths) --- + if full_thinking: + step_data = { + 'id': f'step-{step_index}', + 'index': step_index, + 'type': 'thinking', + 'content': full_thinking, + } + all_steps.append(step_data) + yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" + step_index += 1 + + if full_content: + step_data = { + 'id': f'step-{step_index}', + 'index': step_index, + 'type': 'text', + 'content': full_content, + } + all_steps.append(step_data) + yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" + step_index += 1 + + # --- Branch: tool calls vs final --- if tool_calls_list: all_tool_calls.extend(tool_calls_list) - # Record thinking as a finalized step (preserves order) - if full_thinking: - step_data = { - 'id': f'step-{step_index}', - 'index': step_index, - 'type': 'thinking', - 'content': full_thinking, - } - all_steps.append(step_data) - yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" - step_index += 1 - - # Record text as a finalized step (text that preceded tool calls) - if full_content: - step_data = { - 'id': f'step-{step_index}', - 'index': step_index, - 'type': 'text', - 'content': full_content, - } - all_steps.append(step_data) - yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" - step_index += 1 - - # Legacy tool_calls event for backward compatibility - yield f"event: tool_calls\ndata: {json.dumps({'calls': tool_calls_list}, ensure_ascii=False)}\n\n" - # Execute each tool call, emit tool_call + tool_result as paired steps tool_results = [] for tc in tool_calls_list: @@ -200,9 +198,6 @@ class ChatService: yield f"event: process_step\ndata: {json.dumps(result_step, ensure_ascii=False)}\n\n" step_index += 1 - # Legacy tool_result event for backward compatibility - yield f"event: tool_result\ndata: {json.dumps({'id': tr['tool_call_id'], 'name': tr['name'], 'content': tr['content'], 'skipped': skipped}, ensure_ascii=False)}\n\n" - # Append assistant message + tool results for the next iteration messages.append({ "role": "assistant", @@ -211,35 +206,12 @@ class ChatService: }) messages.extend(tool_results) all_tool_results.extend(tool_results) - total_prompt_tokens += prompt_tokens total_completion_tokens += token_count continue - # --- No tool calls: final iteration — emit remaining steps and save --- - if full_thinking: - step_data = { - 'id': f'step-{step_index}', - 'index': step_index, - 'type': 'thinking', - 'content': full_thinking, - } - all_steps.append(step_data) - yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" - step_index += 1 - - if full_content: - step_data = { - 'id': f'step-{step_index}', - 'index': step_index, - 'type': 'text', - 'content': full_content, - } - all_steps.append(step_data) - yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n" - step_index += 1 - + # --- No tool calls: final iteration — save message to DB --- suggested_title = None - total_prompt_tokens += prompt_tokens + # prompt_tokens already holds the last iteration's value (set during streaming) total_completion_tokens += token_count with app.app_context(): # Build content JSON with ordered steps array for DB storage. @@ -268,7 +240,7 @@ class ChatService: # Record token usage (get user_id from conv, not g — # app.app_context() creates a new context where g.current_user is lost) if conv: - record_token_usage(conv.user_id, conv_model, total_prompt_tokens, total_completion_tokens) + record_token_usage(conv.user_id, conv_model, prompt_tokens, total_completion_tokens) if conv and (not conv.title or conv.title == "新对话"): user_msg = Message.query.filter_by( diff --git a/docs/Design.md b/docs/Design.md index bf9b419..b2dc5b6 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -162,16 +162,22 @@ classDiagram `content` 字段统一使用 JSON 格式存储: **User 消息:** + ```json { "text": "用户输入的文本内容", "attachments": [ - {"name": "utils.py", "extension": "py", "content": "def hello()..."} + { +    "name": "utils.py", +    "extension": "py", +    "content": "def hello()..." +    } ] } ``` **Assistant 消息:** + ```json { "text": "AI 回复的文本内容", @@ -360,23 +366,24 @@ def copy_folder_to_project(source_path: str, project_dir: Path, project_name: st ```python def validate_path_in_project(path: str, project_dir: Path) -> Path: p = Path(path) - + # 相对路径转换为绝对路径 if not p.is_absolute(): p = project_dir / p - + p = p.resolve() - + # 安全检查:确保路径在项目目录内 try: p.relative_to(project_dir.resolve()) except ValueError: raise ValueError(f"Path '{path}' is outside project directory") - + return p ``` 即使传入恶意路径,后端也会拒绝: + ```python "../../../etc/passwd" # 尝试跳出项目目录 -> ValueError "/etc/passwd" # 绝对路径攻击 -> ValueError @@ -393,11 +400,11 @@ def process_tool_calls(self, tool_calls, context=None): for call in tool_calls: name = call["function"]["name"] args = json.loads(call["function"]["arguments"]) - + # 自动注入 project_id if context and name.startswith("file_") and "project_id" in context: args["project_id"] = context["project_id"] - + result = self.registry.execute(name, args) ``` @@ -407,70 +414,89 @@ def process_tool_calls(self, tool_calls, context=None): ### 认证 -| 方法 | 路径 | 说明 | -|------|------|------| -| `GET` | `/api/auth/mode` | 获取当前认证模式(公开端点) | -| `POST` | `/api/auth/login` | 用户登录,返回 JWT token | -| `POST` | `/api/auth/register` | 用户注册(仅多用户模式可用) | -| `GET` | `/api/auth/profile` | 获取当前用户信息 | -| `PATCH` | `/api/auth/profile` | 更新当前用户信息 | +| 方法 | 路径 | 说明 | +| ------- | -------------------- | ----------------- | +| `GET` | `/api/auth/mode` | 获取当前认证模式(公开端点) | +| `POST` | `/api/auth/login` | 用户登录,返回 JWT token | +| `POST` | `/api/auth/register` | 用户注册(仅多用户模式可用) | +| `GET` | `/api/auth/profile` | 获取当前用户信息 | +| `PATCH` | `/api/auth/profile` | 更新当前用户信息 | ### 会话管理 -| 方法 | 路径 | 说明 | -|------|------|------| -| `POST` | `/api/conversations` | 创建会话(可选 `project_id` 绑定项目) | -| `GET` | `/api/conversations` | 获取会话列表(可选 `project_id` 筛选,游标分页) | -| `GET` | `/api/conversations/:id` | 获取会话详情 | -| `PATCH` | `/api/conversations/:id` | 更新会话(支持修改 `project_id`) | -| `DELETE` | `/api/conversations/:id` | 删除会话 | +| 方法 | 路径 | 说明 | +| -------- | ------------------------ | ------------------------------- | +| `POST` | `/api/conversations` | 创建会话(可选 `project_id` 绑定项目) | +| `GET` | `/api/conversations` | 获取会话列表(可选 `project_id` 筛选,游标分页) | +| `GET` | `/api/conversations/:id` | 获取会话详情 | +| `PATCH` | `/api/conversations/:id` | 更新会话(支持修改 `project_id`) | +| `DELETE` | `/api/conversations/:id` | 删除会话 | ### 消息管理 -| 方法 | 路径 | 说明 | -|------|------|------| -| `GET` | `/api/conversations/:id/messages` | 获取消息列表(游标分页) | -| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) | -| `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 | -| `POST` | `/api/conversations/:id/regenerate/:mid` | 重新生成消息 | +| 方法 | 路径 | 说明 | +| -------- | ---------------------------------------- | ------------ | +| `GET` | `/api/conversations/:id/messages` | 获取消息列表(游标分页) | +| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) | +| `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 | +| `POST` | `/api/conversations/:id/regenerate/:mid` | 重新生成消息 | ### 项目管理 -| 方法 | 路径 | 说明 | -|------|------|------| -| `GET` | `/api/projects` | 获取项目列表 | -| `POST` | `/api/projects` | 创建项目 | -| `GET` | `/api/projects/:id` | 获取项目详情 | -| `PUT` | `/api/projects/:id` | 更新项目 | -| `DELETE` | `/api/projects/:id` | 删除项目 | -| `POST` | `/api/projects/upload` | 上传文件夹作为项目 | -| `GET` | `/api/projects/:id/files` | 列出项目文件(支持 `?path=subdir` 子目录) | -| `GET` | `/api/projects/:id/files/:filepath` | 读取文件内容(文本文件,最大 5 MB) | -| `PUT` | `/api/projects/:id/files/:filepath` | 创建或覆盖文件(Body: `{"content": "..."}`) | -| `DELETE` | `/api/projects/:id/files/:filepath` | 删除文件或目录 | -| `POST` | `/api/projects/:id/files/mkdir` | 创建目录(Body: `{"path": "src/utils"}`) | -| `POST` | `/api/projects/:id/search` | 搜索文件内容(Body: `{"query": "...", "path": "", "max_results": 50, "case_sensitive": false}`) | +| 方法 | 路径 | 说明 | +| -------- | ----------------------------------- | ---------------------------------------------------------------------------------------- | +| `GET` | `/api/projects` | 获取项目列表 | +| `POST` | `/api/projects` | 创建项目 | +| `GET` | `/api/projects/:id` | 获取项目详情 | +| `PUT` | `/api/projects/:id` | 更新项目 | +| `DELETE` | `/api/projects/:id` | 删除项目 | +| `POST` | `/api/projects/upload` | 上传文件夹作为项目 | +| `GET` | `/api/projects/:id/files` | 列出项目文件(支持 `?path=subdir` 子目录) | +| `GET` | `/api/projects/:id/files/:filepath` | 读取文件内容(文本文件,最大 5 MB) | +| `PUT` | `/api/projects/:id/files/:filepath` | 创建或覆盖文件(Body: `{"content": "..."}`) | +| `DELETE` | `/api/projects/:id/files/:filepath` | 删除文件或目录 | +| `POST` | `/api/projects/:id/files/mkdir` | 创建目录(Body: `{"path": "src/utils"}`) | +| `POST` | `/api/projects/:id/search` | 搜索文件内容(Body: `{"query": "...", "path": "", "max_results": 50, "case_sensitive": false}`) | ### 其他 -| 方法 | 路径 | 说明 | -|------|------|------| -| `GET` | `/api/models` | 获取模型列表 | -| `GET` | `/api/tools` | 获取工具列表 | +| 方法 | 路径 | 说明 | +| ----- | ------------------- | ---------- | +| `GET` | `/api/models` | 获取模型列表 | +| `GET` | `/api/tools` | 获取工具列表 | | `GET` | `/api/stats/tokens` | Token 使用统计 | --- ## SSE 事件 -| 事件 | 说明 | -|------|------| -| `message` | 回复内容的增量片段 | -| `tool_calls` | 工具调用信息 | -| `tool_result` | 工具执行结果 | +| 事件 | 说明 | +| -------------- | ------------------------------------------------------------------------- | +| `thinking` | 思考过程的增量片段(实时流式输出) | +| `message` | 回复内容的增量片段(实时流式输出) | | `process_step` | 有序处理步骤(thinking/text/tool_call/tool_result),支持穿插显示。携带 `id`、`index` 确保渲染顺序 | -| `error` | 错误信息 | -| `done` | 回复结束,携带 message_id 和 token_count | +| `error` | 错误信息 | +| `done` | 回复结束,携带 message_id、token_count 和 suggested_title | + +> **注意**:`thinking` 和 `message` 事件提供实时流式体验,每条 chunk 立即推送到前端。`process_step` 事件在每次迭代结束后发送完整内容,用于确定渲染顺序和 DB 存储。 + +### thinking / message 事件格式 + +实时流式事件,每条携带一个增量片段: + +```json +// 思考增量片段 +{"content": "正在分析用户需求..."} + +// 文本增量片段 +{"content": "根据分析结果"} +``` + +字段说明: + +| 字段 | 说明 | +| --------- | ---------------------------- | +| `content` | 增量文本片段(前端累积拼接为完整内容) | ### process_step 事件格式 @@ -480,6 +506,7 @@ def process_tool_calls(self, tool_calls, context=None): // 思考过程 {"id": "step-0", "index": 0, "type": "thinking", "content": "完整思考内容..."} + // 回复文本(可穿插在任意步骤之间) {"id": "step-1", "index": 1, "type": "text", "content": "回复文本内容..."} @@ -492,16 +519,16 @@ def process_tool_calls(self, tool_calls, context=None): 字段说明: -| 字段 | 说明 | -|------|------| -| `id` | 步骤唯一标识(格式 `step-{index}`),用于前端 key | -| `index` | 步骤序号,确保按正确顺序显示 | -| `type` | 步骤类型:`thinking` / `text` / `tool_call` / `tool_result` | -| `id_ref` | 工具调用引用 ID(仅 tool_call/tool_result),用于匹配调用与结果 | -| `name` | 工具名称(仅 tool_call/tool_result) | -| `arguments` | 工具调用参数 JSON 字符串(仅 tool_call) | -| `content` | 内容(thinking 的思考内容、text 的文本、tool_result 的返回结果) | -| `skipped` | 工具是否被跳过(仅 tool_result) | +| 字段 | 说明 | +| ----------- | ------------------------------------------------------ | +| `id` | 步骤唯一标识(格式 `step-{index}`),用于前端 key | +| `index` | 步骤序号,确保按正确顺序显示 | +| `type` | 步骤类型:`thinking` / `text` / `tool_call` / `tool_result` | +| `id_ref` | 工具调用引用 ID(仅 tool_call/tool_result),用于匹配调用与结果 | +| `name` | 工具名称(仅 tool_call/tool_result) | +| `arguments` | 工具调用参数 JSON 字符串(仅 tool_call) | +| `content` | 内容(thinking 的思考内容、text 的文本、tool_result 的返回结果) | +| `skipped` | 工具是否被跳过(仅 tool_result) | ### 多轮迭代中的步骤顺序 @@ -516,79 +543,91 @@ def process_tool_calls(self, tool_calls, context=None): 所有步骤通过全局递增的 `index` 保证顺序。后端在完成所有迭代后,将这些步骤存入 `content_json["steps"]` 数组写入数据库。前端页面刷新时从 API 加载消息,`message_to_dict` 提取 `steps` 字段映射为 `process_steps` 返回,ProcessBlock 组件按 `index` 顺序渲染。 +### done 事件格式 + +```json +{"message_id": "msg-uuid", "token_count": 1234, "suggested_title": "分析数据"} +``` + +| 字段 | 说明 | +| ---------------- | ------------------------------------- | +| `message_id` | 消息 UUID(已入库) | +| `token_count` | 总输出 token 数(跨所有迭代累积) | +| `suggested_title` | 建议会话标题(从首条用户消息提取,无标题时为 `"新对话"`,已有标题时为 `null`) | + --- ## 数据模型 ### User(用户) -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `id` | Integer | - | 自增主键 | -| `username` | String(50) | - | 用户名(唯一) | -| `password_hash` | String(255) | null | 密码哈希(可为空,支持 API-key-only 认证) | -| `email` | String(120) | null | 邮箱(唯一) | -| `avatar` | String(512) | null | 头像 URL | -| `role` | String(20) | "user" | 角色:`user` / `admin` | -| `is_active` | Boolean | true | 是否激活 | -| `created_at` | DateTime | now | 创建时间 | -| `last_login_at` | DateTime | null | 最后登录时间 | +| 字段 | 类型 | 默认值 | 说明 | +| --------------- | ----------- | ------ | ---------------------------- | +| `id` | Integer | - | 自增主键 | +| `username` | String(50) | - | 用户名(唯一) | +| `password_hash` | String(255) | null | 密码哈希(可为空,支持 API-key-only 认证) | +| `email` | String(120) | null | 邮箱(唯一) | +| `avatar` | String(512) | null | 头像 URL | +| `role` | String(20) | "user" | 角色:`user` / `admin` | +| `is_active` | Boolean | true | 是否激活 | +| `created_at` | DateTime | now | 创建时间 | +| `last_login_at` | DateTime | null | 最后登录时间 | `password` 通过 property setter 自动调用 `werkzeug` 的 `generate_password_hash` 存储,通过 `check_password()` 方法验证。 ### Project(项目) -| 字段 | 类型 | 说明 | -|------|------|------| -| `id` | String(64) | UUID 主键 | -| `user_id` | Integer | 外键关联 User | -| `name` | String(255) | 项目名称(用户内唯一) | -| `path` | String(512) | 相对路径(如 user_1/my_project) | -| `description` | Text | 项目描述 | -| `created_at` | DateTime | 创建时间 | -| `updated_at` | DateTime | 更新时间 | +| 字段 | 类型 | 说明 | +| ------------- | ----------- | ------------------------- | +| `id` | String(64) | UUID 主键 | +| `user_id` | Integer | 外键关联 User | +| `name` | String(255) | 项目名称(用户内唯一) | +| `path` | String(512) | 相对路径(如 user_1/my_project) | +| `description` | Text | 项目描述 | +| `created_at` | DateTime | 创建时间 | +| `updated_at` | DateTime | 更新时间 | ### Conversation(会话) -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `id` | String(64) | UUID | 主键 | -| `user_id` | Integer | - | 外键关联 User | -| `project_id` | String(64) | null | 外键关联 Project(可选) | -| `title` | String(255) | "" | 会话标题 | -| `model` | String(64) | "glm-5" | 模型名称 | -| `system_prompt` | Text | "" | 系统提示词 | -| `temperature` | Float | 1.0 | 采样温度 | -| `max_tokens` | Integer | 65536 | 最大输出 token | -| `thinking_enabled` | Boolean | False | 是否启用思维链 | -| `created_at` | DateTime | now | 创建时间 | -| `updated_at` | DateTime | now | 更新时间 | +| 字段 | 类型 | 默认值 | 说明 | +| ------------------ | ----------- | ------- | ---------------- | +| `id` | String(64) | UUID | 主键 | +| `user_id` | Integer | - | 外键关联 User | +| `project_id` | String(64) | null | 外键关联 Project(可选) | +| `title` | String(255) | "" | 会话标题 | +| `model` | String(64) | "glm-5" | 模型名称 | +| `system_prompt` | Text | "" | 系统提示词 | +| `temperature` | Float | 1.0 | 采样温度 | +| `max_tokens` | Integer | 65536 | 最大输出 token | +| `thinking_enabled` | Boolean | False | 是否启用思维链 | +| `created_at` | DateTime | now | 创建时间 | +| `updated_at` | DateTime | now | 更新时间 | ### Message(消息) -| 字段 | 类型 | 说明 | -|------|------|------| -| `id` | String(64) | UUID 主键 | -| `conversation_id` | String(64) | 外键关联 Conversation | -| `role` | String(16) | user/assistant/system/tool | -| `content` | LongText | JSON 格式内容(见上方结构说明),assistant 消息包含 `steps` 有序步骤数组 | -| `token_count` | Integer | Token 数量 | -| `created_at` | DateTime | 创建时间 | +| 字段 | 类型 | 说明 | +| ----------------- | ---------- | ------------------------------------------------ | +| `id` | String(64) | UUID 主键 | +| `conversation_id` | String(64) | 外键关联 Conversation | +| `role` | String(16) | user/assistant/system/tool | +| `content` | LongText | JSON 格式内容(见上方结构说明),assistant 消息包含 `steps` 有序步骤数组 | +| `token_count` | Integer | Token 数量 | +| `created_at` | DateTime | 创建时间 | `message_to_dict()` 辅助函数负责解析 `content` JSON,并提取 `steps` 字段映射为 `process_steps` 返回给前端,确保页面刷新后仍能按正确顺序渲染穿插的思考、文本和工具调用。 ### TokenUsage(Token 使用统计) -| 字段 | 类型 | 说明 | -|------|------|------| -| `id` | Integer | 自增主键 | -| `user_id` | Integer | 外键关联 User | -| `date` | Date | 统计日期 | -| `model` | String(64) | 模型名称 | -| `prompt_tokens` | Integer | 输入 token | -| `completion_tokens` | Integer | 输出 token | -| `total_tokens` | Integer | 总 token | -| `created_at` | DateTime | 创建时间 | +| 字段 | 类型 | 说明 | +| ------------------- | ---------- | --------- | +| `id` | Integer | 自增主键 | +| `user_id` | Integer | 外键关联 User | +| `date` | Date | 统计日期 | +| `model` | String(64) | 模型名称 | +| `prompt_tokens` | Integer | 输入 token | +| `completion_tokens` | Integer | 输出 token | +| `total_tokens` | Integer | 总 token | +| `created_at` | DateTime | 创建时间 | --- @@ -601,6 +640,7 @@ GET /api/conversations?limit=20&cursor=conv_abc123 ``` 响应: + ```json { "code": 0, @@ -651,16 +691,17 @@ GET /api/conversations?limit=20&cursor=conv_abc123 ### 公开端点(无需认证) -| 端点 | 说明 | -|------|------| -| `POST /api/auth/login` | 登录 | -| `POST /api/auth/register` | 注册 | -| `GET /api/models` | 模型列表 | -| `GET /api/tools` | 工具列表 | +| 端点 | 说明 | +| ------------------------- | ---- | +| `POST /api/auth/login` | 登录 | +| `POST /api/auth/register` | 注册 | +| `GET /api/models` | 模型列表 | +| `GET /api/tools` | 工具列表 | ### 前端适配 前端 API 层(`frontend/src/api/index.js`)已预留 token 管理: + - `getToken()` / `setToken(token)` / `clearToken()` - 所有请求自动附带 `Authorization: Bearer `(token 为空时不发送) - 收到 401 时自动清除 token @@ -669,17 +710,18 @@ GET /api/conversations?limit=20&cursor=conv_abc123 --- -| Code | 说明 | -|------|------| -| `0` | 成功 | -| `400` | 请求参数错误 | +| Code | 说明 | +| ----- | ---------------------- | +| `0` | 成功 | +| `400` | 请求参数错误 | | `401` | 未认证(多用户模式下缺少或无效 token) | -| `403` | 禁止访问(账户禁用、单用户模式下注册等) | -| `404` | 资源不存在 | -| `409` | 资源冲突(用户名/邮箱已存在) | -| `500` | 服务器错误 | +| `403` | 禁止访问(账户禁用、单用户模式下注册等) | +| `404` | 资源不存在 | +| `409` | 资源冲突(用户名/邮箱已存在) | +| `500` | 服务器错误 | 错误响应: + ```json { "code": 404, @@ -694,6 +736,7 @@ GET /api/conversations?limit=20&cursor=conv_abc123 ### 设计目标 将项目(Project)和对话(Conversation)建立**持久绑定关系**,实现: + 1. 创建对话时自动绑定当前选中的项目 2. 对话列表支持按项目筛选/分组 3. 工具执行自动使用对话所属项目的上下文,无需 AI 每次询问 `project_id` @@ -718,6 +761,7 @@ erDiagram ``` `Conversation.project_id` 是 nullable 的外键: + - `null` = 未绑定项目(通用对话,文件工具不可用) - 非 null = 绑定到特定项目(工具自动使用该项目的工作空间) @@ -793,6 +837,7 @@ GET /api/conversations # 返回所有对话(当前行为) #### 发送消息 `POST /api/conversations/:id/messages` `project_id` 优先级: + 1. 请求体中的 `project_id`(前端显式传递) 2. `conversation.project_id`(对话绑定的项目,自动回退) 3. `null`(无项目上下文,文件工具报错提示) @@ -849,6 +894,7 @@ if name.startswith("file_") and context and "project_id" in context: ``` **交互规则:** + 1. 顶部项目选择器决定**当前工作空间** 2. 选中项目后,对话列表**仅显示该项目的对话** 3. 创建新对话时**自动绑定**当前项目 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4d8da0a..12192c2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -44,6 +44,7 @@ :messages="messages" :streaming="streaming" :streaming-content="streamContent" + :streaming-thinking="streamThinkingContent" :streaming-process-steps="streamProcessSteps" :has-more-messages="hasMoreMessages" :loading-more="loadingMessages" @@ -140,7 +141,7 @@ const nextMsgCursor = ref(null) // processSteps are stored in the message object and later persisted to DB. const streaming = ref(false) const streamContent = ref('') // Accumulated text content during current iteration -const streamToolCalls = shallowRef([]) // All tool calls across iterations (legacy compat) +const streamThinkingContent = ref('') // Accumulated thinking content during current iteration const streamProcessSteps = shallowRef([]) // Ordered steps: thinking/text/tool_call/tool_result // 保存每个对话的流式状态 @@ -149,7 +150,7 @@ const streamStates = new Map() function setStreamState(isActive) { streaming.value = isActive streamContent.value = '' - streamToolCalls.value = [] + streamThinkingContent.value = '' streamProcessSteps.value = [] } @@ -251,7 +252,7 @@ async function selectConversation(id) { streamStates.set(currentConvId.value, { streaming: true, streamContent: streamContent.value, - streamToolCalls: [...streamToolCalls.value], + streamThinkingContent: streamThinkingContent.value, streamProcessSteps: [...streamProcessSteps.value], messages: [...messages.value], }) @@ -266,7 +267,7 @@ async function selectConversation(id) { if (savedState && savedState.streaming) { streaming.value = true streamContent.value = savedState.streamContent - streamToolCalls.value = savedState.streamToolCalls + streamThinkingContent.value = savedState.streamThinkingContent || '' streamProcessSteps.value = savedState.streamProcessSteps messages.value = savedState.messages || [] } else { @@ -309,19 +310,8 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) { onMessage(text) { updateStreamField(convId, 'streamContent', streamContent, prev => (prev || '') + text) }, - onToolCalls(calls) { - updateStreamField(convId, 'streamToolCalls', streamToolCalls, prev => [ - ...(prev || []), - ...calls.map(c => ({ ...c, result: null })), - ]) - }, - onToolResult(result) { - updateStreamField(convId, 'streamToolCalls', streamToolCalls, prev => { - const arr = prev ? [...prev] : [] - const call = arr.find(c => c.id === result.id) - if (call) call.result = result.content - return arr - }) + onThinking(text) { + updateStreamField(convId, 'streamThinkingContent', streamThinkingContent, prev => (prev || '') + text) }, onProcessStep(step) { // Insert step at its index position to preserve ordering. @@ -334,10 +324,12 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) { steps[step.index] = step return steps }) - // When text is finalized as a process_step, reset streaming content - // to prevent duplication (the text is now rendered via processSteps). + // When a step is finalized, reset the corresponding streaming content + // to prevent duplication (the content is now rendered via processSteps). if (step.type === 'text') { updateStreamField(convId, 'streamContent', streamContent, () => '') + } else if (step.type === 'thinking') { + updateStreamField(convId, 'streamThinkingContent', streamThinkingContent, () => '') } }, async onDone(data) { @@ -349,13 +341,34 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) { // Build the final message object. // process_steps is the primary ordered data for rendering (thinking/text/tool_call/tool_result). // When page reloads, these steps are loaded from DB via the 'steps' field in content JSON. + // NOTE: streamContent is already '' at this point (reset by process_step text event), + // so extract text from the last text step in processSteps. + const steps = streamProcessSteps.value.filter(Boolean) + const textSteps = steps.filter(s => s.type === 'text') + const lastText = textSteps.length > 0 ? textSteps[textSteps.length - 1].content : streamContent.value + + // Derive legacy tool_calls from processSteps (backward compat for DB and MessageBubble fallback) + const toolCallSteps = steps.filter(s => s && s.type === 'tool_call') + const toolResultMap = {} + for (const s of steps) { + if (s && s.type === 'tool_result') toolResultMap[s.id_ref] = s.content + } + const legacyToolCalls = toolCallSteps.length > 0 + ? toolCallSteps.map(tc => ({ + id: tc.id_ref || tc.id, + type: 'function', + function: { name: tc.name, arguments: tc.arguments }, + result: toolResultMap[tc.id_ref || tc.id] || null, + })) + : null + messages.value = [...messages.value, { id: data.message_id, conversation_id: convId, role: 'assistant', - text: streamContent.value, - tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null, - process_steps: streamProcessSteps.value.filter(Boolean), + text: lastText, + tool_calls: legacyToolCalls, + process_steps: steps, token_count: data.token_count, created_at: new Date().toISOString(), }] diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 309a801..0fd92a5 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -29,10 +29,10 @@ async function request(url, options = {}) { * Shared SSE stream processor - parses SSE events and dispatches to callbacks * @param {string} url - API URL (without BASE prefix) * @param {object} body - Request body - * @param {object} callbacks - Event handlers: { onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } + * @param {object} callbacks - Event handlers: { onMessage, onThinking, onProcessStep, onDone, onError } * @returns {{ abort: () => void }} */ -function createSSEStream(url, body, { onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError }) { +function createSSEStream(url, body, { onMessage, onThinking, onProcessStep, onDone, onError }) { const controller = new AbortController() const promise = (async () => { @@ -67,12 +67,10 @@ function createSSEStream(url, body, { onMessage, onToolCalls, onToolResult, onPr currentEvent = line.slice(7).trim() } else if (line.startsWith('data: ')) { const data = JSON.parse(line.slice(6)) - if (currentEvent === 'message' && onMessage) { + if (currentEvent === 'thinking' && onThinking) { + onThinking(data.content) + } else if (currentEvent === 'message' && onMessage) { onMessage(data.content) - } else if (currentEvent === 'tool_calls' && onToolCalls) { - onToolCalls(data.calls) - } else if (currentEvent === 'tool_result' && onToolResult) { - onToolResult(data) } else if (currentEvent === 'process_step' && onProcessStep) { onProcessStep(data) } else if (currentEvent === 'done' && onDone) { diff --git a/frontend/src/components/ChatView.vue b/frontend/src/components/ChatView.vue index 04c32c3..a59719f 100644 --- a/frontend/src/components/ChatView.vue +++ b/frontend/src/components/ChatView.vue @@ -49,6 +49,7 @@ @@ -87,6 +88,7 @@ const props = defineProps({ messages: { type: Array, required: true }, streaming: { type: Boolean, default: false }, streamingContent: { type: String, default: '' }, + streamingThinking: { type: String, default: '' }, streamingProcessSteps: { type: Array, default: () => [] }, hasMoreMessages: { type: Boolean, default: false }, loadingMore: { type: Boolean, default: false }, @@ -161,7 +163,7 @@ function scrollToMessage(msgId) { } // 流式时使用 instant 滚动,避免 smooth 动画与内容增长互相打架造成抖动 -watch([() => props.messages.length, () => props.streamingContent], () => { +watch([() => props.messages.length, () => props.streamingContent, () => props.streamingThinking], () => { nextTick(() => { const el = scrollContainer.value if (!el) return diff --git a/frontend/src/components/MessageBubble.vue b/frontend/src/components/MessageBubble.vue index b7647c8..ca7ba11 100644 --- a/frontend/src/components/MessageBubble.vue +++ b/frontend/src/components/MessageBubble.vue @@ -87,7 +87,15 @@ const renderedContent = computed(() => { useCodeEnhancement(messageRef, renderedContent) function copyContent() { - navigator.clipboard.writeText(props.text || '').catch(() => {}) + // Extract text from processSteps (preferred) or fall back to text prop + let text = props.text || '' + if (props.processSteps && props.processSteps.length > 0) { + const parts = props.processSteps + .filter(s => s && s.type === 'text') + .map(s => s.content) + if (parts.length > 0) text = parts.join('\n\n') + } + navigator.clipboard.writeText(text).catch(() => {}) } diff --git a/frontend/src/components/ProcessBlock.vue b/frontend/src/components/ProcessBlock.vue index d6ec76a..a9f3bbc 100644 --- a/frontend/src/components/ProcessBlock.vue +++ b/frontend/src/components/ProcessBlock.vue @@ -72,6 +72,7 @@ const props = defineProps({ toolCalls: { type: Array, default: () => [] }, processSteps: { type: Array, default: () => [] }, streamingContent: { type: String, default: '' }, + streamingThinking: { type: String, default: '' }, streaming: { type: Boolean, default: false } }) @@ -108,6 +109,17 @@ function getResultSummary(result) { const processItems = computed(() => { const items = [] + // Prepend live streaming thinking content as the first item (before finalized steps). + // This appears while thinking chunks are streaming and before the finalized thinking step arrives. + if (props.streaming && props.streamingThinking) { + items.push({ + type: 'thinking', + content: props.streamingThinking, + summary: truncate(props.streamingThinking), + key: 'thinking-streaming', + }) + } + // Build items from processSteps — finalized steps sent by backend or loaded from DB. // Steps are ordered: each iteration produces thinking → text → tool_call → tool_result. if (props.processSteps && props.processSteps.length > 0) {