feat: 增加工具调用界面

This commit is contained in:
ViperEkura 2026-03-24 22:08:34 +08:00
parent e77fd71aa7
commit 9a0a55e392
13 changed files with 931 additions and 119 deletions

View File

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

View File

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

View File

@ -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,33 +415,24 @@ 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)
@ -420,6 +440,14 @@ def _stream_response(conv):
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 = ""
full_thinking = ""
@ -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"
@ -504,25 +535,47 @@ def _stream_response(conv):
})
messages.extend(tool_results)
# Collect tool results
all_tool_results.extend(tool_results)
# Send 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"
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"

View File

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

View File

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

View File

@ -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 | 工具调用 IDtool 消息) |
| `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..."
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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