feat: 增加工具调用界面
This commit is contained in:
parent
e77fd71aa7
commit
9a0a55e392
|
|
@ -2,6 +2,7 @@ import os
|
||||||
import yaml
|
import yaml
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_cors import CORS
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Initialize db BEFORE importing models/routes that depend on it
|
# Initialize db BEFORE importing models/routes that depend on it
|
||||||
|
|
@ -25,6 +26,9 @@ def create_app():
|
||||||
)
|
)
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
|
||||||
|
# Enable CORS for all routes
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
|
|
||||||
# Import after db is initialized
|
# Import after db is initialized
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,16 @@ class Message(db.Model):
|
||||||
|
|
||||||
id = db.Column(db.String(64), primary_key=True)
|
id = db.Column(db.String(64), primary_key=True)
|
||||||
conversation_id = db.Column(db.String(64), db.ForeignKey("conversations.id"), nullable=False)
|
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="")
|
content = db.Column(db.Text, default="")
|
||||||
token_count = db.Column(db.Integer, default=0)
|
token_count = db.Column(db.Integer, default=0)
|
||||||
thinking_content = db.Column(db.Text, default="")
|
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)
|
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"):
|
for k in ("created_at", "updated_at"):
|
||||||
if k in d and hasattr(d[k], "strftime"):
|
if k in d and hasattr(d[k], "strftime"):
|
||||||
d[k] = d[k].strftime("%Y-%m-%dT%H:%M:%SZ")
|
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)
|
d.update(extra)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
@ -296,10 +307,12 @@ def message_list(conv_id):
|
||||||
db.session.add(user_msg)
|
db.session.add(user_msg)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
if d.get("stream", False):
|
tools_enabled = d.get("tools_enabled", True)
|
||||||
return _stream_response(conv)
|
|
||||||
|
|
||||||
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"])
|
@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"""
|
"""Sync response with tool call support"""
|
||||||
executor = ToolExecutor(registry=registry)
|
executor = ToolExecutor(registry=registry)
|
||||||
tools = registry.list_all()
|
tools = registry.list_all() if tools_enabled else None
|
||||||
messages = build_glm_messages(conv)
|
messages = build_glm_messages(conv)
|
||||||
max_iterations = 5 # Max tool call iterations
|
max_iterations = 5 # Max tool call iterations
|
||||||
|
|
||||||
|
# Collect all tool calls and results
|
||||||
|
all_tool_calls = []
|
||||||
|
all_tool_results = []
|
||||||
|
|
||||||
for _ in range(max_iterations):
|
for _ in range(max_iterations):
|
||||||
try:
|
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()
|
resp.raise_for_status()
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -363,11 +380,23 @@ def _sync_response(conv):
|
||||||
prompt_tokens = usage.get("prompt_tokens", 0)
|
prompt_tokens = usage.get("prompt_tokens", 0)
|
||||||
completion_tokens = usage.get("completion_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(
|
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", ""),
|
content=message.get("content", ""),
|
||||||
token_count=completion_tokens,
|
token_count=completion_tokens,
|
||||||
thinking_content=message.get("reasoning_content", ""),
|
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.add(msg)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
@ -386,33 +415,24 @@ def _sync_response(conv):
|
||||||
|
|
||||||
# Process tool calls
|
# Process tool calls
|
||||||
tool_calls = message["tool_calls"]
|
tool_calls = message["tool_calls"]
|
||||||
|
all_tool_calls.extend(tool_calls)
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
|
|
||||||
# Execute tools and add results
|
# Execute tools and add results
|
||||||
tool_results = executor.process_tool_calls(tool_calls)
|
tool_results = executor.process_tool_calls(tool_calls)
|
||||||
|
all_tool_results.extend(tool_results)
|
||||||
messages.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")
|
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"""
|
"""Stream response with tool call support"""
|
||||||
conv_id = conv.id
|
conv_id = conv.id
|
||||||
conv_model = conv.model
|
conv_model = conv.model
|
||||||
app = current_app._get_current_object()
|
app = current_app._get_current_object()
|
||||||
executor = ToolExecutor(registry=registry)
|
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)
|
# Build messages BEFORE entering generator (in request context)
|
||||||
initial_messages = build_glm_messages(conv)
|
initial_messages = build_glm_messages(conv)
|
||||||
|
|
||||||
|
|
@ -420,6 +440,14 @@ def _stream_response(conv):
|
||||||
messages = list(initial_messages) # Copy to avoid mutation
|
messages = list(initial_messages) # Copy to avoid mutation
|
||||||
max_iterations = 5
|
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):
|
for iteration in range(max_iterations):
|
||||||
full_content = ""
|
full_content = ""
|
||||||
full_thinking = ""
|
full_thinking = ""
|
||||||
|
|
@ -432,7 +460,7 @@ def _stream_response(conv):
|
||||||
try:
|
try:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
active_conv = db.session.get(Conversation, conv_id)
|
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()
|
resp.raise_for_status()
|
||||||
|
|
||||||
for line in resp.iter_lines():
|
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 exist, execute and continue loop
|
||||||
if tool_calls_list:
|
if tool_calls_list:
|
||||||
|
# Collect tool calls
|
||||||
|
all_tool_calls.extend(tool_calls_list)
|
||||||
|
|
||||||
# Send tool call info
|
# Send tool call info
|
||||||
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"
|
||||||
|
|
||||||
|
|
@ -504,25 +535,47 @@ def _stream_response(conv):
|
||||||
})
|
})
|
||||||
messages.extend(tool_results)
|
messages.extend(tool_results)
|
||||||
|
|
||||||
|
# Collect tool results
|
||||||
|
all_tool_results.extend(tool_results)
|
||||||
|
|
||||||
# Send tool results
|
# Send tool results
|
||||||
for tr in tool_results:
|
for tr in tool_results:
|
||||||
yield f"event: tool_result\ndata: {json.dumps({'name': tr['name'], 'content': tr['content']}, ensure_ascii=False)}\n\n"
|
yield f"event: tool_result\ndata: {json.dumps({'name': tr['name'], 'content': tr['content']}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
continue
|
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():
|
with app.app_context():
|
||||||
|
# Save assistant message with all tool calls (including results)
|
||||||
msg = Message(
|
msg = Message(
|
||||||
id=msg_id, conversation_id=conv_id, role="assistant",
|
id=msg_id,
|
||||||
content=full_content, token_count=token_count, thinking_content=full_thinking,
|
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.add(msg)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
user = get_or_create_default_user()
|
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
|
return
|
||||||
|
|
||||||
yield f"event: error\ndata: {json.dumps({'content': 'exceeded maximum tool call iterations'}, ensure_ascii=False)}\n\n"
|
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
|
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
|
# 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` 流式) |
|
| `POST` | `/api/conversations/:id/messages` | 发送消息(对话补全,支持 `stream` 流式) |
|
||||||
| `DELETE` | `/api/conversations/:id/messages/:message_id` | 删除消息 |
|
| `DELETE` | `/api/conversations/:id/messages/:message_id` | 删除消息 |
|
||||||
|
|
||||||
|
### 模型与工具
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| ------ | ------------- | -------- |
|
||||||
|
| `GET` | `/api/models` | 获取模型列表 |
|
||||||
|
| `GET` | `/api/tools` | 获取工具列表 |
|
||||||
|
|
||||||
|
### 统计信息
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| ------ | -------------------- | ---------------- |
|
||||||
|
| `GET` | `/api/stats/tokens` | 获取 Token 使用统计 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API 接口
|
## API 接口
|
||||||
|
|
@ -219,6 +232,8 @@ POST /api/conversations/:id/messages
|
||||||
|
|
||||||
**流式响应 (stream=true):**
|
**流式响应 (stream=true):**
|
||||||
|
|
||||||
|
**普通回复示例:**
|
||||||
|
|
||||||
```
|
```
|
||||||
HTTP/1.1 200 OK
|
HTTP/1.1 200 OK
|
||||||
Content-Type: text/event-stream
|
Content-Type: text/event-stream
|
||||||
|
|
@ -239,6 +254,37 @@ event: done
|
||||||
data: {"message_id": "msg_003", "token_count": 200}
|
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):**
|
**非流式响应 (stream=false):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -280,18 +326,162 @@ DELETE /api/conversations/:id/messages/:message_id
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. SSE 事件说明
|
### 3. 模型与工具
|
||||||
|
|
||||||
| 事件 | 说明 |
|
#### 获取模型列表
|
||||||
| ---------- | ------------------------------- |
|
|
||||||
| `thinking` | 思维链增量内容(启用时) |
|
```
|
||||||
| `message` | 回复内容的增量片段 |
|
GET /api/models
|
||||||
| `error` | 错误信息 |
|
```
|
||||||
| `done` | 回复结束,携带完整 message_id 和 token 统计 |
|
|
||||||
|
**响应:**
|
||||||
|
|
||||||
|
```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 | 说明 |
|
| code | 说明 |
|
||||||
| ----- | -------- |
|
| ----- | -------- |
|
||||||
|
|
@ -344,8 +534,81 @@ User 1 ── * Conversation 1 ── * Message
|
||||||
| ------------------ | ------------- | ------------------------------- |
|
| ------------------ | ------------- | ------------------------------- |
|
||||||
| `id` | string (UUID) | 消息 ID |
|
| `id` | string (UUID) | 消息 ID |
|
||||||
| `conversation_id` | string | 所属会话 ID |
|
| `conversation_id` | string | 所属会话 ID |
|
||||||
| `role` | enum | `user` / `assistant` / `system` |
|
| `role` | enum | `user` / `assistant` / `system` / `tool` |
|
||||||
| `content` | string | 消息内容 |
|
| `content` | string | 消息内容 |
|
||||||
| `token_count` | integer | token 消耗数 |
|
| `token_count` | integer | token 消耗数 |
|
||||||
| `thinking_content` | string | 思维链内容(启用时) |
|
| `thinking_content` | string | 思维链内容(启用时) |
|
||||||
|
| `tool_calls` | string (JSON) | 工具调用请求(assistant 消息) |
|
||||||
|
| `tool_call_id` | string | 工具调用 ID(tool 消息) |
|
||||||
|
| `name` | string | 工具名称(tool 消息) |
|
||||||
| `created_at` | datetime | 创建时间 |
|
| `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="streaming"
|
||||||
:streaming-content="streamContent"
|
:streaming-content="streamContent"
|
||||||
:streaming-thinking="streamThinking"
|
:streaming-thinking="streamThinking"
|
||||||
|
:streaming-tool-calls="streamToolCalls"
|
||||||
:has-more-messages="hasMoreMessages"
|
:has-more-messages="hasMoreMessages"
|
||||||
:loading-more="loadingMessages"
|
:loading-more="loadingMessages"
|
||||||
|
:tools-enabled="toolsEnabled"
|
||||||
@send-message="sendMessage"
|
@send-message="sendMessage"
|
||||||
@delete-message="deleteMessage"
|
@delete-message="deleteMessage"
|
||||||
@toggle-settings="showSettings = true"
|
@toggle-settings="showSettings = true"
|
||||||
@load-more-messages="loadMoreMessages"
|
@load-more-messages="loadMoreMessages"
|
||||||
|
@toggle-tools="updateToolsEnabled"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
|
|
@ -61,9 +64,11 @@ const nextMsgCursor = ref(null)
|
||||||
const streaming = ref(false)
|
const streaming = ref(false)
|
||||||
const streamContent = ref('')
|
const streamContent = ref('')
|
||||||
const streamThinking = ref('')
|
const streamThinking = ref('')
|
||||||
|
const streamToolCalls = ref([])
|
||||||
|
|
||||||
// -- UI state --
|
// -- UI state --
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启
|
||||||
|
|
||||||
const currentConv = computed(() =>
|
const currentConv = computed(() =>
|
||||||
conversations.value.find(c => c.id === currentConvId.value) || null
|
conversations.value.find(c => c.id === currentConvId.value) || null
|
||||||
|
|
@ -122,9 +127,10 @@ async function loadMessages(reset = true) {
|
||||||
try {
|
try {
|
||||||
const res = await messageApi.list(currentConvId.value, reset ? null : nextMsgCursor.value)
|
const res = await messageApi.list(currentConvId.value, reset ? null : nextMsgCursor.value)
|
||||||
if (reset) {
|
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 {
|
} 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
|
nextMsgCursor.value = res.data.next_cursor
|
||||||
hasMoreMessages.value = res.data.has_more
|
hasMoreMessages.value = res.data.has_more
|
||||||
|
|
@ -158,15 +164,34 @@ async function sendMessage(content) {
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
streamContent.value = ''
|
streamContent.value = ''
|
||||||
streamThinking.value = ''
|
streamThinking.value = ''
|
||||||
|
streamToolCalls.value = []
|
||||||
|
|
||||||
await messageApi.send(currentConvId.value, content, {
|
await messageApi.send(currentConvId.value, content, {
|
||||||
stream: true,
|
stream: true,
|
||||||
|
toolsEnabled: toolsEnabled.value,
|
||||||
onThinking(text) {
|
onThinking(text) {
|
||||||
streamThinking.value += text
|
streamThinking.value += text
|
||||||
},
|
},
|
||||||
onMessage(text) {
|
onMessage(text) {
|
||||||
streamContent.value += 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) {
|
async onDone(data) {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
// Replace temp message and add assistant message from server
|
// Replace temp message and add assistant message from server
|
||||||
|
|
@ -178,6 +203,7 @@ async function sendMessage(content) {
|
||||||
content: streamContent.value,
|
content: streamContent.value,
|
||||||
token_count: data.token_count,
|
token_count: data.token_count,
|
||||||
thinking_content: streamThinking.value || null,
|
thinking_content: streamThinking.value || null,
|
||||||
|
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
streamContent.value = ''
|
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 --
|
// -- Init --
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadConversations()
|
loadConversations()
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,11 @@ export const messageApi = {
|
||||||
return request(`/conversations/${convId}/messages?${params}`)
|
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) {
|
if (!stream) {
|
||||||
return request(`/conversations/${convId}/messages`, {
|
return request(`/conversations/${convId}/messages`, {
|
||||||
method: 'POST',
|
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`, {
|
const res = await fetch(`${BASE}/conversations/${convId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content, stream: true }),
|
body: JSON.stringify({ content, stream: true, tools_enabled: toolsEnabled }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -110,6 +110,10 @@ export const messageApi = {
|
||||||
onThinking(data.content)
|
onThinking(data.content)
|
||||||
} else if (currentEvent === 'message' && onMessage) {
|
} else if (currentEvent === 'message' && onMessage) {
|
||||||
onMessage(data.content)
|
onMessage(data.content)
|
||||||
|
} else if (currentEvent === 'tool_calls' && onToolCalls) {
|
||||||
|
onToolCalls(data.calls)
|
||||||
|
} else if (currentEvent === 'tool_result' && onToolResult) {
|
||||||
|
onToolResult(data)
|
||||||
} else if (currentEvent === 'done' && onDone) {
|
} else if (currentEvent === 'done' && onDone) {
|
||||||
onDone(data)
|
onDone(data)
|
||||||
} else if (currentEvent === 'error' && onError) {
|
} else if (currentEvent === 'error' && onError) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@
|
||||||
:role="msg.role"
|
:role="msg.role"
|
||||||
:content="msg.content"
|
:content="msg.content"
|
||||||
:thinking-content="msg.thinking_content"
|
:thinking-content="msg.thinking_content"
|
||||||
|
:tool-calls="msg.tool_calls"
|
||||||
|
:tool-name="msg.name"
|
||||||
:token-count="msg.token_count"
|
:token-count="msg.token_count"
|
||||||
:created-at="msg.created_at"
|
:created-at="msg.created_at"
|
||||||
:deletable="msg.role === 'user'"
|
:deletable="msg.role === 'user'"
|
||||||
|
|
@ -46,9 +48,11 @@
|
||||||
<div v-if="streaming" class="message-bubble assistant">
|
<div v-if="streaming" class="message-bubble assistant">
|
||||||
<div class="avatar">claw</div>
|
<div class="avatar">claw</div>
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<div v-if="streamingThinking" class="thinking-content streaming-thinking">
|
<ProcessBlock
|
||||||
{{ streamingThinking }}
|
:thinking-content="streamingThinking"
|
||||||
</div>
|
:tool-calls="streamingToolCalls"
|
||||||
|
:streaming="streaming"
|
||||||
|
/>
|
||||||
<div class="message-content streaming-content" v-html="renderedStreamContent || '<span class=\'placeholder\'>...</span>'"></div>
|
<div class="message-content streaming-content" v-html="renderedStreamContent || '<span class=\'placeholder\'>...</span>'"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -58,7 +62,9 @@
|
||||||
<MessageInput
|
<MessageInput
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
:disabled="streaming"
|
:disabled="streaming"
|
||||||
|
:tools-enabled="toolsEnabled"
|
||||||
@send="$emit('sendMessage', $event)"
|
@send="$emit('sendMessage', $event)"
|
||||||
|
@toggle-tools="$emit('toggleTools', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -68,6 +74,7 @@
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import MessageBubble from './MessageBubble.vue'
|
import MessageBubble from './MessageBubble.vue'
|
||||||
import MessageInput from './MessageInput.vue'
|
import MessageInput from './MessageInput.vue'
|
||||||
|
import ProcessBlock from './ProcessBlock.vue'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -76,11 +83,13 @@ const props = defineProps({
|
||||||
streaming: { type: Boolean, default: false },
|
streaming: { type: Boolean, default: false },
|
||||||
streamingContent: { type: String, default: '' },
|
streamingContent: { type: String, default: '' },
|
||||||
streamingThinking: { type: String, default: '' },
|
streamingThinking: { type: String, default: '' },
|
||||||
|
streamingToolCalls: { type: Array, default: () => [] },
|
||||||
hasMoreMessages: { type: Boolean, default: false },
|
hasMoreMessages: { type: Boolean, default: false },
|
||||||
loadingMore: { 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 scrollContainer = ref(null)
|
||||||
const inputRef = ref(null)
|
const inputRef = ref(null)
|
||||||
|
|
@ -296,18 +305,6 @@ defineExpose({ scrollToBottom })
|
||||||
color: white;
|
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 {
|
.streaming-content {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,16 @@
|
||||||
<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-body">
|
<div class="message-body">
|
||||||
<div v-if="thinkingContent" class="thinking-block">
|
<ProcessBlock
|
||||||
<button class="thinking-toggle" @click="showThinking = !showThinking">
|
v-if="thinkingContent || (toolCalls && toolCalls.length > 0)"
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
:thinking-content="thinkingContent"
|
||||||
<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"/>
|
:tool-calls="toolCalls"
|
||||||
</svg>
|
/>
|
||||||
<span>思考过程</span>
|
<div v-if="role === 'tool'" class="tool-result-content">
|
||||||
<svg class="arrow" :class="{ open: showThinking }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<div class="tool-badge">工具返回结果: {{ toolName }}</div>
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
<pre>{{ content }}</pre>
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div v-if="showThinking" class="thinking-content">{{ thinkingContent }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="message-content" v-html="renderedContent"></div>
|
<div v-else class="message-content" v-html="renderedContent"></div>
|
||||||
<div class="message-footer">
|
<div class="message-footer">
|
||||||
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
||||||
<span class="message-time">{{ formatTime(createdAt) }}</span>
|
<span class="message-time">{{ formatTime(createdAt) }}</span>
|
||||||
|
|
@ -39,11 +36,14 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
|
import ProcessBlock from './ProcessBlock.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
role: { type: String, required: true },
|
role: { type: String, required: true },
|
||||||
content: { type: String, default: '' },
|
content: { type: String, default: '' },
|
||||||
thinkingContent: { type: String, default: '' },
|
thinkingContent: { type: String, default: '' },
|
||||||
|
toolCalls: { type: Array, default: () => [] },
|
||||||
|
toolName: { type: String, 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 },
|
||||||
|
|
@ -51,8 +51,6 @@ const props = defineProps({
|
||||||
|
|
||||||
defineEmits(['delete'])
|
defineEmits(['delete'])
|
||||||
|
|
||||||
const showThinking = ref(false)
|
|
||||||
|
|
||||||
const renderedContent = computed(() => {
|
const renderedContent = computed(() => {
|
||||||
if (!props.content) return ''
|
if (!props.content) return ''
|
||||||
return renderMarkdown(props.content)
|
return renderMarkdown(props.content)
|
||||||
|
|
@ -112,52 +110,6 @@ function copyContent() {
|
||||||
min-width: 0;
|
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 {
|
.message-content {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
|
@ -165,6 +117,36 @@ function copyContent() {
|
||||||
word-break: break-word;
|
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) {
|
.message-content :deep(p) {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,17 @@
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="input-actions">
|
<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
|
<button
|
||||||
class="btn-send"
|
class="btn-send"
|
||||||
:class="{ active: text.trim() && !disabled }"
|
:class="{ active: text.trim() && !disabled }"
|
||||||
|
|
@ -33,9 +44,10 @@ import { ref, nextTick } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
|
toolsEnabled: { type: Boolean, default: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['send'])
|
const emit = defineEmits(['send', 'toggleTools'])
|
||||||
const text = ref('')
|
const text = ref('')
|
||||||
const textareaRef = ref(null)
|
const textareaRef = ref(null)
|
||||||
|
|
||||||
|
|
@ -63,6 +75,10 @@ function send() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleTools() {
|
||||||
|
emit('toggleTools', !props.toolsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
function focus() {
|
function focus() {
|
||||||
textareaRef.value?.focus()
|
textareaRef.value?.focus()
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +136,35 @@ textarea:disabled {
|
||||||
margin-left: 8px;
|
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 {
|
.btn-send {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 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 = [
|
dependencies = [
|
||||||
"flask>=3.0",
|
"flask>=3.0",
|
||||||
"flask-sqlalchemy>=3.1",
|
"flask-sqlalchemy>=3.1",
|
||||||
|
"flask-cors>=4.0",
|
||||||
"pymysql>=1.1",
|
"pymysql>=1.1",
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue