dsh-ntr 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -230,6 +230,47 @@ export function useAcpMenu() {
230
230
  return () => { menuListeners.delete(cb); };
231
231
  }, () => menuState);
232
232
  }
233
+ // ---- The NTR settings dialog ----
234
+ /**
235
+ * Open state of the frame-wide NTR settings dialog (`settings-dialog.tsx`).
236
+ *
237
+ * Module-level because the two ends live in unrelated slot trees: the trigger
238
+ * is the composer's agent base (a session-scoped model-seat entry) and the
239
+ * dialog itself rides the root `shell.overlay` layer. A React context cannot
240
+ * span that boundary, so this is the same tiny external-store seam the menu
241
+ * uses above.
242
+ */
243
+ let settingsDialogOpen = false;
244
+ const settingsDialogListeners = new Set();
245
+ function emitSettingsDialog() {
246
+ for (const listener of [...settingsDialogListeners])
247
+ listener();
248
+ }
249
+ /** Open the NTR settings dialog (the empty agent roster's way forward). */
250
+ export function openAcpSettingsDialog() {
251
+ if (settingsDialogOpen)
252
+ return;
253
+ settingsDialogOpen = true;
254
+ emitSettingsDialog();
255
+ }
256
+ /** Close the NTR settings dialog (close button, mask click, Escape). */
257
+ export function closeAcpSettingsDialog() {
258
+ if (!settingsDialogOpen)
259
+ return;
260
+ settingsDialogOpen = false;
261
+ emitSettingsDialog();
262
+ }
263
+ /** Current dialog state, for tests and non-React readers. */
264
+ export function acpSettingsDialogIsOpen() {
265
+ return settingsDialogOpen;
266
+ }
267
+ /** Subscribe to the dialog state (the root overlay entry reads it). */
268
+ export function useAcpSettingsDialogOpen() {
269
+ return useSyncExternalStore(cb => {
270
+ settingsDialogListeners.add(cb);
271
+ return () => { settingsDialogListeners.delete(cb); };
272
+ }, acpSettingsDialogIsOpen);
273
+ }
233
274
  /**
234
275
  * Pure popup placement, viewport coordinates: the menu docks with its right
235
276
  * edge under the trigger's, `gap`px above it — and only then clamps to the
@@ -527,6 +568,26 @@ const STYLE = `
527
568
  .ntr-acp-empty-hint { color: var(--dsw-alias-text-secondary, #6b7280); font-size: 12px; }
528
569
  .ntr-acp-seat-diag { font-family: var(--dsw-alias-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); opacity: 0.72; word-break: break-word; }
529
570
  .ntr-acp-empty-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
571
+ /* ---- NTR settings dialog (shell.overlay) ---- */
572
+ /* Frame-wide surface opened from the agent base's empty-roster menu. The
573
+ overlay layer itself is click-through, so the layer opts back in and blocks
574
+ the app while it is up. */
575
+ .ntr-acp-dialog-layer { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; pointer-events: auto; }
576
+ .ntr-acp-dialog-mask { position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.4)); }
577
+ .ntr-acp-dialog {
578
+ position: relative; display: flex; flex-direction: column; overflow: hidden;
579
+ width: min(920px, calc(100vw - 48px)); height: min(820px, calc(100vh - 48px));
580
+ background: var(--dsw-alias-bg-layer-2, #fff); color: inherit;
581
+ border-radius: 24px; box-shadow: var(--dsw-elevation-prominent, 0 24px 60px rgba(0, 0, 0, 0.25));
582
+ }
583
+ .ntr-acp-dialog-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 16px 16px 8px 24px; }
584
+ .ntr-acp-dialog-title { flex: 1; font-size: 16px; font-weight: 600; }
585
+ .ntr-acp-dialog-close { font: inherit; font-size: 14px; line-height: 1; width: 30px; height: 30px; padding: 0; border: none; border-radius: 999px; background: none; color: inherit; cursor: pointer; }
586
+ .ntr-acp-dialog-close:hover { background: var(--dsw-alias-interactive-bg-active, #0000000d); }
587
+ .ntr-acp-dialog-body { flex: 1; min-height: 0; overflow: auto; }
588
+ /* The section brings its own page scroll for the settings shell's content
589
+ column; the dialog body is the scroll owner here. */
590
+ .ntr-acp-dialog-body > .dwa-root { max-width: none; overflow: visible; }
530
591
  `;
531
592
  let styleMounted = false;
532
593
  /** Mount the shared stylesheet once (same pattern as the settings panel's). */
@@ -179,9 +179,10 @@ function PlanCard({ entries, t }) {
179
179
  return (_jsxs("div", { className: "ntr-acp-plan", children: [_jsx("div", { className: "ntr-acp-plan-head", children: t(`计划 ${done}/${entries.length}`, `Plan ${done}/${entries.length}`) }), entries.map((entry, index) => (_jsxs("div", { className: `ntr-acp-plan-row is-${entry.status}`, children: [_jsx("span", { className: "ntr-acp-plan-glyph", "aria-hidden": true, children: entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '▸' : '○' }), _jsx("span", { children: entry.content }), entry.priority !== undefined && _jsx("span", { className: "ntr-acp-plan-prio", children: entry.priority })] }, index)))] }));
180
180
  }
181
181
  /**
182
- * One stream block, dispatched by kind. Memoized on its props — `block` and
183
- * the store's instance-bound `label` — so a folded batch re-renders only the
184
- * rows whose block object changed (`foldUpdate` replaces blocks immutably)
182
+ * One stream block, dispatched by kind. Memoized on its props — `block`, the
183
+ * store's instance-bound `label`, and `localeRev` (it moves on a language
184
+ * switch, so translated copy re-renders) — so a folded batch re-renders only
185
+ * the rows whose block object changed (`foldUpdate` replaces blocks immutably)
185
186
  * instead of the whole transcript on every store notification. `key` stays the
186
187
  * index on purpose: blocks fold append-only, and identity is the block object.
187
188
  */
@@ -327,5 +328,5 @@ export function AcpConversationView(props) {
327
328
  pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < 40;
328
329
  }, children: transcript.blocks.length === 0
329
330
  ? _jsx("div", { className: "ntr-acp-empty-hint", children: t('还没有消息:在下面的输入框里发给它。', 'Nothing yet — send it a message below.') })
330
- : transcript.blocks.map((block, index) => _jsx(BlockView, { block: block, label: acp.label }, index)) })] }));
331
+ : transcript.blocks.map((block, index) => _jsx(BlockView, { block: block, label: acp.label, localeRev: ui.localeRev }, index)) })] }));
331
332
  }
@@ -10,12 +10,13 @@ import { AcpSettingsSection } from "./panel.js";
10
10
  import { AcpComposerMode } from "./composer-mode.js";
11
11
  import { AcpComposerAgentTag, AcpSessionControls, AcpSessionMenu } from "./session-controls.js";
12
12
  import { AcpConversationView } from "./acp-view.js";
13
+ import { AcpSettingsDialog } from "./settings-dialog.js";
13
14
  import { createModelSeatClaim, createPermissionSeatClaim, MODEL_SEAT_KEY, PERMISSION_SEAT_KEY, } from "./model-seat.js";
14
15
  import { acpModeFromPresetEvent, rowAgentPreset, stagedIsAcpMode, stagedSessionOf, } from "./acp-ui.js";
15
16
  import { createSlashIsolation, slashAcpVerdict } from "./slash-isolation.js";
16
17
  export const name = 'dsh-ntr-client';
17
18
  export const inject = ['slots', 'connection'];
18
- const zh = typeof navigator !== 'undefined' && /^zh\b/i.test(navigator.language);
19
+ const browserPrefersZh = typeof navigator !== 'undefined' && /^zh\b/i.test(navigator.language);
19
20
  /**
20
21
  * How long a host verdict may stand as the gate's ONLY fact source before the
21
22
  * gate asks the host again.
@@ -50,8 +51,6 @@ export function apply(ctx) {
50
51
  throw new Error('dsh-ntr: browser connection.rpc is unavailable');
51
52
  }
52
53
  const call = (channel, endpoint, payload) => rpc.call(channel, endpoint, payload);
53
- const store = new AcpStore(call, (zhText, enText) => (zh ? zhText : enText));
54
- store.start();
55
54
  /**
56
55
  * Resolve one optional service without injecting it. A plain `ctx.get` at
57
56
  * call time keeps the plugin loadable when the service is absent AND picks it
@@ -59,6 +58,35 @@ export function apply(ctx) {
59
58
  * also making its absence fatal.
60
59
  */
61
60
  const service = (name) => ctx.get(name);
61
+ /**
62
+ * Resolve a Chinese/English pair against the shell's active locale. Read per
63
+ * call, so a language switch reaches every render-time `label()` call without
64
+ * rebuilding the store. A host without the locale service keeps the
65
+ * browser-derived choice the pairs shipped with.
66
+ */
67
+ const label = (zhText, enText) => {
68
+ const resolved = service('locale')?.resolveText?.({ zh: zhText, en: enText });
69
+ return typeof resolved === 'string' && resolved !== '' ? resolved : browserPrefersZh ? zhText : enText;
70
+ };
71
+ const store = new AcpStore(call, label);
72
+ store.start();
73
+ // A locale switch changes no data, only which side of each pair renders;
74
+ // republishing the snapshot is what re-runs them. Attached through
75
+ // `internal/service` so a late-arriving locale service is still hooked.
76
+ ctx.effect(() => {
77
+ let stop;
78
+ const attach = () => {
79
+ if (stop !== undefined)
80
+ return;
81
+ stop = service('locale')?.subscribe?.(() => { store.refreshLocale(); });
82
+ };
83
+ attach();
84
+ const stopService = ctx.on('internal/service', attach);
85
+ return () => {
86
+ stopService();
87
+ stop?.();
88
+ };
89
+ }, 'dsh-ntr: locale switch re-render');
62
90
  /** This session's command/projection face (`ctx.sessions`); `undefined` before it materializes. */
63
91
  const faceFor = (sessionId) => {
64
92
  try {
@@ -81,6 +109,18 @@ export function apply(ctx) {
81
109
  label: () => 'NTR',
82
110
  inject: () => ({ acp: store }),
83
111
  }, AcpSettingsSection)), 'dsh-ntr: settings section');
112
+ // The same NTR settings, opened from the composer when the agent roster is
113
+ // empty (the agent base's picker action). It rides the frame's overlay layer
114
+ // because the composer seat and the dialog live in unrelated slot trees; the
115
+ // host settings shell has no programmatic open seam, so the dialog mounts the
116
+ // same section the shell mounts (see settings-dialog.tsx). Renders nothing
117
+ // while closed.
118
+ ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({
119
+ name: 'shell.overlay',
120
+ id: 'acp-settings-dialog',
121
+ order: 30,
122
+ inject: () => ({ acp: store }),
123
+ }, AcpSettingsDialog)), 'dsh-ntr: NTR settings dialog');
84
124
  // The agent's OWN modes sit in the composer tool row — the seat the slot
85
125
  // contract reserves for exactly this ("entries sit beside that chrome, never
86
126
  // replace it"). They are a different layer from everything the host controls:
@@ -138,16 +138,18 @@ const STYLE = `
138
138
  .dwa-inline-setting { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border-radius: var(--ntr-radius-sm); background: var(--dsw-alias-bg-layer-2, #f9fafb); }
139
139
  .dwa-inline-setting .dwa-switch { flex-shrink: 0; margin-top: 1px; }
140
140
  .dwa-setting-copy { color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
141
- /* Panes stay flush (equal height) whatever the two list lengths are; the
142
- shorter list's list body grows to fill, so its leftover is scrolling room
143
- inside the list rather than a detached block under the last row. */
144
- .dwa-model-transfer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: stretch; }
145
- /* Tall enough for a comfortable selection (about ten rows) before scrolling:
146
- both panes share this ceiling, so equal heights cost at most one row of
147
- slack on the shorter side instead of a whole empty half. */
141
+ /* Stacked, not side by side: the rows carry name, id and credit, and a
142
+ half-width pane truncated them at every realistic settings width. Each list
143
+ gets the whole column instead. */
144
+ .dwa-model-transfer { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
145
+ /* Tall enough for a comfortable selection (about ten rows) before scrolling;
146
+ the cap keeps the two stacked panes from pushing the rest of the card away. */
148
147
  .dwa-model-pane { min-width: 0; max-height: 360px; display: flex; flex-direction: column; border: var(--ntr-line); border-radius: var(--ntr-radius-sm); background: var(--dsw-alias-bg-layer-1, #fff); overflow: hidden; }
149
148
  .dwa-model-pane-head { display: flex; align-items: center; gap: 6px; padding: 7px 9px; border-bottom: var(--ntr-line); font-size: var(--ntr-font-xs); font-weight: 600; color: var(--dsw-alias-text-secondary, #4b5563); }
150
149
  .dwa-model-pane-head .dwa-btn { margin-left: auto; min-height: 26px; padding: 2px 8px; font-size: var(--ntr-font-xs); }
150
+ .dwa-model-pane-head .dwa-caret-btn { flex: none; width: 22px; height: 22px; background: transparent; }
151
+ .dwa-model-pane-head .dwa-caret-btn:hover { background: var(--dsw-alias-interactive-bg-active, #e5e7eb); }
152
+ .dwa-model-pane.is-collapsed .dwa-model-list { display: none; }
151
153
  .dwa-model-list { display: flex; flex-direction: column; gap: 2px; padding: 5px; flex: 1 1 auto; min-height: 0; overflow-y: auto; }
152
154
  .dwa-model-section { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; font-size: 12px; }
153
155
  .dwa-model-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -164,7 +166,19 @@ const STYLE = `
164
166
  .dwa-model-row.is-on { background: #3b82f614; color: #2563eb; }
165
167
  .dwa-model-row-mark { width: 12px; flex: none; text-align: center; opacity: 0.75; }
166
168
  .dwa-model-row-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
167
- .dwa-model-row-value { flex: 0 1 auto; max-width: 45%; opacity: 0.55; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
169
+ /* Name and credit keep their room; the id is the only cell that collapses. */
170
+ .dwa-model-row-desc { flex: 0 0 auto; max-width: 45%; opacity: 0.75; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
171
+ /* The trailing 14px is reserved for the fade mask: while the id fits, the
172
+ gradient only covers empty padding; once it overflows, its tail dissolves
173
+ instead of ending in a hard ellipsis. armModelIdMarquee reads the same
174
+ padding to scroll the full text under the pointer. */
175
+ .dwa-model-row-value { flex: 0 1 auto; flex-shrink: 1000; box-sizing: border-box; min-width: 0; max-width: 45%; padding-right: 14px; opacity: 0.55; font-size: 10px; overflow: hidden; white-space: nowrap; mask-image: linear-gradient(to right, #000 calc(100% - 14px), transparent); -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 14px), transparent); }
176
+ .dwa-model-row-value-text { display: inline-block; white-space: nowrap; }
177
+ .dwa-model-row:hover .dwa-model-row-value-text { animation: dwa-model-id-marquee var(--dwa-id-duration, 1.4s) ease-in-out 0.2s infinite alternate; }
178
+ @keyframes dwa-model-id-marquee {
179
+ from { transform: translateX(0); }
180
+ to { transform: translateX(var(--dwa-id-shift, 0px)); }
181
+ }
168
182
  .dwa-model-row-remove { flex: none; opacity: 0.5; padding: 0 2px; }
169
183
  .dwa-model-row:hover .dwa-model-row-remove { opacity: 1; }
170
184
  .dwa-seg { display: inline-flex; border-radius: var(--ntr-radius-sm); border: 1px solid var(--dsw-alias-border-l2, #d1d5db); overflow: hidden; vertical-align: middle; }
@@ -181,7 +195,6 @@ const STYLE = `
181
195
  .dwa-page-header { flex-direction: column; }
182
196
  .dwa-page-actions { width: 100%; }
183
197
  .dwa-page-actions .dwa-btn { width: 100%; }
184
- .dwa-model-transfer { grid-template-columns: 1fr; }
185
198
  .dwa-model-toolbar-actions { width: 100%; }
186
199
  .dwa-model-search { flex: 1 1 auto; width: auto; }
187
200
  .dwa-subsection-head, .dwa-preference-row { align-items: flex-start; flex-direction: column; }
@@ -189,6 +202,7 @@ const STYLE = `
189
202
  }
190
203
  @media (prefers-reduced-motion: reduce) {
191
204
  .dwa-root *, .dwa-root *::before, .dwa-root *::after { transition: none !important; }
205
+ .dwa-model-row:hover .dwa-model-row-value-text { animation: none !important; }
192
206
  }
193
207
  `;
194
208
  let styleMounted = false;
@@ -678,6 +692,23 @@ function RowMenu({ acp, canStop, onStop, onRemove, }) {
678
692
  void onRemove();
679
693
  }, children: acp.label('确认移除?', 'Confirm remove?') }), _jsx("button", { type: "button", className: "dwa-btn", onClick: () => setConfirming(false), children: acp.label('取消', 'Cancel') })] })) : (_jsx("button", { type: "button", role: "menuitem", className: "dwa-btn dwa-menu-item dwa-menu-item-danger", onClick: () => setConfirming(true), children: acp.label('移除配置', 'Remove') }))] }))] }));
680
694
  }
695
+ /**
696
+ * Measure the id cell on row hover so its CSS marquee can reach the end of the
697
+ * text. The id is the only cell that yields width, and the panel is resizable,
698
+ * so the overflow is re-read on every entry rather than cached. The animation
699
+ * itself lives in CSS and only runs while the row is hovered; `title` on the
700
+ * row stays the fallback for reduced-motion users.
701
+ */
702
+ function armModelIdMarquee(row) {
703
+ const view = row.querySelector('.dwa-model-row-value');
704
+ const text = view?.querySelector('.dwa-model-row-value-text');
705
+ if (view === null || view === undefined || text === null || text === undefined)
706
+ return;
707
+ const fade = Number.parseFloat(getComputedStyle(view).paddingRight) || 0;
708
+ const shift = Math.max(0, text.offsetWidth - (view.clientWidth - fade));
709
+ row.style.setProperty('--dwa-id-shift', `${String(-shift)}px`);
710
+ row.style.setProperty('--dwa-id-duration', `${String(Math.min(4, Math.max(1.2, shift / 30)))}s`);
711
+ }
681
712
  /**
682
713
  * Model / thought-level catalog display plus the native-selector visibility
683
714
  * filter, as a two-pane transfer list: every known model on the left, the
@@ -718,6 +749,11 @@ function ModelCatalogBlock({ agentId, detected, acp, active, exposed, onExposedC
718
749
  const modelChoices = result?.catalog.model?.choices ?? [];
719
750
  /** Result of the last click on 「检测可用模型」 (what the refresh changed). */
720
751
  const [detectNote, setDetectNote] = useState(undefined);
752
+ // Each list folds on its own so a long selection can be parked while the
753
+ // other pane keeps the full column. The list stays mounted (display-toggled)
754
+ // to keep its scroll position.
755
+ const [allCollapsed, setAllCollapsed] = useState(false);
756
+ const [selectedCollapsed, setSelectedCollapsed] = useState(false);
721
757
  // Display read, not a probe: the host answers from its persisted catalog
722
758
  // cache, so the panes already list the known models the first time the card
723
759
  // opens. Gated on `active` because every configured agent's body stays
@@ -860,7 +896,7 @@ function ModelCatalogBlock({ agentId, detected, acp, active, exposed, onExposedC
860
896
  : acp.label('该 Agent 未暴露模型选择器(category: model)。', 'This agent exposes no model selector (category: model).');
861
897
  return (_jsxs("div", { children: [!isDefault && (_jsx("div", { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, children: _jsx("button", { className: "dwa-btn", onClick: () => void acp.setDefaultAgent(agentId), children: acp.label('设为默认智能体', 'Set as default agent') }) })), isDefault && (
862
898
  // This default belongs to the plugin, not the deployment model route.
863
- _jsx("div", { className: "dwa-hint", style: { marginTop: 8 }, children: acp.label('当前为默认智能体:新的 NTR 会话由它服务,不影响其他模式的默认模型。', "This is the default agent for new NTR sessions; other modes' default models are untouched.") })), _jsxs("div", { className: "dwa-inline-setting", style: { marginTop: 12 }, children: [_jsxs("label", { className: "dwa-switch", title: acp.label('Agent 新发布的模型会自动加入右侧已选列表;首次检测到的目录只作为基线,不会一次性全选。', 'Models the agent newly publishes are added to the selected list; the first observed catalog is only a baseline and is not selected wholesale.'), children: [_jsx("input", { type: "checkbox", checked: autoDiscover, onChange: e => void toggleAutoDiscover(e.target.checked), style: { margin: 0, accentColor: '#2563eb' } }), acp.label('自动发现新模型', 'Auto-discover new models')] }), _jsx("span", { className: "dwa-setting-copy", children: acp.label('新增模型自动加入已选列表,现有勾选不受影响。', 'New models join the selected list automatically; existing picks are untouched.') })] }), saveError !== undefined && _jsx("div", { className: "dwa-error", style: { marginTop: 4 }, children: saveError }), probeError !== undefined && (_jsxs("div", { className: "dwa-error", style: { marginTop: 8, wordBreak: 'break-word' }, children: ["\u26A0 ", acp.label('模型检测失败:', 'Model detection failed: '), probeError] })), _jsxs("div", { className: "dwa-model-section", children: [_jsxs("div", { className: "dwa-model-toolbar", children: [_jsxs("span", { className: "dwa-form-label", children: [acp.label('对话中可见的模型', 'Models offered in the conversation'), _jsx("span", { className: "dwa-hint", style: { marginLeft: 6, fontWeight: 400 }, title: acp.label('NTR 模式下的对话只列出这里勾选的模型;未勾选的仍可用——正在生效的那个也会以「· 当前」列出来,其余不列出。', 'The NTR surfaces only list what is checked here; unchecked models still work, and the one currently in force is still listed with a "· current" row — the rest are not offered.'), children: acp.label('(NTR 座位 / 芯片的下拉列表)', '(the NTR seat and chip pickers)') })] }), _jsx("span", { className: "dwa-badge dwa-badge-info", children: selectionBadge }), catalogStatus !== undefined && _jsx("span", { className: "dwa-hint dwa-model-status", children: catalogStatus }), _jsxs("div", { className: "dwa-model-toolbar-actions", children: [_jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: probing, onClick: () => void detect(), title: acp.label('重新读取 Agent 的模型目录:新增的加入左侧,已下架的从左侧移除;已选列表不受影响。', 'Re-read the agent catalog: newly published models join the left list, withdrawn ones drop out. The selection is untouched.'), children: probing ? acp.label('模型检测中...', 'Probing models...') : acp.label('检测可用模型', 'Detect available models') }), _jsx("input", { className: "dwa-input dwa-model-search", value: search, onChange: e => setSearch(e.target.value), placeholder: acp.label('搜索模型…', 'Search models…') })] })] }), _jsxs("div", { className: "dwa-model-transfer", children: [_jsxs("div", { className: "dwa-model-pane", children: [_jsxs("div", { className: "dwa-model-pane-head", children: [_jsx("span", { children: acp.label('全部模型', 'All models') }), result !== undefined && _jsx("span", { className: "dwa-badge", children: modelChoices.length }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: visibleChoices.length === 0, onClick: addVisible, title: acp.label('把当前搜索范围内的模型全部加入右侧', 'Add every model matching the current search'), children: acp.label('全部加入', 'Add all') })] }), _jsxs("div", { className: "dwa-model-list", children: [visibleChoices.map(choice => {
899
+ _jsx("div", { className: "dwa-hint", style: { marginTop: 8 }, children: acp.label('当前为默认智能体:新的 NTR 会话由它服务,不影响其他模式的默认模型。', "This is the default agent for new NTR sessions; other modes' default models are untouched.") })), _jsxs("div", { className: "dwa-inline-setting", style: { marginTop: 12 }, children: [_jsxs("label", { className: "dwa-switch", title: acp.label('Agent 新发布的模型会自动加入右侧已选列表;首次检测到的目录只作为基线,不会一次性全选。', 'Models the agent newly publishes are added to the selected list; the first observed catalog is only a baseline and is not selected wholesale.'), children: [_jsx("input", { type: "checkbox", checked: autoDiscover, onChange: e => void toggleAutoDiscover(e.target.checked), style: { margin: 0, accentColor: '#2563eb' } }), acp.label('自动发现新模型', 'Auto-discover new models')] }), _jsx("span", { className: "dwa-setting-copy", children: acp.label('新增模型自动加入已选列表,现有勾选不受影响。', 'New models join the selected list automatically; existing picks are untouched.') })] }), saveError !== undefined && _jsx("div", { className: "dwa-error", style: { marginTop: 4 }, children: saveError }), probeError !== undefined && (_jsxs("div", { className: "dwa-error", style: { marginTop: 8, wordBreak: 'break-word' }, children: ["\u26A0 ", acp.label('模型检测失败:', 'Model detection failed: '), probeError] })), _jsxs("div", { className: "dwa-model-section", children: [_jsxs("div", { className: "dwa-model-toolbar", children: [_jsxs("span", { className: "dwa-form-label", children: [acp.label('对话中可见的模型', 'Models offered in the conversation'), _jsx("span", { className: "dwa-hint", style: { marginLeft: 6, fontWeight: 400 }, title: acp.label('NTR 模式下的对话只列出这里勾选的模型;未勾选的仍可用——正在生效的那个也会以「· 当前」列出来,其余不列出。', 'The NTR surfaces only list what is checked here; unchecked models still work, and the one currently in force is still listed with a "· current" row — the rest are not offered.'), children: acp.label('(NTR 座位 / 芯片的下拉列表)', '(the NTR seat and chip pickers)') })] }), _jsx("span", { className: "dwa-badge dwa-badge-info", children: selectionBadge }), catalogStatus !== undefined && _jsx("span", { className: "dwa-hint dwa-model-status", children: catalogStatus }), _jsxs("div", { className: "dwa-model-toolbar-actions", children: [_jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: probing, onClick: () => void detect(), title: acp.label('重新读取 Agent 的模型目录:新增的加入左侧,已下架的从左侧移除;已选列表不受影响。', 'Re-read the agent catalog: newly published models join the left list, withdrawn ones drop out. The selection is untouched.'), children: probing ? acp.label('模型检测中...', 'Probing models...') : acp.label('检测可用模型', 'Detect available models') }), _jsx("input", { className: "dwa-input dwa-model-search", value: search, onChange: e => setSearch(e.target.value), placeholder: acp.label('搜索模型…', 'Search models…') })] })] }), _jsxs("div", { className: "dwa-model-transfer", children: [_jsxs("div", { className: `dwa-model-pane${allCollapsed ? ' is-collapsed' : ''}`, children: [_jsxs("div", { className: "dwa-model-pane-head", children: [_jsx("span", { children: acp.label('全部模型', 'All models') }), result !== undefined && _jsx("span", { className: "dwa-badge", children: modelChoices.length }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: visibleChoices.length === 0, onClick: addVisible, title: acp.label('把当前搜索范围内的模型全部加入右侧', 'Add every model matching the current search'), children: acp.label('全部加入', 'Add all') }), _jsx("button", { type: "button", className: "dwa-caret-btn", "aria-expanded": !allCollapsed, onClick: () => setAllCollapsed(value => !value), title: allCollapsed ? acp.label('展开全部模型', 'Expand all models') : acp.label('收起全部模型', 'Collapse all models'), children: _jsx("span", { className: "dwa-caret", "aria-hidden": true, children: allCollapsed ? '▸' : '▾' }) })] }), _jsxs("div", { className: "dwa-model-list", children: [visibleChoices.map(choice => {
864
900
  const checked = selected.has(choice.value);
865
901
  // group 前缀仅来自 agent 返回的 SessionConfigSelectGroup(见 impl.ts
866
902
  // pushCatalogChoice);供应商可见性兜底靠原样展示 value,严禁从
@@ -868,6 +904,6 @@ function ModelCatalogBlock({ agentId, detected, acp, active, exposed, onExposedC
868
904
  const title = choice.description === undefined
869
905
  ? choice.value
870
906
  : `${choice.value}\n${choice.description}`;
871
- return (_jsxs("button", { type: "button", className: `dwa-model-row${checked ? ' is-on' : ''}`, title: title, onClick: () => toggleChoice(choice.value), children: [_jsx("span", { className: "dwa-model-row-mark", children: checked ? '✓' : '+' }), _jsxs("span", { className: "dwa-model-row-name", children: [choice.group !== undefined && _jsxs("span", { style: { opacity: 0.7 }, children: [choice.group, " \u00B7 "] }), choice.name] }), choice.value !== choice.name && (_jsx("span", { className: "dwa-model-row-value", children: choice.value }))] }, choice.value));
872
- }), leftEmptyCopy !== undefined && _jsx("span", { className: "dwa-hint dwa-model-empty", children: leftEmptyCopy })] })] }), _jsxs("div", { className: "dwa-model-pane", children: [_jsxs("div", { className: "dwa-model-pane-head", children: [_jsx("span", { children: acp.label('已选模型', 'Selected models') }), _jsx("span", { className: "dwa-badge dwa-badge-info", children: basis.length }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: basis.length === 0, onClick: clearAll, children: acp.label('清空', 'Clear') })] }), _jsxs("div", { className: "dwa-model-list", children: [selectedChoices.length === 0 && (_jsx("span", { className: "dwa-hint dwa-model-empty", children: acp.label('还没有选择模型(点左侧的模型加入)', 'Nothing selected yet — pick models on the left') })), selectedChoices.map(choice => (_jsxs("button", { type: "button", className: "dwa-model-row is-on", title: choice.description === undefined ? choice.value : `${choice.value}\n${choice.description}`, onClick: () => toggleChoice(choice.value), children: [_jsxs("span", { className: "dwa-model-row-name", children: [choice.group !== undefined && _jsxs("span", { style: { opacity: 0.7 }, children: [choice.group, " \u00B7 "] }), choice.name] }), choice.value !== choice.name && (_jsx("span", { className: "dwa-model-row-value", children: choice.value })), _jsx("span", { className: "dwa-model-row-remove", "aria-hidden": "true", children: "\u00D7" })] }, choice.value)))] })] })] }), result?.catalog.thought !== undefined && (_jsxs("div", { style: { display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }, children: [_jsx("span", { className: "dwa-form-label", children: acp.label('思考等级', 'Thought Levels') }), result.catalog.thought.choices.map(choice => (_jsx("span", { className: "dwa-badge", title: choice.description, children: choice.name }, choice.value)))] }))] })] }));
907
+ return (_jsxs("button", { type: "button", className: `dwa-model-row${checked ? ' is-on' : ''}`, title: title, onClick: () => toggleChoice(choice.value), onPointerEnter: event => { armModelIdMarquee(event.currentTarget); }, children: [_jsx("span", { className: "dwa-model-row-mark", children: checked ? '✓' : '+' }), _jsxs("span", { className: "dwa-model-row-name", children: [choice.group !== undefined && _jsxs("span", { style: { opacity: 0.7 }, children: [choice.group, " \u00B7 "] }), choice.name] }), choice.value !== choice.name && (_jsx("span", { className: "dwa-model-row-value", children: _jsx("span", { className: "dwa-model-row-value-text", children: choice.value }) })), choice.description !== undefined && choice.description !== choice.group && (_jsx("span", { className: "dwa-model-row-desc", children: choice.description }))] }, choice.value));
908
+ }), leftEmptyCopy !== undefined && _jsx("span", { className: "dwa-hint dwa-model-empty", children: leftEmptyCopy })] })] }), _jsxs("div", { className: `dwa-model-pane${selectedCollapsed ? ' is-collapsed' : ''}`, children: [_jsxs("div", { className: "dwa-model-pane-head", children: [_jsx("span", { children: acp.label('已选模型', 'Selected models') }), _jsx("span", { className: "dwa-badge dwa-badge-info", children: basis.length }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: basis.length === 0, onClick: clearAll, children: acp.label('清空', 'Clear') }), _jsx("button", { type: "button", className: "dwa-caret-btn", "aria-expanded": !selectedCollapsed, onClick: () => setSelectedCollapsed(value => !value), title: selectedCollapsed ? acp.label('展开已选模型', 'Expand selected models') : acp.label('收起已选模型', 'Collapse selected models'), children: _jsx("span", { className: "dwa-caret", "aria-hidden": true, children: selectedCollapsed ? '▸' : '▾' }) })] }), _jsxs("div", { className: "dwa-model-list", children: [selectedChoices.length === 0 && (_jsx("span", { className: "dwa-hint dwa-model-empty", children: acp.label('还没有选择模型(点左侧的模型加入)', 'Nothing selected yet — pick models on the left') })), selectedChoices.map(choice => (_jsxs("button", { type: "button", className: "dwa-model-row is-on", title: choice.description === undefined ? choice.value : `${choice.value}\n${choice.description}`, onClick: () => toggleChoice(choice.value), onPointerEnter: event => { armModelIdMarquee(event.currentTarget); }, children: [_jsxs("span", { className: "dwa-model-row-name", children: [choice.group !== undefined && _jsxs("span", { style: { opacity: 0.7 }, children: [choice.group, " \u00B7 "] }), choice.name] }), choice.value !== choice.name && (_jsx("span", { className: "dwa-model-row-value", children: _jsx("span", { className: "dwa-model-row-value-text", children: choice.value }) })), choice.description !== undefined && choice.description !== choice.group && (_jsx("span", { className: "dwa-model-row-desc", children: choice.description })), _jsx("span", { className: "dwa-model-row-remove", "aria-hidden": "true", children: "\u00D7" })] }, choice.value)))] })] })] }), result?.catalog.thought !== undefined && (_jsxs("div", { style: { display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }, children: [_jsx("span", { className: "dwa-form-label", children: acp.label('思考等级', 'Thought Levels') }), result.catalog.thought.choices.map(choice => (_jsx("span", { className: "dwa-badge", title: choice.description, children: choice.name }, choice.value)))] }))] })] }));
873
909
  }
@@ -50,13 +50,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
50
50
  * different slots, so they share that module's phase clock rather than trying
51
51
  * to animate across slot boundaries.
52
52
  *
53
- * It is deliberately quiet: the base renders NOTHING for a conversation no ACP
54
- * agent has served, so an ordinary chat is untouched even before the gate
55
- * stands down. The ACP view tab is the entry point for those (and for re-entry
56
- * after a detour through another provider).
53
+ * It is deliberately quiet: outside ACP mode the gate never mounts it, and
54
+ * before the host has answered the agent roster it renders nothing. Once the
55
+ * roster is KNOWN to be empty the base stays and its picker carries the
56
+ * "open NTR settings" action — an NTR conversation with nothing configured
57
+ * still has a way forward instead of an empty cell. The ACP view tab remains
58
+ * the entry point for conversations no agent has served.
57
59
  */
58
60
  import { useEffect, useState } from 'react';
59
- import { AcpChip, AcpMenu, AcpShatterParts, buildAcpModelId, closeAcpMenu, ensureAcpStyle, openAcpMenu, setAcpMenuError, splitAcpModelId, useAcpMenu, useUi, } from "./acp-ui.js";
61
+ import { AcpChip, AcpMenu, AcpShatterParts, buildAcpModelId, closeAcpMenu, ensureAcpStyle, openAcpMenu, openAcpSettingsDialog, setAcpMenuError, splitAcpModelId, useAcpMenu, useUi, } from "./acp-ui.js";
60
62
  import { playAgentLockAnim, useAgentLockPhase } from "./agent-lock-anim.js";
61
63
  const STATE_COLORS = {
62
64
  ready: '#22c55e',
@@ -105,6 +107,18 @@ function routeAgentId(boundAgentId, bound, agents, defaultAgent) {
105
107
  return defaultAgent;
106
108
  return undefined;
107
109
  }
110
+ /**
111
+ * Whether the agent base has anything honest to show.
112
+ *
113
+ * An empty roster has two meanings, and they must not be conflated: before the
114
+ * host answers `agents/list` it means "not read yet" (rendering a setup entry
115
+ * there would accuse a correctly configured deployment on every page load),
116
+ * while after the answer it means "nothing is configured" — exactly the state
117
+ * that must keep the base and offer the way into NTR settings.
118
+ */
119
+ export function agentBaseHasData(bound, agents, rosterLoaded) {
120
+ return bound !== undefined || agents.length > 0 || rosterLoaded;
121
+ }
108
122
  /**
109
123
  * The agent this conversation is LOCKED to, if its first ACP turn has run.
110
124
  *
@@ -294,14 +308,18 @@ export function advancedOptions(catalog, choice) {
294
308
  * The tooltip claims only what the menu behind the chip does: switch the agent.
295
309
  * The agent's models and thought levels have chips of their own (user decision,
296
310
  * 2026-10-17), so the previous wording — "switch the agent, its model and the
297
- * thought level" — promised a menu that holds the roster alone.
311
+ * thought level" — promised a menu that holds the roster alone. An EMPTY
312
+ * roster is its own case: the menu cannot switch to anything, it offers the
313
+ * way into NTR settings, so the tooltip says that instead of promising a pick.
298
314
  */
299
- export function agentBaseChip(route, routed, acp) {
315
+ export function agentBaseChip(route, routed, acp, rosterEmpty = false) {
300
316
  const label = route.agentTitle === undefined
301
317
  ? (routed ? 'NTR' : acp.label('选择 Agent', 'Choose agent'))
302
318
  : `NTR · ${route.agentTitle}`;
303
319
  const title = route.agentId === undefined
304
- ? acp.label('这条会话当前没有 NTR agent 服务;点开可选择 agent', 'No NTR agent is serving this conversation right now — open to choose one')
320
+ ? rosterEmpty
321
+ ? acp.label('这条会话当前没有 NTR agent;点开可前往 NTR 设置添加', 'No NTR agent is serving this conversation — open to set one up in NTR settings')
322
+ : acp.label('这条会话当前没有 NTR agent 服务;点开可选择 agent', 'No NTR agent is serving this conversation right now — open to choose one')
305
323
  : acp.label('这条会话由外接 NTR agent 服务;点开可切换 agent', 'This conversation is served by an external NTR agent — open to switch the agent');
306
324
  return { label, title };
307
325
  }
@@ -424,9 +442,12 @@ export function AcpSessionControls(props) {
424
442
  const route = describeRoute(agents, bound,
425
443
  // A locked conversation has no default fallback: the lock is the answer.
426
444
  lockedTo === undefined ? ui.defaultAgent : undefined, catalog, lockedTo ?? ui.chatBoundAgent[sessionId], choice);
427
- // A conversation with no ACP story to tell gets no chrome: the ACP view tab
428
- // owns the entry point for starting one.
429
- if (bound === undefined && agents.length === 0)
445
+ // An empty roster is the SETUP case, not "no story": the base stays and its
446
+ // picker carries the way into NTR settings. Only the window before the host
447
+ // has answered `agents/list` stays blank — `agents: []` there means "not read
448
+ // yet", and the setup entry would accuse a configured deployment (see
449
+ // `agentBaseHasData`).
450
+ if (!agentBaseHasData(bound, agents, ui.agentsLoaded))
430
451
  return null;
431
452
  // Set up = an agent serves this conversation: a live session, a persisted
432
453
  // pick, the host's optimistic binding, OR the plugin default the guard's
@@ -471,7 +492,7 @@ export function AcpSessionControls(props) {
471
492
  // agent's extra options never push the primary axes around. Once the first
472
493
  // turn locks the conversation the base retires (2026-10-20) and the
473
494
  // model / thought pair inherits the right edge.
474
- return (_jsxs("span", { className: "ntr-acp-cluster", children: [showAdvanced && (_jsx(AcpChip, { label: `${acp.label('高级', 'Advanced')} · ${String(extras.length)}`, title: acp.label(`agent 的额外配置项:${extras.map(option => option.name).join('、')}`, `extra agent config options: ${extras.map(option => option.name).join(', ')}`), active: menu?.sessionId === sessionId && menu.kind === 'advanced', onClick: (trigger) => { open('advanced', trigger); } })), showModel && modelLabel !== undefined && (_jsx(AcpChip, { label: modelLabel, title: acp.label('agent 自己的模型目录', "the agent's own model directory"), active: menu?.sessionId === sessionId && menu.kind === 'model', onClick: (trigger) => { open('model', trigger); } })), showThought && (_jsx(AcpChip, { label: `${acp.label('思考', 'think')} · ${route.effortLabel ?? acp.label('默认', 'default')}`, title: acp.label('思考等级(agent 的 thought_level)', "Thought level (the agent's thought_level)"), active: menu?.sessionId === sessionId && menu.kind === 'thought', onClick: (trigger) => { open('thought', trigger); } })), showAgentBase && (_jsx(AcpChip, { ...agentBaseChip(route, routed, acp), active: routed, className: shattering ? 'ntr-acp-shatter' : undefined, onClick: shattering ? undefined : (trigger) => { open('agent', trigger); }, children: shattering && _jsx(AcpShatterParts, { mode: "out" }) }))] }));
495
+ return (_jsxs("span", { className: "ntr-acp-cluster", children: [showAdvanced && (_jsx(AcpChip, { label: `${acp.label('高级', 'Advanced')} · ${String(extras.length)}`, title: acp.label(`agent 的额外配置项:${extras.map(option => option.name).join('、')}`, `extra agent config options: ${extras.map(option => option.name).join(', ')}`), active: menu?.sessionId === sessionId && menu.kind === 'advanced', onClick: (trigger) => { open('advanced', trigger); } })), showModel && modelLabel !== undefined && (_jsx(AcpChip, { label: modelLabel, title: acp.label('agent 自己的模型目录', "the agent's own model directory"), active: menu?.sessionId === sessionId && menu.kind === 'model', onClick: (trigger) => { open('model', trigger); } })), showThought && (_jsx(AcpChip, { label: `${acp.label('思考', 'think')} · ${route.effortLabel ?? acp.label('默认', 'default')}`, title: acp.label('思考等级(agent 的 thought_level)', "Thought level (the agent's thought_level)"), active: menu?.sessionId === sessionId && menu.kind === 'thought', onClick: (trigger) => { open('thought', trigger); } })), showAgentBase && (_jsx(AcpChip, { ...agentBaseChip(route, routed, acp, agents.length === 0), active: routed, className: shattering ? 'ntr-acp-shatter' : undefined, onClick: shattering ? undefined : (trigger) => { open('agent', trigger); }, children: shattering && _jsx(AcpShatterParts, { mode: "out" }) }))] }));
475
496
  }
476
497
  /**
477
498
  * The model picker's explicit refresh row. It is an ACTION, not a model: the
@@ -481,6 +502,13 @@ export function AcpSessionControls(props) {
481
502
  * `agentId@value`.
482
503
  */
483
504
  export const REFRESH_CATALOG_ITEM = '__refresh-catalog__';
505
+ /**
506
+ * The empty roster's setup action. Like the model picker's refresh row it is an
507
+ * ACTION inside the picker, not an agent: agent ids match
508
+ * `[a-z0-9][a-z0-9._-]{0,63}` (no leading underscore), so no real id can
509
+ * collide with it.
510
+ */
511
+ export const OPEN_SETTINGS_ITEM = '__open-settings__';
484
512
  /** The agent roster rows: one row per configured ACP agent, current one marked. */
485
513
  function agentSection(context) {
486
514
  const { acp, route } = context;
@@ -492,12 +520,21 @@ function agentSection(context) {
492
520
  active: route.agentId === agent.id,
493
521
  }));
494
522
  if (items.length === 0) {
523
+ // The base stays on an empty roster (see `agentBaseHasData`), so this menu
524
+ // is the setup surface: a status row that says what is missing, then an
525
+ // ACTION row that opens the NTR settings. The action is a row of its own
526
+ // because a disabled status row cannot be the way forward.
495
527
  items.push({
496
528
  id: 'none',
497
529
  label: acp.label('还没有配置 NTR agent', 'No NTR agent configured yet'),
498
- detail: acp.label('设置 → NTR', 'Settings → NTR'),
530
+ detail: acp.label('添加并启用一个 agent 后即可开始对话', 'Add and enable an agent to start'),
499
531
  disabled: true,
500
532
  });
533
+ items.push({
534
+ id: OPEN_SETTINGS_ITEM,
535
+ label: acp.label('打开 NTR 设置', 'Open NTR settings'),
536
+ detail: acp.label('添加或启用 agent', 'Add or enable an agent'),
537
+ });
501
538
  }
502
539
  return {
503
540
  id: 'agent',
@@ -787,6 +824,15 @@ export function AcpSessionMenu(props) {
787
824
  await acp.probeModels(agentId);
788
825
  return;
789
826
  }
827
+ if (sectionId === 'agent' && itemId === OPEN_SETTINGS_ITEM) {
828
+ // The empty roster's way out (see `agentSection`): there is no agent to
829
+ // bind, so this row opens the NTR settings surface instead. Close the
830
+ // picker first — the dialog is a frame overlay and the menu must not
831
+ // linger behind it.
832
+ closeAcpMenu();
833
+ openAcpSettingsDialog();
834
+ return;
835
+ }
790
836
  if (sectionId === 'agent') {
791
837
  await acp.bindChatAgent(sessionId, itemId);
792
838
  }
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The NTR settings dialog, opened from the agent base's empty-roster menu.
4
+ *
5
+ * WHY A DIALOG OF OUR OWN: the host settings shell owns its open state and
6
+ * exposes no programmatic way in. The sidebar seat that receives `openSettings`
7
+ * (`settings.launcher`) is a single-occupant slot already held by the account
8
+ * menu, and `openSection` is only handed to `settings.onboarding` registrants —
9
+ * so a conversation surface cannot drive the shell's modal. This dialog mounts
10
+ * the SAME `AcpSettingsSection` the shell mounts, on the frame's overlay layer
11
+ * (`shell.overlay`): one settings surface, two entrances, no duplicated
12
+ * configuration logic.
13
+ *
14
+ * The trigger (composer model seat) and this dialog (root overlay) live in
15
+ * unrelated slot trees, so the open state is the module-level store in
16
+ * `acp-ui.tsx` (`openAcpSettingsDialog`).
17
+ */
18
+ import { useEffect } from 'react';
19
+ import { AcpSettingsSection } from "./panel.js";
20
+ import { closeAcpSettingsDialog, ensureAcpStyle, useAcpSettingsDialogOpen, } from "./acp-ui.js";
21
+ /** Frame-wide NTR settings surface; renders nothing while closed. */
22
+ export function AcpSettingsDialog(props) {
23
+ ensureAcpStyle();
24
+ const { acp } = props;
25
+ const open = useAcpSettingsDialogOpen();
26
+ useEffect(() => {
27
+ if (!open)
28
+ return;
29
+ const onKey = (event) => {
30
+ if (event.key !== 'Escape')
31
+ return;
32
+ event.preventDefault();
33
+ event.stopPropagation();
34
+ closeAcpSettingsDialog();
35
+ };
36
+ document.addEventListener('keydown', onKey, true);
37
+ return () => { document.removeEventListener('keydown', onKey, true); };
38
+ }, [open]);
39
+ if (!open)
40
+ return null;
41
+ const title = acp.label('NTR 设置', 'NTR settings');
42
+ return (_jsxs("div", { className: "ntr-acp-dialog-layer", children: [_jsx("div", { className: "ntr-acp-dialog-mask", "aria-hidden": true, onClick: closeAcpSettingsDialog }), _jsxs("div", { className: "ntr-acp-dialog", role: "dialog", "aria-modal": "true", "aria-label": title, children: [_jsxs("div", { className: "ntr-acp-dialog-head", children: [_jsx("span", { className: "ntr-acp-dialog-title", children: title }), _jsx("button", { type: "button", className: "ntr-acp-dialog-close", title: acp.label('关闭', 'Close'), "aria-label": acp.label('关闭 NTR 设置', 'Close NTR settings'), autoFocus: true, onClick: closeAcpSettingsDialog, children: "\u2715" })] }), _jsx("div", { className: "ntr-acp-dialog-body", children: _jsx(AcpSettingsSection, { acp: acp }) })] })] }));
43
+ }
@@ -284,8 +284,10 @@ export class AcpStore {
284
284
  locale;
285
285
  state = {
286
286
  open: false,
287
+ localeRev: 0,
287
288
  stream: 'init',
288
289
  agents: [],
290
+ agentsLoaded: false,
289
291
  sessions: [],
290
292
  blocks: [],
291
293
  permissions: [],
@@ -357,6 +359,8 @@ export class AcpStore {
357
359
  seatCatalogReads = new Set();
358
360
  /** In-flight `agent/cached-models` reads, keyed by agent id. */
359
361
  cachedModelReads = new Set();
362
+ /** Counts behind the last successful explicit re-detect, so a locale switch can re-render its summary. */
363
+ detectCounts;
360
364
  constructor(rpc, locale) {
361
365
  this.rpc = rpc;
362
366
  this.locale = locale;
@@ -514,6 +518,7 @@ export class AcpStore {
514
518
  // Absent on an older host: keep the settings-panel card (degraded path).
515
519
  this.mutate({
516
520
  agents,
521
+ agentsLoaded: true,
517
522
  sessions,
518
523
  defaultAgent: defaultAgentInfo?.agentId,
519
524
  detectedAgents,
@@ -535,10 +540,11 @@ export class AcpStore {
535
540
  const result = await this.call('agents/detect');
536
541
  const agents = result.agents.length > 0 ? result.agents : [...DEFAULT_KNOWN_PRESETS];
537
542
  const installed = agents.filter(agent => agent.detected).length;
543
+ this.detectCounts = { total: agents.length, installed };
538
544
  this.mutate({
539
545
  detectedAgents: agents,
540
546
  detecting: false,
541
- detectSummary: this.locale(`已检测 ${String(agents.length)} 个预设:${String(installed)} 个已安装,其余可从「添加智能体」启用。`, `Detected ${String(agents.length)} presets: ${String(installed)} installed, enable the rest via "Add agent".`),
547
+ detectSummary: this.detectSummaryOf(agents.length, installed),
542
548
  });
543
549
  }
544
550
  catch (error) {
@@ -546,6 +552,7 @@ export class AcpStore {
546
552
  const notice = msg.includes('unknown endpoint agents/detect')
547
553
  ? this.locale('宿主服务运行着旧版本代码,请在终端重启 dsh web 服务以激活主机检测功能。', 'Host running older version. Please restart dsh web to enable agent detection.')
548
554
  : msg;
555
+ this.detectCounts = undefined;
549
556
  this.mutate({
550
557
  detecting: false,
551
558
  notice,
@@ -555,6 +562,9 @@ export class AcpStore {
555
562
  });
556
563
  }
557
564
  }
565
+ detectSummaryOf(total, installed) {
566
+ return this.locale(`已检测 ${String(total)} 个预设:${String(installed)} 个已安装,其余可从「添加智能体」启用。`, `Detected ${String(total)} presets: ${String(installed)} installed, enable the rest via "Add agent".`);
567
+ }
558
568
  async saveAgent(payload) {
559
569
  try {
560
570
  await this.call('agent/save', payload);
@@ -1366,6 +1376,19 @@ export class AcpStore {
1366
1376
  * renders or every row re-renders on every store notification.
1367
1377
  */
1368
1378
  label = (zh, en) => this.locale(zh, en);
1379
+ /**
1380
+ * Republish the snapshot after a locale switch. Every `label()` resolves
1381
+ * against the current language while rendering, so a notification is all
1382
+ * that is needed — except the detect summary, which the store owns as an
1383
+ * already-resolved string and rebuilds from its counts here.
1384
+ */
1385
+ refreshLocale() {
1386
+ const counts = this.detectCounts;
1387
+ this.mutate({
1388
+ localeRev: this.state.localeRev + 1,
1389
+ ...(counts === undefined ? {} : { detectSummary: this.detectSummaryOf(counts.total, counts.installed) }),
1390
+ });
1391
+ }
1369
1392
  clearNotice() {
1370
1393
  this.mutate({ notice: undefined });
1371
1394
  }