feat: 增加工具调用界面
This commit is contained in:
parent
e77fd71aa7
commit
9a0a55e392
|
|
@ -2,6 +2,7 @@ import os
|
|||
import yaml
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_cors import CORS
|
||||
from pathlib import Path
|
||||
|
||||
# Initialize db BEFORE importing models/routes that depend on it
|
||||
|
|
@ -25,6 +26,9 @@ def create_app():
|
|||
)
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
|
||||
# Enable CORS for all routes
|
||||
CORS(app)
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
# Import after db is initialized
|
||||
|
|
|
|||
|
|
@ -39,10 +39,16 @@ class Message(db.Model):
|
|||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
conversation_id = db.Column(db.String(64), db.ForeignKey("conversations.id"), nullable=False)
|
||||
role = db.Column(db.String(16), nullable=False)
|
||||
role = db.Column(db.String(16), nullable=False) # user, assistant, system, tool
|
||||
content = db.Column(db.Text, default="")
|
||||
token_count = db.Column(db.Integer, default=0)
|
||||
thinking_content = db.Column(db.Text, default="")
|
||||
|
||||
# Tool call support
|
||||
tool_calls = db.Column(db.Text) # JSON string: tool call requests (assistant messages)
|
||||
tool_call_id = db.Column(db.String(64)) # Tool call ID (tool messages)
|
||||
name = db.Column(db.String(64)) # Tool name (tool messages)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,17 @@ def to_dict(inst, **extra):
|
|||
for k in ("created_at", "updated_at"):
|
||||
if k in d and hasattr(d[k], "strftime"):
|
||||
d[k] = d[k].strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Parse tool_calls JSON if present
|
||||
if "tool_calls" in d and d["tool_calls"]:
|
||||
try:
|
||||
d["tool_calls"] = json.loads(d["tool_calls"])
|
||||
except:
|
||||
pass
|
||||
|
||||
# Filter out None values for cleaner API response
|
||||
d = {k: v for k, v in d.items() if v is not None}
|
||||
|
||||
d.update(extra)
|
||||
return d
|
||||
|
||||
|
|
@ -296,10 +307,12 @@ def message_list(conv_id):
|
|||
db.session.add(user_msg)
|
||||
db.session.commit()
|
||||
|
||||
if d.get("stream", False):
|
||||
return _stream_response(conv)
|
||||
tools_enabled = d.get("tools_enabled", True)
|
||||
|
||||
return _sync_response(conv)
|
||||
if d.get("stream", False):
|
||||
return _stream_response(conv, tools_enabled)
|
||||
|
||||
return _sync_response(conv, tools_enabled)
|
||||
|
||||
|
||||
@bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"])
|
||||
|
|
@ -339,16 +352,20 @@ def _call_glm(conv, stream=False, tools=None, messages=None):
|
|||
)
|
||||
|
||||
|
||||
def _sync_response(conv):
|
||||
def _sync_response(conv, tools_enabled=True):
|
||||
"""Sync response with tool call support"""
|
||||
executor = ToolExecutor(registry=registry)
|
||||
tools = registry.list_all()
|
||||
tools = registry.list_all() if tools_enabled else None
|
||||
messages = build_glm_messages(conv)
|
||||
max_iterations = 5 # Max tool call iterations
|
||||
|
||||
# Collect all tool calls and results
|
||||
all_tool_calls = []
|
||||
all_tool_results = []
|
||||
|
||||
for _ in range(max_iterations):
|
||||
try:
|
||||
resp = _call_glm(conv, tools=tools if tools else None, messages=messages)
|
||||
resp = _call_glm(conv, tools=tools, messages=messages)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
except Exception as e:
|
||||
|
|
@ -363,11 +380,23 @@ def _sync_response(conv):
|
|||
prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
completion_tokens = usage.get("completion_tokens", 0)
|
||||
|
||||
# Merge tool results into tool_calls
|
||||
merged_tool_calls = []
|
||||
for i, tc in enumerate(all_tool_calls):
|
||||
merged_tc = dict(tc)
|
||||
if i < len(all_tool_results):
|
||||
merged_tc["result"] = all_tool_results[i]["content"]
|
||||
merged_tool_calls.append(merged_tc)
|
||||
|
||||
# Save assistant message with all tool calls (including results)
|
||||
msg = Message(
|
||||
id=str(uuid.uuid4()), conversation_id=conv.id, role="assistant",
|
||||
id=str(uuid.uuid4()),
|
||||
conversation_id=conv.id,
|
||||
role="assistant",
|
||||
content=message.get("content", ""),
|
||||
token_count=completion_tokens,
|
||||
thinking_content=message.get("reasoning_content", ""),
|
||||
tool_calls=json.dumps(merged_tool_calls) if merged_tool_calls else None
|
||||
)
|
||||
db.session.add(msg)
|
||||
db.session.commit()
|
||||
|
|
@ -386,39 +415,38 @@ def _sync_response(conv):
|
|||
|
||||
# Process tool calls
|
||||
tool_calls = message["tool_calls"]
|
||||
all_tool_calls.extend(tool_calls)
|
||||
messages.append(message)
|
||||
|
||||
# Execute tools and add results
|
||||
tool_results = executor.process_tool_calls(tool_calls)
|
||||
all_tool_results.extend(tool_results)
|
||||
messages.extend(tool_results)
|
||||
|
||||
# Save tool call records to database
|
||||
for i, call in enumerate(tool_calls):
|
||||
tool_msg = Message(
|
||||
id=str(uuid.uuid4()),
|
||||
conversation_id=conv.id,
|
||||
role="tool",
|
||||
content=tool_results[i]["content"]
|
||||
)
|
||||
db.session.add(tool_msg)
|
||||
db.session.commit()
|
||||
|
||||
return err(500, "exceeded maximum tool call iterations")
|
||||
|
||||
|
||||
def _stream_response(conv):
|
||||
def _stream_response(conv, tools_enabled=True):
|
||||
"""Stream response with tool call support"""
|
||||
conv_id = conv.id
|
||||
conv_model = conv.model
|
||||
app = current_app._get_current_object()
|
||||
executor = ToolExecutor(registry=registry)
|
||||
tools = registry.list_all()
|
||||
tools = registry.list_all() if tools_enabled else None
|
||||
# Build messages BEFORE entering generator (in request context)
|
||||
initial_messages = build_glm_messages(conv)
|
||||
|
||||
def generate():
|
||||
messages = list(initial_messages) # Copy to avoid mutation
|
||||
max_iterations = 5
|
||||
|
||||
# Collect all tool calls and results
|
||||
all_tool_calls = []
|
||||
all_tool_results = []
|
||||
total_content = ""
|
||||
total_thinking = ""
|
||||
total_tokens = 0
|
||||
total_prompt_tokens = 0
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
full_content = ""
|
||||
|
|
@ -432,7 +460,7 @@ def _stream_response(conv):
|
|||
try:
|
||||
with app.app_context():
|
||||
active_conv = db.session.get(Conversation, conv_id)
|
||||
resp = _call_glm(active_conv, stream=True, tools=tools if tools else None, messages=messages)
|
||||
resp = _call_glm(active_conv, stream=True, tools=tools, messages=messages)
|
||||
resp.raise_for_status()
|
||||
|
||||
for line in resp.iter_lines():
|
||||
|
|
@ -492,6 +520,9 @@ def _stream_response(conv):
|
|||
|
||||
# If tool calls exist, execute and continue loop
|
||||
if tool_calls_list:
|
||||
# Collect tool calls
|
||||
all_tool_calls.extend(tool_calls_list)
|
||||
|
||||
# Send tool call info
|
||||
yield f"event: tool_calls\ndata: {json.dumps({'calls': tool_calls_list}, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
|
@ -503,6 +534,9 @@ def _stream_response(conv):
|
|||
"tool_calls": tool_calls_list
|
||||
})
|
||||
messages.extend(tool_results)
|
||||
|
||||
# Collect tool results
|
||||
all_tool_results.extend(tool_results)
|
||||
|
||||
# Send tool results
|
||||
for tr in tool_results:
|
||||
|
|
@ -510,19 +544,38 @@ def _stream_response(conv):
|
|||
|
||||
continue
|
||||
|
||||
# No tool calls, finish
|
||||
# No tool calls, finish - save everything
|
||||
total_content = full_content
|
||||
total_thinking = full_thinking
|
||||
total_tokens = token_count
|
||||
total_prompt_tokens = prompt_tokens
|
||||
|
||||
# Merge tool results into tool_calls
|
||||
merged_tool_calls = []
|
||||
for i, tc in enumerate(all_tool_calls):
|
||||
merged_tc = dict(tc)
|
||||
if i < len(all_tool_results):
|
||||
merged_tc["result"] = all_tool_results[i]["content"]
|
||||
merged_tool_calls.append(merged_tc)
|
||||
|
||||
with app.app_context():
|
||||
# Save assistant message with all tool calls (including results)
|
||||
msg = Message(
|
||||
id=msg_id, conversation_id=conv_id, role="assistant",
|
||||
content=full_content, token_count=token_count, thinking_content=full_thinking,
|
||||
id=msg_id,
|
||||
conversation_id=conv_id,
|
||||
role="assistant",
|
||||
content=total_content,
|
||||
token_count=total_tokens,
|
||||
thinking_content=total_thinking,
|
||||
tool_calls=json.dumps(merged_tool_calls) if merged_tool_calls else None
|
||||
)
|
||||
db.session.add(msg)
|
||||
db.session.commit()
|
||||
|
||||
user = get_or_create_default_user()
|
||||
record_token_usage(user.id, conv_model, prompt_tokens, token_count)
|
||||
record_token_usage(user.id, conv_model, total_prompt_tokens, total_tokens)
|
||||
|
||||
yield f"event: done\ndata: {json.dumps({'message_id': msg_id, 'token_count': token_count})}\n\n"
|
||||
yield f"event: done\ndata: {json.dumps({'message_id': msg_id, 'token_count': total_tokens})}\n\n"
|
||||
return
|
||||
|
||||
yield f"event: error\ndata: {json.dumps({'content': 'exceeded maximum tool call iterations'}, ensure_ascii=False)}\n\n"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ def init_tools() -> None:
|
|||
|
||||
Importing builtin module automatically registers all decorator-defined tools
|
||||
"""
|
||||
from .builtin import crawler, data # noqa: F401
|
||||
from .builtin import crawler, data, weather # noqa: F401
|
||||
|
||||
|
||||
# Public API exports
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
"""Weather related tools"""
|
||||
from ..factory import tool
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_weather",
|
||||
description="Get weather information for a specified city. Use when user asks about weather.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name, e.g.: 北京, 上海, 广州"
|
||||
}
|
||||
},
|
||||
"required": ["city"]
|
||||
},
|
||||
category="weather"
|
||||
)
|
||||
def get_weather(arguments: dict) -> dict:
|
||||
"""
|
||||
Weather query tool (simulated)
|
||||
|
||||
Args:
|
||||
arguments: {
|
||||
"city": "北京"
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"city": "北京",
|
||||
"temperature": 25,
|
||||
"humidity": 60,
|
||||
"description": "晴天"
|
||||
}
|
||||
"""
|
||||
city = arguments["city"]
|
||||
|
||||
# 模拟天气数据
|
||||
weather_data = {
|
||||
"北京": {"temperature": 25, "humidity": 60, "description": "晴天"},
|
||||
"上海": {"temperature": 28, "humidity": 75, "description": "多云"},
|
||||
"广州": {"temperature": 32, "humidity": 85, "description": "雷阵雨"},
|
||||
"深圳": {"temperature": 30, "humidity": 80, "description": "阴天"},
|
||||
}
|
||||
|
||||
data = weather_data.get(city, {
|
||||
"temperature": 22,
|
||||
"humidity": 65,
|
||||
"description": "晴天"
|
||||
})
|
||||
|
||||
return {
|
||||
"city": city,
|
||||
**data,
|
||||
"query_time": "2026-03-24 12:00:00"
|
||||
}
|
||||
281
docs/Design.md
281
docs/Design.md
|
|
@ -20,6 +20,19 @@
|
|||
| `POST` | `/api/conversations/:id/messages` | 发送消息(对话补全,支持 `stream` 流式) |
|
||||
| `DELETE` | `/api/conversations/:id/messages/:message_id` | 删除消息 |
|
||||
|
||||
### 模型与工具
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ------------- | -------- |
|
||||
| `GET` | `/api/models` | 获取模型列表 |
|
||||
| `GET` | `/api/tools` | 获取工具列表 |
|
||||
|
||||
### 统计信息
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | -------------------- | ---------------- |
|
||||
| `GET` | `/api/stats/tokens` | 获取 Token 使用统计 |
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
|
@ -219,6 +232,8 @@ POST /api/conversations/:id/messages
|
|||
|
||||
**流式响应 (stream=true):**
|
||||
|
||||
**普通回复示例:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/event-stream
|
||||
|
|
@ -239,6 +254,37 @@ event: done
|
|||
data: {"message_id": "msg_003", "token_count": 200}
|
||||
```
|
||||
|
||||
**工具调用示例:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/event-stream
|
||||
|
||||
event: thinking
|
||||
data: {"content": "用户想知道北京天气..."}
|
||||
|
||||
event: tool_calls
|
||||
data: {"calls": [{"id": "call_001", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"北京\"}"}}]}
|
||||
|
||||
event: tool_result
|
||||
data: {"name": "get_weather", "content": "{\"temperature\": 25, \"humidity\": 60, \"description\": \"晴天\"}"}
|
||||
|
||||
event: message
|
||||
data: {"content": "北京"}
|
||||
|
||||
event: message
|
||||
data: {"content": "今天天气晴朗,"}
|
||||
|
||||
event: message
|
||||
data: {"content": "温度25°C,"}
|
||||
|
||||
event: message
|
||||
data: {"content": "湿度60%"}
|
||||
|
||||
event: done
|
||||
data: {"message_id": "msg_003", "token_count": 150}
|
||||
```
|
||||
|
||||
**非流式响应 (stream=false):**
|
||||
|
||||
```json
|
||||
|
|
@ -280,18 +326,162 @@ DELETE /api/conversations/:id/messages/:message_id
|
|||
|
||||
---
|
||||
|
||||
### 3. SSE 事件说明
|
||||
### 3. 模型与工具
|
||||
|
||||
| 事件 | 说明 |
|
||||
| ---------- | ------------------------------- |
|
||||
| `thinking` | 思维链增量内容(启用时) |
|
||||
| `message` | 回复内容的增量片段 |
|
||||
| `error` | 错误信息 |
|
||||
| `done` | 回复结束,携带完整 message_id 和 token 统计 |
|
||||
#### 获取模型列表
|
||||
|
||||
```
|
||||
GET /api/models
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": ["glm-5", "glm-4", "glm-3-turbo"]
|
||||
}
|
||||
```
|
||||
|
||||
#### 获取工具列表
|
||||
|
||||
```
|
||||
GET /api/tools
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "获取指定城市的天气信息",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "城市名称"
|
||||
}
|
||||
},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 错误码
|
||||
### 4. 统计信息
|
||||
|
||||
#### 获取 Token 使用统计
|
||||
|
||||
```
|
||||
GET /api/stats/tokens?period=daily
|
||||
```
|
||||
|
||||
**参数:**
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| -------- | ------ | ------------------------------------- |
|
||||
| `period` | string | 统计周期:`daily`(今日)、`weekly`(近7天)、`monthly`(近30天) |
|
||||
|
||||
**响应(daily):**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"period": "daily",
|
||||
"date": "2026-03-24",
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 2000,
|
||||
"total_tokens": 3000,
|
||||
"by_model": {
|
||||
"glm-5": {
|
||||
"prompt": 500,
|
||||
"completion": 1000,
|
||||
"total": 1500
|
||||
},
|
||||
"glm-4": {
|
||||
"prompt": 500,
|
||||
"completion": 1000,
|
||||
"total": 1500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应(weekly/monthly):**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"period": "weekly",
|
||||
"start_date": "2026-03-18",
|
||||
"end_date": "2026-03-24",
|
||||
"prompt_tokens": 7000,
|
||||
"completion_tokens": 14000,
|
||||
"total_tokens": 21000,
|
||||
"daily": {
|
||||
"2026-03-18": {"prompt": 1000, "completion": 2000, "total": 3000},
|
||||
"2026-03-19": {"prompt": 1000, "completion": 2000, "total": 3000},
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. SSE 事件说明
|
||||
|
||||
| 事件 | 说明 |
|
||||
| ------------- | ---------------------------------------- |
|
||||
| `thinking` | 思维链增量内容(启用时) |
|
||||
| `message` | 回复内容的增量片段 |
|
||||
| `tool_calls` | 工具调用信息,包含工具名称和参数 |
|
||||
| `tool_result` | 工具执行结果,包含工具名称和返回内容 |
|
||||
| `error` | 错误信息 |
|
||||
| `done` | 回复结束,携带完整 message_id 和 token 统计 |
|
||||
|
||||
**tool_calls 事件数据格式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"id": "call_001",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\": \"北京\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**tool_result 事件数据格式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "get_weather",
|
||||
"content": "{\"temperature\": 25, \"humidity\": 60}"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 错误码
|
||||
|
||||
| code | 说明 |
|
||||
| ----- | -------- |
|
||||
|
|
@ -344,8 +534,81 @@ User 1 ── * Conversation 1 ── * Message
|
|||
| ------------------ | ------------- | ------------------------------- |
|
||||
| `id` | string (UUID) | 消息 ID |
|
||||
| `conversation_id` | string | 所属会话 ID |
|
||||
| `role` | enum | `user` / `assistant` / `system` |
|
||||
| `role` | enum | `user` / `assistant` / `system` / `tool` |
|
||||
| `content` | string | 消息内容 |
|
||||
| `token_count` | integer | token 消耗数 |
|
||||
| `thinking_content` | string | 思维链内容(启用时) |
|
||||
| `tool_calls` | string (JSON) | 工具调用请求(assistant 消息) |
|
||||
| `tool_call_id` | string | 工具调用 ID(tool 消息) |
|
||||
| `name` | string | 工具名称(tool 消息) |
|
||||
| `created_at` | datetime | 创建时间 |
|
||||
|
||||
#### 消息类型说明
|
||||
|
||||
**1. 用户消息 (role=user)**
|
||||
```json
|
||||
{
|
||||
"id": "msg_001",
|
||||
"role": "user",
|
||||
"content": "北京今天天气怎么样?",
|
||||
"created_at": "2026-03-24T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**2. 助手消息 - 普通回复 (role=assistant)**
|
||||
```json
|
||||
{
|
||||
"id": "msg_002",
|
||||
"role": "assistant",
|
||||
"content": "北京今天天气晴朗...",
|
||||
"token_count": 50,
|
||||
"thinking_content": "用户想了解天气...",
|
||||
"created_at": "2026-03-24T10:00:01Z"
|
||||
}
|
||||
```
|
||||
|
||||
**3. 助手消息 - 工具调用 (role=assistant, with tool_calls)**
|
||||
```json
|
||||
{
|
||||
"id": "msg_003",
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\": \"北京\"}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"created_at": "2026-03-24T10:00:01Z"
|
||||
}
|
||||
```
|
||||
|
||||
**4. 工具返回消息 (role=tool)**
|
||||
```json
|
||||
{
|
||||
"id": "msg_004",
|
||||
"role": "tool",
|
||||
"content": "{\"temperature\": 25, \"humidity\": 60, \"description\": \"晴天\"}",
|
||||
"tool_call_id": "call_abc123",
|
||||
"name": "get_weather",
|
||||
"created_at": "2026-03-24T10:00:02Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 工具调用流程示例
|
||||
|
||||
```
|
||||
用户: "北京今天天气怎么样?"
|
||||
↓
|
||||
[msg_001] role=user, content="北京今天天气怎么样?"
|
||||
↓
|
||||
[msg_002] role=assistant, tool_calls=[{get_weather, args:{"city":"北京"}}]
|
||||
↓
|
||||
[msg_003] role=tool, name=get_weather, content="{...weather data...}"
|
||||
↓
|
||||
[msg_004] role=assistant, content="北京今天天气晴朗,温度25°C..."
|
||||
```
|
||||
|
|
|
|||
|
|
@ -18,12 +18,15 @@
|
|||
:streaming="streaming"
|
||||
:streaming-content="streamContent"
|
||||
:streaming-thinking="streamThinking"
|
||||
:streaming-tool-calls="streamToolCalls"
|
||||
:has-more-messages="hasMoreMessages"
|
||||
:loading-more="loadingMessages"
|
||||
:tools-enabled="toolsEnabled"
|
||||
@send-message="sendMessage"
|
||||
@delete-message="deleteMessage"
|
||||
@toggle-settings="showSettings = true"
|
||||
@load-more-messages="loadMoreMessages"
|
||||
@toggle-tools="updateToolsEnabled"
|
||||
/>
|
||||
|
||||
<SettingsPanel
|
||||
|
|
@ -61,9 +64,11 @@ const nextMsgCursor = ref(null)
|
|||
const streaming = ref(false)
|
||||
const streamContent = ref('')
|
||||
const streamThinking = ref('')
|
||||
const streamToolCalls = ref([])
|
||||
|
||||
// -- UI state --
|
||||
const showSettings = ref(false)
|
||||
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启
|
||||
|
||||
const currentConv = computed(() =>
|
||||
conversations.value.find(c => c.id === currentConvId.value) || null
|
||||
|
|
@ -122,9 +127,10 @@ async function loadMessages(reset = true) {
|
|||
try {
|
||||
const res = await messageApi.list(currentConvId.value, reset ? null : nextMsgCursor.value)
|
||||
if (reset) {
|
||||
messages.value = res.data.items
|
||||
// Filter out tool messages (they're merged into assistant messages)
|
||||
messages.value = res.data.items.filter(m => m.role !== 'tool')
|
||||
} else {
|
||||
messages.value = [...res.data.items, ...messages.value]
|
||||
messages.value = [...res.data.items.filter(m => m.role !== 'tool'), ...messages.value]
|
||||
}
|
||||
nextMsgCursor.value = res.data.next_cursor
|
||||
hasMoreMessages.value = res.data.has_more
|
||||
|
|
@ -158,15 +164,34 @@ async function sendMessage(content) {
|
|||
streaming.value = true
|
||||
streamContent.value = ''
|
||||
streamThinking.value = ''
|
||||
streamToolCalls.value = []
|
||||
|
||||
await messageApi.send(currentConvId.value, content, {
|
||||
stream: true,
|
||||
toolsEnabled: toolsEnabled.value,
|
||||
onThinking(text) {
|
||||
streamThinking.value += text
|
||||
},
|
||||
onMessage(text) {
|
||||
streamContent.value += text
|
||||
},
|
||||
onToolCalls(calls) {
|
||||
console.log('🔧 Tool calls received:', calls)
|
||||
streamToolCalls.value = calls
|
||||
},
|
||||
onToolResult(result) {
|
||||
console.log('✅ Tool result received:', result)
|
||||
// 更新工具调用结果
|
||||
const call = streamToolCalls.value.find(c => c.function?.name === result.name)
|
||||
if (call) {
|
||||
call.result = result.content
|
||||
} else {
|
||||
// 如果找不到,添加到第一个调用(兜底处理)
|
||||
if (streamToolCalls.value.length > 0) {
|
||||
streamToolCalls.value[0].result = result.content
|
||||
}
|
||||
}
|
||||
},
|
||||
async onDone(data) {
|
||||
streaming.value = false
|
||||
// Replace temp message and add assistant message from server
|
||||
|
|
@ -178,6 +203,7 @@ async function sendMessage(content) {
|
|||
content: streamContent.value,
|
||||
token_count: data.token_count,
|
||||
thinking_content: streamThinking.value || null,
|
||||
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
streamContent.value = ''
|
||||
|
|
@ -251,6 +277,12 @@ async function saveSettings(data) {
|
|||
}
|
||||
}
|
||||
|
||||
// -- Update tools enabled --
|
||||
function updateToolsEnabled(val) {
|
||||
toolsEnabled.value = val
|
||||
localStorage.setItem('tools_enabled', String(val))
|
||||
}
|
||||
|
||||
// -- Init --
|
||||
onMounted(() => {
|
||||
loadConversations()
|
||||
|
|
|
|||
|
|
@ -64,11 +64,11 @@ export const messageApi = {
|
|||
return request(`/conversations/${convId}/messages?${params}`)
|
||||
},
|
||||
|
||||
send(convId, content, { stream = true, onThinking, onMessage, onDone, onError } = {}) {
|
||||
send(convId, content, { stream = true, toolsEnabled = true, onThinking, onMessage, onToolCalls, onToolResult, onDone, onError } = {}) {
|
||||
if (!stream) {
|
||||
return request(`/conversations/${convId}/messages`, {
|
||||
method: 'POST',
|
||||
body: { content, stream: false },
|
||||
body: { content, stream: false, tools_enabled: toolsEnabled },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ export const messageApi = {
|
|||
const res = await fetch(`${BASE}/conversations/${convId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, stream: true }),
|
||||
body: JSON.stringify({ content, stream: true, tools_enabled: toolsEnabled }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
|
|
@ -110,6 +110,10 @@ export const messageApi = {
|
|||
onThinking(data.content)
|
||||
} else if (currentEvent === 'message' && onMessage) {
|
||||
onMessage(data.content)
|
||||
} else if (currentEvent === 'tool_calls' && onToolCalls) {
|
||||
onToolCalls(data.calls)
|
||||
} else if (currentEvent === 'tool_result' && onToolResult) {
|
||||
onToolResult(data)
|
||||
} else if (currentEvent === 'done' && onDone) {
|
||||
onDone(data)
|
||||
} else if (currentEvent === 'error' && onError) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@
|
|||
:role="msg.role"
|
||||
:content="msg.content"
|
||||
:thinking-content="msg.thinking_content"
|
||||
:tool-calls="msg.tool_calls"
|
||||
:tool-name="msg.name"
|
||||
:token-count="msg.token_count"
|
||||
:created-at="msg.created_at"
|
||||
:deletable="msg.role === 'user'"
|
||||
|
|
@ -46,9 +48,11 @@
|
|||
<div v-if="streaming" class="message-bubble assistant">
|
||||
<div class="avatar">claw</div>
|
||||
<div class="message-body">
|
||||
<div v-if="streamingThinking" class="thinking-content streaming-thinking">
|
||||
{{ streamingThinking }}
|
||||
</div>
|
||||
<ProcessBlock
|
||||
:thinking-content="streamingThinking"
|
||||
:tool-calls="streamingToolCalls"
|
||||
:streaming="streaming"
|
||||
/>
|
||||
<div class="message-content streaming-content" v-html="renderedStreamContent || '<span class=\'placeholder\'>...</span>'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -58,7 +62,9 @@
|
|||
<MessageInput
|
||||
ref="inputRef"
|
||||
:disabled="streaming"
|
||||
:tools-enabled="toolsEnabled"
|
||||
@send="$emit('sendMessage', $event)"
|
||||
@toggle-tools="$emit('toggleTools', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -68,6 +74,7 @@
|
|||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import MessageInput from './MessageInput.vue'
|
||||
import ProcessBlock from './ProcessBlock.vue'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -76,11 +83,13 @@ const props = defineProps({
|
|||
streaming: { type: Boolean, default: false },
|
||||
streamingContent: { type: String, default: '' },
|
||||
streamingThinking: { type: String, default: '' },
|
||||
streamingToolCalls: { type: Array, default: () => [] },
|
||||
hasMoreMessages: { type: Boolean, default: false },
|
||||
loadingMore: { type: Boolean, default: false },
|
||||
toolsEnabled: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
defineEmits(['sendMessage', 'deleteMessage', 'toggleSettings', 'loadMoreMessages'])
|
||||
defineEmits(['sendMessage', 'deleteMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools'])
|
||||
|
||||
const scrollContainer = ref(null)
|
||||
const inputRef = ref(null)
|
||||
|
|
@ -296,18 +305,6 @@ defineExpose({ scrollToBottom })
|
|||
color: white;
|
||||
}
|
||||
|
||||
.streaming-thinking {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
padding: 12px;
|
||||
background: var(--bg-thinking);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.streaming-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
|
|
|
|||
|
|
@ -3,19 +3,16 @@
|
|||
<div v-if="role === 'user'" class="avatar">user</div>
|
||||
<div v-else class="avatar">claw</div>
|
||||
<div class="message-body">
|
||||
<div v-if="thinkingContent" class="thinking-block">
|
||||
<button class="thinking-toggle" @click="showThinking = !showThinking">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||
</svg>
|
||||
<span>思考过程</span>
|
||||
<svg class="arrow" :class="{ open: showThinking }" 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>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showThinking" class="thinking-content">{{ thinkingContent }}</div>
|
||||
<ProcessBlock
|
||||
v-if="thinkingContent || (toolCalls && toolCalls.length > 0)"
|
||||
:thinking-content="thinkingContent"
|
||||
:tool-calls="toolCalls"
|
||||
/>
|
||||
<div v-if="role === 'tool'" class="tool-result-content">
|
||||
<div class="tool-badge">工具返回结果: {{ toolName }}</div>
|
||||
<pre>{{ content }}</pre>
|
||||
</div>
|
||||
<div class="message-content" v-html="renderedContent"></div>
|
||||
<div v-else class="message-content" v-html="renderedContent"></div>
|
||||
<div class="message-footer">
|
||||
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
||||
<span class="message-time">{{ formatTime(createdAt) }}</span>
|
||||
|
|
@ -39,11 +36,14 @@
|
|||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
import ProcessBlock from './ProcessBlock.vue'
|
||||
|
||||
const props = defineProps({
|
||||
role: { type: String, required: true },
|
||||
content: { type: String, default: '' },
|
||||
thinkingContent: { type: String, default: '' },
|
||||
toolCalls: { type: Array, default: () => [] },
|
||||
toolName: { type: String, default: '' },
|
||||
tokenCount: { type: Number, default: 0 },
|
||||
createdAt: { type: String, default: '' },
|
||||
deletable: { type: Boolean, default: false },
|
||||
|
|
@ -51,8 +51,6 @@ const props = defineProps({
|
|||
|
||||
defineEmits(['delete'])
|
||||
|
||||
const showThinking = ref(false)
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!props.content) return ''
|
||||
return renderMarkdown(props.content)
|
||||
|
|
@ -112,52 +110,6 @@ function copyContent() {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.thinking-block {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thinking-toggle {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.thinking-toggle:hover {
|
||||
background: var(--bg-code);
|
||||
}
|
||||
|
||||
.thinking-toggle .arrow {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.thinking-toggle .arrow.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-secondary);
|
||||
white-space: pre-wrap;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
|
|
@ -165,6 +117,36 @@ function copyContent() {
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-result-content {
|
||||
background: var(--bg-code);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-badge {
|
||||
font-size: 11px;
|
||||
color: var(--success-color);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--success-bg);
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tool-result-content pre {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-content :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,17 @@
|
|||
:disabled="disabled"
|
||||
></textarea>
|
||||
<div class="input-actions">
|
||||
<button
|
||||
class="btn-tool"
|
||||
:class="{ active: toolsEnabled }"
|
||||
:disabled="disabled"
|
||||
@click="toggleTools"
|
||||
:title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-send"
|
||||
:class="{ active: text.trim() && !disabled }"
|
||||
|
|
@ -33,9 +44,10 @@ import { ref, nextTick } from 'vue'
|
|||
|
||||
const props = defineProps({
|
||||
disabled: { type: Boolean, default: false },
|
||||
toolsEnabled: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['send'])
|
||||
const emit = defineEmits(['send', 'toggleTools'])
|
||||
const text = ref('')
|
||||
const textareaRef = ref(null)
|
||||
|
||||
|
|
@ -63,6 +75,10 @@ function send() {
|
|||
})
|
||||
}
|
||||
|
||||
function toggleTools() {
|
||||
emit('toggleTools', !props.toolsEnabled)
|
||||
}
|
||||
|
||||
function focus() {
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
|
@ -120,6 +136,35 @@ textarea:disabled {
|
|||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.btn-tool {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--bg-code);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-tool:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-tool.active {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.btn-tool:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
<template>
|
||||
<div class="process-block">
|
||||
<button class="process-toggle" @click="toggleAll">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||
</svg>
|
||||
<span>思考与工具调用过程</span>
|
||||
<span class="process-count">{{ processItems.length }} 步</span>
|
||||
<svg class="arrow" :class="{ open: allExpanded }" 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>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="allExpanded" class="process-list">
|
||||
<div v-for="(item, index) in processItems" :key="index" class="process-item" :class="item.type">
|
||||
<div class="process-header" @click="toggleItem(item.index)">
|
||||
<div class="process-icon">
|
||||
<svg v-if="item.type === 'thinking'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||
</svg>
|
||||
<svg v-else-if="item.type === 'tool_call'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
<svg v-else-if="item.type === 'tool_result'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 11 12 14 22 4"/>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="process-label">{{ item.label }}</span>
|
||||
<span v-if="item.type === 'tool_result'" class="process-summary" :class="{ success: item.isSuccess, error: !item.isSuccess }">{{ item.summary }}</span>
|
||||
<span class="process-time">{{ item.time }}</span>
|
||||
<svg class="item-arrow" :class="{ open: isItemExpanded(item.index) }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-if="isItemExpanded(item.index)" class="process-content">
|
||||
<div v-if="item.type === 'thinking'" class="thinking-text">{{ item.content }}</div>
|
||||
|
||||
<div v-else-if="item.type === 'tool_call'" class="tool-call-detail">
|
||||
<div class="tool-name">
|
||||
<span class="label">工具名称:</span>
|
||||
<span class="value">{{ item.toolName }}</span>
|
||||
</div>
|
||||
<div v-if="item.arguments" class="tool-args">
|
||||
<span class="label">调用参数:</span>
|
||||
<pre>{{ item.arguments }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === 'tool_result'" class="tool-result-detail">
|
||||
<div class="result-label">返回结果:</div>
|
||||
<pre>{{ item.content }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
thinkingContent: { type: String, default: '' },
|
||||
toolCalls: { type: Array, default: () => [] },
|
||||
streaming: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const allExpanded = ref(false)
|
||||
const itemExpanded = ref({}) // 存储每个项目的展开状态
|
||||
|
||||
const processItems = computed(() => {
|
||||
const items = []
|
||||
let index = 0
|
||||
|
||||
// 添加思考过程
|
||||
if (props.thinkingContent) {
|
||||
items.push({
|
||||
type: 'thinking',
|
||||
label: '思考过程',
|
||||
content: props.thinkingContent,
|
||||
time: '',
|
||||
index: index++
|
||||
})
|
||||
}
|
||||
|
||||
// 添加工具调用
|
||||
if (props.toolCalls && props.toolCalls.length > 0) {
|
||||
props.toolCalls.forEach((call, i) => {
|
||||
// 工具调用
|
||||
items.push({
|
||||
type: 'tool_call',
|
||||
label: `调用工具: ${call.function?.name || '未知工具'}`,
|
||||
toolName: call.function?.name || '未知工具',
|
||||
arguments: formatArgs(call.function?.arguments),
|
||||
index: index++
|
||||
})
|
||||
|
||||
// 工具结果
|
||||
if (call.result) {
|
||||
const resultSummary = getResultSummary(call.result)
|
||||
items.push({
|
||||
type: 'tool_result',
|
||||
label: `工具返回: ${call.function?.name || '未知工具'}`,
|
||||
content: formatResult(call.result),
|
||||
summary: resultSummary.text,
|
||||
isSuccess: resultSummary.success,
|
||||
index: index++
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function isItemExpanded(index) {
|
||||
return itemExpanded.value[index] || false
|
||||
}
|
||||
|
||||
function toggleItem(index) {
|
||||
itemExpanded.value[index] = !isItemExpanded(index)
|
||||
}
|
||||
|
||||
function formatArgs(args) {
|
||||
if (!args) return ''
|
||||
try {
|
||||
const parsed = JSON.parse(args)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
function formatResult(result) {
|
||||
if (typeof result === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(result)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return JSON.stringify(result, null, 2)
|
||||
}
|
||||
|
||||
function getResultSummary(result) {
|
||||
try {
|
||||
const parsed = typeof result === 'string' ? JSON.parse(result) : result
|
||||
if (parsed.success === true) {
|
||||
return { text: '成功', success: true }
|
||||
} else if (parsed.success === false || parsed.error) {
|
||||
return { text: parsed.error || '失败', success: false }
|
||||
} else if (parsed.results) {
|
||||
return { text: `${parsed.results.length} 条结果`, success: true }
|
||||
}
|
||||
return { text: '完成', success: true }
|
||||
} catch {
|
||||
return { text: '完成', success: true }
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
allExpanded.value = !allExpanded.value
|
||||
}
|
||||
|
||||
// 自动展开流式内容(只展开外层面板,不展开内部项目)
|
||||
watch(() => props.streaming, (streaming) => {
|
||||
if (streaming) {
|
||||
allExpanded.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.process-block {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.process-toggle {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.process-toggle:hover {
|
||||
background: var(--bg-code);
|
||||
}
|
||||
|
||||
.process-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: var(--accent-primary-light);
|
||||
color: var(--accent-primary);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
margin-left: 8px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.arrow.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.process-list {
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.process-item {
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.process-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.process-header {
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.15s;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.process-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.process-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.thinking .process-icon {
|
||||
background: #fef3c7;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.tool_call .process-icon {
|
||||
background: #f3e8ff;
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.tool_result .process-icon {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.process-label {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.process-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.process-summary {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.process-summary.success {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.process-summary.error {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.item-arrow.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.process-content {
|
||||
padding: 12px;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.thinking-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tool-call-detail,
|
||||
.tool-result-detail {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-name,
|
||||
.tool-args {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tool-name:last-child,
|
||||
.tool-args:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tool-args pre,
|
||||
.tool-result-detail pre {
|
||||
margin-top: 4px;
|
||||
padding: 8px;
|
||||
background: var(--bg-code);
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,6 +6,7 @@ requires-python = ">=3.10"
|
|||
dependencies = [
|
||||
"flask>=3.0",
|
||||
"flask-sqlalchemy>=3.1",
|
||||
"flask-cors>=4.0",
|
||||
"pymysql>=1.1",
|
||||
"requests>=2.31",
|
||||
"pyyaml>=6.0",
|
||||
|
|
|
|||
Loading…
Reference in New Issue