@morlay/dsh-session-mode 0.0.1-alpha.4
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/LICENSE +21 -0
- package/README.md +116 -0
- package/cordis.patch.yml +69 -0
- package/dist/client.cjs +1008 -0
- package/dist/client.d.cts +210 -0
- package/dist/client.d.mts +210 -0
- package/dist/index.d.cts +129 -0
- package/dist/index.d.mts +129 -0
- package/dist/index.mjs +368 -0
- package/dist/shared-ChCbbJa8.mjs +14 -0
- package/dist/shared-FNWe-_rS.d.cts +106 -0
- package/dist/shared-FNWe-_rS.d.mts +106 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +108 -0
- package/src/client/ModelDefaultsCard.styles.ts +134 -0
- package/src/client/ModelDefaultsCard.tsx +233 -0
- package/src/client/SessionModeLabel.module.css +24 -0
- package/src/client/SessionModeLabel.tsx +44 -0
- package/src/client/SessionModeSeat.module.css +74 -0
- package/src/client/SessionModeSeat.tsx +109 -0
- package/src/client/api.ts +45 -0
- package/src/client/index.ts +142 -0
- package/src/client/locales.ts +88 -0
- package/src/client/model-defaults-card-controller.ts +408 -0
- package/src/client/use-roster.ts +30 -0
- package/src/index.ts +425 -0
- package/src/modes.ts +184 -0
- package/src/persona.ts +44 -0
- package/src/shared.ts +36 -0
- package/tool/modes.ts +85 -0
- package/tool/patch.ts +91 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页那张卡片(Plugins 列表里的「会话模式」)的控制器:编辑 config 顶层的 `models`(模式 id → 模型)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不用 `@morlay/dsh-client-ui-primitives/client` 的 `SettingsFormModel`:那个模型是**标量字段**表
|
|
5
|
+
* —一个字段名一份文本草稿。这里的编辑面是一张**动态映射**(每行一个模式,值是一个三元组),要套进去就得
|
|
6
|
+
* 把模型编码成文本再解回来。所以卡片只用原语里的**壳**:`SettingsForm`(保存 / 丢弃 / 只读 / 不可用 /
|
|
7
|
+
* 保存失败的约定)与 `SettingsFormShell` 类型;控件与草稿自己管,保存仍是一次带 revision 栅栏的 `mutate`。
|
|
8
|
+
*
|
|
9
|
+
* 写入路径(`SESSION_MODE_NS` = host 行 id,settings 的命名空间名就是它):
|
|
10
|
+
*
|
|
11
|
+
* - 某一行选了模型:`{ op: 'set', path: ['models', <模式 id>], value: { provider, model, reasoningEffort? } }`
|
|
12
|
+
* - 某一行回到装配层的值:`{ op: 'unset', path: ['models', <模式 id>] }`
|
|
13
|
+
*
|
|
14
|
+
* 为什么 `models` 在顶层而不在 `modes.<id>` 里:settings 的设置面只编辑 volatile 字段,且
|
|
15
|
+
* `volatileForm` 只认固定路径(dict 内部的字段一律 blocked)。判据与取舍见
|
|
16
|
+
* [ADR 模式默认模型搬到顶层 volatile](../../.agents/adrs/20260925-模式默认模型搬到顶层volatile.md)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ModelProviderGroup } from "@deepseek-ai/dsh-api-remotes/client";
|
|
20
|
+
import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-store";
|
|
21
|
+
import type {
|
|
22
|
+
SettingsFormPathOp,
|
|
23
|
+
SettingsFormScope,
|
|
24
|
+
SettingsFormShell,
|
|
25
|
+
} from "@morlay/dsh-client-ui-primitives/client";
|
|
26
|
+
import type { SessionModeModel, SessionModeModels } from "../modes.ts";
|
|
27
|
+
import type { SessionModeRoster } from "../shared.ts";
|
|
28
|
+
|
|
29
|
+
/** 本包 host 行 id:settings 的命名空间名与 `config` 的 volatile 字段都在它下面。 */
|
|
30
|
+
export const SESSION_MODE_NS = "session-mode";
|
|
31
|
+
|
|
32
|
+
/** settings 里这一段的可编辑形状(`volatileForm` 从 schema 上挑出来的那几个字段)。 */
|
|
33
|
+
export interface SessionModeSettings {
|
|
34
|
+
/** 各模式的默认模型;省略或空对象=一个都没配。 */
|
|
35
|
+
readonly models?: SessionModeModels;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 卡片要的那部分模型目录:可以选的 provider / model 分组,以及读失败的 provider 显示名。 */
|
|
39
|
+
export interface ModelDirectory {
|
|
40
|
+
/** 目录里有模型的 provider(`ctx.remote.session.modelCatalog()` 的成功分组)。 */
|
|
41
|
+
readonly groups: readonly ModelProviderGroup[];
|
|
42
|
+
/** 目录里读失败的 provider 显示名(部分失败:其余分组照常可用)。 */
|
|
43
|
+
readonly failures: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 卡片的两条外部读取(都注入进来,测试给假的即可,不需要真的 ctx / HTTP)。 */
|
|
47
|
+
export interface ModelDefaultsSources {
|
|
48
|
+
/** 模式清单:`GET /session-mode`(列哪些模式各一行)。 */
|
|
49
|
+
roster(): Promise<SessionModeRoster>;
|
|
50
|
+
/** 模型目录:`ctx.remote.session.modelCatalog()`;拒绝与抛错对卡片是同一件事——读不到目录。 */
|
|
51
|
+
directory(): Promise<ModelDirectory>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 一个模式一行:清单里的展示信息 + 当前值 + 草稿状态。 */
|
|
55
|
+
export interface ModelDefaultsRow {
|
|
56
|
+
readonly id: string;
|
|
57
|
+
readonly name: string;
|
|
58
|
+
readonly description?: string;
|
|
59
|
+
/** 这一行当前显示的模型(草稿优先;草稿是"回到默认"时显示装配层那份);`undefined` = 跟全局默认。 */
|
|
60
|
+
readonly value?: SessionModeModel;
|
|
61
|
+
/** 保存之后这一行会不会留下用户层那一条(有就得给"恢复默认")。 */
|
|
62
|
+
readonly overridden: boolean;
|
|
63
|
+
/** 这一行有未保存的编辑。 */
|
|
64
|
+
readonly dirty: boolean;
|
|
65
|
+
/** 草稿不完整(选了 provider 没选 model):它阻止保存。 */
|
|
66
|
+
readonly invalid: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 卡片呈现的状态:表单壳 + 行 + 两条读取的落点。 */
|
|
70
|
+
export interface ModelDefaultsCardState extends SettingsFormShell {
|
|
71
|
+
readonly rows: readonly ModelDefaultsRow[];
|
|
72
|
+
/** 目录里的 provider 分组(选择器的选项来源)。 */
|
|
73
|
+
readonly directory: readonly ModelProviderGroup[];
|
|
74
|
+
readonly rosterStatus: "loading" | "ready" | "error";
|
|
75
|
+
readonly directoryStatus: "loading" | "ready" | "error";
|
|
76
|
+
/** 部分失败:这些 provider 的目录没读到。 */
|
|
77
|
+
readonly directoryFailures: readonly string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 槽位渲染器绑定的状态与动作。 */
|
|
81
|
+
export interface ModelDefaultsCardFace {
|
|
82
|
+
hooks: {
|
|
83
|
+
modelDefaultsCard: SnapshotStore<ModelDefaultsCardState>;
|
|
84
|
+
};
|
|
85
|
+
/** 给某个模式换 provider;换 provider 会连 model 与档位一起清掉(档位是模型自带的)。 */
|
|
86
|
+
setProvider(modeId: string, provider: string): void;
|
|
87
|
+
/** 给某个模式选 model;新模型不认识旧档位时档位被清掉。 */
|
|
88
|
+
setModel(modeId: string, model: string): void;
|
|
89
|
+
/** 给某个模式选档位;空串=跟模型(provider)默认。 */
|
|
90
|
+
setEffort(modeId: string, effort: string): void;
|
|
91
|
+
/** 这一行回到装配层的值(清掉用户层那一条)。 */
|
|
92
|
+
resetMode(modeId: string): void;
|
|
93
|
+
/** 把全部草稿写成一次 `mutate`。 */
|
|
94
|
+
save(): void;
|
|
95
|
+
/** 丢掉全部草稿。 */
|
|
96
|
+
discard(): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 一个模式的草稿:`null` = 用户层那条清掉(回到装配层),`undefined` = 没动过。 */
|
|
100
|
+
type Draft = SessionModeModel | null;
|
|
101
|
+
|
|
102
|
+
/** 从 settings 的一层里取 `models`(层是原始 JSON 形状,取不到就是空)。 */
|
|
103
|
+
function layerModels(layer: unknown): SessionModeModels {
|
|
104
|
+
const models: unknown = (layer as { models?: unknown } | null | undefined)?.models;
|
|
105
|
+
return typeof models === "object" && models !== null ? (models as SessionModeModels) : {};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 两份模型是不是同一份(三个字段逐个比;缺省字段与 `undefined` 同义)。 */
|
|
109
|
+
function sameModel(left: SessionModeModel, right: SessionModeModel | undefined): boolean {
|
|
110
|
+
return (
|
|
111
|
+
right !== undefined &&
|
|
112
|
+
left.provider === right.provider &&
|
|
113
|
+
left.model === right.model &&
|
|
114
|
+
left.reasoningEffort === right.reasoningEffort
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 草稿是不是半成品(选了 provider 还没选 model)——半成品不许保存。 */
|
|
119
|
+
function incomplete(draft: Draft | undefined): boolean {
|
|
120
|
+
return draft !== undefined && draft !== null && draft.model === "";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 把 staged 的三元组写成 config 里的那个值(空档位不写键,别把"跟模型默认"物化成一个值)。 */
|
|
124
|
+
function modelValue(draft: SessionModeModel): Record<string, unknown> {
|
|
125
|
+
return {
|
|
126
|
+
provider: draft.provider,
|
|
127
|
+
model: draft.model,
|
|
128
|
+
...(draft.reasoningEffort === undefined ? {} : { reasoningEffort: draft.reasoningEffort }),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 把一张动态映射编辑成一次设置的写。 */
|
|
133
|
+
export class ModelDefaultsCardController {
|
|
134
|
+
private readonly store: SnapshotStore<ModelDefaultsCardState>;
|
|
135
|
+
private readonly unsubscribe: () => void;
|
|
136
|
+
private readonly drafts = new Map<string, Draft>();
|
|
137
|
+
private roster: SessionModeRoster | undefined;
|
|
138
|
+
private rosterStatus: ModelDefaultsCardState["rosterStatus"] = "loading";
|
|
139
|
+
private directory: readonly ModelProviderGroup[] = [];
|
|
140
|
+
private directoryFailures: readonly string[] = [];
|
|
141
|
+
private directoryStatus: ModelDefaultsCardState["directoryStatus"] = "loading";
|
|
142
|
+
private saving = false;
|
|
143
|
+
private failed = false;
|
|
144
|
+
private disposed = false;
|
|
145
|
+
private saveGeneration = 0;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param scope - 本包 host 行(`session-mode`)的共享配置表单。
|
|
149
|
+
* @param sources - 模式清单与模型目录的两条读取。
|
|
150
|
+
*/
|
|
151
|
+
constructor(
|
|
152
|
+
private readonly scope: SettingsFormScope<SessionModeSettings>,
|
|
153
|
+
private readonly sources: ModelDefaultsSources,
|
|
154
|
+
) {
|
|
155
|
+
this.store = createSnapshotStore(this.projection());
|
|
156
|
+
// 装配层与用户层的变化(别人写、或者我们刚保存)都从这一条进来。
|
|
157
|
+
this.unsubscribe = scope.subscribe(() => {
|
|
158
|
+
this.publish();
|
|
159
|
+
});
|
|
160
|
+
void this.loadRoster();
|
|
161
|
+
void this.loadDirectory();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 停止观察设置,并让晚到的写入结果不再落到状态上。 */
|
|
165
|
+
dispose(): void {
|
|
166
|
+
this.disposed = true;
|
|
167
|
+
this.saveGeneration += 1;
|
|
168
|
+
this.unsubscribe();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 把状态与动作绑到槽位渲染器上。
|
|
173
|
+
* @returns 快照与编辑、保存动作。
|
|
174
|
+
*/
|
|
175
|
+
inject(): ModelDefaultsCardFace {
|
|
176
|
+
return {
|
|
177
|
+
hooks: { modelDefaultsCard: this.store },
|
|
178
|
+
setProvider: (modeId, provider) => {
|
|
179
|
+
this.setProvider(modeId, provider);
|
|
180
|
+
},
|
|
181
|
+
setModel: (modeId, model) => {
|
|
182
|
+
this.setModel(modeId, model);
|
|
183
|
+
},
|
|
184
|
+
setEffort: (modeId, effort) => {
|
|
185
|
+
this.setEffort(modeId, effort);
|
|
186
|
+
},
|
|
187
|
+
resetMode: (modeId) => {
|
|
188
|
+
this.stage(modeId, null);
|
|
189
|
+
},
|
|
190
|
+
save: () => {
|
|
191
|
+
void this.save();
|
|
192
|
+
},
|
|
193
|
+
discard: () => {
|
|
194
|
+
this.discard();
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 用户层有没有这一条。 */
|
|
200
|
+
private overridden(modeId: string): boolean {
|
|
201
|
+
return Object.hasOwn(layerModels(this.scope.getSnapshot().user), modeId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 生效值(用户层叠在装配层上)。 */
|
|
205
|
+
private current(modeId: string): SessionModeModel | undefined {
|
|
206
|
+
return this.scope.getSnapshot().value?.models?.[modeId];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 装配层那份(用户层清掉之后剩下的)。 */
|
|
210
|
+
private base(modeId: string): SessionModeModel | undefined {
|
|
211
|
+
return layerModels(this.scope.getSnapshot().base)[modeId];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 这一行现在的值:草稿优先,否则是生效值;草稿是"回到默认"时看装配层那份(它清掉的就是用户层)。
|
|
216
|
+
* 同一个值既是选择器显示的东西,也是下一次编辑的起点。
|
|
217
|
+
* @param modeId - 模式 id。
|
|
218
|
+
* @returns 这一行呈现的模型;`undefined` = 跟全局默认。
|
|
219
|
+
*/
|
|
220
|
+
private shown(modeId: string): SessionModeModel | undefined {
|
|
221
|
+
const draft = this.drafts.get(modeId);
|
|
222
|
+
if (draft === null) return this.base(modeId);
|
|
223
|
+
return draft ?? this.current(modeId);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 这一行的草稿算不算一次真的编辑。
|
|
228
|
+
* @param modeId - 模式 id。
|
|
229
|
+
* @returns 保存会不会改变什么。
|
|
230
|
+
*/
|
|
231
|
+
private dirty(modeId: string): boolean {
|
|
232
|
+
const draft = this.drafts.get(modeId);
|
|
233
|
+
if (draft === undefined) return false;
|
|
234
|
+
// "回到默认"只在用户层有那一条时才算改了东西(装配层没有用户层这一条)。
|
|
235
|
+
if (draft === null) return this.overridden(modeId);
|
|
236
|
+
return !sameModel(draft, this.current(modeId));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 保存之后这一行会不会留下用户层那一条("已覆盖"与"恢复默认"都看它)。 */
|
|
240
|
+
private leavesOverride(modeId: string): boolean {
|
|
241
|
+
return this.overridden(modeId) || this.drafts.get(modeId) !== undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private setProvider(modeId: string, provider: string): void {
|
|
245
|
+
if (!this.writable() || provider === this.shown(modeId)?.provider) return;
|
|
246
|
+
// 选择器里的空 provider 就是"跟全局默认":这一行回到装配层的值(与"恢复默认"同一个动作)。
|
|
247
|
+
if (provider === "") {
|
|
248
|
+
this.stage(modeId, null);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// 换 provider 等于换了能选的模型集:model 与档位一起清掉(档位是模型自带的)。
|
|
252
|
+
this.stage(modeId, { provider, model: "" });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private setModel(modeId: string, model: string): void {
|
|
256
|
+
const current = this.shown(modeId);
|
|
257
|
+
if (!this.writable() || current === undefined) return;
|
|
258
|
+
const efforts = this.efforts(current.provider, model);
|
|
259
|
+
const keep = efforts.some((effort) => effort.id === current.reasoningEffort);
|
|
260
|
+
this.stage(modeId, {
|
|
261
|
+
provider: current.provider,
|
|
262
|
+
model,
|
|
263
|
+
...(keep && current.reasoningEffort !== undefined
|
|
264
|
+
? { reasoningEffort: current.reasoningEffort }
|
|
265
|
+
: {}),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private setEffort(modeId: string, effort: string): void {
|
|
270
|
+
const current = this.shown(modeId);
|
|
271
|
+
if (!this.writable() || current === undefined) return;
|
|
272
|
+
this.stage(modeId, {
|
|
273
|
+
provider: current.provider,
|
|
274
|
+
model: current.model,
|
|
275
|
+
...(effort === "" ? {} : { reasoningEffort: effort }),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private stage(modeId: string, draft: Draft): void {
|
|
280
|
+
if (!this.writable()) return;
|
|
281
|
+
this.drafts.set(modeId, draft);
|
|
282
|
+
this.failed = false;
|
|
283
|
+
this.publish();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private discard(): void {
|
|
287
|
+
if (this.saving || this.drafts.size === 0) return;
|
|
288
|
+
this.drafts.clear();
|
|
289
|
+
this.failed = false;
|
|
290
|
+
this.publish();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 可以编辑:这一行被服务到(`status: ready`)且文档接受写入。 */
|
|
294
|
+
private writable(): boolean {
|
|
295
|
+
const snapshot = this.scope.getSnapshot();
|
|
296
|
+
return !this.disposed && !this.saving && snapshot.status === "ready" && snapshot.writable;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 这一次保存要写的 path op(按编辑顺序;没变的行不写)。 */
|
|
300
|
+
private plannedOps(): SettingsFormPathOp[] {
|
|
301
|
+
const ops: SettingsFormPathOp[] = [];
|
|
302
|
+
for (const [modeId, draft] of this.drafts) {
|
|
303
|
+
if (!this.dirty(modeId)) continue;
|
|
304
|
+
ops.push(
|
|
305
|
+
draft === null
|
|
306
|
+
? { op: "unset", path: ["models", modeId] }
|
|
307
|
+
: { op: "set", path: ["models", modeId], value: modelValue(draft) },
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return ops;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async save(): Promise<void> {
|
|
314
|
+
const snapshot = this.scope.getSnapshot();
|
|
315
|
+
const ops = this.plannedOps();
|
|
316
|
+
if (
|
|
317
|
+
this.disposed ||
|
|
318
|
+
this.saving ||
|
|
319
|
+
snapshot.status !== "ready" ||
|
|
320
|
+
!snapshot.writable ||
|
|
321
|
+
ops.length === 0 ||
|
|
322
|
+
[...this.drafts.values()].some(incomplete)
|
|
323
|
+
) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const generation = this.saveGeneration;
|
|
327
|
+
this.saving = true;
|
|
328
|
+
this.failed = false;
|
|
329
|
+
this.publish();
|
|
330
|
+
let landed = false;
|
|
331
|
+
try {
|
|
332
|
+
// revision 栅栏:以读到这些值的那个 revision 为准,别盖掉别人后写的改动。
|
|
333
|
+
landed = await this.scope.mutate(ops, snapshot.revision);
|
|
334
|
+
} catch {
|
|
335
|
+
landed = false;
|
|
336
|
+
}
|
|
337
|
+
if (generation !== this.saveGeneration || this.disposed) return;
|
|
338
|
+
this.saving = false;
|
|
339
|
+
this.failed = !landed;
|
|
340
|
+
if (landed) this.drafts.clear();
|
|
341
|
+
this.publish();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private async loadRoster(): Promise<void> {
|
|
345
|
+
try {
|
|
346
|
+
this.roster = await this.sources.roster();
|
|
347
|
+
this.rosterStatus = "ready";
|
|
348
|
+
} catch {
|
|
349
|
+
this.rosterStatus = "error";
|
|
350
|
+
}
|
|
351
|
+
if (!this.disposed) this.publish();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async loadDirectory(): Promise<void> {
|
|
355
|
+
try {
|
|
356
|
+
const directory = await this.sources.directory();
|
|
357
|
+
this.directory = directory.groups;
|
|
358
|
+
this.directoryFailures = directory.failures;
|
|
359
|
+
this.directoryStatus = "ready";
|
|
360
|
+
} catch {
|
|
361
|
+
this.directoryStatus = "error";
|
|
362
|
+
}
|
|
363
|
+
if (!this.disposed) this.publish();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** 某个 provider 在目录里的模型。 */
|
|
367
|
+
private modelsOf(provider: string): ModelProviderGroup["models"] {
|
|
368
|
+
return this.directory.find((group) => group.id === provider)?.models ?? [];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** 某个 provider + model 声明的档位(模型自带;没声明就是没有)。 */
|
|
372
|
+
private efforts(provider: string, model: string): readonly { readonly id: string; readonly name: string }[] {
|
|
373
|
+
return this.modelsOf(provider).find((entry) => entry.id === model)?.reasoning?.efforts ?? [];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private projection(): ModelDefaultsCardState {
|
|
377
|
+
const snapshot = this.scope.getSnapshot();
|
|
378
|
+
const invalid = [...this.drafts.values()].some(incomplete);
|
|
379
|
+
return {
|
|
380
|
+
available: snapshot.status === "ready",
|
|
381
|
+
writable: snapshot.writable,
|
|
382
|
+
dirty: [...this.drafts.keys()].some((modeId) => this.dirty(modeId)),
|
|
383
|
+
invalid,
|
|
384
|
+
saving: this.saving,
|
|
385
|
+
failed: this.failed,
|
|
386
|
+
rows: (this.roster?.modes ?? []).map((mode) => {
|
|
387
|
+
const value = this.shown(mode.id);
|
|
388
|
+
return {
|
|
389
|
+
id: mode.id,
|
|
390
|
+
name: mode.name,
|
|
391
|
+
...(mode.description === undefined ? {} : { description: mode.description }),
|
|
392
|
+
...(value === undefined ? {} : { value }),
|
|
393
|
+
overridden: this.leavesOverride(mode.id),
|
|
394
|
+
dirty: this.dirty(mode.id),
|
|
395
|
+
invalid: incomplete(this.drafts.get(mode.id)),
|
|
396
|
+
};
|
|
397
|
+
}),
|
|
398
|
+
directory: this.directory,
|
|
399
|
+
rosterStatus: this.rosterStatus,
|
|
400
|
+
directoryStatus: this.directoryStatus,
|
|
401
|
+
directoryFailures: this.directoryFailures,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private publish(): void {
|
|
406
|
+
this.store.set(this.projection());
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { SessionModeRoster } from "../shared.ts";
|
|
3
|
+
import { fetchRoster } from "./api.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 模块级缓存:清单是装配事实(`session-mode` 行的 config),页面生命周期里不会变,两次挂载之间不必重拉。
|
|
7
|
+
* 失败不缓存——下次挂载就是一次重试。
|
|
8
|
+
*/
|
|
9
|
+
let pending: Promise<SessionModeRoster> | undefined;
|
|
10
|
+
|
|
11
|
+
/** 读模式清单;还没读到(或读失败)时返回 undefined,组件据此不渲染。 */
|
|
12
|
+
export function useRoster(): SessionModeRoster | undefined {
|
|
13
|
+
const [roster, setRoster] = useState<SessionModeRoster | undefined>(undefined);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
let alive = true;
|
|
16
|
+
pending ??= fetchRoster();
|
|
17
|
+
pending.then(
|
|
18
|
+
(value) => {
|
|
19
|
+
if (alive) setRoster(value);
|
|
20
|
+
},
|
|
21
|
+
() => {
|
|
22
|
+
pending = undefined;
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
return () => {
|
|
26
|
+
alive = false;
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
return roster;
|
|
30
|
+
}
|