fix: 流式输出bug 修复
This commit is contained in:
parent
767a8daf23
commit
f57e813f76
|
|
@ -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(
|
||||
|
|
|
|||
300
docs/Design.md
300
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>`(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. 创建新对话时**自动绑定**当前项目
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
<ProcessBlock
|
||||
:process-steps="streamingProcessSteps"
|
||||
:streaming-content="streamingContent"
|
||||
:streaming-thinking="streamingThinking"
|
||||
:streaming="streaming"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue