dsh-skill-browser 1.8.7 → 1.9.9
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/CHANGELOG.md +178 -0
- package/README-zh.md +190 -0
- package/lib/client.js +1723 -108
- package/lib/groups.js +956 -0
- package/lib/index.js +377 -22
- package/lib/keyword-groups.js +200 -0
- package/lib/ledger-stats.js +203 -0
- package/lib/ledger-writer.js +56 -1
- package/lib/scan.js +16 -0
- package/lib/zh.js +117 -1
- package/package.json +9 -3
package/lib/groups.js
ADDED
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
// dsh-skill-browser - v1.9.0 user-defined view groups (分组).
|
|
2
|
+
//
|
|
3
|
+
// A "group" is a PURELY VISUAL bucket: it never moves, copies, renames or
|
|
4
|
+
// deletes any skill file. Skills keep living where they are on disk; a group
|
|
5
|
+
// only records "these skill dirs belong together in the panel".
|
|
6
|
+
//
|
|
7
|
+
// Storage: ~/.dsh/data/dsh-skill-browser/groups.json
|
|
8
|
+
// {
|
|
9
|
+
// "version": 2,
|
|
10
|
+
// "groups": [ { id, name, emoji, color, cover, createdAt, ai, parent } ],
|
|
11
|
+
// "assign": { "<skill-dir>": "<group-id>" }
|
|
12
|
+
// }
|
|
13
|
+
//
|
|
14
|
+
// `cover` holds a raw <svg>…</svg> string produced by the AI cover generator
|
|
15
|
+
// (or "" when unset). The client turns it into a data: URI background.
|
|
16
|
+
//
|
|
17
|
+
// v1.9.3 — two-level (总-分) groups. `parent` is "" for a top-level DOMAIN and
|
|
18
|
+
// the domain's id for a SUB-GROUP. Depth is capped at 2: a group whose parent
|
|
19
|
+
// is itself a sub-group is promoted back to top level on load. A skill may be
|
|
20
|
+
// assigned either to a sub-group or directly to a domain (meaning "this domain,
|
|
21
|
+
// no finer sub-class"), so nothing is ever forced into a fake "其他" bin.
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
23
|
+
import { join, dirname } from "node:path";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { categoriesMeta } from "./scan.js";
|
|
26
|
+
// NOTE: @deepseek-ai/dsh-llm is an OPTIONAL peer. It is imported lazily inside
|
|
27
|
+
// askModel() so a host without the package still loads this plugin (the AI
|
|
28
|
+
// buttons then report "llm service unavailable" instead of breaking everything).
|
|
29
|
+
var _llmMod = null;
|
|
30
|
+
// llm is an OPTIONAL capability: read it through ctx.get() so the plugin still
|
|
31
|
+
// loads on a host/profile that has no llm service (official rule: reserve
|
|
32
|
+
// ctx.<name> for declared injections, use ctx.get() for optional services).
|
|
33
|
+
function getLlm(ctx) {
|
|
34
|
+
if (!ctx) return null;
|
|
35
|
+
try {
|
|
36
|
+
if (typeof ctx.get === "function") return ctx.get("llm") || null;
|
|
37
|
+
} catch (e) { /* service absent */ }
|
|
38
|
+
return ctx.llm || null;
|
|
39
|
+
}
|
|
40
|
+
async function loadLlm() {
|
|
41
|
+
if (_llmMod) return _llmMod;
|
|
42
|
+
// Test seam: lets the offline smoke test drive the AI paths without a live
|
|
43
|
+
// model. Unset in normal runs, so production always resolves the real module.
|
|
44
|
+
if (globalThis.__DSB_TEST_LLM__) { _llmMod = globalThis.__DSB_TEST_LLM__; return _llmMod; }
|
|
45
|
+
try { _llmMod = await import("@deepseek-ai/dsh-llm"); }
|
|
46
|
+
catch (e) { throw new Error("宿主未提供 llm 服务(@deepseek-ai/dsh-llm 不可用):" + String((e && e.message) || e)); }
|
|
47
|
+
return _llmMod;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
var STORE_FILE = join(homedir(), ".dsh", "data", "dsh-skill-browser", "groups.json");
|
|
51
|
+
// Two levels (domain -> sub-group) means a 109-skill library can legitimately
|
|
52
|
+
// need 10 domains x 5 sub-groups, so the cap has to be generous.
|
|
53
|
+
var MAX_GROUPS = 160;
|
|
54
|
+
var MAX_NAME = 24;
|
|
55
|
+
|
|
56
|
+
// ── store i/o ───────────────────────────────────────────────────────────
|
|
57
|
+
function emptyStore() {
|
|
58
|
+
return { version: 2, groups: [], assign: {} };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadStore() {
|
|
62
|
+
try {
|
|
63
|
+
if (!existsSync(STORE_FILE)) return emptyStore();
|
|
64
|
+
// Tolerate a BOM (the file may be hand-edited in Notepad).
|
|
65
|
+
var parsed = JSON.parse(String(readFileSync(STORE_FILE, "utf8")).replace(/^\uFEFF/, ""));
|
|
66
|
+
if (!parsed || typeof parsed !== "object") return emptyStore();
|
|
67
|
+
var groups = Array.isArray(parsed.groups) ? parsed.groups : [];
|
|
68
|
+
var assign = parsed.assign && typeof parsed.assign === "object" ? parsed.assign : {};
|
|
69
|
+
var clean = [];
|
|
70
|
+
var byId = {};
|
|
71
|
+
for (var i = 0; i < groups.length; i++) {
|
|
72
|
+
var g = groups[i];
|
|
73
|
+
if (!g || typeof g.id !== "string" || !g.id) continue;
|
|
74
|
+
var item = {
|
|
75
|
+
id: g.id,
|
|
76
|
+
name: String(g.name || "").slice(0, MAX_NAME) || "未命名分组",
|
|
77
|
+
emoji: String(g.emoji || "📁"),
|
|
78
|
+
color: String(g.color || ""),
|
|
79
|
+
cover: typeof g.cover === "string" ? g.cover : "",
|
|
80
|
+
ai: !!g.ai,
|
|
81
|
+
parent: typeof g.parent === "string" ? g.parent : "",
|
|
82
|
+
createdAt: Number(g.createdAt) || 0
|
|
83
|
+
};
|
|
84
|
+
clean.push(item);
|
|
85
|
+
byId[item.id] = item;
|
|
86
|
+
}
|
|
87
|
+
// Depth is capped at 2: a sub-group whose parent is itself a sub-group (or
|
|
88
|
+
// a missing id) is promoted to a top-level domain instead of being dropped -
|
|
89
|
+
// losing a user's grouping is worse than flattening it.
|
|
90
|
+
for (var p = 0; p < clean.length; p++) {
|
|
91
|
+
if (!clean[p].parent) continue;
|
|
92
|
+
var par = byId[clean[p].parent];
|
|
93
|
+
if (!par || par.id === clean[p].id) { clean[p].parent = ""; continue; }
|
|
94
|
+
if (par.parent) clean[p].parent = "";
|
|
95
|
+
}
|
|
96
|
+
var assignClean = {};
|
|
97
|
+
for (var k in assign) {
|
|
98
|
+
if (!Object.prototype.hasOwnProperty.call(assign, k)) continue;
|
|
99
|
+
if (typeof assign[k] === "string" && assign[k]) assignClean[k] = assign[k];
|
|
100
|
+
}
|
|
101
|
+
return { version: 2, groups: clean, assign: assignClean };
|
|
102
|
+
} catch (e) {
|
|
103
|
+
// A corrupt or half-written store must NEVER be silently downgraded to an
|
|
104
|
+
// empty one - the next save would then overwrite the user real groups.
|
|
105
|
+
var err = new Error("groups.json 读取失败(文件可能损坏):" + String((e && e.message) || e));
|
|
106
|
+
err.code = "STORE_CORRUPT";
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function saveStore(store) {
|
|
112
|
+
try {
|
|
113
|
+
mkdirSync(dirname(STORE_FILE), { recursive: true });
|
|
114
|
+
// Rolling backup: a bad write can never lose the user groups.
|
|
115
|
+
try {
|
|
116
|
+
if (existsSync(STORE_FILE)) {
|
|
117
|
+
var prev = readFileSync(STORE_FILE, "utf8");
|
|
118
|
+
if (prev && prev.trim()) writeFileSync(STORE_FILE + ".bak", prev, "utf8");
|
|
119
|
+
}
|
|
120
|
+
} catch (eBak) { /* best-effort */ }
|
|
121
|
+
writeFileSync(STORE_FILE, JSON.stringify(store, null, 2), "utf8");
|
|
122
|
+
return true;
|
|
123
|
+
} catch (e) { return false; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function storePath() { return STORE_FILE; }
|
|
127
|
+
|
|
128
|
+
// Group ids are generated, never user-supplied, so the charset is ours.
|
|
129
|
+
function newId() {
|
|
130
|
+
return "g" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sanitizeName(name) {
|
|
134
|
+
return String(name == null ? "" : name).replace(/[\r\n\t]/g, " ").trim().slice(0, MAX_NAME);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Keep only assignments that still point at an existing group.
|
|
138
|
+
function pruneAssign(store, validDirs) {
|
|
139
|
+
var ids = {};
|
|
140
|
+
for (var i = 0; i < store.groups.length; i++) ids[store.groups[i].id] = true;
|
|
141
|
+
var out = {};
|
|
142
|
+
for (var d in store.assign) {
|
|
143
|
+
if (!Object.prototype.hasOwnProperty.call(store.assign, d)) continue;
|
|
144
|
+
var gid = store.assign[d];
|
|
145
|
+
if (!ids[gid]) continue;
|
|
146
|
+
if (validDirs && validDirs.indexOf(d) < 0) continue;
|
|
147
|
+
out[d] = gid;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── CRUD ────────────────────────────────────────────────────────────────
|
|
153
|
+
export function listGroups(validDirs) {
|
|
154
|
+
var store = loadStore();
|
|
155
|
+
var assign = pruneAssign(store, validDirs || null);
|
|
156
|
+
// Only persist the pruned map when the caller actually supplied a live skill
|
|
157
|
+
// list. With validDirs === null a temporarily unreadable skill root must not
|
|
158
|
+
// permanently delete the user's group assignments.
|
|
159
|
+
if (validDirs && Object.keys(assign).length !== Object.keys(store.assign).length) {
|
|
160
|
+
store.assign = assign;
|
|
161
|
+
saveStore(store);
|
|
162
|
+
}
|
|
163
|
+
var members = {};
|
|
164
|
+
for (var i = 0; i < store.groups.length; i++) members[store.groups[i].id] = [];
|
|
165
|
+
for (var d in assign) {
|
|
166
|
+
if (!Object.prototype.hasOwnProperty.call(assign, d)) continue;
|
|
167
|
+
if (members[assign[d]]) members[assign[d]].push(d);
|
|
168
|
+
}
|
|
169
|
+
var out = [];
|
|
170
|
+
for (var j = 0; j < store.groups.length; j++) {
|
|
171
|
+
var g = store.groups[j];
|
|
172
|
+
out.push({
|
|
173
|
+
id: g.id, name: g.name, emoji: g.emoji, color: g.color,
|
|
174
|
+
cover: g.cover, ai: g.ai, parent: g.parent || "", createdAt: g.createdAt,
|
|
175
|
+
members: members[g.id] || []
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// childCount / total are what the client shows on a domain tile: `total`
|
|
179
|
+
// includes every skill in the domain's sub-groups so "Fluent 81" stays true
|
|
180
|
+
// even when the domain itself holds no direct members.
|
|
181
|
+
for (var a = 0; a < out.length; a++) {
|
|
182
|
+
var kids = [];
|
|
183
|
+
for (var b = 0; b < out.length; b++) if (out[b].parent === out[a].id) kids.push(out[b].id);
|
|
184
|
+
out[a].children = kids;
|
|
185
|
+
var total = out[a].members.length;
|
|
186
|
+
for (var c = 0; c < kids.length; c++) {
|
|
187
|
+
for (var e = 0; e < out.length; e++) if (out[e].id === kids[c]) total += out[e].members.length;
|
|
188
|
+
}
|
|
189
|
+
out[a].total = total;
|
|
190
|
+
}
|
|
191
|
+
return { ok: true, storePath: STORE_FILE, groups: out, assign: assign };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function createGroup(name, emoji, color, parent) {
|
|
195
|
+
var store = loadStore();
|
|
196
|
+
if (store.groups.length >= MAX_GROUPS) {
|
|
197
|
+
return { ok: false, error: "分组数量已达上限 " + MAX_GROUPS };
|
|
198
|
+
}
|
|
199
|
+
var nm = sanitizeName(name);
|
|
200
|
+
if (!nm) return { ok: false, error: "分组名称不能为空" };
|
|
201
|
+
var par = String(parent || "");
|
|
202
|
+
if (par) {
|
|
203
|
+
var host = null;
|
|
204
|
+
for (var h = 0; h < store.groups.length; h++) if (store.groups[h].id === par) host = store.groups[h];
|
|
205
|
+
if (!host) return { ok: false, error: "上级分组不存在:" + par };
|
|
206
|
+
if (host.parent) return { ok: false, error: "只支持两级分组:不能在大类下再建子类(" + host.name + " 已是子类)" };
|
|
207
|
+
}
|
|
208
|
+
// Same-name check is scoped to the parent so "安装" can exist under both
|
|
209
|
+
// DSH and 软件安装.
|
|
210
|
+
for (var i = 0; i < store.groups.length; i++) {
|
|
211
|
+
if (store.groups[i].name === nm && (store.groups[i].parent || "") === par) {
|
|
212
|
+
return { ok: false, error: "同级下已存在同名分组:" + nm };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
var g = {
|
|
216
|
+
id: newId(), name: nm,
|
|
217
|
+
emoji: String(emoji || "📁").slice(0, 8),
|
|
218
|
+
color: String(color || ""),
|
|
219
|
+
cover: "", ai: false, parent: par, createdAt: Date.now()
|
|
220
|
+
};
|
|
221
|
+
store.groups.push(g);
|
|
222
|
+
var saved = saveStore(store);
|
|
223
|
+
return { ok: saved, group: g, storePath: STORE_FILE, error: saved ? "" : "写入失败" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function renameGroup(id, name, emoji, color) {
|
|
227
|
+
var store = loadStore();
|
|
228
|
+
for (var i = 0; i < store.groups.length; i++) {
|
|
229
|
+
if (store.groups[i].id !== id) continue;
|
|
230
|
+
if (name != null) {
|
|
231
|
+
var nm = sanitizeName(name);
|
|
232
|
+
if (!nm) return { ok: false, error: "分组名称不能为空" };
|
|
233
|
+
store.groups[i].name = nm;
|
|
234
|
+
}
|
|
235
|
+
if (emoji != null) store.groups[i].emoji = String(emoji).slice(0, 8) || "📁";
|
|
236
|
+
if (color != null) store.groups[i].color = String(color);
|
|
237
|
+
var saved = saveStore(store);
|
|
238
|
+
return { ok: saved, group: store.groups[i], error: saved ? "" : "写入失败" };
|
|
239
|
+
}
|
|
240
|
+
return { ok: false, error: "分组不存在:" + id };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function deleteGroup(id) {
|
|
244
|
+
var store = loadStore();
|
|
245
|
+
// Deleting a domain takes its sub-groups with it - a sub-group floating
|
|
246
|
+
// without its domain would be unreachable in the two-level UI.
|
|
247
|
+
var doomed = {};
|
|
248
|
+
doomed[id] = true;
|
|
249
|
+
var grew = true;
|
|
250
|
+
while (grew) {
|
|
251
|
+
grew = false;
|
|
252
|
+
for (var w = 0; w < store.groups.length; w++) {
|
|
253
|
+
var gp = store.groups[w];
|
|
254
|
+
if (doomed[gp.id]) continue;
|
|
255
|
+
if (gp.parent && doomed[gp.parent]) { doomed[gp.id] = true; grew = true; }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
var kept = [];
|
|
259
|
+
var found = false;
|
|
260
|
+
for (var i = 0; i < store.groups.length; i++) {
|
|
261
|
+
if (doomed[store.groups[i].id]) { found = true; continue; }
|
|
262
|
+
kept.push(store.groups[i]);
|
|
263
|
+
}
|
|
264
|
+
if (!found) return { ok: false, error: "分组不存在:" + id };
|
|
265
|
+
store.groups = kept;
|
|
266
|
+
for (var d in store.assign) {
|
|
267
|
+
if (!Object.prototype.hasOwnProperty.call(store.assign, d)) continue;
|
|
268
|
+
if (doomed[store.assign[d]]) delete store.assign[d];
|
|
269
|
+
}
|
|
270
|
+
var saved = saveStore(store);
|
|
271
|
+
return { ok: saved, removed: Object.keys(doomed).length, error: saved ? "" : "写入失败" };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Re-parent a group: parent === "" promotes it to a top-level domain, any other
|
|
275
|
+
// id makes it a sub-group of that domain. Moving a domain that owns sub-groups
|
|
276
|
+
// is refused - that would create a third level.
|
|
277
|
+
export function moveGroup(id, parent) {
|
|
278
|
+
var store = loadStore();
|
|
279
|
+
var par = String(parent || "");
|
|
280
|
+
var target = null;
|
|
281
|
+
for (var i = 0; i < store.groups.length; i++) if (store.groups[i].id === id) target = store.groups[i];
|
|
282
|
+
if (!target) return { ok: false, error: "分组不存在:" + id };
|
|
283
|
+
if (par === id) return { ok: false, error: "不能把分组挂到自己下面" };
|
|
284
|
+
if (par) {
|
|
285
|
+
var host = null;
|
|
286
|
+
for (var j = 0; j < store.groups.length; j++) if (store.groups[j].id === par) host = store.groups[j];
|
|
287
|
+
if (!host) return { ok: false, error: "上级分组不存在:" + par };
|
|
288
|
+
if (host.parent) return { ok: false, error: "只支持两级分组:目标已是大类下的子类" };
|
|
289
|
+
for (var k = 0; k < store.groups.length; k++) {
|
|
290
|
+
if (store.groups[k].parent === id) return { ok: false, error: "该分组下已有子类,不能再降为子类" };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
target.parent = par;
|
|
294
|
+
var saved = saveStore(store);
|
|
295
|
+
return { ok: saved, group: target, error: saved ? "" : "写入失败" };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Move a skill into a group (gid === "" or null removes it from any group).
|
|
299
|
+
// NOTE: this touches NOTHING on disk except groups.json — the skill directory
|
|
300
|
+
// itself is never moved.
|
|
301
|
+
export function assignSkill(dir, gid) {
|
|
302
|
+
var store = loadStore();
|
|
303
|
+
var d = String(dir || "");
|
|
304
|
+
if (!d) return { ok: false, error: "缺少技能目录名" };
|
|
305
|
+
if (gid === undefined || gid === null) return { ok: false, error: "缺少目标分组 id(传空字符串表示移出分组)" };
|
|
306
|
+
if (!gid) {
|
|
307
|
+
delete store.assign[d];
|
|
308
|
+
} else {
|
|
309
|
+
var exists = false;
|
|
310
|
+
for (var i = 0; i < store.groups.length; i++) {
|
|
311
|
+
if (store.groups[i].id === gid) { exists = true; break; }
|
|
312
|
+
}
|
|
313
|
+
if (!exists) return { ok: false, error: "分组不存在:" + gid };
|
|
314
|
+
store.assign[d] = gid;
|
|
315
|
+
}
|
|
316
|
+
var saved = saveStore(store);
|
|
317
|
+
return { ok: saved, dir: d, gid: gid || "", error: saved ? "" : "写入失败" };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Bulk assign: {dirs:[...], gid} or {dirs:[...], gid:""} to clear.
|
|
321
|
+
export function assignSkills(dirs, gid) {
|
|
322
|
+
var store = loadStore();
|
|
323
|
+
if (gid) {
|
|
324
|
+
var exists = false;
|
|
325
|
+
for (var i = 0; i < store.groups.length; i++) {
|
|
326
|
+
if (store.groups[i].id === gid) { exists = true; break; }
|
|
327
|
+
}
|
|
328
|
+
if (!exists) return { ok: false, error: "分组不存在:" + gid };
|
|
329
|
+
}
|
|
330
|
+
var n = 0;
|
|
331
|
+
for (var j = 0; j < dirs.length; j++) {
|
|
332
|
+
var d = String(dirs[j] || "");
|
|
333
|
+
if (!d) continue;
|
|
334
|
+
if (gid) store.assign[d] = gid; else delete store.assign[d];
|
|
335
|
+
n++;
|
|
336
|
+
}
|
|
337
|
+
var saved = saveStore(store);
|
|
338
|
+
return { ok: saved, count: n, gid: gid || "", error: saved ? "" : "写入失败" };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function setGroupCover(id, svg) {
|
|
342
|
+
var store = loadStore();
|
|
343
|
+
for (var i = 0; i < store.groups.length; i++) {
|
|
344
|
+
if (store.groups[i].id !== id) continue;
|
|
345
|
+
store.groups[i].cover = typeof svg === "string" ? svg : "";
|
|
346
|
+
var saved = saveStore(store);
|
|
347
|
+
return { ok: saved, group: store.groups[i], error: saved ? "" : "写入失败" };
|
|
348
|
+
}
|
|
349
|
+
return { ok: false, error: "分组不存在:" + id };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── LLM plumbing (same contract as dsh-qq-digest: ctx.llm.stream) ────────
|
|
353
|
+
//
|
|
354
|
+
// Model discovery is deliberately FOUR-STAGE so that a fresh install needs
|
|
355
|
+
// ZERO configuration: the user installs the plugin, clicks 🤖, and it uses the
|
|
356
|
+
// very model they are chatting with.
|
|
357
|
+
//
|
|
358
|
+
// 1. plugin config —— cfg.provider + cfg.model (explicit override, optional)
|
|
359
|
+
// 2. agentDefaultModel —— the "default model" in DSH settings, which is what
|
|
360
|
+
// the chat itself uses. This is the zero-config path for ~everyone.
|
|
361
|
+
// 3. ctx.agent / agents —— the live agent's own selection (per-session model
|
|
362
|
+
// switches, e.g. user picked a model just for this conversation).
|
|
363
|
+
// 4. llm.listProviders() + listModels() —— last resort: ask the host which
|
|
364
|
+
// providers/models actually exist right now and take the first one. This
|
|
365
|
+
// keeps working even when no default was ever saved.
|
|
366
|
+
//
|
|
367
|
+
// Every stage is guarded; a missing service just falls through to the next.
|
|
368
|
+
|
|
369
|
+
/** Stage 2: the default model selected in DSH settings (same one chat uses). */
|
|
370
|
+
function routeFromDefaultModel(ctx) {
|
|
371
|
+
try {
|
|
372
|
+
var svc = typeof ctx.get === "function" ? ctx.get("agentDefaultModel") : ctx.agentDefaultModel;
|
|
373
|
+
if (!svc) return null;
|
|
374
|
+
var sel = typeof svc.currentSelection === "function" ? svc.currentSelection() : null;
|
|
375
|
+
if (sel && sel.provider && sel.model) {
|
|
376
|
+
return {
|
|
377
|
+
provider: String(sel.provider),
|
|
378
|
+
model: String(sel.model),
|
|
379
|
+
source: "dsh-default",
|
|
380
|
+
reasoningEffort: sel.reasoningEffort ? String(sel.reasoningEffort) : ""
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
} catch (e) { /* fall through */ }
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Stage 3: the live agent's own selection (per-session model switch). */
|
|
388
|
+
function routeFromAgent(ctx) {
|
|
389
|
+
try {
|
|
390
|
+
var ag = ctx.agent || (typeof ctx.get === "function" ? ctx.get("agent") : null);
|
|
391
|
+
if (!ag) return null;
|
|
392
|
+
// dsh-agent installs a mutable selection; shapes seen in the wild:
|
|
393
|
+
// ag.selection.current = {provider, model, reasoningEffort}
|
|
394
|
+
// ag.model = {provider, model}
|
|
395
|
+
var cands = [
|
|
396
|
+
ag.selection && ag.selection.current,
|
|
397
|
+
ag.selection,
|
|
398
|
+
ag.model,
|
|
399
|
+
ag.modelSelection && ag.modelSelection.current
|
|
400
|
+
];
|
|
401
|
+
for (var i = 0; i < cands.length; i++) {
|
|
402
|
+
var c = cands[i];
|
|
403
|
+
if (c && c.provider && c.model) {
|
|
404
|
+
return {
|
|
405
|
+
provider: String(c.provider),
|
|
406
|
+
model: String(c.model),
|
|
407
|
+
source: "agent-session",
|
|
408
|
+
reasoningEffort: c.reasoningEffort ? String(c.reasoningEffort) : ""
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} catch (e) { /* fall through */ }
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Stage 4: ask the host which providers/models are actually registered. */
|
|
417
|
+
async function routeFromLlmCatalog(ctx) {
|
|
418
|
+
var llm = getLlm(ctx);
|
|
419
|
+
if (!llm || typeof llm.listProviders !== "function") return null;
|
|
420
|
+
var provs = [];
|
|
421
|
+
try { provs = llm.listProviders() || []; } catch (e) { return null; }
|
|
422
|
+
for (var i = 0; i < provs.length; i++) {
|
|
423
|
+
var pid = provs[i] && (provs[i].id || provs[i].provider);
|
|
424
|
+
if (!pid) continue;
|
|
425
|
+
var models = [];
|
|
426
|
+
try {
|
|
427
|
+
if (typeof llm.listModels === "function") models = (await llm.listModels(String(pid))) || [];
|
|
428
|
+
} catch (e) { models = []; }
|
|
429
|
+
if (!models.length) continue;
|
|
430
|
+
var mid = models[0] && (models[0].id || models[0].model);
|
|
431
|
+
if (!mid) continue;
|
|
432
|
+
return { provider: String(pid), model: String(mid), source: "llm-catalog", reasoningEffort: "" };
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Resolve which provider/model the AI features should use. Async because the
|
|
439
|
+
* last-resort stage queries the host catalog. Throws only when every stage
|
|
440
|
+
* failed, with an error that says what was tried.
|
|
441
|
+
*/
|
|
442
|
+
export async function resolveModelRoute(ctx, cfg) {
|
|
443
|
+
var c = cfg || {};
|
|
444
|
+
// Stage 1: explicit plugin config wins.
|
|
445
|
+
if (c.provider && c.model) {
|
|
446
|
+
var route = { provider: String(c.provider), model: String(c.model), source: "config", reasoningEffort: "" };
|
|
447
|
+
if (c.reasoningEffort) route.reasoningEffort = String(c.reasoningEffort);
|
|
448
|
+
return route;
|
|
449
|
+
}
|
|
450
|
+
var tried = [];
|
|
451
|
+
var r2 = routeFromDefaultModel(ctx);
|
|
452
|
+
if (r2) return r2;
|
|
453
|
+
tried.push("agentDefaultModel");
|
|
454
|
+
var r3 = routeFromAgent(ctx);
|
|
455
|
+
if (r3) return r3;
|
|
456
|
+
tried.push("agent.session");
|
|
457
|
+
var r4 = await routeFromLlmCatalog(ctx);
|
|
458
|
+
if (r4) return r4;
|
|
459
|
+
tried.push("llm.listProviders");
|
|
460
|
+
var err = new Error(
|
|
461
|
+
"找不到可用模型(已尝试:" + tried.join(" / ") + ")。" +
|
|
462
|
+
"请到 DSH 设置 → 模型 里选择一个默认模型后重试;或在插件配置里填 llm.provider + llm.model。"
|
|
463
|
+
);
|
|
464
|
+
err.code = "NO_MODEL_ROUTE";
|
|
465
|
+
throw err;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Non-throwing probe used by the panel to show WHICH model AI features will
|
|
470
|
+
* use, so users are never left guessing. Never triggers a model call.
|
|
471
|
+
*/
|
|
472
|
+
export async function describeModelRoute(ctx, cfg) {
|
|
473
|
+
try {
|
|
474
|
+
var r = await resolveModelRoute(ctx, cfg);
|
|
475
|
+
// A route alone is not enough: without the host's llm service AI features
|
|
476
|
+
// still cannot run, so report that up front instead of failing later.
|
|
477
|
+
var llmSvc = getLlm(ctx);
|
|
478
|
+
if (!llmSvc) {
|
|
479
|
+
return { ok: false, provider: r.provider, model: r.model, source: r.source,
|
|
480
|
+
error: "宿主未提供 llm 服务,AI 分组不可用(关键词分组不受影响)" };
|
|
481
|
+
}
|
|
482
|
+
return { ok: true, provider: r.provider, model: r.model, source: r.source, error: "" };
|
|
483
|
+
} catch (e) {
|
|
484
|
+
return { ok: false, provider: "", model: "", source: "", error: String((e && e.message) || e) };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
async function askModel(ctx, cfg, system, userText, purpose, maxTokens) {
|
|
490
|
+
// resolveModelRoute is async: stage 4 may query the host's provider catalog.
|
|
491
|
+
var route = await resolveModelRoute(ctx, cfg);
|
|
492
|
+
var llm = await loadLlm();
|
|
493
|
+
var messages = [
|
|
494
|
+
llm.createUserMessage({ content: [{ type: "text", text: userText }], source: { kind: "dsh-skill-browser" } })
|
|
495
|
+
];
|
|
496
|
+
var options = {
|
|
497
|
+
provider: route.provider,
|
|
498
|
+
model: route.model,
|
|
499
|
+
messages: messages,
|
|
500
|
+
system: system,
|
|
501
|
+
maxTokens: maxTokens || 2400,
|
|
502
|
+
purpose: purpose,
|
|
503
|
+
signal: AbortSignal.timeout(120000)
|
|
504
|
+
};
|
|
505
|
+
if (route.reasoningEffort) options.reasoningEffort = route.reasoningEffort;
|
|
506
|
+
try {
|
|
507
|
+
var sid = ctx.agent && ctx.agent.session && ctx.agent.session.id;
|
|
508
|
+
if (sid) options.sessionId = String(sid);
|
|
509
|
+
} catch (e) { /* optional */ }
|
|
510
|
+
// NEVER deep-freeze the whole options object: the host skips AbortSignal on
|
|
511
|
+
// purpose, and freezing signal breaks the timeout/abort chain.
|
|
512
|
+
for (var key in options) {
|
|
513
|
+
if (!Object.prototype.hasOwnProperty.call(options, key)) continue;
|
|
514
|
+
if (key === "signal") continue;
|
|
515
|
+
if (options[key] && typeof options[key] === "object") Object.freeze(options[key]);
|
|
516
|
+
}
|
|
517
|
+
Object.freeze(messages);
|
|
518
|
+
|
|
519
|
+
var llmSvc = getLlm(ctx);
|
|
520
|
+
if (!llmSvc) throw new Error("宿主未提供 llm 服务,AI 功能不可用");
|
|
521
|
+
var assembler = new llm.BlockAssembler();
|
|
522
|
+
for await (var chunk of llmSvc.stream(options)) assembler.push(chunk);
|
|
523
|
+
var finish = assembler.finish;
|
|
524
|
+
if (finish && finish.kind && finish.kind !== "stop") {
|
|
525
|
+
// finish shape is { kind, failure } — NOT { error }.
|
|
526
|
+
var f = finish.failure;
|
|
527
|
+
var detail = f ? [f.code ? "[" + String(f.code) + "]" : "", String(f.message || f)].filter(Boolean).join(" ") : "(无 failure 详情)";
|
|
528
|
+
var err = new Error("模型流异常结束(" + String(finish.kind) + "): " + detail);
|
|
529
|
+
err.code = f && f.code ? String(f.code) : "LLM_FINISH_" + String(finish.kind).toUpperCase();
|
|
530
|
+
throw err;
|
|
531
|
+
}
|
|
532
|
+
var text = assembler.blocks().filter(function (b) { return b.type === "text"; }).map(function (b) { return b.text; }).join("").trim();
|
|
533
|
+
if (!text) throw new Error("模型没有返回文本");
|
|
534
|
+
return { text: text, route: route };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Strip ```json fences the model likes to add, then take the outermost JSON
|
|
538
|
+
// object/array. Returns the parsed value or throws.
|
|
539
|
+
function extractJson(text) {
|
|
540
|
+
var t = String(text || "").trim();
|
|
541
|
+
var fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(t);
|
|
542
|
+
if (fence) t = fence[1].trim();
|
|
543
|
+
var first = t.search(/[[{]/);
|
|
544
|
+
if (first < 0) throw new Error("模型输出里没有 JSON");
|
|
545
|
+
var openCh = t.charAt(first);
|
|
546
|
+
var closeCh = openCh === "{" ? "}" : "]";
|
|
547
|
+
var depth = 0, inStr = false, esc = false, end = -1;
|
|
548
|
+
for (var i = first; i < t.length; i++) {
|
|
549
|
+
var ch = t.charAt(i);
|
|
550
|
+
if (inStr) {
|
|
551
|
+
if (esc) esc = false;
|
|
552
|
+
else if (ch === "\\") esc = true;
|
|
553
|
+
else if (ch === '"') inStr = false;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (ch === '"') { inStr = true; continue; }
|
|
557
|
+
if (ch === openCh) depth++;
|
|
558
|
+
else if (ch === closeCh) { depth--; if (depth === 0) { end = i; break; } }
|
|
559
|
+
}
|
|
560
|
+
if (end < 0) throw new Error("模型输出的 JSON 不完整");
|
|
561
|
+
return JSON.parse(t.slice(first, end + 1));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── AI: auto-classify skills into a two-level (domain -> sub) tree ───────
|
|
565
|
+
var CLASSIFY_SYSTEM =
|
|
566
|
+
"你是技能库分类助手。把给定的技能清单整理成「大类 → 子类」两级中文目录树。只输出 JSON,不要解释。\n" +
|
|
567
|
+
"硬性要求:\n" +
|
|
568
|
+
"1) 每个技能必须归入且只归入一个子类,清单里的技能一个都不能漏。\n" +
|
|
569
|
+
"2) 大类 = 领域(例:Fluent/CFD 仿真、DSH 工具链、软件安装、网关与反代、文档处理);子类 = 该领域内的具体用途。\n" +
|
|
570
|
+
"3) 大类里技能数超过 6 个时,必须拆成 2~6 个子类。示例:Fluent 拆成「前处理」「画网格」「求解与监测」「后处理」「故障排查」;DSH 拆成「启动失败」「插件冲突」「插件开发」「安装经验」;网关拆成「接入配置」「额度与凭据」「故障排查」。\n" +
|
|
571
|
+
"4) 每个子类最多 10 个技能;子类名必须说明具体用途,不能只是重复大类名。\n" +
|
|
572
|
+
"5) 禁止「通用」「其他」「杂项」「综合」「工具集」这类大杂烩名字。\n" +
|
|
573
|
+
"6) 各领域拆分粒度保持一致:不要只细分技能多的领域,技能少的领域同样按用途拆。\n" +
|
|
574
|
+
"7) 大类里技能数少于 4 个时,允许只有 1 个子类(子类名要写清具体用途)。\n" +
|
|
575
|
+
"8) 大类数量按清单规模决定(经验值:技能数 / 12 向上取整,允许 ±2)。大类名不超过 10 个汉字,子类名不超过 12 个汉字。\n" +
|
|
576
|
+
"9) 清单里的「现分类」只是领域边界提示,不要照搬,按实际用途重新归纳。";
|
|
577
|
+
|
|
578
|
+
// Build a classification PLAN without touching groups.json. The client shows it
|
|
579
|
+
// to the user first, then calls applyPlan() with mode replace|merge - "如果对 AI
|
|
580
|
+
// 分组满意就替换原来的组" must be an explicit user decision, never a side effect.
|
|
581
|
+
export async function aiClassify(ctx, cfg, skills, opts) {
|
|
582
|
+
if (!getLlm(ctx)) throw new Error("宿主未提供 llm 服务,AI 功能不可用");
|
|
583
|
+
var o = opts || {};
|
|
584
|
+
var mode = o.mode === "merge" ? "merge" : "replace";
|
|
585
|
+
var catLabelOf = {};
|
|
586
|
+
var cats = categoriesMeta();
|
|
587
|
+
for (var ci = 0; ci < cats.length; ci++) catLabelOf[cats[ci].id] = cats[ci].label;
|
|
588
|
+
|
|
589
|
+
// merge mode: only classify skills that no group owns yet.
|
|
590
|
+
var pool = skills;
|
|
591
|
+
if (mode === "merge") {
|
|
592
|
+
var store0 = loadStore();
|
|
593
|
+
var owned = {};
|
|
594
|
+
for (var od in store0.assign) {
|
|
595
|
+
if (!Object.prototype.hasOwnProperty.call(store0.assign, od)) continue;
|
|
596
|
+
owned[od] = true;
|
|
597
|
+
}
|
|
598
|
+
pool = [];
|
|
599
|
+
for (var pz = 0; pz < skills.length; pz++) if (!owned[skills[pz].dir]) pool.push(skills[pz]);
|
|
600
|
+
if (!pool.length) {
|
|
601
|
+
return { ok: true, plan: { domains: [] }, stats: { skills: 0, domains: 0, subs: 0, covered: 0 }, model: null, note: "所有技能都已在分组里,没有需要追加的技能" };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function buildList(arr) {
|
|
606
|
+
var out = [];
|
|
607
|
+
for (var i = 0; i < arr.length; i++) {
|
|
608
|
+
var s = arr[i];
|
|
609
|
+
var line = s.dir;
|
|
610
|
+
var zh = String(s.zh || "").trim();
|
|
611
|
+
var desc = String(s.desc || "").trim();
|
|
612
|
+
if (zh) line += " | " + zh.slice(0, 60);
|
|
613
|
+
else if (desc) line += " | " + desc.slice(0, 60);
|
|
614
|
+
if (s.cat && s.cat !== "other") line += " | 现分类:" + (catLabelOf[s.cat] || s.cat);
|
|
615
|
+
out.push(line);
|
|
616
|
+
}
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function classifyOnce(arr, already) {
|
|
621
|
+
var list = buildList(arr);
|
|
622
|
+
var want = Math.max(3, Math.min(16, Math.ceil(list.length / 12)));
|
|
623
|
+
var userText =
|
|
624
|
+
(already && already.length
|
|
625
|
+
? "【已完成的大类】" + already.join("、") + "\n这些大类已存在,请勿重复创建,也不要往里加技能。\n"
|
|
626
|
+
: "") +
|
|
627
|
+
"【任务】把下面 " + list.length + " 个技能整理成约 " + want + " 个大类(可 ±2),每个大类下面按用途分 1~6 个子类。\n" +
|
|
628
|
+
"【输出格式】严格 JSON,形如:\n" +
|
|
629
|
+
'{"domains":[{"name":"大类名","emoji":"一个emoji","subs":[{"name":"子类名","emoji":"一个emoji","skills":["技能目录名",...]}]}]}\n' +
|
|
630
|
+
"【硬性要求】每个技能恰好出现一次、一个不漏;子类不超过 10 个技能;禁止「通用/其他/杂项/综合」类名字;各领域拆分粒度一致。\n" +
|
|
631
|
+
"【技能清单】(每行:目录名 | 中文简介 | 现分类)\n" +
|
|
632
|
+
list.join("\n");
|
|
633
|
+
// A full 109-skill tree easily exceeds 4k tokens; truncation would parse-fail
|
|
634
|
+
// and surface as "AI 分组坏了".
|
|
635
|
+
var res = await askModel(ctx, cfg, CLASSIFY_SYSTEM, userText, "skill-browser-classify", 8000);
|
|
636
|
+
var data = extractJson(res.text);
|
|
637
|
+
var raw = data && Array.isArray(data.domains) ? data.domains : [];
|
|
638
|
+
if (!raw.length && data && Array.isArray(data.groups)) raw = legacyToDomains(data.groups);
|
|
639
|
+
return { raw: raw, route: res.route };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
var validDir = {};
|
|
643
|
+
for (var j = 0; j < pool.length; j++) validDir[pool[j].dir] = true;
|
|
644
|
+
|
|
645
|
+
var plan = { domains: [] };
|
|
646
|
+
var taken = {};
|
|
647
|
+
var created = [];
|
|
648
|
+
var routeUsed = null;
|
|
649
|
+
|
|
650
|
+
function newDomain(rg) {
|
|
651
|
+
var nm = sanitizeName(rg.name);
|
|
652
|
+
if (!nm) return null;
|
|
653
|
+
var d = {
|
|
654
|
+
name: nm,
|
|
655
|
+
emoji: String(rg.emoji || "📂").slice(0, 8) || "📂",
|
|
656
|
+
subs: []
|
|
657
|
+
};
|
|
658
|
+
plan.domains.push(d);
|
|
659
|
+
return d;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function addSub(d, rs) {
|
|
663
|
+
var nm = sanitizeName(rs.name) || d.name;
|
|
664
|
+
var s = { name: nm, emoji: String(rs.emoji || "📁").slice(0, 8) || "📁", skills: [] };
|
|
665
|
+
var dirs = Array.isArray(rs.skills) ? rs.skills : [];
|
|
666
|
+
for (var p = 0; p < dirs.length; p++) {
|
|
667
|
+
var dd = String(dirs[p] || "").trim();
|
|
668
|
+
if (!validDir[dd]) continue;
|
|
669
|
+
if (taken[dd]) continue; // first sub wins, no duplicates
|
|
670
|
+
taken[dd] = true;
|
|
671
|
+
s.skills.push(dd);
|
|
672
|
+
}
|
|
673
|
+
if (!s.skills.length) return 0;
|
|
674
|
+
// Split an over-long sub so no bin ever exceeds 10 (the model ignores the cap
|
|
675
|
+
// surprisingly often on big catalogues).
|
|
676
|
+
var chunks = Math.ceil(s.skills.length / 10);
|
|
677
|
+
for (var ch = 0; ch < chunks; ch++) {
|
|
678
|
+
var slice = s.skills.slice(ch * 10, ch * 10 + 10);
|
|
679
|
+
var nm2 = chunks > 1 ? sanitizeName(s.name + " " + (ch + 1)) : s.name;
|
|
680
|
+
d.subs.push({ name: nm2, emoji: s.emoji, skills: slice });
|
|
681
|
+
}
|
|
682
|
+
return s.skills.length;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function absorb(rawDomains) {
|
|
686
|
+
for (var n = 0; n < rawDomains.length; n++) {
|
|
687
|
+
var rg = rawDomains[n] || {};
|
|
688
|
+
var subs = Array.isArray(rg.subs) ? rg.subs : [];
|
|
689
|
+
// Legacy/flat reply: a domain with a bare skills[] becomes a 1-sub domain.
|
|
690
|
+
if (!subs.length && Array.isArray(rg.skills)) subs = [{ name: sanitizeName(rg.name) || "未细分", skills: rg.skills }];
|
|
691
|
+
var d = newDomain(rg);
|
|
692
|
+
if (!d) continue;
|
|
693
|
+
var took = 0;
|
|
694
|
+
for (var q = 0; q < subs.length; q++) took += addSub(d, subs[q] || {});
|
|
695
|
+
if (!took) { plan.domains.pop(); continue; } // drop empty domains
|
|
696
|
+
created.push({ name: d.name, emoji: d.emoji, subs: d.subs.length, count: took });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function leftoversOf() {
|
|
701
|
+
var out = [];
|
|
702
|
+
for (var r = 0; r < pool.length; r++) if (!taken[pool[r].dir]) out.push(pool[r]);
|
|
703
|
+
return out;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Pass 1: the whole (or unassigned) catalogue.
|
|
707
|
+
var p1 = await classifyOnce(pool, []);
|
|
708
|
+
routeUsed = p1.route;
|
|
709
|
+
if (!p1.raw.length) throw new Error("模型没有给出任何分组");
|
|
710
|
+
absorb(p1.raw);
|
|
711
|
+
|
|
712
|
+
// Pass 2: the model always drops a tail (historically every non-Fluent skill).
|
|
713
|
+
// Re-ask only about the leftovers so they get real semantic groups instead of
|
|
714
|
+
// one "其他" bin.
|
|
715
|
+
var left = leftoversOf();
|
|
716
|
+
if (left.length && plan.domains.length) {
|
|
717
|
+
var names = [];
|
|
718
|
+
for (var cn = 0; cn < plan.domains.length; cn++) names.push(plan.domains[cn].name);
|
|
719
|
+
try {
|
|
720
|
+
var p2 = await classifyOnce(left, names);
|
|
721
|
+
absorb(p2.raw);
|
|
722
|
+
} catch (e) {
|
|
723
|
+
if (ctx.logger && ctx.logger.warn) ctx.logger.warn("dsh-skill-browser: classify pass2 failed: " + String((e && e.message) || e));
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Pass 3 (no model): anything still uncovered falls back to its own category
|
|
728
|
+
// label as a domain, split into 10-skill subs so nothing is silently dropped.
|
|
729
|
+
left = leftoversOf();
|
|
730
|
+
if (left.length) {
|
|
731
|
+
var byCat = {};
|
|
732
|
+
var catOrder = [];
|
|
733
|
+
for (var r2 = 0; r2 < left.length; r2++) {
|
|
734
|
+
var cid = left[r2].cat || "other";
|
|
735
|
+
if (!byCat[cid]) { byCat[cid] = []; catOrder.push(cid); }
|
|
736
|
+
byCat[cid].push(left[r2].dir);
|
|
737
|
+
}
|
|
738
|
+
for (var t = 0; t < catOrder.length; t++) {
|
|
739
|
+
var dl = byCat[catOrder[t]];
|
|
740
|
+
var baseName = catLabelOf[catOrder[t]] || "其他技能";
|
|
741
|
+
var d2 = newDomain({ name: baseName, emoji: "📦" });
|
|
742
|
+
if (!d2) continue;
|
|
743
|
+
var took2 = addSub(d2, { name: "未细分", emoji: "📦", skills: dl });
|
|
744
|
+
if (!took2) { plan.domains.pop(); continue; }
|
|
745
|
+
created.push({ name: d2.name, emoji: d2.emoji, subs: d2.subs.length, count: took2 });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (!created.length) throw new Error("模型返回的分组没有一个匹配到现有技能");
|
|
750
|
+
|
|
751
|
+
var subCount = 0, covered = 0;
|
|
752
|
+
for (var a = 0; a < plan.domains.length; a++) {
|
|
753
|
+
subCount += plan.domains[a].subs.length;
|
|
754
|
+
for (var b = 0; b < plan.domains[a].subs.length; b++) covered += plan.domains[a].subs[b].skills.length;
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
ok: true,
|
|
758
|
+
plan: plan,
|
|
759
|
+
mode: mode,
|
|
760
|
+
stats: { skills: pool.length, domains: plan.domains.length, subs: subCount, covered: covered },
|
|
761
|
+
model: routeUsed
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Turn a flat [{"name","skills":[...]}] reply into the two-level shape.
|
|
766
|
+
function legacyToDomains(groups) {
|
|
767
|
+
var out = [];
|
|
768
|
+
for (var i = 0; i < groups.length; i++) {
|
|
769
|
+
var g = groups[i] || {};
|
|
770
|
+
out.push({
|
|
771
|
+
name: g.name, emoji: g.emoji || "📂",
|
|
772
|
+
subs: [{ name: g.name, emoji: g.emoji || "📁", skills: Array.isArray(g.skills) ? g.skills : [] }]
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
return out;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Persist a plan produced by aiClassify(). mode "replace" drops every AI group
|
|
779
|
+
// (and their assignments) and rebuilds from the plan, keeping the user's manual
|
|
780
|
+
// groups; mode "merge" keeps everything and only adds the plan's groups.
|
|
781
|
+
// NOTE: still touches NOTHING on disk except groups.json.
|
|
782
|
+
export function applyPlan(plan, skills, mode) {
|
|
783
|
+
if (!plan || !Array.isArray(plan.domains) || !plan.domains.length) {
|
|
784
|
+
return { ok: false, error: "分组方案为空" };
|
|
785
|
+
}
|
|
786
|
+
var validDir = {};
|
|
787
|
+
for (var i = 0; i < (skills || []).length; i++) validDir[skills[i].dir] = true;
|
|
788
|
+
|
|
789
|
+
var store = loadStore();
|
|
790
|
+
var keep = [];
|
|
791
|
+
var dropped = {};
|
|
792
|
+
if (mode !== "merge") {
|
|
793
|
+
for (var k = 0; k < store.groups.length; k++) {
|
|
794
|
+
var g0 = store.groups[k];
|
|
795
|
+
if (g0.ai) { dropped[g0.id] = true; continue; } // rebuild AI groups only
|
|
796
|
+
keep.push(g0);
|
|
797
|
+
}
|
|
798
|
+
} else {
|
|
799
|
+
keep = store.groups.slice();
|
|
800
|
+
}
|
|
801
|
+
var nextAssign = {};
|
|
802
|
+
for (var d in store.assign) {
|
|
803
|
+
if (!Object.prototype.hasOwnProperty.call(store.assign, d)) continue;
|
|
804
|
+
if (dropped[store.assign[d]]) continue;
|
|
805
|
+
nextAssign[d] = store.assign[d];
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
var created = [];
|
|
809
|
+
var added = 0;
|
|
810
|
+
function room() { return keep.length < MAX_GROUPS - 2; }
|
|
811
|
+
|
|
812
|
+
for (var n = 0; n < plan.domains.length && room(); n++) {
|
|
813
|
+
var rd = plan.domains[n] || {};
|
|
814
|
+
var dnm = sanitizeName(rd.name);
|
|
815
|
+
if (!dnm) continue;
|
|
816
|
+
var subs = Array.isArray(rd.subs) ? rd.subs : [];
|
|
817
|
+
var domId = newId();
|
|
818
|
+
var dom = {
|
|
819
|
+
id: domId, name: dnm,
|
|
820
|
+
emoji: String(rd.emoji || "📂").slice(0, 8) || "📂",
|
|
821
|
+
color: "", cover: "", ai: true, parent: "", createdAt: Date.now()
|
|
822
|
+
};
|
|
823
|
+
keep.push(dom);
|
|
824
|
+
var domTook = 0;
|
|
825
|
+
var subCount = 0;
|
|
826
|
+
for (var q = 0; q < subs.length && room(); q++) {
|
|
827
|
+
var rs = subs[q] || {};
|
|
828
|
+
var snm = sanitizeName(rs.name);
|
|
829
|
+
if (!snm) continue;
|
|
830
|
+
var dirs = Array.isArray(rs.skills) ? rs.skills : [];
|
|
831
|
+
var usable = [];
|
|
832
|
+
for (var p = 0; p < dirs.length; p++) {
|
|
833
|
+
var dd = String(dirs[p] || "").trim();
|
|
834
|
+
if (!validDir[dd] || nextAssign[dd]) continue;
|
|
835
|
+
usable.push(dd);
|
|
836
|
+
}
|
|
837
|
+
if (!usable.length) continue;
|
|
838
|
+
var sid = newId();
|
|
839
|
+
keep.push({
|
|
840
|
+
id: sid, name: snm,
|
|
841
|
+
emoji: String(rs.emoji || "📁").slice(0, 8) || "📁",
|
|
842
|
+
color: "", cover: "", ai: true, parent: domId, createdAt: Date.now()
|
|
843
|
+
});
|
|
844
|
+
for (var u = 0; u < usable.length; u++) nextAssign[usable[u]] = sid;
|
|
845
|
+
domTook += usable.length;
|
|
846
|
+
subCount++;
|
|
847
|
+
added++;
|
|
848
|
+
}
|
|
849
|
+
if (!domTook) { keep.pop(); continue; } // domain with no usable sub
|
|
850
|
+
created.push({ id: domId, name: dnm, emoji: dom.emoji, subs: subCount, count: domTook });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// merge 模式下"所有技能都已有归属"是正常结果,不是错误 —— 只有 replace
|
|
854
|
+
// 才可能因为"方案里的技能一个都不在库里"而失败。
|
|
855
|
+
if (!created.length) {
|
|
856
|
+
if (mode === "merge") {
|
|
857
|
+
store.groups = keep;
|
|
858
|
+
store.assign = nextAssign;
|
|
859
|
+
var savedNoop = saveStore(store);
|
|
860
|
+
return {
|
|
861
|
+
ok: savedNoop, mode: "merge", domains: 0, subs: 0, groups: [],
|
|
862
|
+
note: "所有技能都已在分组里,本次没有需要补的技能",
|
|
863
|
+
error: savedNoop ? "" : "写入失败"
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
return { ok: false, error: "方案里的技能都不在当前技能库中" };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
store.groups = keep;
|
|
870
|
+
store.assign = nextAssign;
|
|
871
|
+
var saved = saveStore(store);
|
|
872
|
+
return {
|
|
873
|
+
ok: saved, mode: mode === "merge" ? "merge" : "replace",
|
|
874
|
+
domains: created.length, subs: added, groups: created,
|
|
875
|
+
error: saved ? "" : "写入失败"
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ── AI: one-click group cover image (SVG) ───────────────────────────────
|
|
880
|
+
var COVER_SYSTEM =
|
|
881
|
+
"你是图标设计师。为一个技能分组生成一张方形封面图,输出**纯 SVG 源码**," +
|
|
882
|
+
"不要 markdown 代码围栏,不要任何解释文字。要求:viewBox=\"0 0 200 200\"," +
|
|
883
|
+
"深色背景 + 渐变或几何图形 + 居中一个简洁符号,配色高级、扁平、无文字。";
|
|
884
|
+
|
|
885
|
+
// SVG whitelist for AI-generated group covers. Anything outside it is either
|
|
886
|
+
// stripped or rejected outright - a cover is untrusted model output.
|
|
887
|
+
var SVG_TAGS = {
|
|
888
|
+
svg:1, g:1, defs:1, title:1, desc:1, symbol:1, use:0,
|
|
889
|
+
path:1, rect:1, circle:1, ellipse:1, line:1, polyline:1, polygon:1,
|
|
890
|
+
text:1, tspan:1,
|
|
891
|
+
linearGradient:1, radialGradient:1, stop:1, pattern:1, clipPath:1, mask:1,
|
|
892
|
+
filter:1, feGaussianBlur:1, feOffset:1, feBlend:1, feColorMatrix:1, feMerge:1, feMergeNode:1
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
function cleanSvg(text) {
|
|
896
|
+
var t = String(text || "").trim();
|
|
897
|
+
var fence = /```(?:svg|xml)?\s*([\s\S]*?)```/i.exec(t);
|
|
898
|
+
if (fence) t = fence[1].trim();
|
|
899
|
+
var start = t.indexOf("<svg");
|
|
900
|
+
if (start < 0) throw new Error("模型没有返回 SVG");
|
|
901
|
+
var end = t.lastIndexOf("</svg>");
|
|
902
|
+
if (end < 0) throw new Error("模型返回的 SVG 不完整");
|
|
903
|
+
var svg = t.slice(start, end + 6);
|
|
904
|
+
|
|
905
|
+
// 1) Drop whole dangerous subtrees first (paired form), then any self-closing
|
|
906
|
+
// or stray opening form of the same tags.
|
|
907
|
+
svg = svg.replace(/<!--[\s\S]*?-->/g, "");
|
|
908
|
+
var dropTags = ["script", "iframe", "foreignObject", "style", "object", "embed", "audio", "video", "canvas", "image"];
|
|
909
|
+
for (var i = 0; i < dropTags.length; i++) {
|
|
910
|
+
var tg = dropTags[i];
|
|
911
|
+
svg = svg.replace(new RegExp("<" + tg + "\\b[\\s\\S]*?</" + tg + "\\s*>", "gi"), "");
|
|
912
|
+
svg = svg.replace(new RegExp("<" + tg + "\\b[^>]*/?>", "gi"), "");
|
|
913
|
+
}
|
|
914
|
+
// <set>/<animate> can retarget attributes (incl. xlink:href) at runtime.
|
|
915
|
+
svg = svg.replace(/<(?:set|animate|animateTransform|animateMotion|handler)\b[^>]*\/?>/gi, "");
|
|
916
|
+
|
|
917
|
+
// 2) Strip every event-handler attribute regardless of quoting style.
|
|
918
|
+
svg = svg.replace(/\son[a-zA-Z-]+\s*=\s*"[^"]*"/g, "");
|
|
919
|
+
svg = svg.replace(/\son[a-zA-Z-]+\s*=\s*'[^']*'/g, "");
|
|
920
|
+
svg = svg.replace(/\son[a-zA-Z-]+\s*=\s*[^\s>]+/g, "");
|
|
921
|
+
|
|
922
|
+
// 3) href / xlink:href may only be an in-document fragment.
|
|
923
|
+
svg = svg.replace(/\s(?:xlink:href|href)\s*=\s*"(?!#)[^"]*"/gi, "");
|
|
924
|
+
svg = svg.replace(/\s(?:xlink:href|href)\s*=\s*'(?!#)[^']*'/gi, "");
|
|
925
|
+
svg = svg.replace(/\s(?:xlink:href|href)\s*=\s*(?!#)[^\s>]+/gi, "");
|
|
926
|
+
|
|
927
|
+
// 4) No script URLs anywhere (also catches url(javascript:...) in attributes).
|
|
928
|
+
if (/javascript\s*:|vbscript\s*:/i.test(svg)) throw new Error("SVG 含可执行脚本 URL,已拒绝");
|
|
929
|
+
|
|
930
|
+
// 5) Whitelist every remaining element name; unknown tag => reject.
|
|
931
|
+
var tagRe = /<\/?([a-zA-Z][a-zA-Z0-9:-]*)/g;
|
|
932
|
+
var m;
|
|
933
|
+
while ((m = tagRe.exec(svg))) {
|
|
934
|
+
var name = m[1];
|
|
935
|
+
if (!Object.prototype.hasOwnProperty.call(SVG_TAGS, name)) {
|
|
936
|
+
throw new Error("SVG 含非白名单元素 <" + name + ">,已拒绝");
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (svg.length > 40000) throw new Error("SVG 过大(>40KB),已拒绝");
|
|
941
|
+
return svg;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
export async function aiCover(ctx, cfg, group, sampleSkills) {
|
|
945
|
+
if (!getLlm(ctx)) throw new Error("宿主未提供 llm 服务,AI 功能不可用");
|
|
946
|
+
var sample = (sampleSkills || []).slice(0, 12).join("、");
|
|
947
|
+
var userText =
|
|
948
|
+
"分组名称:" + group.name + "\n" +
|
|
949
|
+
(sample ? "包含技能:" + sample + "\n" : "") +
|
|
950
|
+
"请为这个分组生成一张 200x200 的 SVG 封面图。";
|
|
951
|
+
var res = await askModel(ctx, cfg, COVER_SYSTEM, userText, "skill-browser-cover", 2000);
|
|
952
|
+
var svg = cleanSvg(res.text);
|
|
953
|
+
var saved = setGroupCover(group.id, svg);
|
|
954
|
+
if (!saved.ok) throw new Error(saved.error || "封面保存失败");
|
|
955
|
+
return { ok: true, id: group.id, cover: svg, model: res.route };
|
|
956
|
+
}
|