dsh-skill-hub 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +1 -1
- package/README.md +181 -249
- package/README.zh.md +275 -0
- package/lib/client.js +395 -257
- package/lib/client.js.map +1 -1
- package/lib/index.js +126 -26
- package/lib/types/client/SkillHubSettingsCard.d.ts +23 -17
- package/lib/types/client/api.d.ts +9 -3
- package/lib/types/client/grouping.d.ts +3 -0
- package/lib/types/client/index.d.ts +12 -6
- package/lib/types/client/locales.d.ts +8 -4
- package/lib/types/client/market-catalog.d.ts +1 -1
- package/lib/types/client/panel/MarketView.d.ts +5 -5
- package/lib/types/client/panel/SkillHubPanel.d.ts +1 -1
- package/lib/types/client/panel/SourcesView.d.ts +5 -3
- package/lib/types/client/panel/useSkillHub.d.ts +4 -0
- package/lib/types/client/settings-card.d.ts +5 -4
- package/lib/types/index.d.ts +12 -1
- package/lib/types/protocol.d.ts +23 -0
- package/lib/types/skillfs.d.ts +1 -1
- package/lib/types/update.d.ts +1 -1
- package/package.json +28 -27
- package/src/client/SkillHubSettingsCard.tsx +25 -14
- package/src/client/api.ts +9 -6
- package/src/client/grouping.test.ts +12 -0
- package/src/client/grouping.ts +10 -1
- package/src/client/index.tsx +25 -19
- package/src/client/locales.ts +16 -8
- package/src/client/market-catalog.ts +1 -1
- package/src/client/panel/MarketView.tsx +77 -64
- package/src/client/panel/SkillHubPanel.tsx +18 -1
- package/src/client/panel/SkillRow.tsx +17 -2
- package/src/client/panel/SourcesView.tsx +92 -5
- package/src/client/panel/panel.module.css +9 -1
- package/src/client/panel/useSkillHub.ts +25 -11
- package/src/client/settings-card.tsx +5 -4
- package/src/index.ts +78 -31
- package/src/protocol.ts +24 -0
- package/src/routes.test.ts +55 -0
- package/src/routes.ts +90 -10
- package/src/skillfs.ts +1 -1
- package/src/update.ts +1 -1
- package/lib/types/client/api-config-scope.d.ts +0 -48
- package/src/client/api-config-scope.test.ts +0 -79
- package/src/client/api-config-scope.ts +0 -119
package/src/routes.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* JSON body) exactly once, so a new route cannot forget them.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
16
16
|
import { basename, dirname, join } from 'node:path'
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
18
18
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
@@ -293,18 +293,85 @@ async function knownSkillNames(deps: SkillHubRouteDeps): Promise<Set<string>> {
|
|
|
293
293
|
return names
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
/**
|
|
296
|
+
/** 已知工作区条目(dsh 的 workspace.json 表)。 */
|
|
297
|
+
interface WorkspaceEntry {
|
|
298
|
+
path: string
|
|
299
|
+
title: string
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 读取 dsh 的已知工作区清单(~/.dsh/storages/workspace.json 的
|
|
304
|
+
* tables.workspaces 表)。面板默认视图据此合并所有工作区的项目技能;
|
|
305
|
+
* 文件缺失/损坏时返回空清单(回退为仅用户级视图)。
|
|
306
|
+
*/
|
|
307
|
+
async function workspaceEntries(home: string): Promise<WorkspaceEntry[]> {
|
|
308
|
+
try {
|
|
309
|
+
const raw = await readFile(join(home, 'storages', 'workspace.json'), 'utf8')
|
|
310
|
+
const parsed: unknown = JSON.parse(raw)
|
|
311
|
+
const tables = typeof parsed === 'object' && parsed !== null
|
|
312
|
+
? (parsed as Record<string, unknown>).tables as Record<string, unknown> | undefined
|
|
313
|
+
: undefined
|
|
314
|
+
const workspaces = tables !== undefined && typeof tables === 'object'
|
|
315
|
+
? (tables as Record<string, unknown>).workspaces as Record<string, unknown> | undefined
|
|
316
|
+
: undefined
|
|
317
|
+
const entries: WorkspaceEntry[] = []
|
|
318
|
+
if (workspaces !== undefined && typeof workspaces === 'object') {
|
|
319
|
+
for (const record of Object.values(workspaces)) {
|
|
320
|
+
const entry = record as { path?: unknown; title?: unknown } | null
|
|
321
|
+
if (entry !== null && typeof entry === 'object' && typeof entry.path === 'string' && entry.path !== '') {
|
|
322
|
+
entries.push({
|
|
323
|
+
path: entry.path,
|
|
324
|
+
title: typeof entry.title === 'string' && entry.title !== '' ? entry.title : entry.path,
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return entries
|
|
330
|
+
} catch {
|
|
331
|
+
return []
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 项目技能来源(workspace 字段只对它们设置)。 */
|
|
336
|
+
function isProjectSource(source: string): boolean {
|
|
337
|
+
return source === 'project-dsh' || source === 'project-agents'
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Build the full catalog response (shared by catalog/toggle/create handlers).
|
|
342
|
+
* 显式 cwd 只看该工作区;否则合并所有已知工作区(workspace.json)的项目技能
|
|
343
|
+
* + 用户级技能,同名技能先到先得;没有任何工作区时回退为仅用户级视图。
|
|
344
|
+
*/
|
|
297
345
|
async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<CatalogResponse> {
|
|
298
|
-
const lookup = cwd !== undefined ? { cwd } : undefined
|
|
299
|
-
const snapshot = await deps.skills.snapshot(lookup)
|
|
300
346
|
const home = homeOf(deps)
|
|
347
|
+
let workspaces: WorkspaceEntry[]
|
|
348
|
+
if (cwd !== undefined && cwd !== '') {
|
|
349
|
+
workspaces = [{ path: cwd, title: cwd }]
|
|
350
|
+
} else {
|
|
351
|
+
workspaces = await workspaceEntries(home)
|
|
352
|
+
if (workspaces.length === 0) workspaces = [{ path: '', title: '' }]
|
|
353
|
+
}
|
|
354
|
+
const byName = new Map<string, { skill: SkillSummary; workspace?: string; workspaceTitle?: string }>()
|
|
355
|
+
let complete = true
|
|
356
|
+
for (const ws of workspaces) {
|
|
357
|
+
const lookup = ws.path !== '' ? { cwd: ws.path } : undefined
|
|
358
|
+
const snapshot = await deps.skills.snapshot(lookup)
|
|
359
|
+
if (!snapshot.complete) complete = false
|
|
360
|
+
for (const skill of snapshot.skills) {
|
|
361
|
+
if (byName.has(skill.name)) continue
|
|
362
|
+
byName.set(skill.name, {
|
|
363
|
+
skill,
|
|
364
|
+
...(isProjectSource(skill.source) && ws.path !== '' ? { workspace: ws.path, workspaceTitle: ws.title } : {}),
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
}
|
|
301
368
|
// 添加/更新时间 = 用户级技能文件的创建/修改时间(排序与详情展示用)。
|
|
302
369
|
// snapshot 只给 SkillSummary(无 path),所以按可写根推断路径;非用户级
|
|
303
370
|
// 来源没有稳定路径,省略字段,客户端排序会把它放到末尾。
|
|
304
371
|
// 全部技能并发收集(面板每 5 秒轮询一次,逐个串行 stat 会让响应随技能
|
|
305
372
|
// 数量线性变慢)。
|
|
306
373
|
const timesByName = new Map<string, { addedAt: number; updatedAt: number }>()
|
|
307
|
-
await Promise.all(
|
|
374
|
+
await Promise.all([...byName.values()].map(async ({ skill }) => {
|
|
308
375
|
if (!isWritableSource(skill.source)) return
|
|
309
376
|
const base = rootPath(skill.source, home)
|
|
310
377
|
for (const candidate of [join(base, skill.name, 'SKILL.md'), join(base, skill.name), join(base, skill.name + '.md')]) {
|
|
@@ -317,7 +384,7 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
|
|
|
317
384
|
}
|
|
318
385
|
}
|
|
319
386
|
}))
|
|
320
|
-
const skills: CatalogSkill[] =
|
|
387
|
+
const skills: CatalogSkill[] = [...byName.values()].map(({ skill, workspace, workspaceTitle }) => {
|
|
321
388
|
const row: CatalogSkill = {
|
|
322
389
|
name: skill.name,
|
|
323
390
|
description: skill.description,
|
|
@@ -328,6 +395,8 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
|
|
|
328
395
|
},
|
|
329
396
|
provider: skill.provider,
|
|
330
397
|
writable: isWritableSource(skill.source),
|
|
398
|
+
source: skill.source,
|
|
399
|
+
...(workspace !== undefined ? { workspace, workspaceTitle: workspaceTitle ?? workspace } : {}),
|
|
331
400
|
}
|
|
332
401
|
const times = timesByName.get(skill.name)
|
|
333
402
|
if (times !== undefined) {
|
|
@@ -341,7 +410,7 @@ async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promise<Cata
|
|
|
341
410
|
...(await scanDiagnostics('user-dsh', home)),
|
|
342
411
|
...(await scanDiagnostics('user-agents', home)),
|
|
343
412
|
]
|
|
344
|
-
return { ok: true, complete
|
|
413
|
+
return { ok: true, complete, skills, disabled, diagnostics }
|
|
345
414
|
}
|
|
346
415
|
|
|
347
416
|
/** Map a loaded definition onto the wire shape. */
|
|
@@ -460,7 +529,18 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
460
529
|
const name = queryParam(url, 'name')
|
|
461
530
|
if (name === undefined || name === '') { writeError(res, 400, 'name query parameter is required'); return }
|
|
462
531
|
const cwd = queryParam(url, 'cwd')
|
|
463
|
-
|
|
532
|
+
// 显式 cwd 只看该工作区;否则按已知工作区逐个查找(与目录默认视图
|
|
533
|
+
// 一致),最后回退用户级根,保证默认视图里可见的项目技能能打开详情。
|
|
534
|
+
let skill: SkillDefinition | undefined
|
|
535
|
+
if (cwd !== undefined && cwd !== '') {
|
|
536
|
+
skill = await deps.skills.get(name, { cwd })
|
|
537
|
+
} else {
|
|
538
|
+
for (const ws of await workspaceEntries(homeOf(deps))) {
|
|
539
|
+
skill = await deps.skills.get(name, { cwd: ws.path })
|
|
540
|
+
if (skill !== undefined) break
|
|
541
|
+
}
|
|
542
|
+
if (skill === undefined) skill = await deps.skills.get(name)
|
|
543
|
+
}
|
|
464
544
|
if (skill === undefined) { writeError(res, 404, 'skill not found: ' + name); return }
|
|
465
545
|
const detail = toDetail(skill)
|
|
466
546
|
if (skill.path !== undefined) {
|
|
@@ -683,7 +763,7 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
683
763
|
},
|
|
684
764
|
}),
|
|
685
765
|
// --------------------------------------------------------------- market
|
|
686
|
-
//
|
|
766
|
+
// Market sources: the user adds repo slugs; each source can
|
|
687
767
|
// be scanned through /repo and imported through /repo/import.
|
|
688
768
|
route({
|
|
689
769
|
path: SKILL_HUB_API.market,
|
|
@@ -926,7 +1006,7 @@ export function makeRoutes(deps: SkillHubRouteDeps): WebRoute[] {
|
|
|
926
1006
|
},
|
|
927
1007
|
}),
|
|
928
1008
|
// -------------------------------------------------------------- update
|
|
929
|
-
// 自身更新检查:查询 GitHub latest release
|
|
1009
|
+
// 自身更新检查:查询 GitHub latest release。
|
|
930
1010
|
route({
|
|
931
1011
|
path: SKILL_HUB_API.update,
|
|
932
1012
|
methods: ['GET'],
|
package/src/skillfs.ts
CHANGED
|
@@ -267,7 +267,7 @@ export async function findProjectRoot(cwd: string): Promise<string> {
|
|
|
267
267
|
|
|
268
268
|
/**
|
|
269
269
|
* Scan one writable root for files the provider ignores, so the GUI can
|
|
270
|
-
* show why a skill never appears
|
|
270
|
+
* show why a skill never appears — a
|
|
271
271
|
* missing frontmatter must be visible, not silent). .disabled files belong
|
|
272
272
|
* to the hub and are skipped.
|
|
273
273
|
*/
|
package/src/update.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Self-update check for dsh-skill-hub
|
|
2
|
+
* Self-update check for dsh-skill-hub
|
|
3
3
|
* query GitHub's latest release and compare it to the installed version.
|
|
4
4
|
*
|
|
5
5
|
* This stays dependency-free: Node's global fetch is used and the version
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ApiConfigScope — a FormScope-compatible view over the hub's own config
|
|
3
|
-
* route (/api/skill-hub/config). The host's settings service refuses to
|
|
4
|
-
* expose third-party namespaces to the web client (dsh-host-apiproxy's
|
|
5
|
-
* allowlist), so the settings card talks to this plugin-owned route instead
|
|
6
|
-
* of the settings transport.
|
|
7
|
-
*
|
|
8
|
-
* The scope mirrors the settings-scope contract the vendored CardForm
|
|
9
|
-
* consumes: `value` is the effective config, `base` the built-in defaults,
|
|
10
|
-
* and `user` the raw saved overrides (a field's presence there marks it
|
|
11
|
-
* overridden). Writes go straight through saveConfig and re-seed the
|
|
12
|
-
* snapshot from the route's response, so the card's save-verification
|
|
13
|
-
* (user layer equals the written value) sees the persisted state.
|
|
14
|
-
*/
|
|
15
|
-
import type { SkillHubApi } from './api.ts';
|
|
16
|
-
import type { FormScope } from './settings-form.ts';
|
|
17
|
-
/** Model-invocable dot color default. Single source for the TS side; the
|
|
18
|
-
* panel's CSS mirrors it via --hub-model (panel.module.css). */
|
|
19
|
-
export declare const DEFAULT_DOT_MODEL_COLOR = "#2f81f7";
|
|
20
|
-
/** User-invocable dot color default. Single source for the TS side; the
|
|
21
|
-
* panel's CSS mirrors it via --hub-user (panel.module.css). */
|
|
22
|
-
export declare const DEFAULT_DOT_USER_COLOR = "#3fb950";
|
|
23
|
-
/** FormScope-compatible reactive config handle (see module doc). */
|
|
24
|
-
export declare class ApiConfigScope implements FormScope {
|
|
25
|
-
private readonly api;
|
|
26
|
-
private status;
|
|
27
|
-
private value;
|
|
28
|
-
private saved;
|
|
29
|
-
private readonly listeners;
|
|
30
|
-
/** @param api - the browser-half API client (shared with the skill panel). */
|
|
31
|
-
constructor(api: SkillHubApi);
|
|
32
|
-
private load;
|
|
33
|
-
subscribe(listener: () => void): () => void;
|
|
34
|
-
getSnapshot(): {
|
|
35
|
-
status: string;
|
|
36
|
-
writable: boolean;
|
|
37
|
-
value?: Record<string, unknown>;
|
|
38
|
-
base?: unknown;
|
|
39
|
-
user?: unknown;
|
|
40
|
-
};
|
|
41
|
-
/** Write one field through the config route and adopt the persisted state. */
|
|
42
|
-
set(field: string, value: unknown): Promise<void>;
|
|
43
|
-
/** Clear one field's override so it re-inherits the default. */
|
|
44
|
-
unset(field: string): Promise<void>;
|
|
45
|
-
/** Persist a patch, then re-seed from the route's fresh response. */
|
|
46
|
-
private apply;
|
|
47
|
-
private publish;
|
|
48
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import type { ConfigRequest, ConfigResponse, HubConfig } from '../protocol.ts'
|
|
3
|
-
import type { SkillHubApi } from './api.ts'
|
|
4
|
-
import { ApiConfigScope } from './api-config-scope.ts'
|
|
5
|
-
|
|
6
|
-
function deferredReady(scope: ApiConfigScope): Promise<void> {
|
|
7
|
-
return new Promise((resolve) => setTimeout(resolve, 0))
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
describe('ApiConfigScope', () => {
|
|
11
|
-
it('persists the panel display toggles (showUseCount etc.)', async () => {
|
|
12
|
-
const saved: Partial<HubConfig> = {}
|
|
13
|
-
const writes: ConfigRequest[] = []
|
|
14
|
-
const api = {
|
|
15
|
-
config: async (): Promise<ConfigResponse> => ({
|
|
16
|
-
ok: true,
|
|
17
|
-
config: {
|
|
18
|
-
enabled: true,
|
|
19
|
-
announceToAgent: true,
|
|
20
|
-
showUseCount: true,
|
|
21
|
-
showUseTime: true,
|
|
22
|
-
showGroupSummary: true,
|
|
23
|
-
},
|
|
24
|
-
saved,
|
|
25
|
-
}),
|
|
26
|
-
saveConfig: async (patch: ConfigRequest): Promise<ConfigResponse> => {
|
|
27
|
-
writes.push(patch)
|
|
28
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
29
|
-
if (value === null) delete saved[key as keyof HubConfig]
|
|
30
|
-
else saved[key as keyof HubConfig] = value as never
|
|
31
|
-
}
|
|
32
|
-
return {
|
|
33
|
-
ok: true,
|
|
34
|
-
config: {
|
|
35
|
-
enabled: true,
|
|
36
|
-
announceToAgent: true,
|
|
37
|
-
showUseCount: saved.showUseCount !== false,
|
|
38
|
-
showUseTime: saved.showUseTime !== false,
|
|
39
|
-
showGroupSummary: saved.showGroupSummary !== false,
|
|
40
|
-
},
|
|
41
|
-
saved,
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
} as unknown as SkillHubApi
|
|
45
|
-
|
|
46
|
-
const scope = new ApiConfigScope(api)
|
|
47
|
-
await deferredReady(scope)
|
|
48
|
-
|
|
49
|
-
await scope.set('showUseCount', false)
|
|
50
|
-
expect(writes).toContainEqual({ showUseCount: false })
|
|
51
|
-
expect(scope.getSnapshot().value).toMatchObject({ showUseCount: false })
|
|
52
|
-
expect(scope.getSnapshot().user).toMatchObject({ showUseCount: false })
|
|
53
|
-
|
|
54
|
-
await scope.unset('showUseCount')
|
|
55
|
-
expect(writes).toContainEqual({ showUseCount: null })
|
|
56
|
-
expect(scope.getSnapshot().user).not.toHaveProperty('showUseCount')
|
|
57
|
-
expect(scope.getSnapshot().value).toMatchObject({ showUseCount: true })
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('ignores unknown fields', async () => {
|
|
61
|
-
const writes: ConfigRequest[] = []
|
|
62
|
-
const api = {
|
|
63
|
-
config: async (): Promise<ConfigResponse> => ({
|
|
64
|
-
ok: true,
|
|
65
|
-
config: { enabled: true, announceToAgent: true },
|
|
66
|
-
saved: {},
|
|
67
|
-
}),
|
|
68
|
-
saveConfig: async (patch: ConfigRequest): Promise<ConfigResponse> => {
|
|
69
|
-
writes.push(patch)
|
|
70
|
-
return { ok: true, config: { enabled: true, announceToAgent: true }, saved: {} }
|
|
71
|
-
},
|
|
72
|
-
} as unknown as SkillHubApi
|
|
73
|
-
|
|
74
|
-
const scope = new ApiConfigScope(api)
|
|
75
|
-
await deferredReady(scope)
|
|
76
|
-
await scope.set('not-a-field', true)
|
|
77
|
-
expect(writes).toHaveLength(0)
|
|
78
|
-
})
|
|
79
|
-
})
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ApiConfigScope — a FormScope-compatible view over the hub's own config
|
|
3
|
-
* route (/api/skill-hub/config). The host's settings service refuses to
|
|
4
|
-
* expose third-party namespaces to the web client (dsh-host-apiproxy's
|
|
5
|
-
* allowlist), so the settings card talks to this plugin-owned route instead
|
|
6
|
-
* of the settings transport.
|
|
7
|
-
*
|
|
8
|
-
* The scope mirrors the settings-scope contract the vendored CardForm
|
|
9
|
-
* consumes: `value` is the effective config, `base` the built-in defaults,
|
|
10
|
-
* and `user` the raw saved overrides (a field's presence there marks it
|
|
11
|
-
* overridden). Writes go straight through saveConfig and re-seed the
|
|
12
|
-
* snapshot from the route's response, so the card's save-verification
|
|
13
|
-
* (user layer equals the written value) sees the persisted state.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { HubConfig } from '../protocol.ts'
|
|
17
|
-
import type { SkillHubApi } from './api.ts'
|
|
18
|
-
import type { FormScope } from './settings-form.ts'
|
|
19
|
-
|
|
20
|
-
/** Model-invocable dot color default. Single source for the TS side; the
|
|
21
|
-
* panel's CSS mirrors it via --hub-model (panel.module.css). */
|
|
22
|
-
export const DEFAULT_DOT_MODEL_COLOR = '#2f81f7'
|
|
23
|
-
/** User-invocable dot color default. Single source for the TS side; the
|
|
24
|
-
* panel's CSS mirrors it via --hub-user (panel.module.css). */
|
|
25
|
-
export const DEFAULT_DOT_USER_COLOR = '#3fb950'
|
|
26
|
-
|
|
27
|
-
/** Built-in defaults every field inherits until the user overrides it. */
|
|
28
|
-
const DEFAULTS: HubConfig = {
|
|
29
|
-
enabled: true,
|
|
30
|
-
announceToAgent: true,
|
|
31
|
-
showUseCount: true,
|
|
32
|
-
showUseTime: true,
|
|
33
|
-
showGroupSummary: true,
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Boolean config fields (color fields are strings). */
|
|
37
|
-
const BOOLEAN_FIELDS = new Set<keyof HubConfig>([
|
|
38
|
-
'enabled', 'announceToAgent', 'showUseCount', 'showUseTime', 'showGroupSummary',
|
|
39
|
-
])
|
|
40
|
-
|
|
41
|
-
/** Config fields the card edits (used to guard set/unset field names). */
|
|
42
|
-
const FIELDS = new Set<keyof HubConfig>([
|
|
43
|
-
'enabled', 'announceToAgent', 'dotModelColor', 'dotUserColor',
|
|
44
|
-
'showUseCount', 'showUseTime', 'showGroupSummary',
|
|
45
|
-
])
|
|
46
|
-
|
|
47
|
-
/** FormScope-compatible reactive config handle (see module doc). */
|
|
48
|
-
export class ApiConfigScope implements FormScope {
|
|
49
|
-
private status: 'loading' | 'ready' | 'unavailable' = 'loading'
|
|
50
|
-
private value: HubConfig = { ...DEFAULTS }
|
|
51
|
-
private saved: Partial<HubConfig> = {}
|
|
52
|
-
private readonly listeners = new Set<() => void>()
|
|
53
|
-
|
|
54
|
-
/** @param api - the browser-half API client (shared with the skill panel). */
|
|
55
|
-
constructor(private readonly api: SkillHubApi) {
|
|
56
|
-
void this.load()
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
private async load(): Promise<void> {
|
|
60
|
-
try {
|
|
61
|
-
const response = await this.api.config()
|
|
62
|
-
this.value = response.config
|
|
63
|
-
this.saved = { ...response.saved }
|
|
64
|
-
this.status = 'ready'
|
|
65
|
-
} catch {
|
|
66
|
-
// The route is missing or unreachable (e.g. host half not yet updated):
|
|
67
|
-
// the card shows its not-exposed state instead of breaking the GUI.
|
|
68
|
-
this.status = 'unavailable'
|
|
69
|
-
}
|
|
70
|
-
this.publish()
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
subscribe(listener: () => void): () => void {
|
|
74
|
-
this.listeners.add(listener)
|
|
75
|
-
return () => { this.listeners.delete(listener) }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
getSnapshot(): { status: string; writable: boolean; value?: Record<string, unknown>; base?: unknown; user?: unknown } {
|
|
79
|
-
return {
|
|
80
|
-
status: this.status,
|
|
81
|
-
writable: true,
|
|
82
|
-
value: { ...this.value } as unknown as Record<string, unknown>,
|
|
83
|
-
base: { ...DEFAULTS },
|
|
84
|
-
user: { ...this.saved },
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Write one field through the config route and adopt the persisted state. */
|
|
89
|
-
async set(field: string, value: unknown): Promise<void> {
|
|
90
|
-
if (!FIELDS.has(field as keyof HubConfig)) return
|
|
91
|
-
const patch = BOOLEAN_FIELDS.has(field as keyof HubConfig)
|
|
92
|
-
? { [field]: Boolean(value) }
|
|
93
|
-
: { [field]: typeof value === 'string' ? value : undefined }
|
|
94
|
-
await this.apply(patch as Partial<HubConfig>)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Clear one field's override so it re-inherits the default. */
|
|
98
|
-
async unset(field: string): Promise<void> {
|
|
99
|
-
if (!FIELDS.has(field as keyof HubConfig)) return
|
|
100
|
-
await this.apply({ [field]: undefined } as Partial<HubConfig>)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Persist a patch, then re-seed from the route's fresh response. */
|
|
104
|
-
private async apply(patch: Partial<HubConfig>): Promise<void> {
|
|
105
|
-
// null on the wire clears a saved override (see ConfigRequest).
|
|
106
|
-
const request: Record<string, boolean | string | null> = {}
|
|
107
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
108
|
-
request[key] = value === undefined ? null : value
|
|
109
|
-
}
|
|
110
|
-
const response = await this.api.saveConfig(request as never)
|
|
111
|
-
this.value = response.config
|
|
112
|
-
this.saved = { ...response.saved }
|
|
113
|
-
this.publish()
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private publish(): void {
|
|
117
|
-
for (const listener of this.listeners) listener()
|
|
118
|
-
}
|
|
119
|
-
}
|