dsh-ntr 0.3.1 → 0.3.3
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/README.en.md +3 -1
- package/README.md +3 -1
- package/cordis.patch.yml +17 -1
- package/lib/bridge.js +2436 -82
- package/lib/client/acp-ui.js +103 -3
- package/lib/client/acp-view.js +24 -4
- package/lib/client/activity-pill.js +156 -0
- package/lib/client/branch-action.js +149 -0
- package/lib/client/composer-mode.js +48 -13
- package/lib/client/context-meter.js +86 -0
- package/lib/client/index.js +279 -16
- package/lib/client/model-seat.js +8 -2
- package/lib/client/panel.js +96 -29
- package/lib/client/released-banner.js +66 -0
- package/lib/client/session-controls.js +38 -13
- package/lib/client/slash-isolation.js +2 -2
- package/lib/client/store.js +380 -13
- package/lib/client.js +1364 -70
- package/lib/client.js.map +1 -1
- package/lib/impl.js +1229 -8
- package/lib/index.js +34 -0
- package/lib/protocol.js +19 -0
- package/lib/tps.js +32 -0
- package/lib/types/bridge.d.ts +850 -18
- package/lib/types/client/acp-ui.d.ts +34 -2
- package/lib/types/client/acp-view.d.ts +13 -2
- package/lib/types/client/activity-pill.d.ts +81 -0
- package/lib/types/client/branch-action.d.ts +47 -0
- package/lib/types/client/composer-mode.d.ts +28 -0
- package/lib/types/client/context-meter.d.ts +51 -0
- package/lib/types/client/model-seat.d.ts +21 -0
- package/lib/types/client/released-banner.d.ts +34 -0
- package/lib/types/client/slash-isolation.d.ts +1 -1
- package/lib/types/client/store.d.ts +108 -1
- package/lib/types/impl.d.ts +94 -7
- package/lib/types/protocol.d.ts +145 -0
- package/lib/types/tps.d.ts +17 -0
- package/package.json +2 -1
- package/src/bridge.ts +2754 -86
- package/src/client/acp-ui.tsx +123 -2
- package/src/client/acp-view.tsx +30 -5
- package/src/client/activity-pill.tsx +201 -0
- package/src/client/branch-action.tsx +178 -0
- package/src/client/composer-mode.tsx +59 -13
- package/src/client/context-meter.tsx +128 -0
- package/src/client/index.tsx +298 -15
- package/src/client/model-seat.tsx +28 -3
- package/src/client/panel.tsx +157 -40
- package/src/client/primitives.d.ts +27 -0
- package/src/client/released-banner.tsx +97 -0
- package/src/client/session-controls.tsx +34 -11
- package/src/client/slash-isolation.ts +2 -1
- package/src/client/store.ts +412 -14
- package/src/impl.ts +1251 -13
- package/src/index.ts +33 -0
- package/src/protocol.ts +145 -0
- package/src/tps.ts +40 -0
|
@@ -65,6 +65,44 @@ export function agentModeTitle(agentId: string | undefined, acp: AcpStore): stri
|
|
|
65
65
|
)
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Whether this conversation is one the agent-mode control may serve.
|
|
70
|
+
*
|
|
71
|
+
* An EXPLICIT non-ACP verdict outranks a lingering session row: switching a
|
|
72
|
+
* conversation off the NTR preset does not close its ACP session, so "a row
|
|
73
|
+
* exists" alone would keep this control on an ordinary conversation — the same
|
|
74
|
+
* precedence the agent tag, the context ring and the released banner already
|
|
75
|
+
* apply. Only while the host has not answered (`undefined`) does a bound
|
|
76
|
+
* session count as proof, which is what keeps the control available before the
|
|
77
|
+
* host's first `session/chat-list` answer lands.
|
|
78
|
+
*/
|
|
79
|
+
export function agentModeVisible(
|
|
80
|
+
acpMode: boolean | undefined,
|
|
81
|
+
hasBoundSession: boolean,
|
|
82
|
+
): boolean {
|
|
83
|
+
if (acpMode === false) return false
|
|
84
|
+
return acpMode === true || hasBoundSession
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The mode the select shows as in force.
|
|
89
|
+
*
|
|
90
|
+
* A LIVE session is the authority (it is what the agent runs right now). A
|
|
91
|
+
* RELEASED row only describes the mode the agent was parked ON, so a newer
|
|
92
|
+
* pick — which `applyChatChoice` will apply on the next resume — outranks it
|
|
93
|
+
* (and the parked row still beats the catalog default). Same precedence as
|
|
94
|
+
* the model and thought level in `describeRoute`: a pre-switch on a parked
|
|
95
|
+
* session must be visible or the control reads as dead.
|
|
96
|
+
*/
|
|
97
|
+
export function modeInForce(
|
|
98
|
+
target: { released?: boolean; modes?: { currentModeId?: string } } | undefined,
|
|
99
|
+
persisted: string | undefined,
|
|
100
|
+
fallback?: string,
|
|
101
|
+
): string {
|
|
102
|
+
const live = target !== undefined && target.released !== true ? target.modes?.currentModeId : undefined
|
|
103
|
+
return live ?? persisted ?? target?.modes?.currentModeId ?? fallback ?? ''
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
export function AcpComposerMode({ acp, sessionId, useSession }: ComposerModeProps) {
|
|
69
107
|
const ui = useUi(acp)
|
|
70
108
|
const dshSessionId = String(sessionId)
|
|
@@ -85,9 +123,10 @@ export function AcpComposerMode({ acp, sessionId, useSession }: ComposerModeProp
|
|
|
85
123
|
// unconditionally), so it must gate itself. The `defaultAgent` fallback below
|
|
86
124
|
// would otherwise put an ACP mode select into ORDINARY conversations: the
|
|
87
125
|
// agent id is plugin-global, so "an agent is configured" is not the same
|
|
88
|
-
// question as "this conversation is served by one". A live session is
|
|
89
|
-
//
|
|
90
|
-
|
|
126
|
+
// question as "this conversation is served by one". A live session is proof
|
|
127
|
+
// only while the host has not answered; an explicit `false` wins over the
|
|
128
|
+
// lingering row a preset switch leaves behind (see `agentModeVisible`).
|
|
129
|
+
const isAcp = agentModeVisible(ui.acpMode[dshSessionId], target !== undefined)
|
|
91
130
|
// The agent this conversation is set up on, live session or not. Before the
|
|
92
131
|
// first message there is no session to read a mode list from, yet the user is
|
|
93
132
|
// already choosing the agent — and the mode is one of the things they may
|
|
@@ -116,6 +155,12 @@ export function AcpComposerMode({ acp, sessionId, useSession }: ComposerModeProp
|
|
|
116
155
|
void acp.loadAgentCatalog(agentId)
|
|
117
156
|
}, [acp, agentId, isAcp, target])
|
|
118
157
|
|
|
158
|
+
// The whole control is ACP-only: a lingering live session row survives a
|
|
159
|
+
// preset switch, so its `availableModes` are NOT a licence to render here.
|
|
160
|
+
// Without this guard an ordinary conversation kept an agent-mode select
|
|
161
|
+
// beside its native controls (reported as "the bottom bar lost its native
|
|
162
|
+
// capabilities").
|
|
163
|
+
if (!isAcp) return null
|
|
119
164
|
const live = target?.modes?.availableModes ?? []
|
|
120
165
|
// TWO sources, one list. A live session's `session/new` answer is the
|
|
121
166
|
// authority on which modes exist and which is in force; the probed catalog
|
|
@@ -123,20 +168,21 @@ export function AcpComposerMode({ acp, sessionId, useSession }: ComposerModeProp
|
|
|
123
168
|
// agent published on ITS throwaway session). A live session always wins.
|
|
124
169
|
const modes = live.length > 0
|
|
125
170
|
? live
|
|
126
|
-
: (
|
|
171
|
+
: (catalog?.mode?.choices ?? []).map(choice => ({ id: choice.id, name: choice.name, description: choice.description }))
|
|
127
172
|
if (modes.length === 0) return null
|
|
128
|
-
// The mode IN FORCE
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// model and thought level do in
|
|
173
|
+
// The mode IN FORCE: a live session is the authority, before one exists (or
|
|
174
|
+
// while one is parked) the claims available are the pick this conversation
|
|
175
|
+
// recorded and the agent's own reported current mode (a pick outranks the
|
|
176
|
+
// agent's default, exactly like the model and thought level do in
|
|
177
|
+
// `describeRoute`; the parked row yields to the pick — see `modeInForce`).
|
|
132
178
|
const persisted = ui.chatChoice[dshSessionId]?.agentId === agentId
|
|
133
179
|
? ui.chatChoice[dshSessionId]?.mode
|
|
134
180
|
: undefined
|
|
135
|
-
const current =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
?? modes[0]?.id
|
|
139
|
-
|
|
181
|
+
const current = modeInForce(
|
|
182
|
+
target,
|
|
183
|
+
persisted,
|
|
184
|
+
catalog?.mode?.currentValue ?? modes[0]?.id,
|
|
185
|
+
)
|
|
140
186
|
|
|
141
187
|
return (
|
|
142
188
|
<label
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer context-occupancy ring at `conversation.composer.dock` — the bottom
|
|
3
|
+
* info row below the input card, where the host also renders its ContextMeter.
|
|
4
|
+
*
|
|
5
|
+
* WHY A PLUGIN-OWNED RING: the host's meter reads its `contextPressure`
|
|
6
|
+
* projection, folded from DSH provider-usage samples plus a route capacity. An
|
|
7
|
+
* ACP conversation produces neither — the agent owns the request and the host
|
|
8
|
+
* never sees its prompt — so the native meter renders nothing there. This ring
|
|
9
|
+
* reports the agent's OWN `usage_update` used/size instead: occupancy only. ACP
|
|
10
|
+
* declares no system/tools/messages split, so no breakdown is invented.
|
|
11
|
+
*
|
|
12
|
+
* Registered for every conversation and gating itself: it renders only for an
|
|
13
|
+
* ACP conversation whose session row carries usage. The row comes from
|
|
14
|
+
* `session/chat-list`; the store live-patches it from the coalesced
|
|
15
|
+
* `usage_update` frames (see `AcpStore.flushUpdates`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useEffect } from 'react'
|
|
19
|
+
// Type-only: pulls the ui-conversation SlotMap merge into this program.
|
|
20
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
21
|
+
import { ensureAcpStyle, useUi } from './acp-ui.tsx'
|
|
22
|
+
import type { AcpSessionHook } from './acp-ui.tsx'
|
|
23
|
+
import type { AcpStore, UiState } from './store.ts'
|
|
24
|
+
import type { SessionView } from '../protocol.ts'
|
|
25
|
+
|
|
26
|
+
/** Props of the composer entry (injected face + the frame's standard kit). */
|
|
27
|
+
export interface AcpContextMeterProps {
|
|
28
|
+
/** Injected face: the chat session id this entry belongs to. */
|
|
29
|
+
sessionId: string
|
|
30
|
+
/** Framework-bound conversation snapshot hook (`SessionStandardProps.useSession`). */
|
|
31
|
+
useSession: AcpSessionHook
|
|
32
|
+
acp: AcpStore
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ContextOccupancy {
|
|
36
|
+
/** Rounded occupancy percentage, clamped to 0..100. */
|
|
37
|
+
percent: number
|
|
38
|
+
used: number
|
|
39
|
+
size: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The sample this conversation's ring should show, or `undefined` when the
|
|
44
|
+
* entry must render nothing. An explicit `acpMode === false` outranks a stale
|
|
45
|
+
* `chatSessions` row: the conversation's preset left NTR while its ACP session
|
|
46
|
+
* was still alive, and the ring must leave with it (same precedence as the
|
|
47
|
+
* agent tag's `agentTagVisible`). While the verdict is unknown, a LIVE row is
|
|
48
|
+
* proof; a PARKED one is not — it can outlive the preset switch, and the ring
|
|
49
|
+
* must not appear on an ordinary conversation during the answer window.
|
|
50
|
+
*/
|
|
51
|
+
export function acpMeterUsage(
|
|
52
|
+
ui: Pick<UiState, 'acpMode' | 'chatSessions' | 'chatSessionsFor'>,
|
|
53
|
+
dshSessionId: string,
|
|
54
|
+
): SessionView['usage'] | undefined {
|
|
55
|
+
if (ui.acpMode[dshSessionId] === false) return undefined
|
|
56
|
+
if (ui.chatSessionsFor !== dshSessionId) return undefined
|
|
57
|
+
const live = ui.chatSessions.find(row => !row.released)
|
|
58
|
+
// A PARKED row alone is not proof this conversation is still in NTR mode:
|
|
59
|
+
// switching off the preset leaves the ACP session record alive, and while the
|
|
60
|
+
// host has not answered (`undefined`) the ring must not appear on an
|
|
61
|
+
// ordinary conversation. A live row is proof; a parked one needs the
|
|
62
|
+
// affirmative verdict.
|
|
63
|
+
if (live === undefined && ui.acpMode[dshSessionId] !== true) return undefined
|
|
64
|
+
return (live ?? ui.chatSessions[0])?.usage
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Bounded display occupancy from one agent sample. `undefined` (no meter) for
|
|
69
|
+
* an absent / malformed / zero-capacity sample rather than a made-up 0%.
|
|
70
|
+
*/
|
|
71
|
+
export function contextOccupancy(usage: SessionView['usage'] | undefined): ContextOccupancy | undefined {
|
|
72
|
+
if (usage === undefined) return undefined
|
|
73
|
+
const { used, size } = usage
|
|
74
|
+
if (!Number.isFinite(used) || !Number.isFinite(size) || used < 0 || size <= 0) return undefined
|
|
75
|
+
return { percent: Math.min(100, Math.round(used / size * 100)), used, size }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Compact token count for the tooltip, in the host meter's K/M shape. */
|
|
79
|
+
export function compactTokenCount(value: number): string {
|
|
80
|
+
if (value < 1e3) return String(Math.round(value))
|
|
81
|
+
const thousands = value / 1e3
|
|
82
|
+
if (value < 1e6) {
|
|
83
|
+
return `${thousands >= 100 ? String(Math.round(thousands)) : String(Math.round(thousands * 10) / 10)}K`
|
|
84
|
+
}
|
|
85
|
+
const millions = value / 1e6
|
|
86
|
+
return `${millions >= 100 ? String(Math.round(millions)) : String(Math.round(millions * 10) / 10)}M`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Ring geometry matches the host ContextMeter: 14px viewBox, 2px stroke. */
|
|
90
|
+
const RADIUS = 5.5
|
|
91
|
+
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
|
92
|
+
|
|
93
|
+
export function AcpContextMeter({ acp, sessionId, useSession }: AcpContextMeterProps) {
|
|
94
|
+
ensureAcpStyle()
|
|
95
|
+
const ui = useUi(acp)
|
|
96
|
+
const dshSessionId = String(sessionId)
|
|
97
|
+
// A fresh conversation gets its ACP session binding during its first turn,
|
|
98
|
+
// and no SSE frame announces that; the running flip is the re-read trigger
|
|
99
|
+
// every composer entry shares.
|
|
100
|
+
const running = useSession(snapshot => snapshot.running)
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
void acp.loadChatSessions(dshSessionId)
|
|
103
|
+
}, [acp, dshSessionId, running])
|
|
104
|
+
|
|
105
|
+
const context = contextOccupancy(acpMeterUsage(ui, dshSessionId))
|
|
106
|
+
if (context === undefined) return null
|
|
107
|
+
|
|
108
|
+
const title = acp.label(
|
|
109
|
+
`上下文已用 ${String(context.percent)}% · ${compactTokenCount(context.used)} / ${compactTokenCount(context.size)}`,
|
|
110
|
+
`${String(context.percent)}% of context used · ${compactTokenCount(context.used)} / ${compactTokenCount(context.size)}`,
|
|
111
|
+
)
|
|
112
|
+
return (
|
|
113
|
+
<span className="ntr-acp-meter" role="img" aria-label={title} title={title}>
|
|
114
|
+
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
|
|
115
|
+
<circle className="ntr-acp-meter-track" cx="7" cy="7" r={RADIUS} />
|
|
116
|
+
<circle
|
|
117
|
+
className="ntr-acp-meter-fill"
|
|
118
|
+
cx="7"
|
|
119
|
+
cy="7"
|
|
120
|
+
r={RADIUS}
|
|
121
|
+
strokeDasharray={`${String(CIRCUMFERENCE * context.percent / 100)} ${String(CIRCUMFERENCE)}`}
|
|
122
|
+
transform="rotate(-90 7 7)"
|
|
123
|
+
/>
|
|
124
|
+
</svg>
|
|
125
|
+
<span className="ntr-acp-meter-percent">{context.percent}%</span>
|
|
126
|
+
</span>
|
|
127
|
+
)
|
|
128
|
+
}
|