fix: 修复数据传递问题
This commit is contained in:
parent
39fb220cf2
commit
6ffbb29ec7
|
|
@ -96,7 +96,16 @@ class Message(db.Model):
|
||||||
role = db.Column(db.String(16), nullable=False) # user, assistant, system, tool
|
role = db.Column(db.String(16), nullable=False) # user, assistant, system, tool
|
||||||
# Unified JSON structure:
|
# Unified JSON structure:
|
||||||
# User: {"text": "...", "attachments": [{"name": "a.py", "extension": "py", "content": "..."}]}
|
# User: {"text": "...", "attachments": [{"name": "a.py", "extension": "py", "content": "..."}]}
|
||||||
# Assistant: {"text": "...", "thinking": "...", "tool_calls": [{"id": "...", "name": "...", "arguments": "...", "result": "..."}]}
|
# Assistant: {
|
||||||
|
# "text": "...",
|
||||||
|
# "tool_calls": [...], // legacy flat structure
|
||||||
|
# "steps": [ // ordered steps for rendering (primary source of truth)
|
||||||
|
# {"id": "step-0", "index": 0, "type": "thinking", "content": "..."},
|
||||||
|
# {"id": "step-1", "index": 1, "type": "text", "content": "..."},
|
||||||
|
# {"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_xxx", "name": "...", "arguments": "..."},
|
||||||
|
# {"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_xxx", "name": "...", "content": "..."},
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
content = db.Column(LongText, default="")
|
content = db.Column(LongText, default="")
|
||||||
token_count = db.Column(db.Integer, default=0)
|
token_count = db.Column(db.Integer, default=0)
|
||||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@ def upload_project_folder():
|
||||||
# Create project record
|
# Create project record
|
||||||
project = Project(
|
project = Project(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
user_id=user_id,
|
user_id=user.id,
|
||||||
name=project_name,
|
name=project_name,
|
||||||
path=relative_path,
|
path=relative_path,
|
||||||
description=description
|
description=description
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ class ChatService:
|
||||||
messages = list(initial_messages)
|
messages = list(initial_messages)
|
||||||
all_tool_calls = []
|
all_tool_calls = []
|
||||||
all_tool_results = []
|
all_tool_results = []
|
||||||
|
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
|
||||||
|
|
||||||
for iteration in range(self.MAX_ITERATIONS):
|
for iteration in range(self.MAX_ITERATIONS):
|
||||||
|
|
@ -80,6 +81,7 @@ class ChatService:
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
# Stream LLM response chunk by chunk
|
||||||
for line in resp.iter_lines():
|
for line in resp.iter_lines():
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
@ -96,20 +98,19 @@ class ChatService:
|
||||||
|
|
||||||
delta = chunk["choices"][0].get("delta", {})
|
delta = chunk["choices"][0].get("delta", {})
|
||||||
|
|
||||||
# Process thinking - send as process_step
|
# Accumulate thinking content for this iteration
|
||||||
reasoning = delta.get("reasoning_content", "")
|
reasoning = delta.get("reasoning_content", "")
|
||||||
if reasoning:
|
if reasoning:
|
||||||
full_thinking += reasoning
|
full_thinking += reasoning
|
||||||
# Still send thinking event for backward compatibility
|
|
||||||
yield f"event: thinking\ndata: {json.dumps({'content': reasoning}, ensure_ascii=False)}\n\n"
|
yield f"event: thinking\ndata: {json.dumps({'content': reasoning}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
# Process text
|
# Accumulate text content for this iteration
|
||||||
text = delta.get("content", "")
|
text = delta.get("content", "")
|
||||||
if text:
|
if text:
|
||||||
full_content += text
|
full_content += text
|
||||||
yield f"event: message\ndata: {json.dumps({'content': text}, ensure_ascii=False)}\n\n"
|
yield f"event: message\ndata: {json.dumps({'content': text}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
# Process tool calls
|
# Accumulate tool calls from streaming deltas
|
||||||
tool_calls_list = self._process_tool_calls_delta(delta, tool_calls_list)
|
tool_calls_list = self._process_tool_calls_delta(delta, tool_calls_list)
|
||||||
|
|
||||||
usage = chunk.get("usage", {})
|
usage = chunk.get("usage", {})
|
||||||
|
|
@ -121,48 +122,82 @@ 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 - execute and continue
|
# --- Tool calls exist: emit finalized steps, execute tools, continue loop ---
|
||||||
if tool_calls_list:
|
if tool_calls_list:
|
||||||
all_tool_calls.extend(tool_calls_list)
|
all_tool_calls.extend(tool_calls_list)
|
||||||
|
|
||||||
# Send thinking as a complete step if exists
|
# Record thinking as a finalized step (preserves order)
|
||||||
if full_thinking:
|
if full_thinking:
|
||||||
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n"
|
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
|
step_index += 1
|
||||||
|
|
||||||
# Send text as a step if exists (text before tool calls)
|
# Record text as a finalized step (text that preceded tool calls)
|
||||||
if full_content:
|
if full_content:
|
||||||
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'text', 'content': full_content}, ensure_ascii=False)}\n\n"
|
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
|
step_index += 1
|
||||||
|
|
||||||
# Also send legacy tool_calls event for backward compatibility
|
# Legacy tool_calls event for backward compatibility
|
||||||
yield f"event: tool_calls\ndata: {json.dumps({'calls': tool_calls_list}, ensure_ascii=False)}\n\n"
|
yield f"event: tool_calls\ndata: {json.dumps({'calls': tool_calls_list}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
# Process each tool call one by one, send result immediately
|
# Execute each tool call, emit tool_call + tool_result as paired steps
|
||||||
tool_results = []
|
tool_results = []
|
||||||
for tc in tool_calls_list:
|
for tc in tool_calls_list:
|
||||||
# Send tool call step
|
# Emit tool_call step (before execution)
|
||||||
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'tool_call', 'id': tc['id'], 'name': tc['function']['name'], 'arguments': tc['function']['arguments']}, ensure_ascii=False)}\n\n"
|
call_step = {
|
||||||
|
'id': f'step-{step_index}',
|
||||||
|
'index': step_index,
|
||||||
|
'type': 'tool_call',
|
||||||
|
'id_ref': tc['id'],
|
||||||
|
'name': tc['function']['name'],
|
||||||
|
'arguments': tc['function']['arguments'],
|
||||||
|
}
|
||||||
|
all_steps.append(call_step)
|
||||||
|
yield f"event: process_step\ndata: {json.dumps(call_step, ensure_ascii=False)}\n\n"
|
||||||
step_index += 1
|
step_index += 1
|
||||||
|
|
||||||
# Execute this single tool call (needs app context for db access)
|
# Execute the tool
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
single_result = self.executor.process_tool_calls([tc], context)
|
single_result = self.executor.process_tool_calls([tc], context)
|
||||||
tool_results.extend(single_result)
|
tool_results.extend(single_result)
|
||||||
|
|
||||||
# Send tool result step immediately
|
# Emit tool_result step (after execution)
|
||||||
tr = single_result[0]
|
tr = single_result[0]
|
||||||
try:
|
try:
|
||||||
result_content = json.loads(tr["content"])
|
result_content = json.loads(tr["content"])
|
||||||
skipped = result_content.get("skipped", False)
|
skipped = result_content.get("skipped", False)
|
||||||
except:
|
except:
|
||||||
skipped = False
|
skipped = False
|
||||||
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'tool_result', 'id': tr['tool_call_id'], 'name': tr['name'], 'content': tr['content'], 'skipped': skipped}, ensure_ascii=False)}\n\n"
|
result_step = {
|
||||||
|
'id': f'step-{step_index}',
|
||||||
|
'index': step_index,
|
||||||
|
'type': 'tool_result',
|
||||||
|
'id_ref': tr['tool_call_id'],
|
||||||
|
'name': tr['name'],
|
||||||
|
'content': tr['content'],
|
||||||
|
'skipped': skipped,
|
||||||
|
}
|
||||||
|
all_steps.append(result_step)
|
||||||
|
yield f"event: process_step\ndata: {json.dumps(result_step, ensure_ascii=False)}\n\n"
|
||||||
step_index += 1
|
step_index += 1
|
||||||
|
|
||||||
# Also send legacy tool_result event
|
# 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"
|
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({
|
messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": full_content or None,
|
"content": full_content or None,
|
||||||
|
|
@ -172,27 +207,40 @@ class ChatService:
|
||||||
all_tool_results.extend(tool_results)
|
all_tool_results.extend(tool_results)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# No tool calls - finish
|
# --- No tool calls: final iteration — emit remaining steps and save ---
|
||||||
# Send thinking as a step if exists
|
|
||||||
if full_thinking:
|
if full_thinking:
|
||||||
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n"
|
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
|
step_index += 1
|
||||||
|
|
||||||
# Send text as a step if exists
|
|
||||||
if full_content:
|
if full_content:
|
||||||
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'text', 'content': full_content}, ensure_ascii=False)}\n\n"
|
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
|
step_index += 1
|
||||||
|
|
||||||
suggested_title = None
|
suggested_title = None
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# Build content JSON
|
# Build content JSON with ordered steps array for DB storage.
|
||||||
|
# 'steps' is the single source of truth for rendering order.
|
||||||
content_json = {
|
content_json = {
|
||||||
"text": full_content,
|
"text": full_content,
|
||||||
}
|
}
|
||||||
if full_thinking:
|
|
||||||
content_json["thinking"] = full_thinking
|
|
||||||
if all_tool_calls:
|
if all_tool_calls:
|
||||||
content_json["tool_calls"] = self._build_tool_calls_json(all_tool_calls, all_tool_results)
|
content_json["tool_calls"] = self._build_tool_calls_json(all_tool_calls, all_tool_results)
|
||||||
|
# Store ordered steps — the single source of truth for rendering order
|
||||||
|
content_json["steps"] = all_steps
|
||||||
|
|
||||||
msg = Message(
|
msg = Message(
|
||||||
id=msg_id,
|
id=msg_id,
|
||||||
|
|
@ -208,15 +256,13 @@ class ChatService:
|
||||||
if user:
|
if user:
|
||||||
record_token_usage(user.id, conv_model, prompt_tokens, token_count)
|
record_token_usage(user.id, conv_model, prompt_tokens, token_count)
|
||||||
|
|
||||||
# Check if we need to set title (first message in conversation)
|
# Auto-generate title from first user message if needed
|
||||||
conv = db.session.get(Conversation, conv_id)
|
conv = db.session.get(Conversation, conv_id)
|
||||||
if conv and (not conv.title or conv.title == "新对话"):
|
if conv and (not conv.title or conv.title == "新对话"):
|
||||||
# Get user message content
|
|
||||||
user_msg = Message.query.filter_by(
|
user_msg = Message.query.filter_by(
|
||||||
conversation_id=conv_id, role="user"
|
conversation_id=conv_id, role="user"
|
||||||
).order_by(Message.created_at.asc()).first()
|
).order_by(Message.created_at.asc()).first()
|
||||||
if user_msg and user_msg.content:
|
if user_msg and user_msg.content:
|
||||||
# Parse content JSON to get text
|
|
||||||
try:
|
try:
|
||||||
content_data = json.loads(user_msg.content)
|
content_data = json.loads(user_msg.content)
|
||||||
title_text = content_data.get("text", "")[:30]
|
title_text = content_data.get("text", "")[:30]
|
||||||
|
|
@ -226,7 +272,6 @@ class ChatService:
|
||||||
suggested_title = title_text
|
suggested_title = title_text
|
||||||
else:
|
else:
|
||||||
suggested_title = "新对话"
|
suggested_title = "新对话"
|
||||||
# Refresh conv to avoid stale state
|
|
||||||
db.session.refresh(conv)
|
db.session.refresh(conv)
|
||||||
conv.title = suggested_title
|
conv.title = suggested_title
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,12 @@ def to_dict(inst, **extra):
|
||||||
|
|
||||||
|
|
||||||
def message_to_dict(msg: Message) -> dict:
|
def message_to_dict(msg: Message) -> dict:
|
||||||
"""Convert message to dict, parsing JSON content"""
|
"""Convert message to dict, parsing JSON content.
|
||||||
|
|
||||||
|
For assistant messages, extracts the 'steps' array which preserves the
|
||||||
|
ordered sequence of thinking/text/tool_call/tool_result steps, so the
|
||||||
|
frontend can render them in the correct interleaved order.
|
||||||
|
"""
|
||||||
result = to_dict(msg)
|
result = to_dict(msg)
|
||||||
|
|
||||||
# Parse content JSON
|
# Parse content JSON
|
||||||
|
|
@ -71,16 +76,15 @@ def message_to_dict(msg: Message) -> dict:
|
||||||
try:
|
try:
|
||||||
content_data = json.loads(msg.content)
|
content_data = json.loads(msg.content)
|
||||||
if isinstance(content_data, dict):
|
if isinstance(content_data, dict):
|
||||||
# Extract all fields from JSON
|
|
||||||
result["text"] = content_data.get("text", "")
|
result["text"] = content_data.get("text", "")
|
||||||
if content_data.get("attachments"):
|
if content_data.get("attachments"):
|
||||||
result["attachments"] = content_data["attachments"]
|
result["attachments"] = content_data["attachments"]
|
||||||
if content_data.get("thinking"):
|
|
||||||
result["thinking"] = content_data["thinking"]
|
|
||||||
if content_data.get("tool_calls"):
|
if content_data.get("tool_calls"):
|
||||||
result["tool_calls"] = content_data["tool_calls"]
|
result["tool_calls"] = content_data["tool_calls"]
|
||||||
|
# Extract ordered steps array for correct rendering order
|
||||||
|
if content_data.get("steps"):
|
||||||
|
result["process_steps"] = content_data["steps"]
|
||||||
else:
|
else:
|
||||||
# Fallback: plain text
|
|
||||||
result["text"] = msg.content
|
result["text"] = msg.content
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
result["text"] = msg.content
|
result["text"] = msg.content
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,6 @@ classDiagram
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"text": "AI 回复的文本内容",
|
"text": "AI 回复的文本内容",
|
||||||
"thinking": "思考过程(可选)",
|
|
||||||
"tool_calls": [
|
"tool_calls": [
|
||||||
{
|
{
|
||||||
"id": "call_xxx",
|
"id": "call_xxx",
|
||||||
|
|
@ -189,10 +188,55 @@ classDiagram
|
||||||
"skipped": false,
|
"skipped": false,
|
||||||
"execution_time": 0.5
|
"execution_time": 0.5
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "step-0",
|
||||||
|
"index": 0,
|
||||||
|
"type": "thinking",
|
||||||
|
"content": "第一轮思考过程..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step-1",
|
||||||
|
"index": 1,
|
||||||
|
"type": "text",
|
||||||
|
"content": "工具调用前的文本..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step-2",
|
||||||
|
"index": 2,
|
||||||
|
"type": "tool_call",
|
||||||
|
"id_ref": "call_abc123",
|
||||||
|
"name": "web_search",
|
||||||
|
"arguments": "{\"query\": \"...\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step-3",
|
||||||
|
"index": 3,
|
||||||
|
"type": "tool_result",
|
||||||
|
"id_ref": "call_abc123",
|
||||||
|
"name": "web_search",
|
||||||
|
"content": "{\"success\": true, ...}",
|
||||||
|
"skipped": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step-4",
|
||||||
|
"index": 4,
|
||||||
|
"type": "thinking",
|
||||||
|
"content": "第二轮思考过程..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step-5",
|
||||||
|
"index": 5,
|
||||||
|
"type": "text",
|
||||||
|
"content": "最终回复文本..."
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`steps` 字段是**渲染顺序的唯一数据源**,按 `index` 顺序排列。thinking、text、tool_call、tool_result 可以在多轮迭代中穿插出现。`id_ref` 用于 tool_call 和 tool_result 步骤之间的匹配(对应 LLM 返回的工具调用 ID)。`tool_calls` 字段保留用于向后兼容旧版前端。
|
||||||
|
|
||||||
### 服务层
|
### 服务层
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|
@ -426,33 +470,53 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
| `message` | 回复内容的增量片段 |
|
| `message` | 回复内容的增量片段 |
|
||||||
| `tool_calls` | 工具调用信息 |
|
| `tool_calls` | 工具调用信息 |
|
||||||
| `tool_result` | 工具执行结果 |
|
| `tool_result` | 工具执行结果 |
|
||||||
| `process_step` | 处理步骤(按顺序:thinking/text/tool_call/tool_result),支持穿插显示 |
|
| `process_step` | 有序处理步骤(thinking/text/tool_call/tool_result),支持穿插显示。携带 `id`、`index` 确保渲染顺序 |
|
||||||
| `error` | 错误信息 |
|
| `error` | 错误信息 |
|
||||||
| `done` | 回复结束,携带 message_id 和 token_count |
|
| `done` | 回复结束,携带 message_id 和 token_count |
|
||||||
|
|
||||||
### process_step 事件格式
|
### process_step 事件格式
|
||||||
|
|
||||||
|
每个 `process_step` 事件携带一个带 `id`、`index` 和 `type` 的步骤对象。步骤按 `index` 顺序排列,确保前端可以正确渲染穿插的思考、文本和工具调用。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// 思考过程
|
// 思考过程
|
||||||
{"index": 0, "type": "thinking", "content": "完整思考内容..."}
|
{"id": "step-0", "index": 0, "type": "thinking", "content": "完整思考内容..."}
|
||||||
|
|
||||||
// 回复文本(可穿插在任意步骤之间)
|
// 回复文本(可穿插在任意步骤之间)
|
||||||
{"index": 1, "type": "text", "content": "回复文本内容..."}
|
{"id": "step-1", "index": 1, "type": "text", "content": "回复文本内容..."}
|
||||||
|
|
||||||
// 工具调用
|
// 工具调用(id_ref 存储工具调用 ID,用于与 tool_result 匹配)
|
||||||
{"index": 2, "type": "tool_call", "id": "call_abc123", "name": "web_search", "arguments": "{\"query\": \"...\"}"}
|
{"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_abc123", "name": "web_search", "arguments": "{\"query\": \"...\"}"}
|
||||||
|
|
||||||
// 工具返回
|
// 工具返回(id_ref 与 tool_call 的 id_ref 匹配)
|
||||||
{"index": 3, "type": "tool_result", "id": "call_abc123", "name": "web_search", "content": "{\"success\": true, ...}", "skipped": false}
|
{"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_abc123", "name": "web_search", "content": "{\"success\": true, ...}", "skipped": false}
|
||||||
```
|
```
|
||||||
|
|
||||||
字段说明:
|
字段说明:
|
||||||
- `index`: 步骤序号,确保按正确顺序显示
|
|
||||||
- `type`: 步骤类型(thinking/tool_call/tool_result)
|
| 字段 | 说明 |
|
||||||
- `id`: 工具调用唯一标识,用于匹配工具调用和返回结果
|
|------|------|
|
||||||
- `name`: 工具名称
|
| `id` | 步骤唯一标识(格式 `step-{index}`),用于前端 key |
|
||||||
- `content`: 内容或结果
|
| `index` | 步骤序号,确保按正确顺序显示 |
|
||||||
- `skipped`: 工具是否被跳过(失败后跳过)
|
| `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) |
|
||||||
|
|
||||||
|
### 多轮迭代中的步骤顺序
|
||||||
|
|
||||||
|
一次完整的 LLM 交互可能经历多轮工具调用循环,每轮产生的步骤按以下顺序追加:
|
||||||
|
|
||||||
|
```
|
||||||
|
迭代 1: thinking → text → tool_call → tool_result
|
||||||
|
迭代 2: thinking → text → tool_call → tool_result
|
||||||
|
...
|
||||||
|
最终轮: thinking → text(无工具调用,结束)
|
||||||
|
```
|
||||||
|
|
||||||
|
所有步骤通过全局递增的 `index` 保证顺序。后端在完成所有迭代后,将这些步骤存入 `content_json["steps"]` 数组写入数据库。前端页面刷新时从 API 加载消息,`message_to_dict` 提取 `steps` 字段映射为 `process_steps` 返回,ProcessBlock 组件按 `index` 顺序渲染。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -509,10 +573,12 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
| `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 |
|
||||||
| `content` | LongText | JSON 格式内容(见上方结构说明) |
|
| `content` | LongText | JSON 格式内容(见上方结构说明),assistant 消息包含 `steps` 有序步骤数组 |
|
||||||
| `token_count` | Integer | Token 数量 |
|
| `token_count` | Integer | Token 数量 |
|
||||||
| `created_at` | DateTime | 创建时间 |
|
| `created_at` | DateTime | 创建时间 |
|
||||||
|
|
||||||
|
`message_to_dict()` 辅助函数负责解析 `content` JSON,并提取 `steps` 字段映射为 `process_steps` 返回给前端,确保页面刷新后仍能按正确顺序渲染穿插的思考、文本和工具调用。
|
||||||
|
|
||||||
### TokenUsage(Token 使用统计)
|
### TokenUsage(Token 使用统计)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
:conversations="conversations"
|
:conversations="conversations"
|
||||||
|
:projects="projects"
|
||||||
:current-id="currentConvId"
|
:current-id="currentConvId"
|
||||||
:loading="loadingConvs"
|
:loading="loadingConvs"
|
||||||
:has-more="hasMoreConvs"
|
:has-more="hasMoreConvs"
|
||||||
|
|
@ -11,6 +12,7 @@
|
||||||
@create-project="showCreateModal = true"
|
@create-project="showCreateModal = true"
|
||||||
@browse-project="browseProject"
|
@browse-project="browseProject"
|
||||||
@create-in-project="createConversationInProject"
|
@create-in-project="createConversationInProject"
|
||||||
|
@delete-project="deleteProject"
|
||||||
@toggle-settings="togglePanel('settings')"
|
@toggle-settings="togglePanel('settings')"
|
||||||
@toggle-stats="togglePanel('stats')"
|
@toggle-stats="togglePanel('stats')"
|
||||||
/>
|
/>
|
||||||
|
|
@ -42,7 +44,6 @@
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:streaming="streaming"
|
:streaming="streaming"
|
||||||
:streaming-content="streamContent"
|
:streaming-content="streamContent"
|
||||||
:streaming-thinking="streamThinking"
|
|
||||||
:streaming-tool-calls="streamToolCalls"
|
:streaming-tool-calls="streamToolCalls"
|
||||||
:streaming-process-steps="streamProcessSteps"
|
:streaming-process-steps="streamProcessSteps"
|
||||||
:has-more-messages="hasMoreMessages"
|
:has-more-messages="hasMoreMessages"
|
||||||
|
|
@ -128,6 +129,9 @@ const loadingConvs = ref(false)
|
||||||
const hasMoreConvs = ref(false)
|
const hasMoreConvs = ref(false)
|
||||||
const nextConvCursor = ref(null)
|
const nextConvCursor = ref(null)
|
||||||
|
|
||||||
|
// -- Projects state --
|
||||||
|
const projects = ref([])
|
||||||
|
|
||||||
// -- Messages state --
|
// -- Messages state --
|
||||||
const messages = shallowRef([])
|
const messages = shallowRef([])
|
||||||
const hasMoreMessages = ref(false)
|
const hasMoreMessages = ref(false)
|
||||||
|
|
@ -135,11 +139,14 @@ const loadingMessages = ref(false)
|
||||||
const nextMsgCursor = ref(null)
|
const nextMsgCursor = ref(null)
|
||||||
|
|
||||||
// -- Streaming state --
|
// -- Streaming state --
|
||||||
|
// These refs hold the real-time streaming data for the current conversation.
|
||||||
|
// When switching conversations, the current state is saved to streamStates Map
|
||||||
|
// and restored when switching back. On stream completion (onDone), the finalized
|
||||||
|
// processSteps are stored in the message object and later persisted to DB.
|
||||||
const streaming = ref(false)
|
const streaming = ref(false)
|
||||||
const streamContent = ref('')
|
const streamContent = ref('') // Accumulated text content during current iteration
|
||||||
const streamThinking = ref('')
|
const streamToolCalls = shallowRef([]) // All tool calls across iterations (legacy compat)
|
||||||
const streamToolCalls = shallowRef([])
|
const streamProcessSteps = shallowRef([]) // Ordered steps: thinking/text/tool_call/tool_result
|
||||||
const streamProcessSteps = shallowRef([])
|
|
||||||
|
|
||||||
// 保存每个对话的流式状态
|
// 保存每个对话的流式状态
|
||||||
const streamStates = new Map()
|
const streamStates = new Map()
|
||||||
|
|
@ -147,7 +154,6 @@ const streamStates = new Map()
|
||||||
function setStreamState(isActive) {
|
function setStreamState(isActive) {
|
||||||
streaming.value = isActive
|
streaming.value = isActive
|
||||||
streamContent.value = ''
|
streamContent.value = ''
|
||||||
streamThinking.value = ''
|
|
||||||
streamToolCalls.value = []
|
streamToolCalls.value = []
|
||||||
streamProcessSteps.value = []
|
streamProcessSteps.value = []
|
||||||
}
|
}
|
||||||
|
|
@ -253,7 +259,6 @@ async function selectConversation(id) {
|
||||||
streamStates.set(currentConvId.value, {
|
streamStates.set(currentConvId.value, {
|
||||||
streaming: true,
|
streaming: true,
|
||||||
streamContent: streamContent.value,
|
streamContent: streamContent.value,
|
||||||
streamThinking: streamThinking.value,
|
|
||||||
streamToolCalls: [...streamToolCalls.value],
|
streamToolCalls: [...streamToolCalls.value],
|
||||||
streamProcessSteps: [...streamProcessSteps.value],
|
streamProcessSteps: [...streamProcessSteps.value],
|
||||||
messages: [...messages.value],
|
messages: [...messages.value],
|
||||||
|
|
@ -269,7 +274,6 @@ 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
|
||||||
streamThinking.value = savedState.streamThinking
|
|
||||||
streamToolCalls.value = savedState.streamToolCalls
|
streamToolCalls.value = savedState.streamToolCalls
|
||||||
streamProcessSteps.value = savedState.streamProcessSteps
|
streamProcessSteps.value = savedState.streamProcessSteps
|
||||||
messages.value = savedState.messages || []
|
messages.value = savedState.messages || []
|
||||||
|
|
@ -310,13 +314,6 @@ function loadMoreMessages() {
|
||||||
// -- Helpers: create stream callbacks for a conversation --
|
// -- Helpers: create stream callbacks for a conversation --
|
||||||
function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
return {
|
return {
|
||||||
onThinkingStart() {
|
|
||||||
updateStreamField(convId, 'streamThinking', streamThinking, '')
|
|
||||||
updateStreamField(convId, 'streamContent', streamContent, '')
|
|
||||||
},
|
|
||||||
onThinking(text) {
|
|
||||||
updateStreamField(convId, 'streamThinking', streamThinking, prev => (prev || '') + text)
|
|
||||||
},
|
|
||||||
onMessage(text) {
|
onMessage(text) {
|
||||||
updateStreamField(convId, 'streamContent', streamContent, prev => (prev || '') + text)
|
updateStreamField(convId, 'streamContent', streamContent, prev => (prev || '') + text)
|
||||||
},
|
},
|
||||||
|
|
@ -335,6 +332,10 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onProcessStep(step) {
|
onProcessStep(step) {
|
||||||
|
// Insert step at its index position to preserve ordering.
|
||||||
|
// Uses sparse array strategy: fills gaps with null.
|
||||||
|
// Each step carries { id, index, type, content, ... } —
|
||||||
|
// these are the same steps that get stored to DB as the 'steps' array.
|
||||||
updateStreamField(convId, 'streamProcessSteps', streamProcessSteps, prev => {
|
updateStreamField(convId, 'streamProcessSteps', streamProcessSteps, prev => {
|
||||||
const steps = prev ? [...prev] : []
|
const steps = prev ? [...prev] : []
|
||||||
while (steps.length <= step.index) steps.push(null)
|
while (steps.length <= step.index) steps.push(null)
|
||||||
|
|
@ -347,12 +348,15 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
|
|
||||||
if (currentConvId.value === convId) {
|
if (currentConvId.value === convId) {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
|
|
||||||
|
// 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.
|
||||||
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: streamContent.value,
|
||||||
thinking: streamThinking.value || null,
|
|
||||||
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
||||||
process_steps: streamProcessSteps.value.filter(Boolean),
|
process_steps: streamProcessSteps.value.filter(Boolean),
|
||||||
token_count: data.token_count,
|
token_count: data.token_count,
|
||||||
|
|
@ -507,13 +511,13 @@ async function createProject() {
|
||||||
creatingProject.value = true
|
creatingProject.value = true
|
||||||
try {
|
try {
|
||||||
await projectApi.create({
|
await projectApi.create({
|
||||||
user_id: 1,
|
|
||||||
name: newProjectName.value.trim(),
|
name: newProjectName.value.trim(),
|
||||||
description: newProjectDesc.value.trim(),
|
description: newProjectDesc.value.trim(),
|
||||||
})
|
})
|
||||||
showCreateModal.value = false
|
showCreateModal.value = false
|
||||||
newProjectName.value = ''
|
newProjectName.value = ''
|
||||||
newProjectDesc.value = ''
|
newProjectDesc.value = ''
|
||||||
|
await loadProjects()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to create project:', e)
|
console.error('Failed to create project:', e)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -521,8 +525,47 @@ async function createProject() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Load projects --
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
const res = await projectApi.list()
|
||||||
|
projects.value = res.data.projects || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load projects:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Delete project --
|
||||||
|
async function deleteProject(project) {
|
||||||
|
if (!confirm(`确定删除项目「${project.name}」及其所有对话?`)) return
|
||||||
|
try {
|
||||||
|
await projectApi.delete(project.id)
|
||||||
|
// Remove conversations belonging to this project
|
||||||
|
conversations.value = conversations.value.filter(c => c.project_id !== project.id)
|
||||||
|
// If current conversation was in this project, switch away
|
||||||
|
if (currentConvId.value && conversations.value.length > 0) {
|
||||||
|
const currentConv = conversations.value.find(c => c.id === currentConvId.value)
|
||||||
|
if (!currentConv || currentConv.project_id === project.id) {
|
||||||
|
await selectConversation(conversations.value[0].id)
|
||||||
|
}
|
||||||
|
} else if (conversations.value.length === 0) {
|
||||||
|
currentConvId.value = null
|
||||||
|
messages.value = []
|
||||||
|
currentProject.value = null
|
||||||
|
}
|
||||||
|
if (currentProject.value?.id === project.id) {
|
||||||
|
currentProject.value = null
|
||||||
|
showFileExplorer.value = false
|
||||||
|
}
|
||||||
|
await loadProjects()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete project:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- Init --
|
// -- Init --
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadProjects()
|
||||||
loadConversations()
|
loadConversations()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,8 @@ export const messageApi = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const projectApi = {
|
export const projectApi = {
|
||||||
list(userId) {
|
list() {
|
||||||
return request(`/projects${buildQueryParams({ user_id: userId })}`)
|
return request('/projects')
|
||||||
},
|
},
|
||||||
|
|
||||||
create(data) {
|
create(data) {
|
||||||
|
|
@ -211,7 +211,6 @@ export const projectApi = {
|
||||||
|
|
||||||
uploadFolder(data) {
|
uploadFolder(data) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('user_id', String(data.user_id))
|
|
||||||
formData.append('name', data.name || '')
|
formData.append('name', data.name || '')
|
||||||
formData.append('description', data.description || '')
|
formData.append('description', data.description || '')
|
||||||
for (const file of data.files) {
|
for (const file of data.files) {
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,11 @@
|
||||||
v-for="msg in messages"
|
v-for="msg in messages"
|
||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
:data-msg-id="msg.id"
|
:data-msg-id="msg.id"
|
||||||
v-memo="[msg.text, msg.thinking, msg.tool_calls, msg.process_steps, msg.attachments]"
|
v-memo="[msg.text, msg.tool_calls, msg.process_steps, msg.attachments]"
|
||||||
>
|
>
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
:role="msg.role"
|
:role="msg.role"
|
||||||
:text="msg.text"
|
:text="msg.text"
|
||||||
:thinking-content="msg.thinking"
|
|
||||||
:tool-calls="msg.tool_calls"
|
:tool-calls="msg.tool_calls"
|
||||||
:process-steps="msg.process_steps"
|
:process-steps="msg.process_steps"
|
||||||
:token-count="msg.token_count"
|
:token-count="msg.token_count"
|
||||||
|
|
@ -48,7 +47,6 @@
|
||||||
<div class="avatar">claw</div>
|
<div class="avatar">claw</div>
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<ProcessBlock
|
<ProcessBlock
|
||||||
:thinking-content="streamingThinking"
|
|
||||||
:tool-calls="streamingToolCalls"
|
:tool-calls="streamingToolCalls"
|
||||||
:process-steps="streamingProcessSteps"
|
:process-steps="streamingProcessSteps"
|
||||||
:streaming-content="streamingContent"
|
:streaming-content="streamingContent"
|
||||||
|
|
@ -90,7 +88,6 @@ 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: '' },
|
|
||||||
streamingToolCalls: { type: Array, default: () => [] },
|
streamingToolCalls: { type: Array, default: () => [] },
|
||||||
streamingProcessSteps: { type: Array, default: () => [] },
|
streamingProcessSteps: { type: Array, default: () => [] },
|
||||||
hasMoreMessages: { type: Boolean, default: false },
|
hasMoreMessages: { type: Boolean, default: false },
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<div v-if="role === 'user'" class="avatar">user</div>
|
<div v-if="role === 'user'" class="avatar">user</div>
|
||||||
<div v-else class="avatar">claw</div>
|
<div v-else class="avatar">claw</div>
|
||||||
<div class="message-container">
|
<div class="message-container">
|
||||||
<!-- 附件列表 -->
|
<!-- File attachments list -->
|
||||||
<div v-if="attachments && attachments.length > 0" class="attachments-list">
|
<div v-if="attachments && attachments.length > 0" class="attachments-list">
|
||||||
<div v-for="(file, index) in attachments" :key="index" class="attachment-item">
|
<div v-for="(file, index) in attachments" :key="index" class="attachment-item">
|
||||||
<span class="attachment-icon">{{ file.extension }}</span>
|
<span class="attachment-icon">{{ file.extension }}</span>
|
||||||
|
|
@ -11,18 +11,18 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="messageRef" class="message-body">
|
<div ref="messageRef" class="message-body">
|
||||||
<!-- 新格式: processSteps 包含所有步骤(含 text),统一通过 ProcessBlock 渲染 -->
|
<!-- Primary rendering path: processSteps contains all ordered steps -->
|
||||||
|
<!-- (thinking, text, tool_call, tool_result) from both streaming and DB load -->
|
||||||
<ProcessBlock
|
<ProcessBlock
|
||||||
v-if="processSteps && processSteps.length > 0"
|
v-if="processSteps && processSteps.length > 0"
|
||||||
:process-steps="processSteps"
|
:process-steps="processSteps"
|
||||||
:thinking-content="thinkingContent"
|
|
||||||
:tool-calls="toolCalls"
|
:tool-calls="toolCalls"
|
||||||
/>
|
/>
|
||||||
<!-- 旧格式: 无 processSteps,分开渲染 ProcessBlock + 文本 -->
|
<!-- Fallback path: old messages without processSteps in DB, -->
|
||||||
|
<!-- render toolCalls via ProcessBlock and text separately -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ProcessBlock
|
<ProcessBlock
|
||||||
v-if="thinkingContent || (toolCalls && toolCalls.length > 0)"
|
v-if="toolCalls && toolCalls.length > 0"
|
||||||
:thinking-content="thinkingContent"
|
|
||||||
:tool-calls="toolCalls"
|
:tool-calls="toolCalls"
|
||||||
/>
|
/>
|
||||||
<div class="md-content message-content" v-html="renderedContent"></div>
|
<div class="md-content message-content" v-html="renderedContent"></div>
|
||||||
|
|
@ -62,15 +62,18 @@ import { useCodeEnhancement } from '../composables/useCodeEnhancement'
|
||||||
import ProcessBlock from './ProcessBlock.vue'
|
import ProcessBlock from './ProcessBlock.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
role: { type: String, required: true },
|
role: { type: String, required: true }, // 'user' or 'assistant'
|
||||||
text: { type: String, default: '' },
|
text: { type: String, default: '' }, // Plain text content (legacy / user messages)
|
||||||
thinkingContent: { type: String, default: '' },
|
toolCalls: { type: Array, default: () => [] }, // Tool calls array (legacy fallback)
|
||||||
toolCalls: { type: Array, default: () => [] },
|
// Ordered steps array — primary rendering data source.
|
||||||
|
// During streaming: accumulated from process_step SSE events.
|
||||||
|
// On page load: loaded from DB via message_to_dict extracting 'steps' field.
|
||||||
|
// Each step: { id, index, type: 'thinking'|'text'|'tool_call'|'tool_result', content, ... }
|
||||||
processSteps: { type: Array, default: () => [] },
|
processSteps: { type: Array, default: () => [] },
|
||||||
tokenCount: { type: Number, default: 0 },
|
tokenCount: { type: Number, default: 0 },
|
||||||
createdAt: { type: String, default: '' },
|
createdAt: { type: String, default: '' },
|
||||||
deletable: { type: Boolean, default: false },
|
deletable: { type: Boolean, default: false },
|
||||||
attachments: { type: Array, default: () => [] },
|
attachments: { type: Array, default: () => [] }, // User file attachments
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['delete', 'regenerate'])
|
defineEmits(['delete', 'regenerate'])
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="processRef" class="process-block" :class="{ 'is-streaming': streaming }">
|
<div ref="processRef" class="process-block" :class="{ 'is-streaming': streaming }">
|
||||||
<!-- 流式加载:还没有任何步骤时 -->
|
<!-- Placeholder while waiting for the first process step to arrive -->
|
||||||
<div v-if="streaming && processItems.length === 0" class="streaming-placeholder">
|
<div v-if="streaming && processItems.length === 0" class="streaming-placeholder">
|
||||||
<div class="streaming-icon">
|
<div class="streaming-icon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
|
@ -10,10 +10,10 @@
|
||||||
<span class="streaming-text">正在思考中<span class="dots">...</span></span>
|
<span class="streaming-text">正在思考中<span class="dots">...</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 按序渲染步骤 -->
|
<!-- Render all steps in order: thinking, text, tool_call, tool_result interleaved -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-for="item in processItems" :key="item.key">
|
<template v-for="item in processItems" :key="item.key">
|
||||||
<!-- 思考过程 -->
|
<!-- Thinking block -->
|
||||||
<div v-if="item.type === 'thinking'" class="step-item thinking">
|
<div v-if="item.type === 'thinking'" class="step-item thinking">
|
||||||
<div class="step-header" @click="toggleItem(item.key)">
|
<div class="step-header" @click="toggleItem(item.key)">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 工具调用 -->
|
<!-- Tool call block -->
|
||||||
<div v-else-if="item.type === 'tool_call'" class="step-item tool_call" :class="{ loading: item.loading }">
|
<div v-else-if="item.type === 'tool_call'" class="step-item tool_call" :class="{ loading: item.loading }">
|
||||||
<div class="step-header" @click="toggleItem(item.key)">
|
<div class="step-header" @click="toggleItem(item.key)">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
|
@ -56,11 +56,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文本内容 - 直接渲染 markdown -->
|
<!-- Text content — render as markdown -->
|
||||||
<div v-else-if="item.type === 'text'" class="step-item text-content md-content" v-html="item.rendered"></div>
|
<div v-else-if="item.type === 'text'" class="step-item text-content md-content" v-html="item.rendered"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 流式进行中指示器 -->
|
<!-- Active streaming indicator (cursor) -->
|
||||||
<div v-if="streaming" class="streaming-indicator">
|
<div v-if="streaming" class="streaming-indicator">
|
||||||
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
|
@ -78,7 +78,6 @@ import { formatJson, truncate } from '../utils/format'
|
||||||
import { useCodeEnhancement } from '../composables/useCodeEnhancement'
|
import { useCodeEnhancement } from '../composables/useCodeEnhancement'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
thinkingContent: { type: String, default: '' },
|
|
||||||
toolCalls: { type: Array, default: () => [] },
|
toolCalls: { type: Array, default: () => [] },
|
||||||
processSteps: { type: Array, default: () => [] },
|
processSteps: { type: Array, default: () => [] },
|
||||||
streamingContent: { type: String, default: '' },
|
streamingContent: { type: String, default: '' },
|
||||||
|
|
@ -87,7 +86,7 @@ const props = defineProps({
|
||||||
|
|
||||||
const expandedKeys = ref({})
|
const expandedKeys = ref({})
|
||||||
|
|
||||||
// 流式时自动展开
|
// Auto-collapse all items when a new stream starts
|
||||||
watch(() => props.streaming, (v) => {
|
watch(() => props.streaming, (v) => {
|
||||||
if (v) expandedKeys.value = {}
|
if (v) expandedKeys.value = {}
|
||||||
})
|
})
|
||||||
|
|
@ -110,30 +109,45 @@ function getResultSummary(result) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build ordered process items from all available data (thinking, tool calls, text).
|
||||||
|
// During streaming, processSteps accumulate completed iterations while streamingContent
|
||||||
|
// represents the text being generated in the current (latest) iteration.
|
||||||
|
// When loaded from DB, steps use 'id_ref' for tool_call/tool_result matching;
|
||||||
|
// during streaming they use 'id'. Both fields are normalized here.
|
||||||
const processItems = computed(() => {
|
const processItems = computed(() => {
|
||||||
const items = []
|
const items = []
|
||||||
|
|
||||||
// 优先使用 processSteps(按顺序)
|
// 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) {
|
if (props.processSteps && props.processSteps.length > 0) {
|
||||||
for (const step of props.processSteps) {
|
for (const step of props.processSteps) {
|
||||||
if (!step) continue
|
if (!step) continue
|
||||||
|
|
||||||
if (step.type === 'thinking') {
|
if (step.type === 'thinking') {
|
||||||
items.push({ type: 'thinking', content: step.content, summary: truncate(step.content), key: `thinking-${step.index}` })
|
items.push({
|
||||||
|
type: 'thinking',
|
||||||
|
content: step.content,
|
||||||
|
summary: truncate(step.content),
|
||||||
|
key: step.id || `thinking-${step.index}`,
|
||||||
|
})
|
||||||
} else if (step.type === 'tool_call') {
|
} else if (step.type === 'tool_call') {
|
||||||
|
// Normalize: DB-loaded steps use 'id_ref', streaming steps use 'id'
|
||||||
|
const toolId = step.id_ref || step.id
|
||||||
items.push({
|
items.push({
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
toolName: step.name || '未知工具',
|
toolName: step.name || '未知工具',
|
||||||
arguments: formatJson(step.arguments),
|
arguments: formatJson(step.arguments),
|
||||||
summary: truncate(step.arguments),
|
summary: truncate(step.arguments),
|
||||||
id: step.id,
|
id: toolId,
|
||||||
key: `tool_call-${step.id || step.index}`,
|
key: step.id || `tool_call-${toolId || step.index}`,
|
||||||
loading: false,
|
loading: false,
|
||||||
result: null,
|
result: null,
|
||||||
})
|
})
|
||||||
} else if (step.type === 'tool_result') {
|
} else if (step.type === 'tool_result') {
|
||||||
|
// Merge result back into its corresponding tool_call item by matching tool ID
|
||||||
|
const toolId = step.id_ref || step.id
|
||||||
const summary = getResultSummary(step.content)
|
const summary = getResultSummary(step.content)
|
||||||
const match = items.findLast(it => it.type === 'tool_call' && it.id === step.id)
|
const match = items.findLast(it => it.type === 'tool_call' && it.id === toolId)
|
||||||
if (match) {
|
if (match) {
|
||||||
match.result = formatJson(step.content)
|
match.result = formatJson(step.content)
|
||||||
match.resultSummary = summary.text
|
match.resultSummary = summary.text
|
||||||
|
|
@ -145,12 +159,12 @@ const processItems = computed(() => {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
content: step.content,
|
content: step.content,
|
||||||
rendered: renderMarkdown(step.content),
|
rendered: renderMarkdown(step.content),
|
||||||
key: `text-${step.index}`,
|
key: step.id || `text-${step.index}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流式中最后一个 tool_call(尚无结果时)标记为 loading
|
// Mark the last tool_call as loading if it has no result yet (still executing)
|
||||||
if (props.streaming && items.length > 0) {
|
if (props.streaming && items.length > 0) {
|
||||||
const last = items[items.length - 1]
|
const last = items[items.length - 1]
|
||||||
if (last.type === 'tool_call' && !last.result) {
|
if (last.type === 'tool_call' && !last.result) {
|
||||||
|
|
@ -158,10 +172,9 @@ const processItems = computed(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流式中追加正在增长的文本(仅当还没有 text 类型的步骤时)
|
// Append the currently streaming text as a live text item.
|
||||||
|
// This text belongs to the latest LLM iteration that hasn't finished yet.
|
||||||
if (props.streaming && props.streamingContent) {
|
if (props.streaming && props.streamingContent) {
|
||||||
const hasTextStep = items.some(it => it.type === 'text')
|
|
||||||
if (!hasTextStep) {
|
|
||||||
items.push({
|
items.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
content: props.streamingContent,
|
content: props.streamingContent,
|
||||||
|
|
@ -169,15 +182,8 @@ const processItems = computed(() => {
|
||||||
key: 'text-streaming',
|
key: 'text-streaming',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 回退逻辑:旧版 thinking + toolCalls
|
// Fallback: legacy mode for old messages without processSteps stored in DB
|
||||||
if (props.thinkingContent) {
|
|
||||||
items.push({ type: 'thinking', content: props.thinkingContent, summary: truncate(props.thinkingContent), key: 'thinking-0' })
|
|
||||||
} else if (props.streaming && items.length === 0) {
|
|
||||||
items.push({ type: 'thinking', content: '', key: 'thinking-loading' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.toolCalls && props.toolCalls.length > 0) {
|
if (props.toolCalls && props.toolCalls.length > 0) {
|
||||||
props.toolCalls.forEach((call, i) => {
|
props.toolCalls.forEach((call, i) => {
|
||||||
const toolName = call.function?.name || '未知工具'
|
const toolName = call.function?.name || '未知工具'
|
||||||
|
|
@ -197,7 +203,7 @@ const processItems = computed(() => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 旧模式下追加流式文本
|
// Append streaming text in legacy mode
|
||||||
if (props.streaming && props.streamingContent) {
|
if (props.streaming && props.streamingContent) {
|
||||||
items.push({
|
items.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|
@ -211,10 +217,10 @@ const processItems = computed(() => {
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
// 增强 processBlock 内代码块
|
// Enhance code blocks inside process items (syntax highlighting, copy buttons)
|
||||||
const { debouncedEnhance } = useCodeEnhancement(processRef, processItems, { deep: true })
|
const { debouncedEnhance } = useCodeEnhancement(processRef, processItems, { deep: true })
|
||||||
|
|
||||||
// 流式时使用节流的代码块增强,减少 DOM 操作
|
// Throttle code enhancement during streaming to reduce DOM operations
|
||||||
watch(() => props.streamingContent?.length, () => {
|
watch(() => props.streamingContent?.length, () => {
|
||||||
if (props.streaming) debouncedEnhance()
|
if (props.streaming) debouncedEnhance()
|
||||||
})
|
})
|
||||||
|
|
@ -225,7 +231,7 @@ watch(() => props.streamingContent?.length, () => {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 流式占位 */
|
/* Streaming placeholder while waiting for first step */
|
||||||
.streaming-placeholder {
|
.streaming-placeholder {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -263,7 +269,7 @@ watch(() => props.streamingContent?.length, () => {
|
||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 步骤通用 */
|
/* Step items (shared) */
|
||||||
.step-item {
|
.step-item {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
@ -272,7 +278,7 @@ watch(() => props.streamingContent?.length, () => {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 思考过程 */
|
/* Thinking and tool call step headers */
|
||||||
.thinking .step-header,
|
.thinking .step-header,
|
||||||
.tool_call .step-header {
|
.tool_call .step-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -361,7 +367,7 @@ watch(() => props.streamingContent?.length, () => {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 步骤展开内容 */
|
/* Expandable step content panel */
|
||||||
.step-content {
|
.step-content {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|
@ -404,7 +410,7 @@ watch(() => props.streamingContent?.length, () => {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 文本内容直接渲染 */
|
/* Text content — rendered as markdown */
|
||||||
.text-content {
|
.text-content {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
|
@ -418,7 +424,7 @@ watch(() => props.streamingContent?.length, () => {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 流式指示器 */
|
/* Streaming cursor indicator */
|
||||||
.streaming-indicator {
|
.streaming-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,16 @@
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-group-action btn-delete-project"
|
||||||
|
title="删除项目"
|
||||||
|
@click.stop="$emit('deleteProject', { id: group.id, name: group.name })"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="expandedGroups[group.id]">
|
<div v-show="expandedGroups[group.id]">
|
||||||
<div
|
<div
|
||||||
|
|
@ -60,8 +70,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Standalone conversations -->
|
<!-- Standalone conversations (always visible) -->
|
||||||
<div v-if="groupedData.standalone.length > 0" class="project-group">
|
<div class="project-group">
|
||||||
<div class="project-header" @click="toggleGroup('__standalone__')">
|
<div class="project-header" @click="toggleGroup('__standalone__')">
|
||||||
<svg class="chevron" :class="{ collapsed: !expandedGroups['__standalone__'] }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="chevron" :class="{ collapsed: !expandedGroups['__standalone__'] }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<polyline points="6 9 12 15 18 9"/>
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
|
@ -131,12 +141,13 @@ import { formatTime } from '../utils/format'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversations: { type: Array, required: true },
|
conversations: { type: Array, required: true },
|
||||||
|
projects: { type: Array, default: () => [] },
|
||||||
currentId: { type: String, default: null },
|
currentId: { type: String, default: null },
|
||||||
loading: { type: Boolean, default: false },
|
loading: { type: Boolean, default: false },
|
||||||
hasMore: { type: Boolean, default: false },
|
hasMore: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'delete', 'loadMore', 'createProject', 'browseProject', 'createInProject', 'toggleSettings', 'toggleStats'])
|
const emit = defineEmits(['select', 'delete', 'loadMore', 'createProject', 'browseProject', 'createInProject', 'toggleSettings', 'toggleStats', 'deleteProject'])
|
||||||
|
|
||||||
const expandedGroups = reactive({})
|
const expandedGroups = reactive({})
|
||||||
|
|
||||||
|
|
@ -144,6 +155,16 @@ const groupedData = computed(() => {
|
||||||
const groups = {}
|
const groups = {}
|
||||||
const standalone = []
|
const standalone = []
|
||||||
|
|
||||||
|
// First, initialize groups from projects list (includes projects with 0 conversations)
|
||||||
|
for (const p of props.projects) {
|
||||||
|
groups[p.id] = {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
conversations: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then merge conversations into groups
|
||||||
for (const conv of props.conversations) {
|
for (const conv of props.conversations) {
|
||||||
if (conv.project_id) {
|
if (conv.project_id) {
|
||||||
if (!groups[conv.project_id]) {
|
if (!groups[conv.project_id]) {
|
||||||
|
|
@ -162,7 +183,7 @@ const groupedData = computed(() => {
|
||||||
for (const id of Object.keys(groups)) {
|
for (const id of Object.keys(groups)) {
|
||||||
if (!(id in expandedGroups)) expandedGroups[id] = true
|
if (!(id in expandedGroups)) expandedGroups[id] = true
|
||||||
}
|
}
|
||||||
if (standalone.length > 0 && !('__standalone__' in expandedGroups)) {
|
if (!('__standalone__' in expandedGroups)) {
|
||||||
expandedGroups['__standalone__'] = true
|
expandedGroups['__standalone__'] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,6 +341,11 @@ function onScroll(e) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-delete-project:hover {
|
||||||
|
color: var(--danger-color);
|
||||||
|
background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.conversation-item {
|
.conversation-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue