This commit is contained in:
Cheney 2026-08-16 22:56:09 +08:00
parent 789ff22263
commit 8ce9361d96
8 changed files with 206 additions and 33 deletions

3
.env
View File

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

View File

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

View File

@ -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": {

86
remote_receiver.ts Normal file
View File

@ -0,0 +1,86 @@
// 远程日志接收器
// 监听 HTTP 端口接收日志内容推送POST /logJSON 或纯文本),
// 按日期追加存入 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}`);
});

View File

@ -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 || "",
};
// 持久化配置到 .envWORKSPACE 为内部默认值,不在设置页暴露)
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[] = [

View File

@ -19,6 +19,11 @@
</select>
</label>
<p class="hint">debug 级别会把所有对话工具调用审批记录到 <code>logs/</code> 目录</p>
<label class="row">
<span>远程日志地址</span>
<input v-model="form.REMOTE_LOG_URL" placeholder="http://127.0.0.1:3200/log留空则只写本地" />
</label>
<p class="hint">填写远程日志接收器的 HTTP 地址后每条日志会同时推送到该地址</p>
</section>
<section>
@ -86,6 +91,7 @@ const form = reactive({
CMD_APPROVE_LEVEL: "medium",
LOG_LEVEL: "debug",
TEMPERATURE: 0.3,
REMOTE_LOG_URL: "",
});
const saving = ref(false);
const msg = ref("");
@ -126,6 +132,7 @@ onMounted(async () => {
form.CMD_APPROVE_LEVEL = d.CMD_APPROVE_LEVEL || "medium";
form.LOG_LEVEL = d.LOG_LEVEL || "debug";
form.TEMPERATURE = typeof d.TEMPERATURE === "number" ? d.TEMPERATURE : 0.3;
form.REMOTE_LOG_URL = d.REMOTE_LOG_URL || "";
// API_KEY
} catch {
msg.value = "加载设置失败";
@ -147,6 +154,7 @@ async function save() {
CMD_APPROVE_LEVEL: form.CMD_APPROVE_LEVEL,
LOG_LEVEL: form.LOG_LEVEL,
TEMPERATURE: form.TEMPERATURE,
REMOTE_LOG_URL: form.REMOTE_LOG_URL,
}),
});
const d = await r.json();

14
start_receiver.bat Normal file
View File

@ -0,0 +1,14 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
echo ========================================
echo 远程日志接收器 启动中...
echo 监听 http://127.0.0.1:3200/log
echo 收到的日志存入 received_logs/ 目录
echo ========================================
if exist "C:\Users\Administrator\.bun\bin\bun.exe" (
"C:\Users\Administrator\.bun\bin\bun.exe" run remote_receiver.ts
) else (
bun run remote_receiver.ts
)
pause

View File

@ -0,0 +1,24 @@
// vite.config.ts
import { defineConfig } from "file:///D:/workbench/miniai/node_modules/vite/dist/node/index.js";
import vue from "file:///D:/workbench/miniai/node_modules/@vitejs/plugin-vue/dist/index.mjs";
var vite_config_default = defineConfig({
plugins: [vue()],
server: {
host: true,
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:3000",
changeOrigin: true
}
}
},
build: {
outDir: "dist",
base: "./"
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJEOlxcXFx3b3JrYmVuY2hcXFxcbWluaWFpXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJEOlxcXFx3b3JrYmVuY2hcXFxcbWluaWFpXFxcXHZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9EOi93b3JrYmVuY2gvbWluaWFpL3ZpdGUuY29uZmlnLnRzXCI7aW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSBcInZpdGVcIjtcbmltcG9ydCB2dWUgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXZ1ZVwiO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbdnVlKCldLFxuICBzZXJ2ZXI6IHtcbiAgICBob3N0OiB0cnVlLFxuICAgIHBvcnQ6IDUxNzMsXG4gICAgcHJveHk6IHtcbiAgICAgIFwiL2FwaVwiOiB7XG4gICAgICAgIHRhcmdldDogXCJodHRwOi8vMTI3LjAuMC4xOjMwMDBcIixcbiAgICAgICAgY2hhbmdlT3JpZ2luOiB0cnVlLFxuICAgICAgfSxcbiAgICB9LFxuICB9LFxuICBidWlsZDoge1xuICAgIG91dERpcjogXCJkaXN0XCIsXG4gICAgYmFzZTogXCIuL1wiLFxuICB9LFxufSk7Il0sCiAgIm1hcHBpbmdzIjogIjtBQUFpUCxTQUFTLG9CQUFvQjtBQUM5USxPQUFPLFNBQVM7QUFFaEIsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsU0FBUyxDQUFDLElBQUksQ0FBQztBQUFBLEVBQ2YsUUFBUTtBQUFBLElBQ04sTUFBTTtBQUFBLElBQ04sTUFBTTtBQUFBLElBQ04sT0FBTztBQUFBLE1BQ0wsUUFBUTtBQUFBLFFBQ04sUUFBUTtBQUFBLFFBQ1IsY0FBYztBQUFBLE1BQ2hCO0FBQUEsSUFDRjtBQUFBLEVBQ0Y7QUFBQSxFQUNBLE9BQU87QUFBQSxJQUNMLFFBQVE7QUFBQSxJQUNSLE1BQU07QUFBQSxFQUNSO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K