@morlay/dsh-client-ui-primitives 0.0.2-alpha.8 → 0.0.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.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * 通用表单的提示面:业务知道、而 schema 表达不了的结构信息。
3
+ *
4
+ * 两件事:**dict 字段的候选键**(例如「会话模式」那一行,键是运行期清单里的模式 id,schema 里写不出来),以及
5
+ * **字段的候选值**(选服务商、选模型这类——候选来自部署里的另一份配置,还经常互相依赖)。业务在客户端注册一个
6
+ * 同步读数,表单一侧在投影时用它补出「未配置」行或把字段画成选择器。
7
+ *
8
+ * 读数必须是**同步**的:投影发生在渲染帧里。异步来源(HTTP / remote)由业务自己取好再注册;注册本身就当作一次
9
+ * 变更通知(所以"取到清单后再注册"是推荐用法)。
10
+ */
11
+
12
+ import { Service } from "@deepseek-ai/cordis";
13
+ import type { Context } from "@deepseek-ai/cordis";
14
+ import { DYNAMIC_SEGMENT } from "./schema-node.ts";
15
+
16
+ /** 一个 dict 字段的候选键读数。 */
17
+ export type SuggestedKeysReader = () => readonly string[];
18
+
19
+ /** 一个字段的文案(业务知道的说法;schema 上没有 title 这种位)。 */
20
+ export interface FieldText {
21
+ /** 短名字(行式视图里当注释的抬头)。 */
22
+ label?: string | undefined;
23
+ /** 一句话说明。 */
24
+ hint?: string | undefined;
25
+ }
26
+
27
+ /** 一个字段的文案读数。 */
28
+ export type FieldTextReader = () => FieldText;
29
+
30
+ /** 一个可选项:要写进去的值,以及页面上显示的名字。 */
31
+ export interface SelectOption {
32
+ /** 选中时写进配置的值。 */
33
+ value: unknown;
34
+ /** 显示名;省略就用值本身。 */
35
+ label?: string | undefined;
36
+ }
37
+
38
+ /** 一个字段的候选值读数。 */
39
+ export interface SelectSpec {
40
+ /**
41
+ * 候选依赖哪些**兄弟字段**(相对该字段所在对象的路径)。
42
+ *
43
+ * 依赖只声明"什么时候要重算":读侧在投影时按当前读数(草稿优先)求一次候选,所以声明了依赖的字段会随着
44
+ * 兄弟字段的编辑立刻换候选——「选完服务商才列它有哪些模型」就是这么来的。
45
+ */
46
+ dependsOn?: readonly (readonly string[])[] | undefined;
47
+ /**
48
+ * 求候选。
49
+ * @param read - 按依赖路径(相对该字段所在对象)读当前值:草稿优先。
50
+ * @returns 可选项,顺序即页面上的顺序。
51
+ */
52
+ options: (read: (path: readonly string[]) => unknown) => readonly SelectOption[];
53
+ }
54
+
55
+ /** 一个命名空间里各字段的候选键、文案与候选值。 */
56
+ export interface SchemaFormHintsFace {
57
+ /**
58
+ * 注册一个 dict 字段的候选键读数(重复注册同一处会覆盖,并通知一次)。
59
+ * @param ns - settings 命名空间(行 id)。
60
+ * @param path - 该 dict 字段的具体路径(顶层字段就是 `['models']`)。
61
+ * @param read - 同步读数:当前候选键(顺序即页面上的顺序)。
62
+ * @returns 注销函数。
63
+ */
64
+ suggestKeys(ns: string, path: readonly string[], read: SuggestedKeysReader): () => void;
65
+ /**
66
+ * 读某处当前声明的候选键。
67
+ * @param ns - settings 命名空间。
68
+ * @param path - 该字段的路径。
69
+ * @returns 候选键;没注册过就是空数组。
70
+ */
71
+ keysFor(ns: string, path: readonly string[]): readonly string[];
72
+
73
+ /**
74
+ * 注册一个字段的文案(短名 + 一句话说明)。行式视图把它画成注释行;schema 的 `description` 是兜底。
75
+ * @param ns - settings 命名空间。
76
+ * @param path - 该字段的路径。
77
+ * @param read - 同步读数。
78
+ * @returns 注销函数。
79
+ */
80
+ describe(ns: string, path: readonly string[], read: FieldTextReader): () => void;
81
+
82
+ /**
83
+ * 读某处当前注册的文案。
84
+ * @param ns - settings 命名空间。
85
+ * @param path - 该字段的路径。
86
+ * @returns 文案;没注册过就是空对象。
87
+ */
88
+ textFor(ns: string, path: readonly string[]): FieldText;
89
+
90
+ /**
91
+ * 注册一个字段的候选值读数(页面据此把字段画成选择器,并随依赖字段变化重算)。
92
+ * @param ns - settings 命名空间。
93
+ * @param path - 该字段的路径。
94
+ * @param spec - 候选取法与它依赖的兄弟字段。
95
+ * @returns 注销函数。
96
+ */
97
+ select(ns: string, path: readonly string[], spec: SelectSpec): () => void;
98
+
99
+ /**
100
+ * 注册一个**具名候选源**:schema 上用 `role('select', { source })` 认领它,字段因此不必在客户端按 ns/path
101
+ * 登记——同一个源可以被多行、多个字段复用(「服务商」「模型」就是这种)。
102
+ * @param name - 源的名字(schema 的 `role` extra 里写的那一个)。
103
+ * @param spec - 候选取法与它依赖的兄弟字段。
104
+ * @returns 注销函数。
105
+ */
106
+ source(name: string, spec: SelectSpec): () => void;
107
+
108
+ /**
109
+ * 读一个具名候选源。
110
+ * @param name - 源的名字。
111
+ * @returns 该源的读数;没注册过就是 `undefined`。
112
+ */
113
+ sourceFor(name: string): SelectSpec | undefined;
114
+
115
+ /**
116
+ * 读某处当前声明的候选值读数。
117
+ * @param ns - settings 命名空间。
118
+ * @param path - 该字段的路径。
119
+ * @returns 候选读数;没注册过就是 `undefined`。
120
+ */
121
+ selectFor(ns: string, path: readonly string[]): SelectSpec | undefined;
122
+ }
123
+
124
+ /** 提示面的完整服务(业务用注册侧,表单一侧用读侧)。 */
125
+ export class SchemaFormHints extends Service implements SchemaFormHintsFace {
126
+ readonly #readers = new Map<string, SuggestedKeysReader>();
127
+ readonly #texts = new Map<string, FieldTextReader>();
128
+ readonly #selects = new Map<string, SelectSpec>();
129
+ readonly #sources = new Map<string, SelectSpec>();
130
+ readonly #listeners = new Set<() => void>();
131
+
132
+ /** @param ctx - 提供本服务的插件上下文。 */
133
+ constructor(ctx: Context) {
134
+ super(ctx, "schemaFormHints");
135
+ }
136
+
137
+ /** @param ns - settings 命名空间。 @param path - 字段路径。 @returns 该处的键。 */
138
+ keysFor(ns: string, path: readonly string[]): readonly string[] {
139
+ return lookup(this.#readers, ns, path)?.() ?? [];
140
+ }
141
+
142
+ /** @param ns - settings 命名空间。 @param path - 字段路径。 @returns 该处注册的文案。 */
143
+ textFor(ns: string, path: readonly string[]): FieldText {
144
+ return lookup(this.#texts, ns, path)?.() ?? {};
145
+ }
146
+
147
+ /**
148
+ * @param ns - settings 命名空间。
149
+ * @param path - 字段路径。
150
+ * @param spec - 候选取法与依赖。
151
+ * @returns 注销函数。
152
+ */
153
+ select(ns: string, path: readonly string[], spec: SelectSpec): () => void {
154
+ const key = locationKey(ns, path);
155
+ this.#selects.set(key, spec);
156
+ this.#publish();
157
+ return () => {
158
+ if (this.#selects.get(key) !== spec) return;
159
+ this.#selects.delete(key);
160
+ this.#publish();
161
+ };
162
+ }
163
+
164
+ /** @param ns - settings 命名空间。 @param path - 字段路径。 @returns 该处注册的候选读数。 */
165
+ selectFor(ns: string, path: readonly string[]): SelectSpec | undefined {
166
+ return lookup(this.#selects, ns, path);
167
+ }
168
+
169
+ /**
170
+ * @param name - 源的名字。
171
+ * @param spec - 候选取法与依赖。
172
+ * @returns 注销函数。
173
+ */
174
+ source(name: string, spec: SelectSpec): () => void {
175
+ this.#sources.set(name, spec);
176
+ this.#publish();
177
+ return () => {
178
+ if (this.#sources.get(name) !== spec) return;
179
+ this.#sources.delete(name);
180
+ this.#publish();
181
+ };
182
+ }
183
+
184
+ /** @param name - 源的名字。 @returns 该源注册的候选读数。 */
185
+ sourceFor(name: string): SelectSpec | undefined {
186
+ return this.#sources.get(name);
187
+ }
188
+
189
+ /**
190
+ * @param ns - settings 命名空间。
191
+ * @param path - 字段路径。
192
+ * @param read - 同步读数。
193
+ * @returns 注销函数。
194
+ */
195
+ describe(ns: string, path: readonly string[], read: FieldTextReader): () => void {
196
+ const key = locationKey(ns, path);
197
+ this.#texts.set(key, read);
198
+ this.#publish();
199
+ return () => {
200
+ if (this.#texts.get(key) !== read) return;
201
+ this.#texts.delete(key);
202
+ this.#publish();
203
+ };
204
+ }
205
+
206
+ /**
207
+ * @param ns - settings 命名空间。
208
+ * @param path - 字段路径。
209
+ * @param read - 同步读数。
210
+ * @returns 注销函数。
211
+ */
212
+ suggestKeys(ns: string, path: readonly string[], read: SuggestedKeysReader): () => void {
213
+ const key = locationKey(ns, path);
214
+ this.#readers.set(key, read);
215
+ this.#publish();
216
+ return () => {
217
+ if (this.#readers.get(key) !== read) return;
218
+ this.#readers.delete(key);
219
+ this.#publish();
220
+ };
221
+ }
222
+
223
+ /**
224
+ * 观察候选键的变化(注册与注销)。
225
+ * @param listener - 变更回调。
226
+ * @returns 取消订阅。
227
+ */
228
+ subscribe(listener: () => void): () => void {
229
+ this.#listeners.add(listener);
230
+ return () => {
231
+ this.#listeners.delete(listener);
232
+ };
233
+ }
234
+
235
+ /**
236
+ * 重新通知一次:读数背后的数据变了(业务自己知道),页面据此重算候选行。
237
+ */
238
+ refresh(): void {
239
+ this.#publish();
240
+ }
241
+
242
+ #publish(): void {
243
+ for (const listener of this.#listeners) listener();
244
+ }
245
+ }
246
+
247
+ /** 一处字段的定位键(命名空间 + 路径)。 */
248
+ function locationKey(ns: string, path: readonly string[]): string {
249
+ return `${ns}\u0000${path.join("\u0000")}`;
250
+ }
251
+
252
+ /**
253
+ * 按具体路径找一处注册:先精确匹配,再试**模板变体**(动态键 / 数组下标的段写成 `*`)。
254
+ *
255
+ * 业务因此可以注册一次 `models.*.provider` 就覆盖每个模式,不必知道运行期有哪些键。
256
+ * @param map - 某类注册表。
257
+ * @param ns - settings 命名空间。
258
+ * @param path - 具体路径。
259
+ * @returns 命中的注册,或 `undefined`。
260
+ */
261
+ function lookup<T>(map: Map<string, T>, ns: string, path: readonly string[]): T | undefined {
262
+ const exact = map.get(locationKey(ns, path));
263
+ if (exact !== undefined) return exact;
264
+ for (const masked of templates(path)) {
265
+ const hit = map.get(locationKey(ns, masked));
266
+ if (hit !== undefined) return hit;
267
+ }
268
+ return undefined;
269
+ }
270
+
271
+ /** 具体路径的模板变体(首段是顶层字段名,保持原样):通配得多的先试。 */
272
+ function* templates(path: readonly string[]): Generator<readonly string[]> {
273
+ const wildcardable = Math.max(0, path.length - 1);
274
+ for (let bits = (1 << wildcardable) - 1; bits > 0; bits--) {
275
+ yield path.map((segment, index) =>
276
+ index > 0 && (bits & (1 << (index - 1))) !== 0 ? DYNAMIC_SEGMENT : segment,
277
+ );
278
+ }
279
+ }
280
+
281
+ declare module "@deepseek-ai/cordis" {
282
+ interface Context {
283
+ /** 通用表单的提示面(dict 的候选键)。 */
284
+ schemaFormHints: SchemaFormHints;
285
+ }
286
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * client 半:为每个可由 schema 生成配置页的行注册本行的配置入口,并给出字段级自定义输入的槽契约。
3
+ *
4
+ * | 面 | 呈现 |
5
+ * | ------------------------------------ | ---------------------------------------------------------------- |
6
+ * | `plugins.row.config`(key 见 `rows.ts`) | `view: 'page'` 渲染该行 schema 生成的表单;`summary` 不画东西 |
7
+ * | Factory `settings.schema-form.form` | 行注册项经它渲染同一份表单体,字段槽因此只声明一次 |
8
+ * | `settings.schema-form.field` | chain 槽:业务方按 `role` 或 `ns + path` 认领字段的控件与文案 |
9
+ *
10
+ * 数据面是 host 的 settings 投影与共享配置表单(`ctx.configForms` / `ctx.settingsSchema`);bundle 与行的对应关系
11
+ * 来自 `remote.pluginManager.listBundles()`。手写卡片用默认 priority 0 注册,按 slots 的 cell winner 规则自然遮住
12
+ * 这里的自动项,因此两种页面可以并存、迁移不必一次做完。
13
+ */
14
+
15
+ import type { Context } from "@deepseek-ai/cordis";
16
+ import type {} from "@deepseek-ai/dsh-api-remotes/client";
17
+ import type {} from "@deepseek-ai/dsh-api-remotes/types";
18
+ import type {} from "@deepseek-ai/dsh-client-locale/client";
19
+ import type {} from "@deepseek-ai/dsh-client-ui-plugin-manager/client";
20
+ import type {} from "@deepseek-ai/dsh-client-ui-renderer/client";
21
+ import type {} from "@deepseek-ai/dsh-client-ui-settings/client";
22
+ import type { PropsRenderFactories, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
23
+ import type { ReactNode } from "react";
24
+ import { SchemaFormController, canRender, type SchemaFormFace } from "./controller.ts";
25
+ import { failureOf, type ValidationFailure } from "./draft.ts";
26
+ import { SchemaFormHints } from "./hints.ts";
27
+ import { en, zh } from "./locales.ts";
28
+ import { RowRegistration } from "./rows.ts";
29
+ import { SchemaForm } from "./SchemaForm.tsx";
30
+ import type { SchemaFormProps } from "./slot-contract.ts";
31
+
32
+ /** 本包字典与槽位文案的命名空间。 */
33
+ export const NS = "settings.schema-form";
34
+
35
+ /** 需要的服务:槽位、字典、共享配置表单、schema 服务与插件管理(bundle 行清单)。 */
36
+ export const inject = [
37
+ "slots",
38
+ "locale",
39
+ "configForms",
40
+ "settingsSchema",
41
+ "remote",
42
+ "remote.pluginManager",
43
+ ];
44
+
45
+ export type {
46
+ SchemaFieldComponentProps,
47
+ SchemaFieldMatch,
48
+ SchemaFieldOwnerProps,
49
+ SchemaFieldSelect,
50
+ SchemaFormProps,
51
+ } from "./slot-contract.ts";
52
+ export type { SchemaFormActions, SchemaFormFace, SchemaFormState } from "./controller.ts";
53
+ export type { FieldNode, FieldMeta, WalkedField } from "./schema-node.ts";
54
+ export type { SchemaFormLocaleKey } from "./locales.ts";
55
+ export type { RowRegistrationPlan } from "./rows.ts";
56
+ export { rowRegistrations } from "./rows.ts";
57
+ export { SchemaFieldDefault, InlineValue, tokenText, tokenTone, valueText } from "./value.tsx";
58
+ export { parseFor } from "./fields.tsx";
59
+ export { containerShape, editorLines, visibleFields } from "./lines.ts";
60
+ export type { EditorLine, FoldState } from "./lines.ts";
61
+ export { SchemaFormController, canRender, optionsFor, projectRoot } from "./controller.ts";
62
+ export { failureOf } from "./draft.ts";
63
+ export type { ValidationFailure } from "./draft.ts";
64
+ export { walkFields, projectNode } from "./schema-node.ts";
65
+ export { SchemaFormHints } from "./hints.ts";
66
+ export type {
67
+ FieldText,
68
+ SchemaFormHintsFace,
69
+ SelectOption,
70
+ SelectSpec,
71
+ SuggestedKeysReader,
72
+ } from "./hints.ts";
73
+ export type { SuggestedKeys } from "./schema-node.ts";
74
+
75
+ /** 行配置入口的注册项:`page` 画表单,`summary` 不画(行的描述来自包自己的 locale)。 */
76
+ type RowConfigEntryProps = PropsRuntime<"plugins.row.config"> & PropsRenderFactories;
77
+
78
+ /**
79
+ * 装上本包:字典、Factory(字段槽的声明者)与按行自动注册。
80
+ * @param ctx - 浏览器插件上下文。
81
+ */
82
+ export function apply(ctx: Context): void {
83
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "ui-schema-form: dictionaries");
84
+ // 提示面(dict 的候选键):同一次 apply 里提供,业务插件注册读数,本包的行投影读它。
85
+ const hints = new SchemaFormHints(ctx);
86
+ ctx.effect(
87
+ () =>
88
+ ctx.slots.registerFactory(
89
+ {
90
+ name: "settings.schema-form.form",
91
+ scope: "root",
92
+ children: { "settings.schema-form.field": { kind: "chain", scope: "root" } },
93
+ locale: NS,
94
+ },
95
+ SchemaForm,
96
+ ),
97
+ "ui-schema-form: form factory",
98
+ );
99
+
100
+ const rehydrate = (serialized: unknown) => ctx.settingsSchema.rehydrate(serialized);
101
+ // `ctx.settingsSchema.validate` 只给消息(丢了路径),这里自己接住错误:行内报错要知道挂在哪一行。
102
+ const validate = (
103
+ schema: Parameters<typeof ctx.settingsSchema.validate>[0],
104
+ value: unknown,
105
+ ): ValidationFailure | undefined => {
106
+ try {
107
+ (schema as unknown as (input: unknown) => unknown)(value);
108
+ return undefined;
109
+ } catch (error) {
110
+ return failureOf(error);
111
+ }
112
+ };
113
+ const resolveText = (text: string | Readonly<Record<string, string>>): string =>
114
+ typeof text === "string" ? text : ctx.locale.resolveText(text as never);
115
+
116
+ const entries = new Map<
117
+ string,
118
+ { controller: SchemaFormController; face: SchemaFormFace; component: unknown }
119
+ >();
120
+ const entryFor = (ns: string): { component: unknown } | undefined => {
121
+ const describe = ctx.configForms.describe();
122
+ const view = describe.getSnapshot().view?.namespaces.find((row) => row.ns === ns);
123
+ if (!canRender(view, rehydrate)) return undefined;
124
+ const existing = entries.get(ns);
125
+ if (existing !== undefined) return existing;
126
+ const controller = new SchemaFormController(ns, {
127
+ form: ctx.configForms.get(ns),
128
+ describe,
129
+ rehydrate,
130
+ validate,
131
+ t: ctx.locale.bind(NS),
132
+ hints: {
133
+ keysFor: (path) => hints.keysFor(ns, path),
134
+ textFor: (path) => hints.textFor(ns, path),
135
+ selectFor: (path) => hints.selectFor(ns, path),
136
+ sourceFor: (name) => hints.sourceFor(name),
137
+ },
138
+ });
139
+ const face = controller.face();
140
+ const props: SchemaFormProps = { ns, face, resolveText };
141
+ const component = (entryProps: RowConfigEntryProps): ReactNode =>
142
+ entryProps.view === "page"
143
+ ? entryProps.renderFactorySlot("settings.schema-form.form", props)
144
+ : null;
145
+ const entry = { controller, face, component };
146
+ entries.set(ns, entry);
147
+ return entry;
148
+ };
149
+
150
+ const registration = new RowRegistration({
151
+ // 槽位注册的 options 由 slots 的窄化签名收口;这里给的是它接受的同一形状(name/key/priority/locale)。
152
+ slots: ctx.slots as never,
153
+ describe: ctx.configForms.describe(),
154
+ // 读不到 bundle 清单就什么都不注册(下一次 describe 变化或插件变更还会再来一遍)。
155
+ bundles: async () => {
156
+ const response = await ctx.remote.pluginManager.listBundles();
157
+ return response.ok ? response.value : [];
158
+ },
159
+ subscribeBundles: (listener) => ctx.remote.$on("plugin-manager/changed", listener),
160
+ entryFor,
161
+ locale: NS,
162
+ release: (ns) => {
163
+ const entry = entries.get(ns);
164
+ entries.delete(ns);
165
+ entry?.controller.dispose();
166
+ },
167
+ });
168
+
169
+ ctx.effect(() => {
170
+ const offDescribe = ctx.configForms.describe().subscribe(() => {
171
+ void registration.sync();
172
+ });
173
+ // 候选键变了(业务取到清单后才注册、或主动 refresh):让已经建好的行重投影一次。
174
+ const offHints = hints.subscribe(() => {
175
+ for (const entry of entries.values()) entry.controller.refresh();
176
+ });
177
+ void registration.sync();
178
+ return () => {
179
+ offDescribe();
180
+ offHints();
181
+ registration.dispose();
182
+ for (const [ns, entry] of entries) {
183
+ entries.delete(ns);
184
+ entry.controller.dispose();
185
+ }
186
+ };
187
+ }, "ui-schema-form: row registrations");
188
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 字段文案:标签兜底与说明解析。
3
+ *
4
+ * 标签没有 schema 声明可用(schemastery 的 meta 没有 title/label),所以先是字段名兜底(驼峰/下划线拆词),
5
+ * 业务方经字段槽给 label/hint 时以槽为准;说明取 `description`(可本地化)与 `comment`。
6
+ */
7
+
8
+ import type { FieldNode } from "./schema-node.ts";
9
+
10
+ /** 把 schema 里的本地化文本解析成当前语言;由 locale 服务提供。 */
11
+ export type ResolveText = (text: string | Readonly<Record<string, string>>) => string;
12
+
13
+ /** 字段名兜底的可读标签:拆词并首字母大写,`0`/`1` 这类索引保持原样。 */
14
+ export function humanizeKey(key: string): string {
15
+ if (key.length === 0) return "";
16
+ if (/^\d+$/.test(key)) return key;
17
+ const words = key
18
+ .replace(/[_-]+/g, " ")
19
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
20
+ .trim();
21
+ return words.charAt(0).toUpperCase() + words.slice(1);
22
+ }
23
+
24
+ /** 字段的说明(`description` 本地化后 + `comment`),没有就是 undefined。 */
25
+ export function fieldHint(node: FieldNode, resolveText: ResolveText): string | undefined {
26
+ const parts: string[] = [];
27
+ if (node.meta.description !== undefined) parts.push(resolveText(node.meta.description));
28
+ if (node.meta.comment !== undefined) parts.push(node.meta.comment);
29
+ return parts.length === 0 ? undefined : parts.join(" ");
30
+ }