diff --git a/.env b/.env index 1c9b660..4d4cccd 100644 --- a/.env +++ b/.env @@ -7,7 +7,6 @@ CMD_APPROVE_LEVEL=medium PORT=3100 LOG_LEVEL=debug TEMPERATURE=0.3 -WHITELIST=d:\ -BLACKLIST= 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/.env.example b/.env.example index 7467f04..dc998be 100644 --- a/.env.example +++ b/.env.example @@ -21,12 +21,10 @@ LOG_LEVEL=debug # 温度(0~1) TEMPERATURE=0.3 -# 路径白名单(允许的盘符/目录,逗号分隔;留空表示仅工作区) -WHITELIST=d:\ - -# 路径黑名单(优先级最高,逗号分隔;留空表示无) -BLACKLIST= - # DuckDB:内置 CLI 可执行文件路径与本地数据库文件(无需原生 .node 依赖) DUCKDB_EXE=bin/duckdb.exe DUCKDB_FILE=data/duck.db + +# 远程日志接收器地址(HTTP,接收方 POST /log 推送日志内容;留空则只写本地日志) +# 例如 http://127.0.0.1:3200/log +REMOTE_LOG_URL= diff --git a/package.json b/package.json index 37439a4..c9181e6 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,10 @@ "version": "1.0.0", "type": "module", "scripts": { - "dev": "concurrently \"vite\" \"bun run node server.ts\"", + "dev": "concurrently \"vite\" \"bun run server.ts\"", "build": "vite build", "start": "bun run server.ts", + "receiver": "bun run remote_receiver.ts", "preview": "vite preview" }, "dependencies": { diff --git a/remote_receiver.ts b/remote_receiver.ts new file mode 100644 index 0000000..fece2f7 --- /dev/null +++ b/remote_receiver.ts @@ -0,0 +1,86 @@ +// 远程日志接收器 +// 监听 HTTP 端口,接收日志内容推送(POST /log,JSON 或纯文本), +// 按日期追加存入 received_logs/ 文件夹。日志内容直接传输,不做文件传输。 +import { createServer } from "node:http"; +import { mkdir, appendFile } from "fs/promises"; +import { existsSync } from "fs"; +import { resolve } from "path"; + +const PORT = parseInt(process.env.RECEIVER_PORT || "3200", 10); +const LOG_DIR = resolve("received_logs"); + +function now(iso: string): string { + return iso ? iso : new Date().toISOString(); +} + +function fmtEntry(p: { level?: string; category?: string; message?: string; line?: string; ts?: string }): string { + const ts = now(p.ts); + const level = (p.level || "info").toUpperCase().padEnd(5); + const category = p.category || "receiver"; + const message = p.message !== undefined ? p.message : ""; + return `[${ts}] [${level}] [${category}] ${message}`; +} + +const server = createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } + + const url = new URL(req.url || "/", `http://localhost:${PORT}`); + + if (req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok", name: "远程日志接收器", receivePath: "POST /log", port: PORT })); + return; + } + + if (req.method === "POST" && (url.pathname === "/log" || url.pathname === "/")) { + let body = ""; + try { + for await (const c of req) body += c; + } catch { + res.writeHead(400); res.end("bad request"); return; + } + + let entry: string; + let level = "info"; + let category = "receiver"; + try { + const json = JSON.parse(body); + if (typeof json === "string") { + entry = json; + } else if (json.line) { + entry = json.line; + level = json.level || level; + category = json.category || category; + } else { + entry = fmtEntry({ level: json.level, category: json.category, message: json.message, ts: json.ts }); + level = json.level || level; + category = json.category || category; + } + } catch { + entry = fmtEntry({ level, category, message: body, ts: new Date().toISOString() }); + } + + try { + if (!existsSync(LOG_DIR)) await mkdir(LOG_DIR, { recursive: true }); + const ts = new Date().toISOString().slice(0, 10); + await appendFile(resolve(LOG_DIR, `received-${ts}.log`), entry + "\n", "utf-8"); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: (e as any).message })); + } + return; + } + + res.writeHead(404); + res.end("Not Found"); +}); + +server.listen(PORT, () => { + console.log(`[remote_receiver] 远程日志接收器已启动: http://127.0.0.1:${PORT}/log (存入 ${LOG_DIR})`); +}); diff --git a/server.ts b/server.ts index 0fe935d..0bac0e8 100644 --- a/server.ts +++ b/server.ts @@ -34,10 +34,11 @@ const config = { TEMPERATURE: parseFloat(process.env.TEMPERATURE || ENV.TEMPERATURE || "0.3"), DUCKDB_EXE: process.env.DUCKDB_EXE || ENV.DUCKDB_EXE || resolve("bin", "duckdb.exe"), DUCKDB_FILE: process.env.DUCKDB_FILE || ENV.DUCKDB_FILE || resolve("data", "duck.db"), + REMOTE_LOG_URL: process.env.REMOTE_LOG_URL || ENV.REMOTE_LOG_URL || "", }; // 持久化配置到 .env(WORKSPACE 为内部默认值,不在设置页暴露) -const ENV_KEYS = ["BASE_URL", "API_KEY", "MODEL", "WORKSPACE", "CMD_APPROVE_LEVEL", "PORT", "LOG_LEVEL", "TEMPERATURE", "DUCKDB_EXE", "DUCKDB_FILE"] as const; +const ENV_KEYS = ["BASE_URL", "API_KEY", "MODEL", "WORKSPACE", "CMD_APPROVE_LEVEL", "PORT", "LOG_LEVEL", "TEMPERATURE", "DUCKDB_EXE", "DUCKDB_FILE", "REMOTE_LOG_URL"] as const; function saveEnv() { const lines = ENV_KEYS.map((k) => `${k}=${String((config as any)[k])}`); const header = "# Mini AI Assistant 配置(可由设置页面修改)\n"; @@ -58,15 +59,39 @@ function shouldLog(level: string): boolean { async function log(level: string, category: string, data: any) { if (!shouldLog(level)) return; const ts = new Date().toISOString(); - const text = typeof s === "string" ? s : safeStringify(data); + const text = typeof data === "string" ? data : safeStringify(data); const line = `[${ts}] [${level.toUpperCase().padEnd(5)}] [${category}] ${text}\n`; try { if (!existsSync(LOG_DIR)) await mkdir(LOG_DIR, { recursive: true }); - await appendFile(`${LOG_DIR}/chat-${ts.slice(0, 10)}.log`, line, "utf-8"); await appendFile(`${LOG_DIR}/global.log`, line, "utf-8"); } catch { /* 忽略日志写入失败 */ } + pushRemote(level, category, text); +} +// 推送到远程日志接收器(仅配置了地址时启用;失败静默忽略) +function pushRemote(level: string, category: string, message: string) { + const url = config.REMOTE_LOG_URL; + if (!url) return; + try { + fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level, category, message, ts: new Date().toISOString() }), + }).catch(() => {}); + } catch { + /* 忽略推送失败 */ + } +} +// 对话消息列表的可读格式化(供“发送给AI”日志使用) +function fmtMessages(msgs: ChatMsg[]): string { + return msgs + .map((m) => { + if (m.role === "tool") return ` [tool ${m.name}] ${clip(m.content ?? "", 600)}`; + if (m.tool_calls?.length) return ` [assistant→tool] ${m.tool_calls.map((tc: any) => tc.function?.name).join(", ")}`; + return ` [${m.role}] ${clip(m.content ?? "", 1000)}`; + }) + .join("\n"); } // 可安全序列化 BigInt 等类型 function safeStringify(s: any): string { @@ -428,19 +453,23 @@ async function runConversation(initialMessages: ChatMsg[], res: any, fromApprova sseInit(res); let messages = withSystem(initialMessages); - // 日志:记录用户/工具输入 - log("debug", "conversation_start", { - fromApproval, - messages: messages.map((m) => ({ role: m.role, content: clip(m.content, 800) })), - }); + // 用户输入 + const userInput = initialMessages + .filter((m) => m.role === "user") + .map((m) => clip(m.content ?? "", 2000)) + .join(" | "); + log("info", "对话开始", fromApproval ? "审批后继续" : "新对话"); + if (userInput) log("info", "用户输入", userInput); // 工具循环(最多 10 轮避免死循环) for (let round = 0; round < 10; round++) { - log("debug", "round_start", { round: round + 1, pendingMessages: messages.length }); + // 发送给 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 = ""; for await (const ev of gen) { if (ev.type === "token") { content += ev.content; @@ -449,23 +478,31 @@ async function runConversation(initialMessages: ChatMsg[], res: any, fromApprova toolCall = ev.toolcall; } else if (ev.type === "error") { errored = true; - log("error", "llm_error", ev.content); + llmErr = ev.content; + log("error", "AI错误", ev.content); sseSend(res, { type: "token", content: "\n\n⚠️ " + ev.content }); } } - log("debug", "assistant_round", { round: round + 1, content: clip(content, 1000), wantsTool: !!toolCall }); - if (errored) { sseSend(res, { type: "done" }); res.end(); return; } + if (errored) { + log("info", "返回界面", "⚠️ " + clip(llmErr, 1000)); + sseSend(res, { type: "done" }); + res.end(); + 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)); if (needsApproval(name, args)) { // 请求审批,保存上下文等待 /api/approve - log("info", "approval_required", { tool: name, args }); + log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(等待用户审批)`); pendingApproval = { messages: [ ...messages, @@ -485,10 +522,11 @@ async function runConversation(initialMessages: ChatMsg[], res: any, fromApprova return; } - // 直接执行 - log("debug", "tool_call", { name, args }); + // 执行命令/工具 + log("info", "执行命令", `${name} 参数 ${safeStringify(args)}`); const exec = await execTool(name, args); - log("debug", "tool_result", { name, ok: exec.ok, result: clip(exec.result, 800) }); + // 执行结果 + log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`); messages = [ ...messages, { role: "assistant", content: content || null, tool_calls: [toolCall] }, @@ -499,13 +537,14 @@ async function runConversation(initialMessages: ChatMsg[], res: any, fromApprova } // 无工具调用,正常结束 - log("debug", "assistant", clip(content, 2000)); + log("info", "AI返回", clip(content, 2000)); + log("info", "返回界面", clip(content, 2000)); sseSend(res, { type: "done" }); res.end(); return; } - log("warn", "round_limit", { round: 10 }); + log("warn", "对话结束", "达到最大工具调用轮数(10),强制结束"); sseSend(res, { type: "token", content: "\n[达到最大工具调用轮数]" }); sseSend(res, { type: "done" }); res.end(); @@ -622,6 +661,7 @@ const server = createServer(async (req, res) => { CMD_APPROVE_LEVEL: config.CMD_APPROVE_LEVEL, LOG_LEVEL: config.LOG_LEVEL, TEMPERATURE: config.TEMPERATURE, + REMOTE_LOG_URL: config.REMOTE_LOG_URL, })); return; } @@ -632,16 +672,18 @@ const server = createServer(async (req, res) => { for await (const c of req) body += c; let payload: any; try { payload = JSON.parse(body); } catch { res.writeHead(400); res.end("bad json"); return; } - if (typeof payload.BASE_URL === "string") config.BASE_URL = payload.BASE_URL; - if (typeof payload.API_KEY === "string" && !payload.API_KEY.startsWith("******")) config.API_KEY = payload.API_KEY; - if (typeof payload.MODEL === "string") config.MODEL = payload.MODEL; + if (typeof payload.BASE_URL === "string" && payload.BASE_URL.trim()) config.BASE_URL = payload.BASE_URL.trim(); + // token 为空时不覆盖已有配置(保留原 token) + if (typeof payload.API_KEY === "string" && payload.API_KEY.trim() && !payload.API_KEY.startsWith("******")) config.API_KEY = payload.API_KEY.trim(); + if (typeof payload.MODEL === "string" && payload.MODEL.trim()) config.MODEL = payload.MODEL.trim(); if (payload.CMD_APPROVE_LEVEL === "high" || payload.CMD_APPROVE_LEVEL === "medium" || payload.CMD_APPROVE_LEVEL === "low") { config.CMD_APPROVE_LEVEL = payload.CMD_APPROVE_LEVEL; } if (typeof payload.LOG_LEVEL === "string") config.LOG_LEVEL = payload.LOG_LEVEL.toLowerCase(); if (typeof payload.TEMPERATURE === "number") config.TEMPERATURE = payload.TEMPERATURE; + if (typeof payload.REMOTE_LOG_URL === "string") config.REMOTE_LOG_URL = payload.REMOTE_LOG_URL.trim(); saveEnv(); - log("info", "settings_updated", { CMD_APPROVE_LEVEL: config.CMD_APPROVE_LEVEL, MODEL: config.MODEL, LOG_LEVEL: config.LOG_LEVEL }); + log("info", "settings_updated", { CMD_APPROVE_LEVEL: config.CMD_APPROVE_LEVEL, MODEL: config.MODEL, LOG_LEVEL: config.LOG_LEVEL, REMOTE_LOG_URL: config.REMOTE_LOG_URL }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); return; @@ -781,10 +823,11 @@ const server = createServer(async (req, res) => { let exec; if (approved) { - log("info", "approval_granted", { tool: name, args }); + log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(已批准)`); exec = await execTool(name, args); + log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`); } else { - log("info", "approval_denied", { tool: name, args }); + log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(已拒绝)`); exec = { ok: false, result: "用户已拒绝该操作" }; } const nextMessages: ChatMsg[] = [ diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue index aa4e4b5..1250377 100644 --- a/src/components/SettingsModal.vue +++ b/src/components/SettingsModal.vue @@ -19,6 +19,11 @@
debug 级别会把所有对话、工具调用、审批记录到 logs/ 目录。
填写远程日志接收器的 HTTP 地址后,每条日志会同时推送到该地址。