dsh-ntr 0.3.0 → 0.3.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/README.en.md +1 -1
- package/README.md +1 -1
- package/cordis.patch.yml +17 -1
- package/lib/bridge.js +614 -25
- package/lib/client/acp-ui.js +103 -3
- package/lib/client/acp-view.js +24 -4
- package/lib/client/branch-action.js +133 -0
- package/lib/client/composer-mode.js +48 -13
- package/lib/client/context-meter.js +86 -0
- package/lib/client/index.js +238 -15
- package/lib/client/panel.js +127 -30
- 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 +403 -16
- package/lib/client.js +1267 -70
- package/lib/client.js.map +1 -1
- package/lib/impl.js +54 -1
- package/lib/index.js +7 -0
- package/lib/protocol.js +19 -0
- package/lib/types/bridge.d.ts +230 -7
- package/lib/types/client/acp-ui.d.ts +34 -2
- package/lib/types/client/acp-view.d.ts +13 -2
- package/lib/types/client/branch-action.d.ts +40 -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/released-banner.d.ts +34 -0
- package/lib/types/client/slash-isolation.d.ts +1 -1
- package/lib/types/client/store.d.ts +131 -1
- package/lib/types/impl.d.ts +41 -1
- package/lib/types/protocol.d.ts +130 -0
- package/lib/types/update.d.ts +7 -4
- package/lib/update.js +25 -10
- package/package.json +2 -1
- package/src/bridge.ts +645 -27
- package/src/client/acp-ui.tsx +123 -2
- package/src/client/acp-view.tsx +30 -5
- package/src/client/branch-action.tsx +160 -0
- package/src/client/composer-mode.tsx +59 -13
- package/src/client/context-meter.tsx +128 -0
- package/src/client/index.tsx +265 -14
- package/src/client/panel.tsx +229 -41
- 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 +463 -20
- package/src/impl.ts +84 -2
- package/src/index.ts +8 -0
- package/src/protocol.ts +129 -0
- package/src/update.ts +21 -8
package/src/client/panel.tsx
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
19
19
|
import { formatCommandLine, isAbsolutePathLike, splitCommandLine } from './acp-ui.tsx'
|
|
20
|
-
import { DEFAULT_KNOWN_PRESETS } from './store.ts'
|
|
20
|
+
import { DEFAULT_KNOWN_PRESETS, LOG_MAX_ENTRIES } from './store.ts'
|
|
21
21
|
import type { AcpStore, UiState } from './store.ts'
|
|
22
|
+
import { IDLE_RELEASE_MS } from '../protocol.ts'
|
|
22
23
|
import type { AcpAgentDef, AcpApprovalDeadlines, AcpCatalogChoice, AcpMcpServerDef, AgentSavePayload, AgentView, DetectedAgentInfo, PermissionTimeoutMode, PluginUpdateState } from '../protocol.ts'
|
|
23
24
|
|
|
24
25
|
interface WithStore { acp: AcpStore }
|
|
@@ -251,6 +252,17 @@ const STYLE = `
|
|
|
251
252
|
.dwa-deadline-flag { margin-top: 4px; font-size: var(--ntr-font-xs); font-weight: 550; color: var(--ntr-warn); }
|
|
252
253
|
.dwa-deadline-warn { margin-top: 10px; padding: 9px 12px; border-left: 3px solid var(--dsw-alias-state-warn-primary, #d97706); background: var(--dsw-alias-state-warn-secondary, #fffbeb); border-radius: var(--ntr-radius-sm); font-size: var(--ntr-font-xs); line-height: 1.55; }
|
|
253
254
|
.dwa-deadline-reset { display: flex; justify-content: flex-end; margin-top: 8px; }
|
|
255
|
+
/* Runtime log: one bounded list, newest first. The time column never wraps;
|
|
256
|
+
the message is the only cell that may break anywhere. */
|
|
257
|
+
.dwa-log-list { list-style: none; margin: 0; padding: 0; border: var(--ntr-line); border-radius: var(--ntr-radius); overflow: hidden; }
|
|
258
|
+
.dwa-log-row { display: flex; align-items: baseline; gap: 8px; padding: 6px 10px; font-size: var(--ntr-font-sm); border-top: var(--ntr-line); min-width: 0; }
|
|
259
|
+
.dwa-log-row:first-child { border-top: 0; }
|
|
260
|
+
.dwa-log-row.is-error { background: var(--dsw-alias-state-error-tertiary, #fef2f2); }
|
|
261
|
+
.dwa-log-time { color: var(--ntr-muted); white-space: nowrap; }
|
|
262
|
+
.dwa-log-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
263
|
+
.dwa-log-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ntr-danger); display: inline-block; margin-left: 6px; }
|
|
264
|
+
.dwa-log-level.is-info { background: var(--dsw-alias-state-business-tertiary, #dbeafe); color: var(--dsw-alias-state-business-primary, #2563eb); }
|
|
265
|
+
.dwa-log-level.is-error { background: var(--dsw-alias-state-error-tertiary, #fef2f2); color: var(--ntr-danger); }
|
|
254
266
|
@media (max-width: 640px) {
|
|
255
267
|
.dwa-root { padding: 20px 16px; }
|
|
256
268
|
}
|
|
@@ -445,20 +457,14 @@ function partitionAgents(
|
|
|
445
457
|
* def (env / npmPackage / exposedModels included); the synthesized fallback
|
|
446
458
|
* covers only the fields an `AgentView` carries, so anything else compares as
|
|
447
459
|
* "changed" — the honest answer when the host never reported it.
|
|
460
|
+
*
|
|
461
|
+
* The idle-release field is compared AS STORED: an absent per-agent field
|
|
462
|
+
* means "follow the global window", and the host's resolved value must not be
|
|
463
|
+
* written back into it — that would freeze the global window into this agent's
|
|
464
|
+
* definition and the global slider would then never reach it.
|
|
448
465
|
*/
|
|
449
|
-
/**
|
|
450
|
-
* The idle-release window in effect for an agent: an unset per-agent field is
|
|
451
|
-
* resolved by the HOST and reported back on `AgentView`, so filling it from
|
|
452
|
-
* there keeps "unset" and "explicitly the host default" the same state —
|
|
453
|
-
* merely opening the form must never read as an unsaved change.
|
|
454
|
-
*/
|
|
455
|
-
function resolvedIdleTimeoutMs(def: AcpAgentDef, activeAgent: AgentView | undefined): AcpAgentDef {
|
|
456
|
-
if (def.idleTimeoutMs !== undefined || activeAgent?.idleTimeoutMs === undefined) return def
|
|
457
|
-
return { ...def, idleTimeoutMs: activeAgent.idleTimeoutMs }
|
|
458
|
-
}
|
|
459
|
-
|
|
460
466
|
function persistedDef(detected: DetectedAgentInfo, activeAgent: AgentView | undefined): AcpAgentDef | undefined {
|
|
461
|
-
if (detected.currentConfig !== undefined) return
|
|
467
|
+
if (detected.currentConfig !== undefined) return detected.currentConfig
|
|
462
468
|
if (activeAgent === undefined) return undefined
|
|
463
469
|
return {
|
|
464
470
|
id: activeAgent.id,
|
|
@@ -468,7 +474,6 @@ function persistedDef(detected: DetectedAgentInfo, activeAgent: AgentView | unde
|
|
|
468
474
|
...(activeAgent.cwd === undefined ? {} : { cwd: activeAgent.cwd }),
|
|
469
475
|
allowedCwdRoots: [...activeAgent.allowedCwdRoots],
|
|
470
476
|
env: {},
|
|
471
|
-
...(activeAgent.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: activeAgent.idleTimeoutMs }),
|
|
472
477
|
}
|
|
473
478
|
}
|
|
474
479
|
|
|
@@ -490,9 +495,10 @@ export function payloadEquals(payload: AgentSavePayload, current: AcpAgentDef):
|
|
|
490
495
|
if (exposureKey(payload.exposedModels) !== exposureKey(current.exposedModels)) return false
|
|
491
496
|
if (mcpKey(payload.mcpServers) !== mcpKey(current.mcpServers)) return false
|
|
492
497
|
if ((payload.autoDiscoverModels !== false) !== (current.autoDiscoverModels !== false)) return false
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
|
|
498
|
+
// The per-agent OVERRIDE, compared as stored: an absent field on both sides
|
|
499
|
+
// means "follow the global window" and is not a change; a field on one
|
|
500
|
+
// side only means the user is adding or dropping an override.
|
|
501
|
+
if (payload.idleTimeoutMs !== current.idleTimeoutMs) return false
|
|
496
502
|
return true
|
|
497
503
|
}
|
|
498
504
|
|
|
@@ -866,7 +872,144 @@ function ApprovalDeadlinesCard(props: { acp: AcpStore; ui: UiState }) {
|
|
|
866
872
|
)
|
|
867
873
|
}
|
|
868
874
|
|
|
869
|
-
|
|
875
|
+
/**
|
|
876
|
+
* The idle-release window, as the settings page edits it.
|
|
877
|
+
*
|
|
878
|
+
* ONE control, because there is ONE window: how long a chat process may sit
|
|
879
|
+
* idle before it is parked. The slider snaps to the shared ladder
|
|
880
|
+
* ({@link IDLE_RELEASE_MS}), so every rung it offers is a window the host can
|
|
881
|
+
* run.
|
|
882
|
+
*
|
|
883
|
+
* The card reports the deployment baseline as the fact being overridden, and
|
|
884
|
+
* offers a reset ONLY when something is really overridden — the same rule the
|
|
885
|
+
* approval deadline card follows.
|
|
886
|
+
*/
|
|
887
|
+
function IdleReleaseCard(props: { acp: AcpStore; ui: UiState }) {
|
|
888
|
+
const { acp, ui } = props
|
|
889
|
+
const release = ui.idleRelease
|
|
890
|
+
// Older host half: no card rather than a number this browser invented.
|
|
891
|
+
if (release === undefined) return null
|
|
892
|
+
// The value a RESET returns to: the window `cordis.patch.yml` names, else
|
|
893
|
+
// the built-in default. The same split the approval card keeps — the
|
|
894
|
+
// reset must restore what the deployment asked for, not a constant.
|
|
895
|
+
const baseline = release.defaultIdleTimeoutMs
|
|
896
|
+
// A reset is only honest when something is actually being overridden.
|
|
897
|
+
const overridden = release.idleTimeoutMs !== baseline
|
|
898
|
+
// A window the deployment named that is not on the ladder still runs as
|
|
899
|
+
// configured: the thumb parks on the nearest rung and the readout prints the
|
|
900
|
+
// exact number, so the card never lies about what is in force.
|
|
901
|
+
const index = IDLE_RELEASE_MS.indexOf(release.idleTimeoutMs)
|
|
902
|
+
const exact = index >= 0
|
|
903
|
+
const shown = exact ? release.idleTimeoutMs : nearestIdleRung(release.idleTimeoutMs)
|
|
904
|
+
// The config file's window is worth saying only when it is being
|
|
905
|
+
// outranked — otherwise it IS what runs, and saying "the value set here
|
|
906
|
+
// wins" would name a win nobody claimed.
|
|
907
|
+
const note = release.configuredIdleTimeoutMs === undefined
|
|
908
|
+
|| release.configuredIdleTimeoutMs === release.idleTimeoutMs
|
|
909
|
+
? undefined
|
|
910
|
+
: acp.label(
|
|
911
|
+
`配置文件(cordis.patch.yml)设为 ${idleWindowText(release.configuredIdleTimeoutMs, acp)},此处设置优先。`,
|
|
912
|
+
`The config file (cordis.patch.yml) asks for ${idleWindowText(release.configuredIdleTimeoutMs, acp)}; the value set here wins.`,
|
|
913
|
+
)
|
|
914
|
+
const resetLabel = release.configuredIdleTimeoutMs === undefined
|
|
915
|
+
? acp.label('恢复默认值', 'Restore default')
|
|
916
|
+
: acp.label('恢复配置文件的值', 'Use config file value')
|
|
917
|
+
return (
|
|
918
|
+
<section className="dwa-section">
|
|
919
|
+
<div className="dwa-section-heading">
|
|
920
|
+
<div>
|
|
921
|
+
<h3 className="dwa-section-title">{acp.label('空闲释放', 'Idle release')}</h3>
|
|
922
|
+
<p className="dwa-section-copy">
|
|
923
|
+
{acp.label('空闲多久后挂起聊天进程。', 'How long an idle chat process is kept before it is parked.')}
|
|
924
|
+
</p>
|
|
925
|
+
</div>
|
|
926
|
+
</div>
|
|
927
|
+
<div className="dwa-preferences">
|
|
928
|
+
<div className="dwa-preference-block">
|
|
929
|
+
<div className="dwa-preference-label">{acp.label('释放窗口', 'Release window')}</div>
|
|
930
|
+
<div className="dwa-deadline-value">{idleWindowText(release.idleTimeoutMs, acp)}</div>
|
|
931
|
+
<input
|
|
932
|
+
className="dwa-deadline-range"
|
|
933
|
+
type="range"
|
|
934
|
+
min={0}
|
|
935
|
+
max={IDLE_RELEASE_MS.length - 1}
|
|
936
|
+
step={1}
|
|
937
|
+
value={exact ? index : IDLE_RELEASE_MS.indexOf(shown)}
|
|
938
|
+
aria-label={acp.label('释放窗口', 'Release window')}
|
|
939
|
+
onChange={event => {
|
|
940
|
+
const next = IDLE_RELEASE_MS[Number(event.target.value)]
|
|
941
|
+
if (next !== undefined && next !== release.idleTimeoutMs) void acp.setIdleRelease(next)
|
|
942
|
+
}}
|
|
943
|
+
/>
|
|
944
|
+
<div className="dwa-deadline-ticks">
|
|
945
|
+
{IDLE_RELEASE_MS.filter((_, i) => i % 2 === 0).map(ms => (
|
|
946
|
+
<span key={ms}>{idleWindowText(ms, acp)}</span>
|
|
947
|
+
))}
|
|
948
|
+
</div>
|
|
949
|
+
<div className="dwa-preference-copy">
|
|
950
|
+
{acp.label(
|
|
951
|
+
'挂起后会话仍保留,下一条消息自动恢复。',
|
|
952
|
+
'A parked session is kept; the next message resumes it.',
|
|
953
|
+
)}
|
|
954
|
+
</div>
|
|
955
|
+
{note !== undefined && <div className="dwa-deadline-flag">⚠ {note}</div>}
|
|
956
|
+
</div>
|
|
957
|
+
{overridden && (
|
|
958
|
+
<div className="dwa-deadline-reset">
|
|
959
|
+
<button type="button" className="dwa-btn dwa-btn-secondary" onClick={() => void acp.resetIdleRelease()}>
|
|
960
|
+
{resetLabel}
|
|
961
|
+
</button>
|
|
962
|
+
</div>
|
|
963
|
+
)}
|
|
964
|
+
</div>
|
|
965
|
+
</section>
|
|
966
|
+
)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* The rung nearest a window that is not on the ladder — used only to park the
|
|
971
|
+
* thumb; the readout always prints the exact value.
|
|
972
|
+
*/
|
|
973
|
+
function nearestIdleRung(ms: number): number {
|
|
974
|
+
let best = IDLE_RELEASE_MS[0]
|
|
975
|
+
let bestDistance = Number.POSITIVE_INFINITY
|
|
976
|
+
for (const rung of IDLE_RELEASE_MS) {
|
|
977
|
+
const distance = Math.abs(rung - ms)
|
|
978
|
+
if (distance < bestDistance) {
|
|
979
|
+
bestDistance = distance
|
|
980
|
+
best = rung
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return best
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* `30 分钟` — the readout and the tick label, one formatter.
|
|
988
|
+
*
|
|
989
|
+
* ALWAYS minutes, even at the top of the ladder: a scale that runs "1 分钟"
|
|
990
|
+
* to "50 分钟" and then "1 小时" reads as two different scales.
|
|
991
|
+
*/
|
|
992
|
+
function idleWindowText(ms: number, acp: AcpStore): string {
|
|
993
|
+
// 0 is the deployment's way of saying "never release", and reading it as
|
|
994
|
+
// "0 分钟" would say the opposite.
|
|
995
|
+
if (ms <= 0) return acp.label('永不释放', 'Never')
|
|
996
|
+
const minutes = Math.round(ms / 60_000)
|
|
997
|
+
return acp.label(`${String(minutes)} 分钟`, `${String(minutes)}m`)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
type SettingsPage = 'agents' | 'add' | 'approvals' | 'processes' | 'appearance' | 'logs' | 'diagnostics'
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Local wall-clock `HH:MM:SS` for one runtime-log line. Hand-built on purpose:
|
|
1004
|
+
* `toLocaleTimeString` may drop seconds or pick a 12-hour clock depending on
|
|
1005
|
+
* the browser locale, which makes the column ragged and ambiguous.
|
|
1006
|
+
*/
|
|
1007
|
+
function logClock(at: number): string {
|
|
1008
|
+
const date = new Date(at)
|
|
1009
|
+
return [date.getHours(), date.getMinutes(), date.getSeconds()]
|
|
1010
|
+
.map(value => String(value).padStart(2, '0'))
|
|
1011
|
+
.join(':')
|
|
1012
|
+
}
|
|
870
1013
|
|
|
871
1014
|
/** The states that carry an update badge next to the version badge. */
|
|
872
1015
|
function updateBadgeText(update: PluginUpdateState, acp: AcpStore): string | undefined {
|
|
@@ -1033,6 +1176,11 @@ export function AcpSettingsSection(props: WithStore) {
|
|
|
1033
1176
|
useEffect(() => {
|
|
1034
1177
|
void acp.checkPluginUpdate()
|
|
1035
1178
|
}, [acp])
|
|
1179
|
+
// Opening the log is the read receipt. The store does not mutate at zero,
|
|
1180
|
+
// so this cannot loop.
|
|
1181
|
+
useEffect(() => {
|
|
1182
|
+
if (page === 'logs' && ui.logUnread > 0) acp.markLogsRead()
|
|
1183
|
+
}, [acp, page, ui.logUnread])
|
|
1036
1184
|
const { configured, available } = partitionAgents(ui.detectedAgents, ui.agents)
|
|
1037
1185
|
const selected = addTarget ?? available[0] ?? NEW_AGENT_SEED
|
|
1038
1186
|
const openAdd = (): void => {
|
|
@@ -1153,7 +1301,12 @@ export function AcpSettingsSection(props: WithStore) {
|
|
|
1153
1301
|
<nav className="dwa-tabs" aria-label={acp.label('NTR 设置分类', 'NTR settings categories')}>
|
|
1154
1302
|
<button type="button" aria-pressed={page === 'agents'} onClick={() => setPage('agents')}>{acp.label('智能体', 'Agents')}</button>
|
|
1155
1303
|
{ui.approvalDeadlines !== undefined && <button type="button" aria-pressed={page === 'approvals'} onClick={() => setPage('approvals')}>{acp.label('审批时限', 'Approval deadlines')}</button>}
|
|
1304
|
+
{ui.idleRelease !== undefined && <button type="button" aria-pressed={page === 'processes'} onClick={() => setPage('processes')}>{acp.label('进程管理', 'Processes')}</button>}
|
|
1156
1305
|
<button type="button" aria-pressed={page === 'appearance'} onClick={() => setPage('appearance')}>{acp.label('对话外观', 'Appearance')}</button>
|
|
1306
|
+
<button type="button" aria-pressed={page === 'logs'} onClick={() => setPage('logs')}>
|
|
1307
|
+
{acp.label('日志', 'Logs')}
|
|
1308
|
+
{ui.logUnread > 0 && <span className="dwa-log-dot" aria-hidden="true" />}
|
|
1309
|
+
</button>
|
|
1157
1310
|
</nav>
|
|
1158
1311
|
{page === 'diagnostics' && (
|
|
1159
1312
|
<section className="dwa-section">
|
|
@@ -1285,6 +1438,7 @@ export function AcpSettingsSection(props: WithStore) {
|
|
|
1285
1438
|
</section>
|
|
1286
1439
|
|
|
1287
1440
|
<div hidden={page !== 'approvals'}><ApprovalDeadlinesCard acp={acp} ui={ui} /></div>
|
|
1441
|
+
<div hidden={page !== 'processes'}><IdleReleaseCard acp={acp} ui={ui} /></div>
|
|
1288
1442
|
|
|
1289
1443
|
<section className="dwa-section" hidden={page !== 'appearance'} aria-label={acp.label('对话外观', 'Appearance')}>
|
|
1290
1444
|
<div className="dwa-section-heading">
|
|
@@ -1334,6 +1488,45 @@ export function AcpSettingsSection(props: WithStore) {
|
|
|
1334
1488
|
)}
|
|
1335
1489
|
</div>
|
|
1336
1490
|
</section>
|
|
1491
|
+
{/* Conditional on purpose: this list can hold 200 rows, and keeping it
|
|
1492
|
+
mounted through a `hidden` page would re-render every line on each
|
|
1493
|
+
`session/update`. It carries no local state, so remounting is free. */}
|
|
1494
|
+
{page === 'logs' && (
|
|
1495
|
+
<section className="dwa-section" aria-label={acp.label('运行日志', 'Runtime log')}>
|
|
1496
|
+
<div className="dwa-section-heading">
|
|
1497
|
+
<div>
|
|
1498
|
+
<h3 className="dwa-section-title">{acp.label('运行日志', 'Runtime log')}</h3>
|
|
1499
|
+
<p className="dwa-section-copy">
|
|
1500
|
+
{acp.label(
|
|
1501
|
+
`agent 运行消息,最多 ${String(LOG_MAX_ENTRIES)} 条。`,
|
|
1502
|
+
`Agent runtime messages, up to ${String(LOG_MAX_ENTRIES)}.`,
|
|
1503
|
+
)}
|
|
1504
|
+
</p>
|
|
1505
|
+
</div>
|
|
1506
|
+
{ui.logs.length > 0 && (
|
|
1507
|
+
<button type="button" className="dwa-btn dwa-btn-quiet" onClick={() => acp.clearLogs()}>
|
|
1508
|
+
{acp.label('清空', 'Clear')}
|
|
1509
|
+
</button>
|
|
1510
|
+
)}
|
|
1511
|
+
</div>
|
|
1512
|
+
{ui.logs.length === 0
|
|
1513
|
+
? <div className="dwa-empty" role="status">{acp.label('暂无运行日志。', 'No runtime messages yet.')}</div>
|
|
1514
|
+
: (
|
|
1515
|
+
<ul className="dwa-log-list">
|
|
1516
|
+
{[...ui.logs].reverse().map(entry => (
|
|
1517
|
+
<li key={entry.id} className={entry.level === 'error' ? 'dwa-log-row is-error' : 'dwa-log-row'}>
|
|
1518
|
+
<span className="dwa-log-time dwa-mono">{logClock(entry.at)}</span>
|
|
1519
|
+
<span className={`dwa-badge dwa-log-level ${entry.level === 'error' ? 'is-error' : 'is-info'}`}>
|
|
1520
|
+
{entry.level === 'error' ? acp.label('错误', 'Error') : acp.label('信息', 'Info')}
|
|
1521
|
+
</span>
|
|
1522
|
+
{entry.agentId !== undefined && <span className="dwa-badge">{entry.agentId}</span>}
|
|
1523
|
+
<span className="dwa-log-text">{entry.text}</span>
|
|
1524
|
+
</li>
|
|
1525
|
+
))}
|
|
1526
|
+
</ul>
|
|
1527
|
+
)}
|
|
1528
|
+
</section>
|
|
1529
|
+
)}
|
|
1337
1530
|
<footer className="dwa-health">
|
|
1338
1531
|
<div className="dwa-health-status">
|
|
1339
1532
|
<span className="dwa-dot" style={{ background: ui.stream === 'live' ? 'var(--ntr-success)' : ui.stream === 'down' ? 'var(--ntr-danger)' : 'var(--ntr-muted)' }} />
|
|
@@ -1612,14 +1805,6 @@ interface AgentForm {
|
|
|
1612
1805
|
mcpText: string
|
|
1613
1806
|
}
|
|
1614
1807
|
|
|
1615
|
-
/**
|
|
1616
|
-
* Hand copy of `DEFAULT_TUNING.idleTimeoutMs` (src/bridge.ts): the browser
|
|
1617
|
-
* cannot import the host plane, and this number is only ever placeholder text
|
|
1618
|
-
* for a NOT-yet-configured agent (a configured one renders the host's resolved
|
|
1619
|
-
* value from `AgentView.idleTimeoutMs`). Keep the two in sync.
|
|
1620
|
-
*/
|
|
1621
|
-
const DEFAULT_IDLE_RELEASE_MS = 1_800_000
|
|
1622
|
-
|
|
1623
1808
|
/**
|
|
1624
1809
|
* Hand copy of `DEFAULT_TUNING.probeIdleTimeoutMs` (src/bridge.ts). A process
|
|
1625
1810
|
* that has never hosted a session — the model probe / post-save probe — lives
|
|
@@ -1648,8 +1833,11 @@ export function initialForm(detected: DetectedAgentInfo, activeAgent: AgentView
|
|
|
1648
1833
|
envRows: Object.entries(config?.env ?? {}).map(([key, value]) => ({ key, value })),
|
|
1649
1834
|
exposed: config?.exposedModels === undefined ? undefined : [...config.exposedModels],
|
|
1650
1835
|
autoDiscoverModels: config?.autoDiscoverModels !== false,
|
|
1651
|
-
//
|
|
1652
|
-
|
|
1836
|
+
// The agent's OWN override only. The host's resolved value is NOT
|
|
1837
|
+
// written back here: it would freeze the global window into this
|
|
1838
|
+
// agent's definition, and the global slider would then never reach it.
|
|
1839
|
+
// An empty field means "follow the global window".
|
|
1840
|
+
idleStr: idleText(config?.idleTimeoutMs),
|
|
1653
1841
|
// The MCP list comes back from the host in its NORMALIZED shape (defaults
|
|
1654
1842
|
// filled in), so re-serializing it reproduces exactly what is stored —
|
|
1655
1843
|
// that is what keeps the card from being permanently dirty.
|
|
@@ -1892,24 +2080,24 @@ function AgentDetails({
|
|
|
1892
2080
|
</div>
|
|
1893
2081
|
</div>
|
|
1894
2082
|
|
|
1895
|
-
{/* Idle release:
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
2083
|
+
{/* Idle release: an OVERRIDE of the global window (「进程管理」). Empty
|
|
2084
|
+
means "follow the global window", so the global slider reaches every
|
|
2085
|
+
agent that does not pin its own. TWO windows exist
|
|
2086
|
+
(AgentBridge.currentIdleTimeoutMs): this field is the chat window of
|
|
2087
|
+
a process that HAS hosted a session; a never-used process (a probe)
|
|
2088
|
+
always gets the short probe window and ignores this field — which is
|
|
2089
|
+
why the copy below names both. */}
|
|
1902
2090
|
<div className="dwa-form-row">
|
|
1903
2091
|
<div className="dwa-form-col">
|
|
1904
|
-
<label className="dwa-form-label">{acp.label('
|
|
2092
|
+
<label className="dwa-form-label">{acp.label('空闲释放覆盖(毫秒)', 'Idle Release Override (ms)')}</label>
|
|
1905
2093
|
<input
|
|
1906
2094
|
className="dwa-input dwa-input-mono"
|
|
1907
|
-
aria-label={acp.label('
|
|
2095
|
+
aria-label={acp.label('空闲释放覆盖(毫秒)', 'Idle Release Override (ms)')}
|
|
1908
2096
|
value={form.idleStr}
|
|
1909
2097
|
onChange={e => patch({ idleStr: e.target.value })}
|
|
1910
2098
|
placeholder={acp.label(
|
|
1911
|
-
`留空 =
|
|
1912
|
-
`Blank =
|
|
2099
|
+
`留空 = 跟随全局设置(探测进程固定 ${String(DEFAULT_PROBE_RELEASE_MS / 60_000)} 分钟)`,
|
|
2100
|
+
`Blank = follow the global setting (probes always release after ${String(DEFAULT_PROBE_RELEASE_MS / 60_000)} min)`,
|
|
1913
2101
|
)}
|
|
1914
2102
|
/>
|
|
1915
2103
|
<span className={idleInvalid ? 'dwa-error' : 'dwa-hint'}>
|
|
@@ -1921,8 +2109,8 @@ function AgentDetails({
|
|
|
1921
2109
|
: (
|
|
1922
2110
|
<>
|
|
1923
2111
|
{acp.label(
|
|
1924
|
-
'
|
|
1925
|
-
'
|
|
2112
|
+
'仅覆盖此智能体;留空即跟随「进程管理」的全局设置。',
|
|
2113
|
+
'Overrides this agent only; leave blank to follow the global setting in “Processes”.',
|
|
1926
2114
|
)}
|
|
1927
2115
|
</>
|
|
1928
2116
|
)}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type shim for `@deepseek-ai/dsh-client-ui-primitives` — a HOST-provided
|
|
3
|
+
* client module (resolved by the shell's lazy CJS module table at runtime, so
|
|
4
|
+
* it is intentionally absent from devDependencies and listed in
|
|
5
|
+
* tsdown.config.mjs's EXTERNAL + package.json `dsh.client.inject`).
|
|
6
|
+
*
|
|
7
|
+
* Only the members this bundle uses are declared, structurally: the host
|
|
8
|
+
* package is not part of a plugin's dependency closure, and the full surface
|
|
9
|
+
* would be a second copy of types this plugin cannot import anyway. The
|
|
10
|
+
* branch icons are the candidates the host's own branch button renders
|
|
11
|
+
* (`MessageIconActions`) across shipped host generations — desktop 0.2.1-rc.2
|
|
12
|
+
* exports `IconBranchOutlineRegular` (and `…Medium`) but NOT `…16`, while
|
|
13
|
+
* newer packages export `…16` — which is why `branch-action.tsx` picks the
|
|
14
|
+
* first present one instead of hard-importing a single name.
|
|
15
|
+
*/
|
|
16
|
+
declare module '@deepseek-ai/dsh-client-ui-primitives' {
|
|
17
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
18
|
+
|
|
19
|
+
export const IconBranchOutline16: ComponentType<Record<string, never>> | undefined
|
|
20
|
+
export const IconBranchOutlineRegular: ComponentType<Record<string, never>> | undefined
|
|
21
|
+
export const IconBranchOutlineMedium: ComponentType<Record<string, never>> | undefined
|
|
22
|
+
export const Tooltip: ComponentType<{
|
|
23
|
+
label: ReactNode
|
|
24
|
+
side?: 'top' | 'bottom' | 'left' | 'right'
|
|
25
|
+
children: ReactNode
|
|
26
|
+
}>
|
|
27
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conversation's released-session banner: one row above the composer
|
|
3
|
+
* (`conversation.input.dock`) saying the agent process behind this
|
|
4
|
+
* conversation has been released, with the explicit resume button.
|
|
5
|
+
*
|
|
6
|
+
* WHY A BANNER: releasing a process is deliberately silent (constraint 19 —
|
|
7
|
+
* an idle teardown is the expected lifecycle, no `agent/notice`), and the
|
|
8
|
+
* only other trace was the NTR view tab's status badge — a surface most
|
|
9
|
+
* users never open. The "your next message resumes it" promise is real but
|
|
10
|
+
* unverifiable from the chat itself, so the banner states it here and the
|
|
11
|
+
* button turns it into a gesture (`session/chat-resume`), the explicit form
|
|
12
|
+
* of the same recovery the next prompt performs lazily.
|
|
13
|
+
*
|
|
14
|
+
* The gate is a pure function ({@link releasedBannerVisible}) — the seam the
|
|
15
|
+
* composer's other visibility decisions use, so the rules are testable with
|
|
16
|
+
* no DOM. Like the agent tag and the context ring, an explicit
|
|
17
|
+
* `acpMode === false` outranks a lingering `chatSessions` row: a conversation
|
|
18
|
+
* that left NTR mode must not keep claiming a parked NTR process.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useState } from 'react'
|
|
22
|
+
import { ensureAcpStyle, useUi } from './acp-ui.tsx'
|
|
23
|
+
import type { AcpStore, UiState } from './store.ts'
|
|
24
|
+
|
|
25
|
+
export interface ReleasedBannerProps {
|
|
26
|
+
/** Injected face: the store plus the chat session id this banner belongs to. */
|
|
27
|
+
acp: AcpStore
|
|
28
|
+
sessionId: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether the banner should render: this conversation's ACP session records
|
|
33
|
+
* are all released — the process is parked (idle release or crash) and
|
|
34
|
+
* nothing is live to serve the next message yet. An empty list (no agent has
|
|
35
|
+
* run here) and a live row anywhere both mean "nothing to recover", so the
|
|
36
|
+
* banner stays out of the way.
|
|
37
|
+
*/
|
|
38
|
+
export function releasedBannerVisible(
|
|
39
|
+
ui: Pick<UiState, 'acpMode' | 'chatSessions' | 'chatSessionsFor'>,
|
|
40
|
+
dshSessionId: string,
|
|
41
|
+
): boolean {
|
|
42
|
+
if (ui.acpMode[dshSessionId] === false) return false
|
|
43
|
+
if (ui.chatSessionsFor !== dshSessionId) return false
|
|
44
|
+
return ui.chatSessions.length > 0 && ui.chatSessions.every(row => row.released === true)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ReleasedBanner({ acp, sessionId }: ReleasedBannerProps) {
|
|
48
|
+
ensureAcpStyle()
|
|
49
|
+
const ui = useUi(acp)
|
|
50
|
+
const dshSessionId = String(sessionId)
|
|
51
|
+
const [busy, setBusy] = useState(false)
|
|
52
|
+
const [error, setError] = useState<string>()
|
|
53
|
+
// The binding has no announcing frame on load (a conversation opened on an
|
|
54
|
+
// already-parked session shows nothing else that would read the list), so
|
|
55
|
+
// this entry reads it once itself — deduped in flight by the store.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
void acp.loadChatSessions(dshSessionId)
|
|
58
|
+
}, [acp, dshSessionId])
|
|
59
|
+
if (!releasedBannerVisible(ui, dshSessionId)) return null
|
|
60
|
+
const resume = (): void => {
|
|
61
|
+
if (busy) return
|
|
62
|
+
setBusy(true)
|
|
63
|
+
setError(undefined)
|
|
64
|
+
void acp.resumeChatSession(dshSessionId)
|
|
65
|
+
// `resumeChatSession` reports to the store's notice too, but that notice
|
|
66
|
+
// lives on the settings page; this pins the failure where the user is
|
|
67
|
+
// looking (same contract as the permission card).
|
|
68
|
+
.catch((failure: unknown) => {
|
|
69
|
+
setError(failure instanceof Error ? failure.message : String(failure))
|
|
70
|
+
})
|
|
71
|
+
.finally(() => { setBusy(false) })
|
|
72
|
+
}
|
|
73
|
+
return (
|
|
74
|
+
<div className="ntr-released-banner" role="region" aria-label={acp.label('进程已释放', 'Process released')}>
|
|
75
|
+
<div className="ntr-released-banner-row">
|
|
76
|
+
<div className="ntr-released-banner-text">
|
|
77
|
+
<span className="ntr-released-banner-title">{acp.label('进程已释放', 'Process released')}</span>
|
|
78
|
+
<span className="ntr-released-banner-body">
|
|
79
|
+
{acp.label('上下文已保留;下一条消息将自动恢复,或点击【恢复进程】按钮。', 'Context is kept — your next message resumes it, or click [Resume process].')}
|
|
80
|
+
</span>
|
|
81
|
+
</div>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="ntr-acp-more is-primary"
|
|
85
|
+
disabled={busy}
|
|
86
|
+
title={acp.label('立即拉起 agent 进程并恢复这个会话', 'Bring the agent process back and restore this session now')}
|
|
87
|
+
onClick={resume}
|
|
88
|
+
>
|
|
89
|
+
{busy ? acp.label('正在恢复…', 'Resuming…') : acp.label('恢复进程', 'Resume process')}
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
{error !== undefined && (
|
|
93
|
+
<div className="ntr-permission-error" role="alert">{error}</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -295,9 +295,20 @@ export function describeRoute(
|
|
|
295
295
|
// pick a conversation set up ahead of its first message could not show the
|
|
296
296
|
// model and thought level it is about to use, which is the whole point of
|
|
297
297
|
// choosing them early.
|
|
298
|
+
//
|
|
299
|
+
// A RELEASED row flips the precedence: it only describes what ran when the
|
|
300
|
+
// process was parked, while the pick is what the next resume applies
|
|
301
|
+
// (`applyChatChoice`) — a newer pick must outrank it, or a pre-switch on a
|
|
302
|
+
// parked session looks like a dead control (the chip snaps back to the
|
|
303
|
+
// parked value on every re-read). The parked row still beats nothing, so a
|
|
304
|
+
// conversation that never picked keeps naming what it ran.
|
|
298
305
|
const pick = choice !== undefined && choice.agentId === agentId ? choice : undefined
|
|
299
|
-
const modelId = bound?.
|
|
300
|
-
|
|
306
|
+
const modelId = bound?.released === true
|
|
307
|
+
? (pick?.modelId ?? bound?.modelId)
|
|
308
|
+
: (bound?.modelId ?? pick?.modelId)
|
|
309
|
+
const effort = bound?.released === true
|
|
310
|
+
? (pick?.effort ?? bound?.reasoningEffort)
|
|
311
|
+
: (bound?.reasoningEffort ?? pick?.effort)
|
|
301
312
|
const reasoning = catalog?.reasoning
|
|
302
313
|
// The model the AGENT says it is on, for a conversation nobody has picked a
|
|
303
314
|
// model for yet. Without it the chip has no label and does not render at all,
|
|
@@ -554,6 +565,16 @@ export function AcpSessionControls(props: AcpClusterProps) {
|
|
|
554
565
|
useEffect(() => {
|
|
555
566
|
if (served && agentListed && agentId !== undefined) void acp.loadAgentCatalog(agentId)
|
|
556
567
|
}, [acp, served, agentListed, agentId])
|
|
568
|
+
// The picker could already be open when the first turn starts (clicking the
|
|
569
|
+
// send button is inside the composer card, which AcpMenu treats as its own
|
|
570
|
+
// furniture, so it does not close the menu). Once locked, that menu can only
|
|
571
|
+
// offer a switch the host will refuse — close it instead of leaving a live
|
|
572
|
+
// control over a locked conversation. Kept above every early return so the
|
|
573
|
+
// component never changes its hook order between renders.
|
|
574
|
+
useEffect(() => {
|
|
575
|
+
if (lockedTo === undefined || menu === null) return
|
|
576
|
+
if (menu.sessionId === sessionId && menu.kind === 'agent') closeAcpMenu()
|
|
577
|
+
}, [lockedTo, menu?.sessionId, menu?.kind, sessionId])
|
|
557
578
|
const route = describeRoute(
|
|
558
579
|
agents,
|
|
559
580
|
bound,
|
|
@@ -579,15 +600,14 @@ export function AcpSessionControls(props: AcpClusterProps) {
|
|
|
579
600
|
// base never comes back, and the tag is the only place the agent's name
|
|
580
601
|
// lives.
|
|
581
602
|
//
|
|
582
|
-
// The
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}, [lockedTo, menu?.sessionId, menu?.kind, sessionId])
|
|
603
|
+
// The gate releases this cell when the conversation leaves ACP mode, and it
|
|
604
|
+
// only ever installs the claim for a session whose verdict it has already
|
|
605
|
+
// folded as TRUE — so the occupant requires that same affirmative fact. A
|
|
606
|
+
// claim held for one more evaluation (or a layout with more than one visible
|
|
607
|
+
// session) must never put NTR chips on an ordinary conversation's bar.
|
|
608
|
+
// Placed AFTER every hook: an early return above a hook would change the
|
|
609
|
+
// hook order between renders.
|
|
610
|
+
if (ui.acpMode[sessionId] !== true) return null
|
|
591
611
|
// Keep the base mounted for the shatter half even though the lock already
|
|
592
612
|
// retired it: unmounting on the same commit the lock arrives would skip the
|
|
593
613
|
// transition entirely (the tag waits for the same phase to leave 'shatter').
|
|
@@ -1008,6 +1028,9 @@ export function AcpSessionMenu(props: AcpClusterProps) {
|
|
|
1008
1028
|
}, [acp, menu, served, agentId])
|
|
1009
1029
|
|
|
1010
1030
|
if (menu === null || menu.sessionId !== sessionId) return null
|
|
1031
|
+
// A stale picker must not outlive the conversation's ACP mode (the gate
|
|
1032
|
+
// releases the view/tab, but the overlay is unconditionally registered).
|
|
1033
|
+
if (ui.acpMode[sessionId] !== true) return null
|
|
1011
1034
|
const route = describeRoute(
|
|
1012
1035
|
agents,
|
|
1013
1036
|
bound,
|
|
@@ -131,8 +131,9 @@ export function slashAcpVerdict(
|
|
|
131
131
|
snapshot: AcpSessionsSnapshot | undefined,
|
|
132
132
|
sessionId: string,
|
|
133
133
|
verdict: (sessionId: string) => boolean | undefined,
|
|
134
|
+
currentSessionId?: string,
|
|
134
135
|
): boolean | undefined {
|
|
135
|
-
const staged = stagedSessionOf(snapshot)
|
|
136
|
+
const staged = stagedSessionOf(snapshot, currentSessionId)
|
|
136
137
|
if (staged !== undefined && staged.sessionId === sessionId) {
|
|
137
138
|
const listed = rowAgentPreset(staged.row)
|
|
138
139
|
if (listed !== undefined) return listed === ACP_PRESET_ID
|