dsh-ntr 0.2.1 → 0.3.1

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.
@@ -17,9 +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 type { AcpAgentDef, AcpApprovalDeadlines, AcpCatalogChoice, AcpMcpServerDef, AgentSavePayload, AgentView, DetectedAgentInfo, PermissionTimeoutMode } from '../protocol.ts'
22
+ import type { AcpAgentDef, AcpApprovalDeadlines, AcpCatalogChoice, AcpMcpServerDef, AgentSavePayload, AgentView, DetectedAgentInfo, PermissionTimeoutMode, PluginUpdateState } from '../protocol.ts'
23
23
 
24
24
  interface WithStore { acp: AcpStore }
25
25
 
@@ -83,7 +83,7 @@ const STYLE = `
83
83
  }
84
84
  .dwa-page-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 28px; }
85
85
  .dwa-page-heading { min-width: 0; }
86
- .dwa-page-title { margin: 0; font-size: 20px; line-height: 1.35; font-weight: 550; letter-spacing: -0.02em; }
86
+ .dwa-page-title { margin: 0; font-size: 20px; line-height: 1.35; font-weight: 550; letter-spacing: -0.02em; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
87
87
  .dwa-page-description { margin: 5px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-sm); }
88
88
  .dwa-btn {
89
89
  min-height: 32px; box-sizing: border-box; font: inherit; border-radius: var(--ntr-radius-sm);
@@ -115,6 +115,20 @@ const STYLE = `
115
115
  .dwa-badge-success { background: var(--dsw-alias-state-success-tertiary, #dcfce7); color: var(--ntr-success); }
116
116
  .dwa-badge-warn { background: var(--dsw-alias-state-warn-tertiary, #fef3c7); color: var(--ntr-warn); }
117
117
  .dwa-badge-info { background: var(--dsw-alias-state-business-tertiary, #dbeafe); color: var(--dsw-alias-state-business-primary, #2563eb); }
118
+ /* The update badge is a button (it expands the update card): strip the
119
+ browser's default button chrome, keep the badge look. */
120
+ .dwa-update-badge { border: 0; font-family: inherit; cursor: pointer; }
121
+ .dwa-update-badge:hover { opacity: 0.85; }
122
+ /* The version badge doubles as the update entry: same badge look, no chrome. */
123
+ .dwa-version-btn { border: 0; font-family: inherit; cursor: pointer; }
124
+ .dwa-version-btn:hover { opacity: 0.85; }
125
+ .dwa-version { font-family: var(--ds-font-family-code, monospace); }
126
+ /* The update card sits between the page header and the permission mirror. */
127
+ .dwa-update-card { margin-bottom: 16px; }
128
+ .dwa-update-summary { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-weight: 550; }
129
+ .dwa-update-copy { margin: 6px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
130
+ .dwa-update-message { margin: 6px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-xs); white-space: pre-wrap; overflow-wrap: anywhere; }
131
+ .dwa-update-actions { margin-top: 10px; }
118
132
  .dwa-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
119
133
  .dwa-agent-list { display: flex; flex-direction: column; border-top: var(--ntr-line); }
120
134
  .dwa-agent-card { border-bottom: var(--ntr-line); min-width: 0; }
@@ -237,6 +251,17 @@ const STYLE = `
237
251
  .dwa-deadline-flag { margin-top: 4px; font-size: var(--ntr-font-xs); font-weight: 550; color: var(--ntr-warn); }
238
252
  .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; }
239
253
  .dwa-deadline-reset { display: flex; justify-content: flex-end; margin-top: 8px; }
254
+ /* Runtime log: one bounded list, newest first. The time column never wraps;
255
+ the message is the only cell that may break anywhere. */
256
+ .dwa-log-list { list-style: none; margin: 0; padding: 0; border: var(--ntr-line); border-radius: var(--ntr-radius); overflow: hidden; }
257
+ .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; }
258
+ .dwa-log-row:first-child { border-top: 0; }
259
+ .dwa-log-row.is-error { background: var(--dsw-alias-state-error-tertiary, #fef2f2); }
260
+ .dwa-log-time { color: var(--ntr-muted); white-space: nowrap; }
261
+ .dwa-log-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
262
+ .dwa-log-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ntr-danger); display: inline-block; margin-left: 6px; }
263
+ .dwa-log-level.is-info { background: var(--dsw-alias-state-business-tertiary, #dbeafe); color: var(--dsw-alias-state-business-primary, #2563eb); }
264
+ .dwa-log-level.is-error { background: var(--dsw-alias-state-error-tertiary, #fef2f2); color: var(--ntr-danger); }
240
265
  @media (max-width: 640px) {
241
266
  .dwa-root { padding: 20px 16px; }
242
267
  }
@@ -852,7 +877,170 @@ function ApprovalDeadlinesCard(props: { acp: AcpStore; ui: UiState }) {
852
877
  )
853
878
  }
854
879
 
855
- type SettingsPage = 'agents' | 'add' | 'approvals' | 'appearance' | 'diagnostics'
880
+ type SettingsPage = 'agents' | 'add' | 'approvals' | 'appearance' | 'logs' | 'diagnostics'
881
+
882
+ /**
883
+ * Local wall-clock `HH:MM:SS` for one runtime-log line. Hand-built on purpose:
884
+ * `toLocaleTimeString` may drop seconds or pick a 12-hour clock depending on
885
+ * the browser locale, which makes the column ragged and ambiguous.
886
+ */
887
+ function logClock(at: number): string {
888
+ const date = new Date(at)
889
+ return [date.getHours(), date.getMinutes(), date.getSeconds()]
890
+ .map(value => String(value).padStart(2, '0'))
891
+ .join(':')
892
+ }
893
+
894
+ /** The states that carry an update badge next to the version badge. */
895
+ function updateBadgeText(update: PluginUpdateState, acp: AcpStore): string | undefined {
896
+ if (update.status === 'available') {
897
+ return acp.label(`可更新 v${update.latest ?? update.current}`, `Update v${update.latest ?? update.current}`)
898
+ }
899
+ if (update.status === 'applying') return acp.label('更新中…', 'Updating…')
900
+ if (update.status === 'applied') {
901
+ const installed = update.installed ?? update.latest ?? update.current
902
+ return acp.label(`已更新 v${installed}`, `Updated v${installed}`)
903
+ }
904
+ if (update.status === 'restart-required') {
905
+ // Only a KNOWN disk version can be named. An interrupted install leaves
906
+ // `installed` unset, and a badge would have to invent a version.
907
+ if (update.installed === undefined) return undefined
908
+ // Neutral: the disk version can be newer OR older than the running one.
909
+ return acp.label(`待重启 v${update.installed}`, `Restart for v${update.installed}`)
910
+ }
911
+ return undefined
912
+ }
913
+
914
+ /**
915
+ * The version badge's update companion. Every badge is a button, `applying`
916
+ * included: the card is where the install's outcome is read, so the running
917
+ * install must be able to open it too. States without anything to act on get
918
+ * no badge at all — the version badge itself still opens the card.
919
+ */
920
+ function PluginUpdateBadge(props: { acp: AcpStore; update: PluginUpdateState; open: boolean; onToggle: () => void }) {
921
+ const { acp, update, open, onToggle } = props
922
+ const text = updateBadgeText(update, acp)
923
+ if (text === undefined) return null
924
+ return (
925
+ <button
926
+ type="button"
927
+ className="dwa-badge dwa-badge-info dwa-update-badge"
928
+ aria-expanded={open}
929
+ aria-controls="ntr-plugin-update-card"
930
+ onClick={onToggle}
931
+ >
932
+ {text}
933
+ </button>
934
+ )
935
+ }
936
+
937
+ /**
938
+ * The update card. In-page, no dialog: the settings page already owns the one
939
+ * action (install the exact version the host reported), and a modal on top of
940
+ * a settings section would hide the version it is updating.
941
+ *
942
+ * The copy stays to one line per state (constraint 33); the host's raw error
943
+ * (`message`) is the only long text, and it wraps on its own line so a stack
944
+ * trace or a registry error is readable without stretching the card.
945
+ */
946
+ function PluginUpdateCard(props: { acp: AcpStore; update: PluginUpdateState; onClose: () => void }) {
947
+ const { acp, update, onClose } = props
948
+ const applying = update.status === 'applying'
949
+ const latest = update.latest ?? update.current
950
+ const onDisk = update.installed !== undefined && update.installed !== update.current
951
+ ? update.installed
952
+ : undefined
953
+ const published = update.publishedAt !== undefined && !Number.isNaN(new Date(update.publishedAt).getTime())
954
+ ? new Date(update.publishedAt).toLocaleDateString()
955
+ : undefined
956
+ const copy = update.status === 'available'
957
+ ? acp.label('更新会卸载并重装插件,之后可能需要重启 DSH。', 'Updating uninstalls and reinstalls the plugin, and may require restarting DSH.')
958
+ : update.status === 'applied'
959
+ ? acp.label('已应用,刷新页面即可使用新版本。', 'Applied. Reload the page to use the new version.')
960
+ : update.status === 'restart-required'
961
+ ? update.installed !== undefined
962
+ ? acp.label(
963
+ `磁盘已是 v${update.installed},重启 DSH 后生效(桌面 App 退出重开;命令行重启 dsh web)。`,
964
+ `On disk v${update.installed}. Restart DSH to apply (quit and reopen the app, or restart dsh web).`,
965
+ )
966
+ : acp.label(
967
+ '更新可能已完成,请刷新页面确认当前版本。',
968
+ 'The update may already be complete. Refresh the page to confirm the current version.',
969
+ )
970
+ : update.status === 'busy'
971
+ ? acp.label('有进行中的对话,结束后才能更新。', 'Finish the running conversation before updating.')
972
+ : update.status === 'failed'
973
+ ? acp.label('更新失败。', 'Update failed.')
974
+ : update.status === 'current'
975
+ ? acp.label('已是最新版本。', 'Up to date.')
976
+ : update.status === 'source-install'
977
+ ? acp.label(
978
+ '源码或本地安装,无法通过 registry 更新。',
979
+ 'Source or local install; update it through its own checkout.',
980
+ )
981
+ : update.status === 'managed'
982
+ ? acp.label('此安装由宿主管理,不能在这里更新。', 'This install is managed by the host and cannot be updated here.')
983
+ : undefined
984
+ const showMessage = (update.status === 'failed' || update.status === 'busy' || update.status === 'managed')
985
+ && update.message !== undefined
986
+ const apply = async (): Promise<void> => {
987
+ if (update.latest === undefined) return
988
+ try {
989
+ await acp.applyPluginUpdate(update.latest)
990
+ } catch {
991
+ // The store keeps the "disk may have changed" state and writes its own notice.
992
+ }
993
+ }
994
+ return (
995
+ <div
996
+ id="ntr-plugin-update-card"
997
+ className="dwa-card dwa-update-card"
998
+ role="group"
999
+ aria-label={acp.label('插件更新', 'Plugin update')}
1000
+ >
1001
+ <div className="dwa-update-summary">
1002
+ <span>{acp.label(`当前 v${update.current}`, `Current v${update.current}`)}</span>
1003
+ {onDisk !== undefined && <span className="dwa-hint">{acp.label(`磁盘 v${onDisk}`, `On disk v${onDisk}`)}</span>}
1004
+ {update.latest !== undefined && (
1005
+ <>
1006
+ <span aria-hidden="true">→</span>
1007
+ <span>{acp.label(`最新 v${latest}`, `Latest v${latest}`)}</span>
1008
+ {published !== undefined && <span className="dwa-hint">{published}</span>}
1009
+ </>
1010
+ )}
1011
+ </div>
1012
+ {copy !== undefined && <p className="dwa-update-copy">{copy}</p>}
1013
+ {showMessage && <div className="dwa-update-message">{update.message}</div>}
1014
+ <div className="dwa-row-actions dwa-update-actions">
1015
+ {update.status === 'available' && (
1016
+ <button type="button" className="dwa-btn dwa-btn-primary" onClick={() => { void apply() }}>
1017
+ {acp.label('更新', 'Update')}
1018
+ </button>
1019
+ )}
1020
+ {applying && (
1021
+ <button type="button" className="dwa-btn dwa-btn-primary" disabled>
1022
+ {acp.label('正在更新…', 'Updating…')}
1023
+ </button>
1024
+ )}
1025
+ {update.status !== 'applying' && (
1026
+ <button type="button" className="dwa-btn" onClick={onClose}>{acp.label('关闭', 'Close')}</button>
1027
+ )}
1028
+ <button type="button" className="dwa-btn dwa-btn-quiet" disabled={applying} onClick={() => void acp.checkPluginUpdate(true)}>
1029
+ {acp.label('重新检查', 'Check again')}
1030
+ </button>
1031
+ </div>
1032
+ </div>
1033
+ )
1034
+ }
1035
+
1036
+ /**
1037
+ * The card is drawn for every state a host can report about this install,
1038
+ * except the two where there is no install to talk about at all.
1039
+ */
1040
+ function updateCardVisible(update: PluginUpdateState | undefined, open: boolean): boolean {
1041
+ if (update === undefined || !open) return false
1042
+ return update.status !== 'unsupported' && update.status !== 'not-installed'
1043
+ }
856
1044
 
857
1045
  export function AcpSettingsSection(props: WithStore) {
858
1046
  const acp = props.acp
@@ -862,6 +1050,17 @@ export function AcpSettingsSection(props: WithStore) {
862
1050
  const [page, setPage] = useState<SettingsPage>('agents')
863
1051
  const [addOpen, setAddOpen] = useState(false)
864
1052
  const [addTarget, setAddTarget] = useState<DetectedAgentInfo | undefined>(undefined)
1053
+ const [updateOpen, setUpdateOpen] = useState(false)
1054
+ // The store throttles this to one registry read per ten minutes, so every
1055
+ // remount (both settings entries mount this section) is cheap.
1056
+ useEffect(() => {
1057
+ void acp.checkPluginUpdate()
1058
+ }, [acp])
1059
+ // Opening the log is the read receipt. The store does not mutate at zero,
1060
+ // so this cannot loop.
1061
+ useEffect(() => {
1062
+ if (page === 'logs' && ui.logUnread > 0) acp.markLogsRead()
1063
+ }, [acp, page, ui.logUnread])
865
1064
  const { configured, available } = partitionAgents(ui.detectedAgents, ui.agents)
866
1065
  const selected = addTarget ?? available[0] ?? NEW_AGENT_SEED
867
1066
  const openAdd = (): void => {
@@ -869,12 +1068,49 @@ export function AcpSettingsSection(props: WithStore) {
869
1068
  setPage('add')
870
1069
  }
871
1070
  const presetProblem = ui.preset !== undefined && (!ui.preset.installed || ui.preset.problem !== undefined)
1071
+ const update = ui.pluginUpdate
1072
+ // A source install cannot be replaced by this browser; the version badge
1073
+ // says so on hover. Neutral wording: a checkout may be git or not.
1074
+ const versionTitle = update?.status === 'source-install'
1075
+ ? acp.label('源码或本地安装,无法通过 registry 更新。', 'Source or local install; update it through its own checkout.')
1076
+ : undefined
1077
+ // The running version is the update entry: every state with a real install
1078
+ // report can open the card, including "up to date" (which offers a re-check).
1079
+ const versionOpensUpdate = update !== undefined
1080
+ && update.status !== 'unsupported'
1081
+ && update.status !== 'not-installed'
872
1082
 
873
1083
  return (
874
1084
  <div className="dwa-root">
875
1085
  <header className="dwa-page-header">
876
1086
  <div className="dwa-page-heading">
877
- <h2 className="dwa-page-title">{acp.label('NTR 设置', 'NTR settings')}</h2>
1087
+ <h2 className="dwa-page-title" aria-label={acp.label('NTR 设置', 'NTR settings')}>
1088
+ {acp.label('NTR 设置', 'NTR settings')}
1089
+ {ui.pluginVersion !== undefined && (
1090
+ versionOpensUpdate
1091
+ ? (
1092
+ <button
1093
+ type="button"
1094
+ className="dwa-badge dwa-version dwa-version-btn"
1095
+ title={versionTitle}
1096
+ aria-expanded={updateOpen}
1097
+ aria-controls="ntr-plugin-update-card"
1098
+ onClick={() => setUpdateOpen(open => !open)}
1099
+ >
1100
+ {`v${ui.pluginVersion}`}
1101
+ </button>
1102
+ )
1103
+ : <span className="dwa-badge dwa-version" title={versionTitle}>{`v${ui.pluginVersion}`}</span>
1104
+ )}
1105
+ {update !== undefined && (
1106
+ <PluginUpdateBadge
1107
+ acp={acp}
1108
+ update={update}
1109
+ open={updateOpen}
1110
+ onToggle={() => setUpdateOpen(open => !open)}
1111
+ />
1112
+ )}
1113
+ </h2>
878
1114
  <p className="dwa-page-description">
879
1115
  {acp.label('连接智能体,设置你的对话偏好。', 'Connect agents and set your conversation preferences.')}
880
1116
  </p>
@@ -886,6 +1122,10 @@ export function AcpSettingsSection(props: WithStore) {
886
1122
  )}
887
1123
  </header>
888
1124
 
1125
+ {update !== undefined && updateCardVisible(update, updateOpen) && (
1126
+ <PluginUpdateCard acp={acp} update={update} onClose={() => setUpdateOpen(false)} />
1127
+ )}
1128
+
889
1129
  {/* The cross-session mirror: the conversation dock answers the asks
890
1130
  belonging to the open conversation; this card answers the rest. */}
891
1131
  {ledger.visible && (
@@ -942,6 +1182,10 @@ export function AcpSettingsSection(props: WithStore) {
942
1182
  <button type="button" aria-pressed={page === 'agents'} onClick={() => setPage('agents')}>{acp.label('智能体', 'Agents')}</button>
943
1183
  {ui.approvalDeadlines !== undefined && <button type="button" aria-pressed={page === 'approvals'} onClick={() => setPage('approvals')}>{acp.label('审批时限', 'Approval deadlines')}</button>}
944
1184
  <button type="button" aria-pressed={page === 'appearance'} onClick={() => setPage('appearance')}>{acp.label('对话外观', 'Appearance')}</button>
1185
+ <button type="button" aria-pressed={page === 'logs'} onClick={() => setPage('logs')}>
1186
+ {acp.label('日志', 'Logs')}
1187
+ {ui.logUnread > 0 && <span className="dwa-log-dot" aria-hidden="true" />}
1188
+ </button>
945
1189
  </nav>
946
1190
  {page === 'diagnostics' && (
947
1191
  <section className="dwa-section">
@@ -1122,6 +1366,45 @@ export function AcpSettingsSection(props: WithStore) {
1122
1366
  )}
1123
1367
  </div>
1124
1368
  </section>
1369
+ {/* Conditional on purpose: this list can hold 200 rows, and keeping it
1370
+ mounted through a `hidden` page would re-render every line on each
1371
+ `session/update`. It carries no local state, so remounting is free. */}
1372
+ {page === 'logs' && (
1373
+ <section className="dwa-section" aria-label={acp.label('运行日志', 'Runtime log')}>
1374
+ <div className="dwa-section-heading">
1375
+ <div>
1376
+ <h3 className="dwa-section-title">{acp.label('运行日志', 'Runtime log')}</h3>
1377
+ <p className="dwa-section-copy">
1378
+ {acp.label(
1379
+ `agent 运行消息,最多 ${String(LOG_MAX_ENTRIES)} 条。`,
1380
+ `Agent runtime messages, up to ${String(LOG_MAX_ENTRIES)}.`,
1381
+ )}
1382
+ </p>
1383
+ </div>
1384
+ {ui.logs.length > 0 && (
1385
+ <button type="button" className="dwa-btn dwa-btn-quiet" onClick={() => acp.clearLogs()}>
1386
+ {acp.label('清空', 'Clear')}
1387
+ </button>
1388
+ )}
1389
+ </div>
1390
+ {ui.logs.length === 0
1391
+ ? <div className="dwa-empty" role="status">{acp.label('暂无运行日志。', 'No runtime messages yet.')}</div>
1392
+ : (
1393
+ <ul className="dwa-log-list">
1394
+ {[...ui.logs].reverse().map(entry => (
1395
+ <li key={entry.id} className={entry.level === 'error' ? 'dwa-log-row is-error' : 'dwa-log-row'}>
1396
+ <span className="dwa-log-time dwa-mono">{logClock(entry.at)}</span>
1397
+ <span className={`dwa-badge dwa-log-level ${entry.level === 'error' ? 'is-error' : 'is-info'}`}>
1398
+ {entry.level === 'error' ? acp.label('错误', 'Error') : acp.label('信息', 'Info')}
1399
+ </span>
1400
+ {entry.agentId !== undefined && <span className="dwa-badge">{entry.agentId}</span>}
1401
+ <span className="dwa-log-text">{entry.text}</span>
1402
+ </li>
1403
+ ))}
1404
+ </ul>
1405
+ )}
1406
+ </section>
1407
+ )}
1125
1408
  <footer className="dwa-health">
1126
1409
  <div className="dwa-health-status">
1127
1410
  <span className="dwa-dot" style={{ background: ui.stream === 'live' ? 'var(--ntr-success)' : ui.stream === 'down' ? 'var(--ntr-danger)' : 'var(--ntr-muted)' }} />