@morlay/dsh-session-mode 0.0.6 → 0.0.8
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 +33 -16
- package/dist/catalog-remote-B-NMi_vS.mjs +21 -0
- package/dist/client.cjs +2623 -1714
- package/dist/client.d.cts +473 -4
- package/dist/client.d.mts +473 -4
- package/dist/index.d.cts +3 -1
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +160 -24
- package/dist/rows.mjs +39 -28
- package/package.json +15 -3
- package/src/catalog-remote.ts +45 -0
- package/src/catalog.ts +75 -0
- package/src/client/BundleConfigPage.tsx +630 -0
- package/src/client/SessionModeSeat.tsx +21 -19
- package/src/client/bundle-config.ts +701 -0
- package/src/client/bundle-locales.ts +214 -0
- package/src/client/index.ts +180 -72
- package/src/client/llm-directory.ts +146 -0
- package/src/client/locales.ts +2 -2
- package/src/index.ts +25 -11
- package/src/mode-sources.ts +20 -6
- package/src/modes.ts +46 -21
- package/src/rows.ts +3 -2
- package/src/scope.ts +4 -5
- package/dist/shared-DX2Giyh3.mjs +0 -5
- package/src/client/SessionModeSeat.module.css +0 -74
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
// bundle 配置页的数据面:把 `session-mode` 行的 volatile schema 折成"默认模式 + 一串模式卡片"。
|
|
2
|
+
//
|
|
3
|
+
// 草稿、整段校验与保存都归通用 schema 表单的控制器(`SchemaFormController`):本文件只声明页面摆哪些字段、把控制器
|
|
4
|
+
// 读数折成卡片视图、把页面动作收成一份注入面。字段路径就是真实路径(`modes.<模式 id>.<字段…>`),所以增删模式走
|
|
5
|
+
// 字典的 `addKey` / `removeKey`,模式里的每个字段走 `setText` / `set` / `clear`。
|
|
6
|
+
//
|
|
7
|
+
// 注册进 `plugins.bundle.config` 的 key 是 **bundle 包名**(页面按它取注册项),表单读写的却是 `session-mode` 行的
|
|
8
|
+
// 命名空间:bundle 装的就是这一行,两个面说的是同一份配置。
|
|
9
|
+
|
|
10
|
+
import type { Context as ClientContext } from "@deepseek-ai/cordis";
|
|
11
|
+
import type {} from "@deepseek-ai/dsh-api-remotes/client";
|
|
12
|
+
import type {} from "@deepseek-ai/dsh-client-ui-settings/client";
|
|
13
|
+
import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-store";
|
|
14
|
+
import {
|
|
15
|
+
failureOf,
|
|
16
|
+
projectRoot,
|
|
17
|
+
SchemaFormController,
|
|
18
|
+
type SchemaFormState,
|
|
19
|
+
type SelectOption,
|
|
20
|
+
type SettingsFormShell,
|
|
21
|
+
type ValidationFailure,
|
|
22
|
+
} from "@morlay/dsh-client-ui-primitives/client";
|
|
23
|
+
import type { BundleLocaleKey } from "./bundle-locales.ts";
|
|
24
|
+
import { loadLlmProviders, routesOf, type ModelRoute } from "./llm-directory.ts";
|
|
25
|
+
|
|
26
|
+
// `session-mode` 行的 settings 命名空间:行 id。
|
|
27
|
+
export const SESSION_MODE_NS = "session-mode";
|
|
28
|
+
|
|
29
|
+
// 本页注册进 `plugins.bundle.config` 的 key:装这一行的 bundle 的包名。
|
|
30
|
+
export const BUNDLE_CONFIG_KEY = "@morlay/session-mode-profile";
|
|
31
|
+
|
|
32
|
+
// 不提供删除的模式:`noop` 是"什么都不加"的那一档,删掉它就没有与上游一致的对照面了。
|
|
33
|
+
export const PROTECTED_MODE_IDS: readonly string[] = ["noop"];
|
|
34
|
+
|
|
35
|
+
// 模式的角色取值:与 schema 的 `roleSchema`(`main` / `subagent` 两个字面量的集合)同源,改 schema 要一起改。
|
|
36
|
+
const ROLE_VALUES: readonly string[] = ["main", "subagent"];
|
|
37
|
+
|
|
38
|
+
// 字段的控件形状:文本 / 多行文本 / 选择器(有候选)/ 标签列表 / 开关 / 三态 / 角色。
|
|
39
|
+
type Control = "text" | "multiline" | "choice" | "tags" | "switch" | "tri" | "roles";
|
|
40
|
+
|
|
41
|
+
// 卡片里的分组(页面按它分段,顺序即数组顺序)。
|
|
42
|
+
export type GroupKey = "identity" | "persona" | "tools" | "rules" | "injections" | "model";
|
|
43
|
+
|
|
44
|
+
export const GROUPS: readonly { key: GroupKey; labelKey: BundleLocaleKey }[] = [
|
|
45
|
+
{ key: "identity", labelKey: "group.identity" },
|
|
46
|
+
{ key: "persona", labelKey: "group.persona" },
|
|
47
|
+
{ key: "tools", labelKey: "group.tools" },
|
|
48
|
+
{ key: "rules", labelKey: "group.rules" },
|
|
49
|
+
{ key: "injections", labelKey: "group.injections" },
|
|
50
|
+
{ key: "model", labelKey: "group.model" },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// 模式定义里的一个字段(路径相对模式定义,不含 `modes.<id>` 前缀)。
|
|
54
|
+
interface FieldSpec {
|
|
55
|
+
readonly path: readonly string[];
|
|
56
|
+
readonly control: Control;
|
|
57
|
+
readonly labelKey: BundleLocaleKey;
|
|
58
|
+
readonly hintKey: BundleLocaleKey;
|
|
59
|
+
readonly group: GroupKey;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 页面摆出来的字段与顺序。缺值的可选字段照常出现(控件显示"没写",用户一填即是写)。
|
|
63
|
+
const MODE_FIELDS: readonly FieldSpec[] = [
|
|
64
|
+
{
|
|
65
|
+
path: ["name"],
|
|
66
|
+
control: "text",
|
|
67
|
+
labelKey: "field.name",
|
|
68
|
+
hintKey: "hint.name",
|
|
69
|
+
group: "identity",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
path: ["description"],
|
|
73
|
+
control: "text",
|
|
74
|
+
labelKey: "field.description",
|
|
75
|
+
hintKey: "hint.description",
|
|
76
|
+
group: "identity",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
path: ["role"],
|
|
80
|
+
control: "roles",
|
|
81
|
+
labelKey: "field.role",
|
|
82
|
+
hintKey: "hint.role",
|
|
83
|
+
group: "identity",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
path: ["presetsOnly"],
|
|
87
|
+
control: "tags",
|
|
88
|
+
labelKey: "field.presetsOnly",
|
|
89
|
+
hintKey: "hint.presetsOnly",
|
|
90
|
+
group: "identity",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
path: ["persona", "prefix"],
|
|
94
|
+
control: "multiline",
|
|
95
|
+
labelKey: "field.prefix",
|
|
96
|
+
hintKey: "hint.prefix",
|
|
97
|
+
group: "persona",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
path: ["persona", "suffix"],
|
|
101
|
+
control: "multiline",
|
|
102
|
+
labelKey: "field.suffix",
|
|
103
|
+
hintKey: "hint.suffix",
|
|
104
|
+
group: "persona",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
path: ["allowTools"],
|
|
108
|
+
control: "tags",
|
|
109
|
+
labelKey: "field.allowTools",
|
|
110
|
+
hintKey: "hint.allowTools",
|
|
111
|
+
group: "tools",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
path: ["denyTools"],
|
|
115
|
+
control: "tags",
|
|
116
|
+
labelKey: "field.denyTools",
|
|
117
|
+
hintKey: "hint.denyTools",
|
|
118
|
+
group: "tools",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
path: ["allowSkills"],
|
|
122
|
+
control: "tags",
|
|
123
|
+
labelKey: "field.allowSkills",
|
|
124
|
+
hintKey: "hint.allowSkills",
|
|
125
|
+
group: "tools",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
path: ["denySkills"],
|
|
129
|
+
control: "tags",
|
|
130
|
+
labelKey: "field.denySkills",
|
|
131
|
+
hintKey: "hint.denySkills",
|
|
132
|
+
group: "tools",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
path: ["allowPolicies"],
|
|
136
|
+
control: "tags",
|
|
137
|
+
labelKey: "field.allowPolicies",
|
|
138
|
+
hintKey: "hint.allowPolicies",
|
|
139
|
+
group: "rules",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
path: ["denyPolicies"],
|
|
143
|
+
control: "tags",
|
|
144
|
+
labelKey: "field.denyPolicies",
|
|
145
|
+
hintKey: "hint.denyPolicies",
|
|
146
|
+
group: "rules",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
path: ["instructions"],
|
|
150
|
+
control: "switch",
|
|
151
|
+
labelKey: "field.instructions",
|
|
152
|
+
hintKey: "hint.instructions",
|
|
153
|
+
group: "injections",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
path: ["skills"],
|
|
157
|
+
control: "tri",
|
|
158
|
+
labelKey: "field.skills",
|
|
159
|
+
hintKey: "hint.skills",
|
|
160
|
+
group: "injections",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
path: ["runtimeContext"],
|
|
164
|
+
control: "switch",
|
|
165
|
+
labelKey: "field.runtimeContext",
|
|
166
|
+
hintKey: "hint.runtimeContext",
|
|
167
|
+
group: "injections",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
path: ["defaultModel", "reasoningEffort"],
|
|
171
|
+
control: "text",
|
|
172
|
+
labelKey: "field.reasoningEffort",
|
|
173
|
+
hintKey: "hint.reasoningEffort",
|
|
174
|
+
group: "model",
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
// 控制器读数里一个字段的状态(`SchemaFormState.fields` 的值)。
|
|
179
|
+
type FieldRead = NonNullable<ReturnType<SchemaFormState["fields"]["get"]>>;
|
|
180
|
+
|
|
181
|
+
// 页面用的字典(键集合在 `bundle-locales.ts`)。
|
|
182
|
+
export type BundleTranslate = (key: BundleLocaleKey, args?: Record<string, unknown>) => string;
|
|
183
|
+
|
|
184
|
+
// 一个字段的显示状态:值(草稿优先)、是否还没写、是否覆盖了组成层。
|
|
185
|
+
export interface BundleFieldView {
|
|
186
|
+
readonly key: string;
|
|
187
|
+
readonly path: readonly string[];
|
|
188
|
+
readonly control: Control;
|
|
189
|
+
readonly label: string;
|
|
190
|
+
// 字段下面那句说明("这一项到底管什么")。
|
|
191
|
+
readonly hint: string;
|
|
192
|
+
// 文本类控件的显示文本(草稿优先)。
|
|
193
|
+
readonly text: string;
|
|
194
|
+
// 开关 / 三态 / 角色 / 标签列表的当前值。
|
|
195
|
+
readonly value: unknown;
|
|
196
|
+
// 字段当前有没有值("没写"的可选字段照常出现在页面上)。
|
|
197
|
+
readonly present: boolean;
|
|
198
|
+
readonly overridden: boolean;
|
|
199
|
+
readonly options: readonly SelectOption[];
|
|
200
|
+
readonly invalid: string | undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 一个模式的卡片。
|
|
204
|
+
export interface BundleModeView {
|
|
205
|
+
readonly id: string;
|
|
206
|
+
readonly title: string;
|
|
207
|
+
readonly role: readonly string[];
|
|
208
|
+
readonly summary: string;
|
|
209
|
+
// 这个模式配的默认模型(服务商与模型一起给):没有 = 跟随全局,页面据此画开关与路由清单。
|
|
210
|
+
readonly model: { readonly provider: string; readonly model: string } | undefined;
|
|
211
|
+
readonly deletable: boolean;
|
|
212
|
+
// 这个模式里有还没保存的编辑。
|
|
213
|
+
readonly staged: boolean;
|
|
214
|
+
readonly groups: readonly {
|
|
215
|
+
readonly key: GroupKey;
|
|
216
|
+
readonly label: string;
|
|
217
|
+
readonly fields: readonly BundleFieldView[];
|
|
218
|
+
}[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 页面读这一行配置的阶段:
|
|
222
|
+
// - `loading`:设置面还没送到(还没读到这一行的命名空间);
|
|
223
|
+
// - `missing`:设置面送到了,但没有 `session-mode` 这个命名空间(行没装或被禁用);
|
|
224
|
+
// - `unreadable`:命名空间在,schema 却读不出来(rehydrate / 字段树失败)——这是配置面自己的问题,不是行没跑;
|
|
225
|
+
// - `ready`:可编辑。
|
|
226
|
+
export type BundleConfigReadiness = "loading" | "missing" | "unreadable" | "ready";
|
|
227
|
+
|
|
228
|
+
// 异常态的诊断读数:设置面现在有哪些命名空间,这一行的 schema 卡在哪,以及控制器那一侧的读数。
|
|
229
|
+
export interface BundleConfigDiagnosis {
|
|
230
|
+
readonly namespaces: readonly string[];
|
|
231
|
+
readonly problem: string;
|
|
232
|
+
// 控制器当前投影的摘要 + 现建一个探针控制器的结果:用来区分"实例没跟上"与"这条路径本身失败"。
|
|
233
|
+
readonly controller: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 部署里"已设置的模型"的读数:路由清单与它的加载状态(默认模型那一组用它)。
|
|
237
|
+
export interface BundleModelsState {
|
|
238
|
+
readonly status: "loading" | "ready" | "error";
|
|
239
|
+
readonly routes: readonly ModelRoute[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 页面的完整读数。
|
|
243
|
+
export interface BundleConfigView extends SettingsFormShell {
|
|
244
|
+
// 行的 schema 可渲染(否则页面只说明这一行没在跑)。
|
|
245
|
+
readonly configured: boolean;
|
|
246
|
+
readonly readiness: BundleConfigReadiness;
|
|
247
|
+
readonly defaultMode: {
|
|
248
|
+
readonly value: string;
|
|
249
|
+
readonly options: readonly SelectOption[];
|
|
250
|
+
readonly invalid: string | undefined;
|
|
251
|
+
};
|
|
252
|
+
readonly modes: readonly BundleModeView[];
|
|
253
|
+
// 整段校验挡下的消息。
|
|
254
|
+
readonly violation: string | undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 页面动作与读数(作为槽位注册项的 inject 面到达组件)。
|
|
258
|
+
export interface BundleConfigActions {
|
|
259
|
+
set: (path: readonly string[], value: unknown) => void;
|
|
260
|
+
// 上报一段文本:空文本=恢复默认,其余按字面量暂存。
|
|
261
|
+
editText: (path: readonly string[], text: string) => void;
|
|
262
|
+
clear: (path: readonly string[]) => void;
|
|
263
|
+
appendItem: (path: readonly string[]) => void;
|
|
264
|
+
removeItem: (path: readonly string[], index: number) => void;
|
|
265
|
+
addMode: (id: string) => void;
|
|
266
|
+
removeMode: (id: string) => void;
|
|
267
|
+
save: () => void;
|
|
268
|
+
discard: () => void;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 完整注入面:动作 + 读数(读数由框架绑成 `useBundleConfig`)。
|
|
272
|
+
export interface BundleConfigFace extends BundleConfigActions {
|
|
273
|
+
readonly hooks: {
|
|
274
|
+
readonly bundleConfig: SnapshotStore<SchemaFormState>;
|
|
275
|
+
// 这一行命名空间的送达状态(`loading` / `ready` / `unavailable`):页面据此区分"还在读"与"这一行没在跑"。
|
|
276
|
+
readonly bundleStatus: SnapshotStore<ConfigStatus>;
|
|
277
|
+
// 部署里"已设置的模型"(一条路由 = provider + model):默认模型那一组的选择器用它。
|
|
278
|
+
readonly bundleModels: SnapshotStore<BundleModelsState>;
|
|
279
|
+
};
|
|
280
|
+
// 异常态的诊断读数(页面只在读不出来时调用一次)。
|
|
281
|
+
diagnose: () => BundleConfigDiagnosis;
|
|
282
|
+
// 强制重读一次 describe 并重建投影(页面挂载时调用一次):视图晚到、通知没落上时,这一步把它拉平。
|
|
283
|
+
// 只在没有草稿时有效——重建出来的投影不带草稿,有草稿时直接跳过。
|
|
284
|
+
resync: () => void;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// `ctx.configForms` 一个命名空间的送达状态(与 `ConfigFormSnapshot.status` 同源)。
|
|
288
|
+
export type ConfigStatus = "loading" | "ready" | "unavailable";
|
|
289
|
+
|
|
290
|
+
// cordis 会把服务值包成追踪代理(`Symbol.for("cordis.original")` 指回原始实例)。代理上的方法调用以代理为
|
|
291
|
+
// `this`,而服务实现用的是 JS 私有字段(`#texts` …)——私有字段只认声明它的那个实例,于是
|
|
292
|
+
// `Cannot read private member #texts from an object whose class did not declare it`。
|
|
293
|
+
// 读候选前先把服务解包成原始实例:这是跨副本共享的唯一入口,解包之后方法里的 `this` 才是原实例。
|
|
294
|
+
export function unwrapService<T>(value: T | undefined): T | undefined {
|
|
295
|
+
if (value === undefined) return undefined;
|
|
296
|
+
const original = (value as { [key: symbol]: unknown })[Symbol.for("cordis.original")];
|
|
297
|
+
return (original ?? value) as T;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 提示面(`schemaFormHints`)在本文件用到的读侧(订阅只在这里用:候选晚到时让页面重投影)。
|
|
301
|
+
export interface HintsLike {
|
|
302
|
+
textFor(ns: string, path: readonly string[]): { label?: string; hint?: string };
|
|
303
|
+
selectFor(ns: string, path: readonly string[]): SelectRead | undefined;
|
|
304
|
+
sourceFor(name: string): SelectRead | undefined;
|
|
305
|
+
subscribe(listener: () => void): () => void;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 提示面的注册侧(业务行用它登记字段文案、候选键与候选值)。
|
|
309
|
+
export interface HintsService extends HintsLike {
|
|
310
|
+
describe(
|
|
311
|
+
ns: string,
|
|
312
|
+
path: readonly string[],
|
|
313
|
+
read: () => { label?: string; hint?: string },
|
|
314
|
+
): () => void;
|
|
315
|
+
select(ns: string, path: readonly string[], spec: SelectRead): () => void;
|
|
316
|
+
source(name: string, spec: SelectRead): () => void;
|
|
317
|
+
refresh(): void;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 候选源:依赖字段变化时按读数重算候选(`dependsOn` 只声明"何时重算",值由 options 自己读)。
|
|
321
|
+
interface SelectRead {
|
|
322
|
+
dependsOn?: readonly (readonly string[])[] | undefined;
|
|
323
|
+
options: (read: (path: readonly string[]) => unknown) => readonly SelectOption[];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 字段在状态表里的键(与渲染层的约定一致:具体路径的 JSON)。
|
|
327
|
+
export function fieldKey(path: readonly string[]): string {
|
|
328
|
+
return JSON.stringify(path);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 组装这一页:控制器(草稿 + 校验 + 保存)与注入面(连同它的释放)。
|
|
332
|
+
//
|
|
333
|
+
// 整段校验 = schema 自己的校验,加上页面能立刻说的跨字段规则(默认模式必须在清单里、默认模型要成对)。
|
|
334
|
+
export function createBundleConfigFace(
|
|
335
|
+
ctx: ClientContext,
|
|
336
|
+
t: BundleTranslate,
|
|
337
|
+
// 提示面(候选值:服务商、模型、policy 名单):由调用方以**属性访问**取到再传进来——`ctx.get(name)` 给的是
|
|
338
|
+
// cordis 的追踪代理,调它上面带 JS 私有字段的方法会抛
|
|
339
|
+
// `Cannot read private member #x from an object whose class did not declare it`。
|
|
340
|
+
hintsOf: () => HintsLike | undefined = () => undefined,
|
|
341
|
+
): { face: BundleConfigFace; refresh: () => void; dispose: () => void } {
|
|
342
|
+
const forms = ctx.configForms;
|
|
343
|
+
const hints = (): HintsLike | undefined => unwrapService(hintsOf());
|
|
344
|
+
const deps = (): ConstructorParameters<typeof SchemaFormController>[1] => ({
|
|
345
|
+
form: forms.get(SESSION_MODE_NS),
|
|
346
|
+
describe: forms.describe(),
|
|
347
|
+
rehydrate: (serialized: unknown) => ctx.settingsSchema.rehydrate(serialized),
|
|
348
|
+
validate: (schema: unknown, value: unknown) => validateSection(schema, value, t),
|
|
349
|
+
t: ctx.locale.bind("settings.schema-form"),
|
|
350
|
+
hints: {
|
|
351
|
+
keysFor: () => [],
|
|
352
|
+
textFor: (path: readonly string[]) => hints()?.textFor(SESSION_MODE_NS, path) ?? {},
|
|
353
|
+
selectFor: (path: readonly string[]) => hints()?.selectFor(SESSION_MODE_NS, path),
|
|
354
|
+
sourceFor: (name: string) => hints()?.sourceFor(name),
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
const controller = new SchemaFormController(SESSION_MODE_NS, deps());
|
|
358
|
+
const face = controller.face();
|
|
359
|
+
const form = forms.get(SESSION_MODE_NS);
|
|
360
|
+
const statusStore = createSnapshotStore<ConfigStatus>(form.getSnapshot().status);
|
|
361
|
+
const unsubscribeStatus = form.subscribe(() => {
|
|
362
|
+
statusStore.set(form.getSnapshot().status);
|
|
363
|
+
});
|
|
364
|
+
// "已设置的模型":进页面就取一次,目录变了(适配器或设置文档一动)再取一次。
|
|
365
|
+
const modelsStore = createSnapshotStore<BundleModelsState>({ status: "loading", routes: [] });
|
|
366
|
+
const loadModels = async (): Promise<void> => {
|
|
367
|
+
try {
|
|
368
|
+
const providers = await loadLlmProviders(ctx);
|
|
369
|
+
modelsStore.set({ status: "ready", routes: routesOf(providers) });
|
|
370
|
+
} catch {
|
|
371
|
+
// 目录读不到(没有可用的 provider 服务)时说一句"读不出来",不给一张空清单。
|
|
372
|
+
modelsStore.set({ status: "error", routes: [] });
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
const remote = ctx.get("remote");
|
|
376
|
+
// 事件订阅不是每个环境都有(测试替身、headless 部署):没有就只加载一次。
|
|
377
|
+
const unsubscribeModels =
|
|
378
|
+
typeof remote?.$on !== "function"
|
|
379
|
+
? []
|
|
380
|
+
: [
|
|
381
|
+
remote.$on("llm/adapters-updated", () => {
|
|
382
|
+
void loadModels();
|
|
383
|
+
}),
|
|
384
|
+
remote.$on("settings/document-updated", () => {
|
|
385
|
+
void loadModels();
|
|
386
|
+
}),
|
|
387
|
+
];
|
|
388
|
+
void loadModels();
|
|
389
|
+
// 诊断:只在页面读不出来时被调用——把"设置面有哪些命名空间"和"这一行为什么渲染不出来"说清。
|
|
390
|
+
const diagnose = (): BundleConfigDiagnosis => {
|
|
391
|
+
const view = forms.describe().getSnapshot().view;
|
|
392
|
+
const namespaces = view?.namespaces.map((row) => row.ns) ?? [];
|
|
393
|
+
const live = controller.face().hooks.schemaForm.getSnapshot();
|
|
394
|
+
const controllerSummary = `store configured=${String(live.configured)} walked=${String(live.walked.length)}`;
|
|
395
|
+
const own = view?.namespaces.find((row) => row.ns === SESSION_MODE_NS);
|
|
396
|
+
if (own === undefined) {
|
|
397
|
+
return {
|
|
398
|
+
namespaces,
|
|
399
|
+
controller: controllerSummary,
|
|
400
|
+
problem: `describe has no namespace "${SESSION_MODE_NS}"`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const schema = ctx.settingsSchema.rehydrate(own.schema) as unknown as { type?: unknown };
|
|
405
|
+
const renderable =
|
|
406
|
+
projectRoot(own.schema, (serialized: unknown) =>
|
|
407
|
+
ctx.settingsSchema.rehydrate(serialized),
|
|
408
|
+
) !== undefined;
|
|
409
|
+
// 探针:用同一份依赖现建一个控制器,看控制器的构造路径本身会不会失败。
|
|
410
|
+
let probe: string;
|
|
411
|
+
try {
|
|
412
|
+
const rebuilt = new SchemaFormController(SESSION_MODE_NS, deps());
|
|
413
|
+
probe = `probe configured=${String(rebuilt.face().hooks.schemaForm.getSnapshot().configured)}`;
|
|
414
|
+
rebuilt.dispose();
|
|
415
|
+
} catch (error: unknown) {
|
|
416
|
+
probe = `probe threw: ${error instanceof Error ? error.message : String(error)}`;
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
namespaces,
|
|
420
|
+
controller: `${controllerSummary}; ${probe}`,
|
|
421
|
+
problem: renderable ? "renderable" : `schema root is "${String(schema.type)}"`,
|
|
422
|
+
};
|
|
423
|
+
} catch (error: unknown) {
|
|
424
|
+
return {
|
|
425
|
+
namespaces,
|
|
426
|
+
controller: controllerSummary,
|
|
427
|
+
problem: `schema rehydrate failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
// 重建一次并把投影写回**现有的** store:组件的 hook 绑的就是这个 store,所以只换读数、不换引用。
|
|
432
|
+
// 重读一次 describe 与候选(`SchemaFormController.refresh` 现在就是重读,草稿不动)。
|
|
433
|
+
const resync = (): void => {
|
|
434
|
+
statusStore.set(form.getSnapshot().status);
|
|
435
|
+
controller.refresh();
|
|
436
|
+
};
|
|
437
|
+
return {
|
|
438
|
+
face: {
|
|
439
|
+
hooks: {
|
|
440
|
+
bundleConfig: face.hooks.schemaForm,
|
|
441
|
+
bundleStatus: statusStore,
|
|
442
|
+
bundleModels: modelsStore,
|
|
443
|
+
},
|
|
444
|
+
diagnose,
|
|
445
|
+
resync,
|
|
446
|
+
set: (path, value) => {
|
|
447
|
+
face.set(path, value);
|
|
448
|
+
},
|
|
449
|
+
editText: (path, text) => {
|
|
450
|
+
// 空文本=这一项恢复默认(清掉用户层);非空按字面量暂存,保存那一刻再解析。
|
|
451
|
+
if (text === "") face.clear(path);
|
|
452
|
+
else face.setText(path, text, (raw) => ({ kind: "value", value: raw }));
|
|
453
|
+
},
|
|
454
|
+
clear: (path) => {
|
|
455
|
+
face.clear(path);
|
|
456
|
+
},
|
|
457
|
+
appendItem: (path) => {
|
|
458
|
+
face.appendItem(path);
|
|
459
|
+
},
|
|
460
|
+
removeItem: (path, index) => {
|
|
461
|
+
face.removeItem(path, index);
|
|
462
|
+
},
|
|
463
|
+
addMode: (id) => {
|
|
464
|
+
const trimmed = id.trim();
|
|
465
|
+
if (trimmed === "") return;
|
|
466
|
+
face.addKey(["modes"], trimmed);
|
|
467
|
+
// 新模式的名称默认给 id:`name` 在 schema 上必填且没有默认值,留空会被校验挡下。
|
|
468
|
+
face.setText(["modes", trimmed, "name"], trimmed, (raw) => ({ kind: "value", value: raw }));
|
|
469
|
+
},
|
|
470
|
+
removeMode: (id) => {
|
|
471
|
+
if (PROTECTED_MODE_IDS.includes(id)) return;
|
|
472
|
+
face.removeKey(["modes"], id);
|
|
473
|
+
},
|
|
474
|
+
save: () => {
|
|
475
|
+
face.save();
|
|
476
|
+
},
|
|
477
|
+
discard: () => {
|
|
478
|
+
face.discard();
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
refresh: resync,
|
|
482
|
+
dispose: () => {
|
|
483
|
+
unsubscribeStatus();
|
|
484
|
+
for (const off of unsubscribeModels) off?.();
|
|
485
|
+
controller.dispose();
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// 整段校验:schema 先说话(消息带路径),再看跨字段的那几条。
|
|
491
|
+
function validateSection(
|
|
492
|
+
schema: unknown,
|
|
493
|
+
value: unknown,
|
|
494
|
+
t: BundleTranslate,
|
|
495
|
+
): ValidationFailure | undefined {
|
|
496
|
+
try {
|
|
497
|
+
(schema as (input: unknown) => unknown)(value);
|
|
498
|
+
} catch (error: unknown) {
|
|
499
|
+
return failureOf(error);
|
|
500
|
+
}
|
|
501
|
+
return rosterProblem(value, t);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// 跨字段规则:至少一个模式、默认模式在清单里、默认模型要成对(与 host 装配期那份判据同向)。
|
|
505
|
+
function rosterProblem(value: unknown, t: BundleTranslate): ValidationFailure | undefined {
|
|
506
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
507
|
+
const modes: unknown = Reflect.get(value, "modes");
|
|
508
|
+
if (typeof modes !== "object" || modes === null || Array.isArray(modes)) {
|
|
509
|
+
return { message: t("violation.modesEmpty"), path: ["modes"] };
|
|
510
|
+
}
|
|
511
|
+
const ids = Object.keys(modes);
|
|
512
|
+
if (ids.length === 0) return { message: t("violation.modesEmpty"), path: ["modes"] };
|
|
513
|
+
const selected: unknown = Reflect.get(value, "default");
|
|
514
|
+
if (typeof selected === "string" && selected !== "" && !ids.includes(selected)) {
|
|
515
|
+
return { message: t("violation.defaultMissing"), path: ["default"] };
|
|
516
|
+
}
|
|
517
|
+
for (const id of ids) {
|
|
518
|
+
const mode: unknown = Reflect.get(modes, id);
|
|
519
|
+
const model: unknown =
|
|
520
|
+
typeof mode === "object" && mode !== null ? Reflect.get(mode, "defaultModel") : undefined;
|
|
521
|
+
if (typeof model !== "object" || model === null) continue;
|
|
522
|
+
const provider: unknown = Reflect.get(model, "provider");
|
|
523
|
+
const name: unknown = Reflect.get(model, "model");
|
|
524
|
+
if (provider === undefined && name === undefined) continue;
|
|
525
|
+
if (
|
|
526
|
+
typeof provider === "string" &&
|
|
527
|
+
provider !== "" &&
|
|
528
|
+
typeof name === "string" &&
|
|
529
|
+
name !== ""
|
|
530
|
+
) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
return { message: t("violation.modelPair"), path: ["modes", id, "defaultModel"] };
|
|
534
|
+
}
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 把控制器读数折成页面视图。
|
|
539
|
+
export function projectBundleConfig(
|
|
540
|
+
state: SchemaFormState,
|
|
541
|
+
t: BundleTranslate,
|
|
542
|
+
status: ConfigStatus = "ready",
|
|
543
|
+
): BundleConfigView {
|
|
544
|
+
const read = (path: readonly string[]): FieldRead | undefined => state.fields.get(fieldKey(path));
|
|
545
|
+
const optionsAt = (path: readonly string[]): readonly SelectOption[] =>
|
|
546
|
+
state.options.get(fieldKey(path)) ?? [];
|
|
547
|
+
const invalidAt = (path: readonly string[]): string | undefined =>
|
|
548
|
+
state.invalidAt.get(fieldKey(path)) ?? read(path)?.invalid;
|
|
549
|
+
|
|
550
|
+
const modes = collectModeIds(state);
|
|
551
|
+
const selected = textOf(read(["default"]));
|
|
552
|
+
return {
|
|
553
|
+
available: state.available,
|
|
554
|
+
writable: state.writable,
|
|
555
|
+
dirty: state.dirty,
|
|
556
|
+
invalid: state.invalid,
|
|
557
|
+
saving: state.saving,
|
|
558
|
+
failed: state.failed,
|
|
559
|
+
configured: state.configured,
|
|
560
|
+
readiness: state.configured
|
|
561
|
+
? "ready"
|
|
562
|
+
: status === "loading"
|
|
563
|
+
? "loading"
|
|
564
|
+
: status === "ready"
|
|
565
|
+
? "unreadable"
|
|
566
|
+
: "missing",
|
|
567
|
+
defaultMode: {
|
|
568
|
+
value: selected,
|
|
569
|
+
options: modes.map((id) => ({ value: id, label: labelOf(id, read) })),
|
|
570
|
+
invalid:
|
|
571
|
+
invalidAt(["default"]) ??
|
|
572
|
+
(selected === "" || modes.includes(selected) ? undefined : t("violation.defaultMissing")),
|
|
573
|
+
},
|
|
574
|
+
modes: modes.map((id) => ({
|
|
575
|
+
id,
|
|
576
|
+
title: labelOf(id, read),
|
|
577
|
+
role: arrayOf(read(["modes", id, "role"])),
|
|
578
|
+
summary: summaryOf(id, read, t),
|
|
579
|
+
model: modelOf(id, read),
|
|
580
|
+
deletable: !PROTECTED_MODE_IDS.includes(id),
|
|
581
|
+
staged: MODE_FIELDS.some((spec) => read(["modes", id, ...spec.path])?.staged === true),
|
|
582
|
+
groups: GROUPS.map((group) => ({
|
|
583
|
+
key: group.key,
|
|
584
|
+
label: t(group.labelKey),
|
|
585
|
+
fields: MODE_FIELDS.filter((spec) => spec.group === group.key).map((spec) =>
|
|
586
|
+
fieldView(id, spec, read, optionsAt, invalidAt, t),
|
|
587
|
+
),
|
|
588
|
+
})),
|
|
589
|
+
})),
|
|
590
|
+
violation: state.violation?.message,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 模式在页面上的标题:名称(没写名称就用 id)。
|
|
595
|
+
function labelOf(id: string, read: (path: readonly string[]) => FieldRead | undefined): string {
|
|
596
|
+
const name = textOf(read(["modes", id, "name"]));
|
|
597
|
+
return name === "" ? id : name;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 模式清单:从展开后的字段路径里收集 `modes.<id>` 的第二个段(顺序即配置顺序)。
|
|
601
|
+
function collectModeIds(state: SchemaFormState): string[] {
|
|
602
|
+
const ids: string[] = [];
|
|
603
|
+
for (const item of state.walked) {
|
|
604
|
+
if (item.path.length < 2 || item.path[0] !== "modes") continue;
|
|
605
|
+
const id = item.path[1];
|
|
606
|
+
if (id === undefined || ids.includes(id)) continue;
|
|
607
|
+
ids.push(id);
|
|
608
|
+
}
|
|
609
|
+
return ids;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// 这个模式配的默认模型:`defaultModel` 整块不在字段树里就是"跟随全局",其余情况给出服务商与模型当前值。
|
|
613
|
+
function modelOf(
|
|
614
|
+
id: string,
|
|
615
|
+
read: (path: readonly string[]) => FieldRead | undefined,
|
|
616
|
+
): { provider: string; model: string } | undefined {
|
|
617
|
+
if (read(["modes", id, "defaultModel"]) === undefined) return undefined;
|
|
618
|
+
return {
|
|
619
|
+
provider: textOf(read(["modes", id, "defaultModel", "provider"])),
|
|
620
|
+
model: textOf(read(["modes", id, "defaultModel", "model"])),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// 一个字段的视图。
|
|
625
|
+
function fieldView(
|
|
626
|
+
id: string,
|
|
627
|
+
spec: FieldSpec,
|
|
628
|
+
read: (path: readonly string[]) => FieldRead | undefined,
|
|
629
|
+
optionsAt: (path: readonly string[]) => readonly SelectOption[],
|
|
630
|
+
invalidAt: (path: readonly string[]) => string | undefined,
|
|
631
|
+
t: BundleTranslate,
|
|
632
|
+
): BundleFieldView {
|
|
633
|
+
const path = ["modes", id, ...spec.path];
|
|
634
|
+
const field = read(path);
|
|
635
|
+
const common = {
|
|
636
|
+
key: fieldKey(path),
|
|
637
|
+
path,
|
|
638
|
+
control: spec.control,
|
|
639
|
+
label: t(spec.labelKey),
|
|
640
|
+
hint: t(spec.hintKey),
|
|
641
|
+
present: field !== undefined,
|
|
642
|
+
overridden: field?.overridden === true,
|
|
643
|
+
options: optionsAt(path),
|
|
644
|
+
invalid: invalidAt(path),
|
|
645
|
+
};
|
|
646
|
+
if (spec.control === "tags" || spec.control === "roles") {
|
|
647
|
+
return { ...common, text: "", value: arrayOf(field) };
|
|
648
|
+
}
|
|
649
|
+
if (spec.control === "switch") {
|
|
650
|
+
return { ...common, text: "", value: field?.value === true };
|
|
651
|
+
}
|
|
652
|
+
if (spec.control === "tri") {
|
|
653
|
+
return { ...common, text: "", value: triOf(field) };
|
|
654
|
+
}
|
|
655
|
+
return { ...common, text: textOf(field), value: field?.value };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 三态字段(`skills`):`unset` 表示配置里没写这个键(按工具名单推导),另两档是显式的开关值。
|
|
659
|
+
function triOf(field: FieldRead | undefined): "unset" | "on" | "off" {
|
|
660
|
+
if (field === undefined || field.value === undefined) return "unset";
|
|
661
|
+
return field.value === true ? "on" : "off";
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// 一个模式的摘要行:工具面与模型面(页面上"一眼看出这个模式收窄了什么")。
|
|
665
|
+
function summaryOf(
|
|
666
|
+
id: string,
|
|
667
|
+
read: (path: readonly string[]) => FieldRead | undefined,
|
|
668
|
+
t: BundleTranslate,
|
|
669
|
+
): string {
|
|
670
|
+
const allowed = arrayOf(read(["modes", id, "allowTools"]));
|
|
671
|
+
const denied = arrayOf(read(["modes", id, "denyTools"]));
|
|
672
|
+
const provider = read(["modes", id, "defaultModel", "provider"])?.value;
|
|
673
|
+
const model = read(["modes", id, "defaultModel", "model"])?.value;
|
|
674
|
+
const tools =
|
|
675
|
+
allowed.length > 0
|
|
676
|
+
? t("summary.tools", { n: String(allowed.length) })
|
|
677
|
+
: denied.length > 0
|
|
678
|
+
? t("summary.denyTools", { n: String(denied.length) })
|
|
679
|
+
: t("summary.allTools");
|
|
680
|
+
const picked =
|
|
681
|
+
typeof model === "string" && model !== ""
|
|
682
|
+
? `${typeof provider === "string" && provider !== "" ? `${provider} / ` : ""}${model}`
|
|
683
|
+
: t("summary.globalModel");
|
|
684
|
+
return `${tools} · ${picked}`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// 字段的显示文本:草稿优先,否则格式化生效值。
|
|
688
|
+
function textOf(field: FieldRead | undefined): string {
|
|
689
|
+
if (field === undefined) return "";
|
|
690
|
+
if (typeof field.text === "string") return field.text;
|
|
691
|
+
return typeof field.value === "string" ? field.value : "";
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// 数组值:非数组(含缺省)当空数组。
|
|
695
|
+
function arrayOf(field: FieldRead | undefined): string[] {
|
|
696
|
+
const value = field?.value;
|
|
697
|
+
return Array.isArray(value) ? value.map((item) => String(item)) : ([] as string[]);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// 角色取值(页面用它画两个复选框)。
|
|
701
|
+
export const ROLES: readonly string[] = ROLE_VALUES;
|