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)
|
all_steps = [] # Collect all ordered steps for DB storage (thinking/text/tool_call/tool_result)
|
||||||
step_index = 0 # Track global step index for ordering
|
step_index = 0 # Track global step index for ordering
|
||||||
total_completion_tokens = 0 # Accumulated across all iterations
|
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):
|
for iteration in range(self.MAX_ITERATIONS):
|
||||||
full_content = ""
|
full_content = ""
|
||||||
full_thinking = ""
|
full_thinking = ""
|
||||||
token_count = 0
|
token_count = 0
|
||||||
prompt_tokens = 0
|
|
||||||
msg_id = str(uuid.uuid4())
|
msg_id = str(uuid.uuid4())
|
||||||
tool_calls_list = []
|
tool_calls_list = []
|
||||||
|
|
||||||
|
|
@ -114,6 +115,7 @@ class ChatService:
|
||||||
reasoning = delta.get("reasoning_content", "")
|
reasoning = delta.get("reasoning_content", "")
|
||||||
if reasoning:
|
if reasoning:
|
||||||
full_thinking += reasoning
|
full_thinking += reasoning
|
||||||
|
yield f"event: thinking\ndata: {json.dumps({'content': reasoning}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
# Accumulate text content for this iteration
|
# Accumulate text content for this iteration
|
||||||
text = delta.get("content", "")
|
text = delta.get("content", "")
|
||||||
|
|
@ -128,11 +130,7 @@ class ChatService:
|
||||||
yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n"
|
yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- Tool calls exist: emit finalized steps, execute tools, continue loop ---
|
# --- Finalize thinking/text steps for this iteration (common to both paths) ---
|
||||||
if tool_calls_list:
|
|
||||||
all_tool_calls.extend(tool_calls_list)
|
|
||||||
|
|
||||||
# Record thinking as a finalized step (preserves order)
|
|
||||||
if full_thinking:
|
if full_thinking:
|
||||||
step_data = {
|
step_data = {
|
||||||
'id': f'step-{step_index}',
|
'id': f'step-{step_index}',
|
||||||
|
|
@ -144,7 +142,6 @@ class ChatService:
|
||||||
yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n"
|
yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n"
|
||||||
step_index += 1
|
step_index += 1
|
||||||
|
|
||||||
# Record text as a finalized step (text that preceded tool calls)
|
|
||||||
if full_content:
|
if full_content:
|
||||||
step_data = {
|
step_data = {
|
||||||
'id': f'step-{step_index}',
|
'id': f'step-{step_index}',
|
||||||
|
|
@ -156,8 +153,9 @@ class ChatService:
|
||||||
yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n"
|
yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n"
|
||||||
step_index += 1
|
step_index += 1
|
||||||
|
|
||||||
# Legacy tool_calls event for backward compatibility
|
# --- Branch: tool calls vs final ---
|
||||||
yield f"event: tool_calls\ndata: {json.dumps({'calls': tool_calls_list}, ensure_ascii=False)}\n\n"
|
if tool_calls_list:
|
||||||
|
all_tool_calls.extend(tool_calls_list)
|
||||||
|
|
||||||
# Execute each tool call, emit tool_call + tool_result as paired steps
|
# Execute each tool call, emit tool_call + tool_result as paired steps
|
||||||
tool_results = []
|
tool_results = []
|
||||||
|
|
@ -200,9 +198,6 @@ class ChatService:
|
||||||
yield f"event: process_step\ndata: {json.dumps(result_step, ensure_ascii=False)}\n\n"
|
yield f"event: process_step\ndata: {json.dumps(result_step, ensure_ascii=False)}\n\n"
|
||||||
step_index += 1
|
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
|
# Append assistant message + tool results for the next iteration
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
|
|
@ -211,35 +206,12 @@ class ChatService:
|
||||||
})
|
})
|
||||||
messages.extend(tool_results)
|
messages.extend(tool_results)
|
||||||
all_tool_results.extend(tool_results)
|
all_tool_results.extend(tool_results)
|
||||||
total_prompt_tokens += prompt_tokens
|
|
||||||
total_completion_tokens += token_count
|
total_completion_tokens += token_count
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --- No tool calls: final iteration — emit remaining steps and save ---
|
# --- No tool calls: final iteration — save message to DB ---
|
||||||
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
|
|
||||||
|
|
||||||
suggested_title = None
|
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
|
total_completion_tokens += token_count
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# Build content JSON with ordered steps array for DB storage.
|
# 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 —
|
# Record token usage (get user_id from conv, not g —
|
||||||
# app.app_context() creates a new context where g.current_user is lost)
|
# app.app_context() creates a new context where g.current_user is lost)
|
||||||
if conv:
|
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 == "新对话"):
|
if conv and (not conv.title or conv.title == "新对话"):
|
||||||
user_msg = Message.query.filter_by(
|
user_msg = Message.query.filter_by(
|
||||||
|
|
|
||||||
|
|
@ -162,16 +162,22 @@ classDiagram
|
||||||
`content` 字段统一使用 JSON 格式存储:
|
`content` 字段统一使用 JSON 格式存储:
|
||||||
|
|
||||||
**User 消息:**
|
**User 消息:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"text": "用户输入的文本内容",
|
"text": "用户输入的文本内容",
|
||||||
"attachments": [
|
"attachments": [
|
||||||
{"name": "utils.py", "extension": "py", "content": "def hello()..."}
|
{
|
||||||
|
"name": "utils.py",
|
||||||
|
"extension": "py",
|
||||||
|
"content": "def hello()..."
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Assistant 消息:**
|
**Assistant 消息:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"text": "AI 回复的文本内容",
|
"text": "AI 回复的文本内容",
|
||||||
|
|
@ -377,6 +383,7 @@ def validate_path_in_project(path: str, project_dir: Path) -> Path:
|
||||||
```
|
```
|
||||||
|
|
||||||
即使传入恶意路径,后端也会拒绝:
|
即使传入恶意路径,后端也会拒绝:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
"../../../etc/passwd" # 尝试跳出项目目录 -> ValueError
|
"../../../etc/passwd" # 尝试跳出项目目录 -> ValueError
|
||||||
"/etc/passwd" # 绝对路径攻击 -> ValueError
|
"/etc/passwd" # 绝对路径攻击 -> ValueError
|
||||||
|
|
@ -408,7 +415,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
### 认证
|
### 认证
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
| ------- | -------------------- | ----------------- |
|
||||||
| `GET` | `/api/auth/mode` | 获取当前认证模式(公开端点) |
|
| `GET` | `/api/auth/mode` | 获取当前认证模式(公开端点) |
|
||||||
| `POST` | `/api/auth/login` | 用户登录,返回 JWT token |
|
| `POST` | `/api/auth/login` | 用户登录,返回 JWT token |
|
||||||
| `POST` | `/api/auth/register` | 用户注册(仅多用户模式可用) |
|
| `POST` | `/api/auth/register` | 用户注册(仅多用户模式可用) |
|
||||||
|
|
@ -418,7 +425,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
### 会话管理
|
### 会话管理
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
| -------- | ------------------------ | ------------------------------- |
|
||||||
| `POST` | `/api/conversations` | 创建会话(可选 `project_id` 绑定项目) |
|
| `POST` | `/api/conversations` | 创建会话(可选 `project_id` 绑定项目) |
|
||||||
| `GET` | `/api/conversations` | 获取会话列表(可选 `project_id` 筛选,游标分页) |
|
| `GET` | `/api/conversations` | 获取会话列表(可选 `project_id` 筛选,游标分页) |
|
||||||
| `GET` | `/api/conversations/:id` | 获取会话详情 |
|
| `GET` | `/api/conversations/:id` | 获取会话详情 |
|
||||||
|
|
@ -428,7 +435,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
### 消息管理
|
### 消息管理
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
| -------- | ---------------------------------------- | ------------ |
|
||||||
| `GET` | `/api/conversations/:id/messages` | 获取消息列表(游标分页) |
|
| `GET` | `/api/conversations/:id/messages` | 获取消息列表(游标分页) |
|
||||||
| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) |
|
| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) |
|
||||||
| `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 |
|
| `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 |
|
||||||
|
|
@ -437,7 +444,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
### 项目管理
|
### 项目管理
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
| -------- | ----------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| `GET` | `/api/projects` | 获取项目列表 |
|
| `GET` | `/api/projects` | 获取项目列表 |
|
||||||
| `POST` | `/api/projects` | 创建项目 |
|
| `POST` | `/api/projects` | 创建项目 |
|
||||||
| `GET` | `/api/projects/:id` | 获取项目详情 |
|
| `GET` | `/api/projects/:id` | 获取项目详情 |
|
||||||
|
|
@ -454,7 +461,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
### 其他
|
### 其他
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
| ----- | ------------------- | ---------- |
|
||||||
| `GET` | `/api/models` | 获取模型列表 |
|
| `GET` | `/api/models` | 获取模型列表 |
|
||||||
| `GET` | `/api/tools` | 获取工具列表 |
|
| `GET` | `/api/tools` | 获取工具列表 |
|
||||||
| `GET` | `/api/stats/tokens` | Token 使用统计 |
|
| `GET` | `/api/stats/tokens` | Token 使用统计 |
|
||||||
|
|
@ -464,13 +471,32 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
## SSE 事件
|
## SSE 事件
|
||||||
|
|
||||||
| 事件 | 说明 |
|
| 事件 | 说明 |
|
||||||
|------|------|
|
| -------------- | ------------------------------------------------------------------------- |
|
||||||
| `message` | 回复内容的增量片段 |
|
| `thinking` | 思考过程的增量片段(实时流式输出) |
|
||||||
| `tool_calls` | 工具调用信息 |
|
| `message` | 回复内容的增量片段(实时流式输出) |
|
||||||
| `tool_result` | 工具执行结果 |
|
|
||||||
| `process_step` | 有序处理步骤(thinking/text/tool_call/tool_result),支持穿插显示。携带 `id`、`index` 确保渲染顺序 |
|
| `process_step` | 有序处理步骤(thinking/text/tool_call/tool_result),支持穿插显示。携带 `id`、`index` 确保渲染顺序 |
|
||||||
| `error` | 错误信息 |
|
| `error` | 错误信息 |
|
||||||
| `done` | 回复结束,携带 message_id 和 token_count |
|
| `done` | 回复结束,携带 message_id、token_count 和 suggested_title |
|
||||||
|
|
||||||
|
> **注意**:`thinking` 和 `message` 事件提供实时流式体验,每条 chunk 立即推送到前端。`process_step` 事件在每次迭代结束后发送完整内容,用于确定渲染顺序和 DB 存储。
|
||||||
|
|
||||||
|
### thinking / message 事件格式
|
||||||
|
|
||||||
|
实时流式事件,每条携带一个增量片段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 思考增量片段
|
||||||
|
{"content": "正在分析用户需求..."}
|
||||||
|
|
||||||
|
// 文本增量片段
|
||||||
|
{"content": "根据分析结果"}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --------- | ---------------------------- |
|
||||||
|
| `content` | 增量文本片段(前端累积拼接为完整内容) |
|
||||||
|
|
||||||
### process_step 事件格式
|
### 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-0", "index": 0, "type": "thinking", "content": "完整思考内容..."}
|
||||||
|
|
||||||
|
|
||||||
// 回复文本(可穿插在任意步骤之间)
|
// 回复文本(可穿插在任意步骤之间)
|
||||||
{"id": "step-1", "index": 1, "type": "text", "content": "回复文本内容..."}
|
{"id": "step-1", "index": 1, "type": "text", "content": "回复文本内容..."}
|
||||||
|
|
||||||
|
|
@ -493,7 +520,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
字段说明:
|
字段说明:
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
|------|------|
|
| ----------- | ------------------------------------------------------ |
|
||||||
| `id` | 步骤唯一标识(格式 `step-{index}`),用于前端 key |
|
| `id` | 步骤唯一标识(格式 `step-{index}`),用于前端 key |
|
||||||
| `index` | 步骤序号,确保按正确顺序显示 |
|
| `index` | 步骤序号,确保按正确顺序显示 |
|
||||||
| `type` | 步骤类型:`thinking` / `text` / `tool_call` / `tool_result` |
|
| `type` | 步骤类型:`thinking` / `text` / `tool_call` / `tool_result` |
|
||||||
|
|
@ -516,6 +543,18 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
|
|
||||||
所有步骤通过全局递增的 `index` 保证顺序。后端在完成所有迭代后,将这些步骤存入 `content_json["steps"]` 数组写入数据库。前端页面刷新时从 API 加载消息,`message_to_dict` 提取 `steps` 字段映射为 `process_steps` 返回,ProcessBlock 组件按 `index` 顺序渲染。
|
所有步骤通过全局递增的 `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`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 数据模型
|
## 数据模型
|
||||||
|
|
@ -523,7 +562,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
### User(用户)
|
### User(用户)
|
||||||
|
|
||||||
| 字段 | 类型 | 默认值 | 说明 |
|
| 字段 | 类型 | 默认值 | 说明 |
|
||||||
|------|------|--------|------|
|
| --------------- | ----------- | ------ | ---------------------------- |
|
||||||
| `id` | Integer | - | 自增主键 |
|
| `id` | Integer | - | 自增主键 |
|
||||||
| `username` | String(50) | - | 用户名(唯一) |
|
| `username` | String(50) | - | 用户名(唯一) |
|
||||||
| `password_hash` | String(255) | null | 密码哈希(可为空,支持 API-key-only 认证) |
|
| `password_hash` | String(255) | null | 密码哈希(可为空,支持 API-key-only 认证) |
|
||||||
|
|
@ -539,7 +578,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
### Project(项目)
|
### Project(项目)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| ------------- | ----------- | ------------------------- |
|
||||||
| `id` | String(64) | UUID 主键 |
|
| `id` | String(64) | UUID 主键 |
|
||||||
| `user_id` | Integer | 外键关联 User |
|
| `user_id` | Integer | 外键关联 User |
|
||||||
| `name` | String(255) | 项目名称(用户内唯一) |
|
| `name` | String(255) | 项目名称(用户内唯一) |
|
||||||
|
|
@ -551,7 +590,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
### Conversation(会话)
|
### Conversation(会话)
|
||||||
|
|
||||||
| 字段 | 类型 | 默认值 | 说明 |
|
| 字段 | 类型 | 默认值 | 说明 |
|
||||||
|------|------|--------|------|
|
| ------------------ | ----------- | ------- | ---------------- |
|
||||||
| `id` | String(64) | UUID | 主键 |
|
| `id` | String(64) | UUID | 主键 |
|
||||||
| `user_id` | Integer | - | 外键关联 User |
|
| `user_id` | Integer | - | 外键关联 User |
|
||||||
| `project_id` | String(64) | null | 外键关联 Project(可选) |
|
| `project_id` | String(64) | null | 外键关联 Project(可选) |
|
||||||
|
|
@ -567,7 +606,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
### Message(消息)
|
### Message(消息)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| ----------------- | ---------- | ------------------------------------------------ |
|
||||||
| `id` | String(64) | UUID 主键 |
|
| `id` | String(64) | UUID 主键 |
|
||||||
| `conversation_id` | String(64) | 外键关联 Conversation |
|
| `conversation_id` | String(64) | 外键关联 Conversation |
|
||||||
| `role` | String(16) | user/assistant/system/tool |
|
| `role` | String(16) | user/assistant/system/tool |
|
||||||
|
|
@ -580,7 +619,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
### TokenUsage(Token 使用统计)
|
### TokenUsage(Token 使用统计)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| ------------------- | ---------- | --------- |
|
||||||
| `id` | Integer | 自增主键 |
|
| `id` | Integer | 自增主键 |
|
||||||
| `user_id` | Integer | 外键关联 User |
|
| `user_id` | Integer | 外键关联 User |
|
||||||
| `date` | Date | 统计日期 |
|
| `date` | Date | 统计日期 |
|
||||||
|
|
@ -601,6 +640,7 @@ GET /api/conversations?limit=20&cursor=conv_abc123
|
||||||
```
|
```
|
||||||
|
|
||||||
响应:
|
响应:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
|
|
@ -652,7 +692,7 @@ GET /api/conversations?limit=20&cursor=conv_abc123
|
||||||
### 公开端点(无需认证)
|
### 公开端点(无需认证)
|
||||||
|
|
||||||
| 端点 | 说明 |
|
| 端点 | 说明 |
|
||||||
|------|------|
|
| ------------------------- | ---- |
|
||||||
| `POST /api/auth/login` | 登录 |
|
| `POST /api/auth/login` | 登录 |
|
||||||
| `POST /api/auth/register` | 注册 |
|
| `POST /api/auth/register` | 注册 |
|
||||||
| `GET /api/models` | 模型列表 |
|
| `GET /api/models` | 模型列表 |
|
||||||
|
|
@ -661,6 +701,7 @@ GET /api/conversations?limit=20&cursor=conv_abc123
|
||||||
### 前端适配
|
### 前端适配
|
||||||
|
|
||||||
前端 API 层(`frontend/src/api/index.js`)已预留 token 管理:
|
前端 API 层(`frontend/src/api/index.js`)已预留 token 管理:
|
||||||
|
|
||||||
- `getToken()` / `setToken(token)` / `clearToken()`
|
- `getToken()` / `setToken(token)` / `clearToken()`
|
||||||
- 所有请求自动附带 `Authorization: Bearer <token>`(token 为空时不发送)
|
- 所有请求自动附带 `Authorization: Bearer <token>`(token 为空时不发送)
|
||||||
- 收到 401 时自动清除 token
|
- 收到 401 时自动清除 token
|
||||||
|
|
@ -670,7 +711,7 @@ GET /api/conversations?limit=20&cursor=conv_abc123
|
||||||
---
|
---
|
||||||
|
|
||||||
| Code | 说明 |
|
| Code | 说明 |
|
||||||
|------|------|
|
| ----- | ---------------------- |
|
||||||
| `0` | 成功 |
|
| `0` | 成功 |
|
||||||
| `400` | 请求参数错误 |
|
| `400` | 请求参数错误 |
|
||||||
| `401` | 未认证(多用户模式下缺少或无效 token) |
|
| `401` | 未认证(多用户模式下缺少或无效 token) |
|
||||||
|
|
@ -680,6 +721,7 @@ GET /api/conversations?limit=20&cursor=conv_abc123
|
||||||
| `500` | 服务器错误 |
|
| `500` | 服务器错误 |
|
||||||
|
|
||||||
错误响应:
|
错误响应:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 404,
|
"code": 404,
|
||||||
|
|
@ -694,6 +736,7 @@ GET /api/conversations?limit=20&cursor=conv_abc123
|
||||||
### 设计目标
|
### 设计目标
|
||||||
|
|
||||||
将项目(Project)和对话(Conversation)建立**持久绑定关系**,实现:
|
将项目(Project)和对话(Conversation)建立**持久绑定关系**,实现:
|
||||||
|
|
||||||
1. 创建对话时自动绑定当前选中的项目
|
1. 创建对话时自动绑定当前选中的项目
|
||||||
2. 对话列表支持按项目筛选/分组
|
2. 对话列表支持按项目筛选/分组
|
||||||
3. 工具执行自动使用对话所属项目的上下文,无需 AI 每次询问 `project_id`
|
3. 工具执行自动使用对话所属项目的上下文,无需 AI 每次询问 `project_id`
|
||||||
|
|
@ -718,6 +761,7 @@ erDiagram
|
||||||
```
|
```
|
||||||
|
|
||||||
`Conversation.project_id` 是 nullable 的外键:
|
`Conversation.project_id` 是 nullable 的外键:
|
||||||
|
|
||||||
- `null` = 未绑定项目(通用对话,文件工具不可用)
|
- `null` = 未绑定项目(通用对话,文件工具不可用)
|
||||||
- 非 null = 绑定到特定项目(工具自动使用该项目的工作空间)
|
- 非 null = 绑定到特定项目(工具自动使用该项目的工作空间)
|
||||||
|
|
||||||
|
|
@ -793,6 +837,7 @@ GET /api/conversations # 返回所有对话(当前行为)
|
||||||
#### 发送消息 `POST /api/conversations/:id/messages`
|
#### 发送消息 `POST /api/conversations/:id/messages`
|
||||||
|
|
||||||
`project_id` 优先级:
|
`project_id` 优先级:
|
||||||
|
|
||||||
1. 请求体中的 `project_id`(前端显式传递)
|
1. 请求体中的 `project_id`(前端显式传递)
|
||||||
2. `conversation.project_id`(对话绑定的项目,自动回退)
|
2. `conversation.project_id`(对话绑定的项目,自动回退)
|
||||||
3. `null`(无项目上下文,文件工具报错提示)
|
3. `null`(无项目上下文,文件工具报错提示)
|
||||||
|
|
@ -849,6 +894,7 @@ if name.startswith("file_") and context and "project_id" in context:
|
||||||
```
|
```
|
||||||
|
|
||||||
**交互规则:**
|
**交互规则:**
|
||||||
|
|
||||||
1. 顶部项目选择器决定**当前工作空间**
|
1. 顶部项目选择器决定**当前工作空间**
|
||||||
2. 选中项目后,对话列表**仅显示该项目的对话**
|
2. 选中项目后,对话列表**仅显示该项目的对话**
|
||||||
3. 创建新对话时**自动绑定**当前项目
|
3. 创建新对话时**自动绑定**当前项目
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:streaming="streaming"
|
:streaming="streaming"
|
||||||
:streaming-content="streamContent"
|
:streaming-content="streamContent"
|
||||||
|
:streaming-thinking="streamThinkingContent"
|
||||||
:streaming-process-steps="streamProcessSteps"
|
:streaming-process-steps="streamProcessSteps"
|
||||||
:has-more-messages="hasMoreMessages"
|
:has-more-messages="hasMoreMessages"
|
||||||
:loading-more="loadingMessages"
|
:loading-more="loadingMessages"
|
||||||
|
|
@ -140,7 +141,7 @@ const nextMsgCursor = ref(null)
|
||||||
// processSteps are stored in the message object and later persisted to DB.
|
// processSteps are stored in the message object and later persisted to DB.
|
||||||
const streaming = ref(false)
|
const streaming = ref(false)
|
||||||
const streamContent = ref('') // Accumulated text content during current iteration
|
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
|
const streamProcessSteps = shallowRef([]) // Ordered steps: thinking/text/tool_call/tool_result
|
||||||
|
|
||||||
// 保存每个对话的流式状态
|
// 保存每个对话的流式状态
|
||||||
|
|
@ -149,7 +150,7 @@ const streamStates = new Map()
|
||||||
function setStreamState(isActive) {
|
function setStreamState(isActive) {
|
||||||
streaming.value = isActive
|
streaming.value = isActive
|
||||||
streamContent.value = ''
|
streamContent.value = ''
|
||||||
streamToolCalls.value = []
|
streamThinkingContent.value = ''
|
||||||
streamProcessSteps.value = []
|
streamProcessSteps.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,7 +252,7 @@ async function selectConversation(id) {
|
||||||
streamStates.set(currentConvId.value, {
|
streamStates.set(currentConvId.value, {
|
||||||
streaming: true,
|
streaming: true,
|
||||||
streamContent: streamContent.value,
|
streamContent: streamContent.value,
|
||||||
streamToolCalls: [...streamToolCalls.value],
|
streamThinkingContent: streamThinkingContent.value,
|
||||||
streamProcessSteps: [...streamProcessSteps.value],
|
streamProcessSteps: [...streamProcessSteps.value],
|
||||||
messages: [...messages.value],
|
messages: [...messages.value],
|
||||||
})
|
})
|
||||||
|
|
@ -266,7 +267,7 @@ async function selectConversation(id) {
|
||||||
if (savedState && savedState.streaming) {
|
if (savedState && savedState.streaming) {
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
streamContent.value = savedState.streamContent
|
streamContent.value = savedState.streamContent
|
||||||
streamToolCalls.value = savedState.streamToolCalls
|
streamThinkingContent.value = savedState.streamThinkingContent || ''
|
||||||
streamProcessSteps.value = savedState.streamProcessSteps
|
streamProcessSteps.value = savedState.streamProcessSteps
|
||||||
messages.value = savedState.messages || []
|
messages.value = savedState.messages || []
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -309,19 +310,8 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
onMessage(text) {
|
onMessage(text) {
|
||||||
updateStreamField(convId, 'streamContent', streamContent, prev => (prev || '') + text)
|
updateStreamField(convId, 'streamContent', streamContent, prev => (prev || '') + text)
|
||||||
},
|
},
|
||||||
onToolCalls(calls) {
|
onThinking(text) {
|
||||||
updateStreamField(convId, 'streamToolCalls', streamToolCalls, prev => [
|
updateStreamField(convId, 'streamThinkingContent', streamThinkingContent, prev => (prev || '') + text)
|
||||||
...(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
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onProcessStep(step) {
|
onProcessStep(step) {
|
||||||
// Insert step at its index position to preserve ordering.
|
// Insert step at its index position to preserve ordering.
|
||||||
|
|
@ -334,10 +324,12 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
steps[step.index] = step
|
steps[step.index] = step
|
||||||
return steps
|
return steps
|
||||||
})
|
})
|
||||||
// When text is finalized as a process_step, reset streaming content
|
// When a step is finalized, reset the corresponding streaming content
|
||||||
// to prevent duplication (the text is now rendered via processSteps).
|
// to prevent duplication (the content is now rendered via processSteps).
|
||||||
if (step.type === 'text') {
|
if (step.type === 'text') {
|
||||||
updateStreamField(convId, 'streamContent', streamContent, () => '')
|
updateStreamField(convId, 'streamContent', streamContent, () => '')
|
||||||
|
} else if (step.type === 'thinking') {
|
||||||
|
updateStreamField(convId, 'streamThinkingContent', streamThinkingContent, () => '')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async onDone(data) {
|
async onDone(data) {
|
||||||
|
|
@ -349,13 +341,34 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
// Build the final message object.
|
// Build the final message object.
|
||||||
// process_steps is the primary ordered data for rendering (thinking/text/tool_call/tool_result).
|
// 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.
|
// 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, {
|
messages.value = [...messages.value, {
|
||||||
id: data.message_id,
|
id: data.message_id,
|
||||||
conversation_id: convId,
|
conversation_id: convId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
text: streamContent.value,
|
text: lastText,
|
||||||
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
tool_calls: legacyToolCalls,
|
||||||
process_steps: streamProcessSteps.value.filter(Boolean),
|
process_steps: steps,
|
||||||
token_count: data.token_count,
|
token_count: data.token_count,
|
||||||
created_at: new Date().toISOString(),
|
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
|
* Shared SSE stream processor - parses SSE events and dispatches to callbacks
|
||||||
* @param {string} url - API URL (without BASE prefix)
|
* @param {string} url - API URL (without BASE prefix)
|
||||||
* @param {object} body - Request body
|
* @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 }}
|
* @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 controller = new AbortController()
|
||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
|
|
@ -67,12 +67,10 @@ function createSSEStream(url, body, { onMessage, onToolCalls, onToolResult, onPr
|
||||||
currentEvent = line.slice(7).trim()
|
currentEvent = line.slice(7).trim()
|
||||||
} else if (line.startsWith('data: ')) {
|
} else if (line.startsWith('data: ')) {
|
||||||
const data = JSON.parse(line.slice(6))
|
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)
|
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) {
|
} else if (currentEvent === 'process_step' && onProcessStep) {
|
||||||
onProcessStep(data)
|
onProcessStep(data)
|
||||||
} else if (currentEvent === 'done' && onDone) {
|
} else if (currentEvent === 'done' && onDone) {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
<ProcessBlock
|
<ProcessBlock
|
||||||
:process-steps="streamingProcessSteps"
|
:process-steps="streamingProcessSteps"
|
||||||
:streaming-content="streamingContent"
|
:streaming-content="streamingContent"
|
||||||
|
:streaming-thinking="streamingThinking"
|
||||||
:streaming="streaming"
|
:streaming="streaming"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,6 +88,7 @@ const props = defineProps({
|
||||||
messages: { type: Array, required: true },
|
messages: { type: Array, required: true },
|
||||||
streaming: { type: Boolean, default: false },
|
streaming: { type: Boolean, default: false },
|
||||||
streamingContent: { type: String, default: '' },
|
streamingContent: { type: String, default: '' },
|
||||||
|
streamingThinking: { type: String, default: '' },
|
||||||
streamingProcessSteps: { type: Array, default: () => [] },
|
streamingProcessSteps: { type: Array, default: () => [] },
|
||||||
hasMoreMessages: { type: Boolean, default: false },
|
hasMoreMessages: { type: Boolean, default: false },
|
||||||
loadingMore: { type: Boolean, default: false },
|
loadingMore: { type: Boolean, default: false },
|
||||||
|
|
@ -161,7 +163,7 @@ function scrollToMessage(msgId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流式时使用 instant 滚动,避免 smooth 动画与内容增长互相打架造成抖动
|
// 流式时使用 instant 滚动,避免 smooth 动画与内容增长互相打架造成抖动
|
||||||
watch([() => props.messages.length, () => props.streamingContent], () => {
|
watch([() => props.messages.length, () => props.streamingContent, () => props.streamingThinking], () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = scrollContainer.value
|
const el = scrollContainer.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,15 @@ const renderedContent = computed(() => {
|
||||||
useCodeEnhancement(messageRef, renderedContent)
|
useCodeEnhancement(messageRef, renderedContent)
|
||||||
|
|
||||||
function copyContent() {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ const props = defineProps({
|
||||||
toolCalls: { type: Array, default: () => [] },
|
toolCalls: { type: Array, default: () => [] },
|
||||||
processSteps: { type: Array, default: () => [] },
|
processSteps: { type: Array, default: () => [] },
|
||||||
streamingContent: { type: String, default: '' },
|
streamingContent: { type: String, default: '' },
|
||||||
|
streamingThinking: { type: String, default: '' },
|
||||||
streaming: { type: Boolean, default: false }
|
streaming: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -108,6 +109,17 @@ function getResultSummary(result) {
|
||||||
const processItems = computed(() => {
|
const processItems = computed(() => {
|
||||||
const items = []
|
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.
|
// 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.
|
// Steps are ordered: each iteration produces thinking → text → tool_call → tool_result.
|
||||||
if (props.processSteps && props.processSteps.length > 0) {
|
if (props.processSteps && props.processSteps.length > 0) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue