mingdao-harness 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/CONFIG.md +24 -2
- package/docs/PLAN-v0.3.0.md +1 -1
- package/docs/PLAN-v0.3.2.md +65 -0
- package/package.json +1 -1
- package/src/agent.js +68 -12
- package/src/audit.js +3 -4
- package/src/cli.js +3 -3
- package/src/commands/diagnose.js +2 -12
- package/src/commands/repl.js +3 -3
- package/src/compact.js +3 -1
- package/src/memory.js +35 -1
- package/src/model-caps.js +69 -0
- package/src/providers/index.js +45 -6
- package/src/providers/openai-compatible.js +8 -3
- package/src/redact.js +26 -0
- package/src/task-state.js +18 -0
- package/src/tools/fetch.js +56 -0
- package/src/tools/git.js +31 -0
- package/src/tools/index.js +39 -1
- package/src/web/app.js +63 -64
- package/src/web/index.html +72 -22
- package/src/web/routes/domains/config.js +26 -2
- package/src/web/server.js +11 -5
package/src/providers/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { chat as openaiChat } from './openai-compatible.js';
|
|
|
15
15
|
import { modelPreset, providerPreset } from '../models.js';
|
|
16
16
|
import { mingdaoHome } from '../config.js';
|
|
17
17
|
import { resolveApiKey } from '../credentials.js';
|
|
18
|
+
import { isLocalBaseUrl } from '../model-caps.js';
|
|
18
19
|
|
|
19
20
|
export function resolveProviderConfig(/** @type {any} */ cfg, /** @type {any} */ modelName) {
|
|
20
21
|
// 自定义模型(config.customModels,WebUI 可增删改):优先于内置预设
|
|
@@ -56,7 +57,7 @@ function isTransient(/** @type {any} */ err) {
|
|
|
56
57
|
return /timeout|超时|ECONNRESET|fetch failed/i.test(String(err?.message || ''));
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
export async function createProvider(/** @type {any} */ cfg, /** @type {any} */ modelName, { timeoutMs
|
|
60
|
+
export async function createProvider(/** @type {any} */ cfg, /** @type {any} */ modelName, /** @type {{ timeoutMs?: number, retries?: number }} */ { timeoutMs, retries = 2 } = {}) {
|
|
60
61
|
const pc = resolveProviderConfig(cfg, modelName);
|
|
61
62
|
|
|
62
63
|
// 自定义 Provider 模块优先(仅普通自定义端点;custom:<模型名> 走 OpenAI 兼容直连)
|
|
@@ -74,6 +75,18 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
74
75
|
throw new Error(`服务商 "${pc.name}" 缺少 baseUrl,请运行 mingdao init 重新配置。`);
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
// v0.3.2 本地模型自适应:本地推理框架(CPU/GPU 有限)长上下文 prefill 极慢(诊断实测 127k 上下文
|
|
79
|
+
// 首 token 需 196s+,q8 dequant 下 prefill 仅 ~165 tok/s)。默认超时按是否本地分层:
|
|
80
|
+
// - 首 token 等待:本地 600s / 远程 300s(覆盖慢 prefill,而非 189s 被掐断)
|
|
81
|
+
// - 流式空闲:有帧后 120s 无新帧即断(真正挂死才断,慢速吐字不误杀)
|
|
82
|
+
// - 总量:本地 30min / 远程 10min(长生成不误杀)
|
|
83
|
+
// 均可用 cfg.timeout.firstTokenMs / streamIdleMs / totalMs 覆盖。
|
|
84
|
+
const isLocal = isLocalBaseUrl(pc.baseUrl);
|
|
85
|
+
const tCfg = cfg?.timeout || {};
|
|
86
|
+
const firstTokenMs = Number(tCfg.firstTokenMs) > 0 ? Number(tCfg.firstTokenMs) : (isLocal ? 600000 : 300000);
|
|
87
|
+
const streamIdleMs = Number(tCfg.streamIdleMs) > 0 ? Number(tCfg.streamIdleMs) : 120000;
|
|
88
|
+
const totalMs = Number(tCfg.totalMs) > 0 ? Number(tCfg.totalMs) : (Number(timeoutMs) > 0 ? Number(timeoutMs) : (isLocal ? 1800000 : 600000));
|
|
89
|
+
|
|
77
90
|
return {
|
|
78
91
|
name: pc.name,
|
|
79
92
|
config: pc,
|
|
@@ -82,10 +95,32 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
82
95
|
for (;;) {
|
|
83
96
|
const ac = new AbortController();
|
|
84
97
|
let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时
|
|
85
|
-
|
|
98
|
+
// 总量护栏:整次请求(prefill+生成)的绝对上限
|
|
99
|
+
const totalTimer = setTimeout(() => {
|
|
100
|
+
timedOut = true;
|
|
101
|
+
ac.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
|
|
102
|
+
}, totalMs);
|
|
103
|
+
// 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
|
|
104
|
+
let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
|
|
86
105
|
timedOut = true;
|
|
87
|
-
ac.abort(new Error(
|
|
88
|
-
},
|
|
106
|
+
ac.abort(new Error(`首 token 等待超限(${Math.round(firstTokenMs / 1000)}s,本地模型长上下文 prefill 可能很慢)——可调大 config.timeout.firstTokenMs,或拆分任务/压缩上下文`));
|
|
107
|
+
}, firstTokenMs));
|
|
108
|
+
// 流式空闲:有帧后 120s 无新帧即断;每收到一帧重置
|
|
109
|
+
let idleTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
|
|
110
|
+
const armIdle = () => {
|
|
111
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
112
|
+
idleTimer = setTimeout(() => {
|
|
113
|
+
timedOut = true;
|
|
114
|
+
ac.abort(new Error(`流式响应空闲超限(${Math.round(streamIdleMs / 1000)}s 无新数据)`));
|
|
115
|
+
}, streamIdleMs);
|
|
116
|
+
};
|
|
117
|
+
const onActivity = () => {
|
|
118
|
+
if (firstTokenTimer) {
|
|
119
|
+
clearTimeout(firstTokenTimer);
|
|
120
|
+
firstTokenTimer = null;
|
|
121
|
+
}
|
|
122
|
+
armIdle();
|
|
123
|
+
};
|
|
89
124
|
// 转发外部信号(用户 Ctrl+C 中断),避免被内部超时信号覆盖
|
|
90
125
|
const onUserAbort = () => ac.abort(opts.signal?.reason);
|
|
91
126
|
if (opts.signal?.aborted) onUserAbort();
|
|
@@ -97,20 +132,24 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
97
132
|
apiKey: pc.apiKey,
|
|
98
133
|
signal: ac.signal,
|
|
99
134
|
includeUsage: cfg?.includeUsage !== false,
|
|
135
|
+
onActivity,
|
|
100
136
|
});
|
|
101
137
|
} catch (err) {
|
|
102
138
|
// 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试
|
|
103
139
|
const transient = (timedOut && !opts.signal?.aborted) || isTransient(err);
|
|
104
140
|
if (!transient || attempt >= retries) throw err;
|
|
105
141
|
attempt += 1;
|
|
106
|
-
//
|
|
142
|
+
// 首 token 等待超时通常不是偶发网络抖动(是模型/上下文慢),重试价值低但保留一次机会;
|
|
143
|
+
// 其余瞬态错误指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,封顶 30s
|
|
107
144
|
let backoff = 1000 * attempt;
|
|
108
145
|
const ra = Number((/** @type {any} */ (err))?.headers?.get?.('retry-after'));
|
|
109
146
|
if (Number.isFinite(ra) && ra > 0) backoff = Math.max(backoff, ra * 1000);
|
|
110
147
|
backoff = Math.min(backoff, 30000);
|
|
111
148
|
await sleep(backoff);
|
|
112
149
|
} finally {
|
|
113
|
-
clearTimeout(
|
|
150
|
+
clearTimeout(totalTimer);
|
|
151
|
+
if (firstTokenTimer) clearTimeout(firstTokenTimer);
|
|
152
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
114
153
|
opts.signal?.removeEventListener('abort', onUserAbort);
|
|
115
154
|
}
|
|
116
155
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @typedef {Error & { status?: number, headers?: Headers }} ApiError
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, includeUsage = true, responseFormat, reasoningEffort }) {
|
|
9
|
+
export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, onActivity, includeUsage = true, responseFormat, reasoningEffort }) {
|
|
10
10
|
const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
|
|
11
11
|
const payload = /** @type {Record<string, any>} */ ({ model, messages });
|
|
12
12
|
if (temperature != null) payload.temperature = temperature;
|
|
@@ -62,7 +62,7 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
|
|
|
62
62
|
if (!json) throw new Error(`[${model}] 响应解析失败。`);
|
|
63
63
|
return parseNonStream(json, onDelta);
|
|
64
64
|
}
|
|
65
|
-
return parseStream(res.body, onDelta);
|
|
65
|
+
return parseStream(res.body, onDelta, onActivity);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDelta) {
|
|
@@ -81,7 +81,9 @@ export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDel
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// 解析 SSE 流:处理跨 chunk 断行、增量 content / reasoning_content / tool_calls。
|
|
84
|
-
|
|
84
|
+
// onActivity:每收到一个有效 SSE 数据帧即回调(含 usage-only 帧),供上层做「首 token 等待/流式空闲」超时仲裁——
|
|
85
|
+
// 长上下文 prefill 时服务端可能 200s+ 无正文,靠「有帧到达」而非「有正文」判定存活,避免误杀慢 prefill。
|
|
86
|
+
export async function parseStream(/** @type {any} */ body, /** @type {any} */ onDelta, /** @type {any} */ onActivity) {
|
|
85
87
|
const reader = body.getReader();
|
|
86
88
|
const decoder = new TextDecoder();
|
|
87
89
|
let buf = '';
|
|
@@ -151,6 +153,9 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
|
|
|
151
153
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
152
154
|
const line = buf.slice(0, nl);
|
|
153
155
|
buf = buf.slice(nl + 1);
|
|
156
|
+
// 有帧到达即视为「活着」:prefill 阶段服务端可能先发 usage-only/空帧,正文迟迟不来,
|
|
157
|
+
// 靠帧到达刷新上层流式空闲计时器(首 token 等待则仍由「无任何帧」触发)
|
|
158
|
+
if (line.trim()) onActivity?.();
|
|
154
159
|
if (handleLine(line)) break;
|
|
155
160
|
}
|
|
156
161
|
}
|
package/src/redact.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// 统一脱敏(v0.3.1 P1-1 修复):审计/日志/会话/诊断/错误消息共用同一套规则,消除「各层自扫门前雪」。
|
|
2
|
+
// - redactSecrets:密钥脱敏(常见前缀 + Bearer + URL 内嵌凭据),保留路径便于排查 → 审计/日志用
|
|
3
|
+
// - redactSensitive:在 redactSecrets 之上再加私网 IP + 家目录路径掩码 → 诊断包/对外输出用
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
// 常见密钥前缀(GitHub/OpenAI/AWS/Slack/Google 等);sk- 保留前缀、其余整体掩码
|
|
7
|
+
const KEY_PREFIX = /(ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[A-Z0-9]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[A-Za-z0-9_-]{30,})/g;
|
|
8
|
+
|
|
9
|
+
export function redactSecrets(/** @type {any} */ text) {
|
|
10
|
+
let s = String(text ?? '');
|
|
11
|
+
s = s.replace(/(sk-[A-Za-z0-9_-]{6,})/g, 'sk-***'); // 保留 sk- 前缀(兼容审计标记)
|
|
12
|
+
s = s.replace(KEY_PREFIX, '***');
|
|
13
|
+
s = s.replace(/(Authorization\s*:\s*Bearer\s+)[^\s"',}]+/gi, '$1***');
|
|
14
|
+
s = s.replace(/((?:api[_-]?key|token|secret|password|passwd|access_token)\s*[=:]\s*["']?)[^\s"',}]+/gi, '$1***');
|
|
15
|
+
s = s.replace(/([?&](?:key|token|secret|api_key|access_token)=)[^&\s"']+/gi, '$1***');
|
|
16
|
+
return s;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function redactSensitive(/** @type {any} */ text) {
|
|
20
|
+
let s = redactSecrets(text);
|
|
21
|
+
s = s.replace(/\b(?:10|127)(?:\.\d{1,3}){3}\b|\b192\.168(?:\.\d{1,3}){2}\b|\b169\.254(?:\.\d{1,3}){2}\b|\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b|\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.\d{1,3}){2}\b/g, '[私网IP]');
|
|
22
|
+
s = s.replace(/(?:fe80:[\da-f:]+|::1|::)/gi, '[链路本地/回环IPv6]');
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
if (home && home.length > 1) s = s.split(home).join('~');
|
|
25
|
+
return s;
|
|
26
|
+
}
|
package/src/task-state.js
CHANGED
|
@@ -44,6 +44,24 @@ export function clearTaskState(sessionName) {
|
|
|
44
44
|
} catch {}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// 合并落盘(v0.3.1 P2-3 修复):续跑再中断时保留原始 goal、合并 artifacts,只更新 progress/status。
|
|
48
|
+
// 避免「第二次续跑」时把 goal 覆盖成「继续」、把已交付文件清单清零。
|
|
49
|
+
/** @param {any} sessionName @param {any} ts */
|
|
50
|
+
export function saveTaskStateMerge(sessionName, ts) {
|
|
51
|
+
const prev = loadTaskState(sessionName);
|
|
52
|
+
const prevUnfinished = prev && (prev.status === 'cap' || prev.status === 'interrupted');
|
|
53
|
+
const merged = prevUnfinished
|
|
54
|
+
? {
|
|
55
|
+
goal: prev.goal || ts.goal,
|
|
56
|
+
artifacts: [...new Set([...(Array.isArray(prev.artifacts) ? prev.artifacts : []), ...(Array.isArray(ts.artifacts) ? ts.artifacts : [])])],
|
|
57
|
+
progress: ts.progress || prev.progress,
|
|
58
|
+
status: ts.status,
|
|
59
|
+
updatedAt: ts.updatedAt,
|
|
60
|
+
}
|
|
61
|
+
: ts;
|
|
62
|
+
saveTaskState(sessionName, merged);
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
// 续跑提示:注入到消息历史,让模型先核对现状(已完成文件不重做)、再做未完成部分。
|
|
48
66
|
/** @param {any} ts */
|
|
49
67
|
export function resumePrompt(ts) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// HTTP 只读抓取工具(v0.3.1):GET 任意 http(s) URL,返回文本(512KB 上限,正文截 20K)。
|
|
2
|
+
// SSRF 防护:字面量私网/回环拒绝 + DNS 解析复检(防域名重绑定),口径与 server.js validateRemoteUrl 一致。
|
|
3
|
+
import { lookup } from 'node:dns/promises';
|
|
4
|
+
|
|
5
|
+
function isPrivateHost(/** @type {string} */ hostname) {
|
|
6
|
+
let h = String(hostname || '').toLowerCase();
|
|
7
|
+
if (!h) return true;
|
|
8
|
+
h = h.replace(/^\[|\]$/g, '');
|
|
9
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
|
|
10
|
+
if (h.includes(':')) {
|
|
11
|
+
if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
|
|
12
|
+
return /^fe[89ab]/.test(h) || /^f[c d]/.test(h) || h === '::' || h === '::1';
|
|
13
|
+
}
|
|
14
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
15
|
+
if (!m) return false;
|
|
16
|
+
const a = Number(m[1]);
|
|
17
|
+
const b = Number(m[2]);
|
|
18
|
+
return a === 10 || a === 127 || a === 0 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx) {
|
|
22
|
+
const raw = String(args.url ?? '').trim();
|
|
23
|
+
if (!raw) return { ok: false, error: '缺少 url 参数。' };
|
|
24
|
+
let u;
|
|
25
|
+
try {
|
|
26
|
+
u = new URL(raw);
|
|
27
|
+
} catch {
|
|
28
|
+
return { ok: false, error: 'url 必须是合法的 http(s) URL。' };
|
|
29
|
+
}
|
|
30
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: '仅支持 http/https 地址。' };
|
|
31
|
+
const host = String(u.hostname || '').toLowerCase();
|
|
32
|
+
let blocked = isPrivateHost(host);
|
|
33
|
+
if (!blocked && host && host !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
|
34
|
+
try {
|
|
35
|
+
const addrs = await lookup(host, { all: true, verbatim: true });
|
|
36
|
+
blocked = addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
|
|
37
|
+
} catch {
|
|
38
|
+
// DNS 解析失败:放行,连接阶段会报错
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (blocked) return { ok: false, error: `拒绝访问内网/本机地址(${host})——SSRF 防护。` };
|
|
42
|
+
const ac = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => ac.abort(), 15000);
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(u, { signal: ac.signal, redirect: 'follow' });
|
|
46
|
+
const buf = await res.arrayBuffer();
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
if (buf.byteLength > 512 * 1024) return { ok: false, error: `响应超过 512KB 上限(实际 ${buf.byteLength} 字节)。` };
|
|
49
|
+
const text = new TextDecoder('utf-8').decode(buf);
|
|
50
|
+
const truncated = text.length > 20000;
|
|
51
|
+
return { ok: true, status: res.status, contentType: res.headers.get('content-type') || '', output: (truncated ? text.slice(0, 20000) + '\n…[正文过长已截断,共 ' + text.length + ' 字符]' : text) || '(空响应)' };
|
|
52
|
+
} catch (/** @type {any} */ err) {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
return { ok: false, error: '抓取失败:' + (err?.name === 'AbortError' ? '超时(15s)' : String(err?.message || err)) };
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/tools/git.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// git 只读工具(v0.3.1):只允许只读子命令(status/log/diff/show/blame/rev-parse/branch/tag/ls-files/shortlog),
|
|
2
|
+
// 经 execFile 无 shell 执行(防注入),运行于 ctx.workingDir,输出与退出码结构化返回。
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const GIT_READONLY = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'branch', 'tag', 'ls-files', 'shortlog']);
|
|
6
|
+
|
|
7
|
+
export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
8
|
+
const command = String(args.command ?? '').trim();
|
|
9
|
+
if (!command) {
|
|
10
|
+
return { ok: false, error: `缺少 command 参数。只读子命令:${[...GIT_READONLY].join(' / ')}` };
|
|
11
|
+
}
|
|
12
|
+
const argv = command.split(/\s+/).filter(Boolean);
|
|
13
|
+
const sub = argv[0];
|
|
14
|
+
if (!GIT_READONLY.has(sub)) {
|
|
15
|
+
return { ok: false, error: `git ${sub} 不是只读子命令(仅支持 ${[...GIT_READONLY].join(' / ')})。写操作请用 bash 并注意授权。` };
|
|
16
|
+
}
|
|
17
|
+
// 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count)
|
|
18
|
+
const cwd = ctx.workingDir || process.cwd();
|
|
19
|
+
try {
|
|
20
|
+
const { stdout, stderr } = await execFile('git', argv, {
|
|
21
|
+
cwd,
|
|
22
|
+
timeout: 15000,
|
|
23
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
24
|
+
});
|
|
25
|
+
const out = String(stdout || '') + String(stderr || '');
|
|
26
|
+
return { ok: true, exitCode: 0, output: out.trim() || '(无输出)' };
|
|
27
|
+
} catch (/** @type {any} */ err) {
|
|
28
|
+
const e = /** @type {any} */ (err);
|
|
29
|
+
return { ok: false, error: String(e.stderr || e.message || err).trim(), exitCode: e.code };
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
import { read, write, edit, ls, glob, grep, undo } from './fs-tools.js';
|
|
5
5
|
import { runBash } from './bash.js';
|
|
6
|
+
import { runGit } from './git.js';
|
|
7
|
+
import { runFetch } from './fetch.js';
|
|
6
8
|
import { listSkills, loadSkill } from '../skills.js';
|
|
7
9
|
|
|
8
10
|
// 只读工具集合的单一来源:permissions.js 引用此导出,新增只读工具时只需改这里
|
|
9
|
-
export const READONLY_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'skill']);
|
|
11
|
+
export const READONLY_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'skill', 'git', 'fetch']);
|
|
10
12
|
|
|
11
13
|
const READ_SCHEMA = {
|
|
12
14
|
type: 'object',
|
|
@@ -116,6 +118,22 @@ const UNDO_SCHEMA = {
|
|
|
116
118
|
},
|
|
117
119
|
};
|
|
118
120
|
|
|
121
|
+
const GIT_SCHEMA = {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
command: { type: 'string', description: '只读 git 子命令与参数,如 log --oneline -10、diff HEAD、status、show。' },
|
|
125
|
+
},
|
|
126
|
+
required: ['command'],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const FETCH_SCHEMA = {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
url: { type: 'string', description: '公网 http(s) 地址。' },
|
|
133
|
+
},
|
|
134
|
+
required: ['url'],
|
|
135
|
+
};
|
|
136
|
+
|
|
119
137
|
const TOOLS = [
|
|
120
138
|
{
|
|
121
139
|
type: 'function',
|
|
@@ -205,6 +223,22 @@ const TOOLS = [
|
|
|
205
223
|
parameters: UNDO_SCHEMA,
|
|
206
224
|
},
|
|
207
225
|
},
|
|
226
|
+
{
|
|
227
|
+
type: 'function',
|
|
228
|
+
function: {
|
|
229
|
+
name: 'git',
|
|
230
|
+
description: '只读 git 查询(status/log/diff/show/blame 等)。写操作请用 bash。',
|
|
231
|
+
parameters: GIT_SCHEMA,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
type: 'function',
|
|
236
|
+
function: {
|
|
237
|
+
name: 'fetch',
|
|
238
|
+
description: '抓取公网 URL 文本(≤512KB,SSRF 防护拒绝内网)。',
|
|
239
|
+
parameters: FETCH_SCHEMA,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
208
242
|
];
|
|
209
243
|
|
|
210
244
|
export function toolSchemas() {
|
|
@@ -322,6 +356,10 @@ export async function dispatch(/** @type {any} */ name, /** @type {any} */ args,
|
|
|
322
356
|
return runTodo(args, ctx);
|
|
323
357
|
case 'undo':
|
|
324
358
|
return undo(args, ctx);
|
|
359
|
+
case 'git':
|
|
360
|
+
return runGit(args, ctx);
|
|
361
|
+
case 'fetch':
|
|
362
|
+
return runFetch(args, ctx);
|
|
325
363
|
default:
|
|
326
364
|
return { ok: false, error: `未知工具:${name}` };
|
|
327
365
|
}
|
package/src/web/app.js
CHANGED
|
@@ -483,6 +483,7 @@ function syncPanelLayout(){
|
|
|
483
483
|
document.body.classList.toggle('traj-open', open('trajPanel'));
|
|
484
484
|
document.body.classList.toggle('sub-open', open('subPanel'));
|
|
485
485
|
document.body.classList.toggle('tasks-open', open('tasksPanel'));
|
|
486
|
+
document.body.classList.toggle('dash-open', open('dashPanel'));
|
|
486
487
|
}
|
|
487
488
|
$('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); syncPanelLayout(); };
|
|
488
489
|
$('#trajRailBtn').onclick=()=>{
|
|
@@ -568,17 +569,20 @@ async function updateTasksPanel(){
|
|
|
568
569
|
}
|
|
569
570
|
}
|
|
570
571
|
setInterval(updateTasksPanel, 2000);
|
|
571
|
-
// 费用徽标:今日费用 / 缓存命中率 / 护栏(15s
|
|
572
|
+
// 费用徽标:今日费用 / 缓存命中率 / 护栏(15s 刷新);点击展开省钱仪表盘
|
|
572
573
|
async function refreshCostBadge(){
|
|
573
574
|
const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
574
575
|
const j=await r.json().catch(()=>null); if(!j) return;
|
|
575
576
|
const bd=j.breakdown||{}; const gd=j.guard||null;
|
|
576
|
-
let t='今日 ≈¥'+(bd.today||0).toFixed(4);
|
|
577
|
+
let t='📊 今日 ≈¥'+(bd.today||0).toFixed(4);
|
|
577
578
|
if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
|
|
578
579
|
if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
|
|
579
580
|
$('#costBadge').textContent=t;
|
|
581
|
+
if($('#dashPanel').style.display==='flex') renderDashboard(j);
|
|
580
582
|
}
|
|
581
583
|
refreshCostBadge(); setInterval(refreshCostBadge, 15000);
|
|
584
|
+
$('#costBadge').onclick=()=>{ const p=$('#dashPanel'); const show=p.style.display==='none'; p.style.display=show?'flex':'none'; if(show){ $('#subPanel').style.display='none'; $('#trajPanel').style.display='none'; $('#tasksPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); $('#trajRailBtn').classList.remove('on'); refreshCostBadge(); } syncPanelLayout(); };
|
|
585
|
+
$('#dashClose').onclick=()=>{ $('#dashPanel').style.display='none'; syncPanelLayout(); };
|
|
582
586
|
// 底部状态栏:轮次/步数/LLM 与工具时长/首 token 平均/吞吐/缓存命中/输入输出 tokens
|
|
583
587
|
async function refreshStatusBar(){
|
|
584
588
|
const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
@@ -625,6 +629,10 @@ async function init(){
|
|
|
625
629
|
$('#sbxHint').textContent=j.sandboxSupported?'':'当前环境未检测到 bubblewrap,readonly/safe 将自动降级为 off';
|
|
626
630
|
$('#routeChk').checked=Boolean(j.routing);
|
|
627
631
|
$('#budgetInput').value=j.contextBudget||128000;
|
|
632
|
+
const to=j.timeout||{};
|
|
633
|
+
$('#toFirstToken').value=to.firstTokenMs?Math.round(to.firstTokenMs/1000):'';
|
|
634
|
+
$('#toStreamIdle').value=to.streamIdleMs?Math.round(to.streamIdleMs/1000):'';
|
|
635
|
+
$('#toTotal').value=to.totalMs?Math.round(to.totalMs/1000):'';
|
|
628
636
|
$('#autoStartChk').checked=Boolean(j.autostart);
|
|
629
637
|
$('#notifyChk').checked=j.notify!==false;
|
|
630
638
|
applyReasoningUI(j.reasoning);
|
|
@@ -699,7 +707,7 @@ $('#wsSel').addEventListener('change', async e=>{
|
|
|
699
707
|
refreshWsSel(); reloadModels();
|
|
700
708
|
}
|
|
701
709
|
});
|
|
702
|
-
$('#cfgBtn').addEventListener('click', ()=>{ refreshModelsCfg(); refreshSyncUI(); refreshSyncShares(); refreshSyncConflicts(); refreshSchList(); refreshWorkspaces(); loadMemoryUI();
|
|
710
|
+
$('#cfgBtn').addEventListener('click', ()=>{ refreshModelsCfg(); refreshSyncUI(); refreshSyncShares(); refreshSyncConflicts(); refreshSchList(); refreshWorkspaces(); loadMemoryUI(); refreshMcpPresets(); refreshSkillLib(''); });
|
|
703
711
|
$('#schWhen').onchange=e=>{ const v=e.target.value; $('#schAtRow').style.display=v==='at'?'':'none'; $('#schEveryRow').style.display=v==='every'?'':'none'; $('#schAfterRow').style.display=v==='after'?'':'none'; $('#schChainRow').style.display=v==='chain'?'':'none'; };
|
|
704
712
|
async function refreshSchList(){
|
|
705
713
|
const r=await fetch('/api/schedule',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
@@ -767,70 +775,61 @@ $('#memDedupe').onclick=async ()=>{
|
|
|
767
775
|
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
768
776
|
if(j.ok){ memFlash('✓ 去重完成,移除 '+j.removed+' 行', true); loadMemoryUI(); } else memFlash('✖ '+(j.error||'去重失败'), false);
|
|
769
777
|
};
|
|
770
|
-
// ——
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
const bar=document.createElement('div'); bar.style.cssText='height:3px;border-radius:2px;background:var(--bg3);margin:1px 0;overflow:hidden';
|
|
786
|
-
const max=bd.byTool&&bd.byTool[0]?bd.byTool[0].ms:1;
|
|
787
|
-
bar.innerHTML='<div style="height:100%;width:'+Math.max(2,Math.round((t.ms||0)/Math.max(max,1e-9)*100))+'%;background:var(--cyan)"></div>';
|
|
788
|
-
const row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;font-size:11.5px;padding:1px 0';
|
|
789
|
-
row.innerHTML='<span>'+esc(t.tool||'—')+'</span><span>'+t.calls+' 次 · '+fmtDur(t.ms)+'</span>';
|
|
790
|
-
const w=document.createElement('div'); w.appendChild(row); w.appendChild(bar); tools.appendChild(w);
|
|
791
|
-
}
|
|
792
|
-
if(!(bd.byTool||[]).length) tools.innerHTML='<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
|
|
793
|
-
const line=$('#costLine');
|
|
794
|
-
const days=(bd.byDay||[]).slice(-14);
|
|
795
|
-
if(!days.length){ line.innerHTML=''; return; }
|
|
796
|
-
const W=320, H=56, P=4;
|
|
797
|
-
const maxC=Math.max(...days.map(d=>d.cost), 1e-9);
|
|
798
|
-
const pts=days.map((d,i)=>{
|
|
778
|
+
// —— 省钱仪表盘(v0.3.1:从设置移出,顶部费用徽标点击展开,真·仪表盘) ——
|
|
779
|
+
function renderRateGauge(rate){
|
|
780
|
+
const r=48, cx=60, cy=60, circ=2*Math.PI*r;
|
|
781
|
+
const p=Math.max(0,Math.min(1,Number(rate)||0));
|
|
782
|
+
const color = p>=0.8 ? 'var(--accent)' : p>=0.5 ? 'var(--accent2)' : 'var(--warn)';
|
|
783
|
+
$('#rateGauge').innerHTML =
|
|
784
|
+
'<circle cx="'+cx+'" cy="'+cy+'" r="'+r+'" fill="none" stroke="var(--bg3)" stroke-width="13"/>'+
|
|
785
|
+
'<circle cx="'+cx+'" cy="'+cy+'" r="'+r+'" fill="none" stroke="'+color+'" stroke-width="13" stroke-linecap="round" stroke-dasharray="'+(circ*p).toFixed(1)+' '+(circ*(1-p)).toFixed(1)+'" transform="rotate(-90 '+cx+' '+cy+')"/>';
|
|
786
|
+
$('#rateGaugeNum').textContent=(p*100).toFixed(1)+'%';
|
|
787
|
+
}
|
|
788
|
+
function trendChart(days){
|
|
789
|
+
if(!days||!days.length) return '<div style="color:var(--faint);font-size:11.5px;padding:6px">暂无费用记录</div>';
|
|
790
|
+
const W=360, H=88, P=8;
|
|
791
|
+
const max=Math.max(...days.map((/** @type {any} */ d)=>Number(d.cost)||0), 1e-9);
|
|
792
|
+
const pts=days.map((/** @type {any} */ d, /** @type {number} */ i)=>{
|
|
799
793
|
const x=P+i*(W-2*P)/Math.max(days.length-1,1);
|
|
800
|
-
const y=H-
|
|
801
|
-
return [x
|
|
794
|
+
const y=H-P-(Number(d.cost)||0)/max*(H-2*P);
|
|
795
|
+
return [x,y];
|
|
802
796
|
});
|
|
803
|
-
const
|
|
797
|
+
const line=pts.map((/** @type {any} */ p)=>p[0].toFixed(1)+','+p[1].toFixed(1)).join(' ');
|
|
798
|
+
const area='M'+pts[0][0].toFixed(1)+','+(H-P)+' L'+pts.map((/** @type {any} */ p)=>p[0].toFixed(1)+','+p[1].toFixed(1)).join(' L')+' L'+pts[pts.length-1][0].toFixed(1)+','+(H-P)+' Z';
|
|
804
799
|
const last=pts[pts.length-1];
|
|
805
|
-
|
|
806
|
-
+'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto
|
|
807
|
-
+'<
|
|
808
|
-
+'<
|
|
809
|
-
+'<
|
|
810
|
-
+'<
|
|
800
|
+
return '<div style="font-size:10.5px;color:var(--faint);margin-bottom:4px">最高 ¥'+max.toFixed(4)+' · '+days[0].day+' → '+days[days.length-1].day+'</div>'
|
|
801
|
+
+'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto">'
|
|
802
|
+
+'<defs><linearGradient id="dg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" style="stop-color:var(--accent2);stop-opacity:.35"/><stop offset="1" style="stop-color:var(--accent2);stop-opacity:0"/></linearGradient></defs>'
|
|
803
|
+
+'<path d="'+area+'" fill="url(#dg)"/>'
|
|
804
|
+
+'<polyline points="'+line+'" fill="none" stroke="var(--accent2)" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>'
|
|
805
|
+
+'<circle cx="'+last[0].toFixed(1)+'" cy="'+last[1].toFixed(1)+'" r="3" fill="var(--accent)"/>'
|
|
811
806
|
+'</svg>';
|
|
812
807
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
808
|
+
function barList(items, isCost){
|
|
809
|
+
if(!items||!items.length) return '<div style="color:var(--faint);font-size:11.5px;padding:4px">暂无记录</div>';
|
|
810
|
+
const max=Math.max(...items.map((/** @type {any} */ i)=>Number(i.val)||0), 1e-9);
|
|
811
|
+
return items.map((/** @type {any} */ i)=>{
|
|
812
|
+
const pct=Math.max(3,Math.round((Number(i.val)||0)/max*100));
|
|
813
|
+
const v=isCost ? '¥'+(Number(i.val)||0).toFixed(4) : fmtDur(i.val);
|
|
814
|
+
return '<div class="dbar"><div class="t"><span class="n" title="'+esc(i.name)+'">'+esc(String(i.name||'—').slice(0,26))+'</span><span class="v">'+v+(i.sub?' · '+i.sub:'')+'</span></div><div class="track"><div class="fill" style="width:'+pct+'%"></div></div></div>';
|
|
815
|
+
}).join('');
|
|
816
|
+
}
|
|
817
|
+
function renderDashboard(j){
|
|
818
|
+
const s=j.summary||{}; const bd=j.breakdown||{};
|
|
819
|
+
const rate=bd.rate!=null?bd.rate:(s.rate!=null?s.rate:0);
|
|
820
|
+
const saved=Number(s.saved||0);
|
|
821
|
+
$('#kpiToday').textContent='¥'+Number(bd.today||0).toFixed(4);
|
|
822
|
+
$('#kpiRate').textContent=(rate*100).toFixed(1)+'%';
|
|
823
|
+
$('#kpiSaved').textContent='¥'+saved.toFixed(4);
|
|
824
|
+
$('#kpiTurns').textContent=(s.turns||0)+' / '+(s.steps||0);
|
|
825
|
+
renderRateGauge(rate);
|
|
826
|
+
$('#trendChart').innerHTML=trendChart((bd.byDay||[]).slice(-14));
|
|
827
|
+
$('#dashModels').innerHTML=barList((bd.byModel||[]).slice(0,5).map((/** @type {any} */ m)=>({name:m.model, val:m.cost, sub:m.turns+' 轮'})), true);
|
|
828
|
+
$('#dashTools').innerHTML=barList((bd.byTool||[]).slice(0,5).map((/** @type {any} */ t)=>({name:t.tool, val:t.ms, sub:t.calls+' 次'})), false);
|
|
829
|
+
$('#dashRecent').innerHTML=(j.recent||[]).slice(0,6).map((/** @type {any} */ e)=>{
|
|
823
830
|
const hit=e.hit!=null&&e.miss!=null&&(e.hit+e.miss)>0?e.hit/(e.hit+e.miss):null;
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
bar.innerHTML='<div style="height:100%;width:'+(hit==null?0:Math.round(hit*100))+'%;background:var(--accent)"></div>';
|
|
827
|
-
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--border);font-size:11.5px;color:var(--dim)';
|
|
828
|
-
const left=document.createElement('div'); left.style.cssText='flex:1';
|
|
829
|
-
left.innerHTML='<div style="display:flex;justify-content:space-between"><span>'+esc(e.model||'')+'</span><span>'+pct+' · ¥'+Number(e.cost||0).toFixed(5)+'</span></div>';
|
|
830
|
-
left.appendChild(bar);
|
|
831
|
-
const when=document.createElement('span'); when.style.cssText='white-space:nowrap;color:var(--faint)'; when.textContent=new Date(e.at).toLocaleTimeString();
|
|
832
|
-
div.appendChild(left); div.appendChild(when); list.appendChild(div);
|
|
833
|
-
}
|
|
831
|
+
return '<div class="dr"><span class="m">'+esc(e.model||'')+'</span><span class="p">'+(hit==null?'—':(hit*100).toFixed(0)+'%')+'</span><span class="c">¥'+Number(e.cost||0).toFixed(4)+'</span></div>';
|
|
832
|
+
}).join('') || '<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
|
|
834
833
|
}
|
|
835
834
|
// —— MCP 生态预设 ——
|
|
836
835
|
let mcpPresetData=[];
|
|
@@ -933,9 +932,9 @@ $('#cmAdd').onclick=async ()=>{
|
|
|
933
932
|
const name=$('#cmName').value.trim();
|
|
934
933
|
const url=$('#cmUrl').value.trim();
|
|
935
934
|
if(!name||!url){ uiAlert('模型名与 API 地址必填'); return; }
|
|
936
|
-
const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'addCustom',name,label:$('#cmLabel').value.trim(),baseUrl:url,key:$('#cmKey').value})});
|
|
935
|
+
const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'addCustom',name,label:$('#cmLabel').value.trim(),baseUrl:url,key:$('#cmKey').value,contextWindow:Number($('#cmCtx').value)||undefined,maxOutputTokens:Number($('#cmMaxOut').value)||undefined})});
|
|
937
936
|
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
938
|
-
if(j.ok){ $('#cmName').value=''; $('#cmLabel').value=''; $('#cmUrl').value=''; $('#cmKey').value=''; refreshModelsCfg(); reloadModels(); } else uiAlert(j.error||'添加失败');
|
|
937
|
+
if(j.ok){ $('#cmName').value=''; $('#cmLabel').value=''; $('#cmUrl').value=''; $('#cmKey').value=''; $('#cmCtx').value=''; $('#cmMaxOut').value=''; refreshModelsCfg(); reloadModels(); } else uiAlert(j.error||'添加失败');
|
|
939
938
|
};
|
|
940
939
|
$('#baseUrlSave').onclick=async ()=>{
|
|
941
940
|
const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setBaseUrl',baseUrl:$('#baseUrlOverride').value.trim()})});
|
|
@@ -1026,7 +1025,7 @@ async function refreshSyncConflicts(){
|
|
|
1026
1025
|
list.appendChild(div);
|
|
1027
1026
|
}
|
|
1028
1027
|
}
|
|
1029
|
-
$('#cfgSave').onclick=()=>{
|
|
1028
|
+
$('#cfgSave').onclick=()=>{ const to={}; const ft=Number($('#toFirstToken').value), si=Number($('#toStreamIdle').value), tt=Number($('#toTotal').value); if(ft>0) to.firstTokenMs=Math.round(ft*1000); if(si>0) to.streamIdleMs=Math.round(si*1000); if(tt>0) to.totalMs=Math.round(tt*1000); const payload={sandbox:$('#sbxSel').value, routing:$('#routeChk').checked, contextBudget:Number($('#budgetInput').value), autostart:$('#autoStartChk').checked, notify:$('#notifyChk').checked}; if(Object.keys(to).length) payload.timeout=to; applyConfig(payload); $('#cfgModal').style.display='none'; };
|
|
1030
1029
|
async function applyConfig(payload, revertTarget){
|
|
1031
1030
|
const r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
1032
1031
|
const j=await r.json().catch(()=>({error:'请求失败'}));
|