@aiwayds/dsh-topics-memory 0.6.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 +112 -0
- package/README.zh.md +112 -0
- package/cordis.patch.yml +8 -0
- package/lib/commands.d.ts +21 -0
- package/lib/commands.js +368 -0
- package/lib/config.d.ts +122 -0
- package/lib/config.js +123 -0
- package/lib/delegation.d.ts +23 -0
- package/lib/delegation.js +21 -0
- package/lib/digest.d.ts +42 -0
- package/lib/digest.js +101 -0
- package/lib/distill.d.ts +186 -0
- package/lib/distill.js +759 -0
- package/lib/git.d.ts +66 -0
- package/lib/git.js +169 -0
- package/lib/ilog.d.ts +53 -0
- package/lib/ilog.js +70 -0
- package/lib/index.d.ts +73 -0
- package/lib/index.js +504 -0
- package/lib/observer.d.ts +41 -0
- package/lib/observer.js +128 -0
- package/lib/okf.d.ts +112 -0
- package/lib/okf.js +279 -0
- package/lib/onboard.d.ts +205 -0
- package/lib/onboard.js +664 -0
- package/lib/paths.d.ts +48 -0
- package/lib/paths.js +84 -0
- package/lib/retrieval.d.ts +61 -0
- package/lib/retrieval.js +178 -0
- package/lib/service.d.ts +96 -0
- package/lib/service.js +375 -0
- package/lib/store.d.ts +153 -0
- package/lib/store.js +489 -0
- package/lib/sync.d.ts +52 -0
- package/lib/sync.js +154 -0
- package/lib/tools.d.ts +8 -0
- package/lib/tools.js +208 -0
- package/lib/viz.d.ts +33 -0
- package/lib/viz.js +310 -0
- package/lib/yaml.d.ts +18 -0
- package/lib/yaml.js +334 -0
- package/package.json +97 -0
- package/src/commands.ts +397 -0
- package/src/config.ts +149 -0
- package/src/delegation.ts +27 -0
- package/src/digest.ts +125 -0
- package/src/distill.ts +885 -0
- package/src/git.ts +209 -0
- package/src/ilog.ts +99 -0
- package/src/index.ts +574 -0
- package/src/observer.ts +150 -0
- package/src/okf.ts +317 -0
- package/src/onboard.ts +762 -0
- package/src/paths.ts +104 -0
- package/src/retrieval.ts +219 -0
- package/src/service.ts +400 -0
- package/src/store.ts +567 -0
- package/src/sync.ts +170 -0
- package/src/tools.ts +225 -0
- package/src/viz.ts +333 -0
- package/src/yaml.ts +322 -0
package/lib/onboard.js
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/topics onboard` — an a/b/c configuration wizard that walks a new user
|
|
3
|
+
* through the five decisions that matter (storage mode, GitHub repo, distill
|
|
4
|
+
* model, injection budget, auto-observe) one command at a time.
|
|
5
|
+
*
|
|
6
|
+
* Design (ADR 0009):
|
|
7
|
+
* - one invocation = one step: the wizard renders the current question with
|
|
8
|
+
* lettered options; the answer arrives as the next invocation's argument
|
|
9
|
+
* (`/topics onboard b`). Works unchanged on every surface (tui, web, feishu,
|
|
10
|
+
* headless one-shots) because it never needs intra-turn interactivity.
|
|
11
|
+
* - answers accumulate in a pending batch; only the final confirm step
|
|
12
|
+
* writes settings — quitting midway leaves the config untouched.
|
|
13
|
+
* - the step machine is pure (`applyAnswer` / `renderStep`), so tests drive
|
|
14
|
+
* it without spawning `gh`; the handler injects the login detector.
|
|
15
|
+
*
|
|
16
|
+
* @module onboard
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { pickLlmCandidate } from "./distill.js";
|
|
20
|
+
import { CONFIG_KEYS, parseConfigValue } from "./config.js";
|
|
21
|
+
/** CamelCase → dash-display, shared with /topics config rendering. */
|
|
22
|
+
export function displayKey(key) {
|
|
23
|
+
return key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
const FOOTER = '→ 回复 /topics onboard a|b|c(自定义项把值直接打在后面);quit 退出不写入';
|
|
26
|
+
/**
|
|
27
|
+
* Suggested remote repo name. Deliberately NOT `dsh-topics-memory`: that is
|
|
28
|
+
* the plugin's own source repository, and the default data repo must never
|
|
29
|
+
* collide with it (ADR 0002/0009).
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_TOPICS_REPO = 'dsh-topics-data';
|
|
32
|
+
export function freshState() {
|
|
33
|
+
return { step: 'mode', pending: {}, pathHasRepo: false };
|
|
34
|
+
}
|
|
35
|
+
/** Human-facing step sequence for the progress marker, honoring the path taken. */
|
|
36
|
+
function sequence(state) {
|
|
37
|
+
return state.pathHasRepo ? ['mode', 'repo', 'distill', 'inject', 'observe'] : ['mode', 'distill', 'inject', 'observe'];
|
|
38
|
+
}
|
|
39
|
+
function header(state, title) {
|
|
40
|
+
if (state.step === 'confirm')
|
|
41
|
+
return `🧭 topics 配置向导(确认)—— ${title}`;
|
|
42
|
+
const seq = sequence(state);
|
|
43
|
+
const index = seq.indexOf(state.step);
|
|
44
|
+
const total = seq.length;
|
|
45
|
+
const at = index === -1 ? total : index + 1;
|
|
46
|
+
return `🧭 topics 配置向导(${at}/${total})—— ${title}`;
|
|
47
|
+
}
|
|
48
|
+
/** Render the current step against live config overlaid with pending answers. */
|
|
49
|
+
export function renderStep(state, cfg) {
|
|
50
|
+
const cur = { ...cfg, ...state.pending };
|
|
51
|
+
switch (state.step) {
|
|
52
|
+
case 'mode':
|
|
53
|
+
return [
|
|
54
|
+
header(state, '存储模式'),
|
|
55
|
+
`当前:${cur.repo === '' ? 'local-only(数据只在本地 bundle)' : `GitHub 同步(${cur.repo})`}`,
|
|
56
|
+
' a. local-only —— 零配置零凭据,先本地用起来(推荐)',
|
|
57
|
+
' b. GitHub 同步 —— 指定私有仓,写穿 + 去抖推送,跨机共享',
|
|
58
|
+
' c. 跳过这项,保持现状',
|
|
59
|
+
'',
|
|
60
|
+
FOOTER,
|
|
61
|
+
].join('\n');
|
|
62
|
+
case 'repo':
|
|
63
|
+
return [
|
|
64
|
+
header(state, 'GitHub 仓库'),
|
|
65
|
+
`当前:${cur.repo === '' ? '未设置' : cur.repo}`,
|
|
66
|
+
' a. 自动探测 gh 登录名,仓库用 <登录名>/' + DEFAULT_TOPICS_REPO,
|
|
67
|
+
' b. 自定义:把 owner/name 直接打在命令里,如 /topics onboard myname/my-wiki',
|
|
68
|
+
' c. 跳过(留在 local-only)',
|
|
69
|
+
'',
|
|
70
|
+
FOOTER,
|
|
71
|
+
].join('\n');
|
|
72
|
+
case 'distill':
|
|
73
|
+
return [
|
|
74
|
+
header(state, '后台蒸馏模型'),
|
|
75
|
+
`当前:${cur.distillProvider !== '' && cur.distillModel !== '' ? `${cur.distillProvider} / ${cur.distillModel}` : '未配置(观察只积累,不自动蒸馏成 Topic)'}`,
|
|
76
|
+
' a. 暂不开 —— 先用一阵,随时 /topics set distill-provider 再开(推荐)',
|
|
77
|
+
' b. 配置:把 provider model 打在命令里,如 /topics onboard zai-coding-cn glm-4.7-air(也支持 provider/model)',
|
|
78
|
+
' c. 跳过',
|
|
79
|
+
'',
|
|
80
|
+
FOOTER,
|
|
81
|
+
].join('\n');
|
|
82
|
+
case 'inject':
|
|
83
|
+
return [
|
|
84
|
+
header(state, '注入档位'),
|
|
85
|
+
`当前:topK ${cur.topK} / 总预算 ${cur.totalBudget} tok / 阈值 ${cur.matchThreshold}`,
|
|
86
|
+
`会话级去重:${cur.injectDedup ? '开(同会话已注入的 Topic 不重注)' : '关'}`,
|
|
87
|
+
' a. 保守 —— topK 2,总预算 800 tok(少而准,token 敏感选这个)',
|
|
88
|
+
' b. 标准 —— topK 4,总预算 1500 tok(默认,推荐)',
|
|
89
|
+
' c. 放量 —— topK 6,总预算 2500 tok(记忆多、上下文宽裕)',
|
|
90
|
+
'',
|
|
91
|
+
FOOTER,
|
|
92
|
+
].join('\n');
|
|
93
|
+
case 'observe':
|
|
94
|
+
return [
|
|
95
|
+
header(state, '自动观察'),
|
|
96
|
+
`当前:${cur.autoObserve ? `开(每轮自动抓原子观察,每侧 ≤${cur.observationMaxChars} 字)` : '关(只手动 topic_save / topic_observe)'}`,
|
|
97
|
+
' a. 保持开(推荐)—— 随手聊就被记录,后台蒸馏成 Topic',
|
|
98
|
+
' b. 关 —— 只在你明确调用工具时记录',
|
|
99
|
+
' c. 跳过',
|
|
100
|
+
'',
|
|
101
|
+
FOOTER,
|
|
102
|
+
].join('\n');
|
|
103
|
+
case 'confirm': {
|
|
104
|
+
const keys = CONFIG_KEYS.filter((k) => k in state.pending);
|
|
105
|
+
if (keys.length === 0) {
|
|
106
|
+
return '向导结束——本次没有选择任何改动,配置保持原样。\n重新 /topics onboard 可再来;/topics set 可微调单项。';
|
|
107
|
+
}
|
|
108
|
+
return [
|
|
109
|
+
header(state, '待写入 settings(topics namespace)'),
|
|
110
|
+
...keys.map((k) => ` ${displayKey(k)} = ${String(state.pending[k])}`),
|
|
111
|
+
'(未列出的项保持现状)',
|
|
112
|
+
'',
|
|
113
|
+
' a. 确认写入 —— 下次会话启动后生效',
|
|
114
|
+
' b. 放弃,不写入任何改动',
|
|
115
|
+
].join('\n');
|
|
116
|
+
}
|
|
117
|
+
case 'done':
|
|
118
|
+
return '向导已结束。重新 /topics onboard 可再来;/topics config 查看当前配置。';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Pure step machine: consume one answer, return the next state. The caller
|
|
123
|
+
* intercepts control answers (quit) and confirm-write before calling this.
|
|
124
|
+
*/
|
|
125
|
+
export function applyAnswer(state, answer, cfg) {
|
|
126
|
+
const letter = /^[a-c]$/i.exec(answer)?.[0]?.toLowerCase();
|
|
127
|
+
const next = (patch) => ({ ...state, ...patch });
|
|
128
|
+
switch (state.step) {
|
|
129
|
+
case 'mode': {
|
|
130
|
+
if (letter === 'b')
|
|
131
|
+
return { state: next({ step: 'repo', pathHasRepo: true }) };
|
|
132
|
+
if (letter === 'a' || letter === 'c')
|
|
133
|
+
return { state: next({ step: 'distill', pathHasRepo: false }) };
|
|
134
|
+
return { state, error: '请回复 a(local-only)、b(GitHub 同步)或 c(跳过)' };
|
|
135
|
+
}
|
|
136
|
+
case 'repo': {
|
|
137
|
+
if (letter === 'c')
|
|
138
|
+
return { state: next({ step: 'distill' }) };
|
|
139
|
+
const parsed = parseConfigValue('repo', answer);
|
|
140
|
+
if (typeof parsed === 'object' && parsed !== null && 'error' in parsed)
|
|
141
|
+
return { state, error: `${parsed.error}(或回复 c 跳过)` };
|
|
142
|
+
return { state: next({ step: 'distill', pending: { ...state.pending, repo: parsed } }) };
|
|
143
|
+
}
|
|
144
|
+
case 'distill': {
|
|
145
|
+
if (letter === 'a' || letter === 'c')
|
|
146
|
+
return { state: next({ step: 'inject' }) };
|
|
147
|
+
// Shared with the /topics set mixed-value split: space form wins so model
|
|
148
|
+
// ids may themselves contain slashes (`openrouter meta-llama/llama-3`).
|
|
149
|
+
const parsed = parseDistillInput(answer);
|
|
150
|
+
if ('error' in parsed)
|
|
151
|
+
return { state, error: parsed.error };
|
|
152
|
+
return { state: next({ step: 'inject', pending: { ...state.pending, distillProvider: parsed.provider, distillModel: parsed.model } }) };
|
|
153
|
+
}
|
|
154
|
+
case 'inject': {
|
|
155
|
+
if (letter === 'a')
|
|
156
|
+
return { state: next({ step: 'observe', pending: { ...state.pending, topK: 2, totalBudget: 800 } }) };
|
|
157
|
+
if (letter === 'b')
|
|
158
|
+
return { state: next({ step: 'observe', pending: { ...state.pending, topK: 4, totalBudget: 1500 } }) };
|
|
159
|
+
if (letter === 'c')
|
|
160
|
+
return { state: next({ step: 'observe' }) };
|
|
161
|
+
return { state, error: '请回复 a(保守)、b(标准)或 c(放量)' };
|
|
162
|
+
}
|
|
163
|
+
case 'observe': {
|
|
164
|
+
if (letter === 'a')
|
|
165
|
+
return { state: next({ step: 'confirm', pending: { ...state.pending, autoObserve: true } }) };
|
|
166
|
+
if (letter === 'b')
|
|
167
|
+
return { state: next({ step: 'confirm', pending: { ...state.pending, autoObserve: false } }) };
|
|
168
|
+
if (letter === 'c')
|
|
169
|
+
return { state: next({ step: 'confirm' }) };
|
|
170
|
+
return { state, error: '请回复 a(开)、b(关)或 c(跳过)' };
|
|
171
|
+
}
|
|
172
|
+
case 'confirm':
|
|
173
|
+
return { state, error: '确认页只认 a(写入)或 b(放弃)' };
|
|
174
|
+
case 'done':
|
|
175
|
+
return { state: freshState() };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Settings ops for the pending batch, in stable CONFIG_KEYS order. */
|
|
179
|
+
export function confirmOps(pending) {
|
|
180
|
+
return CONFIG_KEYS.filter((k) => k in pending).map((k) => ({ op: 'set', path: [k], value: pending[k] }));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* `gh api user --jq .login` with a hard timeout; undefined on any failure
|
|
184
|
+
* (gh missing, not logged in, slow). Never throws.
|
|
185
|
+
*/
|
|
186
|
+
export function detectGithubLogin(timeoutMs = 5000) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
let settled = false;
|
|
189
|
+
const done = (login) => {
|
|
190
|
+
if (settled)
|
|
191
|
+
return;
|
|
192
|
+
settled = true;
|
|
193
|
+
resolve(login);
|
|
194
|
+
};
|
|
195
|
+
try {
|
|
196
|
+
const child = spawn('gh', ['api', 'user', '--jq', '.login'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
197
|
+
let out = '';
|
|
198
|
+
child.stdout?.on('data', (chunk) => {
|
|
199
|
+
out += String(chunk);
|
|
200
|
+
});
|
|
201
|
+
const timer = setTimeout(() => {
|
|
202
|
+
child.kill('SIGKILL');
|
|
203
|
+
done(undefined);
|
|
204
|
+
}, timeoutMs);
|
|
205
|
+
child.on('error', () => {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
done(undefined);
|
|
208
|
+
});
|
|
209
|
+
child.on('close', (code) => {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
const login = out.trim();
|
|
212
|
+
done(code === 0 && /^[A-Za-z0-9-]+$/.test(login) ? login : undefined);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
done(undefined);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const QUIT = /^(q|quit|exit|退出)$/i;
|
|
221
|
+
/**
|
|
222
|
+
* First candidate with a non-empty live route table wins. Distinguishes
|
|
223
|
+
* 「有实例但列表空」(service present, zero enabled providers) from
|
|
224
|
+
* 「均不可用」(nothing reachable) so each gets its own user hint.
|
|
225
|
+
*/
|
|
226
|
+
export function pickLlmDirectory(candidates) {
|
|
227
|
+
const { llm, probed } = pickLlmCandidate(candidates, (providers) => providers.length > 0);
|
|
228
|
+
if (llm !== undefined)
|
|
229
|
+
return { kind: 'ok', llm: llm };
|
|
230
|
+
return probed > 0 ? { kind: 'empty-directory' } : { kind: 'no-instance' };
|
|
231
|
+
}
|
|
232
|
+
/** One-line why-not for the distill pick panels; undefined when usable. */
|
|
233
|
+
export function llmDirectoryNotice(pick) {
|
|
234
|
+
if (pick.kind === 'ok')
|
|
235
|
+
return undefined;
|
|
236
|
+
if (pick.kind === 'empty-directory') {
|
|
237
|
+
return 'llm 服务在,但本机没有已启用的模型 provider(先在 dsh 配置中启用模型适配器)';
|
|
238
|
+
}
|
|
239
|
+
return '本机未检测到可用模型路由(llm 服务缺失或会话作用域已释放)';
|
|
240
|
+
}
|
|
241
|
+
/** Build one ask() closure that rides the invocation's agent/signal context. */
|
|
242
|
+
export function makePanel(ask, agent, signal) {
|
|
243
|
+
return async (questions) => {
|
|
244
|
+
const r = await ask.ask({
|
|
245
|
+
questions,
|
|
246
|
+
...(agent === undefined ? {} : { agent }),
|
|
247
|
+
...(signal === undefined ? {} : { signal }),
|
|
248
|
+
});
|
|
249
|
+
// Normalize: tolerate providers that omit `selected` on pure-custom answers.
|
|
250
|
+
return (r.answers ?? []).map((a) => ({ ...a, selected: a.selected ?? [] }));
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** provider model(空格)或 provider/model(斜杠);model 本身可含斜杠。 */
|
|
254
|
+
export function parseDistillInput(raw) {
|
|
255
|
+
let provider;
|
|
256
|
+
let model;
|
|
257
|
+
if (/\s/.test(raw.trim())) {
|
|
258
|
+
const split = raw.trim().split(/\s+/);
|
|
259
|
+
provider = split[0] ?? '';
|
|
260
|
+
model = split.slice(1).join(' ');
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const slash = raw.indexOf('/');
|
|
264
|
+
provider = slash === -1 ? raw : raw.slice(0, slash);
|
|
265
|
+
model = slash === -1 ? '' : raw.slice(slash + 1);
|
|
266
|
+
}
|
|
267
|
+
provider = provider.trim();
|
|
268
|
+
model = model.trim();
|
|
269
|
+
if (provider === '' || model === '' || /\s/.test(provider) || /\s/.test(model)) {
|
|
270
|
+
return { error: '蒸馏模型格式:provider model(空格分隔)或 provider/model(斜杠分隔),如 zai-coding-cn glm-4.7-air' };
|
|
271
|
+
}
|
|
272
|
+
return { provider, model };
|
|
273
|
+
}
|
|
274
|
+
function answerFor(answers, id) {
|
|
275
|
+
return answers?.find((a) => a.id === id);
|
|
276
|
+
}
|
|
277
|
+
/** Blank item = the UI's "skipped" encoding: keep the current value. */
|
|
278
|
+
export function isAnswered(a) {
|
|
279
|
+
return a !== undefined && !(a.selected.length === 0 && (a.custom === undefined || a.custom.trim() === ''));
|
|
280
|
+
}
|
|
281
|
+
/** Custom input wins over a selected option (both may arrive). */
|
|
282
|
+
function pickText(a) {
|
|
283
|
+
return (a.custom !== undefined && a.custom.trim() !== '' ? a.custom : a.selected[0] ?? '').trim();
|
|
284
|
+
}
|
|
285
|
+
const SKIP_LABEL = '暂不启用蒸馏(跳过)';
|
|
286
|
+
const MAX_PANEL_OPTIONS = 10;
|
|
287
|
+
const MAX_MODEL_OPTIONS = 8;
|
|
288
|
+
/** Safe catalog reads: the advisory directory must never break the wizard. */
|
|
289
|
+
function safeProviders(llm) {
|
|
290
|
+
try {
|
|
291
|
+
return [...llm.listProviders()];
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function safeModels(llm, provider) {
|
|
298
|
+
try {
|
|
299
|
+
return [...(await llm.listModels(provider))];
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Panel P — which provider owns the distill route? Options = live ids (+ skip in the wizard). */
|
|
306
|
+
function distillProviderQuestion(llm, current, notice, header, withSkip) {
|
|
307
|
+
const providers = safeProviders(llm);
|
|
308
|
+
const listed = providers.slice(0, MAX_PANEL_OPTIONS - (withSkip ? 1 : 0));
|
|
309
|
+
return {
|
|
310
|
+
id: 'distill-provider',
|
|
311
|
+
header,
|
|
312
|
+
question: '后台蒸馏用哪个 provider?',
|
|
313
|
+
detail: [
|
|
314
|
+
`当前:${current === '' ? '未启用蒸馏(观察只积累,不自动蒸馏成 Topic)' : current}`,
|
|
315
|
+
...(notice === undefined ? [] : [`⚠️ ${notice}`]),
|
|
316
|
+
...(providers.length > listed.length ? [`共 ${providers.length} 个已启用 provider(列出前 ${listed.length}),其他选 Other 手输 provider id`] : []),
|
|
317
|
+
].join('\n'),
|
|
318
|
+
options: [
|
|
319
|
+
...listed.map((p) => ({ label: p.id, description: p.name })),
|
|
320
|
+
...(withSkip ? [{ label: SKIP_LABEL, description: '先不启用蒸馏,随时 /topics set distill-provider 再开' }] : []),
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** Panel M — which model on that provider? Options = top of the advisory catalog + custom. */
|
|
325
|
+
async function distillModelQuestion(llm, provider, current, header, notice) {
|
|
326
|
+
const models = await safeModels(llm, provider);
|
|
327
|
+
const listed = models.slice(0, MAX_MODEL_OPTIONS);
|
|
328
|
+
return {
|
|
329
|
+
id: 'distill-model',
|
|
330
|
+
header,
|
|
331
|
+
question: `蒸馏用 ${provider} 的哪个 model?`,
|
|
332
|
+
detail: [
|
|
333
|
+
`当前 model:${current === '' ? '未设置' : current}`,
|
|
334
|
+
...(notice === undefined ? [] : [`⚠️ ${notice}`]),
|
|
335
|
+
`该 provider 目录共 ${models.length} 个模型${models.length > listed.length ? `(列出前 ${listed.length})` : ''};目录仅供参考,其他选 Other 手输 model id`,
|
|
336
|
+
].join('\n'),
|
|
337
|
+
options: listed.map((m) => ({ label: m.id, description: m.description === undefined ? m.name : `${m.name} — ${m.description}` })),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const WIZARD_HEADER = 'topics 配置向导(2/4)';
|
|
341
|
+
const SET_HEADER = 'topics distill 配置';
|
|
342
|
+
/**
|
|
343
|
+
* Two dependent panels — provider first, then that provider's models — so the
|
|
344
|
+
* model list can come from the chosen provider's catalog. Answers run through
|
|
345
|
+
* `resolveModelInfo` before entering the pending batch: NO_ADAPTER blocks and
|
|
346
|
+
* re-asks (bounded), any other rejection (model outside the advisory catalog,
|
|
347
|
+
* possibly still usable) warns but allows. Returns undefined when the user
|
|
348
|
+
* skips or blanks the panel (keep current).
|
|
349
|
+
*/
|
|
350
|
+
export async function askDistillRoute(llm, panel, cfg, maxAttempts = 3) {
|
|
351
|
+
let notice;
|
|
352
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
353
|
+
const pAnswers = await panel([distillProviderQuestion(llm, cfg.distillProvider, notice, WIZARD_HEADER, true)]);
|
|
354
|
+
const pPick = answerFor(pAnswers, 'distill-provider');
|
|
355
|
+
if (!isAnswered(pPick) || pickText(pPick) === SKIP_LABEL)
|
|
356
|
+
return undefined;
|
|
357
|
+
const provider = pickText(pPick);
|
|
358
|
+
const mAnswers = await panel([await distillModelQuestion(llm, provider, cfg.distillModel, WIZARD_HEADER)]);
|
|
359
|
+
const mPick = answerFor(mAnswers, 'distill-model');
|
|
360
|
+
if (!isAnswered(mPick))
|
|
361
|
+
return undefined;
|
|
362
|
+
const model = pickText(mPick);
|
|
363
|
+
try {
|
|
364
|
+
await llm.resolveModelInfo(provider, model);
|
|
365
|
+
return { provider, model, unknownModel: false };
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (error.code === 'NO_ADAPTER') {
|
|
369
|
+
notice = `provider ${provider} 当前没有可用的模型路由,请重新选择`;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
// Non-NO_ADAPTER failure: the catalog is advisory — warn, allow.
|
|
373
|
+
return { provider, model, unknownModel: true };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
throw new Error(`多次选择仍没有可用的模型路由(${notice ?? 'NO_ADAPTER'}),未写入任何改动;请确认 provider 已启用后重试 /topics onboard`);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Single-panel variant behind `/topics set distill-provider|distill-model`:
|
|
380
|
+
* one question, current value shown, blank answer = keep. Validation mirrors
|
|
381
|
+
* the wizard (`askDistillRoute`): a provider pick must sit in the live route
|
|
382
|
+
* table (the NO_ADAPTER equivalent — blocked, bounded re-ask); a model pick
|
|
383
|
+
* runs through `resolveModelInfo` — NO_ADAPTER blocks, any other failure
|
|
384
|
+
* warns but allows (model outside the advisory catalog, possibly still
|
|
385
|
+
* usable). Returns the picked value (plus the warning when a failed check was
|
|
386
|
+
* allowed through), or undefined on skip/blank.
|
|
387
|
+
*/
|
|
388
|
+
export async function askDistillSingle(llm, panel, key, cfg, maxAttempts = 3) {
|
|
389
|
+
let notice;
|
|
390
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
391
|
+
const question = key === 'distill-provider'
|
|
392
|
+
? distillProviderQuestion(llm, cfg.distillProvider, notice, SET_HEADER, false)
|
|
393
|
+
: await distillModelQuestion(llm, cfg.distillProvider, cfg.distillModel, SET_HEADER, notice);
|
|
394
|
+
const a = answerFor(await panel([question]), question.id);
|
|
395
|
+
if (!isAnswered(a) || pickText(a) === SKIP_LABEL)
|
|
396
|
+
return undefined;
|
|
397
|
+
const value = pickText(a);
|
|
398
|
+
if (key === 'distill-provider') {
|
|
399
|
+
// Custom ids must still be a live route — the NO_ADAPTER equivalent.
|
|
400
|
+
if (safeProviders(llm).some((p) => p.id === value))
|
|
401
|
+
return { value };
|
|
402
|
+
notice = `provider ${value} 当前没有可用的模型路由,请重新选择`;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
await llm.resolveModelInfo(cfg.distillProvider, value);
|
|
407
|
+
return { value };
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
if (error.code === 'NO_ADAPTER') {
|
|
411
|
+
notice = `provider ${cfg.distillProvider} 当前没有可用的模型路由,请重新选择`;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
value,
|
|
416
|
+
warning: `${value} 未通过 ${cfg.distillProvider} 的模型目录校验(模型目录外,可能仍可用),已放行;若实际不可用可再改`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
throw new Error('多次选择仍没有可用的模型路由,未写入任何改动;请确认 provider 已启用后重试');
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The interactive flow: storage mode, then the distill route as two dependent
|
|
424
|
+
* panels (provider → that provider's models), then a batched panel (repo when
|
|
425
|
+
* GitHub mode, inject tier, observe), then a confirm panel whose detail lists
|
|
426
|
+
* exactly what will be written. A closed panel or a skipped question never
|
|
427
|
+
* writes. Hosts without the llm directory never enter here (the handler gates
|
|
428
|
+
* on it and falls back to the typed wizard).
|
|
429
|
+
*/
|
|
430
|
+
export async function runInteractiveOnboard(service, mutate, ask, detectLogin, agent, signal, llm) {
|
|
431
|
+
const ok = (text) => ({ kind: 'success', text });
|
|
432
|
+
const fail = (text) => ({ kind: 'error', text });
|
|
433
|
+
const panel = makePanel(ask, agent, signal);
|
|
434
|
+
try {
|
|
435
|
+
const cfg = service.cfg;
|
|
436
|
+
// Stage 1 — storage mode (its own panel: the batch depends on it).
|
|
437
|
+
const modeAnswers = await panel([{
|
|
438
|
+
id: 'mode',
|
|
439
|
+
header: 'topics 配置向导(1/4)',
|
|
440
|
+
question: 'Topic 记忆存在哪里?',
|
|
441
|
+
detail: `当前:${cfg.repo === '' ? 'local-only' : `GitHub 同步(${cfg.repo})`}`,
|
|
442
|
+
options: [
|
|
443
|
+
{ label: 'local-only', description: '零配置零凭据,数据只在本地 bundle(推荐)' },
|
|
444
|
+
{ label: 'GitHub 同步', description: '指定私有仓,写穿 + 去抖推送,跨机共享' },
|
|
445
|
+
],
|
|
446
|
+
}]);
|
|
447
|
+
const modePick = answerFor(modeAnswers, 'mode');
|
|
448
|
+
if (!isAnswered(modePick))
|
|
449
|
+
return ok('向导结束——未选择存储模式,配置保持原样。');
|
|
450
|
+
const githubMode = (modePick.selected[0] ?? '').includes('GitHub');
|
|
451
|
+
// Stage 2 — the distill route: two dependent asks (the model list comes
|
|
452
|
+
// from the chosen provider's catalog), pre-validated via resolveModelInfo.
|
|
453
|
+
const route = await askDistillRoute(llm, panel, cfg);
|
|
454
|
+
// Stage 3 — the remaining decisions in one batched panel (tabs / pager).
|
|
455
|
+
const questions = [];
|
|
456
|
+
let suggestedRepo;
|
|
457
|
+
if (githubMode) {
|
|
458
|
+
const login = await detectLogin();
|
|
459
|
+
if (login !== undefined)
|
|
460
|
+
suggestedRepo = `${login}/${DEFAULT_TOPICS_REPO}`;
|
|
461
|
+
questions.push({
|
|
462
|
+
id: 'repo',
|
|
463
|
+
header: 'topics 配置向导(3/4)',
|
|
464
|
+
question: 'GitHub 仓库(owner/name)?',
|
|
465
|
+
detail: `留空跳过 = 留在 local-only。默认建议 ${suggestedRepo ?? `${DEFAULT_TOPICS_REPO}(gh 登录名探测失败,请选 Other 输入完整 owner/name)`}`,
|
|
466
|
+
...(suggestedRepo === undefined ? {} : { options: [{ label: suggestedRepo, description: '自动探测的 gh 登录名 + 默认仓库名' }] }),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
questions.push({
|
|
470
|
+
id: 'inject',
|
|
471
|
+
header: 'topics 配置向导(3/4)',
|
|
472
|
+
question: '注入档位?',
|
|
473
|
+
detail: `当前:topK ${cfg.topK} / 总预算 ${cfg.totalBudget} tok / 阈值 ${cfg.matchThreshold};会话级去重${cfg.injectDedup ? '开(同会话已注入不重注)' : '关'}`,
|
|
474
|
+
options: [
|
|
475
|
+
{ label: '保守(topK 2 · 800 tok)', description: '少而准,token 敏感选这个' },
|
|
476
|
+
{ label: '标准(topK 4 · 1.5k tok)', description: '默认档(推荐)' },
|
|
477
|
+
{ label: '放量(topK 6 · 2.5k tok)', description: '记忆多、上下文宽裕' },
|
|
478
|
+
],
|
|
479
|
+
}, {
|
|
480
|
+
id: 'observe',
|
|
481
|
+
header: 'topics 配置向导(3/4)',
|
|
482
|
+
question: '自动观察?',
|
|
483
|
+
detail: `当前:${cfg.autoObserve ? `开(每轮自动抓原子观察,每侧 ≤${cfg.observationMaxChars} 字)` : '关(只手动 topic_save / topic_observe)'}`,
|
|
484
|
+
options: [
|
|
485
|
+
{ label: '保持开(推荐)', description: '随手聊就被记录,后台蒸馏成 Topic' },
|
|
486
|
+
{ label: '关闭', description: '只在你明确调用工具时记录' },
|
|
487
|
+
],
|
|
488
|
+
});
|
|
489
|
+
const batchAnswers = await panel(questions);
|
|
490
|
+
// Collect into the same pending batch the typed wizard confirms with.
|
|
491
|
+
const pending = {};
|
|
492
|
+
if (githubMode) {
|
|
493
|
+
const a = answerFor(batchAnswers, 'repo');
|
|
494
|
+
if (isAnswered(a)) {
|
|
495
|
+
const raw = (a.custom !== undefined && a.custom.trim() !== '' ? a.custom : a.selected[0] ?? '').trim();
|
|
496
|
+
const parsed = parseConfigValue('repo', raw);
|
|
497
|
+
if (typeof parsed === 'object' && parsed !== null && 'error' in parsed) {
|
|
498
|
+
return fail(`${parsed.error}\n未写入任何改动;稍后可用 /topics set repo <owner/name> 单独设置。`);
|
|
499
|
+
}
|
|
500
|
+
pending.repo = parsed;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (route !== undefined) {
|
|
504
|
+
pending.distillProvider = route.provider;
|
|
505
|
+
pending.distillModel = route.model;
|
|
506
|
+
}
|
|
507
|
+
const inject = answerFor(batchAnswers, 'inject');
|
|
508
|
+
if (isAnswered(inject)) {
|
|
509
|
+
const label = inject.selected[0] ?? '';
|
|
510
|
+
if (label.includes('保守')) {
|
|
511
|
+
pending.topK = 2;
|
|
512
|
+
pending.totalBudget = 800;
|
|
513
|
+
}
|
|
514
|
+
else if (label.includes('放量')) {
|
|
515
|
+
pending.topK = 6;
|
|
516
|
+
pending.totalBudget = 2500;
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
pending.topK = 4;
|
|
520
|
+
pending.totalBudget = 1500;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const observe = answerFor(batchAnswers, 'observe');
|
|
524
|
+
if (isAnswered(observe))
|
|
525
|
+
pending.autoObserve = (observe.selected[0] ?? '').includes('关闭') ? false : true;
|
|
526
|
+
const ops = confirmOps(pending);
|
|
527
|
+
if (ops.length === 0)
|
|
528
|
+
return ok('向导结束——本次没有选择任何改动,配置保持原样。');
|
|
529
|
+
// Stage 4 — confirm with the exact write set in the detail.
|
|
530
|
+
const keys = CONFIG_KEYS.filter((k) => k in pending);
|
|
531
|
+
const confirmAnswers = await panel([{
|
|
532
|
+
id: 'confirm',
|
|
533
|
+
header: 'topics 配置向导(4/4)',
|
|
534
|
+
question: `写入这 ${ops.length} 项配置?(下次会话启动后生效)`,
|
|
535
|
+
detail: [
|
|
536
|
+
...keys.map((k) => `- ${displayKey(k)} = ${String(pending[k])}`),
|
|
537
|
+
...(route?.unknownModel === true ? [`- ⚠️ ${route.model} 未通过 ${route.provider} 的模型目录校验(模型目录外,可能仍可用)——已放行,若实际不可用可再改`] : []),
|
|
538
|
+
].join('\n'),
|
|
539
|
+
options: [
|
|
540
|
+
{ label: '写入', description: '批量写入 settings(topics namespace)' },
|
|
541
|
+
{ label: '放弃', description: '不写入任何改动' },
|
|
542
|
+
],
|
|
543
|
+
}]);
|
|
544
|
+
const confirmPick = answerFor(confirmAnswers, 'confirm');
|
|
545
|
+
if (!isAnswered(confirmPick) || (confirmPick.selected[0] ?? '').includes('放弃')) {
|
|
546
|
+
return ok('已放弃,未写入任何改动。随时 /topics onboard 重新开始。');
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
await mutate(ops);
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
553
|
+
return fail(`写入 settings 失败:${message}`);
|
|
554
|
+
}
|
|
555
|
+
if (pending.repo !== undefined || pending.autoInject !== undefined)
|
|
556
|
+
service.invalidate();
|
|
557
|
+
return ok([
|
|
558
|
+
`✅ 已写入 topics 配置(${ops.length} 项):`,
|
|
559
|
+
...keys.map((k) => ` ${displayKey(k)} = ${String(pending[k])}`),
|
|
560
|
+
'下次会话启动后生效。',
|
|
561
|
+
'之后:/topics status 看健康;/topics stats 看注入命中;会话里说「记住…」就会沉淀 Topic。',
|
|
562
|
+
].join('\n'));
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
const code = error.code;
|
|
566
|
+
if (code === 'ASK_CANCELLED' || code === 'ASK_ABORTED') {
|
|
567
|
+
return ok('已取消配置向导,未写入任何改动。随时 /topics onboard 重新开始。');
|
|
568
|
+
}
|
|
569
|
+
if (code === 'ASK_MISSING_AGENT') {
|
|
570
|
+
return fail('当前 surface 的 ask-user 需要会话上下文,无法从命令发起。请改用 /topics set,或在 TUI / 浏览器会话里重跑 /topics onboard。');
|
|
571
|
+
}
|
|
572
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
573
|
+
return fail(`配置向导中断:${message}\n未写入任何改动。`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* The `/topics onboard <args>` handler. One instance per command registration —
|
|
578
|
+
* wizard state lives in the closure, so tests get a fresh wizard per
|
|
579
|
+
* buildTopicsCommand() and concurrent surfaces don't share progress.
|
|
580
|
+
*
|
|
581
|
+
* Primary path is the native ask-user seam when BOTH providers are present:
|
|
582
|
+
* `resolveAsk()` (TUI panel, web composer, feishu card — whichever surface
|
|
583
|
+
* owns the slot, with dsh-ask-router optionally fanning out) and a usable
|
|
584
|
+
* `resolveLlm()` candidate (the first with a non-empty live route table,
|
|
585
|
+
* feeding the distill provider/model panels). Either one missing — bare
|
|
586
|
+
* hosts, no UI, no model route — falls back to the typed wizard.
|
|
587
|
+
*/
|
|
588
|
+
export function createOnboardHandler(service, mutate, detectLogin = detectGithubLogin, resolveAsk = () => undefined, resolveLlm = () => []) {
|
|
589
|
+
let state = freshState();
|
|
590
|
+
const ok = (text) => ({ kind: 'success', text });
|
|
591
|
+
const fail = (text) => ({ kind: 'error', text });
|
|
592
|
+
const advance = (answer) => {
|
|
593
|
+
const r = applyAnswer(state, answer, service.cfg);
|
|
594
|
+
if (r.error !== undefined)
|
|
595
|
+
return fail(`${r.error}\n\n${renderStep(state, service.cfg)}`);
|
|
596
|
+
state = r.state;
|
|
597
|
+
return ok(renderStep(state, service.cfg));
|
|
598
|
+
};
|
|
599
|
+
return async (args, invocation) => {
|
|
600
|
+
const input = args.join(' ').trim();
|
|
601
|
+
if (input === '') {
|
|
602
|
+
// Bare `/topics onboard` with a live ask-user provider AND a usable llm
|
|
603
|
+
// directory (first candidate with a non-empty route table) opens the
|
|
604
|
+
// native panel flow; explicit args always mean typed-wizard answers.
|
|
605
|
+
const askService = resolveAsk();
|
|
606
|
+
const pick = pickLlmDirectory(resolveLlm());
|
|
607
|
+
if (askService !== undefined && typeof askService.ask === 'function' && pick.kind === 'ok') {
|
|
608
|
+
const invocationLike = invocation;
|
|
609
|
+
return runInteractiveOnboard(service, mutate, askService, detectLogin, invocationLike.agent, invocationLike.signal, pick.llm);
|
|
610
|
+
}
|
|
611
|
+
if (state.step === 'done')
|
|
612
|
+
state = freshState();
|
|
613
|
+
// Typed fallback; when only the model route is missing, say why the
|
|
614
|
+
// pick panels did not open.
|
|
615
|
+
const notice = llmDirectoryNotice(pick);
|
|
616
|
+
return ok(notice === undefined ? renderStep(state, service.cfg) : `${notice}\n${renderStep(state, service.cfg)}`);
|
|
617
|
+
}
|
|
618
|
+
if (QUIT.test(input)) {
|
|
619
|
+
state = freshState();
|
|
620
|
+
return ok('已退出配置向导,未写入任何改动。随时 /topics onboard 重新开始。');
|
|
621
|
+
}
|
|
622
|
+
if (state.step === 'done')
|
|
623
|
+
state = freshState();
|
|
624
|
+
if (state.step === 'confirm') {
|
|
625
|
+
const ops = confirmOps(state.pending);
|
|
626
|
+
if (ops.length === 0) {
|
|
627
|
+
state = freshState();
|
|
628
|
+
return ok('向导结束——本次没有选择任何改动,配置保持原样。随时 /topics onboard 重新开始。');
|
|
629
|
+
}
|
|
630
|
+
if (/^a$/i.test(input)) {
|
|
631
|
+
try {
|
|
632
|
+
await mutate(ops);
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
636
|
+
return fail(`写入 settings 失败:${message}\n\n${renderStep(state, service.cfg)}`);
|
|
637
|
+
}
|
|
638
|
+
if (ops.some((o) => o.path[0] === 'repo' || o.path[0] === 'autoInject'))
|
|
639
|
+
service.invalidate();
|
|
640
|
+
const keys = ops.map((o) => ` ${displayKey(String(o.path[0]))} = ${String(o.value)}`);
|
|
641
|
+
state = freshState();
|
|
642
|
+
return ok([
|
|
643
|
+
`✅ 已写入 topics 配置(${ops.length} 项):`,
|
|
644
|
+
...keys,
|
|
645
|
+
'下次会话启动后生效。',
|
|
646
|
+
'之后:/topics status 看健康;/topics stats 看注入命中;会话里说「记住…」就会沉淀 Topic。',
|
|
647
|
+
].join('\n'));
|
|
648
|
+
}
|
|
649
|
+
if (/^b$/i.test(input)) {
|
|
650
|
+
state = freshState();
|
|
651
|
+
return ok('已放弃,未写入任何改动。随时 /topics onboard 重新开始。');
|
|
652
|
+
}
|
|
653
|
+
return fail(`确认页只认 a(写入)或 b(放弃)。\n\n${renderStep(state, service.cfg)}`);
|
|
654
|
+
}
|
|
655
|
+
if (state.step === 'repo' && /^a$/i.test(input)) {
|
|
656
|
+
const login = await detectLogin();
|
|
657
|
+
if (login === undefined) {
|
|
658
|
+
return fail('探测 gh 登录名失败(gh 未登录或不可用)。直接输入 owner/name,或回复 c 跳过。');
|
|
659
|
+
}
|
|
660
|
+
return advance(`${login}/${DEFAULT_TOPICS_REPO}`);
|
|
661
|
+
}
|
|
662
|
+
return advance(input);
|
|
663
|
+
};
|
|
664
|
+
}
|