@stofone/dsh-agent-roster 0.0.0-stage → 0.5.0

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/client.js ADDED
@@ -0,0 +1,1950 @@
1
+ /**
2
+ * 浏览器半边:Agent 档案面板 + 设置页里的语言节。
3
+ *
4
+ * 三个页签对应三类"用户真正关心的事":
5
+ * - 档案:看得见、改得动。新建/编辑/复制/删除/导入/导出都在这里完成,不再只能靠工具。
6
+ * - 团队:agent-teams 建出来的团队到底长什么样、成员是不是真的来自档案、字段有没有漂移。
7
+ * - 体检:档案库、目录、状态目录、工具策略这些"配置有没有指对"的问题集中在这里,
8
+ * 面板顶部只放一条"有几件事需要处理"的横幅,避免把配置噪声堆到首屏。
9
+ *
10
+ * 写操作一律走 Host 的 `/plugins/dsh-agent-roster/rosters`(同源 + 宿主 connection 围栏 +
11
+ * 档案库根目录白名单)。客户端不做权威校验:它只做"少一次无谓往返"的本地提示,
12
+ * 真正的校验仍在 Host 侧,失败时把诊断原样摆出来。
13
+ *
14
+ * 位置:`main` 键控槽 + `sidebar.panellist` 列表槽,同一个 PANEL_ID 关联;
15
+ * 设置页另注册一个 `settings.section`,只放语言与只读的配置摘要。
16
+ */
17
+ window.__ModuleLoader__.load({
18
+ id: "@stofone/dsh-agent-roster",
19
+ factory(require) {
20
+ const React = require("react");
21
+ const h = React.createElement;
22
+ const { useCallback, useEffect, useMemo, useRef, useState } = React;
23
+
24
+ /** 本插件在配置表单里的命名空间(与 cordis.patch.yml 的插件 id 一致)。 */
25
+ const NS = "agent-roster";
26
+
27
+ /** 主面板身份:`main` 槽的 key 与 `sidebar.panellist` 的 id 必须一致。 */
28
+ const PANEL_ID = "agent-roster";
29
+
30
+ const LIBRARY_ROUTE = "/plugins/dsh-agent-roster/library";
31
+ const LOCALE_ROUTE = "/plugins/dsh-agent-roster/locale";
32
+ const DOCUMENT_ROUTE = "/plugins/dsh-agent-roster/document";
33
+ const ROSTERS_ROUTE = "/plugins/dsh-agent-roster/rosters";
34
+ const INSTALL_ROUTE = "/plugins/dsh-agent-roster/install";
35
+ const COLLECTION_ROUTE = "/plugins/dsh-agent-roster/collection";
36
+
37
+ const EFFORTS = ["low", "medium", "high", "default"];
38
+ const LANGUAGES = ["auto", "zh", "en"];
39
+ const FORMATS = ["yaml", "json"];
40
+ const STYLE_ID = "dsh-agent-roster-style";
41
+
42
+ /* ------------------------------------------------------------------ */
43
+ /* i18n */
44
+ /* ------------------------------------------------------------------ */
45
+
46
+ /**
47
+ * 词典运行时。语言来源有两处:插件设置里的 language,与 DSH 自己的当前语言。
48
+ * `auto` 时跟随 DSH;用户显式选择后以插件设置为准。
49
+ */
50
+ const i18n = {
51
+ preference: "auto",
52
+ activeLocale: "",
53
+ dicts: {},
54
+ listeners: new Set(),
55
+
56
+ language() {
57
+ if (this.preference === "zh" || this.preference === "en") return this.preference;
58
+ return String(this.activeLocale).toLowerCase().startsWith("zh") ? "zh" : "en";
59
+ },
60
+
61
+ dict() {
62
+ return this.dicts[this.language()] ?? {};
63
+ },
64
+
65
+ emit() {
66
+ for (const listener of [...this.listeners]) listener();
67
+ },
68
+
69
+ subscribe(listener) {
70
+ this.listeners.add(listener);
71
+ return () => this.listeners.delete(listener);
72
+ },
73
+
74
+ /** 词典来自 Host 的只读路由,静态文件,取一次即可;失败时静默降级为 key。 */
75
+ async load() {
76
+ await Promise.all(
77
+ ["en", "zh"].map(async (language) => {
78
+ if (this.dicts[language]) return;
79
+ try {
80
+ const response = await fetch(`${LOCALE_ROUTE}?lang=${language}`, {
81
+ credentials: "same-origin",
82
+ cache: "no-store",
83
+ });
84
+ if (response.ok) this.dicts[language] = await response.json();
85
+ } catch {
86
+ /* 词典拿不到不是致命错误:界面退化成 key 名。 */
87
+ }
88
+ }),
89
+ );
90
+ this.emit();
91
+ },
92
+ };
93
+
94
+ /**
95
+ * 取词:支持点号路径与 `{name}` 插值,缺失时退回英文,再退回 key 本身。
96
+ *
97
+ * @param {string} key
98
+ * @param {Record<string, unknown>} [params]
99
+ * @returns {string}
100
+ */
101
+ function t(key, params) {
102
+ const value = readPath(i18n.dict(), key) ?? readPath(i18n.dicts.en ?? {}, key) ?? key;
103
+ if (typeof value !== "string") return key;
104
+ if (params === undefined) return value;
105
+ return value.replace(/\{(\w+)\}/g, (match, name) =>
106
+ params[name] === undefined || params[name] === null ? match : String(params[name]),
107
+ );
108
+ }
109
+
110
+ function readPath(object, path) {
111
+ let node = object;
112
+ for (const part of path.split(".")) {
113
+ if (node === null || typeof node !== "object") return undefined;
114
+ node = node[part];
115
+ }
116
+ return typeof node === "string" ? node : undefined;
117
+ }
118
+
119
+ /** 订阅词典与语言变化:语言一切换,所有用它的组件重渲染。 */
120
+ function useTranslation() {
121
+ const [, setVersion] = useState(0);
122
+ useEffect(() => i18n.subscribe(() => setVersion((value) => value + 1)), []);
123
+ }
124
+
125
+ /* ------------------------------------------------------------------ */
126
+ /* 数据访问 */
127
+ /* ------------------------------------------------------------------ */
128
+
129
+ async function readJson(url, init) {
130
+ const response = await fetch(url, { credentials: "same-origin", cache: "no-store", ...init });
131
+ const text = await response.text();
132
+ let body = null;
133
+ if (text !== "") {
134
+ try {
135
+ body = JSON.parse(text);
136
+ } catch {
137
+ body = null;
138
+ }
139
+ }
140
+ return { status: response.status, ok: response.ok, body };
141
+ }
142
+
143
+ /**
144
+ * 写操作:POST JSON 到 rosters 路由。
145
+ *
146
+ * @param {object} payload
147
+ * @returns {Promise<{ ok: boolean, status: number, body: any }>}
148
+ */
149
+ function mutate(payload) {
150
+ return readJson(ROSTERS_ROUTE, {
151
+ method: "POST",
152
+ headers: { "content-type": "application/json" },
153
+ body: JSON.stringify(payload),
154
+ });
155
+ }
156
+
157
+ function useLibrary() {
158
+ const [state, setState] = useState({ status: "loading", data: null, error: false });
159
+ const load = useCallback(async () => {
160
+ setState((current) => ({ ...current, status: current.data === null ? "loading" : "refreshing" }));
161
+ try {
162
+ const result = await readJson(LIBRARY_ROUTE);
163
+ if (!result.ok || result.body === null) throw new Error(String(result.status));
164
+ setState({ status: "ready", data: result.body, error: false });
165
+ } catch {
166
+ setState((current) => ({ ...current, status: "error", error: true }));
167
+ }
168
+ }, []);
169
+ useEffect(() => {
170
+ load();
171
+ }, [load]);
172
+ return { ...state, reload: load };
173
+ }
174
+
175
+ /* ------------------------------------------------------------------ */
176
+ /* YAML 生成(表单 → 源码) */
177
+ /* ------------------------------------------------------------------ */
178
+
179
+ /**
180
+ * YAML 标量:能裸写就裸写,可能被误读的一律加双引号。
181
+ *
182
+ * @param {unknown} value
183
+ * @returns {string}
184
+ */
185
+ function quote(value) {
186
+ const text = String(value ?? "");
187
+ const plain = /^[A-Za-z0-9\u4e00-\u9fa5][^\n:#"']*$/.test(text) && !/\s$/.test(text);
188
+ return plain ? text : JSON.stringify(text);
189
+ }
190
+
191
+ /**
192
+ * 把草稿渲染成档案源码。缩进固定两格:与 README 里的样例保持一致,人读得动。
193
+ *
194
+ * @param {object} draft 表单草稿
195
+ * @returns {string}
196
+ */
197
+ function renderDocument(draft) {
198
+ const lines = [];
199
+ const push = (indent, text) => lines.push(" ".repeat(indent) + text);
200
+ const scalar = (key, value, indent = 0) => {
201
+ if (value === undefined || value === null || value === "") return;
202
+ push(indent, `${key}: ${quote(value)}`);
203
+ };
204
+
205
+ push(0, "kind: dsh-agent-roster/v1");
206
+ scalar("roster", draft.id);
207
+ scalar("version", draft.version || "0.1.0");
208
+ scalar("title", draft.title);
209
+ scalar("description", draft.description);
210
+
211
+ for (const [key, raw] of [
212
+ ["tags", draft.tags],
213
+ ["maintainers", draft.maintainers],
214
+ ]) {
215
+ const values = splitList(raw);
216
+ if (values.length === 0) continue;
217
+ push(0, `${key}:`);
218
+ for (const value of values) push(2, `- ${quote(value)}`);
219
+ }
220
+
221
+ push(0, "members:");
222
+ for (const member of draft.members) {
223
+ push(2, `- name: ${quote(member.name)}`);
224
+ scalarAt(push, 4, "role", member.role);
225
+ scalarAt(push, 4, "title", member.title);
226
+ scalarAt(push, 4, "description", member.description);
227
+ scalarAt(push, 4, "provider", member.provider);
228
+ scalarAt(push, 4, "model", member.model);
229
+ scalarAt(push, 4, "reasoningEffort", member.reasoningEffort);
230
+ scalarAt(push, 4, "toolPolicy", member.toolPolicy);
231
+ listAt(push, 4, "aliases", splitList(member.aliases));
232
+ listAt(push, 4, "skills", splitList(member.skills));
233
+ listAt(push, 4, "examples", splitList(member.examples));
234
+ const tools = splitList(member.tools);
235
+ if (tools.length > 0) {
236
+ push(4, "toolScope:");
237
+ push(6, "allow:");
238
+ for (const tool of tools) push(8, `- ${quote(tool)}`);
239
+ }
240
+ const prompt = String(member.systemPrompt ?? "").replace(/\r\n/g, "\n").replace(/\s+$/, "");
241
+ if (prompt !== "") {
242
+ // 人设几乎都是多行:用块标量,避免把用户的长文压成一整行转义串。
243
+ push(4, "systemPrompt: |");
244
+ for (const line of prompt.split("\n")) push(6, line);
245
+ }
246
+ scalarAt(push, 4, "notes", member.notes);
247
+ }
248
+
249
+ const captain = draft.captain;
250
+ if (captain !== null && typeof captain === "object" && String(captain.name ?? "").trim() !== "") {
251
+ push(0, "captain:");
252
+ scalarAt(push, 2, "name", captain.name);
253
+ scalarAt(push, 2, "description", captain.description);
254
+ scalarAt(push, 2, "provider", captain.provider);
255
+ scalarAt(push, 2, "model", captain.model);
256
+ scalarAt(push, 2, "reasoningEffort", captain.reasoningEffort);
257
+ const persona = String(captain.personaPrefix ?? "").replace(/\r\n/g, "\n").replace(/\s+$/, "");
258
+ if (persona !== "") {
259
+ push(2, "personaPrefix: |");
260
+ for (const line of persona.split("\n")) push(4, line);
261
+ }
262
+ }
263
+
264
+ return `${lines.join("\n")}\n`;
265
+ }
266
+
267
+ /** 一列块状数组:空数组不写,避免产出 `aliases:` 后面什么都没有的残缺 YAML。 */
268
+ function listAt(push, indent, key, values) {
269
+ if (!Array.isArray(values) || values.length === 0) return;
270
+ push(indent, `${key}:`);
271
+ for (const value of values) push(indent + 2, `- ${quote(value)}`);
272
+ }
273
+
274
+ function scalarAt(push, indent, key, value) {
275
+ if (value === undefined || value === null || value === "") return;
276
+ push(indent, `${key}: ${quote(value)}`);
277
+ }
278
+
279
+ /**
280
+ * 把草稿渲染成 JSON。字段与人读的 YAML 一致,方便机器读、diff。
281
+ *
282
+ * @param {object} draft
283
+ * @returns {string}
284
+ */
285
+ function renderJson(draft) {
286
+ const members = draft.members.map((member) => {
287
+ const tools = splitList(member.tools);
288
+ const entry = { name: member.name || "" };
289
+ for (const field of ["role", "title", "description", "provider", "model", "reasoningEffort", "toolPolicy"]) {
290
+ if (member[field]) entry[field] = member[field];
291
+ }
292
+ for (const field of ["aliases", "skills", "examples"]) {
293
+ const values = splitList(member[field]);
294
+ if (values.length > 0) entry[field] = values;
295
+ }
296
+ if (tools.length > 0) entry.toolScope = { allow: tools };
297
+ entry.systemPrompt = String(member.systemPrompt ?? "");
298
+ if (member.notes) entry.notes = member.notes;
299
+ return entry;
300
+ });
301
+ const document = {
302
+ kind: "dsh-agent-roster/v1",
303
+ roster: draft.id || "",
304
+ version: draft.version || "0.1.0",
305
+ title: draft.title || "",
306
+ description: draft.description || "",
307
+ tags: splitList(draft.tags),
308
+ maintainers: splitList(draft.maintainers),
309
+ members,
310
+ };
311
+ const captain = draft.captain;
312
+ if (captain !== null && typeof captain === "object" && String(captain.name ?? "").trim() !== "") {
313
+ const block = { name: captain.name };
314
+ for (const field of ["description", "provider", "model", "reasoningEffort"]) {
315
+ if (captain[field]) block[field] = captain[field];
316
+ }
317
+ if (String(captain.personaPrefix ?? "").trim() !== "") block.personaPrefix = captain.personaPrefix;
318
+ document.captain = block;
319
+ }
320
+ return `${JSON.stringify(document, null, 2)}\n`;
321
+ }
322
+
323
+ function splitList(value) {
324
+ if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item !== "");
325
+ if (typeof value !== "string") return [];
326
+ return value
327
+ .split(/[,,\n]/)
328
+ .map((item) => item.trim())
329
+ .filter((item) => item !== "");
330
+ }
331
+
332
+ /* ------------------------------------------------------------------ */
333
+ /* 小组件 */
334
+ /* ------------------------------------------------------------------ */
335
+
336
+ function Chip({ children, tone }) {
337
+ return h("span", { className: `rstr-chip${tone === undefined ? "" : ` rstr-chip-${tone}`}` }, children);
338
+ }
339
+
340
+ function Badge({ tone, children }) {
341
+ return h("span", { className: `rstr-badge rstr-badge-${tone ?? "info"}` }, children);
342
+ }
343
+
344
+ function EmptyState({ title, body, cta, onCta }) {
345
+ return h(
346
+ "div",
347
+ { className: "rstr-empty" },
348
+ h("p", { className: "rstr-empty-title" }, title),
349
+ h("p", { className: "rstr-muted" }, body),
350
+ cta ? h("button", { type: "button", className: "rstr-btn rstr-btn-primary", onClick: onCta }, cta) : null,
351
+ );
352
+ }
353
+
354
+ /**
355
+ * 对话框:遮罩 + 居中卡片。Esc 关闭,打开时锁住 body 滚动。
356
+ */
357
+ function Modal({ title, children, footer, onClose, width }) {
358
+ const ref = useRef(null);
359
+ useEffect(() => {
360
+ const onKey = (event) => {
361
+ if (event.key === "Escape") onClose();
362
+ };
363
+ document.addEventListener("keydown", onKey);
364
+ return () => document.removeEventListener("keydown", onKey);
365
+ }, [onClose]);
366
+
367
+ return h(
368
+ "div",
369
+ {
370
+ className: "rstr-overlay",
371
+ onMouseDown: (event) => {
372
+ if (event.target === ref.current) onClose();
373
+ },
374
+ ref,
375
+ },
376
+ h(
377
+ "div",
378
+ {
379
+ className: "rstr-modal",
380
+ role: "dialog",
381
+ "aria-modal": "true",
382
+ "aria-label": title,
383
+ style: width === undefined ? undefined : { width },
384
+ },
385
+ h(
386
+ "div",
387
+ { className: "rstr-modal-head" },
388
+ h("h2", { className: "rstr-modal-title" }, title),
389
+ h("button", { type: "button", className: "rstr-icon-btn", onClick: onClose, "aria-label": t("editor.cancel") }, "×"),
390
+ ),
391
+ h("div", { className: "rstr-modal-body" }, children),
392
+ footer ? h("div", { className: "rstr-modal-foot" }, footer) : null,
393
+ ),
394
+ );
395
+ }
396
+
397
+ function Field({ label, hint, children }) {
398
+ return h(
399
+ "label",
400
+ { className: "rstr-field" },
401
+ h("span", { className: "rstr-label" }, label),
402
+ children,
403
+ hint ? h("span", { className: "rstr-hint" }, hint) : null,
404
+ );
405
+ }
406
+
407
+ function TextInput({ value, onChange, placeholder, mono, disabled }) {
408
+ return h("input", {
409
+ className: `rstr-input${mono ? " rstr-mono" : ""}`,
410
+ value: value ?? "",
411
+ placeholder: placeholder ?? "",
412
+ disabled: disabled === true,
413
+ onChange: (event) => onChange(event.target.value),
414
+ });
415
+ }
416
+
417
+ function TextArea({ value, onChange, rows, mono, placeholder }) {
418
+ return h("textarea", {
419
+ className: `rstr-input rstr-textarea${mono ? " rstr-mono" : ""}`,
420
+ value: value ?? "",
421
+ rows: rows ?? 4,
422
+ placeholder: placeholder ?? "",
423
+ spellCheck: false,
424
+ onChange: (event) => onChange(event.target.value),
425
+ });
426
+ }
427
+
428
+ function Select({ value, onChange, options }) {
429
+ return h(
430
+ "select",
431
+ { className: "rstr-input", value: value ?? "", onChange: (event) => onChange(event.target.value) },
432
+ options.map((option) =>
433
+ h("option", { key: option.value, value: option.value }, option.label),
434
+ ),
435
+ );
436
+ }
437
+
438
+ /** 诊断列表:严重度 + code + 位置 + 说明。说明来自 Host,界面不翻译它。 */
439
+ function DiagnosticList({ items }) {
440
+ if (!Array.isArray(items) || items.length === 0) return null;
441
+ return h(
442
+ "ul",
443
+ { className: "rstr-diagnostics" },
444
+ items.filter(Boolean).map((item, index) =>
445
+ h(
446
+ "li",
447
+ { key: `${item.code ?? "item"}-${index}`, className: `rstr-diag rstr-diag-${item.severity ?? "info"}` },
448
+ h(Badge, { tone: item.severity }, t(`diagnostics.severity${capitalize(item.severity ?? "info")}`)),
449
+ h("code", { className: "rstr-code" }, item.code ?? ""),
450
+ item.path ? h("span", { className: "rstr-code" }, item.path) : null,
451
+ h("span", { className: "rstr-diag-reason" }, item.reason ?? ""),
452
+ item.file ? h("span", { className: "rstr-muted rstr-small" }, item.file) : null,
453
+ ),
454
+ ),
455
+ );
456
+ }
457
+
458
+ function capitalize(value) {
459
+ return String(value).charAt(0).toUpperCase() + String(value).slice(1);
460
+ }
461
+
462
+ /* ------------------------------------------------------------------ */
463
+ /* 档案卡片 */
464
+ /* ------------------------------------------------------------------ */
465
+
466
+ function MemberBlock({ member }) {
467
+ return h(
468
+ "div",
469
+ { className: "rstr-member" },
470
+ h(
471
+ "div",
472
+ { className: "rstr-member-head" },
473
+ h("strong", null, member.name),
474
+ member.role ? h(Chip, null, member.role) : null,
475
+ member.toolPolicy ? h(Chip, { tone: "policy" }, `${t("member.policy")}: ${member.toolPolicy}`) : null,
476
+ ),
477
+ member.description ? h("p", { className: "rstr-member-desc" }, member.description) : null,
478
+ Array.isArray(member.aliases) && member.aliases.length > 0
479
+ ? h("p", { className: "rstr-muted rstr-small" }, `${t("member.aliases")}: ${member.aliases.join(", ")}`)
480
+ : null,
481
+ h(
482
+ "div",
483
+ { className: "rstr-member-meta" },
484
+ member.model ? h("span", null, `${t("member.model")}: ${member.model}`) : null,
485
+ member.reasoningEffort ? h("span", null, `${t("member.effort")}: ${member.reasoningEffort}`) : null,
486
+ member.fallback ? h("span", null, `${t("member.fallback")}: ${member.fallback}`) : null,
487
+ member.toolScope?.allow?.length
488
+ ? h("span", null, `${t("member.tools")}: ${member.toolScope.allow.join(", ")}`)
489
+ : null,
490
+ h("span", { className: "rstr-muted" }, t("team.promptChars", { count: member.systemPromptChars })),
491
+ ),
492
+ Array.isArray(member.skills) && member.skills.length > 0
493
+ ? h(
494
+ "p",
495
+ { className: "rstr-muted rstr-small" },
496
+ `${t("member.skills")}: ${member.skills.join(", ")}(${t("member.skillsHint")})`,
497
+ )
498
+ : null,
499
+ Array.isArray(member.inherited) && member.inherited.length > 0
500
+ ? h("p", { className: "rstr-muted rstr-small" }, `inherited: ${member.inherited.join(", ")}`)
501
+ : null,
502
+ member.notes ? h("p", { className: "rstr-muted rstr-small" }, member.notes) : null,
503
+ );
504
+ }
505
+
506
+ function RosterCard({ roster, deployment, onEdit, onDuplicate, onDelete, onCopy }) {
507
+ const [open, setOpen] = useState(false);
508
+ const errors = (roster.diagnostics ?? []).filter((item) => item?.severity === "error").length;
509
+ const deployed = deployment !== undefined && deployment.deployed > 0;
510
+
511
+ return h(
512
+ "article",
513
+ { className: `rstr-card${errors > 0 ? " rstr-card-bad" : ""}` },
514
+ h(
515
+ "header",
516
+ { className: "rstr-card-head" },
517
+ h(
518
+ "div",
519
+ null,
520
+ h(
521
+ "div",
522
+ { className: "rstr-card-id" },
523
+ h("code", { className: "rstr-code rstr-code-strong" }, roster.id),
524
+ h(Chip, null, t("roster.version", { version: roster.version })),
525
+ errors > 0 ? h(Badge, { tone: "error" }, t("roster.diagnostics", { count: errors })) : null,
526
+ ),
527
+ h("h3", { className: "rstr-card-title" }, roster.title || roster.id),
528
+ roster.description ? h("p", { className: "rstr-muted" }, roster.description) : null,
529
+ ),
530
+ ),
531
+ h(
532
+ "div",
533
+ { className: "rstr-card-meta" },
534
+ h("span", null, t("roster.members", { count: roster.members.length })),
535
+ h("span", { className: "rstr-muted" }, roster.file),
536
+ h(
537
+ "span",
538
+ { className: deployed ? "rstr-ok" : "rstr-muted" },
539
+ deployed
540
+ ? t("roster.deployed", { teams: (deployment.teams ?? []).join(", ") })
541
+ : t("roster.notDeployed"),
542
+ ),
543
+ deployment !== undefined && deployment.drifted > 0
544
+ ? h(Badge, { tone: "warn" }, t("roster.drifted", { count: deployment.drifted }))
545
+ : null,
546
+ ),
547
+ h(
548
+ "div",
549
+ { className: "rstr-card-actions" },
550
+ h("button", { type: "button", className: "rstr-btn", onClick: () => onEdit(roster) }, t("roster.edit")),
551
+ h("button", { type: "button", className: "rstr-btn", onClick: () => onDuplicate(roster) }, t("roster.duplicate")),
552
+ h("button", { type: "button", className: "rstr-btn", onClick: () => onCopy(roster) }, t("roster.copyCreate")),
553
+ h(
554
+ "button",
555
+ { type: "button", className: "rstr-btn rstr-btn-danger", onClick: () => onDelete(roster) },
556
+ t("roster.delete"),
557
+ ),
558
+ h(
559
+ "button",
560
+ {
561
+ type: "button",
562
+ className: "rstr-btn rstr-btn-ghost",
563
+ "aria-expanded": open,
564
+ onClick: () => setOpen(!open),
565
+ },
566
+ open ? t("roster.hideMembers") : t("roster.showMembers"),
567
+ ),
568
+ ),
569
+ open
570
+ ? h(
571
+ "div",
572
+ { className: "rstr-members" },
573
+ roster.members.length === 0
574
+ ? h("p", { className: "rstr-muted" }, t("roster.noMembers"))
575
+ : roster.members.map((member) => h(MemberBlock, { key: member.name, member })),
576
+ h(DiagnosticList, { items: roster.diagnostics }),
577
+ )
578
+ : null,
579
+ );
580
+ }
581
+
582
+ /* ------------------------------------------------------------------ */
583
+ /* 团队视图 */
584
+ /* ------------------------------------------------------------------ */
585
+
586
+ function TeamCard({ team }) {
587
+ return h(
588
+ "article",
589
+ { className: "rstr-card" },
590
+ h(
591
+ "header",
592
+ { className: "rstr-card-head" },
593
+ h(
594
+ "div",
595
+ null,
596
+ h(
597
+ "div",
598
+ { className: "rstr-card-id" },
599
+ h("code", { className: "rstr-code rstr-code-strong" }, team.id),
600
+ h(Badge, { tone: team.phase === "staged" ? "warn" : "ok" }, t(team.phase === "staged" ? "team.phaseStaged" : "team.phaseRunning")),
601
+ team.halted ? h(Badge, { tone: "warn" }, t("team.halted")) : null,
602
+ team.escalated ? h(Badge, { tone: "error" }, t("team.escalated")) : null,
603
+ ),
604
+ h("h3", { className: "rstr-card-title" }, team.name || team.id),
605
+ team.description ? h("p", { className: "rstr-muted" }, team.description) : null,
606
+ ),
607
+ ),
608
+ h(
609
+ "div",
610
+ { className: "rstr-card-meta" },
611
+ h("span", null, t("team.tasks", team.tasks)),
612
+ h("span", { className: "rstr-muted rstr-small" }, t("team.file") + ": " + team.file),
613
+ ),
614
+ h(
615
+ "div",
616
+ { className: "rstr-members" },
617
+ h("div", { className: "rstr-label rstr-mt" }, t("team.members")),
618
+ team.members.map((member) =>
619
+ h(
620
+ "div",
621
+ { key: member.name, className: "rstr-member" },
622
+ h(
623
+ "div",
624
+ { className: "rstr-member-head" },
625
+ h("strong", null, member.name),
626
+ member.role ? h(Chip, null, member.role) : null,
627
+ member.rosterId === null
628
+ ? h(Badge, { tone: "muted" }, t("team.unmanaged"))
629
+ : h(Badge, { tone: member.faithful ? "ok" : "warn" }, t(member.faithful ? "team.faithful" : "team.drift")),
630
+ ),
631
+ h(
632
+ "div",
633
+ { className: "rstr-member-meta" },
634
+ member.model ? h("span", null, `${t("member.model")}: ${member.model}`) : null,
635
+ member.reasoningEffort ? h("span", null, `${t("member.effort")}: ${member.reasoningEffort}`) : null,
636
+ member.toolPolicy ? h("span", null, `${t("member.policy")}: ${member.toolPolicy}`) : null,
637
+ member.rosterId !== null ? h("span", { className: "rstr-muted" }, t("team.managed", { roster: member.rosterId })) : null,
638
+ h("span", { className: "rstr-muted" }, t("team.promptChars", { count: member.promptChars })),
639
+ ),
640
+ member.drift.length > 0
641
+ ? h(
642
+ "ul",
643
+ { className: "rstr-drift" },
644
+ member.drift.map((item) =>
645
+ h(
646
+ "li",
647
+ { key: item.field },
648
+ t("team.driftDetail", { field: item.field, wanted: item.roster, actual: item.runtime }),
649
+ ),
650
+ ),
651
+ )
652
+ : null,
653
+ ),
654
+ ),
655
+ ),
656
+ );
657
+ }
658
+
659
+ /* ------------------------------------------------------------------ */
660
+ /* 编辑器 */
661
+ /* ------------------------------------------------------------------ */
662
+
663
+ /** 空草稿:新建时给一个"改完就能存"的起点,而不是一张空表单。 */
664
+ function blankDraft() {
665
+ return {
666
+ id: "",
667
+ title: "",
668
+ version: "0.1.0",
669
+ description: "",
670
+ tags: "",
671
+ maintainers: "",
672
+ members: [{ name: "", role: "", title: "", description: "", provider: "", model: "", reasoningEffort: "", toolPolicy: "", aliases: "", skills: "", examples: "", tools: "", systemPrompt: "", notes: "" }],
673
+ captain: null,
674
+ };
675
+ }
676
+
677
+ function draftFromRoster(roster) {
678
+ return {
679
+ id: roster.id ?? "",
680
+ title: roster.title ?? "",
681
+ version: roster.version ?? "0.1.0",
682
+ description: roster.description ?? "",
683
+ tags: (roster.tags ?? []).join(", "),
684
+ maintainers: (roster.maintainers ?? []).join(", "),
685
+ members: (roster.members ?? []).map((member) => ({
686
+ name: member.name ?? "",
687
+ role: member.role ?? "",
688
+ title: member.title ?? "",
689
+ description: member.description ?? "",
690
+ provider: member.provider ?? "",
691
+ model: member.model ?? "",
692
+ reasoningEffort: member.reasoningEffort ?? "",
693
+ toolPolicy: member.toolPolicy ?? "",
694
+ aliases: (member.aliases ?? []).join(", "),
695
+ skills: (member.skills ?? []).join(", "),
696
+ examples: (member.examples ?? []).join("\n"),
697
+ tools: (member.toolScope?.allow ?? []).join(", "),
698
+ systemPrompt: member.systemPrompt ?? "",
699
+ notes: member.notes ?? "",
700
+ })),
701
+ captain:
702
+ roster.captain === null || roster.captain === undefined
703
+ ? null
704
+ : {
705
+ name: roster.captain.name ?? "",
706
+ description: roster.captain.description ?? "",
707
+ personaPrefix: roster.captain.personaPrefix ?? "",
708
+ provider: roster.captain.provider ?? "",
709
+ model: roster.captain.model ?? "",
710
+ reasoningEffort: roster.captain.reasoningEffort ?? "",
711
+ },
712
+ };
713
+ }
714
+
715
+ function Editor({ target, onClose, onSaved }) {
716
+ useTranslation();
717
+ const existing = target.roster;
718
+ const duplicate = target.duplicate === true;
719
+ const [mode, setMode] = useState("form");
720
+ const [format, setFormat] = useState(existing?.file?.endsWith(".json") ? "json" : "yaml");
721
+ const [draft, setDraft] = useState(() => {
722
+ if (existing === null) return blankDraft();
723
+ const base = draftFromRoster(existing);
724
+ if (duplicate) {
725
+ // 复制出来的档案必须换 id,否则保存时会撞名——这里先替用户想好一个。
726
+ base.id = `${existing.id}-copy`;
727
+ }
728
+ return base;
729
+ });
730
+ const [source, setSource] = useState("");
731
+ const [busy, setBusy] = useState(false);
732
+ const [failure, setFailure] = useState(null);
733
+ const [confirmOverwrite, setConfirmOverwrite] = useState(null);
734
+ const [loaded, setLoaded] = useState(existing === null);
735
+
736
+ // 编辑已有档案:先拉原文,表单与源码两个视图共用同一份内容。
737
+ useEffect(() => {
738
+ if (existing === null) return;
739
+ let cancelled = false;
740
+ (async () => {
741
+ const result = await readJson(
742
+ `${DOCUMENT_ROUTE}?rootDir=${encodeURIComponent(existing.rootDir)}&id=${encodeURIComponent(existing.id)}`,
743
+ );
744
+ if (cancelled) return;
745
+ if (result.ok && result.body) {
746
+ setSource(result.body.text ?? "");
747
+ // 复制模式保留自己生成的 id,不拿原档案覆盖。
748
+ if (result.body.roster && !duplicate) setDraft(draftFromRoster(result.body.roster));
749
+ setFormat(result.body.format ?? format);
750
+ } else {
751
+ setFailure({ code: "document-unreadable" });
752
+ }
753
+ setLoaded(true);
754
+ })();
755
+ return () => {
756
+ cancelled = true;
757
+ };
758
+ }, [existing?.id, existing?.rootDir]);
759
+
760
+ const text = mode === "source" ? source : format === "json" ? renderJson(draft) : renderDocument(draft);
761
+
762
+ useEffect(() => {
763
+ if (mode === "form") setSource(format === "json" ? renderJson(draft) : renderDocument(draft));
764
+ }, [mode, format, draft]);
765
+
766
+ const validation = validateDraft(draft);
767
+
768
+ async function save(overwrite) {
769
+ setBusy(true);
770
+ setFailure(null);
771
+ const result = await mutate({
772
+ action: "save",
773
+ rootDir: existing?.rootDir ?? target.rootDir,
774
+ text: mode === "source" ? source : text,
775
+ format,
776
+ overwrite: overwrite === true,
777
+ });
778
+ setBusy(false);
779
+ if (result.ok) {
780
+ onSaved(result.body?.file ?? `${draft.id}/${format === "json" ? "roster.json" : "roster.yaml"}`);
781
+ return;
782
+ }
783
+ const code = result.body?.error?.code ?? "save-failed";
784
+ if (code === "exists" || code === "format-conflict") {
785
+ setConfirmOverwrite({ code, existing: result.body?.existing ?? null });
786
+ return;
787
+ }
788
+ setFailure({ code, diagnostics: result.body?.diagnostics ?? [] });
789
+ }
790
+
791
+ const update = (patch) => setDraft((current) => ({ ...current, ...patch }));
792
+ const updateMember = (index, patch) =>
793
+ setDraft((current) => ({
794
+ ...current,
795
+ members: current.members.map((member, position) => (position === index ? { ...member, ...patch } : member)),
796
+ }));
797
+
798
+ const body = [];
799
+ body.push(
800
+ h(
801
+ "div",
802
+ { className: "rstr-editor-toolbar" },
803
+ h(
804
+ "div",
805
+ { className: "rstr-segmented", role: "group", "aria-label": t("editor.source") },
806
+ h(
807
+ "button",
808
+ { type: "button", className: `rstr-seg${mode === "form" ? " rstr-seg-on" : ""}`, onClick: () => setMode("form") },
809
+ t("editor.form"),
810
+ ),
811
+ h(
812
+ "button",
813
+ { type: "button", className: `rstr-seg${mode === "source" ? " rstr-seg-on" : ""}`, onClick: () => setMode("source") },
814
+ t("editor.source"),
815
+ ),
816
+ ),
817
+ h(Select, {
818
+ value: format,
819
+ onChange: setFormat,
820
+ options: FORMATS.map((value) => ({ value, label: value.toUpperCase() })),
821
+ }),
822
+ ),
823
+ );
824
+
825
+ if (mode === "source") {
826
+ body.push(
827
+ h("p", { className: "rstr-hint" }, t("editor.sourceHint")),
828
+ h(TextArea, { value: source, onChange: setSource, rows: 22, mono: true }),
829
+ );
830
+ } else {
831
+ body.push(
832
+ h(
833
+ "div",
834
+ { className: "rstr-grid" },
835
+ h(Field, { label: t("editor.id"), hint: t("editor.idHint") }, h(TextInput, { value: draft.id, onChange: (value) => update({ id: value }), mono: true })),
836
+ h(Field, { label: t("editor.titleField") }, h(TextInput, { value: draft.title, onChange: (value) => update({ title: value }) })),
837
+ h(Field, { label: t("editor.version") }, h(TextInput, { value: draft.version, onChange: (value) => update({ version: value }), mono: true })),
838
+ h(Field, { label: t("editor.tags"), hint: t("editor.tagsHint") }, h(TextInput, { value: draft.tags, onChange: (value) => update({ tags: value }) })),
839
+ ),
840
+ h(Field, { label: t("editor.description") }, h(TextArea, { value: draft.description, onChange: (value) => update({ description: value }), rows: 2 })),
841
+ h("div", { className: "rstr-section-title rstr-mt" }, t("editor.members")),
842
+ );
843
+
844
+ draft.members.forEach((member, index) => {
845
+ body.push(
846
+ h(
847
+ "div",
848
+ { key: index, className: "rstr-member-editor" },
849
+ h(
850
+ "div",
851
+ { className: "rstr-member-editor-head" },
852
+ h("span", { className: "rstr-step" }, String(index + 1)),
853
+ h(TextInput, { value: member.name, placeholder: t("editor.memberName"), mono: true, onChange: (value) => updateMember(index, { name: value }) }),
854
+ draft.members.length > 1
855
+ ? h(
856
+ "button",
857
+ { type: "button", className: "rstr-btn rstr-btn-danger rstr-btn-sm", onClick: () => setDraft((current) => ({ ...current, members: current.members.filter((_, position) => position !== index) })) },
858
+ t("editor.removeMember"),
859
+ )
860
+ : null,
861
+ ),
862
+ h(
863
+ "div",
864
+ { className: "rstr-grid" },
865
+ h(Field, { label: t("editor.memberRole") }, h(TextInput, { value: member.role, onChange: (value) => updateMember(index, { role: value }) })),
866
+ h(Field, { label: t("editor.memberTitle") }, h(TextInput, { value: member.title, onChange: (value) => updateMember(index, { title: value }) })),
867
+ h(Field, { label: t("editor.memberProvider") }, h(TextInput, { value: member.provider, mono: true, onChange: (value) => updateMember(index, { provider: value }) })),
868
+ h(Field, { label: t("editor.memberModel") }, h(TextInput, { value: member.model, mono: true, onChange: (value) => updateMember(index, { model: value }) })),
869
+ h(
870
+ Field,
871
+ { label: t("editor.memberEffort") },
872
+ h(Select, {
873
+ value: member.reasoningEffort,
874
+ onChange: (value) => updateMember(index, { reasoningEffort: value }),
875
+ options: [{ value: "", label: "—" }, ...EFFORTS.map((value) => ({ value, label: value }))],
876
+ }),
877
+ ),
878
+ h(Field, { label: t("editor.memberPolicy") }, h(TextInput, { value: member.toolPolicy, mono: true, onChange: (value) => updateMember(index, { toolPolicy: value }) })),
879
+ ),
880
+ h(Field, { label: t("editor.memberScope"), hint: t("editor.memberScopeHint") }, h(TextInput, { value: member.tools, mono: true, onChange: (value) => updateMember(index, { tools: value }) })),
881
+ h(Field, { label: t("editor.memberDescription") }, h(TextInput, { value: member.description, onChange: (value) => updateMember(index, { description: value }) })),
882
+ h(Field, { label: t("editor.memberAliases"), hint: t("editor.listHint") }, h(TextInput, { value: member.aliases, mono: true, onChange: (value) => updateMember(index, { aliases: value }) })),
883
+ h(Field, { label: t("editor.memberSkills"), hint: t("editor.skillsHint") }, h(TextInput, { value: member.skills, mono: true, onChange: (value) => updateMember(index, { skills: value }) })),
884
+ h(Field, { label: t("editor.memberExamples"), hint: t("editor.examplesHint") }, h(TextArea, { value: member.examples, rows: 3, onChange: (value) => updateMember(index, { examples: value }) })),
885
+ h(Field, { label: t("editor.memberPrompt") }, h(TextArea, { value: member.systemPrompt, rows: 8, onChange: (value) => updateMember(index, { systemPrompt: value }) })),
886
+ ),
887
+ );
888
+ });
889
+
890
+ body.push(
891
+ h(
892
+ "button",
893
+ { type: "button", className: "rstr-btn rstr-btn-dashed", onClick: () => setDraft((current) => ({ ...current, members: [...current.members, blankDraft().members[0]] })) },
894
+ `+ ${t("editor.addMember")}`,
895
+ ),
896
+ );
897
+
898
+ const captain = draft.captain ?? { name: "", description: "", personaPrefix: "", provider: "", model: "", reasoningEffort: "" };
899
+ const updateCaptain = (patch) => setDraft((current) => ({ ...current, captain: { ...captain, ...patch } }));
900
+ body.push(h("div", { className: "rstr-section-title rstr-mt" }, t("editor.captain")));
901
+ body.push(h("p", { className: "rstr-hint" }, t("editor.captainHint")));
902
+ body.push(
903
+ h(
904
+ "div",
905
+ { className: "rstr-grid" },
906
+ h(Field, { label: t("editor.captainName"), hint: t("editor.captainNameHint") }, h(TextInput, { value: captain.name, mono: true, onChange: (value) => updateCaptain({ name: value }) })),
907
+ h(Field, { label: t("editor.captainDescription") }, h(TextInput, { value: captain.description, onChange: (value) => updateCaptain({ description: value }) })),
908
+ h(Field, { label: t("editor.captainProvider") }, h(TextInput, { value: captain.provider, mono: true, onChange: (value) => updateCaptain({ provider: value }) })),
909
+ h(Field, { label: t("editor.captainModel") }, h(TextInput, { value: captain.model, mono: true, onChange: (value) => updateCaptain({ model: value }) })),
910
+ h(
911
+ Field,
912
+ { label: t("editor.captainEffort") },
913
+ h(Select, {
914
+ value: captain.reasoningEffort,
915
+ onChange: (value) => updateCaptain({ reasoningEffort: value }),
916
+ options: [{ value: "", label: "—" }, ...EFFORTS.map((value) => ({ value, label: value }))],
917
+ }),
918
+ ),
919
+ ),
920
+ );
921
+ body.push(
922
+ h(Field, { label: t("editor.captainPersona"), hint: t("editor.captainPersonaHint") }, h(TextArea, { value: captain.personaPrefix, rows: 4, onChange: (value) => updateCaptain({ personaPrefix: value }) })),
923
+ );
924
+ }
925
+
926
+ if (Object.keys(validation).length > 0) {
927
+ body.push(h("p", { className: "rstr-hint rstr-hint-warn" }, Object.values(validation).map((key) => t(key)).join(" ")));
928
+ }
929
+ if (failure !== null) {
930
+ body.push(
931
+ h(
932
+ "div",
933
+ { className: "rstr-error" },
934
+ h("p", { className: "rstr-error-title" }, t("editor.failedTitle")),
935
+ h("code", { className: "rstr-code" }, failure.code),
936
+ h(DiagnosticList, { items: failure.diagnostics }),
937
+ ),
938
+ );
939
+ }
940
+ if (confirmOverwrite !== null) {
941
+ body.push(
942
+ h(
943
+ "div",
944
+ { className: "rstr-confirm" },
945
+ h("p", null, t("editor.overwriteTitle")),
946
+ h("p", { className: "rstr-muted" }, t("editor.overwriteBody", { file: confirmOverwrite.existing?.file ?? "" })),
947
+ h(
948
+ "button",
949
+ { type: "button", className: "rstr-btn rstr-btn-danger", onClick: () => { setConfirmOverwrite(null); save(true); } },
950
+ t("editor.overwriteConfirm"),
951
+ ),
952
+ ),
953
+ );
954
+ }
955
+
956
+ const disabled = busy || !loaded || Object.keys(validation).length > 0;
957
+
958
+ return h(
959
+ Modal,
960
+ {
961
+ title: existing ? t("editor.editTitle", { id: existing.id }) : t("editor.createTitle"),
962
+ onClose,
963
+ width: "820px",
964
+ footer: h(
965
+ "div",
966
+ { className: "rstr-modal-foot-actions" },
967
+ h("button", { type: "button", className: "rstr-btn", onClick: onClose }, t("editor.cancel")),
968
+ h("button", { type: "button", className: "rstr-btn rstr-btn-primary", disabled, onClick: () => save(false) }, busy ? t("editor.saving") : t("editor.save")),
969
+ ),
970
+ },
971
+ body,
972
+ );
973
+ }
974
+
975
+ /** 表单侧的"能省一次往返"的最小校验:权威校验仍在 Host。 */
976
+ function validateDraft(draft) {
977
+ const problems = {};
978
+ if (draft.id.trim() === "") problems.id = "editor.emptyId";
979
+ if (draft.title.trim() === "") problems.title = "editor.emptyTitle";
980
+ if (draft.members.length === 0) problems.members = "editor.emptyMembers";
981
+ else if (draft.members.some((member) => member.name.trim() === "")) problems.memberName = "editor.emptyMemberName";
982
+ else if (draft.members.some((member) => member.systemPrompt.trim() === "")) problems.memberPrompt = "editor.emptyMemberPrompt";
983
+ return problems;
984
+ }
985
+
986
+ /* ------------------------------------------------------------------ */
987
+ /* 导入 / 删除 */
988
+ /* ------------------------------------------------------------------ */
989
+
990
+ function ImportDialog({ targets, initialRoot, onClose, onDone }) {
991
+ useTranslation();
992
+ const [text, setText] = useState("");
993
+ const [rootDir, setRootDir] = useState(initialRoot);
994
+ const [busy, setBusy] = useState(false);
995
+ const [failure, setFailure] = useState(null);
996
+
997
+ async function submit() {
998
+ setBusy(true);
999
+ setFailure(null);
1000
+ const trimmed = text.trim();
1001
+ const format = trimmed.startsWith("{") ? "json" : "yaml";
1002
+ const result = await mutate({ action: "import", rootDir, text, format, overwrite: false });
1003
+ setBusy(false);
1004
+ if (result.ok) {
1005
+ onDone(result.body?.file ?? "");
1006
+ return;
1007
+ }
1008
+ setFailure({ code: result.body?.error?.code ?? "import-failed", diagnostics: result.body?.diagnostics ?? [] });
1009
+ }
1010
+
1011
+ return h(
1012
+ Modal,
1013
+ {
1014
+ title: t("importDialog.title"),
1015
+ onClose,
1016
+ width: "760px",
1017
+ footer: h(
1018
+ "div",
1019
+ { className: "rstr-modal-foot-actions" },
1020
+ h("button", { type: "button", className: "rstr-btn", onClick: onClose }, t("editor.cancel")),
1021
+ h("button", { type: "button", className: "rstr-btn rstr-btn-primary", disabled: busy || text.trim() === "", onClick: submit }, busy ? t("editor.saving") : t("importDialog.submit")),
1022
+ ),
1023
+ },
1024
+ h("p", { className: "rstr-hint" }, t("importDialog.hint")),
1025
+ h(Field, { label: t("importDialog.target") }, h(Select, { value: rootDir, onChange: setRootDir, options: targets.map((target) => ({ value: target.rootDir, label: target.label })) })),
1026
+ h(TextArea, { value: text, onChange: setText, rows: 16, mono: true, placeholder: t("importDialog.placeholder") }),
1027
+ failure !== null
1028
+ ? h("div", { className: "rstr-error" }, h("code", { className: "rstr-code" }, failure.code), h(DiagnosticList, { items: failure.diagnostics }))
1029
+ : null,
1030
+ );
1031
+ }
1032
+
1033
+ function DeleteDialog({ roster, onClose, onDone }) {
1034
+ useTranslation();
1035
+ const [busy, setBusy] = useState(false);
1036
+ const [failure, setFailure] = useState(null);
1037
+
1038
+ async function submit() {
1039
+ setBusy(true);
1040
+ const result = await mutate({ action: "delete", rootDir: roster.rootDir, id: roster.id });
1041
+ setBusy(false);
1042
+ if (result.ok) onDone();
1043
+ else setFailure(result.body?.error?.code ?? "delete-failed");
1044
+ }
1045
+
1046
+ return h(
1047
+ Modal,
1048
+ {
1049
+ title: t("delete.title", { id: roster.id }),
1050
+ onClose,
1051
+ footer: h(
1052
+ "div",
1053
+ { className: "rstr-modal-foot-actions" },
1054
+ h("button", { type: "button", className: "rstr-btn", onClick: onClose }, t("delete.cancel")),
1055
+ h("button", { type: "button", className: "rstr-btn rstr-btn-danger", disabled: busy, onClick: submit }, t("delete.confirm")),
1056
+ ),
1057
+ },
1058
+ h("p", null, t("delete.body", { file: roster.file })),
1059
+ failure !== null ? h("code", { className: "rstr-code" }, failure) : null,
1060
+ );
1061
+ }
1062
+
1063
+ /* ------------------------------------------------------------------ */
1064
+ /* 主面板 */
1065
+ /* ------------------------------------------------------------------ */
1066
+
1067
+ function Panel() {
1068
+ useTranslation();
1069
+ const library = useLibrary();
1070
+ const [tab, setTab] = useState("rosters");
1071
+ const [query, setQuery] = useState("");
1072
+ const [editor, setEditor] = useState(null);
1073
+ const [importing, setImporting] = useState(false);
1074
+ const [removing, setRemoving] = useState(null);
1075
+ const [toast, setToast] = useState(null);
1076
+
1077
+ useEffect(() => {
1078
+ if (toast === null) return;
1079
+ const timer = setTimeout(() => setToast(null), 4000);
1080
+ return () => clearTimeout(timer);
1081
+ }, [toast]);
1082
+
1083
+ const snapshot = library.data;
1084
+ const rosters = useMemo(() => {
1085
+ const all = snapshot?.workspaces?.flatMap((workspace) =>
1086
+ (workspace.rosters ?? []).map((roster) => ({ ...roster, rootDir: workspace.rootDir })),
1087
+ ) ?? [];
1088
+ const needle = query.trim().toLowerCase();
1089
+ if (needle === "") return all;
1090
+ return all.filter((roster) =>
1091
+ [roster.id, roster.title, roster.description, ...(roster.tags ?? []), ...(roster.members ?? []).map((member) => member.name)]
1092
+ .filter((value) => typeof value === "string")
1093
+ .some((value) => value.toLowerCase().includes(needle)),
1094
+ );
1095
+ }, [snapshot, query]);
1096
+
1097
+ const teams = snapshot?.teams?.teams ?? [];
1098
+ const hints = snapshot?.hints ?? [];
1099
+ const attention = hints.filter((hint) => hint.severity !== "info").length;
1100
+ const targets = useMemo(
1101
+ () =>
1102
+ (snapshot?.workspaces ?? []).map((workspace) => ({
1103
+ rootDir: workspace.rootDir,
1104
+ label:
1105
+ workspace.scope === "global"
1106
+ ? `${t("settings.globalLibrary")} — ${workspace.rootDir}`
1107
+ : `${workspace.rootDir}${workspace.title ? ` — ${workspace.title}` : ""}`,
1108
+ })),
1109
+ [snapshot],
1110
+ );
1111
+ const defaultRoot =
1112
+ snapshot?.workspaces?.find((workspace) => workspace.missing !== true)?.rootDir ?? targets[0]?.rootDir ?? "";
1113
+
1114
+ async function copyPayload(roster) {
1115
+ const payload = {
1116
+ plan: {
1117
+ members: roster.members.map((member) => {
1118
+ const entry = { name: member.name, role: member.role || member.name };
1119
+ if (member.provider) entry.provider = member.provider;
1120
+ if (member.model) entry.model = member.model;
1121
+ if (member.reasoningEffort && member.reasoningEffort !== "default") entry.reasoningEffort = member.reasoningEffort;
1122
+ if (member.toolPolicy) entry.tool_policy = member.toolPolicy;
1123
+ return entry;
1124
+ }),
1125
+ tasks: [],
1126
+ },
1127
+ };
1128
+ try {
1129
+ await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
1130
+ setToast(t("roster.copied"));
1131
+ } catch {
1132
+ setToast(t("roster.copyFailed"));
1133
+ }
1134
+ }
1135
+
1136
+ const header = h(
1137
+ "header",
1138
+ { className: "rstr-head" },
1139
+ h(
1140
+ "div",
1141
+ null,
1142
+ h("h1", { className: "rstr-h1" }, t("panel.title")),
1143
+ h(
1144
+ "p",
1145
+ { className: "rstr-muted" },
1146
+ t("panel.subtitle", {
1147
+ rosters: snapshot?.summary?.rosters ?? 0,
1148
+ members: snapshot?.summary?.members ?? 0,
1149
+ teams: snapshot?.summary?.teams ?? 0,
1150
+ }),
1151
+ ),
1152
+ ),
1153
+ h(
1154
+ "div",
1155
+ { className: "rstr-head-actions" },
1156
+ h("button", { type: "button", className: "rstr-btn", onClick: library.reload, disabled: library.status === "refreshing" }, library.status === "refreshing" ? t("panel.refreshing") : t("panel.refresh")),
1157
+ h("button", { type: "button", className: "rstr-btn", onClick: () => setImporting(true), disabled: targets.length === 0 }, t("panel.import")),
1158
+ h("button", { type: "button", className: "rstr-btn rstr-btn-primary", onClick: () => setEditor({ roster: null, rootDir: defaultRoot }), disabled: targets.length === 0 }, t("panel.create")),
1159
+ ),
1160
+ );
1161
+
1162
+ if (library.status === "loading") {
1163
+ return h("div", { className: "rstr-page" }, header, h("p", { className: "rstr-muted" }, t("panel.loading")));
1164
+ }
1165
+
1166
+ if (library.status === "error" && snapshot === null) {
1167
+ return h(
1168
+ "div",
1169
+ { className: "rstr-page" },
1170
+ header,
1171
+ h(
1172
+ "div",
1173
+ { className: "rstr-error" },
1174
+ h("p", { className: "rstr-error-title" }, t("panel.errorTitle")),
1175
+ h("button", { type: "button", className: "rstr-btn", onClick: library.reload }, t("panel.retry")),
1176
+ ),
1177
+ );
1178
+ }
1179
+
1180
+ const tabs = h(
1181
+ "div",
1182
+ { className: "rstr-tabs", role: "tablist" },
1183
+ [
1184
+ ["rosters", t("panel.tabsRosters"), rosters.length],
1185
+ ["teams", t("panel.tabsTeams"), teams.length],
1186
+ ["sources", t("panel.tabsSources"), snapshot?.remote?.collections?.length ?? 0],
1187
+ // 只有真正要用户处理的问题才计数:info 只是说明,不该在页签上挂数字。
1188
+ ["health", t("panel.tabsDiagnostics"), attention],
1189
+ ].map(([value, label, count]) =>
1190
+ h(
1191
+ "button",
1192
+ {
1193
+ key: value,
1194
+ type: "button",
1195
+ role: "tab",
1196
+ "aria-selected": tab === value,
1197
+ className: `rstr-tab${tab === value ? " rstr-tab-on" : ""}`,
1198
+ onClick: () => setTab(value),
1199
+ },
1200
+ label,
1201
+ h("span", { className: "rstr-tab-count" }, String(count)),
1202
+ ),
1203
+ ),
1204
+ );
1205
+
1206
+ let content = null;
1207
+
1208
+ if (tab === "rosters") {
1209
+ if (rosters.length === 0) {
1210
+ content = h(EmptyState, {
1211
+ title: t("panel.emptyTitle"),
1212
+ body: t("panel.emptyBody"),
1213
+ cta: targets.length === 0 ? undefined : t("panel.emptyCta"),
1214
+ onCta: () => setEditor({ roster: null, rootDir: defaultRoot }),
1215
+ });
1216
+ } else {
1217
+ content = h(
1218
+ "div",
1219
+ null,
1220
+ h("input", {
1221
+ className: "rstr-input rstr-search",
1222
+ value: query,
1223
+ placeholder: t("panel.searchPlaceholder"),
1224
+ "aria-label": t("panel.search"),
1225
+ onChange: (event) => setQuery(event.target.value),
1226
+ }),
1227
+ rosters.length === 0
1228
+ ? h("p", { className: "rstr-muted" }, t("panel.noMatch"))
1229
+ : h(
1230
+ "div",
1231
+ { className: "rstr-cards" },
1232
+ rosters.map((roster) =>
1233
+ h(RosterCard, {
1234
+ key: `${roster.rootDir}::${roster.id}`,
1235
+ roster,
1236
+ deployment: snapshot?.deployments?.[roster.id],
1237
+ onEdit: () => setEditor({ roster, rootDir: roster.rootDir }),
1238
+ onDuplicate: () => setEditor({ roster, rootDir: roster.rootDir, duplicate: true }),
1239
+ onDelete: () => setRemoving(roster),
1240
+ onCopy: copyPayload,
1241
+ }),
1242
+ ),
1243
+ ),
1244
+ );
1245
+ }
1246
+ }
1247
+
1248
+ if (tab === "teams") {
1249
+ content =
1250
+ teams.length === 0
1251
+ ? h(EmptyState, { title: t("team.emptyTitle"), body: t("team.emptyBody") })
1252
+ : h("div", { className: "rstr-cards" }, teams.map((team) => h(TeamCard, { key: team.id, team })));
1253
+ }
1254
+
1255
+ if (tab === "sources") {
1256
+ content = h(SourcesTab, {
1257
+ remote: snapshot?.remote ?? null,
1258
+ onToast: setToast,
1259
+ onPulled: () => library.reload(),
1260
+ });
1261
+ }
1262
+
1263
+ if (tab === "health") {
1264
+ content = h(HealthTab, { snapshot });
1265
+ }
1266
+
1267
+ const banner =
1268
+ attention > 0
1269
+ ? h(
1270
+ "div",
1271
+ { className: "rstr-banner" },
1272
+ h("span", null, attention === 1 ? t("panel.bannerOne") : t("panel.bannerMany", { count: attention })),
1273
+ h("button", { type: "button", className: "rstr-btn rstr-btn-sm", onClick: () => setTab("health") }, t("panel.bannerCta")),
1274
+ )
1275
+ : null;
1276
+
1277
+ return h(
1278
+ "div",
1279
+ { className: "rstr-page" },
1280
+ header,
1281
+ banner,
1282
+ tabs,
1283
+ content,
1284
+ h(
1285
+ "footer",
1286
+ { className: "rstr-foot" },
1287
+ h("span", { className: "rstr-muted rstr-small" }, `${snapshot?.plugin ?? "agent-roster"} ${snapshot?.version ?? ""}`),
1288
+ snapshot?.workspaces?.some((workspace) => workspace.fallbackWorkspace)
1289
+ ? h("span", { className: "rstr-muted rstr-small" }, t("panel.workspaceFallback"))
1290
+ : null,
1291
+ ),
1292
+ toast !== null ? h("div", { className: "rstr-toast", role: "status" }, toast) : null,
1293
+ editor !== null
1294
+ ? h(Editor, {
1295
+ target: { roster: editor.roster, rootDir: editor.rootDir, duplicate: editor.duplicate === true },
1296
+ onClose: () => setEditor(null),
1297
+ onSaved: (file) => {
1298
+ setEditor(null);
1299
+ setToast(file);
1300
+ library.reload();
1301
+ },
1302
+ })
1303
+ : null,
1304
+ importing
1305
+ ? h(ImportDialog, {
1306
+ targets,
1307
+ initialRoot: defaultRoot,
1308
+ onClose: () => setImporting(false),
1309
+ onDone: (file) => {
1310
+ setImporting(false);
1311
+ setToast(t("import.done", { file }));
1312
+ library.reload();
1313
+ },
1314
+ })
1315
+ : null,
1316
+ removing !== null
1317
+ ? h(DeleteDialog, {
1318
+ roster: removing,
1319
+ onClose: () => setRemoving(null),
1320
+ onDone: () => {
1321
+ setRemoving(null);
1322
+ library.reload();
1323
+ },
1324
+ })
1325
+ : null,
1326
+ );
1327
+ }
1328
+
1329
+ /**
1330
+ * 公共档案源:列出订阅的 registry 目录,逐个提供「拉取到本机」。
1331
+ *
1332
+ * 拉取是**显式动作**:后端按内容比对,相同的跳过、不同的默认不覆盖(会把冲突数报回来)。
1333
+ * 前端不做这份判断——它只把结果说清楚,避免"点一下按钮,本地改动就没了"。
1334
+ */
1335
+ function SourcesTab({ remote, onToast, onPulled }) {
1336
+ useTranslation();
1337
+ const [pulling, setPulling] = useState("");
1338
+ const [openId, setOpenId] = useState("");
1339
+ const [detail, setDetail] = useState(null);
1340
+
1341
+ const collections = remote?.collections ?? [];
1342
+ const indexes = remote?.indexes ?? [];
1343
+ const problems = (remote?.diagnostics ?? []).filter((item) => item.severity !== "info");
1344
+
1345
+ async function pull(collection) {
1346
+ setPulling(collection.id);
1347
+ try {
1348
+ const result = await readJson(INSTALL_ROUTE, {
1349
+ method: "POST",
1350
+ headers: { "content-type": "application/json" },
1351
+ body: JSON.stringify({ collection: collection.id }),
1352
+ });
1353
+ if (!result.ok || result.body === null) {
1354
+ onToast(`${t("misc.failed")} (${String(result.status)})`);
1355
+ return;
1356
+ }
1357
+ const body = result.body;
1358
+ const summary = t("source.pulled", {
1359
+ written: body.written ?? 0,
1360
+ unchanged: body.unchanged ?? 0,
1361
+ conflicts: body.conflicts ?? 0,
1362
+ });
1363
+ onToast(body.conflicts > 0 ? `${summary} · ${t("source.conflictHint")}` : summary);
1364
+ onPulled();
1365
+ } catch {
1366
+ onToast(t("misc.failed"));
1367
+ } finally {
1368
+ setPulling("");
1369
+ }
1370
+ }
1371
+
1372
+ async function toggle(collection) {
1373
+ if (openId === collection.id) {
1374
+ setOpenId("");
1375
+ setDetail(null);
1376
+ return;
1377
+ }
1378
+ setOpenId(collection.id);
1379
+ setDetail({ id: collection.id, status: "loading", body: null });
1380
+ const query = new URLSearchParams({ id: collection.id });
1381
+ if (collection.origin?.source !== undefined) query.set("source", collection.origin.source);
1382
+ const result = await readJson(`${COLLECTION_ROUTE}?${query.toString()}`);
1383
+ if (result.ok && result.body !== null) {
1384
+ setDetail({ id: collection.id, status: "ready", body: result.body });
1385
+ } else {
1386
+ setDetail({ id: collection.id, status: "error", body: null });
1387
+ }
1388
+ }
1389
+
1390
+ if (collections.length === 0) {
1391
+ return h(
1392
+ "div",
1393
+ null,
1394
+ h(EmptyState, { title: t("source.empty"), body: t("source.emptyHint") }),
1395
+ problems.length > 0 ? h(DiagnosticList, { items: problems }) : null,
1396
+ );
1397
+ }
1398
+
1399
+ return h(
1400
+ "div",
1401
+ { className: "rstr-sources" },
1402
+ indexes.length > 0
1403
+ ? h(
1404
+ "p",
1405
+ { className: "rstr-muted rstr-small" },
1406
+ indexes
1407
+ .map((index) =>
1408
+ `${index.label} · ${index.stale ? t("source.stale") : t("source.fetchedAt", { when: index.fetchedAt ?? "-" })}`,
1409
+ )
1410
+ .join(" "),
1411
+ )
1412
+ : null,
1413
+ h(
1414
+ "div",
1415
+ { className: "rstr-cards" },
1416
+ collections.map((collection) =>
1417
+ h(
1418
+ "article",
1419
+ { key: `${collection.origin?.source ?? ""}::${collection.id}`, className: "rstr-card" },
1420
+ h(
1421
+ "header",
1422
+ { className: "rstr-card-head" },
1423
+ h("h3", { className: "rstr-card-title" }, collection.title || collection.id),
1424
+ h(Badge, { tone: "neutral" }, t("source.registered", { count: collection.rosters?.length ?? 0 })),
1425
+ ),
1426
+ collection.description ? h("p", { className: "rstr-card-desc" }, collection.description) : null,
1427
+ h(
1428
+ "p",
1429
+ { className: "rstr-muted rstr-small" },
1430
+ `${t("source.repo")} ${collection.repo ?? ""}@${collection.ref ?? "main"}${collection.path ? `/${collection.path}` : ""}`,
1431
+ ),
1432
+ h(
1433
+ "div",
1434
+ { className: "rstr-card-actions" },
1435
+ h(
1436
+ "button",
1437
+ {
1438
+ type: "button",
1439
+ className: "rstr-btn rstr-btn-primary",
1440
+ disabled: pulling !== "",
1441
+ onClick: () => pull(collection),
1442
+ },
1443
+ pulling === collection.id ? t("source.pulling") : t("source.pull"),
1444
+ ),
1445
+ h(
1446
+ "button",
1447
+ { type: "button", className: "rstr-btn", onClick: () => toggle(collection) },
1448
+ openId === collection.id ? t("source.hide") : t("source.open"),
1449
+ ),
1450
+ ),
1451
+ openId === collection.id
1452
+ ? detail === null || detail.status === "loading"
1453
+ ? h("p", { className: "rstr-muted" }, t("source.loading"))
1454
+ : detail.status === "error"
1455
+ ? h("p", { className: "rstr-muted" }, t("misc.failed"))
1456
+ : h(
1457
+ "div",
1458
+ { className: "rstr-source-detail" },
1459
+ (detail.body?.rosters ?? []).map((roster) =>
1460
+ h(
1461
+ "div",
1462
+ { key: roster.id, className: "rstr-source-roster" },
1463
+ h("span", { className: "rstr-mono" }, roster.id),
1464
+ h("span", { className: "rstr-muted" }, roster.title ?? ""),
1465
+ h(Badge, { tone: "neutral" }, `${(roster.members ?? []).length}`),
1466
+ ),
1467
+ ),
1468
+ (detail.body?.diagnostics ?? []).length > 0
1469
+ ? h(DiagnosticList, { items: detail.body.diagnostics })
1470
+ : null,
1471
+ )
1472
+ : null,
1473
+ ),
1474
+ ),
1475
+ ),
1476
+ problems.length > 0 ? h(DiagnosticList, { items: problems }) : null,
1477
+ );
1478
+ }
1479
+
1480
+ /** 体检页:配置摘要 + 提示 + 诊断,全部来自快照,不在前端算任何东西。 */
1481
+ function HealthTab({ snapshot }) {
1482
+ const hints = snapshot?.hints ?? [];
1483
+ const diagnostics = (snapshot?.workspaces ?? []).flatMap((workspace) => workspace.diagnostics ?? []);
1484
+ const teamDiagnostics = snapshot?.teams?.diagnostics ?? [];
1485
+
1486
+ return h(
1487
+ "div",
1488
+ { className: "rstr-health" },
1489
+ h(
1490
+ "div",
1491
+ { className: "rstr-config" },
1492
+ configRow(
1493
+ t("settings.rosterDirs"),
1494
+ (snapshot?.config?.rosterDirs ?? []).length > 0
1495
+ ? snapshot.config.rosterDirs.join(", ")
1496
+ : `${t("settings.globalLibrary")} ${snapshot?.config?.defaultRoot ?? ""}`,
1497
+ ),
1498
+ configRow(t("settings.dataDir"), snapshot?.config?.dshHome ?? ""),
1499
+ configRow(t("settings.stateDir"), snapshot?.teams?.stateDir ?? ""),
1500
+ configRow(t("settings.policies"), snapshot?.config?.knownToolPolicies?.length ? t("settings.policiesCount", { count: snapshot.config.knownToolPolicies.length }) : t("settings.policiesNone")),
1501
+ configRow(t("settings.version"), snapshot?.version ?? ""),
1502
+ ),
1503
+ hints.length === 0
1504
+ ? h("p", { className: "rstr-muted" }, t("diagnostics.empty"))
1505
+ : h(
1506
+ "div",
1507
+ { className: "rstr-hint-cards" },
1508
+ hints.map((hint, index) =>
1509
+ h(
1510
+ "article",
1511
+ { key: `${hint.code}-${index}`, className: "rstr-hint-card" },
1512
+ h(
1513
+ "div",
1514
+ { className: "rstr-hint-card-head" },
1515
+ h(Badge, { tone: hint.severity }, severityLabel(hint.severity)),
1516
+ h("strong", null, t(`hint.${hint.code}.title`)),
1517
+ ),
1518
+ h("p", { className: "rstr-muted" }, t(`hint.${hint.code}.body`, hint.params ?? {})),
1519
+ ),
1520
+ ),
1521
+ ),
1522
+ h(DiagnosticList, { items: [...diagnostics, ...teamDiagnostics] }),
1523
+ );
1524
+ }
1525
+
1526
+ function configRow(label, value) {
1527
+ return h(
1528
+ "div",
1529
+ { className: "rstr-config-row" },
1530
+ h("span", { className: "rstr-label" }, label),
1531
+ h("code", { className: "rstr-code rstr-truncate", title: value }, value === "" ? "—" : value),
1532
+ );
1533
+ }
1534
+
1535
+ /**
1536
+ * 严重度的可读标签。`settings` 里没有 severity 词条,复用诊断里的三个词,
1537
+ * 拼不出词条时退回原始值——宁可露出 `warn` 也不显示空标签。
1538
+ */
1539
+ function severityLabel(severity) {
1540
+ const key = `diagnostics.severity${capitalize(severity ?? "info")}`;
1541
+ const value = readPath(i18n.dict(), key) ?? readPath(i18n.dicts.en ?? {}, key);
1542
+ return value ?? String(severity ?? "info");
1543
+ }
1544
+
1545
+ /* ------------------------------------------------------------------ */
1546
+ /* 设置页:语言 + 只读配置摘要 */
1547
+ /* ------------------------------------------------------------------ */
1548
+
1549
+ /**
1550
+ * 设置页的一节。
1551
+ *
1552
+ * 语言的写入走配置表单(`configForms.get(NS)`),因此保存后立即生效、无需重启;
1553
+ * 语言之外的配置项只读展示——档案目录与状态目录属于"部署事实",
1554
+ * 让它们出现在两个地方反而会让人分不清哪个生效。
1555
+ */
1556
+ function SettingsSection({ form, library, onOpenPanel }) {
1557
+ useTranslation();
1558
+ const [language, setLanguage] = useState(() => readFormLanguage(form));
1559
+ const [version, setVersion] = useState("");
1560
+
1561
+ useEffect(() => {
1562
+ if (form?.subscribe === undefined) return undefined;
1563
+ const off = form.subscribe(() => setLanguage(readFormLanguage(form)));
1564
+ return () => {
1565
+ if (typeof off === "function") off();
1566
+ };
1567
+ }, [form]);
1568
+
1569
+ useEffect(() => {
1570
+ setVersion(String(library?.data?.version ?? ""));
1571
+ }, [library?.data?.version]);
1572
+
1573
+ function change(next) {
1574
+ setLanguage(next);
1575
+ // auto 用空串表示"跟随":配置项的默认值就是 auto,写回字符串即可。
1576
+ form?.set?.("language", next);
1577
+ }
1578
+
1579
+ return h(
1580
+ "section",
1581
+ { className: "rstr-settings" },
1582
+ h("h3", { className: "rstr-settings-title" }, t("settings.nav")),
1583
+ h("p", { className: "rstr-muted rstr-small" }, t("settings.languageHint")),
1584
+ h(
1585
+ "div",
1586
+ { className: "rstr-settings-row" },
1587
+ h("span", { className: "rstr-label" }, t("settings.language")),
1588
+ h(
1589
+ "div",
1590
+ { className: "rstr-segmented" },
1591
+ LANGUAGES.map((value) =>
1592
+ h(
1593
+ "button",
1594
+ {
1595
+ key: value,
1596
+ type: "button",
1597
+ className: `rstr-seg${language === value ? " rstr-seg-on" : ""}`,
1598
+ "aria-pressed": language === value,
1599
+ onClick: () => change(value),
1600
+ },
1601
+ value === "auto" ? t("settings.languageAuto") : value === "zh" ? t("settings.languageZh") : t("settings.languageEn"),
1602
+ ),
1603
+ ),
1604
+ ),
1605
+ ),
1606
+ h(
1607
+ "div",
1608
+ { className: "rstr-settings-row" },
1609
+ h("span", { className: "rstr-label" }, t("settings.rosterDirs")),
1610
+ h(
1611
+ "code",
1612
+ { className: "rstr-code rstr-truncate", title: (library?.data?.config?.rosterDirs ?? []).join(", ") },
1613
+ (library?.data?.config?.rosterDirs ?? []).length > 0
1614
+ ? library.data.config.rosterDirs.join(", ")
1615
+ : `${t("settings.globalLibrary")} ${library?.data?.config?.defaultRoot ?? ""}`,
1616
+ ),
1617
+ ),
1618
+ h(
1619
+ "div",
1620
+ { className: "rstr-settings-row" },
1621
+ h("span", { className: "rstr-label" }, t("settings.dataDir")),
1622
+ h(
1623
+ "code",
1624
+ { className: "rstr-code rstr-truncate", title: library?.data?.config?.defaultRoot ?? "" },
1625
+ library?.data?.config?.dshHome ?? "—",
1626
+ ),
1627
+ ),
1628
+ h(
1629
+ "div",
1630
+ { className: "rstr-settings-row" },
1631
+ h("span", { className: "rstr-label" }, t("settings.stateDir")),
1632
+ h("code", { className: "rstr-code rstr-truncate", title: library?.data?.teams?.stateDir ?? "" }, library?.data?.teams?.stateDir ?? "—"),
1633
+ ),
1634
+ h(
1635
+ "div",
1636
+ { className: "rstr-settings-row" },
1637
+ h("span", { className: "rstr-label" }, t("settings.version")),
1638
+ h("code", { className: "rstr-code" }, version),
1639
+ h("button", { type: "button", className: "rstr-btn rstr-btn-sm", onClick: onOpenPanel }, t("settings.openPanel")),
1640
+ ),
1641
+ );
1642
+ }
1643
+
1644
+ function readFormLanguage(form) {
1645
+ let value = "auto";
1646
+ try {
1647
+ const snapshotValue = form?.getSnapshot?.()?.value;
1648
+ if (snapshotValue !== null && typeof snapshotValue === "object" && LANGUAGES.includes(snapshotValue.language)) {
1649
+ value = snapshotValue.language;
1650
+ }
1651
+ } catch {
1652
+ /* 表单不可用时按"跟随 DSH"处理。 */
1653
+ }
1654
+ return value;
1655
+ }
1656
+
1657
+ /* ------------------------------------------------------------------ */
1658
+ /* 样式 */
1659
+ /* ------------------------------------------------------------------ */
1660
+
1661
+ /**
1662
+ * 一次性注入样式表。
1663
+ *
1664
+ * 颜色**只用**宿主真实存在的 alias token(见 dsh-client-ui-theme 的 body
1665
+ * 变量表),**不写浅色兜底值**:宿主的 token 会随 `body[data-ds-dark-theme]`
1666
+ * 整体切换,写死 `#fff`/`#111` 的兜底在暗色下就是白底白字。
1667
+ * 需要"淡色底"的地方用 `color-mix(... , transparent)` 自己算,深浅主题都成立。
1668
+ * 文本色一律显式给出(不用 inherit),避免继承到与背景同色的文本。
1669
+ */
1670
+ const CSS = `
1671
+ .rstr-page { display: flex; flex-direction: column; gap: 16px; padding: 20px 24px 32px; max-width: 1120px; margin: 0 auto; color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.55; }
1672
+ .rstr-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
1673
+ .rstr-h1 { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; color: var(--dsw-alias-label-primary); }
1674
+ .rstr-muted { color: var(--dsw-alias-label-secondary); margin: 0; }
1675
+ .rstr-small { font-size: 12px; }
1676
+ .rstr-mono, .rstr-code, code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
1677
+ .rstr-code { font-size: 12px; color: var(--dsw-alias-label-secondary); }
1678
+ .rstr-code-strong { font-size: 13px; color: var(--dsw-alias-label-primary); font-weight: 600; }
1679
+ .rstr-truncate { display: inline-block; max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
1680
+ .rstr-head-actions { display: flex; gap: 8px; flex-wrap: wrap; }
1681
+
1682
+ .rstr-btn { border: 1px solid var(--dsw-alias-border-l3); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 8px; padding: 6px 12px; font-size: 13px; font-family: inherit; cursor: pointer; transition: background .12s ease, border-color .12s ease; }
1683
+ .rstr-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
1684
+ .rstr-btn:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 1px; }
1685
+ .rstr-btn:disabled { opacity: .5; cursor: not-allowed; }
1686
+ .rstr-btn-primary { background: var(--dsw-alias-button-primary-fill); border-color: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-inverted); }
1687
+ .rstr-btn-primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); border-color: var(--dsw-alias-button-primary-hover); }
1688
+ .rstr-btn-danger { color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 72%, var(--dsw-alias-label-primary)); border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); }
1689
+ .rstr-btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); }
1690
+ .rstr-btn-ghost { border-style: dashed; }
1691
+ .rstr-btn-sm { padding: 3px 9px; font-size: 12px; }
1692
+ .rstr-btn-dashed { width: 100%; border-style: dashed; margin-top: 8px; }
1693
+ .rstr-icon-btn { border: none; background: transparent; font-size: 20px; line-height: 1; cursor: pointer; color: var(--dsw-alias-label-secondary); padding: 4px 8px; border-radius: 6px; }
1694
+ .rstr-icon-btn:hover { background: var(--dsw-alias-interactive-bg-hover); }
1695
+
1696
+ .rstr-input { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l3); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 8px; padding: 7px 10px; font-size: 13px; font-family: inherit; }
1697
+ .rstr-input::placeholder { color: var(--dsw-alias-label-secondary); }
1698
+ .rstr-input:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 1px; }
1699
+ .rstr-textarea { resize: vertical; line-height: 1.5; }
1700
+ .rstr-textarea.rstr-mono { font-size: 12px; }
1701
+ .rstr-search { max-width: 320px; }
1702
+
1703
+ .rstr-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 10px; background: var(--dsw-alias-state-warn-tertiary); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 45%, transparent); color: var(--dsw-alias-label-primary); }
1704
+
1705
+ .rstr-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
1706
+ .rstr-tab { border: none; background: transparent; padding: 8px 12px; font-size: 13px; color: var(--dsw-alias-label-secondary); cursor: pointer; border-bottom: 2px solid transparent; display: inline-flex; align-items: center; gap: 6px; }
1707
+ .rstr-tab:hover { color: var(--dsw-alias-label-primary); }
1708
+ .rstr-tab-on { color: var(--dsw-alias-label-primary); border-bottom-color: var(--dsw-alias-state-business-primary); font-weight: 600; }
1709
+ .rstr-tab-count { font-size: 11px; padding: 0 6px; border-radius: 999px; background: var(--dsw-alias-button-ghost-active-fill); color: var(--dsw-alias-label-secondary); }
1710
+
1711
+ .rstr-cards { display: grid; gap: 12px; }
1712
+ .rstr-card { border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; padding: 14px 16px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); display: flex; flex-direction: column; gap: 10px; }
1713
+ .rstr-card-bad { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent); }
1714
+ .rstr-card-id { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1715
+ .rstr-card-title { margin: 4px 0 2px; font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary); }
1716
+ .rstr-card-meta { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--dsw-alias-label-secondary); }
1717
+ .rstr-card-actions { display: flex; gap: 6px; flex-wrap: wrap; }
1718
+ .rstr-sources { display: flex; flex-direction: column; gap: 10px; }
1719
+ .rstr-source-detail { display: flex; flex-direction: column; gap: 6px; border-top: 1px dashed var(--dsw-alias-border-l1); padding-top: 8px; }
1720
+ .rstr-source-roster { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; }
1721
+ .rstr-source-roster > .rstr-muted { flex: 1 1 auto; }
1722
+ .rstr-ok { color: var(--dsw-alias-label-secondary); font-weight: 600; }
1723
+
1724
+ .rstr-chip { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: var(--dsw-alias-button-ghost-active-fill); color: var(--dsw-alias-label-secondary); }
1725
+ .rstr-chip-policy { background: var(--dsw-alias-state-business-tertiary); color: var(--dsw-alias-label-primary); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); }
1726
+ .rstr-badge { font-size: 11px; padding: 1px 7px; border-radius: 6px; border: 1px solid transparent; color: var(--dsw-alias-label-primary); }
1727
+ .rstr-badge-error { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 16%, transparent); border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); }
1728
+ .rstr-badge-warn { background: var(--dsw-alias-state-warn-tertiary); border-color: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 45%, transparent); }
1729
+ .rstr-badge-ok { background: var(--dsw-alias-state-success-tertiary); border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 45%, transparent); }
1730
+ .rstr-badge-info, .rstr-badge-muted { background: var(--dsw-alias-button-ghost-active-fill); color: var(--dsw-alias-label-secondary); }
1731
+
1732
+ .rstr-members { display: flex; flex-direction: column; gap: 8px; border-top: 1px dashed var(--dsw-alias-border-l1); padding-top: 10px; }
1733
+ .rstr-member { padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-markdown-code-block); color: var(--dsw-alias-label-primary); }
1734
+ .rstr-member-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1735
+ .rstr-member-meta { display: flex; gap: 10px; flex-wrap: wrap; font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: 4px; }
1736
+ .rstr-drift { margin: 4px 0 0; padding-left: 18px; font-size: 12px; color: color-mix(in srgb, var(--dsw-alias-state-warn-label) 72%, var(--dsw-alias-label-primary)); }
1737
+
1738
+ .rstr-empty { border: 1px dashed var(--dsw-alias-border-l3); border-radius: 12px; padding: 32px 24px; text-align: center; display: flex; flex-direction: column; gap: 8px; align-items: center; color: var(--dsw-alias-label-primary); }
1739
+ .rstr-empty-title { margin: 0; font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary); }
1740
+
1741
+ .rstr-health { display: flex; flex-direction: column; gap: 14px; }
1742
+ .rstr-config { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 10px; background: var(--dsw-alias-markdown-code-block); color: var(--dsw-alias-label-primary); }
1743
+ .rstr-config-row { display: flex; gap: 12px; align-items: baseline; }
1744
+ .rstr-config-row .rstr-label { min-width: 160px; }
1745
+ .rstr-hint-cards { display: grid; gap: 10px; }
1746
+ .rstr-hint-card { border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; padding: 10px 12px; color: var(--dsw-alias-label-primary); }
1747
+ .rstr-hint-card-head { display: flex; gap: 8px; align-items: center; margin-bottom: 4px; }
1748
+
1749
+ .rstr-diagnostics { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
1750
+ .rstr-diag { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: 12px; padding: 6px 8px; border-radius: 8px; background: var(--dsw-alias-markdown-code-block); color: var(--dsw-alias-label-primary); }
1751
+ .rstr-diag-reason { color: var(--dsw-alias-label-primary); }
1752
+
1753
+ .rstr-overlay { position: fixed; inset: 0; background: var(--dsw-alias-bg-mask-3); display: flex; align-items: flex-start; justify-content: center; padding: 5vh 16px; z-index: 1000; overflow: auto; }
1754
+ .rstr-modal { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l2); border-radius: 14px; width: 640px; max-width: 100%; box-shadow: 0 24px 64px rgba(0,0,0,.32); display: flex; flex-direction: column; max-height: 90vh; }
1755
+ .rstr-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
1756
+ .rstr-modal-title { margin: 0; font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary); }
1757
+ .rstr-modal-body { padding: 16px 18px; overflow: auto; display: flex; flex-direction: column; gap: 12px; }
1758
+ .rstr-modal-foot { padding: 12px 18px; border-top: 1px solid var(--dsw-alias-border-l1); display: flex; justify-content: flex-end; gap: 8px; }
1759
+ .rstr-modal-foot-actions { display: flex; gap: 8px; }
1760
+
1761
+ .rstr-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
1762
+ .rstr-label { font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-secondary); }
1763
+ .rstr-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); margin: 0; }
1764
+ .rstr-hint-warn { color: color-mix(in srgb, var(--dsw-alias-state-warn-label) 70%, var(--dsw-alias-label-primary)); }
1765
+ .rstr-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }
1766
+ .rstr-section-title { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); }
1767
+ .rstr-mt { margin-top: 6px; }
1768
+
1769
+ .rstr-editor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
1770
+ .rstr-editor-toolbar .rstr-input { width: 110px; }
1771
+ .rstr-segmented { display: inline-flex; border: 1px solid var(--dsw-alias-border-l3); border-radius: 8px; overflow: hidden; }
1772
+ .rstr-seg { border: none; background: transparent; padding: 5px 12px; font-size: 12px; cursor: pointer; color: var(--dsw-alias-label-secondary); }
1773
+ .rstr-seg:hover { background: var(--dsw-alias-interactive-bg-hover); }
1774
+ .rstr-seg-on { background: var(--dsw-alias-state-business-tertiary); color: var(--dsw-alias-label-primary); font-weight: 600; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-business-primary) 45%, transparent); }
1775
+
1776
+ .rstr-member-editor { border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; color: var(--dsw-alias-label-primary); }
1777
+ .rstr-member-editor-head { display: flex; align-items: center; gap: 8px; }
1778
+ .rstr-member-editor-head .rstr-input { width: 180px; }
1779
+ .rstr-step { width: 20px; height: 20px; border-radius: 50%; background: var(--dsw-alias-button-ghost-active-fill); font-size: 11px; display: inline-flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-secondary); }
1780
+
1781
+ .rstr-error { border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; color: var(--dsw-alias-label-primary); }
1782
+ .rstr-error-title { margin: 0; font-weight: 600; color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 72%, var(--dsw-alias-label-primary)); }
1783
+ .rstr-confirm { border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 45%, transparent); background: var(--dsw-alias-state-warn-tertiary); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start; color: var(--dsw-alias-label-primary); }
1784
+
1785
+ .rstr-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--dsw-alias-toast-bg); color: var(--dsw-alias-toast-label); padding: 8px 16px; border-radius: 999px; font-size: 13px; z-index: 1100; box-shadow: 0 8px 24px rgba(0,0,0,.24); }
1786
+
1787
+ .rstr-foot { display: flex; justify-content: space-between; gap: 12px; border-top: 1px solid var(--dsw-alias-border-l1); padding-top: 10px; margin-top: 4px; color: var(--dsw-alias-label-secondary); }
1788
+
1789
+ .rstr-settings { display: flex; flex-direction: column; gap: 10px; }
1790
+ .rstr-settings-title { margin: 0; font-size: 14px; font-weight: 600; }
1791
+ .rstr-settings-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
1792
+ .rstr-settings-row .rstr-label { min-width: 120px; }
1793
+ `;
1794
+
1795
+ function ensureStyle() {
1796
+ if (typeof document === "undefined") return;
1797
+ if (document.getElementById(STYLE_ID) !== null) return;
1798
+ const style = document.createElement("style");
1799
+ style.id = STYLE_ID;
1800
+ style.textContent = CSS;
1801
+ document.head.appendChild(style);
1802
+ }
1803
+
1804
+ /* ------------------------------------------------------------------ */
1805
+ /* 挂载 */
1806
+ /* ------------------------------------------------------------------ */
1807
+
1808
+ /**
1809
+ * 注册面板、语言节,并把插件配置与 DSH 语言同步进词典运行时。
1810
+ *
1811
+ * @param {object} ctx 客户端上下文
1812
+ * @param {{ settings?: object }} [_config] 客户端拿不到插件配置,语言走配置表单
1813
+ * @returns {{ apply: Function }}
1814
+ */
1815
+ function defineClient() {
1816
+ return { inject: ["slots"], apply };
1817
+ }
1818
+
1819
+ /**
1820
+ * 客户端挂载点。
1821
+ *
1822
+ * 三个订阅源(配置表单 / DSH 语言 / 词典加载)都汇进同一个 i18n 对象:
1823
+ * 任意一处变化都触发面板与设置节重渲染,不需要手动刷新页面。
1824
+ *
1825
+ * @param {object} ctx
1826
+ * @returns {void}
1827
+ */
1828
+ function apply(ctx) {
1829
+ ensureStyle();
1830
+ if (typeof document !== "undefined") void i18n.load();
1831
+
1832
+ const locale = ctx.get?.("locale") ?? ctx.locale;
1833
+ const configForms = ctx.get?.("configForms") ?? ctx.configForms;
1834
+ const form = configForms?.get?.(NS) ?? null;
1835
+ const layout = ctx.get?.("layout") ?? ctx.layout;
1836
+
1837
+ const readActive = () => {
1838
+ try {
1839
+ const value = locale?.getLocale?.();
1840
+ if (typeof value === "string") return value;
1841
+ if (value !== null && typeof value === "object") {
1842
+ // LocaleSnapshot:{ active, locales, revision }。先取 active,再退到旧形状。
1843
+ return String(value.active ?? value.id ?? value.language ?? value.lang ?? "");
1844
+ }
1845
+ } catch {
1846
+ /* 拿不到就当英文。 */
1847
+ }
1848
+ return "";
1849
+ };
1850
+
1851
+ const sync = () => {
1852
+ i18n.preference = readFormLanguage(form);
1853
+ i18n.activeLocale = readActive();
1854
+ i18n.emit();
1855
+ };
1856
+ sync();
1857
+
1858
+ // 词典是异步取的:取到之后注册进宿主 locale 服务,槽位里的 `locale` 字段
1859
+ // 与 label() 才会走宿主的查找链;注册失败(已存在/语言包不接受)不影响自绘词典。
1860
+ let registered = false;
1861
+ const registerDictionaries = () => {
1862
+ if (registered) return;
1863
+ if (typeof locale?.register !== "function") return;
1864
+ if (i18n.dicts.en === undefined || i18n.dicts.zh === undefined) return;
1865
+ registered = true;
1866
+ try {
1867
+ locale.register(NS, "en", i18n.dicts.en);
1868
+ locale.register(NS, "zh", i18n.dicts.zh);
1869
+ } catch {
1870
+ /* 词典所有权冲突时保持静默:界面仍用 i18n.dict()。 */
1871
+ }
1872
+ };
1873
+ i18n.subscribe(registerDictionaries);
1874
+ registerDictionaries();
1875
+
1876
+ ctx.effect(
1877
+ () => {
1878
+ const offs = [];
1879
+ for (const source of [form, locale]) {
1880
+ if (source !== null && source !== undefined && typeof source.subscribe === "function") {
1881
+ const off = source.subscribe(sync);
1882
+ if (typeof off === "function") offs.push(off);
1883
+ }
1884
+ }
1885
+ return () => {
1886
+ for (const off of offs) off();
1887
+ };
1888
+ },
1889
+ "agent-roster: language sync",
1890
+ );
1891
+
1892
+ const openPanel = () => {
1893
+ try {
1894
+ layout?.selectPanel?.(PANEL_ID);
1895
+ } catch {
1896
+ /* 面板注册晚于点击时 selectPanel 会抛错,忽略即可。 */
1897
+ }
1898
+ };
1899
+
1900
+ ctx.slots.inject("sidebar.panellist", () =>
1901
+ ctx.slots.register(
1902
+ {
1903
+ name: "sidebar.panellist",
1904
+ id: PANEL_ID,
1905
+ order: 20,
1906
+ locale: NS,
1907
+ label: () => t("settings.nav"),
1908
+ },
1909
+ function RosterSidebarIcon(props) {
1910
+ const size = props?.size ?? 16;
1911
+ const active = props?.active === true;
1912
+ return h(
1913
+ "svg",
1914
+ {
1915
+ width: size,
1916
+ height: size,
1917
+ viewBox: "0 0 20 20",
1918
+ fill: "none",
1919
+ "aria-hidden": "true",
1920
+ style: { opacity: active ? 1 : 0.85 },
1921
+ },
1922
+ // 两个头型:左边实心大圆,右边描边小圆,读起来像"一队人"。
1923
+ h("circle", { cx: 7.5, cy: 7, r: 3.2, fill: "currentColor", opacity: active ? 1 : 0.75 }),
1924
+ h("path", { d: "M2.6 16.4c0-2.9 2.2-4.6 4.9-4.6s4.9 1.7 4.9 4.6", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", fill: "none" }),
1925
+ h("circle", { cx: 14.4, cy: 8.2, r: 2.3, stroke: "currentColor", strokeWidth: 1.4, fill: "none" }),
1926
+ h("path", { d: "M12.4 16.2c.2-2.2 1.5-3.5 3.4-3.5 1.5 0 2.6.9 3 2.4", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", fill: "none" }),
1927
+ );
1928
+ },
1929
+ ),
1930
+ );
1931
+
1932
+ ctx.slots.inject("main", () =>
1933
+ ctx.slots.register({ name: "main", key: PANEL_ID, locale: NS }, Panel),
1934
+ );
1935
+
1936
+ ctx.slots.inject("settings.section", () =>
1937
+ ctx.slots.register(
1938
+ { name: "settings.section", id: PANEL_ID, order: 55, locale: NS, label: () => t("settings.nav") },
1939
+ function AgentRosterSettings() {
1940
+ const library = useLibrary();
1941
+ return h(SettingsSection, { form, library, onOpenPanel: openPanel });
1942
+ },
1943
+ ),
1944
+ );
1945
+ }
1946
+
1947
+ return defineClient();
1948
+ },
1949
+ });
1950
+