203 KiB
Fetch工具用于Agent前置开发设计
Session ID: ses_030805bd0ffeWj0QrxmT2CWz6P Created: 8/5/2026, 9:17:56 AM Updated: 8/5/2026, 5:41:39 PM
User
思考和工具的调用都属于助手的回复,不应该只有回答由助手图标,助手的回答只有一个,思考和工具调用和回答在一个面板内
Assistant (Build · GLM-5.2 · 161.9s)
明白了。整个 assistant 回复(思考+工具调用+回答)应该在一个面板内,统一一个助手头像/标识,而不是每个 part 各自一个气泡。
Tool: question
Input:
{
"questions": [
{
"header": "渲染方案",
"options": [
{
"label": "自定义 assistant 面板",
"description": "不用 ChatBubble,自己写一个 assistant 面板组件:左侧助手头像 + 右侧垂直链路(思考/工具调用/文本回答),所有 part 在一个面板内"
},
{
"label": "ChatBubble 包裹 slot",
"description": "用 ChatBubble 做外层头像+容器,通过 slot 把 parts 链路塞进去"
}
],
"question": "我建议自定义一个 assistant 面板:左侧助手头像,右侧是一个卡片容器,容器内按顺序渲染思考块、工具调用块、文本回答块。user 消息保持用 ChatBubble(右侧气泡)。这样助手只有一个面板,内部展示完整链路。你同意吗?"
}
]
}
Output:
User has answered your questions: "我建议自定义一个 assistant 面板:左侧助手头像,右侧是一个卡片容器,容器内按顺序渲染思考块、工具调用块、文本回答块。user 消息保持用 ChatBubble(右侧气泡)。这样助手只有一个面板,内部展示完整链路。你同意吗?"="自定义 assistant 面板". You can now continue with the user's answers in mind.
Assistant (Build · GLM-5.2 · 12.1s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"limit": 70,
"offset": 220
}
Output:
<path>/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue</path>
<type>file</type>
<content>
220: <template v-for="msg in messages" :key="msg.id">
221: <div class="assistant-chain">
222: <!-- Loading placeholder when no parts yet -->
223: <BoChatBubble
224: v-if="msg.role === 'assistant' && isLoading && !msg.parts?.length"
225: role="assistant"
226: content=""
227: :loading="true"
228: />
229:
230: <!-- User message: single text bubble -->
231: <BoChatBubble
232: v-if="msg.role === 'user'"
233: :role="msg.role"
234: :content="msg.content"
235: />
236:
237: <!-- Assistant message: render parts in order -->
238: <template v-for="part in msg.parts" :key="part.id">
239: <!-- Reasoning part -->
240: <div v-if="part.type === 'reasoning'" class="reasoning-part">
241: <div class="reasoning-header" @click="toggleReasoning(part.id)">
242: <Icon name="lucide:brain" class="reasoning-icon" />
243: <span class="reasoning-title">思考</span>
244: <span v-if="part.reasoningLoading" class="reasoning-status">思考中...</span>
245: <Icon
246: :name="collapsedReasoning.has(part.id) ? 'lucide:chevron-right' : 'lucide:chevron-down'"
247: class="reasoning-toggle"
248: />
249: </div>
250: <div v-show="!collapsedReasoning.has(part.id)" class="reasoning-content">
251: {{ part.text }}
252: </div>
253: </div>
254:
255: <!-- Text part -->
256: <BoChatBubble
257: v-else-if="part.type === 'text'"
258: role="assistant"
259: :content="part.text || ''"
260: />
261:
262: <!-- Tool call part -->
263: <div v-else-if="part.type === 'tool-call'" class="tool-call-item">
264: <div class="tool-call-header">
265: <Icon name="lucide:wrench" class="tool-icon" />
266: <span class="tool-name">{{ part.toolName }}</span>
267: <span :class="['tool-state', part.state]">
268: {{ part.state === 'call' ? '调用中...' : '完成' }}
269: </span>
270: </div>
271: <div v-if="part.args" class="tool-args">
272: <span class="tool-label">参数:</span>
273: <code>{{ JSON.stringify(part.args) }}</code>
274: </div>
275: <div v-if="part.result !== undefined" class="tool-result">
276: <span class="tool-label">结果:</span>
277: <pre>{{ typeof part.result === 'string' ? part.result.slice(0, 500) : JSON.stringify(part.result, null, 2)?.slice(0, 500) }}</pre>
278: </div>
279: </div>
280: </template>
281: </div>
282: </template>
283: </div>
284:
285: <div v-if="errorMessage" class="chat-error">
286: <Icon name="lucide:alert-circle" />
287: {{ errorMessage }}
288: </div>
289:
(Showing lines 220-289 of 921. Use offset=290 to continue.)
</content>
Assistant (Build · GLM-5.2 · 44.8s)
重写渲染区域:user 用 ChatBubble,assistant 用自定义面板(头像+卡片容器,内部按序渲染 parts):
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"newString": " <template v-for=\"msg in messages\" :key=\"msg.id\">\n <!-- User message: right-aligned bubble -->\n <BoChatBubble\n v-if=\"msg.role === 'user'\"\n role=\"user\"\n :content=\"msg.content\"\n />\n\n <!-- Assistant message: single panel with avatar + chain -->\n <div v-else class=\"assistant-panel\">\n <div class=\"assistant-avatar\">\n <Icon name=\"lucide:bot\" />\n </div>\n <div class=\"assistant-body\">\n <!-- Loading placeholder -->\n <div v-if=\"isLoading && !msg.parts?.length\" class=\"assistant-loading\">\n <span class=\"loading-dot\" />\n <span class=\"loading-dot\" />\n <span class=\"loading-dot\" />\n </div>\n\n <template v-for=\"part in msg.parts\" :key=\"part.id\">\n <!-- Reasoning part -->\n <div v-if=\"part.type === 'reasoning'\" class=\"reasoning-part\">\n <div class=\"reasoning-header\" @click=\"toggleReasoning(part.id)\">\n <Icon name=\"lucide:brain\" class=\"reasoning-icon\" />\n <span class=\"reasoning-title\">思考</span>\n <span v-if=\"part.reasoningLoading\" class=\"reasoning-status\">思考中...</span>\n <Icon\n :name=\"collapsedReasoning.has(part.id) ? 'lucide:chevron-right' : 'lucide:chevron-down'\"\n class=\"reasoning-toggle\"\n />\n </div>\n <div v-show=\"!collapsedReasoning.has(part.id)\" class=\"reasoning-content\">\n {{ part.text }}\n </div>\n </div>\n\n <!-- Tool call part -->\n <div v-else-if=\"part.type === 'tool-call'\" class=\"tool-call-item\">\n <div class=\"tool-call-header\">\n <Icon name=\"lucide:wrench\" class=\"tool-icon\" />\n <span class=\"tool-name\">{{ part.toolName }}</span>\n <span :class=\"['tool-state', part.state]\">\n {{ part.state === 'call' ? '调用中...' : '完成' }}\n </span>\n </div>\n <div v-if=\"part.args\" class=\"tool-args\">\n <span class=\"tool-label\">参数:</span>\n <code>{{ JSON.stringify(part.args) }}</code>\n </div>\n <div v-if=\"part.result !== undefined\" class=\"tool-result\">\n <span class=\"tool-label\">结果:</span>\n <pre>{{ typeof part.result === 'string' ? part.result.slice(0, 500) : JSON.stringify(part.result, null, 2)?.slice(0, 500) }}</pre>\n </div>\n </div>\n\n <!-- Text part -->\n <div v-else-if=\"part.type === 'text'\" class=\"assistant-text\">\n {{ part.text }}\n </div>\n </template>\n </div>\n </div>\n </template>",
"oldString": " <template v-for=\"msg in messages\" :key=\"msg.id\">\n <div class=\"assistant-chain\">\n <!-- Loading placeholder when no parts yet -->\n <BoChatBubble\n v-if=\"msg.role === 'assistant' && isLoading && !msg.parts?.length\"\n role=\"assistant\"\n content=\"\"\n :loading=\"true\"\n />\n\n <!-- User message: single text bubble -->\n <BoChatBubble\n v-if=\"msg.role === 'user'\"\n :role=\"msg.role\"\n :content=\"msg.content\"\n />\n\n <!-- Assistant message: render parts in order -->\n <template v-for=\"part in msg.parts\" :key=\"part.id\">\n <!-- Reasoning part -->\n <div v-if=\"part.type === 'reasoning'\" class=\"reasoning-part\">\n <div class=\"reasoning-header\" @click=\"toggleReasoning(part.id)\">\n <Icon name=\"lucide:brain\" class=\"reasoning-icon\" />\n <span class=\"reasoning-title\">思考</span>\n <span v-if=\"part.reasoningLoading\" class=\"reasoning-status\">思考中...</span>\n <Icon\n :name=\"collapsedReasoning.has(part.id) ? 'lucide:chevron-right' : 'lucide:chevron-down'\"\n class=\"reasoning-toggle\"\n />\n </div>\n <div v-show=\"!collapsedReasoning.has(part.id)\" class=\"reasoning-content\">\n {{ part.text }}\n </div>\n </div>\n\n <!-- Text part -->\n <BoChatBubble\n v-else-if=\"part.type === 'text'\"\n role=\"assistant\"\n :content=\"part.text || ''\"\n />\n\n <!-- Tool call part -->\n <div v-else-if=\"part.type === 'tool-call'\" class=\"tool-call-item\">\n <div class=\"tool-call-header\">\n <Icon name=\"lucide:wrench\" class=\"tool-icon\" />\n <span class=\"tool-name\">{{ part.toolName }}</span>\n <span :class=\"['tool-state', part.state]\">\n {{ part.state === 'call' ? '调用中...' : '完成' }}\n </span>\n </div>\n <div v-if=\"part.args\" class=\"tool-args\">\n <span class=\"tool-label\">参数:</span>\n <code>{{ JSON.stringify(part.args) }}</code>\n </div>\n <div v-if=\"part.result !== undefined\" class=\"tool-result\">\n <span class=\"tool-label\">结果:</span>\n <pre>{{ typeof part.result === 'string' ? part.result.slice(0, 500) : JSON.stringify(part.result, null, 2)?.slice(0, 500) }}</pre>\n </div>\n </div>\n </template>\n </div>\n </template>"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 23.1s)
现在更新样式——替换 assistant-chain 为 assistant-panel + assistant-avatar + assistant-body + assistant-text + assistant-loading:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"newString": ".assistant-panel {\n display: flex;\n gap: 12px;\n align-items: flex-start;\n}\n\n.assistant-avatar {\n flex-shrink: 0;\n width: 32px;\n height: 32px;\n border-radius: 8px;\n background: var(--color-primary);\n color: #fff;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.assistant-avatar :deep(svg) {\n width: 18px;\n height: 18px;\n}\n\n.assistant-body {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.assistant-text {\n background: var(--color-surface-card);\n border: 1px solid var(--color-hairline);\n border-radius: 12px;\n padding: 12px 16px;\n color: var(--color-ink);\n font-size: 14px;\n line-height: 1.7;\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n.assistant-loading {\n display: flex;\n gap: 4px;\n padding: 12px 16px;\n}\n\n.loading-dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: var(--color-muted);\n animation: bounce-dot 1.4s infinite ease-in-out both;\n}\n\n.loading-dot:nth-child(1) { animation-delay: -0.32s; }\n.loading-dot:nth-child(2) { animation-delay: -0.16s; }\n\n@keyframes bounce-dot {\n 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }\n 40% { transform: scale(1); opacity: 1; }\n}",
"oldString": ".assistant-chain {\n display: flex;\n flex-direction: column;\n gap: 8px;\n}"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 8.1s)
现在 assistant 回复是一个统一面板:左侧头像 + 右侧卡片容器,容器内按流式顺序渲染思考块、工具调用块、文本回答块。刷新页面测试即可看到完整 agent 链路。
User
模型agent测试回答多次就突然中断了,没任何提示
Assistant (Build · GLM-5.2 · 10.4s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts</path>
<type>file</type>
<content>
1: import { requireUser } from "#server/utils/context";
2: import { getProviderById, getModelById } from "#server/service/llm";
3: import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4: import { streamText, type LanguageModelV1 } from "ai";
5: import { getEnabledToolsForLlm } from "#server/service/agent-tool";
6: import log4js from "logger";
7:
8: const logger = log4js.getLogger("APP");
9:
10: function resolveModel(
11: provider: {
12: name: string;
13: apiKey: string | null;
14: baseUrl: string | null;
15: parseMode: string;
16: },
17: modelId: string,
18: ): LanguageModelV1 {
19: const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined;
20:
21: if (provider.parseMode === "anthropic") {
22: throw createError({
23: statusCode: 400,
24: statusMessage: "Anthropic 解析模式暂不支持流式对话,请使用 OpenAI 兼容模式",
25: });
26: }
27:
28: const openaiCompatible = createOpenAICompatible({
29: name: provider.name,
30: apiKey: provider.apiKey || undefined,
31: baseURL: baseUrl || "https://api.openai.com/v1",
32: });
33:
34: return openaiCompatible(modelId) as LanguageModelV1;
35: }
36:
37: export default defineEventHandler(async (event) => {
38: const user = await requireUser(event);
39: if (!user) {
40: throw createError({ statusCode: 401, statusMessage: "未登录" });
41: }
42:
43: const body = await readBody(event);
44: const { modelId: llmModelId, messages, enableThinking, enableTools } = body as {
45: modelId: number;
46: messages: { role: "user" | "assistant" | "system"; content: string }[];
47: enableThinking?: boolean;
48: enableTools?: boolean;
49: };
50:
51: if (!llmModelId || !messages || !Array.isArray(messages) || messages.length === 0) {
52: throw createError({ statusCode: 400, statusMessage: "参数无效" });
53: }
54:
55: const model = await getModelById(llmModelId, user.id);
56: if (!model) {
57: throw createError({ statusCode: 404, statusMessage: "模型不存在" });
58: }
59:
60: const provider = await getProviderById(model.providerId, user.id);
61: if (!provider) {
62: throw createError({ statusCode: 404, statusMessage: "供应商不存在" });
63: }
64:
65: if (provider.status !== "active") {
66: throw createError({ statusCode: 400, statusMessage: "供应商已禁用" });
67: }
68:
69: if (!provider.apiKey) {
70: throw createError({ statusCode: 400, statusMessage: "供应商未配置 API Key" });
71: }
72:
73: logger.info(
74: "[%s] [LLM-CHAT] userId=%d modelId=%d provider=%s parseMode=%s messages=%d thinking=%s tools=%s",
75: event.context.requestId ?? "-",
76: user.id,
77: llmModelId,
78: provider.name,
79: provider.parseMode,
80: messages.length,
81: enableThinking ? "on" : "off",
82: enableTools ? "on" : "off",
83: );
84:
85: const languageModel = resolveModel(provider, model.modelId);
86:
87: const tools = enableTools ? await getEnabledToolsForLlm() : undefined;
88:
89: const result = streamText({
90: model: languageModel,
91: messages,
92: maxTokens: model.maxTokens || undefined,
93: ...(tools && Object.keys(tools).length > 0
94: ? { tools, maxSteps: 5 }
95: : {}),
96: ...(enableThinking
97: ? {
98: providerOptions: {
99: openaiCompatible: { reasoningEffort: "high" },
100: },
101: }
102: : {}),
103: });
104:
105: return result.toDataStreamResponse({ sendReasoning: true });
106: });
(End of file - total 106 lines)
</content>
Assistant (Build · GLM-5.2 · 13.9s)
问题可能是 maxSteps: 5 限制了工具调用轮数,或者流式响应中出错被静默吞掉了。让我看 composable 中的错误处理:
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"limit": 30,
"offset": 180
}
Output:
<path>/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts</path>
<type>file</type>
<content>
180: } catch (err: any) {
181: if (err.name === 'AbortError') {
182: // user stopped
183: } else {
184: errorMessage.value = err.message || '请求失败'
185: const msg = messages.value[assistantIdx]
186: if (msg && !msg.content && (!msg.parts || msg.parts.length === 0)) {
187: messages.value.splice(assistantIdx, 1)
188: }
189: }
190: } finally {
191: isLoading.value = false
192: abortController = null
193: }
194: }
195:
196: function stopGeneration() {
197: if (abortController) {
198: abortController.abort()
199: abortController = null
200: }
201: }
202:
203: function clearChat() {
204: messages.value = []
205: errorMessage.value = ''
206: }
207:
208: return {
209: messages,
(Showing lines 180-209 of 216. Use offset=210 to continue.)
</content>
Assistant (Build · GLM-5.2 · 16.9s)
问题在于 processDataStream 中的错误可能被静默吞掉。onErrorPart 只处理 stream 内的 error part,但如果 stream 本身断开或 LLM 返回错误,processDataStream 可能不抛异常。另外 maxSteps: 5 到了上限后 stream 就结束了,不会有任何提示。
让我加几个改进:
processDataStream后检查是否有实际内容产出,没有则报错onErrorPart的错误也展示到 UI- 检查 HTTP response 是否有非 200 状态
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"limit": 50,
"offset": 100
}
Output:
<path>/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts</path>
<type>file</type>
<content>
100: const res = await fetch(apiEndpoint, {
101: method: 'POST',
102: headers: { 'Content-Type': 'application/json' },
103: body: JSON.stringify({
104: modelId: mid,
105: messages: [
106: ...(systemPrompt?.() ? [{ role: 'system' as const, content: systemPrompt() }] : []),
107: ...messages.value
108: .filter(m => m.content)
109: .map(m => ({ role: m.role, content: m.content })),
110: ],
111: enableThinking: enableThinking?.() ?? false,
112: enableTools: enableTools?.() ?? false,
113: }),
114: signal: abortController.signal,
115: })
116:
117: if (!res.ok) {
118: const errText = await res.text()
119: throw new Error(errText || `请求失败 (${res.status})`)
120: }
121:
122: if (!res.body) {
123: throw new Error('响应体为空')
124: }
125:
126: await processDataStream({
127: stream: res.body,
128: onReasoningPart: (text) => {
129: const msg = messages.value[assistantIdx]
130: if (!msg) return
131: let part = getOrCreateLastPart(msg, 'reasoning')
132: if (!part) {
133: if (reasoningStartTime === null) reasoningStartTime = Date.now()
134: part = { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true }
135: appendPart(msg, part)
136: }
137: part.text = (part.text ?? '') + text
138: },
139: onTextPart: (text) => {
140: const msg = messages.value[assistantIdx]
141: if (!msg) return
142: updateLastReasoningDuration(msg)
143: let part = getOrCreateLastPart(msg, 'text')
144: if (!part) {
145: part = { id: generateId(), type: 'text', text: '' }
146: appendPart(msg, part)
147: }
148: part.text = (part.text ?? '') + text
149: msg.content += text
(Showing lines 100-149 of 216. Use offset=150 to continue.)
</content>
Assistant (Build · GLM-5.2 · 8.7s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"limit": 40,
"offset": 150
}
Output:
<path>/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts</path>
<type>file</type>
<content>
150: },
151: onErrorPart: (error) => {
152: errorMessage.value = error || '流式响应出错'
153: },
154: onToolCallPart: (part) => {
155: const msg = messages.value[assistantIdx]
156: if (!msg) return
157: updateLastReasoningDuration(msg)
158: appendPart(msg, {
159: id: generateId(),
160: type: 'tool-call',
161: toolName: part.toolName,
162: toolCallId: part.toolCallId,
163: args: part.args,
164: state: 'call',
165: })
166: },
167: onToolResultPart: (part) => {
168: const msg = messages.value[assistantIdx]
169: if (!msg || !msg.parts) return
170: const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === part.toolCallId)
171: if (callPart) {
172: callPart.result = part.result
173: callPart.state = 'result'
174: }
175: },
176: })
177:
178: const msg = messages.value[assistantIdx]
179: if (msg) updateLastReasoningDuration(msg)
180: } catch (err: any) {
181: if (err.name === 'AbortError') {
182: // user stopped
183: } else {
184: errorMessage.value = err.message || '请求失败'
185: const msg = messages.value[assistantIdx]
186: if (msg && !msg.content && (!msg.parts || msg.parts.length === 0)) {
187: messages.value.splice(assistantIdx, 1)
188: }
189: }
(Showing lines 150-189 of 216. Use offset=190 to continue.)
</content>
Assistant (Build · GLM-5.2 · 44.8s)
问题找到了。processDataStream 完成后如果没有任何 part 产出(stream 静默结束),不会有任何错误提示。另外 onErrorPart 设置了 errorMessage 但如果 stream 正常结束,用户可能看不到。还有 maxSteps: 5 到了上限后 stream 直接结束,没有任何提示。
加几个修复:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"newString": " await processDataStream({\n stream: res.body,\n onReasoningPart: (text) => {\n const msg = messages.value[assistantIdx]\n if (!msg) return\n let part = getOrCreateLastPart(msg, 'reasoning')\n if (!part) {\n if (reasoningStartTime === null) reasoningStartTime = Date.now()\n part = { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true }\n appendPart(msg, part)\n }\n part.text = (part.text ?? '') + text\n },\n onTextPart: (text) => {\n const msg = messages.value[assistantIdx]\n if (!msg) return\n updateLastReasoningDuration(msg)\n let part = getOrCreateLastPart(msg, 'text')\n if (!part) {\n part = { id: generateId(), type: 'text', text: '' }\n appendPart(msg, part)\n }\n part.text = (part.text ?? '') + text\n msg.content += text\n },\n onErrorPart: (error) => {\n errorMessage.value = error || '流式响应出错'\n },\n onToolCallPart: (part) => {\n const msg = messages.value[assistantIdx]\n if (!msg) return\n updateLastReasoningDuration(msg)\n appendPart(msg, {\n id: generateId(),\n type: 'tool-call',\n toolName: part.toolName,\n toolCallId: part.toolCallId,\n args: part.args,\n state: 'call',\n })\n },\n onToolResultPart: (part) => {\n const msg = messages.value[assistantIdx]\n if (!msg || !msg.parts) return\n const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === part.toolCallId)\n if (callPart) {\n callPart.result = part.result\n callPart.state = 'result'\n }\n },\n })\n\n const msg = messages.value[assistantIdx]\n if (msg) updateLastReasoningDuration(msg)\n\n // 检查是否有实际内容产出\n const finalMsg = messages.value[assistantIdx]\n if (finalMsg && !finalMsg.content && (!finalMsg.parts || finalMsg.parts.length === 0) && !errorMessage.value) {\n errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'\n messages.value.splice(assistantIdx, 1)\n }",
"oldString": " await processDataStream({\n stream: res.body,\n onReasoningPart: (text) => {\n const msg = messages.value[assistantIdx]\n if (!msg) return\n let part = getOrCreateLastPart(msg, 'reasoning')\n if (!part) {\n if (reasoningStartTime === null) reasoningStartTime = Date.now()\n part = { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true }\n appendPart(msg, part)\n }\n part.text = (part.text ?? '') + text\n },\n onTextPart: (text) => {\n const msg = messages.value[assistantIdx]\n if (!msg) return\n updateLastReasoningDuration(msg)\n let part = getOrCreateLastPart(msg, 'text')\n if (!part) {\n part = { id: generateId(), type: 'text', text: '' }\n appendPart(msg, part)\n }\n part.text = (part.text ?? '') + text\n msg.content += text\n },\n onErrorPart: (error) => {\n errorMessage.value = error || '流式响应出错'\n },\n onToolCallPart: (part) => {\n const msg = messages.value[assistantIdx]\n if (!msg) return\n updateLastReasoningDuration(msg)\n appendPart(msg, {\n id: generateId(),\n type: 'tool-call',\n toolName: part.toolName,\n toolCallId: part.toolCallId,\n args: part.args,\n state: 'call',\n })\n },\n onToolResultPart: (part) => {\n const msg = messages.value[assistantIdx]\n if (!msg || !msg.parts) return\n const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === part.toolCallId)\n if (callPart) {\n callPart.result = part.result\n callPart.state = 'result'\n }\n },\n })\n\n const msg = messages.value[assistantIdx]\n if (msg) updateLastReasoningDuration(msg)"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 13.8s)
但"突然中断"更可能是 stream 中途出错或 LLM API 返回错误。让我也检查服务端是否有日志。先看下 streamText 是否有 onError 或 onFinish 回调可以捕获:
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && grep -A5 \"onFinish\\|onError\\|onStepFinish\" node_modules/ai/dist/index.d.ts 2>/dev/null | grep -E \"onFinish|onError|onStepFinish\" | head -10",
"description": "查 streamText 回调"
}
Output:
import { LanguageModelV1, LanguageModelV1FinishReason, LanguageModelV1LogProbs, LanguageModelV1CallWarning, LanguageModelV1Source, JSONValue, EmbeddingModelV1, EmbeddingModelV1Embedding, ImageModelV1, ImageModelV1CallWarning, LanguageModelV1ProviderMetadata, TranscriptionModelV1, TranscriptionModelV1CallWarning, SpeechModelV1, SpeechModelV1CallWarning, LanguageModelV1CallOptions, AISDKError, LanguageModelV1FunctionToolCall, JSONSchema7, JSONParseError, TypeValidationError, ProviderV1, NoSuchModelError } from '@ai-sdk/provider';
export { AISDKError, APICallError, EmptyResponseBodyError, InvalidPromptError, InvalidResponseDataError, JSONParseError, LanguageModelV1, LanguageModelV1CallOptions, LanguageModelV1Prompt, LanguageModelV1StreamPart, LoadAPIKeyError, NoContentGeneratedError, NoSuchModelError, TypeValidationError, UnsupportedFunctionalityError } from '@ai-sdk/provider';
onError: ((error: unknown) => string) | undefined;
declare function createDataStream({ execute, onError, }: {
onError?: (error: unknown) => string;
declare function createDataStreamResponse({ status, statusText, headers, execute, onError, }: ResponseInit & {
onError?: (error: unknown) => string;
declare function pipeDataStreamToResponse(response: ServerResponse, { status, statusText, headers, execute, onError, }: ResponseInit & {
onError?: (error: unknown) => string;
private onError;
Assistant (Build · GLM-5.2 · 18.3s)
streamText 支持 onError 回调。但更关键的是,toDataStreamResponse 默认会把错误吞掉。让我在服务端加 onError 把错误打到日志:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts",
"newString": " const result = streamText({\n model: languageModel,\n messages,\n maxTokens: model.maxTokens || undefined,\n ...(tools && Object.keys(tools).length > 0\n ? { tools, maxSteps: 5 }\n : {}),\n ...(enableThinking\n ? {\n providerOptions: {\n openaiCompatible: { reasoningEffort: \"high\" },\n },\n }\n : {}),\n onError: (error) => {\n logger.error(\"[%s] [LLM-CHAT] streamText error: %s\", event.context.requestId ?? \"-\", error);\n return error instanceof Error ? error.message : String(error);\n },\n });\n\n return result.toDataStreamResponse({ sendReasoning: true });",
"oldString": " const result = streamText({\n model: languageModel,\n messages,\n maxTokens: model.maxTokens || undefined,\n ...(tools && Object.keys(tools).length > 0\n ? { tools, maxSteps: 5 }\n : {}),\n ...(enableThinking\n ? {\n providerOptions: {\n openaiCompatible: { reasoningEffort: \"high\" },\n },\n }\n : {}),\n });\n\n return result.toDataStreamResponse({ sendReasoning: true });"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 10.5s)
现在服务端错误会通过 onError 返回到 stream 中,前端的 onErrorPart 会捕获并展示。同时如果 stream 静默结束(无任何 part),前端也会提示"模型未返回任何内容"。
刷新页面再试,如果还是中断,现在应该能看到错误提示了。如果看到"可能已达到工具调用次数上限",那就是 maxSteps: 5 的限制,可以调大。
User
还是突然到这里就停止了,没有回答:
[Pasted ~12 linesfetch_html 完成 参数: {"url":"https://api.juejin.cn/content_api/v1/post/detail?post_id=7626159015530594331"} 结果: {"err_no":2,"err_msg":"请求路由不存在","data":""} fetch_html 完成 参数: {"url":"https://api.juejin.cn/content_api/v1/article/detail?article_id=7626159015530594331&client_type=web"} 结果: {"err_no":2,"err_msg":"请求路由不存在","data":""}
Assistant (Build · GLM-5.2 · 29.2s)
工具调用返回了错误结果(err_no:2),模型可能不知道怎么继续,或者 maxSteps: 5 到了上限。让我看服务端日志确认:
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && tail -50 logs/*.log 2>/dev/null || tail -50 .nuxt/dev/*.log 2>/dev/null || echo \"no log files found\"",
"description": "查看服务端日志"
}
Output:
[2026-08-05T16:38:51.337] [INFO] APP - [b7926259-d70c-4c3a-9ec8-cb454b3a43c8] [GET-/api/chat/unread] 请求结束,花费了 2 ms
[2026-08-05T16:39:49.231] [INFO] APP - [c6cb306b-46b4-4d67-b29f-dd574ecbd1f2] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:39:49.233] [INFO] APP - [c6cb306b-46b4-4d67-b29f-dd574ecbd1f2] [GET-/api/chat/unread] 请求结束,花费了 2 ms
[2026-08-05T16:40:52.448] [INFO] APP - [d03fb705-666a-45c9-8e91-74c8e7b9e5ae] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:40:52.451] [INFO] APP - [d03fb705-666a-45c9-8e91-74c8e7b9e5ae] [GET-/api/chat/unread] 请求结束,花费了 3 ms
[2026-08-05T16:41:49.357] [INFO] APP - [9c23ddc6-60da-462c-90b5-3b7f64e18585] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:41:49.359] [INFO] APP - [9c23ddc6-60da-462c-90b5-3b7f64e18585] [GET-/api/chat/unread] 请求结束,花费了 2 ms
[2026-08-05T16:42:03.919] [INFO] APP - [60ecf898-87fc-4388-83ba-b72c5b681aec] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:42:03.921] [INFO] APP - [60ecf898-87fc-4388-83ba-b72c5b681aec] [GET-/api/chat/unread] 请求结束,花费了 2 ms
[2026-08-05T16:42:05.208] [INFO] APP - [cb807466-d175-46ad-a120-ec56a909bb24] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:42:05.210] [INFO] APP - [cb807466-d175-46ad-a120-ec56a909bb24] [GET-/api/chat/unread] 请求结束,花费了 2 ms
[2026-08-05T16:42:14.354] [INFO] APP - [0f45e118-4d80-4c57-afcf-6292436501e2] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:42:14.356] [INFO] APP - [0f45e118-4d80-4c57-afcf-6292436501e2] [GET-/api/chat/unread] 请求结束,花费了 2 ms
[2026-08-05T16:42:23.528] [INFO] APP - [54346010-203e-479c-9325-37bc91fb9834] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:42:23.531] [INFO] APP - [54346010-203e-479c-9325-37bc91fb9834] [GET-/api/chat/unread] 请求结束,花费了 3 ms
[2026-08-05T16:42:27.867] [INFO] APP - [502b39d0-afd3-4696-b768-33d8660f9770] [POST-/api/llm/chat] 开始请求
[2026-08-05T16:42:27.884] [INFO] APP - [502b39d0-afd3-4696-b768-33d8660f9770] [LLM-CHAT] userId=11 modelId=2 provider=deepseek parseMode=openai messages=1 thinking=off tools=on
[2026-08-05T16:42:32.697] [INFO] APP - [43713769-7fa3-4200-8faa-d46319d07853] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:42:32.698] [INFO] APP - [43713769-7fa3-4200-8faa-d46319d07853] [GET-/api/chat/unread] 请求结束,花费了 1 ms
[2026-08-05T16:42:41.922] [INFO] APP - [377c825c-300e-41ce-a470-76c0a70c8c8a] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:42:41.925] [INFO] APP - [377c825c-300e-41ce-a470-76c0a70c8c8a] [GET-/api/chat/unread] 请求结束,花费了 3 ms
[2026-08-05T16:42:43.048] [INFO] APP - [502b39d0-afd3-4696-b768-33d8660f9770] [POST-/api/llm/chat] 请求结束,花费了 15181 ms
[2026-08-05T16:42:55.333] [INFO] APP - [26697860-23f8-4f08-903a-1cf47a5fe85c] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:42:55.334] [INFO] APP - [26697860-23f8-4f08-903a-1cf47a5fe85c] [GET-/api/chat/unread] 请求结束,花费了 1 ms
[2026-08-05T16:43:04.495] [INFO] APP - [d71e987f-b12c-4aa1-b559-6a1669f424f3] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:43:04.496] [INFO] APP - [d71e987f-b12c-4aa1-b559-6a1669f424f3] [GET-/api/chat/unread] 请求结束,花费了 2 ms
[2026-08-05T16:43:13.661] [INFO] APP - [2bccf0db-a1a9-41f5-82cb-229ee3a260f0] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:43:13.662] [INFO] APP - [2bccf0db-a1a9-41f5-82cb-229ee3a260f0] [GET-/api/chat/unread] 请求结束,花费了 1 ms
[2026-08-05T16:43:23.238] [INFO] APP - [a903b524-e9ca-481d-82af-e9f3b390da71] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:43:23.240] [INFO] APP - [a903b524-e9ca-481d-82af-e9f3b390da71] [GET-/api/chat/unread] 请求结束,花费了 2 ms
[2026-08-05T16:43:32.396] [INFO] APP - [08cac32f-a807-4c71-accd-0ae9c20c503c] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:43:32.397] [INFO] APP - [08cac32f-a807-4c71-accd-0ae9c20c503c] [GET-/api/chat/unread] 请求结束,花费了 2 ms
[2026-08-05T16:43:46.217] [INFO] APP - [50881f56-4008-4fed-aef6-394a41378c19] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:43:46.219] [INFO] APP - [50881f56-4008-4fed-aef6-394a41378c19] [GET-/api/chat/unread] 请求结束,花费了 2 ms
[2026-08-05T16:43:51.618] [INFO] APP - [8749c179-9d0d-4ac2-a961-95f2b0604abb] [GET-/settings/llm-test] 开始请求
[2026-08-05T16:43:55.711] [INFO] APP - [cc006b1a-dca6-432a-81d7-9537420edca4] [GET-/api/config/global] 开始请求
[2026-08-05T16:43:55.716] [INFO] APP - [cc006b1a-dca6-432a-81d7-9537420edca4] [GET-/api/config/global] 请求结束,花费了 5 ms
[2026-08-05T16:43:55.752] [INFO] APP - [145e84d4-fef1-4bce-840d-858e924a3f64] [GET-/api/llm/chat/models] 开始请求
[2026-08-05T16:43:55.758] [INFO] APP - [145e84d4-fef1-4bce-840d-858e924a3f64] [GET-/api/llm/chat/models] 请求结束,花费了 6 ms
[2026-08-05T16:43:55.762] [INFO] APP - [7c8db268-8984-4393-b514-1191a6751aef] [GET-/api/_nuxt_icon/lucide.json?icons=brain-circuit%2Chome%2Clog-out%2Cmessage-square-text%2Csettings-2] 开始请求
[2026-08-05T16:43:55.772] [INFO] APP - [7c8db268-8984-4393-b514-1191a6751aef] [GET-/api/_nuxt_icon/lucide.json?icons=brain-circuit%2Chome%2Clog-out%2Cmessage-square-text%2Csettings-2] 请求结束,花费了 10 ms
[2026-08-05T16:43:55.799] [INFO] APP - [8749c179-9d0d-4ac2-a961-95f2b0604abb] [GET-/settings/llm-test] 请求结束,花费了 4181 ms
[2026-08-05T16:43:55.851] [INFO] APP - [140a0174-6b56-4ef5-bb7d-69924430719d] [GET-/api/chat/sse] 请求结束,花费了 524224 ms
[2026-08-05T16:43:57.227] [INFO] APP - [badbaf0c-a5ad-4b23-83a7-72beed80e80b] [GET-/api/llm/chat/models] 开始请求
[2026-08-05T16:43:57.238] [INFO] APP - [badbaf0c-a5ad-4b23-83a7-72beed80e80b] [GET-/api/llm/chat/models] 请求结束,花费了 11 ms
[2026-08-05T16:43:57.244] [INFO] APP - [462fe370-cc6d-4e7e-aa73-f7013c6e1baa] [GET-/api/chat/sse] 开始请求
[2026-08-05T16:43:57.250] [INFO] APP - [5340bf30-da07-4d27-84c5-edadbd3412b8] [GET-/api/chat/unread] 开始请求
[2026-08-05T16:43:57.253] [INFO] APP - [5340bf30-da07-4d27-84c5-edadbd3412b8] [GET-/api/chat/unread] 请求结束,花费了 3 ms
[2026-08-05T16:43:57.254] [INFO] APP - [80967af2-f976-4ee7-b9af-691def931efc] [GET-/api/chat/messages] 开始请求
[2026-08-05T16:43:57.259] [INFO] APP - [80967af2-f976-4ee7-b9af-691def931efc] [GET-/api/chat/messages] 请求结束,花费了 5 ms
Assistant (Build · GLM-5.2 · 14.0s)
日志显示请求正常结束(15181ms),没有 onError 日志。说明 maxSteps: 5 到了上限——模型调了 2 次工具(每次算 1 step),加上初始回答和最终回答,5 步用完后 stream 就结束了,但模型还没来得及输出最终文本。
问题根因:maxSteps 包含工具调用轮次,到了上限后 stream 直接结束,不会让模型再生成最终文本回答。
把 maxSteps 调大,并加 onFinish 回调记录 finish reason:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts",
"newString": " const result = streamText({\n model: languageModel,\n messages,\n maxTokens: model.maxTokens || undefined,\n ...(tools && Object.keys(tools).length > 0\n ? { tools, maxSteps: 10 }\n : {}),\n ...(enableThinking\n ? {\n providerOptions: {\n openaiCompatible: { reasoningEffort: \"high\" },\n },\n }\n : {}),\n onError: (error) => {\n logger.error(\"[%s] [LLM-CHAT] streamText error: %s\", event.context.requestId ?? \"-\", error);\n return error instanceof Error ? error.message : String(error);\n },\n onFinish: ({ finishReason, usage, steps }) => {\n logger.info(\n \"[%s] [LLM-CHAT] finished: reason=%s steps=%d inputTokens=%d outputTokens=%d\",\n event.context.requestId ?? \"-\",\n finishReason,\n steps.length,\n usage?.promptTokens ?? 0,\n usage?.completionTokens ?? 0,\n );\n },\n });",
"oldString": " const result = streamText({\n model: languageModel,\n messages,\n maxTokens: model.maxTokens || undefined,\n ...(tools && Object.keys(tools).length > 0\n ? { tools, maxSteps: 5 }\n : {}),\n ...(enableThinking\n ? {\n providerOptions: {\n openaiCompatible: { reasoningEffort: \"high\" },\n },\n }\n : {}),\n onError: (error) => {\n logger.error(\"[%s] [LLM-CHAT] streamText error: %s\", event.context.requestId ?? \"-\", error);\n return error instanceof Error ? error.message : String(error);\n },\n });"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 29.8s)
maxSteps 从 5 调到 10,并加了 onFinish 日志记录 finish reason 和步数。再试一次,如果日志显示 reason=max-steps 说明还是步数限制,需要继续调大;如果显示 reason=stop 说明模型自己选择不回答了(可能是因为工具返回错误结果后模型不知道怎么继续)。
User
还是莫名就停止了,为什么呢,日志:[Pasted ~1 lines][2026-08-05T16:46:49.815] [INFO] APP - [001d651a-7490-43a5-a7c0-ec79ed87461f] [LLM-CHAT] finished: reason=tool-calls steps=10 inputTokens=146179 outputTokens=4244
Assistant (Build · GLM-5.2 · 26.0s)
reason=tool-calls steps=10 — 10 步全用完了,模型一直在调工具没停。inputTokens=146179 说明上下文越来越大,模型反复调用工具但每次都得到错误结果(err_no:2),陷入死循环。
根本问题是:工具返回了错误结果,但模型没有意识到该放弃并直接回答用户。需要在工具结果中给模型更明确的提示,或者限制工具调用次数。
两个改进:
maxSteps降到合理值(如 5),但加onStepFinish检测连续工具调用失败- 在 fetch executor 中,如果 HTTP 返回非 200 或 JSON 错误,返回更明确的错误信息让模型知道该 URL 无效
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/executors/fetch/fetch.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/executors/fetch/fetch.ts</path>
<type>file</type>
<content>
1: import { z } from "zod";
2: import type { JSONSchema7 } from "json-schema";
3: import type { ToolExecutor, ToolContext, ToolResult } from "../../registry";
4: import { assertSafeUrl, checkDomainAccess } from "./security";
5: import { parseResponse } from "./parse";
6: import type { FetchToolConfig } from "./config";
7:
8: const fetchInputSchema = z.object({
9: url: z.string().url().describe("要抓取的 URL(http/https)"),
10: method: z.enum(["GET", "POST"]).optional().describe("HTTP 方法"),
11: headers: z.record(z.string(), z.string()).optional().describe("自定义请求头"),
12: body: z.string().optional().describe("POST 请求体"),
13: });
14:
15: type FetchInput = z.infer<typeof fetchInputSchema>;
16:
17: export const fetchExecutor: ToolExecutor<FetchToolConfig> = {
18: buildInputSchema(config: FetchToolConfig): JSONSchema7 {
19: return {
20: type: "object",
21: properties: {
22: url: { type: "string", description: "要抓取的 URL(http/https)" },
23: method: {
24: type: "string",
25: enum: ["GET", "POST"],
26: default: config.defaultMethod,
27: description: "HTTP 方法",
28: },
29: headers: {
30: type: "object",
31: description: "自定义请求头",
32: additionalProperties: { type: "string" },
33: },
34: body: { type: "string", description: "POST 请求体" },
35: },
36: required: ["url"],
37: };
38: },
39:
40: buildDescription(config: FetchToolConfig): string {
41: const domains = config.allowedDomains.includes("*")
42: ? "任意域名"
43: : `仅限: ${config.allowedDomains.join(", ")}`;
44: return `抓取网页或 API 内容。支持 ${config.parseMode} 模式。域名限制: ${domains}。超时 ${config.timeout}ms,最大响应 ${config.maxResponseSize} bytes。`;
45: },
46:
47: async execute(
48: input: unknown,
49: config: FetchToolConfig,
50: ctx: ToolContext,
51: ): Promise<ToolResult> {
52: const start = Date.now();
53:
54: // 1. 校验 input
55: const parsed = fetchInputSchema.safeParse(input);
56: if (!parsed.success) {
57: return {
58: success: false,
59: data: null,
60: error: `输入参数校验失败: ${parsed.error.message}`,
61: metadata: { durationMs: Date.now() - start },
62: };
63: }
64: const fetchInput: FetchInput = parsed.data;
65: const method = fetchInput.method ?? config.defaultMethod;
66:
67: // 2. SSRF 检查
68: try {
69: await assertSafeUrl(fetchInput.url);
70: } catch (e) {
71: return {
72: success: false,
73: data: null,
74: error: e instanceof Error ? e.message : String(e),
75: metadata: { durationMs: Date.now() - start },
76: };
77: }
78:
79: // 3. 域名白/黑名单检查
80: const hostname = new URL(fetchInput.url).hostname;
81: try {
82: checkDomainAccess(hostname, config.allowedDomains, config.blockedDomains);
83: } catch (e) {
84: return {
85: success: false,
86: data: null,
87: error: e instanceof Error ? e.message : String(e),
88: metadata: { durationMs: Date.now() - start },
89: };
90: }
91:
92: // 4. 发起 fetch
93: const controller = new AbortController();
94: const timeoutId = setTimeout(() => controller.abort(), config.timeout);
95:
96: try {
97: const mergedHeaders: Record<string, string> = {
98: ...config.defaultHeaders,
99: ...(fetchInput.headers ?? {}),
100: };
101:
102: const response = await fetch(fetchInput.url, {
103: method,
104: headers: mergedHeaders,
105: body: method === "POST" ? fetchInput.body : undefined,
106: signal: controller.signal,
107: redirect: "follow",
108: });
109:
110: clearTimeout(timeoutId);
111:
112: // 5. 大小限制检查
113: const contentLength = response.headers.get("content-length");
114: if (contentLength && parseInt(contentLength, 10) > config.maxResponseSize) {
115: return {
116: success: false,
117: data: null,
118: error: `响应过大: ${contentLength} bytes (限制 ${config.maxResponseSize})`,
119: metadata: {
120: statusCode: response.status,
121: durationMs: Date.now() - start,
122: },
123: };
124: }
125:
126: // 读取 body(分块检查大小)
127: const reader = response.body?.getReader();
128: if (!reader) {
129: const text = await response.text();
130: if (text.length > config.maxResponseSize) {
131: return {
132: success: false,
133: data: null,
134: error: `响应过大: ${text.length} bytes (限制 ${config.maxResponseSize})`,
135: metadata: {
136: statusCode: response.status,
137: durationMs: Date.now() - start,
138: },
139: };
140: }
141: const result = parseResponse(text, config.parseMode, response.headers.get("content-type") ?? undefined);
142: return {
143: success: true,
144: data: result.content,
145: metadata: {
146: statusCode: response.status,
147: responseSize: text.length,
148: durationMs: Date.now() - start,
149: },
150: };
151: }
152:
153: const chunks: Uint8Array[] = [];
154: let totalSize = 0;
155: let oversized = false;
156: while (true) {
157: const { done, value } = await reader.read();
158: if (done) break;
159: if (value) {
160: totalSize += value.length;
161: if (totalSize > config.maxResponseSize) {
162: oversized = true;
163: break;
164: }
165: chunks.push(value);
166: }
167: }
168: reader.cancel();
169:
170: if (oversized) {
171: return {
172: success: false,
173: data: null,
174: error: `响应过大: 超过 ${config.maxResponseSize} bytes`,
175: metadata: {
176: statusCode: response.status,
177: durationMs: Date.now() - start,
178: },
179: };
180: }
181:
182: const decoder = new TextDecoder("utf-8");
183: const bodyText = chunks.map((c) => decoder.decode(c, { stream: true })).join("") + decoder.decode();
184:
185: // 6. 按 parseMode 处理
186: const result = parseResponse(
187: bodyText,
188: config.parseMode,
189: response.headers.get("content-type") ?? undefined,
190: );
191:
192: return {
193: success: true,
194: data: result.content,
195: metadata: {
196: statusCode: response.status,
197: responseSize: totalSize,
198: durationMs: Date.now() - start,
199: },
200: };
201: } catch (e) {
202: clearTimeout(timeoutId);
203: if (e instanceof Error && e.name === "AbortError") {
204: return {
205: success: false,
206: data: null,
207: error: `请求超时 (${config.timeout}ms)`,
208: metadata: { durationMs: Date.now() - start },
209: };
210: }
211: return {
212: success: false,
213: data: null,
214: error: e instanceof Error ? e.message : String(e),
215: metadata: { durationMs: Date.now() - start },
216: };
217: }
218: },
219: };
(End of file - total 219 lines)
</content>
Assistant (Build · GLM-5.2 · 24.2s)
问题在于:fetch 工具即使 HTTP 200 但返回的 JSON body 是业务错误(err_no:2),工具仍标记为 success: true,模型看到结果后不知道这是错误,反复尝试不同 URL。
两个修复:
- fetch executor:HTTP 非 200 时返回
success: false,让模型明确知道请求失败 - chat 集成:工具执行失败时,给模型更明确的错误提示,并在
maxSteps到达上限时让模型仍能输出最终文本
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/executors/fetch/fetch.ts",
"newString": " clearTimeout(timeoutId);\n\n // HTTP 非 2xx 直接返回失败,让模型知道请求无效\n if (!response.ok) {\n const errBody = await response.text().catch(() => \"\");\n return {\n success: false,\n data: null,\n error: `HTTP ${response.status} ${response.statusText}: ${errBody.slice(0, 500)}`,\n metadata: {\n statusCode: response.status,\n durationMs: Date.now() - start,\n },\n };\n }\n\n // 5. 大小限制检查",
"oldString": " clearTimeout(timeoutId);\n\n // 5. 大小限制检查"
}
Output:
Edit applied successfully.
User
Assistant (Compaction · GLM-5.2 · 93.7s)
Goal
- 构建一个通用工具调用框架,作为 agent 系统的前置开发基础设施,fetch 作为第一个内置工具,后续可扩展 search、code-exec 等
- 在 LLM 测试页面展示完整 agent 思考链路:思考 → 调用工具 → 观察结果 → 继续思考 → 回答,按流式顺序在一个助手面板内展示
Constraints & Preferences
- 工具执行独立 API + chat 集成(两阶段,先独立执行可调试,再集成到 chat)
- 新建
agent_tools表,不复用现有tools表(现有 tools 表是内容分类标签,语义不同) - 工具定义采用混合模式:type 硬编码执行器 + config 差异化配置
- fetch 工具支持三种 parseMode:raw / markdown / json
- 安全边界:域名控制 + SSRF 防护 + 超时/大小限制 + 执行日志,频率限制暂缓
- chat 集成为全自动 tool loop,maxSteps=5,无需用户确认
- 工具全局启用,所有用户共享同一套工具配置
enableTools默认 false,向后兼容现有 chat 行为- Input Schema 改为只读展示(fetch 工具参数固定,无需用户编辑,等未来多种工具类型时再开放)
- agent 链路展示:所有 part(思考/工具调用/文本回答)在同一个助手面板内,左侧一个助手头像,右侧按序展示
Progress
Done
- 探索项目上下文:Nuxt 4 + Drizzle ORM + ai-sdk,已有 LLM 集成、chat 服务、tools 表(分类标签)
- 完成所有澄清问题(8 轮 Q&A)
- 完成设计方案 7 个 section,用户逐一确认通过
- 设计文档写入
docs/superpowers/specs/2026-08-05-agent-tool-framework-design.md并提交 git(commit fcc2992) - Spec 自检完成,修正了架构图路由名称和 jsonSchemaToZod 说明
- 用户 review spec 并确认通过
- 创建 15 项实现计划 TODO(DB → Registry → fetch executor → 日志 → Service → API → chat 集成 → 前端 → 测试)
- DB Schema 完成:新建
packages/drizzle-pkg/lib/schema/agent-tool.ts,定义agentTools+agentToolLogs两张表,生成迁移0014_breezy_maestro.sql并执行成功 - Tool Registry 完成:
server/service/agent-tool/registry.ts,定义ToolExecutor<TConfig>接口、ToolContext、ToolResult,实现registerToolType/getExecutor/listToolTypes - fetch executor config 完成:
server/service/agent-tool/executors/fetch/config.ts,zod schema 校验 +DEFAULT_FETCH_CONFIG+parseFetchConfig - fetch executor security 完成:
server/service/agent-tool/executors/fetch/security.ts,SSRF 防护 + 域名白/黑名单 +assertSafeUrl - fetch executor parse 完成:
server/service/agent-tool/executors/fetch/parse.ts,raw/markdown/json 三种模式,markdown 用turndown@7.2.0 - fetch executor fetch.ts 完成:
server/service/agent-tool/executors/fetch/fetch.ts - 注册入口完成:
server/service/agent-tool/register.ts(简化为直接调用registerToolType,去掉registeredflag) - 日志服务完成:
server/service/agent-tool/log.ts - Service 层完成:
server/service/agent-tool/index.ts,实现 CRUD +executeAgentTool+getEnabledToolsForLlm - API 层 CRUD 完成:6 个端点全部创建,全部使用
requireAdmin权限校验 - 安装依赖:
@types/json-schema@7.0.15、json-schema-to-zod@2.0.0、turndown@7.2.0、@types/turndown@5.0.5、zod-to-json-schema@3.24.5 - chat 集成完成:
server/api/llm/chat/index.post.ts已扩展enableTools参数 - 前端管理页面完成:
app/pages/admin/agent-tools/index.vue - 前端表单 Modal 完成:
app/components/AgentToolFormModal.vue - 前端执行测试 Modal 完成:
app/components/AgentToolExecuteModal.vue - composable 扩展完成:
app/composables/useLlmChat.ts添加enableTools选项 - llm-test 页面工具开关完成:
app/pages/settings/llm-test/index.vue添加"工具调用"toggle 开关 - 前端 toast 修复:三个文件改用
useNuxtApp().$toast - typecheck 通过(项目已有无关错误)
- 端到端验证通过(service 层):Registry / Security / Parse / Fetch executor / CRUD /
getEnabledToolsForLlm全链路通过 - BigInt 警告修复:
security.ts中 BigInt literal 改为BigInt("0x0a000000")调用形式 require is not defined修复:json-schema-to-zod改用createRequire(import.meta.url)加载Unknown tool type: fetch修复:注册逻辑内联到index.ts,不依赖register.tsside-effect import- 域名白名单逻辑修复:白名单为空时允许所有域名
- admin dashboard 菜单入口添加
- Input Schema 改为只读
jsonSchemaToZod返回字符串问题修复:去掉json-schema-to-zod,改用z.object({ url: z.string() })- zod v4 +
zod-to-json-schema不兼容修复:改用z.toJSONSchema()+ ai-sdkjsonSchema()包装器 - message 模型重构为 parts 数组:
LlmChatMessage改为parts?: MessagePart[],每个 part 有 type(text/reasoning/tool-call/tool-result),按流式到达顺序追加 - composable stream 处理重构:
onTextPart/onReasoningPart/onToolCallPart/onToolResultPart均按序追加到msg.parts数组;getOrCreateLastPart合并连续同类型 part;updateLastReasoningDuration标记最后一个 reasoning part 完成 part.input→part.args修复:onToolCallPart的字段名是args不是inputpart.output→part.result修复:onToolResultPart的字段名是result不是output(ai-sdktool_resultstream part 包含toolCallId+result)- assistant 面板重构:去掉 ChatBubble 用于 assistant,改为自定义
assistant-panel(左侧头像 + 右侧assistant-body卡片容器),内部按序渲染 reasoning-part / tool-call-item / assistant-text - user 消息保持 ChatBubble(右侧气泡)
- reasoning 折叠/展开:
collapsedReasoningSet +toggleReasoning方法 - loading 动画:三个跳动圆点替代 ChatBubble loading
- stream 静默结束检测:
processDataStream完成后检查是否有 part 产出,无则报错"模型未返回任何内容" - 服务端
onError回调添加:streamText添加onError回调,错误打到日志并返回到 stream 中,前端onErrorPart可捕获 executorundefined 防御:executeAgentTool中if (!executor) return { success: false, error: "工具类型未注册" }z.toJSONSchema类型不兼容修复:as Record<string, unknown>绕过JSONSchema7类型不匹配
In Progress
- 用户反馈"模型 agent 测试回答多次就突然中断了,没任何提示"——已添加服务端
onError回调 + 前端空内容检测,需用户验证是否修复
Blocked
- 项目已有 bug:
login_post$1/rendererbefore initialization 错误,非我们引入,导致无法通过 API 登录做完整端到端测试 - 前端 execute 请求
Failed to fetch:日志中无 execute 请求记录,可能是$fetch未带 cookie 导致 401 或请求被浏览器拦截
Key Decisions
- 方案 C:工具执行独立 API + chat 集成,便于调试和复用
- 新建
agent_tools表而非扩展tools表,职责清晰 - 混合 schema 管理:type 硬编码执行逻辑,config 存差异化配置
- 全自动 tool loop(A 方案),安全由域名控制 + SSRF 防护兜底
- 全局工具启用(A 方案),工具是平台级基础设施
enableTools默认 false,现有 chat 不受影响agentTools表 id 使用at_{timestamp36}_{random}格式,非自增agentToolLogs表 id 使用自增 integer- API 端点全部使用
requireAdmin,chat 集成使用requireUser getEnabledToolsForLlm返回Record<string, any>,key 为 tool slug- 前端 toast 使用
useNuxtApp().$toast - markdown parseMode 用
turndown@7.2.0做 HTML→MD security.tsBigInt 用BigInt("0x0a000000")调用形式registerToolType容忍重复注册,getExecutor返回undefined而非抛异常- 注册逻辑直接内联在
index.ts中,不依赖单独的register.tsside-effect import - 去掉
json-schema-to-zod依赖,fetch 工具用固定 zod schema - Input Schema 界面改为只读展示
- zod v4 不兼容
zod-to-json-schema@3.x,改用z.toJSONSchema()+ ai-sdkjsonSchema()包装器 - message 模型重构为 parts 数组(text/reasoning/tool-call/tool-result 按序追加),替代旧的 content + toolCalls 分离模型
- assistant 回复用自定义面板(头像+卡片容器),不用 ChatBubble;user 消息保持 ChatBubble
onToolCallPart字段名是args(不是input);onToolResultPart字段名是result(不是output)- 服务端
streamText添加onError回调,错误返回到 stream 中供前端捕获
Next Steps
- 用户验证"突然中断"问题是否已修复(服务端 onError + 前端空内容检测)
- 如果中断仍存在,检查
maxSteps: 5是否为限制原因,可能需要调大 - 验证前端 execute 请求能正常工作(用户在浏览器测试)
- 清理:
register.ts文件可能已不需要(注册逻辑已内联到index.ts) - 清理:
json-schema-to-zod和zod-to-json-schema@3.24.5依赖可能已不需要
Critical Context
- 项目使用 Nuxt 4 + Drizzle ORM (SQLite) + ai-sdk (Vercel AI SDK) v4.3.16
- zod 版本为 v4.3.6,内置
z.toJSONSchema()方法可直接生成 JSON Schema zod-to-json-schema@3.x不兼容 zod v4- ai-sdk
tool()的parameters接受 zod schema 或jsonSchema()包装的对象 processDataStream支持回调:onTextPart、onReasoningPart、onErrorPart、onToolCallPart、onToolResultPart等onToolCallPart的 stream part 类型为tool_call,包含toolName、toolCallId、args(不是input)onToolResultPart的 stream part 类型为tool_result,包含toolCallId、result(不是output)streamText的onError回调返回错误字符串,会作为 error part 发送到 stream 中toDataStreamResponse({ sendReasoning: true })发送 reasoning part- 现有
server/api/llm/chat/index.post.ts使用streamText+defineEventHandler server/service/agent-tool/为新增独立模块,与现有server/service/tool/并行- API 响应模式:CRUD 端点用
defineWrappedResponseHandler+R.success/R.throwError,chat 端点用defineEventHandler+createError requireAdmin在server/utils/admin-guard.tsdbGlobal从drizzle-pkg/lib/db导入- 前端组件库
packages/bolt-ui/有ChatBubble组件,但不支持 toolCalls prop app/composables/useLlmChat.ts是 chat 的核心 composable- 项目已有 bug:
login_post$1/rendererbefore initialization(非我们引入) - 数据库中已有用户:admin (id=11, role=admin)、npmrun (id=12, role=user)
- 数据库中已有 1 个 fetch 工具:id=
at_msfj6cbp_1float,slug=test,type=fetch,enabled=1 - Nitro tree-shaking 会移除没有导出的 side-effect import
json-schema-to-zod的jsonSchemaToZod()返回的是 zod 代码字符串,不是 zod schema 实例MessagePart类型:id/type(text/reasoning/tool-call/tool-result)/text/toolName/toolCallId/args/result/state/reasoningLoading/reasoningDurationgetOrCreateLastPart合并连续同类型 part(text 追加到上一个 text part,reasoning 追加到上一个 reasoning part)updateLastReasoningDuration从 parts 末尾向前找第一个reasoningLoading的 reasoning part 并标记完成
Relevant Files
docs/superpowers/specs/2026-08-05-agent-tool-framework-design.md— 设计文档(已提交)packages/drizzle-pkg/lib/schema/agent-tool.ts— schema 定义packages/drizzle-pkg/migrations/0014_breezy_maestro.sql— 迁移文件(已执行)server/service/agent-tool/registry.ts— ToolExecutor 接口 + 注册机制server/service/agent-tool/register.ts— 注册入口(可能已不需要)server/service/agent-tool/log.ts— writeToolLog 日志服务server/service/agent-tool/index.ts— Service 层 CRUD + execute + getEnabledToolsForLlm(内联注册 +z.toJSONSchema()+jsonSchema()包装 + executor undefined 防御 +as Record<string, unknown>类型修复)server/service/agent-tool/executors/fetch/config.ts— fetch 配置 zod schema + 默认值server/service/agent-tool/executors/fetch/security.ts— SSRF 防护 + 域名检查server/service/agent-tool/executors/fetch/parse.ts— raw/markdown/json 解析server/service/agent-tool/executors/fetch/fetch.ts— fetch 执行器组装server/api/agent-tools/index.get.ts— 列出工具server/api/agent-tools/index.post.ts— 创建工具server/api/agent-tools/[id].get.ts— 获取详情server/api/agent-tools/[id].put.ts— 更新工具server/api/agent-tools/[id].delete.ts— 删除工具server/api/agent-tools/[id]/execute.post.ts— 独立执行server/api/llm/chat/index.post.ts— 流式对话接口,已扩展 tool-calling +onError回调app/pages/admin/agent-tools/index.vue— admin 管理列表页app/pages/admin/dashboard.vue— admin 仪表盘,已添加 Agent 工具管理入口app/components/AgentToolFormModal.vue— 工具创建/编辑表单 Modal(inputSchema 只读)app/components/AgentToolExecuteModal.vue— 工具执行测试 Modalapp/composables/useLlmChat.ts— chat composable,已重构为 parts 数组模型 +onToolCallPart(args) /onToolResultPart(result) + 空内容检测app/pages/settings/llm-test/index.vue— LLM 测试页面,已重构为 assistant-panel(头像+卡片容器,内部按序渲染 reasoning/tool-call/text)+ user ChatBubble + reasoning 折叠 + loading 动画
User
Assistant (Build · GLM-5.2 · 23.5s)
现在看 chat 集成层,工具结果如何传给模型:
Tool: grep
Input:
{
"pattern": "executeAgentTool|tool_result|toolResult",
"path": "/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts"
}
Output:
No files found
Assistant (Build · GLM-5.2 · 21.1s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts</path>
<type>file</type>
<content>
1: import { requireUser } from "#server/utils/context";
2: import { getProviderById, getModelById } from "#server/service/llm";
3: import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4: import { streamText, type LanguageModelV1 } from "ai";
5: import { getEnabledToolsForLlm } from "#server/service/agent-tool";
6: import log4js from "logger";
7:
8: const logger = log4js.getLogger("APP");
9:
10: function resolveModel(
11: provider: {
12: name: string;
13: apiKey: string | null;
14: baseUrl: string | null;
15: parseMode: string;
16: },
17: modelId: string,
18: ): LanguageModelV1 {
19: const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined;
20:
21: if (provider.parseMode === "anthropic") {
22: throw createError({
23: statusCode: 400,
24: statusMessage: "Anthropic 解析模式暂不支持流式对话,请使用 OpenAI 兼容模式",
25: });
26: }
27:
28: const openaiCompatible = createOpenAICompatible({
29: name: provider.name,
30: apiKey: provider.apiKey || undefined,
31: baseURL: baseUrl || "https://api.openai.com/v1",
32: });
33:
34: return openaiCompatible(modelId) as LanguageModelV1;
35: }
36:
37: export default defineEventHandler(async (event) => {
38: const user = await requireUser(event);
39: if (!user) {
40: throw createError({ statusCode: 401, statusMessage: "未登录" });
41: }
42:
43: const body = await readBody(event);
44: const { modelId: llmModelId, messages, enableThinking, enableTools } = body as {
45: modelId: number;
46: messages: { role: "user" | "assistant" | "system"; content: string }[];
47: enableThinking?: boolean;
48: enableTools?: boolean;
49: };
50:
51: if (!llmModelId || !messages || !Array.isArray(messages) || messages.length === 0) {
52: throw createError({ statusCode: 400, statusMessage: "参数无效" });
53: }
54:
55: const model = await getModelById(llmModelId, user.id);
56: if (!model) {
57: throw createError({ statusCode: 404, statusMessage: "模型不存在" });
58: }
59:
60: const provider = await getProviderById(model.providerId, user.id);
61: if (!provider) {
62: throw createError({ statusCode: 404, statusMessage: "供应商不存在" });
63: }
64:
65: if (provider.status !== "active") {
66: throw createError({ statusCode: 400, statusMessage: "供应商已禁用" });
67: }
68:
69: if (!provider.apiKey) {
70: throw createError({ statusCode: 400, statusMessage: "供应商未配置 API Key" });
71: }
72:
73: logger.info(
74: "[%s] [LLM-CHAT] userId=%d modelId=%d provider=%s parseMode=%s messages=%d thinking=%s tools=%s",
75: event.context.requestId ?? "-",
76: user.id,
77: llmModelId,
78: provider.name,
79: provider.parseMode,
80: messages.length,
81: enableThinking ? "on" : "off",
82: enableTools ? "on" : "off",
83: );
84:
85: const languageModel = resolveModel(provider, model.modelId);
86:
87: const tools = enableTools ? await getEnabledToolsForLlm() : undefined;
88:
89: const result = streamText({
90: model: languageModel,
91: messages,
92: maxTokens: model.maxTokens || undefined,
93: ...(tools && Object.keys(tools).length > 0
94: ? { tools, maxSteps: 10 }
95: : {}),
96: ...(enableThinking
97: ? {
98: providerOptions: {
99: openaiCompatible: { reasoningEffort: "high" },
100: },
101: }
102: : {}),
103: onError: (error) => {
104: logger.error("[%s] [LLM-CHAT] streamText error: %s", event.context.requestId ?? "-", error);
105: return error instanceof Error ? error.message : String(error);
106: },
107: onFinish: ({ finishReason, usage, steps }) => {
108: logger.info(
109: "[%s] [LLM-CHAT] finished: reason=%s steps=%d inputTokens=%d outputTokens=%d",
110: event.context.requestId ?? "-",
111: finishReason,
112: steps.length,
113: usage?.promptTokens ?? 0,
114: usage?.completionTokens ?? 0,
115: );
116: },
117: });
118:
119: return result.toDataStreamResponse({ sendReasoning: true });
120: });
(End of file - total 120 lines)
</content>
Assistant (Build · GLM-5.2 · 18.1s)
Tool: grep
Input:
{
"path": "/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts",
"pattern": "getEnabledToolsForLlm|execute"
}
Output:
Found 6 matches
/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts:
Line 12: // 立即注册 fetch 工具类型,确保在任何 execute 调用前完成
Line 122: export async function executeAgentTool(
Line 166: const result = await executor.execute(input, config, ctx);
Line 184: export async function getEnabledToolsForLlm(): Promise<Record<string, ReturnType<typeof tool>>> {
Line 215: execute: async (input: unknown) => {
Line 216: const execResult = await executeAgentTool(agentTool.id, input, null);
Assistant (Build · GLM-5.2 · 6.8s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts",
"offset": 180,
"limit": 60
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts</path>
<type>file</type>
<content>
180:
181: return result;
182: }
183:
184: export async function getEnabledToolsForLlm(): Promise<Record<string, ReturnType<typeof tool>>> {
185: const tools = await dbGlobal
186: .select()
187: .from(agentTools)
188: .where(eq(agentTools.enabled, 1))
189: .orderBy(asc(agentTools.sortOrder));
190:
191: const result: Record<string, any> = {};
192: for (const agentTool of tools) {
193: const executor = getExecutor(agentTool.type);
194: if (!executor) continue;
195: let config: unknown;
196: try {
197: config = JSON.parse(agentTool.config);
198: } catch {
199: config = agentTool.type === "fetch" ? DEFAULT_FETCH_CONFIG : {};
200: }
201:
202: const zodSchema = agentTool.type === "fetch" ? FETCH_INPUT_SCHEMA : z.object({});
203: const jsonSch = z.toJSONSchema(zodSchema) as Record<string, unknown>;
204:
205: result[agentTool.slug] = tool({
206: description: executor.buildDescription(config),
207: parameters: jsonSchema(jsonSch, {
208: validate: (v: unknown) => {
209: const r = zodSchema.safeParse(v);
210: return r.success
211: ? { success: true as const, value: r.data }
212: : { success: false as const, error: r.error };
213: },
214: }),
215: execute: async (input: unknown) => {
216: const execResult = await executeAgentTool(agentTool.id, input, null);
217: return execResult.success ? execResult.data : { error: execResult.error };
218: },
219: });
220: }
221: return result;
222: }
223:
224: export { DEFAULT_FETCH_CONFIG } from "./executors/fetch/config";
225: export type { FetchToolConfig } from "./executors/fetch/config";
226: export type { ToolExecutor, ToolContext, ToolResult } from "./registry";
(End of file - total 226 lines)
</content>
Assistant (Build · GLM-5.2 · 22.6s)
找到关键问题:第 217 行 return execResult.success ? execResult.data : { error: execResult.error }。
当工具失败时返回 { error: "..." },但模型可能不理解这是错误,继续尝试。而且掘金的 API 返回 HTTP 200 + err_no:2,工具标记为 success: true,模型看到 {"err_no":2,"err_msg":"请求路由不存在"} 后反复换 URL 重试。
修复方案:
- 工具失败时返回更明确的错误提示文本
maxSteps到上限时,ai-sdk 的finishReason会是tool-calls而非stop,模型不会输出最终文本——需要在前端检测这种情况并提示用户
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts",
"newString": " execute: async (input: unknown) => {\n const execResult = await executeAgentTool(agentTool.id, input, null);\n if (execResult.success) {\n return execResult.data;\n }\n // 返回明确的错误文本,引导模型停止重试\n return `工具执行失败: ${execResult.error ?? \"未知错误\"}。请停止调用此工具,基于已有信息回答用户或告知用户此工具不可用。`;\n },",
"oldString": " execute: async (input: unknown) => {\n const execResult = await executeAgentTool(agentTool.id, input, null);\n return execResult.success ? execResult.data : { error: execResult.error };\n },"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 24.0s)
现在处理 maxSteps 到上限的问题。ai-sdk 在 maxSteps 用完且最后一步是 tool-call 时,finishReason 是 tool-calls,stream 会直接结束,模型没有机会输出最终文本。
解决方案:把 maxSteps 降到 5(合理值),并在前端检测 finishReason=tool-calls 时给用户提示。但更好的方案是用 onStepFinish 在最后一步强制让模型回答。
实际上 ai-sdk v4 的 maxSteps 语义是:包含初始生成 + 后续 tool-call 轮次。maxSteps=10 意味着模型可以调用工具 9 次然后回答 1 次。但日志显示 steps=10 reason=tool-calls,说明 10 步全是 tool-call,模型从未生成最终文本。
把 maxSteps 降到 5,并在前端 composable 检测 stream 结束但无 text part 的情况:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts",
"newString": " ...(tools && Object.keys(tools).length > 0\n ? { tools, maxSteps: 5 }\n : {}),",
"oldString": " ...(tools && Object.keys(tools).length > 0\n ? { tools, maxSteps: 10 }\n : {}),"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 37.0s)
现在看前端 composable 的 stream 结束处理:
Tool: grep
Input:
{
"path": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"pattern": "processDataStream|onFinish|未返回|空内容"
}
Output:
Found 3 matches
/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts:
Line 1: import { processDataStream } from 'ai'
Line 126: await processDataStream({
Line 184: errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'
Assistant (Build · GLM-5.2 · 9.3s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"limit": 80,
"offset": 120
}
Output:
<path>/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts</path>
<type>file</type>
<content>
120: }
121:
122: if (!res.body) {
123: throw new Error('响应体为空')
124: }
125:
126: await processDataStream({
127: stream: res.body,
128: onReasoningPart: (text) => {
129: const msg = messages.value[assistantIdx]
130: if (!msg) return
131: let part = getOrCreateLastPart(msg, 'reasoning')
132: if (!part) {
133: if (reasoningStartTime === null) reasoningStartTime = Date.now()
134: part = { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true }
135: appendPart(msg, part)
136: }
137: part.text = (part.text ?? '') + text
138: },
139: onTextPart: (text) => {
140: const msg = messages.value[assistantIdx]
141: if (!msg) return
142: updateLastReasoningDuration(msg)
143: let part = getOrCreateLastPart(msg, 'text')
144: if (!part) {
145: part = { id: generateId(), type: 'text', text: '' }
146: appendPart(msg, part)
147: }
148: part.text = (part.text ?? '') + text
149: msg.content += text
150: },
151: onErrorPart: (error) => {
152: errorMessage.value = error || '流式响应出错'
153: },
154: onToolCallPart: (part) => {
155: const msg = messages.value[assistantIdx]
156: if (!msg) return
157: updateLastReasoningDuration(msg)
158: appendPart(msg, {
159: id: generateId(),
160: type: 'tool-call',
161: toolName: part.toolName,
162: toolCallId: part.toolCallId,
163: args: part.args,
164: state: 'call',
165: })
166: },
167: onToolResultPart: (part) => {
168: const msg = messages.value[assistantIdx]
169: if (!msg || !msg.parts) return
170: const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === part.toolCallId)
171: if (callPart) {
172: callPart.result = part.result
173: callPart.state = 'result'
174: }
175: },
176: })
177:
178: const msg = messages.value[assistantIdx]
179: if (msg) updateLastReasoningDuration(msg)
180:
181: // 检查是否有实际内容产出
182: const finalMsg = messages.value[assistantIdx]
183: if (finalMsg && !finalMsg.content && (!finalMsg.parts || finalMsg.parts.length === 0) && !errorMessage.value) {
184: errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'
185: messages.value.splice(assistantIdx, 1)
186: }
187: } catch (err: any) {
188: if (err.name === 'AbortError') {
189: // user stopped
190: } else {
191: errorMessage.value = err.message || '请求失败'
192: const msg = messages.value[assistantIdx]
193: if (msg && !msg.content && (!msg.parts || msg.parts.length === 0)) {
194: messages.value.splice(assistantIdx, 1)
195: }
196: }
197: } finally {
198: isLoading.value = false
199: abortController = null
(Showing lines 120-199 of 223. Use offset=200 to continue.)
</content>
Assistant (Build · GLM-5.2 · 25.0s)
问题在第 183 行:检查条件是 !finalMsg.content && parts.length === 0,但当模型调了工具后,parts 数组里有 tool-call 和 tool-result part,content 为空字符串。条件不满足,所以不会显示错误提示,用户看到的就是"有工具调用但没文本回答"的空状态。
修复:如果 stream 结束后只有 tool-call part 没有 text part,说明 maxSteps 用完了,应该提示用户。
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"newString": " // 检查是否有实际内容产出\n const finalMsg = messages.value[assistantIdx]\n if (finalMsg && !errorMessage.value) {\n const hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text)\n const hasToolCall = finalMsg.parts?.some(p => p.type === 'tool-call')\n if (!hasText && !hasToolCall) {\n // 完全无内容,移除空消息\n errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'\n messages.value.splice(assistantIdx, 1)\n } else if (!hasText && hasToolCall) {\n // 有工具调用但无最终文本回答(maxSteps 用完)\n errorMessage.value = '模型达到工具调用次数上限,未能生成最终回答。请简化问题或调整工具配置后重试。'\n }\n }",
"oldString": " // 检查是否有实际内容产出\n const finalMsg = messages.value[assistantIdx]\n if (finalMsg && !finalMsg.content && (!finalMsg.parts || finalMsg.parts.length === 0) && !errorMessage.value) {\n errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'\n messages.value.splice(assistantIdx, 1)\n }"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 57.9s)
现在验证 typecheck:
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && npx nuxi typecheck 2>&1 | tail -30",
"description": "运行 typecheck",
"timeout": 120000
}
Output:
Type 'undefined' is not assignable to type 'string | null'.
server/api/llm/chat/index.post.ts(103,5): error TS2322: Type '(error: { error: unknown; }) => string' is not assignable to type 'StreamTextOnErrorCallback'.
Type 'string' is not assignable to type 'void | Promise<void>'.
server/api/users/batch.post.ts(28,61): error TS7006: Parameter 'n' implicitly has an 'any' type.
server/api/users/index.get.ts(38,21): error TS2769: No overload matches this call.
Overload 1 of 3, '(left: SQLiteColumn<{ name: "role"; tableName: "users"; dataType: "string"; columnType: "SQLiteText"; data: "admin" | "user"; driverParam: string; notNull: true; hasDefault: true; isPrimaryKey: false; isAutoincrement: false; ... 4 more ...; generated: undefined; }, {}, { ...; }>, right: "admin" | ... 1 more ... | SQLWrapper): SQL<...>', gave the following error.
Argument of type 'string' is not assignable to parameter of type '"admin" | "user" | SQLWrapper'.
Overload 2 of 3, '(left: Aliased<string>, right: string | SQLWrapper): SQL<unknown>', gave the following error.
Argument of type 'SQLiteColumn<{ name: "role"; tableName: "users"; dataType: "string"; columnType: "SQLiteText"; data: "admin" | "user"; driverParam: string; notNull: true; hasDefault: true; isPrimaryKey: false; isAutoincrement: false; ... 4 more ...; generated: undefined; }, {}, { ...; }>' is not assignable to parameter of type 'Aliased<string>'.
Type 'SQLiteColumn<{ name: "role"; tableName: "users"; dataType: "string"; columnType: "SQLiteText"; data: "admin" | "user"; driverParam: string; notNull: true; hasDefault: true; isPrimaryKey: false; isAutoincrement: false; ... 4 more ...; generated: undefined; }, {}, { ...; }>' is missing the following properties from type 'Aliased<string>': sql, fieldAlias
Overload 3 of 3, '(left: never, right: unknown): SQL<unknown>', gave the following error.
Argument of type 'SQLiteColumn<{ name: "role"; tableName: "users"; dataType: "string"; columnType: "SQLiteText"; data: "admin" | "user"; driverParam: string; notNull: true; hasDefault: true; isPrimaryKey: false; isAutoincrement: false; ... 4 more ...; generated: undefined; }, {}, { ...; }>' is not assignable to parameter of type 'never'.
server/service/agent-tool/index.ts(147,5): error TS2741: Property 'data' is missing in type '{ success: false; error: string; metadata: { durationMs: number; }; }' but required in type 'ToolResult'.
server/service/chat/index.ts(124,5): error TS2741: Property 'where' is missing in type 'Omit<import("/home/dash/coding/nuxt-app/node_modules/.bun/drizzle-orm@0.45.2+988eccb4023f4f3f/node_modules/drizzle-orm/sqlite-core/query-builders/select").SQLiteSelectBase<"chat_messages", "async", import("/home/dash/coding/nuxt-app/node_modules/.bun/@libsql+core@0.17.3/node_modules/@libsql/core/lib-esm/api").Result...' but required in type 'Omit<import("/home/dash/coding/nuxt-app/node_modules/.bun/drizzle-orm@0.45.2+988eccb4023f4f3f/node_modules/drizzle-orm/sqlite-core/query-builders/select").SQLiteSelectBase<"chat_messages", "async", import("/home/dash/coding/nuxt-app/node_modules/.bun/@libsql+core@0.17.3/node_modules/@libsql/core/lib-esm/api").Result...'.
server/service/chat/index.ts(221,12): error TS2339: Property 'count' does not exist on type '{ count: number; } | undefined'.
server/service/favorite/index.ts(172,9): error TS2322: Type '{ id: number; type: "text" | "image" | "image-text" | "project"; title: string; description: string | null; aspectRatio: number | null; categoryId: string | null; createdAt: Date; updatedAt: Date; images: { ...; }[]; tags: { ...; }[]; articles: never[]; }[]' is not assignable to type 'CardWithRelations[]'.
Property 'content' is missing in type '{ id: number; type: CardWithRelations["type"]; title: string; description: string | null; aspectRatio: number | null; categoryId: string | null; createdAt: Date; updatedAt: Date; images: { ...; }[]; tags: { ...; }[]; articles: never[]; }' but required in type 'CardWithRelations'.
server/service/ideas/index.ts(35,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
ERROR Process exited with non-zero status (2)
at z._waitForOutput (node_modules/.bun/tinyexec@1.1.2/node_modules/tinyexec/dist/main.mjs:578:92)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Object.run (node_modules/.bun/@nuxt+cli@3.35.2+eaef1d45de35c53b/node_modules/@nuxt/cli/dist/typecheck-DQJUWomH.mjs:65:3)
at async runCommand (node_modules/.bun/citty@0.2.2/node_modules/citty/dist/index.mjs:228:47)
at async runCommand (node_modules/.bun/citty@0.2.2/node_modules/citty/dist/index.mjs:217:5)
at async runMain (node_modules/.bun/citty@0.2.2/node_modules/citty/dist/index.mjs:394:10)
Assistant (Build · GLM-5.2 · 17.7s)
有两个我们引入的错误需要修复:
server/api/llm/chat/index.post.ts(103,5)—onError返回 string 但期望 voidserver/service/agent-tool/index.ts(147,5)—ToolResult的data字段必填
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/registry.ts",
"limit": 40
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/registry.ts</path>
<type>file</type>
<content>
1: import type { JSONSchema7 } from "json-schema";
2:
3: export interface ToolExecutor<TConfig> {
4: buildInputSchema(config: TConfig): JSONSchema7;
5: buildDescription(config: TConfig): string;
6: execute(input: unknown, config: TConfig, ctx: ToolContext): Promise<ToolResult>;
7: }
8:
9: export interface ToolContext {
10: toolId: string;
11: toolSlug: string;
12: userId: number | null;
13: }
14:
15: export interface ToolResult {
16: success: boolean;
17: data: unknown;
18: error?: string;
19: metadata?: {
20: statusCode?: number;
21: responseSize?: number;
22: durationMs: number;
23: };
24: }
25:
26: const registry = new Map<string, ToolExecutor<any>>();
27:
28: export function registerToolType(type: string, executor: ToolExecutor<any>): void {
29: registry.set(type, executor);
30: }
31:
32: export function getExecutor(type: string): ToolExecutor<any> | undefined {
33: return registry.get(type);
34: }
35:
36: export function listToolTypes(): string[] {
37: return Array.from(registry.keys());
38: }
(End of file - total 38 lines)
</content>
Assistant (Build · GLM-5.2 · 21.1s)
data: unknown 是必填的。修复 ToolResult 让 data 可选,或者在失败时传 null:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/registry.ts",
"newString": "export interface ToolResult {\n success: boolean;\n data?: unknown;\n error?: string;\n metadata?: {\n statusCode?: number;\n responseSize?: number;\n durationMs: number;\n };\n}",
"oldString": "export interface ToolResult {\n success: boolean;\n data: unknown;\n error?: string;\n metadata?: {\n statusCode?: number;\n responseSize?: number;\n durationMs: number;\n };\n}"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 16.1s)
现在修复 onError 返回类型——ai-sdk v4 的 onError 回调返回 void,不能返回 string。错误需要通过其他方式传递:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts",
"newString": " onError: (errorData) => {\n const errMsg = errorData?.error instanceof Error\n ? errorData.error.message\n : String(errorData?.error ?? \"未知错误\");\n logger.error(\"[%s] [LLM-CHAT] streamText error: %s\", event.context.requestId ?? \"-\", errMsg);\n },",
"oldString": " onError: (error) => {\n logger.error(\"[%s] [LLM-CHAT] streamText error: %s\", event.context.requestId ?? \"-\", error);\n return error instanceof Error ? error.message : String(error);\n },"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 58.6s)
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && npx nuxi typecheck 2>&1 | grep -E \"agent-tool|llm/chat\" | head -20",
"description": "检查相关文件 typecheck",
"timeout": 120000
}
Output:
(no output)
Assistant (Build · GLM-5.2 · 27.2s)
我们引入的 typecheck 错误已全部修复。总结改动:
- fetch executor:HTTP 非 2xx 时返回
success: false+ 错误信息(之前 HTTP 200 但业务错误仍返回 success) - 工具失败提示:失败时返回明确的文本提示,引导模型停止重试(之前返回
{ error: "..." }对象,模型不理解) - maxSteps 降到 5:避免无限工具调用循环
- 前端检测:stream 结束后如果有 tool-call 但无 text part,提示用户"达到工具调用次数上限"
- ToolResult.data 改为可选:失败时不需要传 data
- onError 回调修复:返回 void 而非 string(ai-sdk v4 要求)
现在再测试,模型应该会在工具失败后停止重试并告知用户,或者达到 5 步上限时前端会显示明确提示。
User
工具失败整个agent就失败了吗?不需要模型最后说一下?
Assistant (Build · GLM-5.2 · 37.3s)
你说得对。maxSteps 到上限后 stream 直接结束,模型没机会说"我试了但失败了"。应该让模型在工具失败后仍能输出最终文本。
ai-sdk v4 的 maxSteps 语义:每一步要么生成文本(结束),要么生成 tool-call(继续)。到上限后如果是 tool-call,stream 就断了。
正确做法:工具失败时不要消耗 step,或者让模型知道失败后自己选择停止。但 ai-sdk 没有前者机制。
实际最简方案:maxSteps 设大一点(比如 8),但给工具的 execute 返回明确错误文本,模型看到失败后通常会在下一 step 生成文本回答而非继续调工具。之前的问题是掘金 API 返回 HTTP 200 + err_no:2,工具标记 success,模型以为成功但看到奇怪结果就反复换 URL。
现在已修复 HTTP 非 2xx 返回失败 + 失败文本引导模型停止。把 maxSteps 调回 8 给模型留余地:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts",
"newString": " ...(tools && Object.keys(tools).length > 0\n ? { tools, maxSteps: 8 }\n : {}),",
"oldString": " ...(tools && Object.keys(tools).length > 0\n ? { tools, maxSteps: 5 }\n : {}),"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 27.5s)
但掘金那个案例是 HTTP 200 + err_no:2,工具仍标记 success。模型看到 {"err_no":2,"err_msg":"请求路由不存在"} 后不理解这是错误,继续换 URL。需要在工具结果里给模型更明确的判断提示。
看下当前工具返回给模型的内容:
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts",
"limit": 15,
"offset": 213
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts</path>
<type>file</type>
<content>
213: },
214: }),
215: execute: async (input: unknown) => {
216: const execResult = await executeAgentTool(agentTool.id, input, null);
217: if (execResult.success) {
218: return execResult.data;
219: }
220: // 返回明确的错误文本,引导模型停止重试
221: return `工具执行失败: ${execResult.error ?? "未知错误"}。请停止调用此工具,基于已有信息回答用户或告知用户此工具不可用。`;
222: },
223: });
224: }
225: return result;
226: }
227:
(Showing lines 213-227 of 230. Use offset=228 to continue.)
</content>
Assistant (Build · GLM-5.2 · 44.0s)
成功时直接返回 execResult.data,但掘金返回的 {"err_no":2,"err_msg":"请求路由不存在"} 对模型来说不够明确。模型看到 JSON 就以为抓到了内容,继续尝试。
改进:成功时也包装一下,给模型上下文提示这是原始响应内容,需要自行判断是否有效:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts",
"newString": " execute: async (input: unknown) => {\n const execResult = await executeAgentTool(agentTool.id, input, null);\n if (!execResult.success) {\n return `工具执行失败: ${execResult.error ?? \"未知错误\"}。请停止调用此工具,基于已有信息回答用户或告知用户此工具不可用。`;\n }\n // 成功时返回内容,附带元信息帮助模型判断结果是否有效\n const meta = execResult.metadata ?? {};\n const sizeInfo = meta.responseSize ? `${meta.responseSize} bytes` : \"未知大小`;\n const statusInfo = meta.statusCode ? `HTTP ${meta.statusCode}` : \"\";\n const dataStr = typeof execResult.data === \"string\" ? execResult.data : JSON.stringify(execResult.data);\n return `[fetch 结果 ${statusInfo} ${sizeInfo}]\\n${dataStr}`;\n },",
"oldString": " execute: async (input: unknown) => {\n const execResult = await executeAgentTool(agentTool.id, input, null);\n if (execResult.success) {\n return execResult.data;\n }\n // 返回明确的错误文本,引导模型停止重试\n return `工具执行失败: ${execResult.error ?? \"未知错误\"}。请停止调用此工具,基于已有信息回答用户或告知用户此工具不可用。`;\n },"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 24.0s)
现在前端那个"达到上限"的提示也要调整——不应该 splice 消息,而是保留工具调用记录,让用户看到模型尝试了什么:
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"limit": 20,
"offset": 178
}
Output:
<path>/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts</path>
<type>file</type>
<content>
178: const msg = messages.value[assistantIdx]
179: if (msg) updateLastReasoningDuration(msg)
180:
181: // 检查是否有实际内容产出
182: const finalMsg = messages.value[assistantIdx]
183: if (finalMsg && !errorMessage.value) {
184: const hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text)
185: const hasToolCall = finalMsg.parts?.some(p => p.type === 'tool-call')
186: if (!hasText && !hasToolCall) {
187: // 完全无内容,移除空消息
188: errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'
189: messages.value.splice(assistantIdx, 1)
190: } else if (!hasText && hasToolCall) {
191: // 有工具调用但无最终文本回答(maxSteps 用完)
192: errorMessage.value = '模型达到工具调用次数上限,未能生成最终回答。请简化问题或调整工具配置后重试。'
193: }
194: }
195: } catch (err: any) {
196: if (err.name === 'AbortError') {
197: // user stopped
(Showing lines 178-197 of 231. Use offset=198 to continue.)
</content>
Assistant (Build · GLM-5.2 · 47.0s)
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"newString": " // 检查是否有实际内容产出\n const finalMsg = messages.value[assistantIdx]\n if (finalMsg && !errorMessage.value) {\n const hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text)\n const hasToolCall = finalMsg.parts?.some(p => p.type === 'tool-call')\n if (!hasText && !hasToolCall) {\n // 完全无内容,移除空消息\n errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'\n messages.value.splice(assistantIdx, 1)\n } else if (!hasText && hasToolCall) {\n // 有工具调用但无最终文本回答(maxSteps 用完),保留工具记录,追加提示\n finalMsg.parts?.push({\n id: generateId(),\n type: 'text',\n text: '(已达到工具调用次数上限,模型未能生成最终回答。以上是工具调用的尝试记录。)',\n })\n }\n }",
"oldString": " // 检查是否有实际内容产出\n const finalMsg = messages.value[assistantIdx]\n if (finalMsg && !errorMessage.value) {\n const hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text)\n const hasToolCall = finalMsg.parts?.some(p => p.type === 'tool-call')\n if (!hasText && !hasToolCall) {\n // 完全无内容,移除空消息\n errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'\n messages.value.splice(assistantIdx, 1)\n } else if (!hasText && hasToolCall) {\n // 有工具调用但无最终文本回答(maxSteps 用完)\n errorMessage.value = '模型达到工具调用次数上限,未能生成最终回答。请简化问题或调整工具配置后重试。'\n }\n }"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 70.8s)
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && npx nuxi typecheck 2>&1 | grep -E \"agent-tool|llm/chat|useLlmChat\" | head -10",
"description": "检查相关文件 typecheck",
"timeout": 120000
}
Output:
server/service/agent-tool/index.ts(222,84): error TS1002: Unterminated string literal.
server/service/agent-tool/index.ts(222,84): error TS1002: Unterminated string literal.
Assistant (Build · GLM-5.2 · 18.9s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts",
"limit": 10,
"offset": 218
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts</path>
<type>file</type>
<content>
218: return `工具执行失败: ${execResult.error ?? "未知错误"}。请停止调用此工具,基于已有信息回答用户或告知用户此工具不可用。`;
219: }
220: // 成功时返回内容,附带元信息帮助模型判断结果是否有效
221: const meta = execResult.metadata ?? {};
222: const sizeInfo = meta.responseSize ? `${meta.responseSize} bytes` : "未知大小`;
223: const statusInfo = meta.statusCode ? `HTTP ${meta.statusCode}` : "";
224: const dataStr = typeof execResult.data === "string" ? execResult.data : JSON.stringify(execResult.data);
225: return `[fetch 结果 ${statusInfo} ${sizeInfo}]\n${dataStr}`;
226: },
227: });
(Showing lines 218-227 of 234. Use offset=228 to continue.)
</content>
Assistant (Build · GLM-5.2 · 60.4s)
看起来没问题,可能是 typecheck 缓存。重新检查:
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && npx nuxi typecheck 2>&1 | grep \"agent-tool/index.ts\" | head -5",
"description": "重新检查 agent-tool typecheck",
"timeout": 120000
}
Output:
server/service/agent-tool/index.ts(222,84): error TS1002: Unterminated string literal.
server/service/agent-tool/index.ts(222,84): error TS1002: Unterminated string literal.
Assistant (Build · GLM-5.2 · 35.5s)
第 222 行第 84 列。看下实际字节:
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && sed -n '222p' server/service/agent-tool/index.ts | cat -A | head -1",
"description": "查看第222行字节"
}
Output:
const sizeInfo = meta.responseSize ? `${meta.responseSize} bytes` : "M-fM-^\M-*M-gM-^_M-%M-eM-$M-'M-eM-0M-^O`;$
Assistant (Build · GLM-5.2 · 18.7s)
"未知大小" 的引号是中文引号 " " 而非 ASCII 引号。修复:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts",
"newString": " const sizeInfo = meta.responseSize ? `${meta.responseSize} bytes` : \"未知大小\";",
"oldString": " const sizeInfo = meta.responseSize ? `${meta.responseSize} bytes` : \"未知大小\";"
}
Error:
No changes to apply: oldString and newString are identical.
Assistant (Build · GLM-5.2 · 24.6s)
edit 工具看到的是相同字符串。用 bash 直接替换:
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && perl -i -pe 's/\\xe2\\x80\\x9c未知大小\\xe2\\x80\\x9d/\"未知大小\"/g' server/service/agent-tool/index.ts && sed -n '222p' server/service/agent-tool/index.ts | cat -A",
"description": "替换中文引号为ASCII引号"
}
Output:
const sizeInfo = meta.responseSize ? `${meta.responseSize} bytes` : "M-fM-^\M-*M-gM-^_M-%M-eM-$M-'M-eM-0M-^O`;$
Assistant (Build · GLM-5.2 · 56.9s)
没替换到。看下实际编码:
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && sed -n '222p' server/service/agent-tool/index.ts | hexdump -C | head -5",
"description": "查看hex编码"
}
Output:
00000000 20 20 20 20 20 20 20 20 63 6f 6e 73 74 20 73 69 | const si|
00000010 7a 65 49 6e 66 6f 20 3d 20 6d 65 74 61 2e 72 65 |zeInfo = meta.re|
00000020 73 70 6f 6e 73 65 53 69 7a 65 20 3f 20 60 24 7b |sponseSize ? `${|
00000030 6d 65 74 61 2e 72 65 73 70 6f 6e 73 65 53 69 7a |meta.responseSiz|
00000040 65 7d 20 62 79 74 65 73 60 20 3a 20 22 e6 9c aa |e} bytes` : "...|
Assistant (Build · GLM-5.2 · 23.4s)
User
我改了,但类型好像有点问题
Assistant (Build · GLM-5.2 · 65.1s)
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && npx nuxi typecheck 2>&1 | grep -E \"agent-tool|llm/chat|useLlmChat\" | head -10",
"description": "检查相关文件 typecheck",
"timeout": 120000
}
Output:
app/composables/useLlmChat.ts(68,11): error TS18048: 'p' is possibly 'undefined'.
app/composables/useLlmChat.ts(68,37): error TS18048: 'p' is possibly 'undefined'.
app/composables/useLlmChat.ts(69,9): error TS18048: 'p' is possibly 'undefined'.
server/service/agent-tool/index.ts(222,31): error TS2339: Property 'responseSize' does not exist on type '{}'.
server/service/agent-tool/index.ts(222,54): error TS2339: Property 'responseSize' does not exist on type '{}'.
server/service/agent-tool/index.ts(223,33): error TS2339: Property 'statusCode' does not exist on type '{}'.
server/service/agent-tool/index.ts(223,59): error TS2339: Property 'statusCode' does not exist on type '{}'.
server/service/agent-tool/index.ts(222,31): error TS2339: Property 'responseSize' does not exist on type '{}'.
server/service/agent-tool/index.ts(222,54): error TS2339: Property 'responseSize' does not exist on type '{}'.
server/service/agent-tool/index.ts(223,33): error TS2339: Property 'statusCode' does not exist on type '{}'.
Assistant (Build · GLM-5.2 · 20.7s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts",
"limit": 15,
"offset": 215
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts</path>
<type>file</type>
<content>
215: execute: async (input: unknown) => {
216: const execResult = await executeAgentTool(agentTool.id, input, null);
217: if (!execResult.success) {
218: return `工具执行失败: ${execResult.error ?? "未知错误"}。请停止调用此工具,基于已有信息回答用户或告知用户此工具不可用。`;
219: }
220: // 成功时返回内容,附带元信息帮助模型判断结果是否有效
221: const meta = execResult.metadata ?? {};
222: const sizeInfo = meta.responseSize ? `${meta.responseSize} bytes` : `未知大小`;
223: const statusInfo = meta.statusCode ? `HTTP ${meta.statusCode}` : "";
224: const dataStr = typeof execResult.data === "string" ? execResult.data : JSON.stringify(execResult.data);
225: return `[fetch 结果 ${statusInfo} ${sizeInfo}]\n${dataStr}`;
226: },
227: });
228: }
229: return result;
(Showing lines 215-229 of 234. Use offset=230 to continue.)
</content>
Assistant (Build · GLM-5.2 · 32.9s)
metadata 类型推断为 {}。需要显式类型标注。同时看下 useLlmChat.ts:68:
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"limit": 10,
"offset": 63
}
Output:
<path>/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts</path>
<type>file</type>
<content>
63:
64: function updateLastReasoningDuration(msg: LlmChatMessage) {
65: if (!msg.parts) return
66: for (let i = msg.parts.length - 1; i >= 0; i--) {
67: const p = msg.parts[i]
68: if (p.type === 'reasoning' && p.reasoningLoading) {
69: p.reasoningLoading = false
70: return
71: }
72: }
(Showing lines 63-72 of 235. Use offset=73 to continue.)
</content>
Assistant (Build · GLM-5.2 · 23.0s)
p 可能 undefined(noUncheckedIndexedAccess)。修复两处:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts",
"newString": " // 成功时返回内容,附带元信息帮助模型判断结果是否有效\n const meta = execResult.metadata;\n const sizeInfo = meta?.responseSize ? `${meta.responseSize} bytes` : `未知大小`;\n const statusInfo = meta?.statusCode ? `HTTP ${meta.statusCode}` : \"\";",
"oldString": " // 成功时返回内容,附带元信息帮助模型判断结果是否有效\n const meta = execResult.metadata ?? {};\n const sizeInfo = meta.responseSize ? `${meta.responseSize} bytes` : `未知大小`;\n const statusInfo = meta.statusCode ? `HTTP ${meta.statusCode}` : \"\";"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 22.3s)
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts",
"newString": " for (let i = msg.parts.length - 1; i >= 0; i--) {\n const p = msg.parts[i]\n if (!p) continue\n if (p.type === 'reasoning' && p.reasoningLoading) {",
"oldString": " for (let i = msg.parts.length - 1; i >= 0; i--) {\n const p = msg.parts[i]\n if (p.type === 'reasoning' && p.reasoningLoading) {"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 54.6s)
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && npx nuxi typecheck 2>&1 | grep -E \"agent-tool|llm/chat|useLlmChat\" | head -10",
"description": "重新检查 typecheck",
"timeout": 120000
}
Output:
(no output)
Assistant (Build · GLM-5.2 · 26.1s)
类型错误已全部修复。现在再测试,模型应该会在工具失败后输出最终文本回答,而不是静默中断。
User
回答部分没有Markdown渲染,要用markdown增量渲染
Assistant (Build · GLM-5.2 · 31.7s)
看下当前 assistant text part 的渲染方式:
Tool: grep
Input:
{
"path": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"pattern": "assistant-text|text part|markdown|v-html"
}
Output:
Found 2 matches
/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue:
Line 278: <div v-else-if="part.type === 'text'" class="assistant-text">
Line 799: .assistant-text {
Assistant (Build · GLM-5.2 · 13.7s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"limit": 15,
"offset": 275
}
Output:
<path>/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue</path>
<type>file</type>
<content>
275: </div>
276:
277: <!-- Text part -->
278: <div v-else-if="part.type === 'text'" class="assistant-text">
279: {{ part.text }}
280: </div>
281: </template>
282: </div>
283: </div>
284: </template>
285: </div>
286:
287: <div v-if="errorMessage" class="chat-error">
288: <Icon name="lucide:alert-circle" />
289: {{ errorMessage }}
(Showing lines 275-289 of 982. Use offset=290 to continue.)
</content>
Assistant (Build · GLM-5.2 · 27.5s)
现在用的是 {{ part.text }} 纯文本。看下项目里有没有现成的 markdown 渲染组件或依赖:
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && grep -E \"markdown|marked|markdown-it|remark|mdsvex\" package.json | head -10",
"description": "查找 markdown 依赖"
}
Output:
"marked": "12.0.2",
Assistant (Build · GLM-5.2 · 26.0s)
Tool: grep
Input:
{
"pattern": "marked|markdown",
"include": "*.vue",
"path": "/home/dash/coding/nuxt-app/app"
}
Output:
Found 19 matches
/home/dash/coding/nuxt-app/app/components/AgentToolFormModal.vue:
Line 19: parseMode: "markdown",
/home/dash/coding/nuxt-app/app/pages/articles/[id].vue:
Line 540: .detail-content :deep(.markdown-body) {
Line 547: .detail-content :deep(.markdown-body h1),
Line 548: .detail-content :deep(.markdown-body h2),
Line 549: .detail-content :deep(.markdown-body h3),
Line 550: .detail-content :deep(.markdown-body h4),
Line 551: .detail-content :deep(.markdown-body h5),
Line 552: .detail-content :deep(.markdown-body h6) {
Line 559: .detail-content :deep(.markdown-body a) {
Line 563: .detail-content :deep(.markdown-body blockquote) {
Line 568: .detail-content :deep(.markdown-body code) {
Line 576: .detail-content :deep(.markdown-body pre) {
Line 581: .detail-content :deep(.markdown-body img) {
Line 585: .detail-content :deep(.markdown-body table) {
Line 590: .detail-content :deep(.markdown-body th),
Line 591: .detail-content :deep(.markdown-body td) {
Line 597: .detail-content :deep(.markdown-body th) {
/home/dash/coding/nuxt-app/app/components/index/CardDetailModal.vue:
Line 5: import { marked } from 'marked'
Line 103: return marked(props.card.content, { breaks: true }) as string
Assistant (Build · GLM-5.2 · 19.5s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/components/index/CardDetailModal.vue",
"limit": 15,
"offset": 1
}
Output:
<path>/home/dash/coding/nuxt-app/app/components/index/CardDetailModal.vue</path>
<type>file</type>
<content>
1: <script setup lang="ts">
2: import type { CategoryNode } from './CategoryTreeNode.vue'
3: import { getTypeLabel, type CardType } from '~/config/cardTypes'
4: import { request } from '~/utils/http/factory'
5: import { marked } from 'marked'
6:
7: export interface CardDetail {
8: id: number
9: type: CardType
10: image?: string
11: images?: string[]
12: title: string
13: description?: string
14: content?: string | null
15: tags?: string[]
(Showing lines 1-15 of 594. Use offset=16 to continue.)
</content>
Assistant (Build · GLM-5.2 · 14.7s)
项目用 marked 渲染 markdown。但流式增量渲染需要注意:每次 text 更新都要重新 parse,且要避免光标跳动。
看下 llm-test 页面的 script 部分,了解现有 imports:
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"limit": 30,
"offset": 1
}
Output:
<path>/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue</path>
<type>file</type>
<content>
1: <script setup lang="ts">
2: interface ModelOption {
3: id: number
4: name: string
5: modelId: string
6: type: string
7: maxTokens: number | null
8: }
9:
10: interface ProviderWithModels {
11: id: number
12: name: string
13: parseMode: string
14: status: string
15: models: ModelOption[]
16: }
17:
18: const { $toast } = useNuxtApp()
19:
20: const { data: modelsData, refresh: refreshModels } = await useHttpFetch('/api/llm/chat/models', {
21: getCachedData: () => undefined,
22: })
23:
24: onActivated(() => {
25: refreshModels()
26: })
27:
28: const providers = computed<ProviderWithModels[]>(() => (modelsData.value as any) ?? [])
29:
30: const allModels = computed(() => {
(Showing lines 1-30 of 982. Use offset=31 to continue.)
</content>
Assistant (Build · GLM-5.2 · 22.7s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"limit": 20,
"offset": 795
}
Output:
<path>/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue</path>
<type>file</type>
<content>
795: flex-direction: column;
796: gap: 8px;
797: }
798:
799: .assistant-text {
800: background: var(--color-surface-card);
801: border: 1px solid var(--color-hairline);
802: border-radius: 12px;
803: padding: 12px 16px;
804: color: var(--color-ink);
805: font-size: 14px;
806: line-height: 1.7;
807: white-space: pre-wrap;
808: word-break: break-word;
809: }
810:
811: .assistant-loading {
812: display: flex;
813: gap: 4px;
814: padding: 12px 16px;
(Showing lines 795-814 of 982. Use offset=815 to continue.)
</content>
Assistant (Build · GLM-5.2 · 39.4s)
现在改造:用 marked 做增量 markdown 渲染。流式时每次 text 更新重新 parse 整个 part.text,用 v-html 输出。
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"newString": "<script setup lang=\"ts\">\nimport { marked } from 'marked'\n\ninterface ModelOption {",
"oldString": "<script setup lang=\"ts\">\ninterface ModelOption {"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 18.7s)
现在添加一个 computed 函数来渲染 markdown,并修改模板:
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"newString": " <!-- Text part -->\n <div v-else-if=\"part.type === 'text'\" class=\"assistant-text markdown-body\" v-html=\"renderMarkdown(part.text || '')\">\n </div>",
"oldString": " <!-- Text part -->\n <div v-else-if=\"part.type === 'text'\" class=\"assistant-text\">\n {{ part.text }}\n </div>"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 17.8s)
现在在 script 中添加 renderMarkdown 函数。找一个合适的位置:
Tool: grep
Input:
{
"path": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"pattern": "function toggleReasoning|const collapsedReasoning"
}
Output:
Found 2 matches
/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue:
Line 61: const collapsedReasoning = ref<Set<string>>(new Set())
Line 63: function toggleReasoning(partId: string) {
Assistant (Build · GLM-5.2 · 29.9s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"limit": 15,
"offset": 58
}
Output:
<path>/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue</path>
<type>file</type>
<content>
58: })
59:
60: const inputMessage = ref('')
61: const collapsedReasoning = ref<Set<string>>(new Set())
62:
63: function toggleReasoning(partId: string) {
64: if (collapsedReasoning.value.has(partId)) {
65: collapsedReasoning.value.delete(partId)
66: } else {
67: collapsedReasoning.value.add(partId)
68: }
69: collapsedReasoning.value = new Set(collapsedReasoning.value)
70: }
71:
72: watch(messages, () => {
(Showing lines 58-72 of 983. Use offset=73 to continue.)
</content>
Assistant (Build · GLM-5.2 · 21.7s)
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"newString": "const inputMessage = ref('')\nconst collapsedReasoning = ref<Set<string>>(new Set())\n\nmarked.setOptions({ breaks: true, gfm: true })\n\nfunction renderMarkdown(text: string): string {\n if (!text) return ''\n try {\n return marked.parse(text, { async: false }) as string\n } catch {\n return text\n }\n}\n\nfunction toggleReasoning(partId: string) {",
"oldString": "const inputMessage = ref('')\nconst collapsedReasoning = ref<Set<string>>(new Set())\n\nfunction toggleReasoning(partId: string) {"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 40.9s)
现在调整 .assistant-text 样式,去掉 white-space: pre-wrap(markdown 渲染后不需要):
Tool: edit
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/pages/settings/llm-test/index.vue",
"newString": ".assistant-text {\n background: var(--color-surface-card);\n border: 1px solid var(--color-hairline);\n border-radius: 12px;\n padding: 12px 16px;\n color: var(--color-ink);\n font-size: 14px;\n line-height: 1.7;\n word-break: break-word;\n}\n\n.assistant-text :deep(p) {\n margin: 0 0 8px;\n}\n\n.assistant-text :deep(p:last-child) {\n margin-bottom: 0;\n}\n\n.assistant-text :deep(pre) {\n background: var(--color-surface-2, #f6f8fa);\n border-radius: 8px;\n padding: 12px;\n overflow-x: auto;\n margin: 8px 0;\n}\n\n.assistant-text :deep(code) {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 13px;\n}\n\n.assistant-text :deep(pre code) {\n background: none;\n padding: 0;\n}\n\n.assistant-text :deep(:not(pre) > code) {\n background: var(--color-surface-2, #f6f8fa);\n padding: 2px 6px;\n border-radius: 4px;\n}\n\n.assistant-text :deep(ul),\n.assistant-text :deep(ol) {\n margin: 8px 0;\n padding-left: 24px;\n}\n\n.assistant-text :deep(li) {\n margin: 4px 0;\n}\n\n.assistant-text :deep(blockquote) {\n border-left: 3px solid var(--color-hairline);\n margin: 8px 0;\n padding: 4px 12px;\n color: var(--color-ink-soft, #666);\n}\n\n.assistant-text :deep(h1),\n.assistant-text :deep(h2),\n.assistant-text :deep(h3),\n.assistant-text :deep(h4) {\n margin: 12px 0 8px;\n font-weight: 600;\n}\n\n.assistant-text :deep(a) {\n color: var(--color-primary, #0969da);\n text-decoration: none;\n}\n\n.assistant-text :deep(table) {\n border-collapse: collapse;\n margin: 8px 0;\n width: 100%;\n}\n\n.assistant-text :deep(th),\n.assistant-text :deep(td) {\n border: 1px solid var(--color-hairline);\n padding: 6px 12px;\n text-align: left;\n}",
"oldString": ".assistant-text {\n background: var(--color-surface-card);\n border: 1px solid var(--color-hairline);\n border-radius: 12px;\n padding: 12px 16px;\n color: var(--color-ink);\n font-size: 14px;\n line-height: 1.7;\n white-space: pre-wrap;\n word-break: break-word;\n}"
}
Output:
Edit applied successfully.
Assistant (Build · GLM-5.2 · 70.6s)
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && npx nuxi typecheck 2>&1 | grep -E \"llm-test\" | head -10",
"description": "检查 llm-test typecheck",
"timeout": 120000
}
Output:
(no output)
Assistant (Build · GLM-5.2 · 38.5s)
typecheck 通过。现在 assistant 的 text part 会用 marked 实时渲染 markdown(包括流式增量更新时每次重新 parse),支持代码块、列表、表格、链接等 GFM 语法。
User
总结开发的工具相关的功能并生成文档,方便下次开发
Assistant (Build · GLM-5.2 · 44.8s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/docs/superpowers/specs/2026-08-05-agent-tool-framework-design.md",
"limit": 50
}
Output:
<path>/home/dash/coding/nuxt-app/docs/superpowers/specs/2026-08-05-agent-tool-framework-design.md</path>
<type>file</type>
<content>
1: # Agent 工具调用框架设计(fetch 为首个工具)
2:
3: ## 概述
4:
5: 构建一个通用工具调用框架,作为 agent 系统的前置基础设施。fetch 工具是第一个内置工具,后续可扩展 search、code-exec 等。框架支持 LLM tool-calling 自主调用,也支持独立执行 API 用于调试。
6:
7: ## 架构
8:
9: ```
10: ┌─────────────────────────────────────────────────┐
11: │ 前端 │
12: │ ┌──────────────┐ ┌───────────────────────┐ │
13: │ │ 工具管理页面 │ │ Chat 页面(现有) │ │
14: │ │ admin/ │ │ + 工具调用状态展示 │ │
15: │ │ agent-tools │ │ │ │
16: │ └──────┬───────┘ └───────────┬───────────┘ │
17: └─────────┼────────────────────────┼──────────────┘
18: │ │
19: ┌─────────┼────────────────────────┼──────────────┐
20: │ 服务端 │ │ │
21: │ ┌──────▼────────┐ ┌──────────▼───────────┐ │
22: │ │ tools API │ │ llm/chat API(扩展) │ │
23: │ │ CRUD + execute│ │ tool-calling loop │ │
24: │ └──────┬────────┘ └──────────┬───────────┘ │
25: │ │ │ │
26: │ ┌──────▼────────────────────────▼───────────┐ │
27: │ │ Tool Registry(工具注册中心) │ │
28: │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
29: │ │ │ fetch │ │ (future)│ │ (future)│ │ │
30: │ │ │ executor│ │ executor│ │ executor│ │ │
31: │ │ └─────────┘ └─────────┘ └─────────┘ │ │
32: │ └───────────────────────────────────────────┘ │
33: │ │ │
34: │ ┌──────▼────────┐ ┌──────────────────────┐ │
35: │ │ agent_tools │ │ agent_tool_logs │ │
36: │ │ (DB) │ │ (DB) │ │
37: │ └───────────────┘ └──────────────────────┘ │
38: └──────────────────────────────────────────────────┘
39: ```
40:
41: 核心分层:
42: - **Tool Registry**:type → executor 的映射,硬编码执行逻辑 + 根据 config 收窄 inputSchema
43: - **agent_tools 表**:存工具实例(全局共享),含 type/config/enabled
44: - **agent_tool_logs 表**:执行日志
45: - **tools API**:CRUD + `POST /tools/:id/execute`(独立执行,调试用)
46: - **llm/chat 扩展**:注入 enabled 工具到 ai-sdk `streamText`,自动 tool loop
47:
48: ## 数据库 Schema
49:
50: ### agent_tools 表
(Showing lines 1-50 of 422. Use offset=51 to continue.)
</content>
Assistant (Build · GLM-5.2 · 31.4s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/registry.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/registry.ts</path>
<type>file</type>
<content>
1: import type { JSONSchema7 } from "json-schema";
2:
3: export interface ToolExecutor<TConfig> {
4: buildInputSchema(config: TConfig): JSONSchema7;
5: buildDescription(config: TConfig): string;
6: execute(input: unknown, config: TConfig, ctx: ToolContext): Promise<ToolResult>;
7: }
8:
9: export interface ToolContext {
10: toolId: string;
11: toolSlug: string;
12: userId: number | null;
13: }
14:
15: export interface ToolResult {
16: success: boolean;
17: data?: unknown;
18: error?: string;
19: metadata?: {
20: statusCode?: number;
21: responseSize?: number;
22: durationMs: number;
23: };
24: }
25:
26: const registry = new Map<string, ToolExecutor<any>>();
27:
28: export function registerToolType(type: string, executor: ToolExecutor<any>): void {
29: registry.set(type, executor);
30: }
31:
32: export function getExecutor(type: string): ToolExecutor<any> | undefined {
33: return registry.get(type);
34: }
35:
36: export function listToolTypes(): string[] {
37: return Array.from(registry.keys());
38: }
(End of file - total 38 lines)
</content>
Assistant (Build · GLM-5.2 · 38.5s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/index.ts</path>
<type>file</type>
<content>
1: import { dbGlobal } from "drizzle-pkg/lib/db";
2: import { agentTools } from "drizzle-pkg/lib/schema/agent-tool";
3: import { eq, asc } from "drizzle-orm";
4: import { tool, jsonSchema } from "ai";
5: import { z } from "zod";
6:
7: import { registerToolType, getExecutor, type ToolContext, type ToolResult } from "./registry";
8: import { writeToolLog } from "./log";
9: import { parseFetchConfig, DEFAULT_FETCH_CONFIG } from "./executors/fetch/config";
10: import { fetchExecutor } from "./executors/fetch/fetch";
11:
12: // 立即注册 fetch 工具类型,确保在任何 execute 调用前完成
13: registerToolType("fetch", fetchExecutor);
14:
15: // fetch 工具的固定 input schema
16: const FETCH_INPUT_SCHEMA = z.object({
17: url: z.string().describe("要抓取的 URL"),
18: });
19:
20: export type AgentToolRow = typeof agentTools.$inferSelect;
21:
22: export interface CreateAgentToolInput {
23: name: string;
24: slug: string;
25: description: string;
26: type: string;
27: config: Record<string, unknown>;
28: enabled?: boolean;
29: sortOrder?: number;
30: }
31:
32: export interface UpdateAgentToolInput {
33: name?: string;
34: slug?: string;
35: description?: string;
36: type?: string;
37: config?: Record<string, unknown>;
38: enabled?: boolean;
39: sortOrder?: number;
40: }
41:
42: export async function listAgentTools(): Promise<AgentToolRow[]> {
43: return dbGlobal.select().from(agentTools).orderBy(asc(agentTools.sortOrder), asc(agentTools.createdAt));
44: }
45:
46: export async function getAgentToolById(id: string): Promise<AgentToolRow | null> {
47: const rows = await dbGlobal.select().from(agentTools).where(eq(agentTools.id, id)).limit(1);
48: return rows[0] ?? null;
49: }
50:
51: export async function getAgentToolBySlug(slug: string): Promise<AgentToolRow | null> {
52: const rows = await dbGlobal.select().from(agentTools).where(eq(agentTools.slug, slug)).limit(1);
53: return rows[0] ?? null;
54: }
55:
56: function generateId(): string {
57: return `at_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
58: }
59:
60: function validateConfig(type: string, config: Record<string, unknown>): Record<string, unknown> {
61: if (type === "fetch") {
62: return parseFetchConfig(config);
63: }
64: return config;
65: }
66:
67: export async function createAgentTool(input: CreateAgentToolInput): Promise<AgentToolRow> {
68: const existing = await getAgentToolBySlug(input.slug);
69: if (existing) {
70: throw new Error(`slug 已存在: ${input.slug}`);
71: }
72: const validatedConfig = validateConfig(input.type, input.config);
73: const id = generateId();
74: await dbGlobal.insert(agentTools).values({
75: id,
76: name: input.name,
77: slug: input.slug,
78: description: input.description,
79: type: input.type,
80: config: JSON.stringify(validatedConfig),
81: enabled: input.enabled === false ? 0 : 1,
82: sortOrder: input.sortOrder ?? 0,
83: });
84: const row = await getAgentToolById(id);
85: if (!row) throw new Error("创建后查询失败");
86: return row;
87: }
88:
89: export async function updateAgentTool(id: string, input: UpdateAgentToolInput): Promise<AgentToolRow | null> {
90: const existing = await getAgentToolById(id);
91: if (!existing) return null;
92:
93: if (input.slug && input.slug !== existing.slug) {
94: const slugConflict = await getAgentToolBySlug(input.slug);
95: if (slugConflict) {
96: throw new Error(`slug 已存在: ${input.slug}`);
97: }
98: }
99:
100: const updates: Partial<AgentToolRow> = {};
101: if (input.name !== undefined) updates.name = input.name;
102: if (input.slug !== undefined) updates.slug = input.slug;
103: if (input.description !== undefined) updates.description = input.description;
104: if (input.type !== undefined) updates.type = input.type;
105: if (input.config !== undefined) {
106: const typeToValidate = input.type ?? existing.type;
107: updates.config = JSON.stringify(validateConfig(typeToValidate, input.config));
108: }
109: if (input.enabled !== undefined) updates.enabled = input.enabled ? 1 : 0;
110: if (input.sortOrder !== undefined) updates.sortOrder = input.sortOrder;
111:
112: if (Object.keys(updates).length === 0) return existing;
113:
114: await dbGlobal.update(agentTools).set(updates).where(eq(agentTools.id, id));
115: return getAgentToolById(id);
116: }
117:
118: export async function deleteAgentTool(id: string): Promise<void> {
119: await dbGlobal.delete(agentTools).where(eq(agentTools.id, id));
120: }
121:
122: export async function executeAgentTool(
123: id: string,
124: input: unknown,
125: userId: number | null,
126: ): Promise<ToolResult> {
127: const agentTool = await getAgentToolById(id);
128: if (!agentTool) {
129: return {
130: success: false,
131: data: null,
132: error: `工具不存在: ${id}`,
133: metadata: { durationMs: 0 },
134: };
135: }
136: if (!agentTool.enabled) {
137: return {
138: success: false,
139: data: null,
140: error: `工具已禁用: ${agentTool.slug}`,
141: metadata: { durationMs: 0 },
142: };
143: }
144:
145: const executor = getExecutor(agentTool.type);
146: if (!executor) {
147: return {
148: success: false,
149: error: `工具类型未注册: ${agentTool.type}`,
150: metadata: { durationMs: 0 },
151: };
152: }
153: let config: unknown;
154: try {
155: config = JSON.parse(agentTool.config);
156: } catch {
157: config = agentTool.type === "fetch" ? DEFAULT_FETCH_CONFIG : {};
158: }
159:
160: const ctx: ToolContext = {
161: toolId: agentTool.id,
162: toolSlug: agentTool.slug,
163: userId,
164: };
165:
166: const result = await executor.execute(input, config, ctx);
167:
168: // 写日志
169: const status = result.success ? "success" : result.error?.includes("超时") ? "timeout" : "error";
170: await writeToolLog({
171: toolId: agentTool.id,
172: toolSlug: agentTool.slug,
173: userId,
174: input: JSON.stringify(input),
175: output: result.success ? JSON.stringify(result.data)?.slice(0, 10000) ?? null : null,
176: status: status as "success" | "error" | "timeout",
177: errorMessage: result.error ?? null,
178: durationMs: result.metadata?.durationMs ?? 0,
179: });
180:
181: return result;
182: }
183:
184: export async function getEnabledToolsForLlm(): Promise<Record<string, ReturnType<typeof tool>>> {
185: const tools = await dbGlobal
186: .select()
187: .from(agentTools)
188: .where(eq(agentTools.enabled, 1))
189: .orderBy(asc(agentTools.sortOrder));
190:
191: const result: Record<string, any> = {};
192: for (const agentTool of tools) {
193: const executor = getExecutor(agentTool.type);
194: if (!executor) continue;
195: let config: unknown;
196: try {
197: config = JSON.parse(agentTool.config);
198: } catch {
199: config = agentTool.type === "fetch" ? DEFAULT_FETCH_CONFIG : {};
200: }
201:
202: const zodSchema = agentTool.type === "fetch" ? FETCH_INPUT_SCHEMA : z.object({});
203: const jsonSch = z.toJSONSchema(zodSchema) as Record<string, unknown>;
204:
205: result[agentTool.slug] = tool({
206: description: executor.buildDescription(config),
207: parameters: jsonSchema(jsonSch, {
208: validate: (v: unknown) => {
209: const r = zodSchema.safeParse(v);
210: return r.success
211: ? { success: true as const, value: r.data }
212: : { success: false as const, error: r.error };
213: },
214: }),
215: execute: async (input: unknown) => {
216: const execResult = await executeAgentTool(agentTool.id, input, null);
217: if (!execResult.success) {
218: return `工具执行失败: ${execResult.error ?? "未知错误"}。请停止调用此工具,基于已有信息回答用户或告知用户此工具不可用。`;
219: }
220: // 成功时返回内容,附带元信息帮助模型判断结果是否有效
221: const meta = execResult.metadata;
222: const sizeInfo = meta?.responseSize ? `${meta.responseSize} bytes` : `未知大小`;
223: const statusInfo = meta?.statusCode ? `HTTP ${meta.statusCode}` : "";
224: const dataStr = typeof execResult.data === "string" ? execResult.data : JSON.stringify(execResult.data);
225: return `[fetch 结果 ${statusInfo} ${sizeInfo}]\n${dataStr}`;
226: },
227: });
228: }
229: return result;
230: }
231:
232: export { DEFAULT_FETCH_CONFIG } from "./executors/fetch/config";
233: export type { FetchToolConfig } from "./executors/fetch/config";
234: export type { ToolExecutor, ToolContext, ToolResult } from "./registry";
(End of file - total 234 lines)
</content>
Assistant (Build · GLM-5.2 · 8.1s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/packages/drizzle-pkg/lib/schema/agent-tool.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/packages/drizzle-pkg/lib/schema/agent-tool.ts</path>
<type>file</type>
<content>
1: import { sqliteTable, text, integer, uniqueIndex, index } from "drizzle-orm/sqlite-core";
2:
3: // ============ AgentToolType ENUM ============
4: export const AgentToolTypes = ["fetch"] as const;
5: export type AgentToolType = (typeof AgentToolTypes)[number];
6:
7: // ============ AgentToolStatus ENUM ============
8: export const AgentToolLogStatuses = ["success", "error", "timeout"] as const;
9: export type AgentToolLogStatus = (typeof AgentToolLogStatuses)[number];
10:
11: // ============ AgentTool(工具定义表)============
12: export const agentTools = sqliteTable(
13: "agent_tools",
14: {
15: id: text("id").primaryKey(),
16: name: text("name", { length: 50 }).notNull(),
17: slug: text("slug", { length: 50 }).notNull(),
18: description: text("description").notNull(),
19: type: text("type", { length: 30 }).notNull(),
20: config: text("config").notNull(),
21: enabled: integer("enabled").default(1).notNull(),
22: sortOrder: integer("sort_order").default(0).notNull(),
23: createdAt: integer("created_at", { mode: "timestamp_ms" })
24: .defaultNow()
25: .notNull(),
26: updatedAt: integer("updated_at", { mode: "timestamp_ms" })
27: .defaultNow()
28: .$onUpdate(() => new Date())
29: .notNull(),
30: },
31: (table) => [
32: uniqueIndex("agent_tools_slug_idx").on(table.slug),
33: index("agent_tools_enabled_idx").on(table.enabled),
34: ],
35: );
36:
37: // ============ AgentToolLog(工具执行日志表)============
38: export const agentToolLogs = sqliteTable(
39: "agent_tool_logs",
40: {
41: id: integer("id").primaryKey({ autoIncrement: true }),
42: toolId: text("tool_id").notNull(),
43: toolSlug: text("tool_slug", { length: 50 }).notNull(),
44: userId: integer("user_id"),
45: input: text("input").notNull(),
46: output: text("output"),
47: status: text("status", { length: 20 }).notNull(),
48: errorMessage: text("error_message"),
49: durationMs: integer("duration_ms").notNull(),
50: createdAt: integer("created_at", { mode: "timestamp_ms" })
51: .defaultNow()
52: .notNull(),
53: },
54: (table) => [
55: index("agent_tool_logs_tool_id_idx").on(table.toolId),
56: index("agent_tool_logs_created_at_idx").on(table.createdAt),
57: ],
58: );
(End of file - total 58 lines)
</content>
Assistant (Build · GLM-5.2 · 33.3s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/executors/fetch/config.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/executors/fetch/config.ts</path>
<type>file</type>
<content>
1: import { z } from "zod";
2:
3: export const fetchConfigSchema = z.object({
4: defaultMethod: z.enum(["GET", "POST"]).default("GET"),
5: defaultHeaders: z.record(z.string(), z.string()).default({}),
6: timeout: z.number().int().positive().max(60000).default(10000),
7: maxResponseSize: z.number().int().positive().max(1048576).default(102400),
8: allowedDomains: z.array(z.string().min(1)).default(["*"]),
9: blockedDomains: z.array(z.string().min(1)).default([]),
10: parseMode: z.enum(["raw", "markdown", "json"]).default("markdown"),
11: });
12:
13: export type FetchToolConfig = z.infer<typeof fetchConfigSchema>;
14:
15: export const DEFAULT_FETCH_CONFIG: FetchToolConfig = {
16: defaultMethod: "GET",
17: defaultHeaders: {},
18: timeout: 10000,
19: maxResponseSize: 102400,
20: allowedDomains: ["*"],
21: blockedDomains: [],
22: parseMode: "markdown",
23: };
24:
25: export function parseFetchConfig(raw: unknown): FetchToolConfig {
26: const parsed = fetchConfigSchema.safeParse(raw);
27: if (!parsed.success) {
28: throw new Error(`Invalid fetch config: ${parsed.error.message}`);
29: }
30: return parsed.data;
31: }
(End of file - total 31 lines)
</content>
Assistant (Build · GLM-5.2 · 19.3s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/executors/fetch/security.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/executors/fetch/security.ts</path>
<type>file</type>
<content>
1: import * as dnsPromises from "node:dns/promises";
2: import { isIP } from "node:net";
3:
4: const PRIVATE_IP_RANGES: Array<{ start: bigint; end: bigint }> = [
5: // 10.0.0.0/8
6: { start: BigInt("0x0a000000"), end: BigInt("0x0affffff") },
7: // 172.16.0.0/12
8: { start: BigInt("0xac100000"), end: BigInt("0xac1fffff") },
9: // 192.168.0.0/16
10: { start: BigInt("0xc0a80000"), end: BigInt("0xc0a8ffff") },
11: // 127.0.0.0/8 (loopback)
12: { start: BigInt("0x7f000000"), end: BigInt("0x7fffffff") },
13: // 169.254.0.0/16 (link-local)
14: { start: BigInt("0xa9fe0000"), end: BigInt("0xa9feffff") },
15: // 0.0.0.0/8
16: { start: BigInt("0x00000000"), end: BigInt("0x00ffffff") },
17: // 100.64.0.0/10 (CGNAT)
18: { start: BigInt("0x64400000"), end: BigInt("0x647fffff") },
19: ];
20:
21: export function isPrivateIp(ip: string): boolean {
22: const type = isIP(ip);
23: if (type === 4) {
24: const parts = ip.split(".").map(Number);
25: const numeric =
26: (BigInt(parts[0] ?? 0) << BigInt(24)) |
27: (BigInt(parts[1] ?? 0) << BigInt(16)) |
28: (BigInt(parts[2] ?? 0) << BigInt(8)) |
29: BigInt(parts[3] ?? 0);
30: return PRIVATE_IP_RANGES.some(
31: (r) => numeric >= r.start && numeric <= r.end,
32: );
33: }
34: if (type === 6) {
35: const lower = ip.toLowerCase();
36: if (lower === "::1") return true;
37: if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
38: if (lower.startsWith("fe80")) return true;
39: if (lower.startsWith("::ffff:")) {
40: const v4 = lower.slice(7);
41: return isPrivateIp(v4);
42: }
43: return false;
44: }
45: return false;
46: }
47:
48: export function matchDomain(hostname: string, pattern: string): boolean {
49: if (pattern === "*") return true;
50: const lowerHost = hostname.toLowerCase();
51: const lowerPattern = pattern.toLowerCase();
52: if (lowerPattern.startsWith("*.")) {
53: const suffix = lowerPattern.slice(2);
54: return lowerHost === suffix || lowerHost.endsWith("." + suffix);
55: }
56: return lowerHost === lowerPattern;
57: }
58:
59: export function checkDomainAccess(
60: hostname: string,
61: allowedDomains: string[],
62: blockedDomains: string[],
63: ): void {
64: for (const blocked of blockedDomains) {
65: if (matchDomain(hostname, blocked)) {
66: throw new Error(`域名被黑名单禁止: ${hostname}`);
67: }
68: }
69: // 白名单为空时允许所有域名(仅靠黑名单限制)
70: if (allowedDomains.length > 0) {
71: const allowed = allowedDomains.some((d) => matchDomain(hostname, d));
72: if (!allowed) {
73: throw new Error(`域名不在白名单中: ${hostname}`);
74: }
75: }
76: }
77:
78: export async function assertSafeUrl(url: string): Promise<void> {
79: let parsed: URL;
80: try {
81: parsed = new URL(url);
82: } catch {
83: throw new Error("无效的 URL 格式");
84: }
85: if (!["http:", "https:"].includes(parsed.protocol)) {
86: throw new Error("仅支持 http/https 协议");
87: }
88: const hostname = parsed.hostname;
89: if (!hostname) {
90: throw new Error("URL 缺少主机名");
91: }
92: // 如果 hostname 本身就是 IP,直接检查
93: if (isIP(hostname)) {
94: if (isPrivateIp(hostname)) {
95: throw new Error(`禁止访问内网地址: ${hostname}`);
96: }
97: return;
98: }
99: // DNS 解析,检查所有返回的 IP
100: let addresses: { address: string; family: number }[];
101: try {
102: addresses = await dnsPromises.lookup(hostname, { all: true });
103: } catch {
104: throw new Error(`DNS 解析失败: ${hostname}`);
105: }
106: if (addresses.length === 0) {
107: throw new Error(`DNS 解析无结果: ${hostname}`);
108: }
109: for (const addr of addresses) {
110: if (isPrivateIp(addr.address)) {
111: throw new Error(`禁止访问内网地址: ${addr.address} (${hostname})`);
112: }
113: }
114: }
(End of file - total 114 lines)
</content>
Assistant (Build · GLM-5.2 · 32.9s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/executors/fetch/parse.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/executors/fetch/parse.ts</path>
<type>file</type>
<content>
1: import TurndownService from "turndown";
2:
3: const turndown = new TurndownService({
4: headingStyle: "atx",
5: codeBlockStyle: "fenced",
6: bulletListMarker: "-",
7: });
8:
9: export type ParseMode = "raw" | "markdown" | "json";
10:
11: export interface ParseResult {
12: content: unknown;
13: contentType: "text" | "markdown" | "json";
14: degraded?: boolean;
15: note?: string;
16: }
17:
18: export function parseResponse(
19: body: string,
20: mode: ParseMode,
21: contentTypeHeader?: string,
22: ): ParseResult {
23: switch (mode) {
24: case "raw":
25: return { content: body, contentType: "text" };
26:
27: case "json": {
28: try {
29: const parsed = JSON.parse(body);
30: return { content: parsed, contentType: "json" };
31: } catch {
32: return {
33: content: body,
34: contentType: "text",
35: degraded: true,
36: note: "JSON 解析失败,返回原始文本",
37: };
38: }
39: }
40:
41: case "markdown": {
42: const isHtml =
43: contentTypeHeader?.includes("text/html") ||
44: /^\s*<(?:!doctype|html|body|div|p|h[1-6]|ul|ol|table|span|a)\b/i.test(body);
45: if (isHtml) {
46: const md = turndown.turndown(body);
47: return { content: md, contentType: "markdown" };
48: }
49: return { content: body, contentType: "text" };
50: }
51:
52: default:
53: return { content: body, contentType: "text" };
54: }
55: }
(End of file - total 55 lines)
</content>
Assistant (Build · GLM-5.2 · 32.7s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/service/agent-tool/log.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/server/service/agent-tool/log.ts</path>
<type>file</type>
<content>
1: import { dbGlobal } from "drizzle-pkg/lib/db";
2: import { agentToolLogs } from "drizzle-pkg/lib/schema/agent-tool";
3: import type { AgentToolLogStatus } from "drizzle-pkg/lib/schema/agent-tool";
4:
5: export interface WriteToolLogParams {
6: toolId: string;
7: toolSlug: string;
8: userId: number | null;
9: input: string;
10: output: string | null;
11: status: AgentToolLogStatus;
12: errorMessage: string | null;
13: durationMs: number;
14: }
15:
16: export async function writeToolLog(params: WriteToolLogParams): Promise<void> {
17: try {
18: await dbGlobal.insert(agentToolLogs).values({
19: toolId: params.toolId,
20: toolSlug: params.toolSlug,
21: userId: params.userId,
22: input: params.input,
23: output: params.output,
24: status: params.status,
25: errorMessage: params.errorMessage,
26: durationMs: params.durationMs,
27: });
28: } catch (e) {
29: console.error("[agent-tool] 写入执行日志失败:", e);
30: }
31: }
(End of file - total 31 lines)
</content>
Assistant (Build · GLM-5.2 · 38.1s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/server/api/llm/chat/index.post.ts</path>
<type>file</type>
<content>
1: import { requireUser } from "#server/utils/context";
2: import { getProviderById, getModelById } from "#server/service/llm";
3: import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4: import { streamText, type LanguageModelV1 } from "ai";
5: import { getEnabledToolsForLlm } from "#server/service/agent-tool";
6: import log4js from "logger";
7:
8: const logger = log4js.getLogger("APP");
9:
10: function resolveModel(
11: provider: {
12: name: string;
13: apiKey: string | null;
14: baseUrl: string | null;
15: parseMode: string;
16: },
17: modelId: string,
18: ): LanguageModelV1 {
19: const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined;
20:
21: if (provider.parseMode === "anthropic") {
22: throw createError({
23: statusCode: 400,
24: statusMessage: "Anthropic 解析模式暂不支持流式对话,请使用 OpenAI 兼容模式",
25: });
26: }
27:
28: const openaiCompatible = createOpenAICompatible({
29: name: provider.name,
30: apiKey: provider.apiKey || undefined,
31: baseURL: baseUrl || "https://api.openai.com/v1",
32: });
33:
34: return openaiCompatible(modelId) as LanguageModelV1;
35: }
36:
37: export default defineEventHandler(async (event) => {
38: const user = await requireUser(event);
39: if (!user) {
40: throw createError({ statusCode: 401, statusMessage: "未登录" });
41: }
42:
43: const body = await readBody(event);
44: const { modelId: llmModelId, messages, enableThinking, enableTools } = body as {
45: modelId: number;
46: messages: { role: "user" | "assistant" | "system"; content: string }[];
47: enableThinking?: boolean;
48: enableTools?: boolean;
49: };
50:
51: if (!llmModelId || !messages || !Array.isArray(messages) || messages.length === 0) {
52: throw createError({ statusCode: 400, statusMessage: "参数无效" });
53: }
54:
55: const model = await getModelById(llmModelId, user.id);
56: if (!model) {
57: throw createError({ statusCode: 404, statusMessage: "模型不存在" });
58: }
59:
60: const provider = await getProviderById(model.providerId, user.id);
61: if (!provider) {
62: throw createError({ statusCode: 404, statusMessage: "供应商不存在" });
63: }
64:
65: if (provider.status !== "active") {
66: throw createError({ statusCode: 400, statusMessage: "供应商已禁用" });
67: }
68:
69: if (!provider.apiKey) {
70: throw createError({ statusCode: 400, statusMessage: "供应商未配置 API Key" });
71: }
72:
73: logger.info(
74: "[%s] [LLM-CHAT] userId=%d modelId=%d provider=%s parseMode=%s messages=%d thinking=%s tools=%s",
75: event.context.requestId ?? "-",
76: user.id,
77: llmModelId,
78: provider.name,
79: provider.parseMode,
80: messages.length,
81: enableThinking ? "on" : "off",
82: enableTools ? "on" : "off",
83: );
84:
85: const languageModel = resolveModel(provider, model.modelId);
86:
87: const tools = enableTools ? await getEnabledToolsForLlm() : undefined;
88:
89: const result = streamText({
90: model: languageModel,
91: messages,
92: maxTokens: model.maxTokens || undefined,
93: ...(tools && Object.keys(tools).length > 0
94: ? { tools, maxSteps: 8 }
95: : {}),
96: ...(enableThinking
97: ? {
98: providerOptions: {
99: openaiCompatible: { reasoningEffort: "high" },
100: },
101: }
102: : {}),
103: onError: (errorData) => {
104: const errMsg = errorData?.error instanceof Error
105: ? errorData.error.message
106: : String(errorData?.error ?? "未知错误");
107: logger.error("[%s] [LLM-CHAT] streamText error: %s", event.context.requestId ?? "-", errMsg);
108: },
109: onFinish: ({ finishReason, usage, steps }) => {
110: logger.info(
111: "[%s] [LLM-CHAT] finished: reason=%s steps=%d inputTokens=%d outputTokens=%d",
112: event.context.requestId ?? "-",
113: finishReason,
114: steps.length,
115: usage?.promptTokens ?? 0,
116: usage?.completionTokens ?? 0,
117: );
118: },
119: });
120:
121: return result.toDataStreamResponse({ sendReasoning: true });
122: });
(End of file - total 122 lines)
</content>
Assistant (Build · GLM-5.2 · 27.3s)
Tool: read
Input:
{
"filePath": "/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts"
}
Output:
<path>/home/dash/coding/nuxt-app/app/composables/useLlmChat.ts</path>
<type>file</type>
<content>
1: import { processDataStream } from 'ai'
2:
3: export type MessagePartType = 'text' | 'reasoning' | 'tool-call' | 'tool-result'
4:
5: export interface MessagePart {
6: id: string
7: type: MessagePartType
8: text?: string
9: toolName?: string
10: toolCallId?: string
11: args?: unknown
12: result?: unknown
13: state?: 'call' | 'result'
14: reasoningLoading?: boolean
15: reasoningDuration?: number
16: }
17:
18: export interface LlmChatMessage {
19: id: string
20: role: 'user' | 'assistant'
21: content: string
22: parts?: MessagePart[]
23: }
24:
25: export interface UseLlmChatOptions {
26: modelId: () => number | null
27: apiEndpoint?: string
28: systemPrompt?: () => string
29: enableThinking?: () => boolean
30: enableTools?: () => boolean
31: }
32:
33: export function useLlmChat(options: UseLlmChatOptions) {
34: const {
35: modelId,
36: apiEndpoint = '/api/llm/chat',
37: systemPrompt,
38: enableThinking,
39: enableTools,
40: } = options
41:
42: const messages = ref<LlmChatMessage[]>([])
43: const isLoading = ref(false)
44: const errorMessage = ref('')
45:
46: let abortController: AbortController | null = null
47:
48: function generateId(): string {
49: return Date.now().toString(36) + Math.random().toString(36).slice(2)
50: }
51:
52: function getOrCreateLastPart(msg: LlmChatMessage, type: MessagePartType): MessagePart | null {
53: if (!msg.parts) msg.parts = []
54: const last = msg.parts[msg.parts.length - 1]
55: if (last && last.type === type) return last
56: return null
57: }
58:
59: function appendPart(msg: LlmChatMessage, part: MessagePart) {
60: if (!msg.parts) msg.parts = []
61: msg.parts.push(part)
62: }
63:
64: function updateLastReasoningDuration(msg: LlmChatMessage) {
65: if (!msg.parts) return
66: for (let i = msg.parts.length - 1; i >= 0; i--) {
67: const p = msg.parts[i]
68: if (!p) continue
69: if (p.type === 'reasoning' && p.reasoningLoading) {
70: p.reasoningLoading = false
71: return
72: }
73: }
74: }
75:
76: async function sendMessage(text: string) {
77: const trimmed = text.trim()
78: const mid = modelId()
79: if (!trimmed || mid === null || isLoading.value) return
80:
81: errorMessage.value = ''
82:
83: const userMsg: LlmChatMessage = { id: generateId(), role: 'user', content: trimmed }
84: messages.value.push(userMsg)
85:
86: const assistantMsg: LlmChatMessage = {
87: id: generateId(),
88: role: 'assistant',
89: content: '',
90: parts: [],
91: }
92: messages.value.push(assistantMsg)
93: const assistantIdx = messages.value.length - 1
94:
95: isLoading.value = true
96: abortController = new AbortController()
97:
98: let reasoningStartTime: number | null = null
99:
100: try {
101: const res = await fetch(apiEndpoint, {
102: method: 'POST',
103: headers: { 'Content-Type': 'application/json' },
104: body: JSON.stringify({
105: modelId: mid,
106: messages: [
107: ...(systemPrompt?.() ? [{ role: 'system' as const, content: systemPrompt() }] : []),
108: ...messages.value
109: .filter(m => m.content)
110: .map(m => ({ role: m.role, content: m.content })),
111: ],
112: enableThinking: enableThinking?.() ?? false,
113: enableTools: enableTools?.() ?? false,
114: }),
115: signal: abortController.signal,
116: })
117:
118: if (!res.ok) {
119: const errText = await res.text()
120: throw new Error(errText || `请求失败 (${res.status})`)
121: }
122:
123: if (!res.body) {
124: throw new Error('响应体为空')
125: }
126:
127: await processDataStream({
128: stream: res.body,
129: onReasoningPart: (text) => {
130: const msg = messages.value[assistantIdx]
131: if (!msg) return
132: let part = getOrCreateLastPart(msg, 'reasoning')
133: if (!part) {
134: if (reasoningStartTime === null) reasoningStartTime = Date.now()
135: part = { id: generateId(), type: 'reasoning', text: '', reasoningLoading: true }
136: appendPart(msg, part)
137: }
138: part.text = (part.text ?? '') + text
139: },
140: onTextPart: (text) => {
141: const msg = messages.value[assistantIdx]
142: if (!msg) return
143: updateLastReasoningDuration(msg)
144: let part = getOrCreateLastPart(msg, 'text')
145: if (!part) {
146: part = { id: generateId(), type: 'text', text: '' }
147: appendPart(msg, part)
148: }
149: part.text = (part.text ?? '') + text
150: msg.content += text
151: },
152: onErrorPart: (error) => {
153: errorMessage.value = error || '流式响应出错'
154: },
155: onToolCallPart: (part) => {
156: const msg = messages.value[assistantIdx]
157: if (!msg) return
158: updateLastReasoningDuration(msg)
159: appendPart(msg, {
160: id: generateId(),
161: type: 'tool-call',
162: toolName: part.toolName,
163: toolCallId: part.toolCallId,
164: args: part.args,
165: state: 'call',
166: })
167: },
168: onToolResultPart: (part) => {
169: const msg = messages.value[assistantIdx]
170: if (!msg || !msg.parts) return
171: const callPart = msg.parts.find(p => p.type === 'tool-call' && p.toolCallId === part.toolCallId)
172: if (callPart) {
173: callPart.result = part.result
174: callPart.state = 'result'
175: }
176: },
177: })
178:
179: const msg = messages.value[assistantIdx]
180: if (msg) updateLastReasoningDuration(msg)
181:
182: // 检查是否有实际内容产出
183: const finalMsg = messages.value[assistantIdx]
184: if (finalMsg && !errorMessage.value) {
185: const hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text)
186: const hasToolCall = finalMsg.parts?.some(p => p.type === 'tool-call')
187: if (!hasText && !hasToolCall) {
188: // 完全无内容,移除空消息
189: errorMessage.value = '模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)'
190: messages.value.splice(assistantIdx, 1)
191: } else if (!hasText && hasToolCall) {
192: // 有工具调用但无最终文本回答(maxSteps 用完),保留工具记录,追加提示
193: finalMsg.parts?.push({
194: id: generateId(),
195: type: 'text',
196: text: '(已达到工具调用次数上限,模型未能生成最终回答。以上是工具调用的尝试记录。)',
197: })
198: }
199: }
200: } catch (err: any) {
201: if (err.name === 'AbortError') {
202: // user stopped
203: } else {
204: errorMessage.value = err.message || '请求失败'
205: const msg = messages.value[assistantIdx]
206: if (msg && !msg.content && (!msg.parts || msg.parts.length === 0)) {
207: messages.value.splice(assistantIdx, 1)
208: }
209: }
210: } finally {
211: isLoading.value = false
212: abortController = null
213: }
214: }
215:
216: function stopGeneration() {
217: if (abortController) {
218: abortController.abort()
219: abortController = null
220: }
221: }
222:
223: function clearChat() {
224: messages.value = []
225: errorMessage.value = ''
226: }
227:
228: return {
229: messages,
230: isLoading,
231: errorMessage,
232: sendMessage,
233: stopGeneration,
234: clearChat,
235: }
236: }
(End of file - total 236 lines)
</content>
Assistant (Build · GLM-5.2 · 31.5s)
现在看下 API 端点和前端组件的关键信息:
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && ls server/api/agent-tools/ && echo \"---\" && ls server/api/agent-tools/[id]/ 2>/dev/null",
"description": "列出 API 端点文件"
}
Output:
[id]
[id].delete.ts
[id].get.ts
[id].put.ts
index.get.ts
index.post.ts
---
zsh:1: no matches found: server/api/agent-tools/[id]/
Assistant (Build · GLM-5.2 · 30.0s)
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && ls \"server/api/agent-tools/[id]/\"",
"description": "列出 [id] 子目录"
}
Output:
execute.post.ts
Assistant (Build · GLM-5.2 · 125.0s)
现在生成开发文档:
Tool: write
Input:
{
"content": "# Agent 工具调用框架 - 开发文档\n\n> 本文档供后续开发者快速了解工具调用框架的完整实现,便于扩展新工具类型和维护现有功能。\n\n## 一、整体架构\n\n```\n┌──────────────────────────────────────────────────────┐\n│ 前端 │\n│ ┌───────────────┐ ┌──────────────────────────┐ │\n│ │ 工具管理页面 │ │ LLM 测试页面 │ │\n│ │ admin/ │ │ + 工具调用开关 │ │\n│ │ agent-tools │ │ + agent 链路展示 │ │\n│ └──────┬────────┘ └──────────┬───────────────┘ │\n└─────────┼────────────────────────┼───────────────────┘\n │ │\n┌─────────┼────────────────────────┼───────────────────┐\n│ 服务端 │ │ │\n│ ┌──────▼────────┐ ┌──────────▼───────────────┐ │\n│ │ agent-tools │ │ llm/chat API(扩展) │ │\n│ │ API (CRUD+ │ │ tool-calling loop │ │\n│ │ execute) │ │ maxSteps=8 │ │\n│ └──────┬────────┘ └──────────┬───────────────┘ │\n│ │ │ │\n│ ┌──────▼────────────────────────▼───────────────┐ │\n│ │ Tool Registry(工具注册中心) │ │\n│ │ registerToolType(type, executor) │ │\n│ │ getExecutor(type) → ToolExecutor │ │\n│ └──────┬────────────────────────────────────────┘ │\n│ │ │\n│ ┌──────▼────────┐ ┌──────────────────────┐ │\n│ │ agent_tools │ │ agent_tool_logs │ │\n│ │ (DB 表) │ │ (DB 表) │ │\n│ └───────────────┘ └──────────────────────┘ │\n└──────────────────────────────────────────────────────┘\n```\n\n核心分层:\n- **Tool Registry**:type → executor 的映射,硬编码执行逻辑 + 根据 config 差异化配置\n- **agent_tools 表**:存工具实例(全局共享),含 type/config/enabled\n- **agent_tool_logs 表**:执行日志\n- **agent-tools API**:CRUD + `POST /agent-tools/:id/execute`(独立执行,调试用)\n- **llm/chat 扩展**:注入 enabled 工具到 ai-sdk `streamText`,自动 tool loop\n\n## 二、数据库\n\n### 2.1 agent_tools 表\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| id | text PK | `at_{timestamp36}_{random}` 格式 |\n| name | text(50) | 工具显示名称 |\n| slug | text(50) | 唯一标识,用于 LLM tool name |\n| description | text | 工具描述 |\n| type | text(30) | 工具类型(如 `fetch`) |\n| config | text | JSON 字符串,工具配置 |\n| enabled | integer | 0/1,是否启用 |\n| sort_order | integer | 排序权重 |\n| created_at | integer(ts_ms) | 创建时间 |\n| updated_at | integer(ts_ms) | 更新时间 |\n\n索引:`slug` 唯一索引、`enabled` 普通索引。\n\n### 2.2 agent_tool_logs 表\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| id | integer PK auto | 自增 |\n| tool_id | text | 关联 agent_tools.id |\n| tool_slug | text(50) | 冗余存储 slug |\n| user_id | integer | 用户 ID(可为 null) |\n| input | text | 输入参数 JSON |\n| output | text | 输出结果(截断 10000 字符) |\n| status | text(20) | success / error / timeout |\n| error_message | text | 错误信息 |\n| duration_ms | integer | 执行耗时 |\n| created_at | integer(ts_ms) | 创建时间 |\n\n索引:`tool_id`、`created_at`。\n\n### 2.3 迁移文件\n\n- `packages/drizzle-pkg/migrations/0014_breezy_maestro.sql`\n\n## 三、服务端实现\n\n### 3.1 目录结构\n\n```\nserver/service/agent-tool/\n├── registry.ts # ToolExecutor 接口 + 注册机制\n├── index.ts # Service 层:CRUD + execute + getEnabledToolsForLlm\n├── log.ts # 日志写入\n└── executors/\n └── fetch/\n ├── config.ts # fetch 配置 zod schema + 默认值\n ├── security.ts # SSRF 防护 + 域名白/黑名单\n ├── parse.ts # raw/markdown/json 解析\n └── fetch.ts # fetch 执行器组装\n```\n\n### 3.2 ToolExecutor 接口\n\n```typescript\n// registry.ts\ninterface ToolExecutor<TConfig> {\n buildInputSchema(config: TConfig): JSONSchema7; // 返回给 LLM 的参数 schema\n buildDescription(config: TConfig): string; // 返回给 LLM 的工具描述\n execute(input: unknown, config: TConfig, ctx: ToolContext): Promise<ToolResult>;\n}\n\ninterface ToolResult {\n success: boolean;\n data?: unknown;\n error?: string;\n metadata?: { statusCode?: number; responseSize?: number; durationMs: number };\n}\n```\n\n### 3.3 注册机制\n\n```typescript\n// index.ts 顶部立即注册\nregisterToolType(\"fetch\", fetchExecutor);\n```\n\n**注意**:注册必须在 `index.ts` 中直接调用,不能依赖单独的 side-effect import 文件(Nitro tree-shaking 会移除无导出的 side-effect import)。\n\n### 3.4 Service 层核心函数\n\n#### `executeAgentTool(id, input, userId)`\n1. 查 DB 获取工具配置\n2. 检查 enabled\n3. `getExecutor(type)` 获取执行器(undefined 则返回失败)\n4. 解析 config JSON\n5. 调用 `executor.execute(input, config, ctx)`\n6. 写日志\n7. 返回 `ToolResult`\n\n#### `getEnabledToolsForLlm()`\n查询所有 enabled 工具,转换为 ai-sdk `tool()` 格式:\n```typescript\nresult[agentTool.slug] = tool({\n description: executor.buildDescription(config),\n parameters: jsonSchema(jsonSch, { validate: ... }),\n execute: async (input) => {\n const execResult = await executeAgentTool(agentTool.id, input, null);\n if (!execResult.success) {\n return `工具执行失败: ${execResult.error}。请停止调用此工具...`;\n }\n // 成功时附带元信息,帮助模型判断结果是否有效\n return `[fetch 结果 HTTP ${statusCode} ${size} bytes]\\n${data}`;\n },\n});\n```\n\n**关键设计**:\n- 工具失败时返回**明确的错误文本**(不是 JSON 对象),引导模型停止重试\n- 工具成功时附带 HTTP 状态码和响应大小,帮助模型判断结果有效性\n- `parameters` 用 `z.toJSONSchema()` 生成 JSON Schema,再用 ai-sdk `jsonSchema()` 包装\n\n### 3.5 fetch 执行器\n\n#### 配置 (config.ts)\n```typescript\n{\n defaultMethod: \"GET\", // 默认 HTTP 方法\n defaultHeaders: {}, // 默认请求头\n timeout: 10000, // 超时 ms(最大 60000)\n maxResponseSize: 102400, // 最大响应字节(最大 1MB)\n allowedDomains: [\"*\"], // 域名白名单(空数组=允许所有)\n blockedDomains: [], // 域名黑名单\n parseMode: \"markdown\", // raw / markdown / json\n}\n```\n\n#### 安全 (security.ts)\n- **SSRF 防护**:DNS 解析后检查所有 IP 是否为内网地址\n - IPv4 私有范围:10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.0/8、169.254.0.0/16、0.0.0.0/8、100.64.0.0/10\n - IPv6:::1、fc00::/7、fe80::/10、::ffff: 映射的 IPv4\n- **域名白名单**:空数组时允许所有域名;支持通配符 `*.example.com`\n- **域名黑名单**:优先于白名单\n- **协议限制**:仅 http/https\n\n#### 解析 (parse.ts)\n- `raw`:返回原始文本\n- `markdown`:HTML → Markdown(用 `turndown@7.2.0`),非 HTML 返回原文\n- `json`:JSON.parse,失败则降级返回原文 + `degraded: true`\n\n#### 执行流程 (fetch.ts)\n1. zod 校验 input(url 必填,method/headers/body 可选)\n2. SSRF 检查 `assertSafeUrl`\n3. 域名白/黑名单检查 `checkDomainAccess`\n4. 发起 fetch(AbortController 超时控制)\n5. **HTTP 非 2xx 直接返回失败**(让模型知道请求无效)\n6. 大小限制检查(content-length + 分块读取双重检查)\n7. 按 parseMode 处理响应\n8. 返回 `ToolResult`\n\n### 3.6 API 端点\n\n| 方法 | 路径 | 权限 | 说明 |\n|------|------|------|------|\n| GET | `/api/agent-tools` | admin | 列出所有工具 |\n| POST | `/api/agent-tools` | admin | 创建工具 |\n| GET | `/api/agent-tools/:id` | admin | 获取工具详情 |\n| PUT | `/api/agent-tools/:id` | admin | 更新工具 |\n| DELETE | `/api/agent-tools/:id` | admin | 删除工具 |\n| POST | `/api/agent-tools/:id/execute` | admin | 独立执行工具(调试用) |\n\n所有端点使用 `requireAdmin` 权限校验。\n\n### 3.7 chat 集成\n\n`server/api/llm/chat/index.post.ts` 扩展了 `enableTools` 参数:\n\n```typescript\nconst tools = enableTools ? await getEnabledToolsForLlm() : undefined;\n\nconst result = streamText({\n model: languageModel,\n messages,\n ...(tools && Object.keys(tools).length > 0\n ? { tools, maxSteps: 8 }\n : {}),\n onError: (errorData) => {\n logger.error(...);\n },\n onFinish: ({ finishReason, usage, steps }) => {\n logger.info(\"finished: reason=%s steps=%d ...\", finishReason, steps.length, ...);\n },\n});\n\nreturn result.toDataStreamResponse({ sendReasoning: true });\n```\n\n**关键参数**:\n- `maxSteps: 8`:工具调用最大轮次(包含初始生成 + tool-call 轮次 + 最终回答)\n- `sendReasoning: true`:发送 reasoning part 到前端\n- `onError`:返回 void(ai-sdk v4 要求),错误打到日志\n- `onFinish`:记录 finishReason 和步数,用于调试\n\n## 四、前端实现\n\n### 4.1 消息模型(parts 数组)\n\n```typescript\n// app/composables/useLlmChat.ts\ninterface MessagePart {\n id: string\n type: 'text' | 'reasoning' | 'tool-call' | 'tool-result'\n text?: string // text / reasoning part 的文本\n toolName?: string // tool-call part 的工具名\n toolCallId?: string // tool-call part 的调用 ID\n args?: unknown // tool-call part 的参数\n result?: unknown // tool-call part 的结果(tool-result 填充)\n state?: 'call' | 'result' // tool-call part 的状态\n reasoningLoading?: boolean // reasoning part 是否正在加载\n reasoningDuration?: number // reasoning part 的耗时\n}\n\ninterface LlmChatMessage {\n id: string\n role: 'user' | 'assistant'\n content: string // 兼容字段,text part 的累加\n parts?: MessagePart[] // 按 stream 到达顺序追加\n}\n```\n\n### 4.2 stream 处理\n\n`processDataStream` 的回调处理:\n\n| 回调 | 处理逻辑 |\n|------|----------|\n| `onReasoningPart` | 追加到上一个 reasoning part(合并连续 reasoning),标记 `reasoningLoading: true` |\n| `onTextPart` | 先 `updateLastReasoningDuration` 标记 reasoning 完成,再追加到上一个 text part(合并连续 text) |\n| `onToolCallPart` | 先 `updateLastReasoningDuration`,再追加新的 tool-call part(`state: 'call'`)。**字段名是 `args` 不是 `input`** |\n| `onToolResultPart` | 找到对应 `toolCallId` 的 tool-call part,填充 `result` 和 `state: 'result'`。**字段名是 `result` 不是 `output`** |\n| `onErrorPart` | 设置 `errorMessage` |\n\n**合并逻辑**:`getOrCreateLastPart(msg, type)` 检查最后一个 part 是否同类型,是则返回它继续追加,否则返回 null 触发新建。\n\n### 4.3 stream 结束检测\n\n```typescript\nconst hasText = finalMsg.parts?.some(p => p.type === 'text' && p.text)\nconst hasToolCall = finalMsg.parts?.some(p => p.type === 'tool-call')\n\nif (!hasText && !hasToolCall) {\n // 完全无内容,移除空消息\n errorMessage.value = '模型未返回任何内容...'\n messages.value.splice(assistantIdx, 1)\n} else if (!hasText && hasToolCall) {\n // 有工具调用但无最终文本(maxSteps 用完),保留工具记录,追加提示\n finalMsg.parts?.push({\n type: 'text',\n text: '(已达到工具调用次数上限,模型未能生成最终回答...)',\n })\n}\n```\n\n### 4.4 LLM 测试页面渲染\n\n`app/pages/settings/llm-test/index.vue`:\n\n- **user 消息**:用 `ChatBubble` 组件(右侧气泡)\n- **assistant 消息**:自定义 `assistant-panel`(左侧头像 + 右侧卡片容器),内部按序渲染 parts:\n - `reasoning` part:可折叠/展开,显示耗时\n - `tool-call` part:显示工具名、参数、状态、结果\n - `text` part:用 `marked` 增量渲染 Markdown(`v-html=\"renderMarkdown(part.text)\"`)\n- **loading 动画**:三个跳动圆点\n- **工具调用开关**:toggle,控制 `enableTools` 参数\n\n### 4.5 Markdown 渲染\n\n```typescript\nimport { marked } from 'marked'\n\nmarked.setOptions({ breaks: true, gfm: true })\n\nfunction renderMarkdown(text: string): string {\n if (!text) return ''\n try {\n return marked.parse(text, { async: false }) as string\n } catch {\n return text\n }\n}\n```\n\n样式通过 `.assistant-text :deep(...)` 覆盖 markdown 元素样式(p、pre、code、ul、ol、blockquote、table 等)。\n\n### 4.6 工具管理页面\n\n- `app/pages/admin/agent-tools/index.vue`:列表页,显示所有工具\n- `app/components/AgentToolFormModal.vue`:创建/编辑表单 Modal\n - Input Schema 只读展示(fetch 工具参数固定)\n - config 可编辑(JSON textarea)\n- `app/components/AgentToolExecuteModal.vue`:执行测试 Modal\n- `app/pages/admin/dashboard.vue`:已添加 Agent 工具管理入口\n\n### 4.7 composable 扩展\n\n```typescript\n// app/composables/useLlmChat.ts\nexport interface UseLlmChatOptions {\n modelId: () => number | null\n apiEndpoint?: string\n systemPrompt?: () => string\n enableThinking?: () => boolean\n enableTools?: () => boolean // 新增\n}\n```\n\n## 五、关键依赖\n\n| 依赖 | 版本 | 用途 |\n|------|------|------|\n| `ai` | 4.3.16 | ai-sdk,`streamText` / `tool` / `processDataStream` / `jsonSchema` |\n| `@ai-sdk/openai-compatible` | - | OpenAI 兼容 provider |\n| `zod` | 4.3.6 | schema 校验,`z.toJSONSchema()` 生成 JSON Schema |\n| `turndown` | 7.2.0 | HTML → Markdown 转换 |\n| `marked` | 12.0.2 | Markdown → HTML 渲染(前端) |\n\n**注意**:\n- `zod-to-json-schema@3.x` 不兼容 zod v4,改用 `z.toJSONSchema()`\n- `json-schema-to-zod` 返回的是代码字符串不是 zod 实例,已移除\n- ai-sdk `tool()` 的 `parameters` 接受 `jsonSchema()` 包装的对象或 zod schema\n\n## 六、ai-sdk v4 关键约定\n\n### 6.1 stream part 字段名\n\n| stream part 类型 | 字段 | 说明 |\n|------------------|------|------|\n| `tool_call` | `toolName`, `toolCallId`, **`args`** | 不是 `input` |\n| `tool_result` | `toolCallId`, **`result`** | 不是 `output` |\n\n### 6.2 `onError` 回调\n\n```typescript\nonError: (errorData: { error: unknown }) => void\n```\n返回 `void`,不能返回 string。错误需通过日志或 stream error part 传递。\n\n### 6.3 `maxSteps` 语义\n\n包含初始生成 + tool-call 轮次 + 最终回答。到上限后如果最后一步是 tool-call,`finishReason` 为 `tool-calls`,stream 直接结束,模型不会输出最终文本。\n\n### 6.4 `toDataStreamResponse`\n\n```typescript\nresult.toDataStreamResponse({ sendReasoning: true })\n```\n`sendReasoning: true` 发送 reasoning part 到前端 stream。\n\n## 七、如何扩展新工具类型\n\n### 7.1 创建 executor\n\n```\nserver/service/agent-tool/executors/<type>/\n├── config.ts # 配置 zod schema + 默认值\n├── <type>.ts # 执行器实现\n└── (可选) 其他辅助文件\n```\n\nexecutor 需实现 `ToolExecutor<TConfig>` 接口:\n```typescript\nexport const myExecutor: ToolExecutor<MyConfig> = {\n buildInputSchema(config) { return { ... } },\n buildDescription(config) { return \"...\" },\n async execute(input, config, ctx) { return { success: true, data: ... } },\n};\n```\n\n### 7.2 注册\n\n在 `server/service/agent-tool/index.ts` 顶部添加:\n```typescript\nimport { myExecutor } from \"./executors/my-type/my-type\";\nregisterToolType(\"my-type\", myExecutor);\n```\n\n### 7.3 配置校验\n\n在 `index.ts` 的 `validateConfig` 函数中添加新类型的校验:\n```typescript\nfunction validateConfig(type: string, config: Record<string, unknown>) {\n if (type === \"fetch\") return parseFetchConfig(config);\n if (type === \"my-type\") return parseMyConfig(config);\n return config;\n}\n```\n\n### 7.4 input schema\n\n在 `index.ts` 的 `getEnabledToolsForLlm` 中添加新类型的 zod schema:\n```typescript\nconst zodSchema = agentTool.type === \"fetch\"\n ? FETCH_INPUT_SCHEMA\n : agentTool.type === \"my-type\"\n ? MY_INPUT_SCHEMA\n : z.object({});\n```\n\n### 7.5 更新 ENUM\n\n在 `packages/drizzle-pkg/lib/schema/agent-tool.ts` 中添加类型:\n```typescript\nexport const AgentToolTypes = [\"fetch\", \"my-type\"] as const;\n```\n\n### 7.6 前端\n\n- `AgentToolFormModal.vue`:添加新类型的 config 表单字段\n- `AgentToolFormModal.vue`:Input Schema 只读展示新类型的参数\n\n## 八、已知问题和注意事项\n\n### 8.1 工具调用循环\n\n模型可能反复调用工具但每次都失败(如 URL 无效),消耗完 `maxSteps` 后 stream 结束。已通过以下方式缓解:\n- HTTP 非 2xx 返回 `success: false` + 明确错误文本\n- 工具失败时返回引导模型停止的提示文本\n- 工具成功时附带元信息帮助模型判断结果有效性\n- `maxSteps: 8` 给模型留余地\n\n### 8.2 前端 execute 请求\n\n`AgentToolExecuteModal.vue` 的 execute 请求可能 `Failed to fetch`,原因是 `$fetch` 未带 cookie 导致 401 或请求被浏览器拦截。需确保请求携带认证信息。\n\n### 8.3 项目已有 bug\n\n`login_post$1` / `renderer` before initialization 错误(非本框架引入),可能导致无法通过 API 登录做完整端到端测试。\n\n### 8.4 Nitro tree-shaking\n\n不要依赖 side-effect import 注册工具(如 `import \"./register.ts\"`),Nitro 会移除无导出的 side-effect import。注册逻辑必须直接在 `index.ts` 中调用 `registerToolType`。\n\n### 8.5 zod v4 兼容性\n\n- `zod-to-json-schema@3.x` 不兼容 zod v4,用 `z.toJSONSchema()` 替代\n- `json-schema-to-zod` 返回代码字符串,不是 zod 实例,已移除\n- ai-sdk `jsonSchema()` 包装器接受 `Record<string, unknown>`,需 `as Record<string, unknown>` 绕过 `JSONSchema7` 类型不匹配\n\n### 8.6 BigInt 字面量\n\n`security.ts` 中 BigInt 字面量(如 `0x0a000000n`)在某些 TypeScript 配置下有警告,改用 `BigInt(\"0x0a000000\")` 调用形式。\n\n## 九、文件清单\n\n### 服务端\n| 文件 | 说明 |\n|------|------|\n| `packages/drizzle-pkg/lib/schema/agent-tool.ts` | DB schema 定义 |\n| `packages/drizzle-pkg/migrations/0014_breezy_maestro.sql` | 迁移文件 |\n| `server/service/agent-tool/registry.ts` | ToolExecutor 接口 + 注册机制 |\n| `server/service/agent-tool/index.ts` | Service 层 CRUD + execute + getEnabledToolsForLlm |\n| `server/service/agent-tool/log.ts` | 日志写入服务 |\n| `server/service/agent-tool/executors/fetch/config.ts` | fetch 配置 zod schema |\n| `server/service/agent-tool/executors/fetch/security.ts` | SSRF 防护 + 域名检查 |\n| `server/service/agent-tool/executors/fetch/parse.ts` | raw/markdown/json 解析 |\n| `server/service/agent-tool/executors/fetch/fetch.ts` | fetch 执行器 |\n| `server/api/agent-tools/index.get.ts` | 列出工具 |\n| `server/api/agent-tools/index.post.ts` | 创建工具 |\n| `server/api/agent-tools/[id].get.ts` | 获取详情 |\n| `server/api/agent-tools/[id].put.ts` | 更新工具 |\n| `server/api/agent-tools/[id].delete.ts` | 删除工具 |\n| `server/api/agent-tools/[id]/execute.post.ts` | 独立执行 |\n| `server/api/llm/chat/index.post.ts` | 流式对话(扩展 tool-calling) |\n\n### 前端\n| 文件 | 说明 |\n|------|------|\n| `app/composables/useLlmChat.ts` | chat composable(parts 数组模型) |\n| `app/pages/settings/llm-test/index.vue` | LLM 测试页面(agent 链路展示 + Markdown 渲染) |\n| `app/pages/admin/agent-tools/index.vue` | 工具管理列表页 |\n| `app/pages/admin/dashboard.vue` | admin 仪表盘(含工具管理入口) |\n| `app/components/AgentToolFormModal.vue` | 工具创建/编辑表单 |\n| `app/components/AgentToolExecuteModal.vue` | 工具执行测试 |\n\n### 文档\n| 文件 | 说明 |\n|------|------|\n| `docs/superpowers/specs/2026-08-05-agent-tool-framework-design.md` | 设计文档 |\n| `docs/agent-tool-framework.md` | 本开发文档 |\n",
"filePath": "/home/dash/coding/nuxt-app/docs/agent-tool-framework.md"
}
Output:
Wrote file successfully.
User
Assistant (Compaction · GLM-5.2 · 165.5s)
Goal
- 构建一个通用工具调用框架,作为 agent 系统的前置开发基础设施,fetch 作为第一个内置工具,后续可扩展 search、code-exec 等
- 在 LLM 测试页面展示完整 agent 思考链路:思考 → 调用工具 → 观察结果 → 继续思考 → 回答,按流式顺序在一个助手面板内展示
Constraints & Preferences
- 工具执行独立 API + chat 集成(两阶段,先独立执行可调试,再集成到 chat)
- 新建
agent_tools表,不复用现有tools表(现有 tools 表是内容分类标签,语义不同) - 工具定义采用混合模式:type 硬编码执行器 + config 差异化配置
- fetch 工具支持三种 parseMode:raw / markdown / json
- 安全边界:域名控制 + SSRF 防护 + 超时/大小限制 + 执行日志,频率限制暂缓
- chat 集成为全自动 tool loop,maxSteps=8,无需用户确认
- 工具全局启用,所有用户共享同一套工具配置
enableTools默认 false,向后兼容现有 chat 行为- Input Schema 改为只读展示(fetch 工具参数固定,无需用户编辑,等未来多种工具类型时再开放)
- agent 链路展示:所有 part(思考/工具调用/文本回答)在同一个助手面板内,左侧一个助手头像,右侧按序展示
- assistant 文本回答需要 Markdown 增量渲染(流式时实时解析)
Progress
Done
- 探索项目上下文:Nuxt 4 + Drizzle ORM + ai-sdk,已有 LLM 集成、chat 服务、tools 表(分类标签)
- 完成所有澄清问题(8 轮 Q&A)
- 完成设计方案 7 个 section,用户逐一确认通过
- 设计文档写入
docs/superpowers/specs/2026-08-05-agent-tool-framework-design.md并提交 git(commit fcc2992) - Spec 自检完成,修正了架构图路由名称和 jsonSchemaToZod 说明
- 用户 review spec 并确认通过
- 创建 15 项实现计划 TODO(DB → Registry → fetch executor → 日志 → Service → API → chat 集成 → 前端 → 测试)
- DB Schema 完成:新建
packages/drizzle-pkg/lib/schema/agent-tool.ts,定义agentTools+agentToolLogs两张表,生成迁移0014_breezy_maestro.sql并执行成功 - Tool Registry 完成:
server/service/agent-tool/registry.ts,定义ToolExecutor<TConfig>接口、ToolContext、ToolResult,实现registerToolType/getExecutor/listToolTypes - fetch executor config 完成:
server/service/agent-tool/executors/fetch/config.ts,zod schema 校验 +DEFAULT_FETCH_CONFIG+parseFetchConfig - fetch executor security 完成:
server/service/agent-tool/executors/fetch/security.ts,SSRF 防护 + 域名白/黑名单 +assertSafeUrl - fetch executor parse 完成:
server/service/agent-tool/executors/fetch/parse.ts,raw/markdown/json 三种模式,markdown 用turndown@7.2.0 - fetch executor fetch.ts 完成:
server/service/agent-tool/executors/fetch/fetch.ts - 注册入口完成:
server/service/agent-tool/register.ts(简化为直接调用registerToolType,去掉registeredflag) - 日志服务完成:
server/service/agent-tool/log.ts - Service 层完成:
server/service/agent-tool/index.ts,实现 CRUD +executeAgentTool+getEnabledToolsForLlm - API 层 CRUD 完成:6 个端点全部创建,全部使用
requireAdmin权限校验 - 安装依赖:
@types/json-schema@7.0.15、json-schema-to-zod@2.0.0、turndown@7.2.0、@types/turndown@5.0.5、zod-to-json-schema@3.24.5 - chat 集成完成:
server/api/llm/chat/index.post.ts已扩展enableTools参数 - 前端管理页面完成:
app/pages/admin/agent-tools/index.vue - 前端表单 Modal 完成:
app/components/AgentToolFormModal.vue - 前端执行测试 Modal 完成:
app/components/AgentToolExecuteModal.vue - composable 扩展完成:
app/composables/useLlmChat.ts添加enableTools选项 - llm-test 页面工具开关完成:
app/pages/settings/llm-test/index.vue添加"工具调用"toggle 开关 - 前端 toast 修复:三个文件改用
useNuxtApp().$toast - typecheck 通过(项目已有无关错误)
- 端到端验证通过(service 层):Registry / Security / Parse / Fetch executor / CRUD /
getEnabledToolsForLlm全链路通过 - BigInt 警告修复:
security.ts中 BigInt literal 改为BigInt("0x0a000000")调用形式 require is not defined修复:json-schema-to-zod改用createRequire(import.meta.url)加载Unknown tool type: fetch修复:注册逻辑内联到index.ts,不依赖register.tsside-effect import- 域名白名单逻辑修复:白名单为空时允许所有域名
- admin dashboard 菜单入口添加
- Input Schema 改为只读
jsonSchemaToZod返回字符串问题修复:去掉json-schema-to-zod,改用z.object({ url: z.string() })- zod v4 +
zod-to-json-schema不兼容修复:改用z.toJSONSchema()+ ai-sdkjsonSchema()包装器 - message 模型重构为 parts 数组:
LlmChatMessage改为parts?: MessagePart[],每个 part 有 type(text/reasoning/tool-call/tool-result),按流式到达顺序追加 - composable stream 处理重构:
onTextPart/onReasoningPart/onToolCallPart/onToolResultPart均按序追加到msg.parts数组;getOrCreateLastPart合并连续同类型 part;updateLastReasoningDuration标记最后一个 reasoning part 完成 part.input→part.args修复:onToolCallPart的字段名是args不是inputpart.output→part.result修复:onToolResultPart的字段名是result不是output- assistant 面板重构:去掉 ChatBubble 用于 assistant,改为自定义
assistant-panel(左侧头像 + 右侧assistant-body卡片容器),内部按序渲染 reasoning-part / tool-call-item / assistant-text - user 消息保持 ChatBubble(右侧气泡)
- reasoning 折叠/展开:
collapsedReasoningSet +toggleReasoning方法 - loading 动画:三个跳动圆点替代 ChatBubble loading
- stream 静默结束检测:
processDataStream完成后检查是否有 part 产出,无则报错"模型未返回任何内容" - 服务端
onError回调添加:streamText添加onError回调,错误打到日志(返回 void,不返回 string) onFinish回调添加:记录finishReason/steps.length/ token 用量到日志executorundefined 防御:executeAgentTool中if (!executor) return { success: false, error: "工具类型未注册" }z.toJSONSchema类型不兼容修复:as Record<string, unknown>绕过JSONSchema7类型不匹配- 工具调用死循环修复(maxSteps 用完无最终回答):
maxSteps从 5 → 10 → 最终定为 8- fetch executor 添加 HTTP 非 2xx 返回
success: false+ 错误信息(之前 HTTP 200 但业务错误仍返回 success) - 工具失败时返回明确文本提示引导模型停止重试(之前返回
{ error: "..." }对象模型不理解) - 工具成功时包装返回内容附带元信息
[fetch 结果 HTTP xxx xxx bytes]\n...,帮助模型判断结果是否有效 ToolResult.data改为可选(失败时不需要传 data)onError回调修复为返回 void(ai-sdk v4 要求)- 前端检测:stream 结束后有 tool-call 但无 text part 时,追加提示文本"已达到工具调用次数上限"(保留工具记录不移除消息)
useLlmChat.ts中updateLastReasoningDuration的p添加if (!p) continue防 undefined
- Markdown 增量渲染完成:
llm-test/index.vue导入marked,添加renderMarkdown(text)函数(marked.setOptions({ breaks: true, gfm: true }))- assistant text part 从
{{ part.text }}改为v-html="renderMarkdown(part.text || '')" .assistant-text去掉white-space: pre-wrap,添加markdown-bodyclass- 添加完整 markdown 样式:p/pre/code/ul/ol/blockquote/h1-h4/a/table/th/td 的
:deep()样式
- typecheck 通过(相关文件无错误)
In Progress
- 用户需要验证工具调用 + Markdown 渲染在浏览器中的实际效果
Blocked
- 项目已有 bug:
login_post$1/rendererbefore initialization 错误,非我们引入,导致无法通过 API 登录做完整端到端测试 - 前端 execute 请求
Failed to fetch:日志中无 execute 请求记录,可能是$fetch未带 cookie 导致 401 或请求被浏览器拦截
Key Decisions
- 方案 C:工具执行独立 API + chat 集成,便于调试和复用
- 新建
agent_tools表而非扩展tools表,职责清晰 - 混合 schema 管理:type 硬编码执行逻辑,config 存差异化配置
- 全自动 tool loop(A 方案),安全由域名控制 + SSRF 防护兜底
- 全局工具启用(A 方案),工具是平台级基础设施
enableTools默认 false,现有 chat 不受影响agentTools表 id 使用at_{timestamp36}_{random}格式,非自增agentToolLogs表 id 使用自增 integer- API 端点全部使用
requireAdmin,chat 集成使用requireUser getEnabledToolsForLlm返回Record<string, any>,key 为 tool slug- 前端 toast 使用
useNuxtApp().$toast - markdown parseMode 用
turndown@7.2.0做 HTML→MD security.tsBigInt 用BigInt("0x0a000000")调用形式registerToolType容忍重复注册,getExecutor返回undefined而非抛异常- 注册逻辑直接内联在
index.ts中,不依赖单独的register.tsside-effect import - 去掉
json-schema-to-zod依赖,fetch 工具用固定 zod schema - Input Schema 界面改为只读展示
- zod v4 不兼容
zod-to-json-schema@3.x,改用z.toJSONSchema()+ ai-sdkjsonSchema()包装器 - message 模型重构为 parts 数组(text/reasoning/tool-call/tool-result 按序追加),替代旧的 content + toolCalls 分离模型
- assistant 回复用自定义面板(头像+卡片容器),不用 ChatBubble;user 消息保持 ChatBubble
onToolCallPart字段名是args(不是input);onToolResultPart字段名是result(不是output)- 服务端
streamText的onError回调返回 void(ai-sdk v4 要求),不能返回 string maxSteps=8:平衡工具调用轮次和避免死循环- 工具失败时返回明确文本提示而非
{ error }对象,引导模型停止重试 - 工具成功时包装返回内容附带元信息(HTTP 状态码 + 响应大小),帮助模型判断结果有效性
- HTTP 非 2xx 直接返回
success: false,不让模型看到业务错误 JSON 后反复重试 ToolResult.data改为可选字段- 前端 maxSteps 用完时保留工具调用记录并追加提示文本,不移除消息
- assistant text part 用
marked做 Markdown 增量渲染(流式时每次 text 更新重新 parse),v-html输出
Next Steps
- 用户验证工具调用 + Markdown 渲染在浏览器中的实际效果
- 验证前端 execute 请求能正常工作(用户在浏览器测试)
- 清理:
register.ts文件可能已不需要(注册逻辑已内联到index.ts) - 清理:
json-schema-to-zod和zod-to-json-schema@3.24.5依赖可能已不需要
Critical Context
- 项目使用 Nuxt 4 + Drizzle ORM (SQLite) + ai-sdk (Vercel AI SDK) v4.3.16
- zod 版本为 v4.3.6,内置
z.toJSONSchema()方法可直接生成 JSON Schema zod-to-json-schema@3.x不兼容 zod v4- ai-sdk
tool()的parameters接受 zod schema 或jsonSchema()包装的对象 processDataStream支持回调:onTextPart、onReasoningPart、onErrorPart、onToolCallPart、onToolResultPart等onToolCallPart的 stream part 类型为tool_call,包含toolName、toolCallId、argsonToolResultPart的 stream part 类型为tool_result,包含toolCallId、resultstreamText的onError回调必须返回 void,不能返回 string(ai-sdk v4 类型要求)streamText的onFinish回调提供finishReason、usage、steps数组maxSteps到上限且最后一步是 tool-call 时,finishReason=tool-calls,stream 直接结束,模型无机会输出最终文本toDataStreamResponse({ sendReasoning: true })发送 reasoning part- 现有
server/api/llm/chat/index.post.ts使用streamText+defineEventHandler server/service/agent-tool/为新增独立模块,与现有server/service/tool/并行- API 响应模式:CRUD 端点用
defineWrappedResponseHandler+R.success/R.throwError,chat 端点用defineEventHandler+createError requireAdmin在server/utils/admin-guard.tsdbGlobal从drizzle-pkg/lib/db导入- 前端组件库
packages/bolt-ui/有ChatBubble组件,但不支持 toolCalls prop app/composables/useLlmChat.ts是 chat 的核心 composable- 项目已有 bug:
login_post$1/rendererbefore initialization(非我们引入) - 数据库中已有用户:admin (id=11, role=admin)、npmrun (id=12, role=user)
- 数据库中已有 1 个 fetch 工具:id=
at_msfj6cbp_1float,slug=test,type=fetch,enabled=1 - Nitro tree-shaking 会移除没有导出的 side-effect import
MessagePart类型:id/type(text/reasoning/tool-call/tool-result)/text/toolName/toolCallId/args/result/state/reasoningLoading/reasoningDurationgetOrCreateLastPart合并连续同类型 part(text 追加到上一个 text part,reasoning 追加到上一个 reasoning part)updateLastReasoningDuration从 parts 末尾向前找第一个reasoningLoading的 reasoning part 并标记完成,需if (!p) continue防 undefinedmarked@12.0.2已在项目依赖中,marked.setOptions({ breaks: true, gfm: true })+marked.parse(text, { async: false }) as string- 掘金 API 返回 HTTP 200 +
{"err_no":2,"err_msg":"请求路由不存在"},工具标记 success 但模型不理解是错误,导致反复换 URL 重试——已通过包装返回内容+元信息修复
Relevant Files
docs/superpowers/specs/2026-08-05-agent-tool-framework-design.md— 设计文档(已提交)packages/drizzle-pkg/lib/schema/agent-tool.ts— schema 定义packages/drizzle-pkg/migrations/0014_breezy_maestro.sql— 迁移文件(已执行)server/service/agent-tool/registry.ts— ToolExecutor 接口 + 注册机制(ToolResult.data改为可选)server/service/agent-tool/register.ts— 注册入口(可能已不需要)server/service/agent-tool/log.ts— writeToolLog 日志服务server/service/agent-tool/index.ts— Service 层 CRUD + execute + getEnabledToolsForLlm(内联注册 + 工具失败返回明确文本提示 + 成功返回包装内容+元信息)server/service/agent-tool/executors/fetch/config.ts— fetch 配置 zod schema + 默认值server/service/agent-tool/executors/fetch/security.ts— SSRF 防护 + 域名检查server/service/agent-tool/executors/fetch/parse.ts— raw/markdown/json 解析server/service/agent-tool/executors/fetch/fetch.ts— fetch 执行器(添加 HTTP 非 2xx 返回 success:false)server/api/agent-tools/index.get.ts— 列出工具server/api/agent-tools/index.post.ts— 创建工具server/api/agent-tools/[id].get.ts— 获取详情server/api/agent-tools/[id].put.ts— 更新工具server/api/agent-tools/[id].delete.ts— 删除工具server/api/agent-tools/[id]/execute.post.ts— 独立执行server/api/llm/chat/index.post.ts— 流式对话接口,已扩展 tool-calling +onError(void) +onFinish+maxSteps=8app/pages/admin/agent-tools/index.vue— admin 管理列表页app/pages/admin/dashboard.vue— admin 仪表盘,已添加 Agent 工具管理入口app/components/AgentToolFormModal.vue— 工具创建/编辑表单 Modal(inputSchema 只读)app/components/AgentToolExecuteModal.vue— 工具执行测试 Modalapp/composables/useLlmChat.ts— chat composable,parts 数组模型 + tool-call/tool-result 处理 + maxSteps 用完追加提示 +pundefined 防御app/pages/settings/llm-test/index.vue— LLM 测试页面,assistant-panel + Markdown 渲染(marked+v-html+renderMarkdown()+ 完整 markdown:deep()样式)
User
Assistant (Build · GLM-5.2 · 15.3s)
根据 Progress 中的 Next Steps,剩余事项都需要用户在浏览器验证,我无法独立完成。需要你确认:
- 浏览器验证:工具调用 + Markdown 渲染在实际对话中是否正常工作?
- 前端 execute 请求:
AgentToolExecuteModal的Failed to fetch问题是否仍存在?需要我排查$fetch认证问题吗? - 清理工作(可独立完成):是否要我现在清理
register.ts(已内联到index.ts)和移除未使用的json-schema-to-zod/zod-to-json-schema依赖?
开发文档已写入 docs/agent-tool-framework.md。
User
- 正常工作
- 没有问题
- 没问题话可以清理
Assistant (Build · GLM-5.2 · 27.5s)
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && cat server/service/agent-tool/register.ts 2>/dev/null && echo \"---EXISTS---\" || echo \"---NOT EXISTS---\"",
"description": "检查 register.ts 是否存在"
}
Output:
import { registerToolType } from "./registry";
import { fetchExecutor } from "./executors/fetch/fetch";
registerToolType("fetch", fetchExecutor);
---EXISTS---
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && grep -rn \"register.ts\\|from.*register\" server/service/agent-tool/ --include=\"*.ts\" 2>/dev/null",
"description": "检查 register.ts 是否被引用"
}
Output:
(no output)
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && grep -rn \"json-schema-to-zod\\|zod-to-json-schema\" --include=\"*.ts\" --include=\"*.vue\" --include=\"package.json\" . 2>/dev/null | grep -v node_modules",
"description": "检查依赖是否还被使用"
}
Output:
./package.json:44: "json-schema-to-zod": "2.0.0",
./package.json:60: "zod-to-json-schema": "3.24.5"
Assistant (Build · GLM-5.2 · 31.5s)
register.ts 存在但无引用,两个依赖只在 package.json 中存在。清理:
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && rm server/service/agent-tool/register.ts && echo \"deleted register.ts\"",
"description": "删除 register.ts"
}
Output:
deleted register.ts
Tool: bash
Input:
{
"command": "cd /home/dash/coding/nuxt-app && pnpm remove json-schema-to-zod zod-to-json-schema 2>&1 | tail -20",
"description": "移除未使用依赖"
}
Output:
[WARN] The "workspaces" field in package.json is not supported by pnpm. Create a "pnpm-workspace.yaml" file instead.
[ERROR] This project is configured to use bun
For help, run: pnpm help remove