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
|
@@ -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
|
|
|
58
60
|
import { useEffect, useState } from 'react'
|
|
@@ -61,7 +63,7 @@ import type { CSSProperties } from 'react'
|
|
|
61
63
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
62
64
|
import {
|
|
63
65
|
AcpChip, AcpMenu, AcpShatterParts, buildAcpModelId, closeAcpMenu, ensureAcpStyle,
|
|
64
|
-
openAcpMenu, setAcpMenuError, splitAcpModelId, useAcpMenu, useUi,
|
|
66
|
+
openAcpMenu, openAcpSettingsDialog, setAcpMenuError, splitAcpModelId, useAcpMenu, useUi,
|
|
65
67
|
} from './acp-ui.tsx'
|
|
66
68
|
import { playAgentLockAnim, useAgentLockPhase } from './agent-lock-anim.ts'
|
|
67
69
|
import type {
|
|
@@ -150,6 +152,23 @@ function routeAgentId(
|
|
|
150
152
|
return undefined
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Whether the agent base has anything honest to show.
|
|
157
|
+
*
|
|
158
|
+
* An empty roster has two meanings, and they must not be conflated: before the
|
|
159
|
+
* host answers `agents/list` it means "not read yet" (rendering a setup entry
|
|
160
|
+
* there would accuse a correctly configured deployment on every page load),
|
|
161
|
+
* while after the answer it means "nothing is configured" — exactly the state
|
|
162
|
+
* that must keep the base and offer the way into NTR settings.
|
|
163
|
+
*/
|
|
164
|
+
export function agentBaseHasData(
|
|
165
|
+
bound: ChatSessionView | undefined,
|
|
166
|
+
agents: readonly AgentView[],
|
|
167
|
+
rosterLoaded: boolean,
|
|
168
|
+
): boolean {
|
|
169
|
+
return bound !== undefined || agents.length > 0 || rosterLoaded
|
|
170
|
+
}
|
|
171
|
+
|
|
153
172
|
/**
|
|
154
173
|
* The agent this conversation is LOCKED to, if its first ACP turn has run.
|
|
155
174
|
*
|
|
@@ -374,21 +393,29 @@ export function advancedOptions(
|
|
|
374
393
|
* The tooltip claims only what the menu behind the chip does: switch the agent.
|
|
375
394
|
* The agent's models and thought levels have chips of their own (user decision,
|
|
376
395
|
* 2026-10-17), so the previous wording — "switch the agent, its model and the
|
|
377
|
-
* thought level" — promised a menu that holds the roster alone.
|
|
396
|
+
* thought level" — promised a menu that holds the roster alone. An EMPTY
|
|
397
|
+
* roster is its own case: the menu cannot switch to anything, it offers the
|
|
398
|
+
* way into NTR settings, so the tooltip says that instead of promising a pick.
|
|
378
399
|
*/
|
|
379
400
|
export function agentBaseChip(
|
|
380
401
|
route: RouteInfo,
|
|
381
402
|
routed: boolean,
|
|
382
403
|
acp: AcpChatInjected['acp'],
|
|
404
|
+
rosterEmpty = false,
|
|
383
405
|
): { label: string; title: string } {
|
|
384
406
|
const label = route.agentTitle === undefined
|
|
385
407
|
? (routed ? 'NTR' : acp.label('选择 Agent', 'Choose agent'))
|
|
386
408
|
: `NTR · ${route.agentTitle}`
|
|
387
409
|
const title = route.agentId === undefined
|
|
388
|
-
?
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
410
|
+
? rosterEmpty
|
|
411
|
+
? acp.label(
|
|
412
|
+
'这条会话当前没有 NTR agent;点开可前往 NTR 设置添加',
|
|
413
|
+
'No NTR agent is serving this conversation — open to set one up in NTR settings',
|
|
414
|
+
)
|
|
415
|
+
: acp.label(
|
|
416
|
+
'这条会话当前没有 NTR agent 服务;点开可选择 agent',
|
|
417
|
+
'No NTR agent is serving this conversation right now — open to choose one',
|
|
418
|
+
)
|
|
392
419
|
: acp.label(
|
|
393
420
|
'这条会话由外接 NTR agent 服务;点开可切换 agent',
|
|
394
421
|
'This conversation is served by an external NTR agent — open to switch the agent',
|
|
@@ -530,9 +557,12 @@ export function AcpSessionControls(props: AcpClusterProps) {
|
|
|
530
557
|
lockedTo ?? ui.chatBoundAgent[sessionId],
|
|
531
558
|
choice,
|
|
532
559
|
)
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
560
|
+
// An empty roster is the SETUP case, not "no story": the base stays and its
|
|
561
|
+
// picker carries the way into NTR settings. Only the window before the host
|
|
562
|
+
// has answered `agents/list` stays blank — `agents: []` there means "not read
|
|
563
|
+
// yet", and the setup entry would accuse a configured deployment (see
|
|
564
|
+
// `agentBaseHasData`).
|
|
565
|
+
if (!agentBaseHasData(bound, agents, ui.agentsLoaded)) return null
|
|
536
566
|
// Set up = an agent serves this conversation: a live session, a persisted
|
|
537
567
|
// pick, the host's optimistic binding, OR the plugin default the guard's
|
|
538
568
|
// route rewrite falls back to. The last one is what makes a brand-new
|
|
@@ -613,7 +643,7 @@ export function AcpSessionControls(props: AcpClusterProps) {
|
|
|
613
643
|
itself is the shatter played here (see `shattering`). */}
|
|
614
644
|
{showAgentBase && (
|
|
615
645
|
<AcpChip
|
|
616
|
-
{...agentBaseChip(route, routed, acp)}
|
|
646
|
+
{...agentBaseChip(route, routed, acp, agents.length === 0)}
|
|
617
647
|
active={routed}
|
|
618
648
|
className={shattering ? 'ntr-acp-shatter' : undefined}
|
|
619
649
|
onClick={shattering ? undefined : (trigger) => { open('agent', trigger) }}
|
|
@@ -648,6 +678,14 @@ export interface MenuContext {
|
|
|
648
678
|
*/
|
|
649
679
|
export const REFRESH_CATALOG_ITEM = '__refresh-catalog__'
|
|
650
680
|
|
|
681
|
+
/**
|
|
682
|
+
* The empty roster's setup action. Like the model picker's refresh row it is an
|
|
683
|
+
* ACTION inside the picker, not an agent: agent ids match
|
|
684
|
+
* `[a-z0-9][a-z0-9._-]{0,63}` (no leading underscore), so no real id can
|
|
685
|
+
* collide with it.
|
|
686
|
+
*/
|
|
687
|
+
export const OPEN_SETTINGS_ITEM = '__open-settings__'
|
|
688
|
+
|
|
651
689
|
/** The agent roster rows: one row per configured ACP agent, current one marked. */
|
|
652
690
|
function agentSection(context: MenuContext): AcpMenuSection {
|
|
653
691
|
const { acp, route } = context
|
|
@@ -659,12 +697,21 @@ function agentSection(context: MenuContext): AcpMenuSection {
|
|
|
659
697
|
active: route.agentId === agent.id,
|
|
660
698
|
}))
|
|
661
699
|
if (items.length === 0) {
|
|
700
|
+
// The base stays on an empty roster (see `agentBaseHasData`), so this menu
|
|
701
|
+
// is the setup surface: a status row that says what is missing, then an
|
|
702
|
+
// ACTION row that opens the NTR settings. The action is a row of its own
|
|
703
|
+
// because a disabled status row cannot be the way forward.
|
|
662
704
|
items.push({
|
|
663
705
|
id: 'none',
|
|
664
706
|
label: acp.label('还没有配置 NTR agent', 'No NTR agent configured yet'),
|
|
665
|
-
detail: acp.label('
|
|
707
|
+
detail: acp.label('添加并启用一个 agent 后即可开始对话', 'Add and enable an agent to start'),
|
|
666
708
|
disabled: true,
|
|
667
709
|
})
|
|
710
|
+
items.push({
|
|
711
|
+
id: OPEN_SETTINGS_ITEM,
|
|
712
|
+
label: acp.label('打开 NTR 设置', 'Open NTR settings'),
|
|
713
|
+
detail: acp.label('添加或启用 agent', 'Add or enable an agent'),
|
|
714
|
+
})
|
|
668
715
|
}
|
|
669
716
|
return {
|
|
670
717
|
id: 'agent',
|
|
@@ -987,6 +1034,15 @@ export function AcpSessionMenu(props: AcpClusterProps) {
|
|
|
987
1034
|
if (agentId !== undefined) await acp.probeModels(agentId)
|
|
988
1035
|
return
|
|
989
1036
|
}
|
|
1037
|
+
if (sectionId === 'agent' && itemId === OPEN_SETTINGS_ITEM) {
|
|
1038
|
+
// The empty roster's way out (see `agentSection`): there is no agent to
|
|
1039
|
+
// bind, so this row opens the NTR settings surface instead. Close the
|
|
1040
|
+
// picker first — the dialog is a frame overlay and the menu must not
|
|
1041
|
+
// linger behind it.
|
|
1042
|
+
closeAcpMenu()
|
|
1043
|
+
openAcpSettingsDialog()
|
|
1044
|
+
return
|
|
1045
|
+
}
|
|
990
1046
|
if (sectionId === 'agent') {
|
|
991
1047
|
await acp.bindChatAgent(sessionId, itemId)
|
|
992
1048
|
} else if (sectionId === 'model') {
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
|
|
18
|
+
import { useEffect } from 'react'
|
|
19
|
+
import { AcpSettingsSection } from './panel.tsx'
|
|
20
|
+
import {
|
|
21
|
+
closeAcpSettingsDialog,
|
|
22
|
+
ensureAcpStyle,
|
|
23
|
+
useAcpSettingsDialogOpen,
|
|
24
|
+
} from './acp-ui.tsx'
|
|
25
|
+
import type { AcpStore } from './store.ts'
|
|
26
|
+
|
|
27
|
+
/** The root overlay entry's inject face. */
|
|
28
|
+
export interface AcpSettingsDialogProps {
|
|
29
|
+
acp: AcpStore
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Frame-wide NTR settings surface; renders nothing while closed. */
|
|
33
|
+
export function AcpSettingsDialog(props: AcpSettingsDialogProps) {
|
|
34
|
+
ensureAcpStyle()
|
|
35
|
+
const { acp } = props
|
|
36
|
+
const open = useAcpSettingsDialogOpen()
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!open) return
|
|
39
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
40
|
+
if (event.key !== 'Escape') return
|
|
41
|
+
event.preventDefault()
|
|
42
|
+
event.stopPropagation()
|
|
43
|
+
closeAcpSettingsDialog()
|
|
44
|
+
}
|
|
45
|
+
document.addEventListener('keydown', onKey, true)
|
|
46
|
+
return () => { document.removeEventListener('keydown', onKey, true) }
|
|
47
|
+
}, [open])
|
|
48
|
+
if (!open) return null
|
|
49
|
+
const title = acp.label('NTR 设置', 'NTR settings')
|
|
50
|
+
return (
|
|
51
|
+
<div className="ntr-acp-dialog-layer">
|
|
52
|
+
<div className="ntr-acp-dialog-mask" aria-hidden onClick={closeAcpSettingsDialog} />
|
|
53
|
+
<div className="ntr-acp-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
|
54
|
+
<div className="ntr-acp-dialog-head">
|
|
55
|
+
<span className="ntr-acp-dialog-title">{title}</span>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
className="ntr-acp-dialog-close"
|
|
59
|
+
title={acp.label('关闭', 'Close')}
|
|
60
|
+
aria-label={acp.label('关闭 NTR 设置', 'Close NTR settings')}
|
|
61
|
+
autoFocus
|
|
62
|
+
onClick={closeAcpSettingsDialog}
|
|
63
|
+
>
|
|
64
|
+
✕
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="ntr-acp-dialog-body">
|
|
68
|
+
<AcpSettingsSection acp={acp} />
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
package/src/client/store.ts
CHANGED
|
@@ -83,8 +83,20 @@ export interface ChatTranscript {
|
|
|
83
83
|
|
|
84
84
|
export interface UiState {
|
|
85
85
|
open: boolean
|
|
86
|
+
/**
|
|
87
|
+
* Bumped by {@link AcpStore.refreshLocale}. Labels resolve while rendering,
|
|
88
|
+
* but memoized transcript rows compare props — this counter is what tells
|
|
89
|
+
* them the language switched.
|
|
90
|
+
*/
|
|
91
|
+
localeRev: number
|
|
86
92
|
stream: 'init' | 'live' | 'down'
|
|
87
93
|
agents: AgentView[]
|
|
94
|
+
/**
|
|
95
|
+
* Whether the host has answered `agents/list` at least once in this page.
|
|
96
|
+
* `agents: []` alone cannot tell "no agent configured" from "not read yet";
|
|
97
|
+
* a surface that offers a setup entry on an empty roster must wait for this.
|
|
98
|
+
*/
|
|
99
|
+
agentsLoaded: boolean
|
|
88
100
|
sessions: SessionView[]
|
|
89
101
|
activeSessionId?: string
|
|
90
102
|
blocks: Block[]
|
|
@@ -506,8 +518,10 @@ export const DEFAULT_KNOWN_PRESETS: DetectedAgentInfo[] = [
|
|
|
506
518
|
export class AcpStore {
|
|
507
519
|
private state: UiState = {
|
|
508
520
|
open: false,
|
|
521
|
+
localeRev: 0,
|
|
509
522
|
stream: 'init',
|
|
510
523
|
agents: [],
|
|
524
|
+
agentsLoaded: false,
|
|
511
525
|
sessions: [],
|
|
512
526
|
blocks: [],
|
|
513
527
|
permissions: [],
|
|
@@ -580,6 +594,8 @@ export class AcpStore {
|
|
|
580
594
|
private readonly seatCatalogReads = new Set<string>()
|
|
581
595
|
/** In-flight `agent/cached-models` reads, keyed by agent id. */
|
|
582
596
|
private readonly cachedModelReads = new Set<string>()
|
|
597
|
+
/** Counts behind the last successful explicit re-detect, so a locale switch can re-render its summary. */
|
|
598
|
+
private detectCounts: { total: number; installed: number } | undefined
|
|
583
599
|
|
|
584
600
|
constructor(private readonly rpc: RpcCall, private readonly locale: (zh: string, en: string) => string) {}
|
|
585
601
|
|
|
@@ -735,6 +751,7 @@ export class AcpStore {
|
|
|
735
751
|
// Absent on an older host: keep the settings-panel card (degraded path).
|
|
736
752
|
this.mutate({
|
|
737
753
|
agents,
|
|
754
|
+
agentsLoaded: true,
|
|
738
755
|
sessions,
|
|
739
756
|
defaultAgent: defaultAgentInfo?.agentId,
|
|
740
757
|
detectedAgents,
|
|
@@ -755,19 +772,18 @@ export class AcpStore {
|
|
|
755
772
|
const result = await this.call<{ agents: DetectedAgentInfo[] }>('agents/detect')
|
|
756
773
|
const agents = result.agents.length > 0 ? result.agents : [...DEFAULT_KNOWN_PRESETS]
|
|
757
774
|
const installed = agents.filter(agent => agent.detected).length
|
|
775
|
+
this.detectCounts = { total: agents.length, installed }
|
|
758
776
|
this.mutate({
|
|
759
777
|
detectedAgents: agents,
|
|
760
778
|
detecting: false,
|
|
761
|
-
detectSummary: this.
|
|
762
|
-
`已检测 ${String(agents.length)} 个预设:${String(installed)} 个已安装,其余可从「添加智能体」启用。`,
|
|
763
|
-
`Detected ${String(agents.length)} presets: ${String(installed)} installed, enable the rest via "Add agent".`,
|
|
764
|
-
),
|
|
779
|
+
detectSummary: this.detectSummaryOf(agents.length, installed),
|
|
765
780
|
})
|
|
766
781
|
} catch (error) {
|
|
767
782
|
const msg = error instanceof Error ? error.message : String(error)
|
|
768
783
|
const notice = msg.includes('unknown endpoint agents/detect')
|
|
769
784
|
? this.locale('宿主服务运行着旧版本代码,请在终端重启 dsh web 服务以激活主机检测功能。', 'Host running older version. Please restart dsh web to enable agent detection.')
|
|
770
785
|
: msg
|
|
786
|
+
this.detectCounts = undefined
|
|
771
787
|
this.mutate({
|
|
772
788
|
detecting: false,
|
|
773
789
|
notice,
|
|
@@ -778,6 +794,13 @@ export class AcpStore {
|
|
|
778
794
|
}
|
|
779
795
|
}
|
|
780
796
|
|
|
797
|
+
private detectSummaryOf(total: number, installed: number): string {
|
|
798
|
+
return this.locale(
|
|
799
|
+
`已检测 ${String(total)} 个预设:${String(installed)} 个已安装,其余可从「添加智能体」启用。`,
|
|
800
|
+
`Detected ${String(total)} presets: ${String(installed)} installed, enable the rest via "Add agent".`,
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
781
804
|
async saveAgent(payload: AgentSavePayload): Promise<void> {
|
|
782
805
|
try {
|
|
783
806
|
await this.call('agent/save', payload)
|
|
@@ -1587,6 +1610,20 @@ export class AcpStore {
|
|
|
1587
1610
|
*/
|
|
1588
1611
|
label: (zh: string, en: string) => string = (zh: string, en: string): string => this.locale(zh, en)
|
|
1589
1612
|
|
|
1613
|
+
/**
|
|
1614
|
+
* Republish the snapshot after a locale switch. Every `label()` resolves
|
|
1615
|
+
* against the current language while rendering, so a notification is all
|
|
1616
|
+
* that is needed — except the detect summary, which the store owns as an
|
|
1617
|
+
* already-resolved string and rebuilds from its counts here.
|
|
1618
|
+
*/
|
|
1619
|
+
refreshLocale(): void {
|
|
1620
|
+
const counts = this.detectCounts
|
|
1621
|
+
this.mutate({
|
|
1622
|
+
localeRev: this.state.localeRev + 1,
|
|
1623
|
+
...(counts === undefined ? {} : { detectSummary: this.detectSummaryOf(counts.total, counts.installed) }),
|
|
1624
|
+
})
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1590
1627
|
clearNotice(): void {
|
|
1591
1628
|
this.mutate({ notice: undefined })
|
|
1592
1629
|
}
|