aims-workstation 0.1.18 → 0.1.20
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/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"@earendil-works/pi-ai": "^1.2.0"
|
|
14
14
|
},
|
|
15
15
|
"packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1",
|
|
16
|
-
"version": "0.1.
|
|
16
|
+
"version": "0.1.20",
|
|
17
17
|
"description": "AIMS 工作站:本地智能体工作站——ARM 助手本地化(下载/离线/升级)、工作台对话式定制、多工作区隔离、分级自主与审计",
|
|
18
18
|
"bin": {
|
|
19
19
|
"aims-workstation": "bin/aims-workstation.js"
|
package/server.js
CHANGED
|
@@ -156,9 +156,41 @@ const LLM_DEFAULT_BASE = 'http://bifrost.infra.dev.aimstek.cn/v1';
|
|
|
156
156
|
const llmBaseUrl = () => cfg.llm_base_url || env.LLM_BASE_URL || LLM_DEFAULT_BASE;
|
|
157
157
|
const llmApiKey = () => cfg.llm_api_key || env.LLM_API_KEY || 'def';
|
|
158
158
|
const llmModelName = () => cfg.llm_model || env.LLM_MODEL || 'def';
|
|
159
|
-
// 上下文窗口(token):设置页可配(跟随所选模型的真实窗口),驱动会话历史裁剪预算;
|
|
159
|
+
// 上下文窗口(token):设置页可配(跟随所选模型的真实窗口),驱动会话历史裁剪预算;
|
|
160
|
+
// 留空/0 = 按模型名自动识别(未识别回落默认 128K)。宁可低估多裁,不可高估撑爆请求。
|
|
160
161
|
const LLM_DEFAULT_CONTEXT_TOKENS = 128_000;
|
|
161
|
-
|
|
162
|
+
// 常见模型族的真实窗口(按序首条命中生效,大小写不敏感;数值随各模型官方文档更新,2026-10)
|
|
163
|
+
const LLM_MODEL_CONTEXT_RULES = [
|
|
164
|
+
{ re: /glm[-_. ]?5/i, tokens: 1_000_000 }, // GLM-5.x:1M
|
|
165
|
+
{ re: /glm[-_. ]?4[-_.]?6/i, tokens: 200_000 }, // GLM-4.6:200K
|
|
166
|
+
{ re: /glm/i, tokens: 128_000 }, // GLM-4.5 及更早:128K
|
|
167
|
+
{ re: /minimax[-_. ]?m[3-9]/i, tokens: 1_000_000 }, // MiniMax-M3 及以后:1M
|
|
168
|
+
{ re: /minimax/i, tokens: 204_800 }, // MiniMax-M2/M1:204K
|
|
169
|
+
{ re: /gpt[-_. ]?4[-_.]?1/i, tokens: 1_000_000 }, // GPT-4.1:1M
|
|
170
|
+
{ re: /claude/i, tokens: 200_000 }, // Claude:200K
|
|
171
|
+
{ re: /deepseek/i, tokens: 128_000 }, // DeepSeek:128K
|
|
172
|
+
{ re: /qwen/i, tokens: 131_072 }, // Qwen:128K
|
|
173
|
+
{ re: /gemini/i, tokens: 1_000_000 }, // Gemini:1M+
|
|
174
|
+
{ re: /gpt/i, tokens: 128_000 }, // GPT-4o 等:128K
|
|
175
|
+
];
|
|
176
|
+
const llmContextInfo = () => {
|
|
177
|
+
const n = +cfg.llm_context_tokens;
|
|
178
|
+
if (Number.isFinite(n) && n > 0) return { source: 'runtime', tokens: n };
|
|
179
|
+
const hit = LLM_MODEL_CONTEXT_RULES.find((r) => r.re.test(llmModelName()));
|
|
180
|
+
return hit ? { source: 'model', tokens: hit.tokens } : { source: 'default', tokens: LLM_DEFAULT_CONTEXT_TOKENS };
|
|
181
|
+
};
|
|
182
|
+
const llmContextTokens = () => llmContextInfo().tokens;
|
|
183
|
+
// 上下文超限的识别与自愈:小白可以不懂数字,但报错必须看得懂、知道去哪调
|
|
184
|
+
let ctxBudgetScale = 1; // 裁剪预算收缩系数:超限自愈时减半(下限 12.5%);改动模型/窗口配置后复位
|
|
185
|
+
const ctxSourceLabel = (src) => ({ runtime: '手动配置', model: '按模型自动识别', default: '默认' }[src] || src);
|
|
186
|
+
// 各家超限文案不同(OpenAI/Anthropic/GLM/MiniMax/中文网关),宽匹配关键词组;认证/限流/超时类不误伤
|
|
187
|
+
const isContextOverflow = (t) => /context.{0,24}(length|window|limit|size)|maximum context|prompt is too long|tokens?.{0,16}(exceed|too many|too long)|exceed.{0,24}context|上下文.{0,10}(超|过长|上限)/i.test(String(t || ''));
|
|
188
|
+
function contextOverflowMsg(raw) {
|
|
189
|
+
const info = llmContextInfo();
|
|
190
|
+
const scaled = ctxBudgetScale < 1 ? `,已自动收缩至 ${Math.round(ctxBudgetScale * 100)}% 仍超` : '';
|
|
191
|
+
return `上下文超出模型窗口(当前生效 ${info.tokens.toLocaleString()} token,${ctxSourceLabel(info.source)}${scaled})。` +
|
|
192
|
+
`请到「系统设置 → LLM 模型服务」调低上下文窗口,或缩短本次输入/更换小附件后重试。模型侧原文:${String(raw || '').slice(0, 160)}`;
|
|
193
|
+
}
|
|
162
194
|
// 场景画像:平台级、全局一份的用户场景描述(团队环境/系统清单/规范),注入所有助手;空则不注入。
|
|
163
195
|
// 与知识检索互补(常驻事实 vs 按需检索);凭据不进画像(放"凭据在哪、找谁要")。
|
|
164
196
|
const sceneProfile = () => String(cfg.scene_profile || '').trim();
|
|
@@ -232,7 +264,10 @@ async function llmProbe() { // 设置页「保存并测试」:一次极小补
|
|
|
232
264
|
async function llmComplete(systemPrompt, userText) { // 无工具的一次性调用(起草/摘要)
|
|
233
265
|
const r = await models.complete(model, { systemPrompt, messages: [{ role: 'user', content: userText, timestamp: Date.now() }], tools: [] });
|
|
234
266
|
// pi-ai 把认证/请求失败表现为 stopReason=error 而非异常(同 llmProbe 判定)——不抛会变成空串静默流向调用方
|
|
235
|
-
if (r.stopReason === 'error')
|
|
267
|
+
if (r.stopReason === 'error') {
|
|
268
|
+
const raw = String(r.errorMessage || r.rawStopReason || 'stop=error');
|
|
269
|
+
throw new Error(isContextOverflow(raw) ? contextOverflowMsg(raw) : `LLM 请求失败: ${raw}`);
|
|
270
|
+
}
|
|
236
271
|
return (r.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
|
|
237
272
|
}
|
|
238
273
|
|
|
@@ -706,6 +741,112 @@ function pruneOldImages(ctx, keep = 3) {
|
|
|
706
741
|
}
|
|
707
742
|
}
|
|
708
743
|
|
|
744
|
+
// ── 平台级定时任务:到点向在岗助手的会话注入消息并跑一轮(与真人发消息同构——同一守护栏/审计/历史回放)──
|
|
745
|
+
// cron 子集:5 段(分 时 日 月 周),支持 * / */n / n / a-b / a-b/n 与逗号组合;周 0/7 均为周日;本地时区
|
|
746
|
+
function parseCronField(f, min, max) {
|
|
747
|
+
const out = new Set();
|
|
748
|
+
for (const part of String(f).split(',')) {
|
|
749
|
+
const m = part.match(/^(\*|\d+-\d+|\d+)(?:\/(\d+))?$/);
|
|
750
|
+
if (!m) throw new Error(`cron 字段无法解析「${part}」`);
|
|
751
|
+
const step = m[2] ? +m[2] : 1;
|
|
752
|
+
if (!Number.isInteger(step) || step < 1) throw new Error(`cron 步长不合法「${part}」`);
|
|
753
|
+
let from, to;
|
|
754
|
+
if (m[1] === '*') { from = min; to = max; }
|
|
755
|
+
else if (m[1].includes('-')) { const [a, b] = m[1].split('-'); from = +a; to = +b; }
|
|
756
|
+
else { from = to = +m[1]; }
|
|
757
|
+
if (from < min || to > max || from > to) throw new Error(`cron 字段越界「${part}」(${min}-${max})`);
|
|
758
|
+
for (let v = from; v <= to; v += step) out.add(v);
|
|
759
|
+
}
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
function parseCron(expr) {
|
|
763
|
+
const f = String(expr || '').trim().split(/\s+/);
|
|
764
|
+
if (f.length !== 5) throw new Error('cron 须为 5 段:分 时 日 月 周(如 "0 12 * * *" 表示每天 12:00)');
|
|
765
|
+
return { minute: parseCronField(f[0], 0, 59), hour: parseCronField(f[1], 0, 23), dom: parseCronField(f[2], 1, 31), month: parseCronField(f[3], 1, 12), dow: parseCronField(f[4], 0, 7), domStar: f[2] === '*', dowStar: f[4] === '*' };
|
|
766
|
+
}
|
|
767
|
+
function cronMatches(p, d) {
|
|
768
|
+
if (!p.month.has(d.getMonth() + 1)) return false;
|
|
769
|
+
const domR = p.dom.has(d.getDate());
|
|
770
|
+
const dowR = p.dow.has(d.getDay()) || (d.getDay() === 0 && p.dow.has(7)); // 0/7 都是周日
|
|
771
|
+
if (!p.domStar && !p.dowStar) { if (!(domR || dowR)) return false; } // 标准语义:日与周同时受限时任一命中即可
|
|
772
|
+
else if (p.domStar) { if (!dowR) return false; }
|
|
773
|
+
else if (!domR) return false;
|
|
774
|
+
return p.hour.has(d.getHours()) && p.minute.has(d.getMinutes());
|
|
775
|
+
}
|
|
776
|
+
function nextCronRun(expr, from = new Date()) {
|
|
777
|
+
const p = parseCron(expr);
|
|
778
|
+
const d = new Date(from); d.setSeconds(0, 0); d.setMinutes(d.getMinutes() + 1); // 从下一整分开始找
|
|
779
|
+
for (let i = 0; i < 366 * 24 * 60; i++) { if (cronMatches(p, d)) return new Date(d); d.setMinutes(d.getMinutes() + 1); }
|
|
780
|
+
return null; // 一年内无命中(如 2 月 30 日)
|
|
781
|
+
}
|
|
782
|
+
const SCHEDULES_FILE = path.join(AIMS_HOME, 'config', 'schedules.json');
|
|
783
|
+
function loadSchedules() { try { return JSON.parse(fs.readFileSync(SCHEDULES_FILE, 'utf8')); } catch { return { schedules: [] }; } }
|
|
784
|
+
function saveSchedules(db) { fs.mkdirSync(path.dirname(SCHEDULES_FILE), { recursive: true }); fs.writeFileSync(SCHEDULES_FILE, JSON.stringify(db, null, 2)); }
|
|
785
|
+
function normalizeSchedule(sc) {
|
|
786
|
+
parseCron(sc.cron); // 校验
|
|
787
|
+
return {
|
|
788
|
+
id: sc.id, name: String(sc.name || '').trim().slice(0, 60) || '未命名任务', agent_id: sc.agent_id, ws_id: sc.ws_id || 'default',
|
|
789
|
+
cron: String(sc.cron).trim(), message: String(sc.message || '').trim(), autonomy_level: Number.isInteger(sc.autonomy_level) && sc.autonomy_level >= 0 && sc.autonomy_level <= 3 ? sc.autonomy_level : null,
|
|
790
|
+
enabled: sc.enabled !== false, created_at: sc.created_at || new Date().toISOString(),
|
|
791
|
+
next_run: sc.next_run || null, last: sc.last || null, history: Array.isArray(sc.history) ? sc.history.slice(0, 10) : [],
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
// 执行一次(定时触发与「立即执行」同一入口):注入消息 → agentLoop(emit 置空);会话忙/待确认跳过;结果入 last/history + 审计
|
|
795
|
+
async function runSchedule(sc, trigger = 'cron') {
|
|
796
|
+
const sessionId = `chat-${sc.agent_id}-${sc.ws_id || 'default'}`;
|
|
797
|
+
const rec = { at: new Date().toISOString(), trigger, status: 'ok', summary: '' };
|
|
798
|
+
try {
|
|
799
|
+
const deployment = loadDeployment(sc.agent_id);
|
|
800
|
+
if (!deployment) throw new Error('助手已不在岗(deployment 缺失)');
|
|
801
|
+
const { s } = await ensureSession(sessionId, sc.agent_id, sc.autonomy_level ?? undefined, sc.ws_id || 'default');
|
|
802
|
+
if (s.running) { rec.status = 'skipped_busy'; rec.summary = `会话正在处理(${s.statusText || '进行中'}),本次跳过`; }
|
|
803
|
+
else if (s.pending) { rec.status = 'skipped_pending'; rec.summary = '会话有写操作等待人工确认,本次跳过'; }
|
|
804
|
+
else {
|
|
805
|
+
s.context.messages.push({ role: 'user', content: `【定时任务 · ${sc.name}】\n${sc.message}`, timestamp: Date.now() });
|
|
806
|
+
trimContext(s.context);
|
|
807
|
+
s.running = true; s.sinceTs = Date.now(); s.statusText = `定时任务:${sc.name}`;
|
|
808
|
+
try {
|
|
809
|
+
const out = await agentLoop(s, deployment, () => { }, null);
|
|
810
|
+
rec.summary = String(out.reply || '').slice(0, 300) || '(无回复)';
|
|
811
|
+
if (out.pending) rec.summary += `(含待确认操作:${out.pending.tool})`;
|
|
812
|
+
} finally { s.running = false; s.statusText = ''; s.abortCtl = null; }
|
|
813
|
+
}
|
|
814
|
+
} catch (e) { rec.status = 'error'; rec.summary = String(e.message).slice(0, 300); }
|
|
815
|
+
const db = loadSchedules(); const cur = db.schedules.find((x) => x.id === sc.id);
|
|
816
|
+
if (cur) { cur.last = rec; cur.history = [rec, ...(cur.history || [])].slice(0, 10); saveSchedules(db); }
|
|
817
|
+
audit({ actor: 'scheduler', action: 'run', schedule: sc.id, name: sc.name, agentId: sc.agent_id, trigger, status: rec.status, summary: rec.summary.slice(0, 120) });
|
|
818
|
+
return rec;
|
|
819
|
+
}
|
|
820
|
+
// 调度器:30s tick + 防重入;到期先从当前时刻重算下一次(错过不补跑),再执行;启动时统一重算 next_run
|
|
821
|
+
let schedulerTimer = null, tickBusy = false;
|
|
822
|
+
function startScheduler() {
|
|
823
|
+
if (schedulerTimer) return;
|
|
824
|
+
{ // 启动归一:不补跑停机期间错过的触发
|
|
825
|
+
const db = loadSchedules(); let dirty = false;
|
|
826
|
+
for (const sc of db.schedules) { const n = sc.enabled ? nextCronRun(sc.cron) : null; if (sc.next_run !== (n ? n.toISOString() : null)) { sc.next_run = n ? n.toISOString() : null; dirty = true; } }
|
|
827
|
+
if (dirty) saveSchedules(db);
|
|
828
|
+
}
|
|
829
|
+
schedulerTimer = setInterval(async () => {
|
|
830
|
+
if (tickBusy) return; tickBusy = true;
|
|
831
|
+
try {
|
|
832
|
+
const db = loadSchedules(); const now = Date.now(); const due = []; let dirty = false;
|
|
833
|
+
for (const sc of db.schedules) {
|
|
834
|
+
if (!sc.enabled) { if (sc.next_run) { sc.next_run = null; dirty = true; } continue; }
|
|
835
|
+
if (!sc.next_run) { const n = nextCronRun(sc.cron); if (n) { sc.next_run = n.toISOString(); dirty = true; } continue; }
|
|
836
|
+
if (now >= Date.parse(sc.next_run)) {
|
|
837
|
+
const n = nextCronRun(sc.cron, new Date(now)); sc.next_run = n ? n.toISOString() : null; dirty = true; due.push(sc);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (dirty) saveSchedules(db); // 先落盘再执行,避免 runSchedule 的历史写被旧快照覆盖
|
|
841
|
+
for (const sc of due) await runSchedule(sc, 'cron');
|
|
842
|
+
} catch (e) { audit({ actor: 'scheduler', action: 'tick-error', error: String(e.message).slice(0, 120) }); }
|
|
843
|
+
finally { tickBusy = false; }
|
|
844
|
+
}, 30_000);
|
|
845
|
+
schedulerTimer.unref?.();
|
|
846
|
+
console.log(`[aims-workstation] 定时任务调度器已启动(${loadSchedules().schedules.filter((s) => s.enabled).length} 个启用)`);
|
|
847
|
+
}
|
|
848
|
+
const scheduleView = (sc) => ({ ...sc, agent_name: loadDeployment(sc.agent_id)?.name || localAgent(sc.agent_id)?.name || sc.agent_id.slice(0, 8), deployed: !!loadDeployment(sc.agent_id) });
|
|
849
|
+
|
|
709
850
|
// ── 通用计算原语与平台数据服务(pi-core 哲学:少量原语 + 组合,而非功能堆叠)──
|
|
710
851
|
const { exec: cpExec } = require('node:child_process');
|
|
711
852
|
// ── exec 用 shell 跨平台解析:/bin/zsh 在 Windows 与无 zsh 的 Linux 上不存在,硬编码会让所有命令 spawn ENOENT ──
|
|
@@ -954,7 +1095,6 @@ function openDb(file) {
|
|
|
954
1095
|
function bumpVer(db, coll) {
|
|
955
1096
|
db.prepare("INSERT INTO _meta (k, v) VALUES (?, '1') ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1").run(`ver:${coll}`);
|
|
956
1097
|
}
|
|
957
|
-
const DB = openDb('store.db'); // 兼容旧引用(迁移后即空)
|
|
958
1098
|
// 作用域语义:agent=助手私有(默认,存归属助手×工作区的独立库);shared=跨助手共享(存共享库)
|
|
959
1099
|
const scopeOf = (m) => (m.scope === 'shared' || m.scope === 'global') ? 'shared' : 'agent'; // legacy global→shared, workbench→agent
|
|
960
1100
|
const dbFor = (m, wsId = 'default') => openDb(scopeOf(m) === 'shared' ? 'store-shared.db' : `ws-${m.owner_agent || 'orphan'}-${wsId}.db`);
|
|
@@ -1039,6 +1179,7 @@ function deleteDoc(m, id, wsId = 'default') {
|
|
|
1039
1179
|
dbFor(m, 'default').prepare('INSERT OR REPLACE INTO docs (collection, id, data, created, updated) VALUES (?,?,?,?,?)')
|
|
1040
1180
|
.run(r.collection, r.id, r.data, r.created, r.updated);
|
|
1041
1181
|
}
|
|
1182
|
+
old.close(); // Windows:句柄不关,renameSync 必报 EBUSY(macOS/Linux 可重命名打开中的文件,故此前未现)
|
|
1042
1183
|
fs.renameSync(legacy, legacy + '.migrated');
|
|
1043
1184
|
audit({ actor: 'migration', action: 'per-agent-split', moved: rows.length });
|
|
1044
1185
|
console.log(`[mvp] 数据已按助手分库迁移(${rows.length} 条)`);
|
|
@@ -1577,7 +1718,7 @@ const devSessions = new Map();
|
|
|
1577
1718
|
// 会话上下文裁剪(dev/主对话共用):前端 sessionId 固定(dev-<agentId前缀> / chat-<agentId>-<wsId>),会话跨请求长存——
|
|
1578
1719
|
// 不裁剪会无限增长,最终超过模型上下文窗口被网关整单拒绝(2026-10-21 海立实例"发消息没反应"根因)。
|
|
1579
1720
|
// 预算随设置页「上下文窗口(token)」联动:预留 16K token 给系统提示/当前轮/输出,1.6 char/token 对中文最坏比例(~0.67 token/char)仍在预留内。
|
|
1580
|
-
const ctxCharBudget = () => Math.max(4_000, Math.floor((llmContextTokens() - 16_000) * 1.6));
|
|
1721
|
+
const ctxCharBudget = () => Math.max(4_000, Math.floor((llmContextTokens() * ctxBudgetScale - 16_000) * 1.6));
|
|
1581
1722
|
// 超预算从最旧轮次整轮丢弃;下刀点只选 user 消息边界,剩余前缀不会出现孤儿 toolResult(转换层要求其紧跟 assistant 的 toolCall);当前轮永不裁。
|
|
1582
1723
|
function trimContext(ctx, budget = ctxCharBudget()) {
|
|
1583
1724
|
const msgs = ctx.messages;
|
|
@@ -1796,6 +1937,7 @@ async function ensureSession(sessionId, agentId, autonomyLevel, wsId) {
|
|
|
1796
1937
|
// 中断时保留已流出的部分文本为助手消息并补记"(已被用户中断)",未应答的 toolCall 补占位 toolResult 保证下轮上下文合法
|
|
1797
1938
|
async function agentLoop(s, deployment, emit, signal = null) {
|
|
1798
1939
|
const interrupted = () => !!signal?.aborted;
|
|
1940
|
+
let ctxRetried = false; // 上下文超限自愈一轮 agentLoop 只重试一次:连续收缩无意义,再失败走友好报错
|
|
1799
1941
|
for (let round = 0; ; round++) {
|
|
1800
1942
|
if (interrupted()) return { reply: '(已被用户中断)', interrupted: true, pending: s.pending };
|
|
1801
1943
|
s.statusText = `第 ${round + 1} 轮 · 推理中`;
|
|
@@ -1815,7 +1957,18 @@ async function agentLoop(s, deployment, emit, signal = null) {
|
|
|
1815
1957
|
}
|
|
1816
1958
|
final = await stream.result(); // 中止时 pi-ai 以 done(aborted) 收尾,result() 返回已积累的部分消息
|
|
1817
1959
|
} catch (e) {
|
|
1818
|
-
if (
|
|
1960
|
+
if (interrupted()) throw e; // 中断路径由下方 aborted 分支兜底
|
|
1961
|
+
const raw = String(e.message || '');
|
|
1962
|
+
if (!ctxRetried && isContextOverflow(raw)) { // 超限自愈:收缩预算 → 重裁历史 → 本轮重试一次
|
|
1963
|
+
ctxRetried = true;
|
|
1964
|
+
ctxBudgetScale = Math.max(0.125, ctxBudgetScale / 2);
|
|
1965
|
+
const dropped = trimContext(s.context);
|
|
1966
|
+
audit({ actor: 'llm', agent: deployment.agent_id, action: 'context-shrink-retry', scale: ctxBudgetScale, dropped, raw: raw.slice(0, 120) });
|
|
1967
|
+
emit('status', { text: dropped > 0 ? `上下文超限,已自动收缩历史(丢弃最早 ${dropped} 条)重试` : '上下文超限,已自动收缩历史重试' });
|
|
1968
|
+
round--; continue;
|
|
1969
|
+
}
|
|
1970
|
+
if (isContextOverflow(raw)) throw new Error(contextOverflowMsg(raw)); // 重试仍超限:指路式报错
|
|
1971
|
+
throw e;
|
|
1819
1972
|
}
|
|
1820
1973
|
const aborted = interrupted();
|
|
1821
1974
|
if (!final) final = { role: 'assistant', content: [], timestamp: Date.now() };
|
|
@@ -1914,6 +2067,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1914
2067
|
const t = armApiToken();
|
|
1915
2068
|
const k = llmApiKey();
|
|
1916
2069
|
const cur = armCurrent();
|
|
2070
|
+
const ctxInfo = llmContextInfo();
|
|
1917
2071
|
return json(res, 200, {
|
|
1918
2072
|
arm: { // 兼容字段描述的是当前源;多源列表与管理走 /api/arm/sources
|
|
1919
2073
|
api_base: armApiBase(), api_base_runtime: !!cur.base, default_base: ARM_DEFAULT_BASE,
|
|
@@ -1926,7 +2080,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1926
2080
|
model: llmModelName(), model_runtime: !!cfg.llm_model, catalog: llmCatalog,
|
|
1927
2081
|
key_set: !!k, key_masked: k ? `${k.slice(0, 6)}…${k.slice(-4)}` : '',
|
|
1928
2082
|
key_source: cfg.llm_api_key ? 'runtime' : (env.LLM_API_KEY ? 'env' : 'none'),
|
|
1929
|
-
context_tokens:
|
|
2083
|
+
context_tokens: ctxInfo.tokens, context_tokens_source: ctxInfo.source, context_tokens_runtime: !!cfg.llm_context_tokens, default_context_tokens: LLM_DEFAULT_CONTEXT_TOKENS,
|
|
1930
2084
|
vision: llmVision(), vision_mode: cfg.llm_vision || 'auto', vision_auto: !!(model && Array.isArray(model.input) && model.input.includes('image')),
|
|
1931
2085
|
},
|
|
1932
2086
|
scene: { profile: sceneProfile(), length: sceneProfile().length },
|
|
@@ -1977,15 +2131,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
1977
2131
|
const m = String(llm_model || '').trim();
|
|
1978
2132
|
if (m && !/^[\w.:@\/-]{1,80}$/.test(m)) return json(res, 400, { error: `模型名不合法:${m}` });
|
|
1979
2133
|
cfg.llm_model = m;
|
|
2134
|
+
ctxBudgetScale = 1; // 换模型:上下文超限自愈的收缩系数复位
|
|
1980
2135
|
}
|
|
1981
|
-
if (llm_context_tokens !== undefined) { // 上下文窗口(token):驱动会话裁剪预算;留空/0 =
|
|
2136
|
+
if (llm_context_tokens !== undefined) { // 上下文窗口(token):驱动会话裁剪预算;留空/0 = 按模型自动识别(未识别回落 128K)。不影响 provider,无需热切换
|
|
1982
2137
|
const raw = String(llm_context_tokens ?? '').trim();
|
|
1983
2138
|
if (raw === '') cfg.llm_context_tokens = 0;
|
|
1984
2139
|
else {
|
|
1985
2140
|
const n = Math.floor(+raw);
|
|
1986
|
-
if (!Number.isFinite(n) || n < 4_000 || n > 2_000_000) return json(res, 400, { error: `上下文窗口须为 4000-2000000 的 token 数(留空 =
|
|
2141
|
+
if (!Number.isFinite(n) || n < 4_000 || n > 2_000_000) return json(res, 400, { error: `上下文窗口须为 4000-2000000 的 token 数(留空 = 按模型自动识别,未识别回落 ${LLM_DEFAULT_CONTEXT_TOKENS}):${raw}` });
|
|
1987
2142
|
cfg.llm_context_tokens = n;
|
|
1988
2143
|
}
|
|
2144
|
+
ctxBudgetScale = 1; // 手动改窗口:收缩系数复位
|
|
1989
2145
|
}
|
|
1990
2146
|
if (llm_api_key !== undefined) cfg.llm_api_key = String(llm_api_key || '').trim();
|
|
1991
2147
|
if (llm_vision !== undefined) { // 视觉能力:auto=按模型条目 input 判定;on/off 强制(自定义模型名能力未知时按实际选)。不影响 provider,无需热切换
|
|
@@ -2898,6 +3054,51 @@ const server = http.createServer(async (req, res) => {
|
|
|
2898
3054
|
return res.end();
|
|
2899
3055
|
}
|
|
2900
3056
|
|
|
3057
|
+
// ── 平台级定时任务:列表 / 增删改 / 立即执行 ──
|
|
3058
|
+
if (pathname === '/api/schedules' && req.method === 'GET') {
|
|
3059
|
+
return json(res, 200, { schedules: loadSchedules().schedules.map(scheduleView) });
|
|
3060
|
+
}
|
|
3061
|
+
if (pathname === '/api/schedules' && req.method === 'POST') {
|
|
3062
|
+
const { name, agent_id, ws_id, cron, message, autonomy_level } = await body(req);
|
|
3063
|
+
if (!/^[\w-]{1,80}$/.test(String(agent_id || ''))) return json(res, 400, { error: 'agent_id 必填' });
|
|
3064
|
+
if (!loadDeployment(agent_id)) return json(res, 400, { error: '该助手未上岗——定时任务只面向在岗助手(先在大厅完成上岗配置)' });
|
|
3065
|
+
if (!String(message || '').trim()) return json(res, 400, { error: '消息内容必填(到点后会作为这条消息发给助手)' });
|
|
3066
|
+
let sc;
|
|
3067
|
+
try { sc = normalizeSchedule({ id: `sch_${crypto.randomUUID().slice(0, 6)}`, name, agent_id, ws_id, cron, message, autonomy_level, enabled: true }); }
|
|
3068
|
+
catch (e) { return json(res, 400, { error: `任务定义不合法:${e.message}` }); }
|
|
3069
|
+
const n = nextCronRun(sc.cron); if (n) sc.next_run = n.toISOString();
|
|
3070
|
+
const db = loadSchedules(); db.schedules.push(sc); saveSchedules(db);
|
|
3071
|
+
audit({ actor: 'user', action: 'schedule-create', schedule: sc.id, name: sc.name, agentId: sc.agent_id, cron: sc.cron });
|
|
3072
|
+
return json(res, 200, { schedule: scheduleView(sc) });
|
|
3073
|
+
}
|
|
3074
|
+
const mSch = pathname.match(/^\/api\/schedules\/([\w-]+)(?:\/(run))?$/);
|
|
3075
|
+
if (mSch) {
|
|
3076
|
+
const db = loadSchedules();
|
|
3077
|
+
const sc = db.schedules.find((x) => x.id === mSch[1]);
|
|
3078
|
+
if (!sc) return json(res, 404, { error: `任务不存在:${mSch[1]}` });
|
|
3079
|
+
if (req.method === 'PUT' && !mSch[2]) {
|
|
3080
|
+
const { name, ws_id, cron, message, autonomy_level, enabled } = await body(req);
|
|
3081
|
+
try {
|
|
3082
|
+
const next = normalizeSchedule({ ...sc, ...(name !== undefined ? { name } : {}), ...(ws_id !== undefined ? { ws_id } : {}), ...(cron !== undefined ? { cron } : {}), ...(message !== undefined ? { message } : {}), ...(autonomy_level !== undefined ? { autonomy_level } : {}), ...(enabled !== undefined ? { enabled } : {}) });
|
|
3083
|
+
const n = next.enabled ? nextCronRun(next.cron) : null; next.next_run = n ? n.toISOString() : null;
|
|
3084
|
+
db.schedules = db.schedules.map((x) => (x.id === next.id ? next : x)); saveSchedules(db);
|
|
3085
|
+
audit({ actor: 'user', action: 'schedule-update', schedule: sc.id, fields: Object.keys({ ...(name !== undefined && { name }), ...(cron !== undefined && { cron }), ...(message !== undefined && { message }), ...(enabled !== undefined && { enabled }) }) });
|
|
3086
|
+
return json(res, 200, { schedule: scheduleView(next) });
|
|
3087
|
+
} catch (e) { return json(res, 400, { error: `任务定义不合法:${e.message}` }); }
|
|
3088
|
+
}
|
|
3089
|
+
if (req.method === 'DELETE' && !mSch[2]) {
|
|
3090
|
+
db.schedules = db.schedules.filter((x) => x.id !== sc.id); saveSchedules(db);
|
|
3091
|
+
audit({ actor: 'user', action: 'schedule-delete', schedule: sc.id, name: sc.name });
|
|
3092
|
+
return json(res, 200, { ok: true });
|
|
3093
|
+
}
|
|
3094
|
+
if (req.method === 'POST' && mSch[2] === 'run') {
|
|
3095
|
+
const rec = await runSchedule(sc, 'manual'); // 立即执行一次(不影响 next_run)
|
|
3096
|
+
const cur = loadSchedules().schedules.find((x) => x.id === sc.id);
|
|
3097
|
+
return json(res, 200, { run: rec, schedule: cur ? scheduleView(cur) : null });
|
|
3098
|
+
}
|
|
3099
|
+
return json(res, 405, { error: 'method not allowed' });
|
|
3100
|
+
}
|
|
3101
|
+
|
|
2901
3102
|
// ── 现场状态 ──
|
|
2902
3103
|
if (req.method === 'GET' && pathname === '/api/state') {
|
|
2903
3104
|
return json(res, 200, { devices: world.devices, alarms: world.alarms, kpi: world.kpi, work_orders: world.work_orders, inspections: world.inspections });
|
|
@@ -3267,11 +3468,87 @@ if (process.argv.includes('--test-arm-sources')) {
|
|
|
3267
3468
|
process.exit(pass ? 0 : 1);
|
|
3268
3469
|
}
|
|
3269
3470
|
|
|
3471
|
+
// ── 单测: node server.js --test-llm-ctx(上下文窗口解析:显式配置优先 / 按模型识别 / 回落与预算联动;不碰网络与 LLM)──
|
|
3472
|
+
if (process.argv.includes('--test-llm-ctx')) {
|
|
3473
|
+
let pass = true;
|
|
3474
|
+
const ok = (name, cond, detail = '') => { if (!cond) pass = false; console.log(`${cond ? '✅' : '❌'} ${name}${cond ? '' : ' ← ' + detail}`); };
|
|
3475
|
+
const saved = { m: cfg.llm_model, c: cfg.llm_context_tokens, e: process.env.LLM_MODEL };
|
|
3476
|
+
const setModel = (m, ctx = 0) => { cfg.llm_model = m; cfg.llm_context_tokens = ctx; process.env.LLM_MODEL = ''; };
|
|
3477
|
+
try {
|
|
3478
|
+
setModel('');
|
|
3479
|
+
ok('未识别模型(def)回落默认 128K', llmContextInfo().source === 'default' && llmContextTokens() === 128_000, JSON.stringify(llmContextInfo()));
|
|
3480
|
+
setModel('glm-5.3');
|
|
3481
|
+
ok('GLM-5.3 → 自动识别 1M', llmContextInfo().source === 'model' && llmContextTokens() === 1_000_000, JSON.stringify(llmContextInfo()));
|
|
3482
|
+
setModel('GLM-4.6-Flag');
|
|
3483
|
+
ok('GLM-4.6 → 200K(大小写不敏感)', llmContextTokens() === 200_000, String(llmContextTokens()));
|
|
3484
|
+
setModel('glm-4.5-air');
|
|
3485
|
+
ok('GLM-4.5 → 128K(泛 GLM 回落)', llmContextTokens() === 128_000, String(llmContextTokens()));
|
|
3486
|
+
setModel('MiniMax-M3');
|
|
3487
|
+
ok('MiniMax-M3 → 1M', llmContextTokens() === 1_000_000, String(llmContextTokens()));
|
|
3488
|
+
setModel('minimax-m2');
|
|
3489
|
+
ok('MiniMax-M2 → 204K(保守回落)', llmContextTokens() === 204_800, String(llmContextTokens()));
|
|
3490
|
+
setModel('claude-sonnet-4.5');
|
|
3491
|
+
ok('Claude → 200K', llmContextTokens() === 200_000, String(llmContextTokens()));
|
|
3492
|
+
setModel('deepseek-chat');
|
|
3493
|
+
ok('DeepSeek → 128K', llmContextTokens() === 128_000, String(llmContextTokens()));
|
|
3494
|
+
setModel('glm-5.3', 64_000);
|
|
3495
|
+
ok('显式配置优先于模型识别', llmContextInfo().source === 'runtime' && llmContextTokens() === 64_000, JSON.stringify(llmContextInfo()));
|
|
3496
|
+
setModel('glm-5.3', 0);
|
|
3497
|
+
ok('清空配置回落模型识别', llmContextTokens() === 1_000_000, String(llmContextTokens()));
|
|
3498
|
+
ok('预算联动:1M 窗口 → 字符预算 (1M-16K)*1.6', ctxCharBudget() === Math.floor((1_000_000 - 16_000) * 1.6), String(ctxCharBudget()));
|
|
3499
|
+
ok('超限识别:OpenAI 文案', isContextOverflow("This model's maximum context length is 128000 tokens. However, you requested 130000 tokens"));
|
|
3500
|
+
ok('超限识别:Anthropic 文案', isContextOverflow('prompt is too long: 210000 tokens > 200000 maximum'));
|
|
3501
|
+
ok('超限识别:GLM/MiniMax 风格', isContextOverflow('input length and max_tokens exceed context limit') && isContextOverflow('Tokens exceed the limit of this model'));
|
|
3502
|
+
ok('超限识别:中文文案', isContextOverflow('请求失败:上下文长度超限'));
|
|
3503
|
+
ok('不误伤:认证/限流/超时/模型不存在', !isContextOverflow('invalid api key') && !isContextOverflow('rate limit exceeded') && !isContextOverflow('request timeout after 30s') && !isContextOverflow('model not found'));
|
|
3504
|
+
const msg = contextOverflowMsg('input length and max_tokens exceed context limit');
|
|
3505
|
+
ok('友好报错:含来源/指路/原文', msg.includes('按模型自动识别') && msg.includes('调低上下文窗口') && msg.includes('系统设置') && msg.includes('token') && msg.includes('max_tokens'), msg);
|
|
3506
|
+
const full = ctxCharBudget();
|
|
3507
|
+
ctxBudgetScale = 0.5;
|
|
3508
|
+
ok('自愈收缩:预算随系数减半', ctxCharBudget() < full / 1.9 && ctxCharBudget() === Math.max(4_000, Math.floor((1_000_000 * 0.5 - 16_000) * 1.6)), `${ctxCharBudget()} / ${full}`);
|
|
3509
|
+
ok('友好报错:收缩后提示已自动收缩', contextOverflowMsg('x').includes('已自动收缩至 50%'), contextOverflowMsg('x'));
|
|
3510
|
+
ctxBudgetScale = 0.125;
|
|
3511
|
+
ok('自愈收缩:下限 12.5%', ctxCharBudget() === Math.max(4_000, Math.floor((1_000_000 * 0.125 - 16_000) * 1.6)), String(ctxCharBudget()));
|
|
3512
|
+
ctxBudgetScale = 1;
|
|
3513
|
+
} finally {
|
|
3514
|
+
cfg.llm_model = saved.m; cfg.llm_context_tokens = saved.c; process.env.LLM_MODEL = saved.e;
|
|
3515
|
+
}
|
|
3516
|
+
console.log(pass ? '上下文窗口单测全部通过' : '上下文窗口单测存在失败');
|
|
3517
|
+
process.exit(pass ? 0 : 1);
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
// ── 单测: node server.js --test-schedules(cron 子集解析/匹配/next-run/任务归一;不碰网络与 LLM)──
|
|
3521
|
+
if (process.argv.includes('--test-schedules')) {
|
|
3522
|
+
let pass = true;
|
|
3523
|
+
const ok = (name, cond, detail = '') => { if (!cond) pass = false; console.log(`${cond ? '✅' : '❌'} ${name}${cond ? '' : ' ← ' + detail}`); };
|
|
3524
|
+
const at = (y, mo, d, h, mi) => new Date(y, mo - 1, d, h, mi, 0, 0);
|
|
3525
|
+
const fmt = (d) => `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
3526
|
+
ok('每天 12:30:午前 → 当天', fmt(nextCronRun('30 12 * * *', at(2026, 10, 28, 11, 0))) === '10-28 12:30');
|
|
3527
|
+
ok('每天 12:30:午后 → 次日', fmt(nextCronRun('30 12 * * *', at(2026, 10, 28, 13, 0))) === '10-29 12:30');
|
|
3528
|
+
ok('*/15 步进:12:07 → 12:15', fmt(nextCronRun('*/15 * * * *', at(2026, 10, 28, 12, 7))) === '10-28 12:15');
|
|
3529
|
+
ok('每周一 09:00:周日 → 次日周', fmt(nextCronRun('0 9 * * 1', at(2026, 11, 1, 10, 0))) === '11-2 09:00', fmt(nextCronRun('0 9 * * 1', at(2026, 11, 1, 10, 0))));
|
|
3530
|
+
ok('周日双写法:0 与 7 等价', fmt(nextCronRun('0 9 * * 0', at(2026, 10, 30, 10, 0))) === '11-1 09:00' && fmt(nextCronRun('0 9 * * 7', at(2026, 10, 30, 10, 0))) === '11-1 09:00');
|
|
3531
|
+
ok('列表与区间组合:0,30 8-18', fmt(nextCronRun('0,30 8-18 * * *', at(2026, 10, 28, 19, 10))) === '10-29 08:00');
|
|
3532
|
+
ok('日/周同时受限按 OR:13 号或每周五', !!nextCronRun('0 9 13 * 5', at(2026, 10, 28, 10, 0)) && fmt(nextCronRun('0 9 13 * 5', at(2026, 10, 28, 10, 0))) === '10-30 09:00', fmt(nextCronRun('0 9 13 * 5', at(2026, 10, 28, 10, 0)))); // 10-28 是周三:13 号是周四、最近的周五是 10-30
|
|
3533
|
+
ok('不存在的日期(2 月 31 日)→ 无下一次', nextCronRun('0 9 31 2 *', at(2026, 1, 31, 10, 0)) === null);
|
|
3534
|
+
for (const [bad, why] of [['* * * *', '4 段'], ['60 * * * *', '分越界'], ['*/0 * * * *', '步长 0'], ['a * * * *', '坏 token'], ['1-5/2/x * * * *', '坏组合']]) {
|
|
3535
|
+
let threw = false; try { parseCron(bad); } catch { threw = true; }
|
|
3536
|
+
ok(`非法拒绝:${why}(${bad})`, threw);
|
|
3537
|
+
}
|
|
3538
|
+
const n = (() => { try { return normalizeSchedule({ id: 'sch_x', name: ' 每日巡检 ', agent_id: 'a', cron: '0 12 * * *', message: ' x ', autonomy_level: 9 }); } catch (e) { return null; } })();
|
|
3539
|
+
ok('任务归一:去空白 / autonomy 越界回落 null / 默认启用', n && n.name === '每日巡检' && n.message === 'x' && n.autonomy_level === null && n.enabled === true && n.ws_id === 'default');
|
|
3540
|
+
let threw = false; try { normalizeSchedule({ id: 'x', agent_id: 'a', cron: 'bad', message: 'm' }); } catch { threw = true; }
|
|
3541
|
+
ok('任务归一:坏 cron 抛错', threw);
|
|
3542
|
+
console.log(pass ? '定时任务单元测试全部通过' : '定时任务单元测试存在失败');
|
|
3543
|
+
process.exit(pass ? 0 : 1);
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3270
3546
|
applyLLMConfig().then(() => {
|
|
3271
3547
|
server.listen(PORT, HOST, () => {
|
|
3272
3548
|
console.log(`[aims-workstation] http://localhost:${PORT}(绑定 ${HOST}) | 模型: ${model.id} @ ${model.baseUrl}`);
|
|
3273
3549
|
console.log(`[aims-workstation] 数据目录 AIMS_HOME: ${AIMS_HOME}`);
|
|
3274
3550
|
console.log(`[aims-workstation] exec shell: ${SHELL}(${SHELL_HINT})`);
|
|
3551
|
+
startScheduler();
|
|
3275
3552
|
console.log(`[aims-workstation] ARM(REST): ${armApiBase()} | PAT: ${ARM_PAT ? '已加载' : '未配置'} | 已上岗助手: ${fs.readdirSync(DEPLOY_DIR).length} | 本地助手库: ${loadRegistry().agents.length}`);
|
|
3276
3553
|
});
|
|
3277
3554
|
// 已上岗助手后台物化为本地档案(best-effort;ARM 不可达时静默跳过,待手动同步)
|