@morlay/dsh-session-mode 0.0.7 → 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.
@@ -0,0 +1,630 @@
1
+ // bundle 配置页:装这个 bundle 的那一行(`session-mode`)的完整配置——新会话的默认模式 + 每个模式一张可折叠卡片。
2
+ //
3
+ // 注册进 `plugins.bundle.config`(key = bundle 包名),读写的命名空间是 `session-mode`。草稿、整段校验与保存都归通用
4
+ // schema 表单的控制器(`bundle-config.ts` 的注入面),本文件只管布局与装配:**控件一律从
5
+ // `@morlay/dsh-client-ui-primitives/client` 取**(它转出官方那套基础组件,并给出这套设置面自有的控件——
6
+ // 字段行 `SettingsFieldRow`、可搜索选择器 `SearchSelect`、标签输入 `TagInput`、多行文本 `MultilineField`、图标按钮
7
+ // `IconButton`),这一页自己的样式只剩结构布局。改动一律先落草稿,底部保存是唯一写盘点。
8
+
9
+ import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
10
+ import {
11
+ Button,
12
+ DisclosureRow,
13
+ IconAgentPresetOutlineRegular,
14
+ IconButton,
15
+ IconPlusOutlineRegular,
16
+ IconTrashOutlineRegular,
17
+ Input,
18
+ Modal,
19
+ ModelRouteList,
20
+ MultilineField,
21
+ Row,
22
+ SegmentedControl,
23
+ SearchSelect,
24
+ SettingsFieldRow,
25
+ SettingsForm,
26
+ Stack,
27
+ Switch,
28
+ TagInput,
29
+ Text,
30
+ type SettingsFormLabels,
31
+ } from "@morlay/dsh-client-ui-primitives/client";
32
+ import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
33
+ import type { SchemaFormState } from "@morlay/dsh-client-ui-primitives/client";
34
+ import {
35
+ SESSION_MODE_NS,
36
+ projectBundleConfig,
37
+ ROLES,
38
+ type BundleConfigActions,
39
+ type BundleConfigFace,
40
+ type ConfigStatus,
41
+ type BundleFieldView,
42
+ type BundleModeView,
43
+ type BundleModelsState,
44
+ type BundleTranslate,
45
+ } from "./bundle-config.ts";
46
+ import { routeKey } from "./llm-directory.ts";
47
+
48
+ export type BundleConfigPageProps = PropsRuntime<"plugins.bundle.config"> &
49
+ PropsLocale<"session-mode-bundle"> &
50
+ InjectFace<BundleConfigFace>;
51
+
52
+ // tri 控件的三个档:`unset` 是"配置里没写这个键"(按工具名单推导),另两档是显式开关值。
53
+ type TriValue = "unset" | "on" | "off";
54
+
55
+ export function BundleConfigPage(props: BundleConfigPageProps): ReactNode {
56
+ const { t, view } = props;
57
+ // 子组件只收动作面:读数走框架绑好的 `useBundleConfig`,动作是同一份注入面里的函数。
58
+ const face: BundleConfigActions = {
59
+ set: props.set,
60
+ editText: props.editText,
61
+ clear: props.clear,
62
+ appendItem: props.appendItem,
63
+ removeItem: props.removeItem,
64
+ addMode: props.addMode,
65
+ removeMode: props.removeMode,
66
+ save: props.save,
67
+ discard: props.discard,
68
+ };
69
+ // 读数取原始快照(引用稳定,uSES 语义正确),投影在渲染里做。
70
+ const snapshot = props.useBundleConfig((current: SchemaFormState) => current);
71
+ const status = props.useBundleStatus((current: ConfigStatus) => current);
72
+ const models = props.useBundleModels((current: BundleModelsState) => current);
73
+ const state = useMemo(() => projectBundleConfig(snapshot, t, status), [snapshot, t, status]);
74
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set<string>());
75
+ const [addingId, setAddingId] = useState("");
76
+ // "添加模式"的弹窗:由"模式"标题右边那个 + 图标按钮打开。
77
+ const [addingOpen, setAddingOpen] = useState(false);
78
+ // 要删的那个模式:删除是不可逆的一步,先过一次确认弹窗(保存之前仍可丢弃)。
79
+ const [removing, setRemoving] = useState<BundleModeView | null>(null);
80
+ // 挂载时强制重读一次 describe:视图晚到、通知没落上时,这一步把读数拉平(有草稿时它自己跳过)。
81
+ const resync = props.resync;
82
+ useEffect(() => {
83
+ resync();
84
+ }, [resync]);
85
+ // 槽位注册项只为 `page` 视图存在(bundle 页不给 `summary` 座位)。
86
+ if (view !== "page") return null;
87
+ if (state.readiness !== "ready") {
88
+ // 只有异常态才做诊断(它读 describe 与 schema,正常路径一次也不跑)。
89
+ const diagnosis = props.diagnose();
90
+ const message =
91
+ state.readiness === "loading"
92
+ ? t("configured.loading")
93
+ : state.readiness === "missing"
94
+ ? t("configured.missing")
95
+ : t("configured.unreadable", { problem: diagnosis.problem });
96
+ return (
97
+ <div data-bundle-config={state.readiness} data-namespace={SESSION_MODE_NS}>
98
+ <Text
99
+ as="p"
100
+ size="sm"
101
+ tone="tertiary"
102
+ data-namespaces={diagnosis.namespaces.join(",")}
103
+ data-problem={diagnosis.problem}
104
+ data-controller={diagnosis.controller}
105
+ >
106
+ {message}
107
+ </Text>
108
+ {state.readiness !== "unreadable" ? null : (
109
+ // 读不出来时把诊断也画出来:这时页面本来就用不了,原因说在明面上比藏在属性里有用。
110
+ <Text as="p" size="xs" tone="tertiary" mono data-diagnosis="true">
111
+ {`${diagnosis.problem} · ${diagnosis.controller} · namespaces=[${diagnosis.namespaces.join(", ")}]`}
112
+ </Text>
113
+ )}
114
+ </div>
115
+ );
116
+ }
117
+ const labels: SettingsFormLabels = {
118
+ unavailable: t("unavailable"),
119
+ readOnly: t("readOnly"),
120
+ saveFailed: t("saveFailed"),
121
+ save: t("save"),
122
+ saving: t("saving"),
123
+ };
124
+ const toggle = (id: string): void => {
125
+ setExpanded((previous) => {
126
+ const next = new Set(previous);
127
+ if (next.has(id)) next.delete(id);
128
+ else next.add(id);
129
+ return next;
130
+ });
131
+ };
132
+ return (
133
+ <SettingsForm labels={labels} state={state} onSave={face.save} onDiscard={face.discard}>
134
+ <Stack gap={16} data-bundle-config="page">
135
+ <Stack as="section" gap={8} data-section="default">
136
+ <SettingsFieldRow
137
+ label={t("default.label")}
138
+ hint={t("default.hint")}
139
+ layout="inline"
140
+ {...(state.defaultMode.invalid === undefined ? {} : { invalid: state.defaultMode.invalid })}
141
+ data-field="default"
142
+ data-control="choice"
143
+ >
144
+ <SearchSelect
145
+ label={t("default.label")}
146
+ value={state.defaultMode.value}
147
+ emptyLabel={t("tri.unset")}
148
+ options={state.defaultMode.options.map((option) => ({
149
+ value: String(option.value),
150
+ ...(option.label === undefined ? {} : { label: option.label }),
151
+ }))}
152
+ searchLabel={t("select.search")}
153
+ noMatchLabel={t("select.noMatch")}
154
+ disabled={!state.writable}
155
+ onSelect={(next) => {
156
+ if (next === "") face.clear(["default"]);
157
+ else face.set(["default"], next);
158
+ }}
159
+ />
160
+ </SettingsFieldRow>
161
+ </Stack>
162
+
163
+ <Stack as="section" gap={8} data-section="modes">
164
+ {/* 这一段的第一个 child 是 `row(col(标题, 说明), 控件)`:左列标题与说明同列,添加入口贴最右。 */}
165
+ <Row gap={8} justify="between">
166
+ <Stack gap={2} grow>
167
+ <Text as="h4" size="md" tone="primary" weight="strong">
168
+ {t("modes.label")}
169
+ </Text>
170
+ <Text as="p" size="sm" tone="tertiary">
171
+ {t("modes.hint")}
172
+ </Text>
173
+ </Stack>
174
+ <IconButton
175
+ label={t("add.label")}
176
+ data-action="open-add-mode"
177
+ disabled={!state.writable}
178
+ onClick={() => {
179
+ setAddingId("");
180
+ setAddingOpen(true);
181
+ }}
182
+ >
183
+ <IconPlusOutlineRegular />
184
+ </IconButton>
185
+ </Row>
186
+ {state.modes.map((mode) => (
187
+ <ModeCard
188
+ key={mode.id}
189
+ mode={mode}
190
+ models={models}
191
+ open={expanded.has(mode.id)}
192
+ writable={state.writable}
193
+ t={t}
194
+ face={face}
195
+ onRequestRemove={() => {
196
+ setRemoving(mode);
197
+ }}
198
+ onToggle={() => {
199
+ toggle(mode.id);
200
+ }}
201
+ />
202
+ ))}
203
+ </Stack>
204
+ </Stack>
205
+ <Modal
206
+ open={addingOpen}
207
+ onClose={() => {
208
+ setAddingOpen(false);
209
+ }}
210
+ title={t("add.label")}
211
+ closeLabel={t("add.close")}
212
+ description={t("add.hint")}
213
+ footer={
214
+ <>
215
+ <Button
216
+ variant="outline"
217
+ onClick={() => {
218
+ setAddingOpen(false);
219
+ }}
220
+ >
221
+ {t("add.cancel")}
222
+ </Button>
223
+ <Button
224
+ variant="primary"
225
+ data-action="add-mode"
226
+ disabled={addingId.trim() === ""}
227
+ onClick={() => {
228
+ const id = addingId.trim();
229
+ face.addMode(id);
230
+ setAddingId("");
231
+ setAddingOpen(false);
232
+ setExpanded((previous) => new Set(previous).add(id));
233
+ }}
234
+ >
235
+ {t("add.confirm")}
236
+ </Button>
237
+ </>
238
+ }
239
+ >
240
+ <Input
241
+ data-field="new-mode"
242
+ data-modal-autofocus
243
+ value={addingId}
244
+ placeholder={t("add.placeholder")}
245
+ aria-label={t("add.label")}
246
+ onChange={(event) => {
247
+ setAddingId(event.currentTarget.value);
248
+ }}
249
+ />
250
+ </Modal>
251
+ <Modal
252
+ open={removing !== null}
253
+ onClose={() => {
254
+ setRemoving(null);
255
+ }}
256
+ title={t("remove.title")}
257
+ closeLabel={t("remove.close")}
258
+ description={t("remove.description", { name: removing?.title ?? "" })}
259
+ footer={
260
+ <>
261
+ <Button
262
+ variant="outline"
263
+ onClick={() => {
264
+ setRemoving(null);
265
+ }}
266
+ >
267
+ {t("remove.cancel")}
268
+ </Button>
269
+ <Button
270
+ variant="primary"
271
+ tone="danger"
272
+ data-action="confirm-remove"
273
+ onClick={() => {
274
+ if (removing !== null) face.removeMode(removing.id);
275
+ setRemoving(null);
276
+ }}
277
+ >
278
+ {t("remove.confirm")}
279
+ </Button>
280
+ </>
281
+ }
282
+ />
283
+ </SettingsForm>
284
+ );
285
+ }
286
+
287
+ // 一张模式卡片:折叠头显示名称与摘要,展开后是分组字段与删除入口。
288
+ function ModeCard({
289
+ mode,
290
+ models,
291
+ open,
292
+ writable,
293
+ t,
294
+ face,
295
+ onRequestRemove,
296
+ onToggle,
297
+ }: {
298
+ mode: BundleModeView;
299
+ // 部署里"已设置的模型"(默认模型那一组的清单用它)。
300
+ models: BundleModelsState;
301
+ open: boolean;
302
+ writable: boolean;
303
+ t: BundleTranslate;
304
+ face: BundleConfigActions;
305
+ // 删除要过确认弹窗:卡片只上报"想删这一个",真正删谁由页面在弹窗里定。
306
+ onRequestRemove: () => void;
307
+ onToggle: () => void;
308
+ }): ReactNode {
309
+ const hasModel = mode.model !== undefined;
310
+ return (
311
+ <Stack
312
+ boxed
313
+ pad="card"
314
+ data-mode={mode.id}
315
+ data-deletable={mode.deletable ? "true" : "false"}
316
+ >
317
+ <DisclosureRow
318
+ icon={<IconAgentPresetOutlineRegular />}
319
+ title={mode.title}
320
+ open={open}
321
+ expandable
322
+ expandOnRowClick
323
+ onToggle={onToggle}
324
+ keepContentWhenOpen
325
+ collapsedContent={
326
+ // 两段:先是紧挨名称的 id,再是贴最右的摘要 + 删除。
327
+ <>
328
+ <Text as="code" size="sm" tone="tertiary" data-mode-id>
329
+ {mode.id}
330
+ </Text>
331
+ <Row gap={8} push>
332
+ <Text as="span" size="sm" tone="tertiary">{mode.summary}</Text>
333
+ {mode.deletable ? (
334
+ <IconButton
335
+ size="sm"
336
+ data-action="remove-mode"
337
+ disabled={!writable}
338
+ label={t("removeNamed", { name: mode.title })}
339
+ onClick={(event) => {
340
+ // 行本身是折叠开关:删除先拦下这次点击,别顺手把卡片收起来(确认弹窗照旧)。
341
+ event.stopPropagation();
342
+ onRequestRemove();
343
+ }}
344
+ >
345
+ <IconTrashOutlineRegular />
346
+ </IconButton>
347
+ ) : (
348
+ <Text as="span" size="sm" tone="tertiary" data-protected="true">
349
+ {t("protected")}
350
+ </Text>
351
+ )}
352
+ </Row>
353
+ </>
354
+ }
355
+ >
356
+ <Stack gap={14} pad="body">
357
+ {mode.groups.map((group) => (
358
+ <Stack key={group.key} gap={0} data-group={group.key}>
359
+ <Row gap={8} justify="between">
360
+ <Text size="sm" tone="secondary" weight="strong">{group.label}</Text>
361
+ </Row>
362
+ {group.key === "model" ? (
363
+ <>
364
+ {/* 整块 `defaultModel` 是一个可加字段:开关开 = 这个模式自带默认模型,关 = 跟随全局。 */}
365
+ <SettingsFieldRow
366
+ label={t("field.defaultModel")}
367
+ hint={t("hint.defaultModel")}
368
+ layout="inline"
369
+ data-control="default-model"
370
+ >
371
+ <Switch
372
+ checked={hasModel}
373
+ label={t("field.defaultModel")}
374
+ disabled={!writable}
375
+ onChange={(next) => {
376
+ if (next) {
377
+ face.set(["modes", mode.id, "defaultModel"], { provider: "", model: "" });
378
+ } else {
379
+ face.clear(["modes", mode.id, "defaultModel"]);
380
+ }
381
+ }}
382
+ />
383
+ </SettingsFieldRow>
384
+ {!hasModel ? null : (
385
+ <SettingsFieldRow
386
+ label={t("field.modelRoute")}
387
+ hint={t("hint.modelRoute")}
388
+ divider
389
+ data-control="model-routes"
390
+ >
391
+ <ModelRouteList
392
+ label={t("field.modelRoute")}
393
+ candidates={models.routes}
394
+ selectedKey={
395
+ mode.model === undefined || mode.model.provider === ""
396
+ ? undefined
397
+ : routeKey(mode.model.provider, mode.model.model)
398
+ }
399
+ disabled={!writable}
400
+ status={models.status}
401
+ loadingLabel={t("model.loading")}
402
+ errorLabel={t("model.error")}
403
+ emptyLabel={t("model.empty")}
404
+ onSelect={(candidate) => {
405
+ // 服务商与模型是一对:选一条路由等于把两个字段一起定下来。
406
+ face.set(["modes", mode.id, "defaultModel"], {
407
+ provider: candidate.provider,
408
+ model: candidate.model,
409
+ });
410
+ }}
411
+ />
412
+ </SettingsFieldRow>
413
+ )}
414
+ {group.fields.map((field, index) => (
415
+ <FieldRow
416
+ key={field.key}
417
+ field={field}
418
+ writable={writable}
419
+ t={t}
420
+ face={face}
421
+ divider={index > 0 || hasModel}
422
+ />
423
+ ))}
424
+ </>
425
+ ) : (
426
+ group.fields.map((field, index) => (
427
+ <FieldRow
428
+ key={field.key}
429
+ field={field}
430
+ writable={writable}
431
+ t={t}
432
+ face={face}
433
+ divider={index > 0}
434
+ />
435
+ ))
436
+ )}
437
+ </Stack>
438
+ ))}
439
+ </Stack>
440
+ </DisclosureRow>
441
+ </Stack>
442
+ );
443
+ }
444
+
445
+ // 一个字段:位置(标签 / 说明 / 徽标 / 分隔线)归 `SettingsFieldRow`,控件本体归 `FieldControl`。
446
+ function FieldRow({
447
+ field,
448
+ writable,
449
+ divider,
450
+ t,
451
+ face,
452
+ }: {
453
+ field: BundleFieldView;
454
+ writable: boolean;
455
+ // 不是这一组的第一个字段:与上一个之间画一条细分隔线(与官方设置面同一种口径)。
456
+ divider: boolean;
457
+ t: BundleTranslate;
458
+ face: BundleConfigActions;
459
+ }): ReactNode {
460
+ // 选项类字段要有候选才画:候选按字段树里的路径给,`defaultModel` 整块没配时它的子字段不在树上,
461
+ // 这时画出来只会是一个没有可选值的空选择器("设置默认模型"按钮才是那个状态下的入口)。
462
+ if (field.control === "choice" && !field.present) return null;
463
+ return (
464
+ <SettingsFieldRow
465
+ label={field.label}
466
+ hint={field.hint}
467
+ divider={divider}
468
+ // 开关 / 三态 / 多选按钮这一类是"右置排":标签与说明在左,控件贴最右(官方设置页的开关行同一种)。
469
+ layout={
470
+ field.control === "switch" || field.control === "tri" || field.control === "roles"
471
+ ? "inline"
472
+ : "stack"
473
+ }
474
+ {...(field.overridden ? { overriddenLabel: t("overridden") } : {})}
475
+ {...(field.control === "text"
476
+ ? {
477
+ resetLabel: t("field.reset"),
478
+ onReset: () => {
479
+ face.clear(field.path);
480
+ },
481
+ }
482
+ : {})}
483
+ {...(field.invalid === undefined ? {} : { invalid: field.invalid })}
484
+ data-field={field.path.join(".")}
485
+ data-control={field.control}
486
+ >
487
+ <FieldControl field={field} writable={writable} t={t} face={face} />
488
+ </SettingsFieldRow>
489
+ );
490
+ }
491
+
492
+ function FieldControl({
493
+ field,
494
+ writable,
495
+ t,
496
+ face,
497
+ }: {
498
+ field: BundleFieldView;
499
+ writable: boolean;
500
+ t: BundleTranslate;
501
+ face: BundleConfigActions;
502
+ }): ReactNode {
503
+ const disabled = !writable;
504
+ const id = useId();
505
+ switch (field.control) {
506
+ case "multiline":
507
+ return (
508
+ <MultilineField
509
+ value={field.text}
510
+ rows={3}
511
+ disabled={disabled}
512
+ aria-label={field.label}
513
+ onChange={(event) => {
514
+ face.editText(field.path, event.currentTarget.value);
515
+ }}
516
+ />
517
+ );
518
+ case "choice":
519
+ return (
520
+ <SearchSelect
521
+ label={field.label}
522
+ value={field.text}
523
+ emptyLabel={t("tri.unset")}
524
+ options={field.options.map((option) => ({
525
+ value: String(option.value),
526
+ ...(option.label === undefined ? {} : { label: option.label }),
527
+ }))}
528
+ searchLabel={t("select.search")}
529
+ noMatchLabel={t("select.noMatch")}
530
+ disabled={disabled}
531
+ onSelect={(next) => {
532
+ if (next === "") face.clear(field.path);
533
+ else face.set(field.path, next);
534
+ }}
535
+ />
536
+ );
537
+ case "switch":
538
+ return (
539
+ <Switch
540
+ checked={field.value === true}
541
+ label={field.label}
542
+ disabled={disabled}
543
+ onChange={(next) => {
544
+ face.set(field.path, next);
545
+ }}
546
+ />
547
+ );
548
+ case "tri":
549
+ return (
550
+ <SegmentedControl<TriValue>
551
+ id={`${id}-tri`}
552
+ label={field.label}
553
+ value={triValue(field)}
554
+ disabled={disabled}
555
+ options={[
556
+ { value: "unset", label: t("tri.unset") },
557
+ { value: "on", label: t("tri.on") },
558
+ { value: "off", label: t("tri.off") },
559
+ ]}
560
+ onChange={(next) => {
561
+ if (next === "unset") face.clear(field.path);
562
+ else face.set(field.path, next === "on");
563
+ }}
564
+ />
565
+ );
566
+ case "roles":
567
+ return (
568
+ <Row gap={6} wrap data-control="roles">
569
+ {ROLES.map((role) => {
570
+ const active = Array.isArray(field.value) && field.value.includes(role);
571
+ return (
572
+ <Button
573
+ key={role}
574
+ variant={active ? "primary" : "outline"}
575
+ size="sm"
576
+ // `data-role` 归控件身份(见 primitives 的控件),这一处标的是"哪个模式角色",所以另起一个名字。
577
+ data-mode-role={role}
578
+ disabled={disabled}
579
+ aria-pressed={active}
580
+ onClick={() => {
581
+ const current = Array.isArray(field.value) ? field.value.map(String) : [];
582
+ const wanted = active
583
+ ? current.filter((value) => value !== role)
584
+ : [...current.filter((value) => value !== role), role];
585
+ face.set(field.path, wanted);
586
+ }}
587
+ >
588
+ {t(`role.${role}` as "role.main")}
589
+ </Button>
590
+ );
591
+ })}
592
+ </Row>
593
+ );
594
+ case "tags":
595
+ return (
596
+ <TagInput
597
+ value={Array.isArray(field.value) ? field.value.map(String) : []}
598
+ options={field.options.map((option) => ({
599
+ value: String(option.value),
600
+ ...(option.label === undefined ? {} : { label: option.label }),
601
+ }))}
602
+ placeholder={t("tags.placeholder")}
603
+ label={field.label}
604
+ noMatchLabel={t("select.noMatch")}
605
+ removeLabel={(name) => t("tags.remove", { name })}
606
+ disabled={disabled}
607
+ onChange={(next) => {
608
+ face.set(field.path, next);
609
+ }}
610
+ />
611
+ );
612
+ case "text":
613
+ return (
614
+ <Input
615
+ value={field.text}
616
+ aria-label={field.label}
617
+ disabled={disabled}
618
+ onChange={(event) => {
619
+ face.editText(field.path, event.currentTarget.value);
620
+ }}
621
+ />
622
+ );
623
+ }
624
+ }
625
+
626
+ function triValue(field: BundleFieldView): TriValue {
627
+ return field.value === "unset" || field.value === "on" || field.value === "off"
628
+ ? field.value
629
+ : "unset";
630
+ }