diff --git a/.env b/.env deleted file mode 100644 index 4d4cccd..0000000 --- a/.env +++ /dev/null @@ -1,12 +0,0 @@ -# Mini AI Assistant 配置(可由设置页面修改) -BASE_URL=https://ark.cn-beijing.volces.com/api/coding/v3 -API_KEY=ark-ef9b970f-5301-4691-a95e-3f01597ca3f2-b430e -MODEL=deepseek-v4-flash -WORKSPACE=D:\workbench\miniai -CMD_APPROVE_LEVEL=medium -PORT=3100 -LOG_LEVEL=debug -TEMPERATURE=0.3 -DUCKDB_EXE=D:\workbench\miniai\bin\duckdb.exe -DUCKDB_FILE=D:\workbench\miniai\data\duck.db -REMOTE_LOG_URL=http://127.0.0.1:3200/log diff --git a/.gitignore b/.gitignore index ca355b9..9fc7d2f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ _be.* conversations/ # 内置 duckdb 可执行文件与运行时数据库 -bin/ data/ # 环境变量(含密钥,勿提交;参考 .env.example) diff --git a/bin/duckdb.exe b/bin/duckdb.exe new file mode 100644 index 0000000..e206bce Binary files /dev/null and b/bin/duckdb.exe differ diff --git a/server.ts b/server.ts index 0bac0e8..11ef289 100644 --- a/server.ts +++ b/server.ts @@ -393,7 +393,9 @@ async function* streamLLM(messages: ChatMsg[]): AsyncGenerator<{ type: "token" | const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; - let toolAcc: any = null; + // 支持一次响应返回多个工具调用:按 id 聚合,各自独立 + const toolCalls = new Map(); + let anonIdx = 0; while (true) { const { done, value } = await reader.read(); @@ -412,17 +414,18 @@ async function* streamLLM(messages: ChatMsg[]): AsyncGenerator<{ type: "token" | if (delta.content) yield { type: "token", content: delta.content }; if (delta.tool_calls) { for (const tc of delta.tool_calls) { - if (!toolAcc) toolAcc = { id: "", type: "function", function: { name: "", arguments: "" } }; - if (tc.id) toolAcc.id = tc.id; - if (tc.function?.name) toolAcc.function.name = tc.function.name; - if (tc.function?.arguments) toolAcc.function.arguments += tc.function.arguments; + const id = tc.id || `call_anon_${anonIdx++}`; + if (!toolCalls.has(id)) toolCalls.set(id, { id, type: "function", function: { name: "", arguments: "" } }); + const acc = toolCalls.get(id); + if (tc.function?.name) acc.function.name = tc.function.name; + if (tc.function?.arguments) acc.function.arguments += tc.function.arguments; } } } } - if (toolAcc) { - if (!toolAcc.id) toolAcc.id = "call_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7); - yield { type: "toolcall", toolcall: toolAcc }; + for (const tc of toolCalls.values()) { + if (!tc.id || tc.id.startsWith("call_anon_")) tc.id = "call_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7); + yield { type: "toolcall", toolcall: tc }; } } @@ -449,6 +452,64 @@ function withSystem(msgs: ChatMsg[]): ChatMsg[] { return [{ role: "system", content: SYSTEM_PROMPT }, ...msgs]; } +// ---------- 工具参数容错解析与路径自动补全 ---------- +// 解析模型返回的工具参数;非法 JSON 尝试修复,裸路径转为 {path} +function parseToolArgs(raw: string | undefined): { args: any; note: string } { + if (!raw || !raw.trim()) return { args: {}, note: "空参数" }; + try { + const a = JSON.parse(raw); + if (typeof a === "string") return { args: { path: a.trim() }, note: "裸路径转为 path" }; + return { args: a && typeof a === "object" && !Array.isArray(a) ? a : {}, note: "" }; + } catch {} + // 尝试转义单反斜杠(模型常把 Windows 路径写成 D:\xxx) + const fixed = raw.replace(/\\(?![\\"])/g, "\\\\"); + if (fixed !== raw) { + try { + const a = JSON.parse(fixed); + return { args: a && typeof a === "object" && !Array.isArray(a) ? a : {}, note: "已修复非法反斜杠" }; + } catch {} + } + // 裸字符串(去掉引号)→ 作为 path(若含 JSON 残留字符则视为解析失败) + const trimmed = raw.trim().replace(/^["']|["']$/g, ""); + if (trimmed && !/[{}"]/.test(trimmed)) return { args: { path: trimmed }, note: "裸路径转为 path" }; + return { args: {}, note: "参数无法解析" }; +} +// 工具缺 path 时,用本对话已知的最近文件/目录补全(仅限非破坏性工具) +function fillPathArgs(name: string, args: any, ctx: { lastDir: string; lastFile: string }): { args: any; note: string } { + if (args.path && String(args.path).trim()) return { args, note: "" }; + if (["read_file", "read_excel", "load_excel"].includes(name)) { + if (ctx.lastFile) return { args: { ...args, path: ctx.lastFile }, note: `自动补全 path=${ctx.lastFile}` }; + if (ctx.lastDir) return { args: { ...args, path: ctx.lastDir }, note: `自动补全 path=${ctx.lastDir}` }; + return { args, note: "" }; + } + if (name === "list_directory") { + if (ctx.lastDir) return { args: { ...args, path: ctx.lastDir }, note: `自动补全 path=${ctx.lastDir}` }; + return { args: { ...args, path: "." }, note: "" }; + } + return { args, note: "" }; +} +// 从工具执行结果中学习已知文件/目录,供后续补全 +function absorbToolContext(name: string, args: any, exec: any, ctx: { lastDir: string; lastFile: string }) { + const p = args.path; + if (p && typeof p === "string") { + if (name === "list_directory") { + ctx.lastDir = p; + } else { + ctx.lastFile = p; + ctx.lastDir = dirname(p); + } + } + if (name === "list_directory" && Array.isArray(exec.result)) { + for (const it of exec.result) { + if (it?.type === "file") ctx.lastFile = resolve(ctx.lastDir || ".", it.name); + } + } + if (name === "execute_command" && typeof exec.result === "string") { + const m = exec.result.split(/\r?\n/).find((l) => /\.(xlsx?|xls|csv|txt|json)$/i.test(l.trim())); + if (m) ctx.lastFile = m.trim(); + } +} + async function runConversation(initialMessages: ChatMsg[], res: any, fromApproval = false) { sseInit(res); let messages = withSystem(initialMessages); @@ -461,21 +522,24 @@ async function runConversation(initialMessages: ChatMsg[], res: any, fromApprova log("info", "对话开始", fromApproval ? "审批后继续" : "新对话"); if (userInput) log("info", "用户输入", userInput); + // 本对话已知的最近文件/目录(供工具缺 path 时自动补全) + const ctx = { lastDir: "", lastFile: "" }; + // 工具循环(最多 10 轮避免死循环) for (let round = 0; round < 10; round++) { // 发送给 AI 的请求 log("info", "发送给AI", `第${round + 1}轮 model=${config.MODEL} temperature=${config.TEMPERATURE}\n${fmtMessages(messages)}`); const gen = streamLLM(messages); let content = ""; - let toolCall: any = null; let errored = false; let llmErr = ""; + const toolCalls: any[] = []; for await (const ev of gen) { if (ev.type === "token") { content += ev.content; sseSend(res, { type: "token", content: ev.content }); } else if (ev.type === "toolcall") { - toolCall = ev.toolcall; + toolCalls.push(ev.toolcall); } else if (ev.type === "error") { errored = true; llmErr = ev.content; @@ -491,48 +555,62 @@ async function runConversation(initialMessages: ChatMsg[], res: any, fromApprova return; } - // 需要工具调用 - if (toolCall && toolCall.function?.name) { - const name = toolCall.function.name; - let args = {}; - try { args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}; } catch { args = {}; } - // AI 返回:决定调用工具 - log("info", "AI返回", `调用工具 ${name},参数 ${safeStringify(args)}`); - if (content) log("info", "返回界面", clip(content, 2000)); + // 需要工具调用:本轮把模型返回的多个工具调用全部执行,统一把结果返回给模型 + const calls = toolCalls.filter((tc) => tc?.function?.name); + if (calls.length) { + const assistantMsg = { role: "assistant", content: content || null, tool_calls: calls.map((tc) => ({ id: tc.id, type: tc.type, function: tc.function })) }; + const toolResults: ChatMsg[] = []; - if (needsApproval(name, args)) { - // 请求审批,保存上下文等待 /api/approve - log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(等待用户审批)`); - pendingApproval = { - messages: [ - ...messages, - { role: "assistant", content: content || null, tool_calls: [toolCall] }, - ], - toolCall, - }; - sseSend(res, { - type: "approval_required", - tool_call_id: toolCall.id, - tool: name, - args, - command: name === "execute_command" ? args.command : `${name}(${JSON.stringify(args)})`, - }); - sseSend(res, { type: "done" }); - res.end(); - return; + for (const tc of calls) { + const name = tc.function.name; + const parsed = parseToolArgs(tc.function.arguments); + let args = parsed.args; + // AI 返回:决定调用工具 + log("info", "AI返回", `调用工具 ${name},参数 ${safeStringify(args)}${parsed.note ? `(${parsed.note})` : ""}`); + + // 路径自动补全:工具缺 path 时用本对话已知的最近文件/目录 + const filled = fillPathArgs(name, args, ctx); + if (filled.note) { + args = filled.args; + log("info", "路径补全", filled.note); + } else { + args = filled.args; + } + + if (needsApproval(name, args)) { + // 请求审批,保存上下文等待 /api/approve(之前已执行的结果一并保留) + log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(等待用户审批,其余 ${calls.length - 1} 个调用一并暂停)`); + pendingApproval = { + messages: [...messages, assistantMsg, ...toolResults], + toolCall: tc, + args, + }; + sseSend(res, { + type: "approval_required", + tool_call_id: tc.id, + tool: name, + args, + command: name === "execute_command" ? args.command : `${name}(${JSON.stringify(args)})`, + }); + sseSend(res, { type: "done" }); + res.end(); + return; + } + + // 执行命令/工具 + log("info", "执行命令", `${name} 参数 ${safeStringify(args)}`); + const exec = await execTool(name, args); + // 执行结果 + log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`); + // 从结果中学习已知路径(供后续工具缺 path 时补全) + absorbToolContext(name, args, exec, ctx); + toolResults.push({ role: "tool", name, content: safeStringify(exec), tool_call_id: tc.id }); } - // 执行命令/工具 - log("info", "执行命令", `${name} 参数 ${safeStringify(args)}`); - const exec = await execTool(name, args); - // 执行结果 - log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`); - messages = [ - ...messages, - { role: "assistant", content: content || null, tool_calls: [toolCall] }, - { role: "tool", name, content: safeStringify(exec), tool_call_id: toolCall.id }, - ]; - // 继续循环,让模型基于工具结果生成最终回复 + if (content) log("info", "返回界面", clip(content, 2000)); + if (calls.length > 1) log("info", "工具调用", `本轮共执行 ${calls.length} 个工具调用`); + messages = [...messages, assistantMsg, ...toolResults]; + // 继续循环,让模型基于全部工具结果生成最终回复 continue; } @@ -815,11 +893,13 @@ const server = createServer(async (req, res) => { res.end(JSON.stringify({ error: "无待审批操作" })); return; } - const { messages, toolCall } = pendingApproval; + const { messages, toolCall, args: pendingArgs } = pendingApproval; pendingApproval = null; const name = toolCall.function.name; - let args = {}; - try { args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}; } catch {} + // 优先使用审批时的已补全参数;否则重新容错解析 + const parsed = parseToolArgs(toolCall.function.arguments); + const args = pendingArgs && typeof pendingArgs === "object" && !Array.isArray(pendingArgs) ? pendingArgs : parsed.args; + log("info", "审批执行", `${name} 参数 ${safeStringify(args)}${parsed.note ? `(${parsed.note})` : ""}`); let exec; if (approved) {