dsh-subagent-profile 0.3.3 → 0.4.0
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/README.md +49 -20
- package/README.zh.md +102 -73
- package/docs/screenshots/dispatch-card.png +0 -0
- package/docs/screenshots/settings-page1.png +0 -0
- package/docs/screenshots/settings-page2.png +0 -0
- package/index.mjs +153 -75
- package/lib/client.js +3081 -444
- package/lib/core/adoption-reminder.mjs +48 -0
- package/lib/core/adoption-tracker.mjs +430 -0
- package/lib/core/background-ledger.mjs +71 -0
- package/lib/core/cost-evidence.mjs +145 -0
- package/lib/core/cost-guard.mjs +1 -1
- package/lib/core/decision-trace.mjs +11 -31
- package/lib/core/delegation.mjs +11 -7
- package/lib/core/dispatch-gates.mjs +12 -5
- package/lib/core/dispatch-guard.mjs +6 -0
- package/lib/core/dispatch-schema.mjs +10 -5
- package/lib/core/dispatch-tool.mjs +52 -45
- package/lib/core/draft-gates.mjs +45 -0
- package/lib/core/drafts-store.mjs +45 -0
- package/lib/core/evolution-advice.mjs +323 -0
- package/lib/core/evolution-assets.mjs +167 -0
- package/lib/core/evolution-draft.mjs +312 -0
- package/lib/core/evolution-engine.mjs +269 -0
- package/lib/core/evolution-generate.mjs +196 -0
- package/lib/core/evolution-ledger.mjs +149 -33
- package/lib/core/evolution-persistence.mjs +213 -0
- package/lib/core/evolution-renewal.mjs +64 -0
- package/lib/core/evolution-routes.mjs +109 -0
- package/lib/core/evolution-summary.mjs +46 -223
- package/lib/core/http-helpers.mjs +35 -0
- package/lib/core/http-routes.mjs +169 -115
- package/lib/core/presets-sync.mjs +254 -256
- package/lib/core/prices.mjs +46 -0
- package/lib/core/profile-directory.mjs +139 -0
- package/lib/core/profile-provider.mjs +5 -3
- package/lib/core/profiles-store.mjs +24 -6
- package/lib/core/reminder-store.mjs +172 -0
- package/lib/core/session-read.mjs +17 -0
- package/lib/core/shims.mjs +13 -2
- package/package.json +82 -82
- package/presets/orchestrator/agent.cordis.yml +243 -243
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// lib/core/evolution-persistence.mjs — 进化资产持久化机制:版本前向迁移与派发台账轮转。
|
|
2
|
+
// 迁移:可重写资产顶层 v 版本号,load 时仅在内存链式前向迁移(v→v+1),迁移完成后
|
|
3
|
+
// 原子写回(tmp+rename);未知/超前版本或缺少迁移步骤一律 fail-soft 跳过并计丢失,
|
|
4
|
+
// 绝不阻断其余功能、绝不改写台账号行。
|
|
5
|
+
// 轮转:dispatch.jsonl 达阈值(记录数或字节数)时封存为 dispatch-<seq>-<ts>.jsonl,
|
|
6
|
+
// 新建空台账续写;封存只 rename + 新建(内容字节不变、封存件不可变);封存件超过
|
|
7
|
+
// 保留份数时按最早封存淘汰,并一次性提示回放窗口缩短。
|
|
8
|
+
// import-free(仅 node 内置),可被 bare-CI 单测直接导入。
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
// --- 版本迁移 ---------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
// 各可重写资产的当前版本号。新增资产类型在此登记。
|
|
24
|
+
export const CURRENT_VERSIONS = Object.freeze({ summaries: 1 });
|
|
25
|
+
|
|
26
|
+
// 前向迁移表:<kind, fromV> → 迁移函数(入参 v=n 的数据,须返回 v=n+1 的数据)。
|
|
27
|
+
// 当前各资产无跨版本 schema 变化,表为空;机制(链式前向迁移 / 未知版本
|
|
28
|
+
// fail-soft / 迁移后写回)由测试注入样例迁移覆盖。出现真实 schema 变化时在此
|
|
29
|
+
// 登记步骤并同步提升 CURRENT_VERSIONS 对应项。
|
|
30
|
+
export const MIGRATIONS = Object.freeze({});
|
|
31
|
+
|
|
32
|
+
// 单次加载允许的最多迁移步数(防迁移表被误配成死循环)。
|
|
33
|
+
const MAX_MIGRATION_STEPS = 100;
|
|
34
|
+
|
|
35
|
+
// 迁移错误:版本非法/超前/缺步骤等,由调用方按 fail-soft 处理。
|
|
36
|
+
export class MigrationError extends Error {}
|
|
37
|
+
|
|
38
|
+
// 内存链式前向迁移:v 小于当前版本时逐级执行迁移表步骤,返回 {data, migrated,
|
|
39
|
+
// fromV, toV};v 等于当前版本原样返回;v 非法/超前或缺步骤抛 MigrationError。
|
|
40
|
+
export function migrateUp(data, kind, { migrations = MIGRATIONS, currentVersions = CURRENT_VERSIONS } = {}) {
|
|
41
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
42
|
+
throw new MigrationError('数据不是普通对象');
|
|
43
|
+
}
|
|
44
|
+
const current = currentVersions[kind];
|
|
45
|
+
if (typeof current !== 'number' || !Number.isInteger(current)) {
|
|
46
|
+
throw new MigrationError(`未知资产类型:${String(kind)}`);
|
|
47
|
+
}
|
|
48
|
+
const fromV = data.v;
|
|
49
|
+
if (!Number.isInteger(fromV) || fromV < 1) {
|
|
50
|
+
throw new MigrationError(`版本号非法:${String(fromV)}`);
|
|
51
|
+
}
|
|
52
|
+
if (fromV > current) throw new MigrationError(`版本 ${fromV} 超前于当前版本 ${current}`);
|
|
53
|
+
if (fromV === current) return { data, migrated: false, fromV, toV: fromV };
|
|
54
|
+
const steps = (migrations ?? {})[kind] ?? {};
|
|
55
|
+
let cursor = data;
|
|
56
|
+
let v = fromV;
|
|
57
|
+
while (v < current) {
|
|
58
|
+
const step = steps[v];
|
|
59
|
+
if (typeof step !== 'function') {
|
|
60
|
+
throw new MigrationError(`缺少 ${String(kind)} v${v} 到 v${v + 1} 的迁移步骤`);
|
|
61
|
+
}
|
|
62
|
+
cursor = step(cursor);
|
|
63
|
+
if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor)) {
|
|
64
|
+
throw new MigrationError(`迁移步骤 v${v} 产出非法数据`);
|
|
65
|
+
}
|
|
66
|
+
v += 1;
|
|
67
|
+
if (v - fromV > MAX_MIGRATION_STEPS) throw new MigrationError('迁移步数超过上限');
|
|
68
|
+
}
|
|
69
|
+
// 归一顶层版本号:迁移步骤可省略写 v,此处强制对齐当前版本。
|
|
70
|
+
cursor.v = current;
|
|
71
|
+
return { data: cursor, migrated: true, fromV, toV: current };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 迁移写回(tmp+rename):崩溃不产生截断文件;失败仅告警(内存迁移结果仍可用),
|
|
75
|
+
// 不抛、不重复计丢失。
|
|
76
|
+
function writeBack(file, data, warnLog) {
|
|
77
|
+
const tmp = `${file}.tmp`;
|
|
78
|
+
try {
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
81
|
+
renameSync(tmp, file);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
84
|
+
warnLog(`[dsh-subagent-profile] 资产迁移写回失败(${file}):${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 读取并迁移单个资产文件。缺失返回 null(不告警不计丢失);损坏/形状不符/版本
|
|
89
|
+
// 无法迁移 → 告警 + onLoss + null(fail-soft 跳过);迁移发生则原子写回后再返回。
|
|
90
|
+
// logger 取带 warn 方法的对象(缺省静默)。
|
|
91
|
+
export function loadWithMigration(
|
|
92
|
+
file,
|
|
93
|
+
kind,
|
|
94
|
+
{ logger, migrations = MIGRATIONS, currentVersions = CURRENT_VERSIONS, onLoss } = {}
|
|
95
|
+
) {
|
|
96
|
+
const warnLog = (message) => {
|
|
97
|
+
if (logger !== undefined && logger !== null && typeof logger.warn === 'function') logger.warn(message);
|
|
98
|
+
};
|
|
99
|
+
const lose = () => { if (typeof onLoss === 'function') onLoss(); };
|
|
100
|
+
if (!existsSync(file)) return null;
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
warnLog(`[dsh-subagent-profile] ${String(kind)} 资产损坏,跳过该资产(fail-soft):${error instanceof Error ? error.message : String(error)}`);
|
|
106
|
+
lose();
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
110
|
+
warnLog(`[dsh-subagent-profile] ${String(kind)} 资产形状不符,跳过该资产(fail-soft)`);
|
|
111
|
+
lose();
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
let result;
|
|
115
|
+
try {
|
|
116
|
+
result = migrateUp(parsed, kind, { migrations, currentVersions });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const version = parsed.v !== undefined ? String(parsed.v) : '未知';
|
|
119
|
+
warnLog(`[dsh-subagent-profile] ${String(kind)} 资产版本 ${version} 无法迁移,跳过该资产(fail-soft):${error instanceof Error ? error.message : String(error)}`);
|
|
120
|
+
lose();
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (result.migrated) writeBack(file, result.data, warnLog);
|
|
124
|
+
return result.data;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- 台账轮转 ----------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
// 轮转阈值:记录数(>50k)或字节数(>5MB),任一命中即轮转。阈值为建议初值,
|
|
130
|
+
// 待实测后校准。
|
|
131
|
+
export const ROTATION_DEFAULT_MAX_RECORDS = 50000;
|
|
132
|
+
export const ROTATION_DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
133
|
+
// 封存件保留上限(回放窗口):超过按最早封存淘汰。
|
|
134
|
+
export const ROTATION_DEFAULT_KEEP = 8;
|
|
135
|
+
|
|
136
|
+
// 封存件文件名:dispatch-<seq>-<ts>.jsonl(同 seq 同毫秒碰撞时追加 -<n>)。
|
|
137
|
+
const ARCHIVE_PATTERN = /^dispatch-(\d+)-(\d+)(?:-\d+)?\.jsonl$/;
|
|
138
|
+
|
|
139
|
+
// 是否达轮转阈值:记录数超限或字节数超限(任一命中)。
|
|
140
|
+
export function rotationNeeded(records, bytes, { maxRecords = ROTATION_DEFAULT_MAX_RECORDS, maxBytes = ROTATION_DEFAULT_MAX_BYTES } = {}) {
|
|
141
|
+
return records > maxRecords || bytes > maxBytes;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 目录内封存件清单(按 seq、ts 升序 = 最早封存在前)。目录缺失/读失败返回空表。
|
|
145
|
+
function listArchives(dir) {
|
|
146
|
+
let names;
|
|
147
|
+
try {
|
|
148
|
+
names = readdirSync(dir);
|
|
149
|
+
} catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
return names
|
|
153
|
+
.map((name) => ARCHIVE_PATTERN.exec(name))
|
|
154
|
+
.filter((match) => match !== null)
|
|
155
|
+
.map((match) => ({ name: match[0], seq: Number(match[1]), ts: Number(match[2]) }))
|
|
156
|
+
.sort((a, b) => a.seq - b.seq || a.ts - b.ts);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 淘汰最早封存件至保留上限;发生淘汰时一次性提示回放窗口缩短(每次轮转最多一次,
|
|
160
|
+
// 因本函数每次轮转只执行一次)。单个文件删除失败不阻断其余淘汰。
|
|
161
|
+
function pruneArchives(dir, keep, warn) {
|
|
162
|
+
const list = listArchives(dir);
|
|
163
|
+
if (list.length <= keep) return 0;
|
|
164
|
+
const excess = list.slice(0, list.length - keep);
|
|
165
|
+
let removed = 0;
|
|
166
|
+
for (const entry of excess) {
|
|
167
|
+
try {
|
|
168
|
+
rmSync(join(dir, entry.name), { force: true });
|
|
169
|
+
removed += 1;
|
|
170
|
+
} catch { /* 单个封存件删除失败不影响主流程 */ }
|
|
171
|
+
}
|
|
172
|
+
warn(`dispatch ledger: 回放窗口缩短——封存件已超过 ${keep} 份,淘汰最旧 ${removed} 份`);
|
|
173
|
+
return removed;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 创建轮转执行器:check(records, bytes) 在阈值命中时执行一次轮转,返回
|
|
177
|
+
// {rotated, archived, seq, pruned};未命中返回 {rotated:false}。seq 账本与 meta
|
|
178
|
+
// 落盘由调用方注入:getRotationSeq/setRotationSeq 管内存 seq,initMetaIfMissing
|
|
179
|
+
// 保证首次轮转先建 meta(seq 现值落盘),persistMeta 以更新后的 seq 落盘。
|
|
180
|
+
// 文件操作异常向上抛,由调用方按 fail-soft 处理(本条已落盘的追加不受影响)。
|
|
181
|
+
export function createRotation({
|
|
182
|
+
dir,
|
|
183
|
+
dispatchFile,
|
|
184
|
+
getRotationSeq = () => 0,
|
|
185
|
+
setRotationSeq = () => {},
|
|
186
|
+
initMetaIfMissing = () => {},
|
|
187
|
+
persistMeta = () => true,
|
|
188
|
+
warn = () => {},
|
|
189
|
+
maxRecords = ROTATION_DEFAULT_MAX_RECORDS,
|
|
190
|
+
maxBytes = ROTATION_DEFAULT_MAX_BYTES,
|
|
191
|
+
keep = ROTATION_DEFAULT_KEEP,
|
|
192
|
+
now = Date.now,
|
|
193
|
+
} = {}) {
|
|
194
|
+
return (records, bytes) => {
|
|
195
|
+
if (!rotationNeeded(records, bytes, { maxRecords, maxBytes })) return { rotated: false };
|
|
196
|
+
initMetaIfMissing();
|
|
197
|
+
const seq = getRotationSeq() + 1;
|
|
198
|
+
const stamp = now();
|
|
199
|
+
let archive = join(dir, `dispatch-${seq}-${stamp}.jsonl`);
|
|
200
|
+
let attempt = 0;
|
|
201
|
+
while (existsSync(archive)) {
|
|
202
|
+
attempt += 1;
|
|
203
|
+
archive = join(dir, `dispatch-${seq}-${stamp}-${attempt}.jsonl`);
|
|
204
|
+
}
|
|
205
|
+
// 只 rename + 新建:封存内容字节不变,绝不重写任何已写行。
|
|
206
|
+
renameSync(dispatchFile, archive);
|
|
207
|
+
setRotationSeq(seq);
|
|
208
|
+
persistMeta();
|
|
209
|
+
writeFileSync(dispatchFile, '', 'utf8');
|
|
210
|
+
const pruned = pruneArchives(dir, keep, warn);
|
|
211
|
+
return { rotated: true, archived: archive, seq, pruned };
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// lib/core/evolution-renewal.mjs — 候选换代判定与换代审计(从 evolution-engine 拆出
|
|
2
|
+
// 守行门):到期判定、有效期初值、数据指纹、显著变化判定(阈值初值,待实测后校准)、
|
|
3
|
+
// suggestion-expire / suggestion-refresh 审计条目。纯函数、零 IO、零依赖,可裸 import 单测。
|
|
4
|
+
|
|
5
|
+
// 候选到期判定:expiry 为 null(不自动过期)恒不到期,否则 now >= expiry。
|
|
6
|
+
export function candidateExpired(entry, now) {
|
|
7
|
+
return typeof entry.expiry === 'number' && now >= entry.expiry;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// 候选有效期初值:手动维护恒不自动过期;自动换新按设置小时数计算,0 = 不自动过期。
|
|
11
|
+
export function candidateExpiryOf(mode, ttlH, now) {
|
|
12
|
+
if (mode === 'manual') return null;
|
|
13
|
+
return ttlH === 0 ? null : now + ttlH * 3600 * 1000;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 数据指纹:该身份键当前聚合的样本数/加权成功分/置信度(换代判定输入)。
|
|
17
|
+
export function dataFingerprintOf(item, summaries) {
|
|
18
|
+
const l1 = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object'
|
|
19
|
+
? summaries.l1
|
|
20
|
+
: {};
|
|
21
|
+
const group = typeof item.profileKey === 'string' ? l1[item.profileKey] : undefined;
|
|
22
|
+
const entry = group !== null && typeof group === 'object' ? group : {};
|
|
23
|
+
return {
|
|
24
|
+
n: Number.isFinite(entry.confidence?.n) ? entry.confidence.n : (Number.isFinite(entry.deployments_total) ? entry.deployments_total : 0),
|
|
25
|
+
score: Number.isFinite(entry.score?.weighted_success) ? entry.score.weighted_success : 0,
|
|
26
|
+
confidence: typeof entry.confidence?.level === 'string' ? entry.confidence.level : '',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 显著变化判定(初值,待实测后校准):样本数增加 ≥2,或加权成功分变化 ≥0.1,
|
|
31
|
+
// 或置信度升级。
|
|
32
|
+
const FINGERPRINT_CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
|
|
33
|
+
export function dataSignificantlyChanged(prev, next) {
|
|
34
|
+
if (next.n - prev.n >= 2) return true;
|
|
35
|
+
if (Math.abs(next.score - prev.score) >= 0.1) return true;
|
|
36
|
+
return (FINGERPRINT_CONFIDENCE_RANK[next.confidence] ?? -1) > (FINGERPRINT_CONFIDENCE_RANK[prev.confidence] ?? -1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// suggestion-expire 事件:候选到期且数据显著变化、换代时转 expired 的留痕(仅记录)。
|
|
40
|
+
export function expireAuditEntry(asset, reason) {
|
|
41
|
+
return {
|
|
42
|
+
event: 'suggestion-expire',
|
|
43
|
+
profile_id: typeof asset.id === 'string' ? asset.id : '',
|
|
44
|
+
from: asset.from,
|
|
45
|
+
to: asset.to,
|
|
46
|
+
axis: asset.axis,
|
|
47
|
+
direction: asset.direction,
|
|
48
|
+
human_confirmed: false,
|
|
49
|
+
reason,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// suggestion-refresh 事件:人工触发「重新生成候选」的留痕(人工触发即确认)。
|
|
54
|
+
export function refreshAuditEntry({ profileKey, from, to, axis, direction }) {
|
|
55
|
+
return {
|
|
56
|
+
event: 'suggestion-refresh',
|
|
57
|
+
profile_id: profileKey,
|
|
58
|
+
from,
|
|
59
|
+
to,
|
|
60
|
+
axis,
|
|
61
|
+
direction,
|
|
62
|
+
human_confirmed: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// lib/core/evolution-routes.mjs — 进化资产与候选管理路由处理:/evolution/assets 只读
|
|
2
|
+
// 列表、/evolution/apply 应用写路径、/evolution/regenerate 重新生成候选、
|
|
3
|
+
// /advice/save-profile 保存为方案、/settings/candidate 候选设置。loopback 与 CSRF
|
|
4
|
+
// 守卫由 createHttpRoutes 入口统一完成,本模块只做 body 解析与响应组装。apply 拒绝
|
|
5
|
+
// (400)回传原因与可选逐项 checks;成功回传新方案 id、资产状态与 persisted 信号。
|
|
6
|
+
// apply 业务本体(三道闸/审计/锁)在 evolution-engine 内,本模块不触碰任何闸判定。
|
|
7
|
+
|
|
8
|
+
import { sanitizeProfile } from './pure.mjs';
|
|
9
|
+
import { computeAdvice } from './evolution-advice.mjs';
|
|
10
|
+
import { lowerHyphen, sourceConfigFromKey } from './evolution-draft.mjs';
|
|
11
|
+
import { json, readBody, persistOk } from './http-helpers.mjs';
|
|
12
|
+
|
|
13
|
+
async function handleEvolutionAssets(deps, res) {
|
|
14
|
+
const assets = deps.evolution !== undefined && typeof deps.evolution.list === 'function' ? deps.evolution.list() : [];
|
|
15
|
+
return json(res, 200, { ok: true, assets });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function handleEvolutionApply(deps, req, res) {
|
|
19
|
+
const body = await readBody(req);
|
|
20
|
+
const assetId = typeof body.assetId === 'string' ? body.assetId : '';
|
|
21
|
+
if (assetId === '') return json(res, 400, { ok: false, error: 'assetId 不能为空' });
|
|
22
|
+
if (deps.evolution === undefined || typeof deps.evolution.apply !== 'function') {
|
|
23
|
+
return json(res, 404, { ok: false, error: '进化 apply 引擎未装配' });
|
|
24
|
+
}
|
|
25
|
+
const result = await deps.evolution.apply({ assetId, humanConfirmed: body.human_confirmed === true });
|
|
26
|
+
if (!result.ok) {
|
|
27
|
+
const payload = { ok: false, error: result.reason };
|
|
28
|
+
if (result.checks !== undefined) payload.checks = result.checks;
|
|
29
|
+
return json(res, 400, payload);
|
|
30
|
+
}
|
|
31
|
+
return json(res, 200, {
|
|
32
|
+
ok: true,
|
|
33
|
+
id: result.id,
|
|
34
|
+
state: result.state,
|
|
35
|
+
persisted: result.persisted,
|
|
36
|
+
...(result.persisted ? {} : { persistWarning: '已应用但未持久化' }),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 候选设置写路径:更新模式(自动换新/手动维护)+ 有效期小时数(0 或 1~8760 整数)。
|
|
41
|
+
// 校验失败 400;成功持久化并回传 persisted 信号与归一后的值。
|
|
42
|
+
async function handleSettingsCandidate(deps, req, res) {
|
|
43
|
+
const body = await readBody(req);
|
|
44
|
+
const mode = body && typeof body.candidateMode === 'string' ? body.candidateMode : '';
|
|
45
|
+
const ttlH = body && typeof body.candidateTtlH === 'number' ? body.candidateTtlH : NaN;
|
|
46
|
+
if (mode !== 'auto' && mode !== 'manual') {
|
|
47
|
+
return json(res, 400, { ok: false, error: '候选更新模式无效(auto/manual)' });
|
|
48
|
+
}
|
|
49
|
+
if (!Number.isInteger(ttlH) || !(ttlH === 0 || (ttlH >= 1 && ttlH <= 8760))) {
|
|
50
|
+
return json(res, 400, { ok: false, error: '候选有效期无效(0 或 1~8760 小时整数)' });
|
|
51
|
+
}
|
|
52
|
+
const modePersist = deps.store.persistCandidateMode(mode);
|
|
53
|
+
const ttlPersist = deps.store.persistCandidateTtlH(ttlH);
|
|
54
|
+
const persisted = modePersist.persisted !== false && ttlPersist.persisted !== false;
|
|
55
|
+
return persistOk(res, {
|
|
56
|
+
candidateMode: deps.store.loadCandidateMode(),
|
|
57
|
+
candidateTtlH: deps.store.loadCandidateTtlH(),
|
|
58
|
+
}, { persisted });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 重新生成候选(人工触发):该身份键所有未处置候选先转 expired,再按当前建议
|
|
62
|
+
// 产新一代;审计由引擎写 suggestion-refresh。无当前建议时只清理不产新候选。
|
|
63
|
+
async function handleEvolutionRegenerate(deps, req, res) {
|
|
64
|
+
const body = await readBody(req);
|
|
65
|
+
const profileKey = body && typeof body.profileKey === 'string' ? body.profileKey : '';
|
|
66
|
+
if (profileKey === '') return json(res, 400, { ok: false, error: 'profileKey 不能为空' });
|
|
67
|
+
if (deps.evolution === undefined || typeof deps.evolution.regenerate !== 'function') {
|
|
68
|
+
return json(res, 404, { ok: false, error: '候选重新生成入口未装配' });
|
|
69
|
+
}
|
|
70
|
+
const computed = computeAdvice({ summariesFile: deps.summariesFile, dispatchFile: deps.dispatchFile, whitelist: deps.adviceWhitelist, logger: deps.logger });
|
|
71
|
+
const item = (Array.isArray(computed.advice) ? computed.advice : []).find((a) => a !== null && typeof a === 'object' && a.profileKey === profileKey);
|
|
72
|
+
const result = await deps.evolution.regenerate({ profileKey, advice: item !== undefined ? [item] : [], summaries: computed.summaries });
|
|
73
|
+
return json(res, 200, { ok: true, ...result });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 保存为方案:把建议来源的当前派发配置(身份键可还原的预设/提供方/模型段)存成
|
|
77
|
+
// 新方案,便于复用与对比。自定义人格与工具过滤原文未留存(台账只存存在性),
|
|
78
|
+
// 不在新方案内。身份键无可还原字段时 400。
|
|
79
|
+
async function handleAdviceSaveProfile(deps, req, res) {
|
|
80
|
+
const body = await readBody(req);
|
|
81
|
+
const profileKey = body && typeof body.profileKey === 'string' ? body.profileKey : '';
|
|
82
|
+
if (profileKey === '') return json(res, 400, { ok: false, error: 'profileKey 不能为空' });
|
|
83
|
+
const from = sourceConfigFromKey(profileKey, {});
|
|
84
|
+
const config = {};
|
|
85
|
+
for (const field of ['preset', 'provider', 'model']) {
|
|
86
|
+
if (typeof from[field] === 'string' && from[field] !== '') config[field] = from[field];
|
|
87
|
+
}
|
|
88
|
+
if (Object.keys(config).length === 0) {
|
|
89
|
+
return json(res, 400, { ok: false, error: '该配置无可保存字段(未指定预设/提供方/模型)' });
|
|
90
|
+
}
|
|
91
|
+
const slug = lowerHyphen(profileKey).slice(0, 24) || 'config';
|
|
92
|
+
const base = `advice-${slug}`.slice(0, 32);
|
|
93
|
+
const taken = new Set(deps.store.profiles.keys());
|
|
94
|
+
let id = base;
|
|
95
|
+
for (let attempt = 2; taken.has(id) && attempt <= 6; attempt += 1) {
|
|
96
|
+
id = `${base}-${attempt}`.slice(0, 32);
|
|
97
|
+
}
|
|
98
|
+
if (taken.has(id)) return json(res, 400, { ok: false, error: '方案编号冲突,无法保存' });
|
|
99
|
+
const name = slug.length > 0 ? `建议配置-${slug.slice(0, 16)}` : '建议配置';
|
|
100
|
+
const { clean, warnings } = sanitizeProfile({ ...config, id, name, description: '来自建议面板的配置快照(仅预设/模型/提供方;人格与工具过滤不落盘,按隐私设计)。便于复用与对比。' }, { strict: true });
|
|
101
|
+
if (warnings.length > 0) {
|
|
102
|
+
const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
|
|
103
|
+
return json(res, 400, { ok: false, error: `保存被拒绝:${detail}` });
|
|
104
|
+
}
|
|
105
|
+
deps.store.profiles.set(clean.id, { ...clean, persisted: true });
|
|
106
|
+
return persistOk(res, { id: clean.id, name: clean.name }, deps.store.persistProfiles());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { handleEvolutionAssets, handleEvolutionApply, handleEvolutionRegenerate, handleAdviceSaveProfile, handleSettingsCandidate };
|