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.
- package/lib/client/acp-ui.js +61 -0
- package/lib/client/acp-view.js +5 -4
- package/lib/client/index.js +43 -3
- package/lib/client/panel.js +48 -12
- package/lib/client/session-controls.js +59 -13
- package/lib/client/settings-dialog.js +43 -0
- package/lib/client/store.js +24 -1
- package/lib/client.js +338 -36
- package/lib/client.js.map +1 -1
- package/lib/types/client/acp-ui.d.ts +8 -0
- package/lib/types/client/session-controls.d.ts +27 -6
- package/lib/types/client/settings-dialog.d.ts +23 -0
- package/lib/types/client/store.d.ts +22 -0
- package/locale/en.json +5 -0
- package/locale/zh.json +5 -0
- package/package.json +5 -3
- package/src/client/acp-ui.tsx +68 -0
- package/src/client/acp-view.tsx +6 -5
- package/src/client/index.tsx +58 -3
- package/src/client/panel.tsx +84 -13
- package/src/client/session-controls.tsx +71 -15
- package/src/client/settings-dialog.tsx +73 -0
- package/src/client/store.ts +41 -4
|
@@ -281,6 +281,14 @@ export declare function closeAcpMenu(): void;
|
|
|
281
281
|
export declare function setAcpMenuError(error?: string): void;
|
|
282
282
|
/** Subscribe to the open-picker state (both the chips and the overlay read it). */
|
|
283
283
|
export declare function useAcpMenu(): MenuState | null;
|
|
284
|
+
/** Open the NTR settings dialog (the empty agent roster's way forward). */
|
|
285
|
+
export declare function openAcpSettingsDialog(): void;
|
|
286
|
+
/** Close the NTR settings dialog (close button, mask click, Escape). */
|
|
287
|
+
export declare function closeAcpSettingsDialog(): void;
|
|
288
|
+
/** Current dialog state, for tests and non-React readers. */
|
|
289
|
+
export declare function acpSettingsDialogIsOpen(): boolean;
|
|
290
|
+
/** Subscribe to the dialog state (the root overlay entry reads it). */
|
|
291
|
+
export declare function useAcpSettingsDialogOpen(): boolean;
|
|
284
292
|
/** One selectable row of a picker. */
|
|
285
293
|
export interface AcpMenuItem {
|
|
286
294
|
id: string;
|
|
@@ -49,10 +49,12 @@
|
|
|
49
49
|
* different slots, so they share that module's phase clock rather than trying
|
|
50
50
|
* to animate across slot boundaries.
|
|
51
51
|
*
|
|
52
|
-
* It is deliberately quiet:
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
52
|
+
* It is deliberately quiet: outside ACP mode the gate never mounts it, and
|
|
53
|
+
* before the host has answered the agent roster it renders nothing. Once the
|
|
54
|
+
* roster is KNOWN to be empty the base stays and its picker carries the
|
|
55
|
+
* "open NTR settings" action — an NTR conversation with nothing configured
|
|
56
|
+
* still has a way forward instead of an empty cell. The ACP view tab remains
|
|
57
|
+
* the entry point for conversations no agent has served.
|
|
56
58
|
*/
|
|
57
59
|
import type { CSSProperties } from 'react';
|
|
58
60
|
import type { AcpChatInjected, AcpMenuKind, AcpMenuSection, AcpSessionHook } from './acp-ui.tsx';
|
|
@@ -88,6 +90,16 @@ interface RouteInfo {
|
|
|
88
90
|
effectiveEffort?: string;
|
|
89
91
|
effortLabel?: string;
|
|
90
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether the agent base has anything honest to show.
|
|
95
|
+
*
|
|
96
|
+
* An empty roster has two meanings, and they must not be conflated: before the
|
|
97
|
+
* host answers `agents/list` it means "not read yet" (rendering a setup entry
|
|
98
|
+
* there would accuse a correctly configured deployment on every page load),
|
|
99
|
+
* while after the answer it means "nothing is configured" — exactly the state
|
|
100
|
+
* that must keep the base and offer the way into NTR settings.
|
|
101
|
+
*/
|
|
102
|
+
export declare function agentBaseHasData(bound: ChatSessionView | undefined, agents: readonly AgentView[], rosterLoaded: boolean): boolean;
|
|
91
103
|
/**
|
|
92
104
|
* The agent this conversation is LOCKED to, if its first ACP turn has run.
|
|
93
105
|
*
|
|
@@ -202,9 +214,11 @@ export declare function advancedOptions(catalog: AgentCatalogResult | undefined,
|
|
|
202
214
|
* The tooltip claims only what the menu behind the chip does: switch the agent.
|
|
203
215
|
* The agent's models and thought levels have chips of their own (user decision,
|
|
204
216
|
* 2026-10-17), so the previous wording — "switch the agent, its model and the
|
|
205
|
-
* thought level" — promised a menu that holds the roster alone.
|
|
217
|
+
* thought level" — promised a menu that holds the roster alone. An EMPTY
|
|
218
|
+
* roster is its own case: the menu cannot switch to anything, it offers the
|
|
219
|
+
* way into NTR settings, so the tooltip says that instead of promising a pick.
|
|
206
220
|
*/
|
|
207
|
-
export declare function agentBaseChip(route: RouteInfo, routed: boolean, acp: AcpChatInjected['acp']): {
|
|
221
|
+
export declare function agentBaseChip(route: RouteInfo, routed: boolean, acp: AcpChatInjected['acp'], rosterEmpty?: boolean): {
|
|
208
222
|
label: string;
|
|
209
223
|
title: string;
|
|
210
224
|
};
|
|
@@ -269,6 +283,13 @@ export interface MenuContext {
|
|
|
269
283
|
* `agentId@value`.
|
|
270
284
|
*/
|
|
271
285
|
export declare const REFRESH_CATALOG_ITEM = "__refresh-catalog__";
|
|
286
|
+
/**
|
|
287
|
+
* The empty roster's setup action. Like the model picker's refresh row it is an
|
|
288
|
+
* ACTION inside the picker, not an agent: agent ids match
|
|
289
|
+
* `[a-z0-9][a-z0-9._-]{0,63}` (no leading underscore), so no real id can
|
|
290
|
+
* collide with it.
|
|
291
|
+
*/
|
|
292
|
+
export declare const OPEN_SETTINGS_ITEM = "__open-settings__";
|
|
272
293
|
/**
|
|
273
294
|
* REMOVED on purpose (2026-10-17): the agent's own `session/set_mode` list used
|
|
274
295
|
* to ride inside the agent base's menu. It has its own control
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The NTR settings dialog, opened from the agent base's empty-roster menu.
|
|
3
|
+
*
|
|
4
|
+
* WHY A DIALOG OF OUR OWN: the host settings shell owns its open state and
|
|
5
|
+
* exposes no programmatic way in. The sidebar seat that receives `openSettings`
|
|
6
|
+
* (`settings.launcher`) is a single-occupant slot already held by the account
|
|
7
|
+
* menu, and `openSection` is only handed to `settings.onboarding` registrants —
|
|
8
|
+
* so a conversation surface cannot drive the shell's modal. This dialog mounts
|
|
9
|
+
* the SAME `AcpSettingsSection` the shell mounts, on the frame's overlay layer
|
|
10
|
+
* (`shell.overlay`): one settings surface, two entrances, no duplicated
|
|
11
|
+
* configuration logic.
|
|
12
|
+
*
|
|
13
|
+
* The trigger (composer model seat) and this dialog (root overlay) live in
|
|
14
|
+
* unrelated slot trees, so the open state is the module-level store in
|
|
15
|
+
* `acp-ui.tsx` (`openAcpSettingsDialog`).
|
|
16
|
+
*/
|
|
17
|
+
import type { AcpStore } from './store.ts';
|
|
18
|
+
/** The root overlay entry's inject face. */
|
|
19
|
+
export interface AcpSettingsDialogProps {
|
|
20
|
+
acp: AcpStore;
|
|
21
|
+
}
|
|
22
|
+
/** Frame-wide NTR settings surface; renders nothing while closed. */
|
|
23
|
+
export declare function AcpSettingsDialog(props: AcpSettingsDialogProps): import("react").JSX.Element | null;
|
|
@@ -110,8 +110,20 @@ export interface ChatTranscript {
|
|
|
110
110
|
}
|
|
111
111
|
export interface UiState {
|
|
112
112
|
open: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Bumped by {@link AcpStore.refreshLocale}. Labels resolve while rendering,
|
|
115
|
+
* but memoized transcript rows compare props — this counter is what tells
|
|
116
|
+
* them the language switched.
|
|
117
|
+
*/
|
|
118
|
+
localeRev: number;
|
|
113
119
|
stream: 'init' | 'live' | 'down';
|
|
114
120
|
agents: AgentView[];
|
|
121
|
+
/**
|
|
122
|
+
* Whether the host has answered `agents/list` at least once in this page.
|
|
123
|
+
* `agents: []` alone cannot tell "no agent configured" from "not read yet";
|
|
124
|
+
* a surface that offers a setup entry on an empty roster must wait for this.
|
|
125
|
+
*/
|
|
126
|
+
agentsLoaded: boolean;
|
|
115
127
|
sessions: SessionView[];
|
|
116
128
|
activeSessionId?: string;
|
|
117
129
|
blocks: Block[];
|
|
@@ -332,6 +344,8 @@ export declare class AcpStore {
|
|
|
332
344
|
private readonly seatCatalogReads;
|
|
333
345
|
/** In-flight `agent/cached-models` reads, keyed by agent id. */
|
|
334
346
|
private readonly cachedModelReads;
|
|
347
|
+
/** Counts behind the last successful explicit re-detect, so a locale switch can re-render its summary. */
|
|
348
|
+
private detectCounts;
|
|
335
349
|
constructor(rpc: RpcCall, locale: (zh: string, en: string) => string);
|
|
336
350
|
subscribe(listener: Listener): () => void;
|
|
337
351
|
getSnapshot(): UiState;
|
|
@@ -356,6 +370,7 @@ export declare class AcpStore {
|
|
|
356
370
|
/** Snapshot refresh used on panel open and after a stream outage. */
|
|
357
371
|
resync(): Promise<void>;
|
|
358
372
|
detectAgents(): Promise<void>;
|
|
373
|
+
private detectSummaryOf;
|
|
359
374
|
saveAgent(payload: AgentSavePayload): Promise<void>;
|
|
360
375
|
/**
|
|
361
376
|
* Dry-run the connection form: the host spawns a throwaway process from
|
|
@@ -580,6 +595,13 @@ export declare class AcpStore {
|
|
|
580
595
|
* renders or every row re-renders on every store notification.
|
|
581
596
|
*/
|
|
582
597
|
label: (zh: string, en: string) => string;
|
|
598
|
+
/**
|
|
599
|
+
* Republish the snapshot after a locale switch. Every `label()` resolves
|
|
600
|
+
* against the current language while rendering, so a notification is all
|
|
601
|
+
* that is needed — except the detect summary, which the store owns as an
|
|
602
|
+
* already-resolved string and rebuilds from its counts here.
|
|
603
|
+
*/
|
|
604
|
+
refreshLocale(): void;
|
|
583
605
|
clearNotice(): void;
|
|
584
606
|
private flashError;
|
|
585
607
|
private applyEvent;
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ntr",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent and stream its session.",
|
|
5
5
|
"repository": "https://github.com/GDWhisper/dsh-ntr.git",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
},
|
|
18
18
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
19
19
|
"./package.json": "./package.json",
|
|
20
|
-
"./acp-guard": "./presets/acp/acp-guard.mjs"
|
|
20
|
+
"./acp-guard": "./presets/acp/acp-guard.mjs",
|
|
21
|
+
"./locale/*.json": "./locale/*.json"
|
|
21
22
|
},
|
|
22
23
|
"files": [
|
|
23
24
|
"lib",
|
|
24
25
|
"src",
|
|
25
26
|
"presets",
|
|
27
|
+
"locale",
|
|
26
28
|
"cordis.patch.yml"
|
|
27
29
|
],
|
|
28
30
|
"scripts": {
|
package/src/client/acp-ui.tsx
CHANGED
|
@@ -434,6 +434,54 @@ export function useAcpMenu(): MenuState | null {
|
|
|
434
434
|
)
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
// ---- The NTR settings dialog ----
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Open state of the frame-wide NTR settings dialog (`settings-dialog.tsx`).
|
|
441
|
+
*
|
|
442
|
+
* Module-level because the two ends live in unrelated slot trees: the trigger
|
|
443
|
+
* is the composer's agent base (a session-scoped model-seat entry) and the
|
|
444
|
+
* dialog itself rides the root `shell.overlay` layer. A React context cannot
|
|
445
|
+
* span that boundary, so this is the same tiny external-store seam the menu
|
|
446
|
+
* uses above.
|
|
447
|
+
*/
|
|
448
|
+
let settingsDialogOpen = false
|
|
449
|
+
const settingsDialogListeners = new Set<() => void>()
|
|
450
|
+
|
|
451
|
+
function emitSettingsDialog(): void {
|
|
452
|
+
for (const listener of [...settingsDialogListeners]) listener()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Open the NTR settings dialog (the empty agent roster's way forward). */
|
|
456
|
+
export function openAcpSettingsDialog(): void {
|
|
457
|
+
if (settingsDialogOpen) return
|
|
458
|
+
settingsDialogOpen = true
|
|
459
|
+
emitSettingsDialog()
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Close the NTR settings dialog (close button, mask click, Escape). */
|
|
463
|
+
export function closeAcpSettingsDialog(): void {
|
|
464
|
+
if (!settingsDialogOpen) return
|
|
465
|
+
settingsDialogOpen = false
|
|
466
|
+
emitSettingsDialog()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Current dialog state, for tests and non-React readers. */
|
|
470
|
+
export function acpSettingsDialogIsOpen(): boolean {
|
|
471
|
+
return settingsDialogOpen
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Subscribe to the dialog state (the root overlay entry reads it). */
|
|
475
|
+
export function useAcpSettingsDialogOpen(): boolean {
|
|
476
|
+
return useSyncExternalStore(
|
|
477
|
+
cb => {
|
|
478
|
+
settingsDialogListeners.add(cb)
|
|
479
|
+
return () => { settingsDialogListeners.delete(cb) }
|
|
480
|
+
},
|
|
481
|
+
acpSettingsDialogIsOpen,
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
|
|
437
485
|
// ---- Menu presentation ----
|
|
438
486
|
|
|
439
487
|
/** One selectable row of a picker. */
|
|
@@ -883,6 +931,26 @@ const STYLE = `
|
|
|
883
931
|
.ntr-acp-empty-hint { color: var(--dsw-alias-text-secondary, #6b7280); font-size: 12px; }
|
|
884
932
|
.ntr-acp-seat-diag { font-family: var(--dsw-alias-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); opacity: 0.72; word-break: break-word; }
|
|
885
933
|
.ntr-acp-empty-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
|
934
|
+
/* ---- NTR settings dialog (shell.overlay) ---- */
|
|
935
|
+
/* Frame-wide surface opened from the agent base's empty-roster menu. The
|
|
936
|
+
overlay layer itself is click-through, so the layer opts back in and blocks
|
|
937
|
+
the app while it is up. */
|
|
938
|
+
.ntr-acp-dialog-layer { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; pointer-events: auto; }
|
|
939
|
+
.ntr-acp-dialog-mask { position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.4)); }
|
|
940
|
+
.ntr-acp-dialog {
|
|
941
|
+
position: relative; display: flex; flex-direction: column; overflow: hidden;
|
|
942
|
+
width: min(920px, calc(100vw - 48px)); height: min(820px, calc(100vh - 48px));
|
|
943
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: inherit;
|
|
944
|
+
border-radius: 24px; box-shadow: var(--dsw-elevation-prominent, 0 24px 60px rgba(0, 0, 0, 0.25));
|
|
945
|
+
}
|
|
946
|
+
.ntr-acp-dialog-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 16px 16px 8px 24px; }
|
|
947
|
+
.ntr-acp-dialog-title { flex: 1; font-size: 16px; font-weight: 600; }
|
|
948
|
+
.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; }
|
|
949
|
+
.ntr-acp-dialog-close:hover { background: var(--dsw-alias-interactive-bg-active, #0000000d); }
|
|
950
|
+
.ntr-acp-dialog-body { flex: 1; min-height: 0; overflow: auto; }
|
|
951
|
+
/* The section brings its own page scroll for the settings shell's content
|
|
952
|
+
column; the dialog body is the scroll owner here. */
|
|
953
|
+
.ntr-acp-dialog-body > .dwa-root { max-width: none; overflow: visible; }
|
|
886
954
|
`
|
|
887
955
|
|
|
888
956
|
let styleMounted = false
|
package/src/client/acp-view.tsx
CHANGED
|
@@ -301,13 +301,14 @@ function PlanCard({ entries, t }: { entries: Extract<Block, { kind: 'plan' }>['e
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
/**
|
|
304
|
-
* One stream block, dispatched by kind. Memoized on its props — `block
|
|
305
|
-
*
|
|
306
|
-
*
|
|
304
|
+
* One stream block, dispatched by kind. Memoized on its props — `block`, the
|
|
305
|
+
* store's instance-bound `label`, and `localeRev` (it moves on a language
|
|
306
|
+
* switch, so translated copy re-renders) — so a folded batch re-renders only
|
|
307
|
+
* the rows whose block object changed (`foldUpdate` replaces blocks immutably)
|
|
307
308
|
* instead of the whole transcript on every store notification. `key` stays the
|
|
308
309
|
* index on purpose: blocks fold append-only, and identity is the block object.
|
|
309
310
|
*/
|
|
310
|
-
const BlockView = memo(function BlockView({ block, label }: { block: Block; label: Translate }) {
|
|
311
|
+
const BlockView = memo(function BlockView({ block, label }: { block: Block; label: Translate; localeRev: number }) {
|
|
311
312
|
const t = label
|
|
312
313
|
switch (block.kind) {
|
|
313
314
|
case 'user':
|
|
@@ -547,7 +548,7 @@ export function AcpConversationView(props: AcpViewProps) {
|
|
|
547
548
|
>
|
|
548
549
|
{transcript.blocks.length === 0
|
|
549
550
|
? <div className="ntr-acp-empty-hint">{t('还没有消息:在下面的输入框里发给它。', 'Nothing yet — send it a message below.')}</div>
|
|
550
|
-
: transcript.blocks.map((block, index) => <BlockView key={index} block={block} label={acp.label} />)}
|
|
551
|
+
: transcript.blocks.map((block, index) => <BlockView key={index} block={block} label={acp.label} localeRev={ui.localeRev} />)}
|
|
551
552
|
</div>
|
|
552
553
|
</div>
|
|
553
554
|
)
|
package/src/client/index.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import { AcpSettingsSection } from './panel.tsx'
|
|
|
18
18
|
import { AcpComposerMode } from './composer-mode.tsx'
|
|
19
19
|
import { AcpComposerAgentTag, AcpSessionControls, AcpSessionMenu } from './session-controls.tsx'
|
|
20
20
|
import { AcpConversationView } from './acp-view.tsx'
|
|
21
|
+
import { AcpSettingsDialog } from './settings-dialog.tsx'
|
|
21
22
|
import {
|
|
22
23
|
createModelSeatClaim,
|
|
23
24
|
createPermissionSeatClaim,
|
|
@@ -59,7 +60,19 @@ interface StructuralServices {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
const
|
|
63
|
+
const browserPrefersZh = typeof navigator !== 'undefined' && /^zh\b/i.test(navigator.language)
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Structural view of the shell's locale service (`@deepseek-ai/dsh-client-locale`).
|
|
67
|
+
*
|
|
68
|
+
* Read through {@link ctx.get} rather than injected: a composition without the
|
|
69
|
+
* service must still load, and the label pairs carry their own browser-derived
|
|
70
|
+
* fallback for exactly that case.
|
|
71
|
+
*/
|
|
72
|
+
interface LocaleFace {
|
|
73
|
+
resolveText?(text: Record<string, string>): string
|
|
74
|
+
subscribe?(listener: () => void): () => void
|
|
75
|
+
}
|
|
63
76
|
|
|
64
77
|
/**
|
|
65
78
|
* How long a host verdict may stand as the gate's ONLY fact source before the
|
|
@@ -97,8 +110,6 @@ export function apply(ctx: Context): void {
|
|
|
97
110
|
throw new Error('dsh-ntr: browser connection.rpc is unavailable')
|
|
98
111
|
}
|
|
99
112
|
const call: RpcCall = (channel, endpoint, payload) => rpc.call(channel, endpoint, payload)
|
|
100
|
-
const store = new AcpStore(call, (zhText, enText) => (zh ? zhText : enText))
|
|
101
|
-
store.start()
|
|
102
113
|
|
|
103
114
|
/**
|
|
104
115
|
* Resolve one optional service without injecting it. A plain `ctx.get` at
|
|
@@ -108,6 +119,37 @@ export function apply(ctx: Context): void {
|
|
|
108
119
|
*/
|
|
109
120
|
const service = <T,>(name: string): T | undefined => (ctx as unknown as { get(key: string): unknown }).get(name) as T | undefined
|
|
110
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Resolve a Chinese/English pair against the shell's active locale. Read per
|
|
124
|
+
* call, so a language switch reaches every render-time `label()` call without
|
|
125
|
+
* rebuilding the store. A host without the locale service keeps the
|
|
126
|
+
* browser-derived choice the pairs shipped with.
|
|
127
|
+
*/
|
|
128
|
+
const label = (zhText: string, enText: string): string => {
|
|
129
|
+
const resolved = service<LocaleFace>('locale')?.resolveText?.({ zh: zhText, en: enText })
|
|
130
|
+
return typeof resolved === 'string' && resolved !== '' ? resolved : browserPrefersZh ? zhText : enText
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const store = new AcpStore(call, label)
|
|
134
|
+
store.start()
|
|
135
|
+
|
|
136
|
+
// A locale switch changes no data, only which side of each pair renders;
|
|
137
|
+
// republishing the snapshot is what re-runs them. Attached through
|
|
138
|
+
// `internal/service` so a late-arriving locale service is still hooked.
|
|
139
|
+
ctx.effect(() => {
|
|
140
|
+
let stop: (() => void) | undefined
|
|
141
|
+
const attach = (): void => {
|
|
142
|
+
if (stop !== undefined) return
|
|
143
|
+
stop = service<LocaleFace>('locale')?.subscribe?.(() => { store.refreshLocale() })
|
|
144
|
+
}
|
|
145
|
+
attach()
|
|
146
|
+
const stopService = ctx.on('internal/service', attach)
|
|
147
|
+
return () => {
|
|
148
|
+
stopService()
|
|
149
|
+
stop?.()
|
|
150
|
+
}
|
|
151
|
+
}, 'dsh-ntr: locale switch re-render')
|
|
152
|
+
|
|
111
153
|
/** This session's command/projection face (`ctx.sessions`); `undefined` before it materializes. */
|
|
112
154
|
const faceFor = (sessionId: string): AcpSessionFace | undefined => {
|
|
113
155
|
try {
|
|
@@ -132,6 +174,19 @@ export function apply(ctx: Context): void {
|
|
|
132
174
|
inject: () => ({ acp: store }),
|
|
133
175
|
}, AcpSettingsSection)), 'dsh-ntr: settings section')
|
|
134
176
|
|
|
177
|
+
// The same NTR settings, opened from the composer when the agent roster is
|
|
178
|
+
// empty (the agent base's picker action). It rides the frame's overlay layer
|
|
179
|
+
// because the composer seat and the dialog live in unrelated slot trees; the
|
|
180
|
+
// host settings shell has no programmatic open seam, so the dialog mounts the
|
|
181
|
+
// same section the shell mounts (see settings-dialog.tsx). Renders nothing
|
|
182
|
+
// while closed.
|
|
183
|
+
ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
184
|
+
name: 'shell.overlay',
|
|
185
|
+
id: 'acp-settings-dialog',
|
|
186
|
+
order: 30,
|
|
187
|
+
inject: () => ({ acp: store }),
|
|
188
|
+
}, AcpSettingsDialog as unknown as SlotComponent<any>)), 'dsh-ntr: NTR settings dialog')
|
|
189
|
+
|
|
135
190
|
// The agent's OWN modes sit in the composer tool row — the seat the slot
|
|
136
191
|
// contract reserves for exactly this ("entries sit beside that chrome, never
|
|
137
192
|
// replace it"). They are a different layer from everything the host controls:
|
package/src/client/panel.tsx
CHANGED
|
@@ -147,16 +147,18 @@ const STYLE = `
|
|
|
147
147
|
.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); }
|
|
148
148
|
.dwa-inline-setting .dwa-switch { flex-shrink: 0; margin-top: 1px; }
|
|
149
149
|
.dwa-setting-copy { color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
|
|
150
|
-
/*
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
.dwa-model-transfer { display: grid; grid-template-columns:
|
|
154
|
-
/* Tall enough for a comfortable selection (about ten rows) before scrolling
|
|
155
|
-
|
|
156
|
-
slack on the shorter side instead of a whole empty half. */
|
|
150
|
+
/* Stacked, not side by side: the rows carry name, id and credit, and a
|
|
151
|
+
half-width pane truncated them at every realistic settings width. Each list
|
|
152
|
+
gets the whole column instead. */
|
|
153
|
+
.dwa-model-transfer { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
|
|
154
|
+
/* Tall enough for a comfortable selection (about ten rows) before scrolling;
|
|
155
|
+
the cap keeps the two stacked panes from pushing the rest of the card away. */
|
|
157
156
|
.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; }
|
|
158
157
|
.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); }
|
|
159
158
|
.dwa-model-pane-head .dwa-btn { margin-left: auto; min-height: 26px; padding: 2px 8px; font-size: var(--ntr-font-xs); }
|
|
159
|
+
.dwa-model-pane-head .dwa-caret-btn { flex: none; width: 22px; height: 22px; background: transparent; }
|
|
160
|
+
.dwa-model-pane-head .dwa-caret-btn:hover { background: var(--dsw-alias-interactive-bg-active, #e5e7eb); }
|
|
161
|
+
.dwa-model-pane.is-collapsed .dwa-model-list { display: none; }
|
|
160
162
|
.dwa-model-list { display: flex; flex-direction: column; gap: 2px; padding: 5px; flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
161
163
|
.dwa-model-section { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; font-size: 12px; }
|
|
162
164
|
.dwa-model-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
@@ -173,7 +175,19 @@ const STYLE = `
|
|
|
173
175
|
.dwa-model-row.is-on { background: #3b82f614; color: #2563eb; }
|
|
174
176
|
.dwa-model-row-mark { width: 12px; flex: none; text-align: center; opacity: 0.75; }
|
|
175
177
|
.dwa-model-row-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
176
|
-
|
|
178
|
+
/* Name and credit keep their room; the id is the only cell that collapses. */
|
|
179
|
+
.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; }
|
|
180
|
+
/* The trailing 14px is reserved for the fade mask: while the id fits, the
|
|
181
|
+
gradient only covers empty padding; once it overflows, its tail dissolves
|
|
182
|
+
instead of ending in a hard ellipsis. armModelIdMarquee reads the same
|
|
183
|
+
padding to scroll the full text under the pointer. */
|
|
184
|
+
.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); }
|
|
185
|
+
.dwa-model-row-value-text { display: inline-block; white-space: nowrap; }
|
|
186
|
+
.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; }
|
|
187
|
+
@keyframes dwa-model-id-marquee {
|
|
188
|
+
from { transform: translateX(0); }
|
|
189
|
+
to { transform: translateX(var(--dwa-id-shift, 0px)); }
|
|
190
|
+
}
|
|
177
191
|
.dwa-model-row-remove { flex: none; opacity: 0.5; padding: 0 2px; }
|
|
178
192
|
.dwa-model-row:hover .dwa-model-row-remove { opacity: 1; }
|
|
179
193
|
.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; }
|
|
@@ -190,7 +204,6 @@ const STYLE = `
|
|
|
190
204
|
.dwa-page-header { flex-direction: column; }
|
|
191
205
|
.dwa-page-actions { width: 100%; }
|
|
192
206
|
.dwa-page-actions .dwa-btn { width: 100%; }
|
|
193
|
-
.dwa-model-transfer { grid-template-columns: 1fr; }
|
|
194
207
|
.dwa-model-toolbar-actions { width: 100%; }
|
|
195
208
|
.dwa-model-search { flex: 1 1 auto; width: auto; }
|
|
196
209
|
.dwa-subsection-head, .dwa-preference-row { align-items: flex-start; flex-direction: column; }
|
|
@@ -198,6 +211,7 @@ const STYLE = `
|
|
|
198
211
|
}
|
|
199
212
|
@media (prefers-reduced-motion: reduce) {
|
|
200
213
|
.dwa-root *, .dwa-root *::before, .dwa-root *::after { transition: none !important; }
|
|
214
|
+
.dwa-model-row:hover .dwa-model-row-value-text { animation: none !important; }
|
|
201
215
|
}
|
|
202
216
|
`
|
|
203
217
|
|
|
@@ -1535,6 +1549,23 @@ function RowMenu({
|
|
|
1535
1549
|
)
|
|
1536
1550
|
}
|
|
1537
1551
|
|
|
1552
|
+
/**
|
|
1553
|
+
* Measure the id cell on row hover so its CSS marquee can reach the end of the
|
|
1554
|
+
* text. The id is the only cell that yields width, and the panel is resizable,
|
|
1555
|
+
* so the overflow is re-read on every entry rather than cached. The animation
|
|
1556
|
+
* itself lives in CSS and only runs while the row is hovered; `title` on the
|
|
1557
|
+
* row stays the fallback for reduced-motion users.
|
|
1558
|
+
*/
|
|
1559
|
+
function armModelIdMarquee(row: HTMLElement): void {
|
|
1560
|
+
const view = row.querySelector<HTMLElement>('.dwa-model-row-value')
|
|
1561
|
+
const text = view?.querySelector<HTMLElement>('.dwa-model-row-value-text')
|
|
1562
|
+
if (view === null || view === undefined || text === null || text === undefined) return
|
|
1563
|
+
const fade = Number.parseFloat(getComputedStyle(view).paddingRight) || 0
|
|
1564
|
+
const shift = Math.max(0, text.offsetWidth - (view.clientWidth - fade))
|
|
1565
|
+
row.style.setProperty('--dwa-id-shift', `${String(-shift)}px`)
|
|
1566
|
+
row.style.setProperty('--dwa-id-duration', `${String(Math.min(4, Math.max(1.2, shift / 30)))}s`)
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1538
1569
|
/**
|
|
1539
1570
|
* Model / thought-level catalog display plus the native-selector visibility
|
|
1540
1571
|
* filter, as a two-pane transfer list: every known model on the left, the
|
|
@@ -1597,6 +1628,11 @@ function ModelCatalogBlock({
|
|
|
1597
1628
|
const modelChoices = result?.catalog.model?.choices ?? []
|
|
1598
1629
|
/** Result of the last click on 「检测可用模型」 (what the refresh changed). */
|
|
1599
1630
|
const [detectNote, setDetectNote] = useState<string | undefined>(undefined)
|
|
1631
|
+
// Each list folds on its own so a long selection can be parked while the
|
|
1632
|
+
// other pane keeps the full column. The list stays mounted (display-toggled)
|
|
1633
|
+
// to keep its scroll position.
|
|
1634
|
+
const [allCollapsed, setAllCollapsed] = useState(false)
|
|
1635
|
+
const [selectedCollapsed, setSelectedCollapsed] = useState(false)
|
|
1600
1636
|
|
|
1601
1637
|
// Display read, not a probe: the host answers from its persisted catalog
|
|
1602
1638
|
// cache, so the panes already list the known models the first time the card
|
|
@@ -1846,7 +1882,7 @@ function ModelCatalogBlock({
|
|
|
1846
1882
|
</div>
|
|
1847
1883
|
|
|
1848
1884
|
<div className="dwa-model-transfer">
|
|
1849
|
-
<div className=
|
|
1885
|
+
<div className={`dwa-model-pane${allCollapsed ? ' is-collapsed' : ''}`}>
|
|
1850
1886
|
<div className="dwa-model-pane-head">
|
|
1851
1887
|
<span>{acp.label('全部模型', 'All models')}</span>
|
|
1852
1888
|
{result !== undefined && <span className="dwa-badge">{modelChoices.length}</span>}
|
|
@@ -1859,6 +1895,15 @@ function ModelCatalogBlock({
|
|
|
1859
1895
|
>
|
|
1860
1896
|
{acp.label('全部加入', 'Add all')}
|
|
1861
1897
|
</button>
|
|
1898
|
+
<button
|
|
1899
|
+
type="button"
|
|
1900
|
+
className="dwa-caret-btn"
|
|
1901
|
+
aria-expanded={!allCollapsed}
|
|
1902
|
+
onClick={() => setAllCollapsed(value => !value)}
|
|
1903
|
+
title={allCollapsed ? acp.label('展开全部模型', 'Expand all models') : acp.label('收起全部模型', 'Collapse all models')}
|
|
1904
|
+
>
|
|
1905
|
+
<span className="dwa-caret" aria-hidden>{allCollapsed ? '▸' : '▾'}</span>
|
|
1906
|
+
</button>
|
|
1862
1907
|
</div>
|
|
1863
1908
|
<div className="dwa-model-list">
|
|
1864
1909
|
{visibleChoices.map(choice => {
|
|
@@ -1876,6 +1921,7 @@ function ModelCatalogBlock({
|
|
|
1876
1921
|
className={`dwa-model-row${checked ? ' is-on' : ''}`}
|
|
1877
1922
|
title={title}
|
|
1878
1923
|
onClick={() => toggleChoice(choice.value)}
|
|
1924
|
+
onPointerEnter={event => { armModelIdMarquee(event.currentTarget) }}
|
|
1879
1925
|
>
|
|
1880
1926
|
<span className="dwa-model-row-mark">{checked ? '✓' : '+'}</span>
|
|
1881
1927
|
<span className="dwa-model-row-name">
|
|
@@ -1883,7 +1929,17 @@ function ModelCatalogBlock({
|
|
|
1883
1929
|
{choice.name}
|
|
1884
1930
|
</span>
|
|
1885
1931
|
{choice.value !== choice.name && (
|
|
1886
|
-
<span className="dwa-model-row-value">
|
|
1932
|
+
<span className="dwa-model-row-value">
|
|
1933
|
+
<span className="dwa-model-row-value-text">{choice.value}</span>
|
|
1934
|
+
</span>
|
|
1935
|
+
)}
|
|
1936
|
+
{/* Credit / capability notes belong on the row itself: the
|
|
1937
|
+
list exists to compare models, and a hover-only price
|
|
1938
|
+
cannot be scanned. Grouped choices reuse the group label
|
|
1939
|
+
as their fallback description — that one is already
|
|
1940
|
+
printed before the name. */}
|
|
1941
|
+
{choice.description !== undefined && choice.description !== choice.group && (
|
|
1942
|
+
<span className="dwa-model-row-desc">{choice.description}</span>
|
|
1887
1943
|
)}
|
|
1888
1944
|
</button>
|
|
1889
1945
|
)
|
|
@@ -1892,7 +1948,7 @@ function ModelCatalogBlock({
|
|
|
1892
1948
|
</div>
|
|
1893
1949
|
</div>
|
|
1894
1950
|
|
|
1895
|
-
<div className=
|
|
1951
|
+
<div className={`dwa-model-pane${selectedCollapsed ? ' is-collapsed' : ''}`}>
|
|
1896
1952
|
<div className="dwa-model-pane-head">
|
|
1897
1953
|
<span>{acp.label('已选模型', 'Selected models')}</span>
|
|
1898
1954
|
<span className="dwa-badge dwa-badge-info">{basis.length}</span>
|
|
@@ -1904,6 +1960,15 @@ function ModelCatalogBlock({
|
|
|
1904
1960
|
>
|
|
1905
1961
|
{acp.label('清空', 'Clear')}
|
|
1906
1962
|
</button>
|
|
1963
|
+
<button
|
|
1964
|
+
type="button"
|
|
1965
|
+
className="dwa-caret-btn"
|
|
1966
|
+
aria-expanded={!selectedCollapsed}
|
|
1967
|
+
onClick={() => setSelectedCollapsed(value => !value)}
|
|
1968
|
+
title={selectedCollapsed ? acp.label('展开已选模型', 'Expand selected models') : acp.label('收起已选模型', 'Collapse selected models')}
|
|
1969
|
+
>
|
|
1970
|
+
<span className="dwa-caret" aria-hidden>{selectedCollapsed ? '▸' : '▾'}</span>
|
|
1971
|
+
</button>
|
|
1907
1972
|
</div>
|
|
1908
1973
|
<div className="dwa-model-list">
|
|
1909
1974
|
{selectedChoices.length === 0 && (
|
|
@@ -1918,13 +1983,19 @@ function ModelCatalogBlock({
|
|
|
1918
1983
|
className="dwa-model-row is-on"
|
|
1919
1984
|
title={choice.description === undefined ? choice.value : `${choice.value}\n${choice.description}`}
|
|
1920
1985
|
onClick={() => toggleChoice(choice.value)}
|
|
1986
|
+
onPointerEnter={event => { armModelIdMarquee(event.currentTarget) }}
|
|
1921
1987
|
>
|
|
1922
1988
|
<span className="dwa-model-row-name">
|
|
1923
1989
|
{choice.group !== undefined && <span style={{ opacity: 0.7 }}>{choice.group} · </span>}
|
|
1924
1990
|
{choice.name}
|
|
1925
1991
|
</span>
|
|
1926
1992
|
{choice.value !== choice.name && (
|
|
1927
|
-
<span className="dwa-model-row-value">
|
|
1993
|
+
<span className="dwa-model-row-value">
|
|
1994
|
+
<span className="dwa-model-row-value-text">{choice.value}</span>
|
|
1995
|
+
</span>
|
|
1996
|
+
)}
|
|
1997
|
+
{choice.description !== undefined && choice.description !== choice.group && (
|
|
1998
|
+
<span className="dwa-model-row-desc">{choice.description}</span>
|
|
1928
1999
|
)}
|
|
1929
2000
|
<span className="dwa-model-row-remove" aria-hidden="true">×</span>
|
|
1930
2001
|
</button>
|