This commit is contained in:
Cheney 2026-08-16 21:48:49 +08:00
parent a82f855743
commit 422a4b9d71
4 changed files with 57 additions and 33 deletions

8
.env
View File

@ -1,7 +1,7 @@
# Mini AI Assistant 配置(可由设置页面修改) # Mini AI Assistant 配置(可由设置页面修改)
BASE_URL=https://opencode.ai/zen/v1 BASE_URL=https://ark.cn-beijing.volces.com/api/coding/v3
API_KEY=sk-ihICiTIZTPdQ0FlhA7ru1kFMpVnxcvHNaK0oxem0ecnz7CpPx0xRoBMCPQhPDmmf API_KEY=ark-ef9b970f-5301-4691-a95e-3f01597ca3f2-b430e
MODEL=deepseek-v4-flash-free MODEL=deepseek-v4-flash
WORKSPACE=D:\workbench\miniai WORKSPACE=D:\workbench\miniai
CMD_APPROVE_LEVEL=medium CMD_APPROVE_LEVEL=medium
PORT=3100 PORT=3100
@ -9,3 +9,5 @@ LOG_LEVEL=debug
TEMPERATURE=0.3 TEMPERATURE=0.3
WHITELIST=d:\ WHITELIST=d:\
BLACKLIST= BLACKLIST=
DUCKDB_EXE=D:\workbench\miniai\bin\duckdb.exe
DUCKDB_FILE=D:\workbench\miniai\data\duck.db

View File

@ -26,3 +26,7 @@ WHITELIST=d:\
# 路径黑名单(优先级最高,逗号分隔;留空表示无) # 路径黑名单(优先级最高,逗号分隔;留空表示无)
BLACKLIST= BLACKLIST=
# DuckDB内置 CLI 可执行文件路径与本地数据库文件(无需原生 .node 依赖)
DUCKDB_EXE=bin/duckdb.exe
DUCKDB_FILE=data/duck.db

4
.gitignore vendored
View File

@ -13,6 +13,10 @@ _be.*
# 会话记忆数据 # 会话记忆数据
conversations/ conversations/
# 内置 duckdb 可执行文件与运行时数据库
bin/
data/
# 环境变量(含密钥,勿提交;参考 .env.example # 环境变量(含密钥,勿提交;参考 .env.example
.env .env
.env.local .env.local

View File

@ -39,10 +39,12 @@ const config = {
TEMPERATURE: parseFloat(process.env.TEMPERATURE || ENV.TEMPERATURE || "0.3"), TEMPERATURE: parseFloat(process.env.TEMPERATURE || ENV.TEMPERATURE || "0.3"),
WHITELIST: parseList(process.env.WHITELIST || ENV.WHITELIST).length ? parseList(process.env.WHITELIST || ENV.WHITELIST) : DEFAULT_WHITELIST, WHITELIST: parseList(process.env.WHITELIST || ENV.WHITELIST).length ? parseList(process.env.WHITELIST || ENV.WHITELIST) : DEFAULT_WHITELIST,
BLACKLIST: parseList(process.env.BLACKLIST || ENV.BLACKLIST), BLACKLIST: parseList(process.env.BLACKLIST || ENV.BLACKLIST),
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"),
}; };
// 持久化配置到 .envWORKSPACE 为内部默认值,不在设置页暴露) // 持久化配置到 .envWORKSPACE 为内部默认值,不在设置页暴露)
const ENV_KEYS = ["BASE_URL", "API_KEY", "MODEL", "WORKSPACE", "CMD_APPROVE_LEVEL", "PORT", "LOG_LEVEL", "TEMPERATURE", "WHITELIST", "BLACKLIST"] as const; const ENV_KEYS = ["BASE_URL", "API_KEY", "MODEL", "WORKSPACE", "CMD_APPROVE_LEVEL", "PORT", "LOG_LEVEL", "TEMPERATURE", "WHITELIST", "BLACKLIST", "DUCKDB_EXE", "DUCKDB_FILE"] as const;
function saveEnv() { function saveEnv() {
const lines = ENV_KEYS.map((k) => `${k}=${String((config as any)[k])}`); const lines = ENV_KEYS.map((k) => `${k}=${String((config as any)[k])}`);
const header = "# Mini AI Assistant 配置(可由设置页面修改)\n"; const header = "# Mini AI Assistant 配置(可由设置页面修改)\n";
@ -88,9 +90,9 @@ function clip(s: any, n = 600): string {
// ---------- 依赖 ---------- // ---------- 依赖 ----------
import { readFile, writeFile, readdir, rename, unlink, stat, mkdir } from "fs/promises"; import { readFile, writeFile, readdir, rename, unlink, stat, mkdir } from "fs/promises";
import { resolve, normalize, extname, sep } from "path"; import { resolve, normalize, extname, sep, dirname } from "path";
import { read as xlsxRead, write as xlsxWrite, utils } from "xlsx"; import { read as xlsxRead, write as xlsxWrite, utils } from "xlsx";
import duckdb from "duckdb"; import { spawn } from "child_process";
// ---------- 路径安全(白名单 + 黑名单,黑名单优先) ---------- // ---------- 路径安全(白名单 + 黑名单,黑名单优先) ----------
// 判断 target 是否等于 base 或位于 base 的子目录下(跨平台) // 判断 target 是否等于 base 或位于 base 的子目录下(跨平台)
@ -125,33 +127,38 @@ function isDangerousCommand(cmd: string): boolean {
return DANGEROUS_PATTERNS.some((p) => lower.includes(p)); return DANGEROUS_PATTERNS.some((p) => lower.includes(p));
} }
// ---------- DuckDBExcel 数据处理) ---------- // ---------- DuckDB内置 duckdb.exe 子进程 + 本地数据库文件,无原生 .node 依赖) ----------
let duckDb: any = null; function duckRun(sql: string): Promise<string> {
function getDuck(): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (duckDb) return resolve(duckDb); mkdirSync(dirname(config.DUCKDB_FILE), { recursive: true });
duckDb = new duckdb.Database(":memory:", (err: any) => { const child = spawn(config.DUCKDB_EXE, [config.DUCKDB_FILE, "-json"], { stdio: ["pipe", "pipe", "pipe"] });
if (err) return reject(err); let out = "", err = "";
resolve(duckDb); child.stdout.on("data", (d) => (out += d.toString("utf8")));
child.stderr.on("data", (d) => (err += d.toString("utf8")));
child.on("error", (e) => reject(new Error("无法启动 duckdb: " + e.message)));
child.on("close", (code) => {
if (code === 0) resolve(out);
else reject(new Error(err.trim() || `duckdb 退出码 ${code}`));
}); });
child.stdin.write(sql, "utf8");
child.stdin.end();
}); });
} }
function duckExec(db: any, sql: string): Promise<void> { async function duckExec(sql: string): Promise<void> {
return new Promise((resolve, reject) => db.exec(sql, (e: any) => (e ? reject(e) : resolve()))); await duckRun(sql);
} }
function duckAll(db: any, sql: string): Promise<any[]> { async function duckAll(sql: string): Promise<any[]> {
return new Promise((resolve, reject) => db.all(sql, (e: any, rows: any) => (e ? reject(e) : resolve(rows || [])))); const out = await duckRun(sql);
const t = out.trim();
if (!t) return [];
try { return JSON.parse(t); } catch { return []; }
} }
function sqlVal(v: any): string { function csvCell(v: any): string {
if (v === null || v === undefined || v === "") return "NULL"; if (v === null || v === undefined) return "";
if (typeof v === "number") return Number.isFinite(v) ? String(v) : "NULL"; if (typeof v === "number") return Number.isFinite(v) ? String(v) : "";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; if (v instanceof Date) return '"' + v.toISOString().replace(/"/g, '""') + '"';
if (v instanceof Date) return `'${v.toISOString().replace(/'/g, "''")}'`; const s = String(v);
return `'${String(v).replace(/'/g, "''")}'`; return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
function safeCol(c: string): string {
const s = String(c).trim() || "col";
return s.replace(/[^\w一-龥]/g, "_");
} }
// ---------- 工具执行 ---------- // ---------- 工具执行 ----------
@ -248,7 +255,6 @@ async function execTool(name: string, args: any): Promise<{ ok: boolean; result:
if (!ws) throw new Error("工作表不存在: " + sheetName); if (!ws) throw new Error("工作表不存在: " + sheetName);
const rows: any[] = utils.sheet_to_json(ws, { defval: null }); const rows: any[] = utils.sheet_to_json(ws, { defval: null });
const table = args.table || "excel_data"; const table = args.table || "excel_data";
const db = await getDuck();
if (!rows.length) return { ok: true, result: `工作表 "${sheetName}" 无数据行` }; if (!rows.length) return { ok: true, result: `工作表 "${sheetName}" 无数据行` };
const rawCols = Object.keys(rows[0]); const rawCols = Object.keys(rows[0]);
const seen = new Set<string>(); const seen = new Set<string>();
@ -272,11 +278,20 @@ async function execTool(name: string, args: any): Promise<{ ok: boolean; result:
types[c] = hasVal && allNum ? "DOUBLE" : "VARCHAR"; types[c] = hasVal && allNum ? "DOUBLE" : "VARCHAR";
} }
const colDefs = cols.map((c) => `"${c}" ${types[c]}`).join(", "); const colDefs = cols.map((c) => `"${c}" ${types[c]}`).join(", ");
await duckExec(db, `CREATE OR REPLACE TABLE "${table}" (${colDefs})`);
const MAX = 50000; const MAX = 50000;
const slice = rows.slice(0, MAX); const slice = rows.slice(0, MAX);
const values = slice.map((r) => `(${cols.map((c) => sqlVal(r[c])).join(", ")})`).join(", "); // 写 UTF-8 CSV → duckdb.exe COPY 导入
if (values) await duckExec(db, `INSERT INTO "${table}" VALUES ${values}`); const csvPath = resolve(config.DUCKDB_FILE, "..", `load_${Date.now()}.csv`);
mkdirSync(dirname(csvPath), { recursive: true });
const csvLines = [cols.join(",")];
for (const r of slice) csvLines.push(cols.map((c) => csvCell(r[c])).join(","));
writeFileSync(csvPath, csvLines.join("\n"), "utf-8");
const csvSql = csvPath.replace(/\\/g, "/");
try {
await duckExec(`CREATE OR REPLACE TABLE "${table}" (${colDefs}); COPY "${table}" FROM '${csvSql}' (FORMAT csv, HEADER true, DELIMITER ',');`);
} finally {
try { unlinkSync(csvPath); } catch {}
}
return { return {
ok: true, ok: true,
result: { result: {
@ -290,9 +305,8 @@ async function execTool(name: string, args: any): Promise<{ ok: boolean; result:
case "query_duckdb": { case "query_duckdb": {
const sql = String(args.sql || "").trim(); const sql = String(args.sql || "").trim();
if (!sql) throw new Error("缺少 SQL 语句"); if (!sql) throw new Error("缺少 SQL 语句");
const db = await getDuck();
try { try {
const rows = await duckAll(db, sql); const rows = await duckAll(sql);
const cols = rows.length ? Object.keys(rows[0]) : []; const cols = rows.length ? Object.keys(rows[0]) : [];
return { ok: true, result: { rowCount: rows.length, columns: cols, data: rows.slice(0, 200) } }; return { ok: true, result: { rowCount: rows.length, columns: cols, data: rows.slice(0, 200) } };
} catch (e: any) { } catch (e: any) {