fix: 流式输出bug 修复

This commit is contained in:
ViperEkura 2026-03-27 08:50:11 +08:00
parent 767a8daf23
commit f57e813f76
7 changed files with 268 additions and 217 deletions

View File

@ -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(

View File

@ -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):
### TokenUsageToken 使用统计) ### TokenUsageToken 使用统计)
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| | ------------------- | ---------- | --------- |
| `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. 创建新对话时**自动绑定**当前项目

View File

@ -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(),
}] }]

View File

@ -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) {

View File

@ -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

View File

@ -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>

View File

@ -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) {