dsh-advisor-plugin 0.2.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +210 -0
  3. package/cordis.patch.yml +6 -0
  4. package/lib/advisor-prompt.d.ts +28 -0
  5. package/lib/advisor-prompt.js +91 -0
  6. package/lib/client/AdvisorCard.d.ts +15 -0
  7. package/lib/client/AdvisorCard.js +60 -0
  8. package/lib/client/AdvisorToolRow.d.ts +40 -0
  9. package/lib/client/AdvisorToolRow.js +52 -0
  10. package/lib/client/PatrolNodeView.d.ts +18 -0
  11. package/lib/client/PatrolNodeView.js +57 -0
  12. package/lib/client/controller.d.ts +161 -0
  13. package/lib/client/controller.js +287 -0
  14. package/lib/client/index.d.ts +20 -0
  15. package/lib/client/index.js +841 -0
  16. package/lib/client/locales.d.ts +54 -0
  17. package/lib/client/locales.js +101 -0
  18. package/lib/client/patrol-chat.d.ts +78 -0
  19. package/lib/client/patrol-chat.js +54 -0
  20. package/lib/client/store.d.ts +11 -0
  21. package/lib/client/store.js +23 -0
  22. package/lib/command.d.ts +7 -0
  23. package/lib/command.js +52 -0
  24. package/lib/config.d.ts +37 -0
  25. package/lib/config.js +83 -0
  26. package/lib/emission-guard.d.ts +32 -0
  27. package/lib/emission-guard.js +74 -0
  28. package/lib/gating.d.ts +8 -0
  29. package/lib/gating.js +48 -0
  30. package/lib/history.d.ts +20 -0
  31. package/lib/history.js +95 -0
  32. package/lib/index.d.ts +25 -0
  33. package/lib/index.js +96 -0
  34. package/lib/llm-call.d.ts +69 -0
  35. package/lib/llm-call.js +483 -0
  36. package/lib/patrol-event.d.ts +88 -0
  37. package/lib/patrol-event.js +68 -0
  38. package/lib/patrol.d.ts +51 -0
  39. package/lib/patrol.js +202 -0
  40. package/lib/prompt-section.d.ts +7 -0
  41. package/lib/prompt-section.js +16 -0
  42. package/lib/settings.d.ts +18 -0
  43. package/lib/settings.js +50 -0
  44. package/lib/tool.d.ts +22 -0
  45. package/lib/tool.js +91 -0
  46. package/package.json +91 -0
  47. package/src/advisor-prompt.ts +102 -0
  48. package/src/client/AdvisorCard.tsx +253 -0
  49. package/src/client/AdvisorToolRow.tsx +83 -0
  50. package/src/client/PatrolNodeView.tsx +85 -0
  51. package/src/client/controller.ts +399 -0
  52. package/src/client/index.ts +100 -0
  53. package/src/client/locales.ts +105 -0
  54. package/src/client/patrol-chat.ts +109 -0
  55. package/src/client/store.ts +29 -0
  56. package/src/command.ts +61 -0
  57. package/src/config.ts +113 -0
  58. package/src/emission-guard.ts +76 -0
  59. package/src/gating.ts +54 -0
  60. package/src/history.ts +102 -0
  61. package/src/index.ts +106 -0
  62. package/src/llm-call.ts +552 -0
  63. package/src/patrol-event.ts +115 -0
  64. package/src/patrol.ts +229 -0
  65. package/src/prompt-section.ts +21 -0
  66. package/src/settings.ts +74 -0
  67. package/src/tool.ts +114 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * AdvisorCard —— 设置页"插件"栏里的 Advisor 卡片(浏览器半)。
3
+ *
4
+ * 交互:provider 分组的模型单选列表(再次点击取消选择)+ 推理档位下拉 +
5
+ * 保存/放弃。数据与动作全部由 controller 通过 slot inject 面注入;
6
+ * 本组件纯渲染,样式内联(不依赖 css module 构建)。
7
+ */
8
+
9
+ import type { AdvisorCardFace, AdvisorCardState } from './controller.js'
10
+ import type { LocaleKey } from './locales.js'
11
+
12
+ /** 组件收到的 props:face 里的 hooks 被渲染器绑定成 use 选择器 hook */
13
+ export type AdvisorCardProps = Omit<AdvisorCardFace, 'hooks'> & {
14
+ useAdvisorCard: <S>(selector: (s: AdvisorCardState) => S) => S
15
+ t: (key: LocaleKey) => string
16
+ }
17
+
18
+ /** 巡逻模式区块(自动检测跑偏) */
19
+ function PatrolSection(props: AdvisorCardProps & {
20
+ enabled: boolean
21
+ everySteps: number
22
+ immuneTurns: number
23
+ investigate: boolean
24
+ disabled: boolean
25
+ }) {
26
+ const { t } = props
27
+ return (
28
+ <div style={{ ...style.card, background: 'rgba(128,128,128,.06)' }}>
29
+ <h4 style={{ ...style.title, fontSize: 13 }}>{t('patrolTitle')}</h4>
30
+ <p style={style.description}>{t('patrolDescription')}</p>
31
+ <div style={style.effortRow}>
32
+ <span>{t('patrolToggle')}</span>
33
+ <button
34
+ type="button"
35
+ role="switch"
36
+ aria-checked={props.enabled}
37
+ aria-label={t('patrolToggle')}
38
+ disabled={props.disabled}
39
+ onClick={props.togglePatrol}
40
+ style={{
41
+ ...style.button,
42
+ width: 40, height: 22, borderRadius: 11, padding: 0, position: 'relative',
43
+ background: props.enabled ? '#16a34a' : 'rgba(128,128,128,.4)', border: 'none',
44
+ }}
45
+ >
46
+ <span style={{
47
+ position: 'absolute', top: 2, left: props.enabled ? 20 : 2,
48
+ width: 18, height: 18, borderRadius: 9, background: '#fff', transition: 'left .15s',
49
+ }} />
50
+ </button>
51
+ </div>
52
+ {props.enabled
53
+ ? (
54
+ <div>
55
+ <div style={style.effortRow}>
56
+ <label htmlFor="advisor-patrol-steps">{t('patrolEverySteps')}</label>
57
+ <input
58
+ id="advisor-patrol-steps"
59
+ type="number"
60
+ min={2}
61
+ max={500}
62
+ value={props.everySteps}
63
+ disabled={props.disabled}
64
+ onChange={(event) => { props.setPatrolEverySteps(Number(event.target.value)) }}
65
+ style={{ width: 64 }}
66
+ />
67
+ </div>
68
+ <div style={style.effortRow}>
69
+ <label htmlFor="advisor-patrol-immune">{t('patrolImmuneTurns')}</label>
70
+ <input
71
+ id="advisor-patrol-immune"
72
+ type="number"
73
+ min={0}
74
+ max={20}
75
+ value={props.immuneTurns}
76
+ disabled={props.disabled}
77
+ onChange={(event) => { props.setPatrolImmuneTurns(Number(event.target.value)) }}
78
+ style={{ width: 64 }}
79
+ />
80
+ </div>
81
+ <div style={{ ...style.effortRow, marginTop: 8 }}>
82
+ <span>{t('investigateToggle')}</span>
83
+ <button
84
+ type="button"
85
+ role="switch"
86
+ aria-checked={props.investigate}
87
+ aria-label={t('investigateToggle')}
88
+ disabled={props.disabled}
89
+ onClick={props.toggleInvestigate}
90
+ style={{
91
+ ...style.button,
92
+ width: 40, height: 22, borderRadius: 11, padding: 0, position: 'relative',
93
+ background: props.investigate ? '#16a34a' : 'rgba(128,128,128,.4)', border: 'none',
94
+ }}
95
+ >
96
+ <span style={{
97
+ position: 'absolute', top: 2, left: props.investigate ? 20 : 2,
98
+ width: 18, height: 18, borderRadius: 9, background: '#fff', transition: 'left .15s',
99
+ }} />
100
+ </button>
101
+ </div>
102
+ <p style={{ ...style.notice, marginTop: -2 }}>{t('investigateHint')}</p>
103
+ </div>
104
+ )
105
+ : null}
106
+ <p style={{ ...style.notice, marginTop: -2 }}>{t('patrolEveryStepsHint')}</p>
107
+ </div>
108
+ )
109
+ }
110
+
111
+ const EFFORT_OPTIONS = ['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
112
+
113
+ // 内联样式(跟随设置页朴素风格,避免 css module 构建依赖)
114
+ const style = {
115
+ card: { border: '1px solid rgba(128,128,128,.35)', borderRadius: 8, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 10 } as const,
116
+ title: { fontSize: 14, fontWeight: 600, margin: 0 } as const,
117
+ description: { fontSize: 12, opacity: 0.75, margin: 0, lineHeight: 1.6 } as const,
118
+ current: { fontSize: 12, margin: 0 } as const,
119
+ groupTitle: { fontSize: 12, fontWeight: 600, opacity: 0.85, margin: '8px 0 2px' } as const,
120
+ row: { display: 'flex', alignItems: 'center', gap: 8, padding: '3px 0', fontSize: 13 } as const,
121
+ modelName: { display: 'flex', flexDirection: 'column' } as const,
122
+ route: { fontSize: 11, opacity: 0.65 } as const,
123
+ badge: { fontSize: 11, color: '#b45309' } as const,
124
+ notice: { fontSize: 12, opacity: 0.7, margin: 0 } as const,
125
+ error: { fontSize: 12, color: '#b91c1c', display: 'flex', gap: 8, alignItems: 'center' } as const,
126
+ effortRow: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginTop: 6 } as const,
127
+ actions: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 4 } as const,
128
+ button: { fontSize: 13, padding: '4px 14px', borderRadius: 6, cursor: 'pointer' } as const,
129
+ primary: { background: '#2563eb', color: '#fff', border: 'none' } as const,
130
+ secondary: { background: 'transparent', border: '1px solid rgba(128,128,128,.5)' } as const,
131
+ status: { fontSize: 12 } as const,
132
+ list: { maxHeight: 260, overflowY: 'auto', border: '1px solid rgba(128,128,128,.25)', borderRadius: 6, padding: '4px 10px' } as const,
133
+ }
134
+
135
+ export function AdvisorCard(props: AdvisorCardProps) {
136
+ const state = props.useAdvisorCard(s => s)
137
+ // 档位选项:目录声明了支持面 → 只列支持的;未声明 → 全档位(host 自动降级兜底)
138
+ const effortOptions: readonly string[] = state.efforts.known
139
+ ? ['', ...state.efforts.levels]
140
+ : EFFORT_OPTIONS
141
+ const disabled = !state.writable || state.saving
142
+ const armed = state.effective.provider !== '' && state.effective.model !== ''
143
+
144
+ return (
145
+ <div style={style.card}>
146
+ <h3 style={style.title}>{props.t('title')}</h3>
147
+ <p style={style.description}>{props.t('description')}</p>
148
+
149
+ <p style={style.current}>
150
+ {props.t('armed')}:
151
+ {armed
152
+ ? <strong>{`${state.effective.provider}/${state.effective.model}${state.effective.effort === '' ? '' : ` (${state.effective.effort})`}`}</strong>
153
+ : props.t('offCurrent')}
154
+ </p>
155
+
156
+ {!state.writable ? <p style={style.notice}>{props.t('readonly')}</p> : null}
157
+
158
+ {state.catalogStatus === 'loading' ? <p style={style.notice}>{props.t('loading')}</p> : null}
159
+ {state.catalogStatus === 'error'
160
+ ? (
161
+ <div style={style.error}>
162
+ <span>{props.t('loadFailed')}</span>
163
+ <button type="button" style={{ ...style.button, ...style.secondary }} disabled={disabled} onClick={props.retryCatalog}>
164
+ {props.t('retry')}
165
+ </button>
166
+ </div>
167
+ )
168
+ : null}
169
+ {state.catalogPartial ? <p style={style.notice}>{props.t('partial')}</p> : null}
170
+
171
+ {state.groups.length > 0
172
+ ? (
173
+ <div>
174
+ <p style={style.notice}>{props.t('choose')}</p>
175
+ <div style={style.list}>
176
+ {state.groups.map(group => (
177
+ <div key={group.id}>
178
+ <div style={style.groupTitle}>{group.name}</div>
179
+ {group.candidates.map(candidate => (
180
+ <label key={candidate.key} style={style.row}>
181
+ <input
182
+ type="radio"
183
+ name="advisor-route"
184
+ checked={candidate.selected}
185
+ disabled={disabled}
186
+ onChange={() => { props.pickRoute(candidate.key) }}
187
+ />
188
+ <span style={style.modelName}>
189
+ <span>{candidate.modelName}</span>
190
+ <span style={style.route}>{`${candidate.provider}/${candidate.model}`}</span>
191
+ </span>
192
+ {!candidate.available ? <span style={style.badge}>{props.t('unavailable')}</span> : null}
193
+ </label>
194
+ ))}
195
+ </div>
196
+ ))}
197
+ </div>
198
+ </div>
199
+ )
200
+ : state.catalogStatus === 'ready' ? <p style={style.notice}>{props.t('empty')}</p> : null}
201
+
202
+ <div style={style.effortRow}>
203
+ <label htmlFor="advisor-effort">{props.t('effort')}</label>
204
+ <select
205
+ id="advisor-effort"
206
+ value={state.effective.effort}
207
+ disabled={disabled}
208
+ onChange={(event) => { props.setEffort(event.target.value) }}
209
+ >
210
+ {/* 已声明支持面的模型只列支持的档位;未声明的列全档位(host 侧自动降级兜底) */}
211
+ {effortOptions.map(option => (
212
+ <option key={option} value={option}>
213
+ {option === '' ? props.t('effortDefault') : option}
214
+ </option>
215
+ ))}
216
+ </select>
217
+ </div>
218
+ <p style={{ ...style.notice, marginTop: -4 }}>
219
+ {state.efforts.known ? props.t('effortHintKnown') : props.t('effortHint')}
220
+ </p>
221
+
222
+ <PatrolSection
223
+ {...props}
224
+ enabled={state.patrol.enabled}
225
+ everySteps={state.patrol.everySteps}
226
+ immuneTurns={state.patrol.immuneTurns}
227
+ investigate={state.patrol.investigate}
228
+ disabled={disabled}
229
+ />
230
+
231
+ <div style={style.actions}>
232
+ <button
233
+ type="button"
234
+ style={{ ...style.button, ...style.primary }}
235
+ disabled={disabled || !state.dirty}
236
+ onClick={props.save}
237
+ >
238
+ {props.t('save')}
239
+ </button>
240
+ <button
241
+ type="button"
242
+ style={{ ...style.button, ...style.secondary }}
243
+ disabled={disabled || !state.dirty}
244
+ onClick={props.discard}
245
+ >
246
+ {props.t('discard')}
247
+ </button>
248
+ {state.saving ? <span style={style.status}>{props.t('saving')}</span> : null}
249
+ {state.failed ? <span style={{ ...style.status, color: '#b91c1c' }}>{props.t('saveFailed')}</span> : null}
250
+ </div>
251
+ </div>
252
+ )
253
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * AdvisorToolRow —— `advisor` 工具在会话消息流里的专属行(keyed
3
+ * `tool.call.toolview` 槽,官方给插件自有工具预留的定制点)。
4
+ *
5
+ * 折叠态:🧭 标题 + 进行中/完成/失败状态 + 审查模型路由;
6
+ * 展开态:审查建议全文(或错误说明)——用户点开即见"真的调了
7
+ * advisor、建议是什么"。
8
+ */
9
+
10
+ import { useState } from 'react'
11
+ import type { LocaleKey } from './locales.js'
12
+
13
+ /** 会话工具块的最小结构(RunningToolCall / settled result 的并集子集) */
14
+ interface AdvisorBlock {
15
+ kind?: unknown
16
+ callId?: unknown
17
+ call?: { argsRaw?: unknown } | undefined
18
+ argsRaw?: unknown
19
+ content?: readonly { type?: unknown, text?: unknown }[] | undefined
20
+ isError?: boolean | undefined
21
+ error?: { name?: unknown, code?: unknown, message?: unknown } | undefined
22
+ }
23
+
24
+ export type AdvisorToolRowProps = {
25
+ callId?: string
26
+ toolName?: string
27
+ block?: AdvisorBlock
28
+ cwd?: string
29
+ home?: string
30
+ openFile?: (path: string) => void
31
+ inspect?: () => void
32
+ t?: (key: LocaleKey) => string
33
+ }
34
+
35
+ const style = {
36
+ row: { display: 'flex', flexDirection: 'column', gap: 4, margin: '6px 0', border: '1px solid rgba(37,99,235,.35)', borderRadius: 8, overflow: 'hidden' } as const,
37
+ head: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', cursor: 'pointer', background: 'rgba(37,99,235,.08)', border: 'none', font: 'inherit', textAlign: 'left', width: '100%' } as const,
38
+ title: { fontWeight: 600, fontSize: 13, color: '#1d4ed8' } as const,
39
+ state: { fontSize: 12, opacity: 0.75 } as const,
40
+ badge: { fontSize: 11, padding: '1px 8px', borderRadius: 8, flex: 'none' } as const,
41
+ badgeRun: { background: 'rgba(37,99,235,.15)', color: '#1d4ed8' } as const,
42
+ badgeOk: { background: 'rgba(22,163,74,.15)', color: '#15803d' } as const,
43
+ badgeErr: { background: 'rgba(185,28,28,.12)', color: '#b91c1c' } as const,
44
+ body: { padding: '8px 12px', fontSize: 13, lineHeight: 1.65, whiteSpace: 'pre-wrap', borderTop: '1px solid rgba(37,99,235,.2)', maxHeight: 320, overflowY: 'auto' } as const,
45
+ }
46
+
47
+ function blockOutput(block: AdvisorBlock | undefined): { text: string, isError: boolean } {
48
+ if (block === undefined) return { text: '', isError: false }
49
+ const parts: string[] = []
50
+ for (const b of block.content ?? []) {
51
+ if (b?.type === 'text' && typeof b.text === 'string') parts.push(b.text)
52
+ else if (b !== undefined && b !== null) parts.push(JSON.stringify(b, null, 2))
53
+ }
54
+ if (parts.length === 0 && block.error !== undefined) {
55
+ const e = block.error
56
+ parts.push(`${typeof e.name === 'string' ? e.name : 'Error'}: ${typeof e.code === 'string' ? e.code : ''} ${typeof e.message === 'string' ? e.message : ''}`.trim())
57
+ }
58
+ return { text: parts.join('\n').trim(), isError: block.isError === true }
59
+ }
60
+
61
+ export function AdvisorToolRow(props: AdvisorToolRowProps) {
62
+ const t = props.t ?? ((key: LocaleKey) => key)
63
+ const [expanded, setExpanded] = useState(false)
64
+ const block = props.block ?? {}
65
+ const done = block.kind !== undefined
66
+ const { text, isError } = done ? blockOutput(block) : { text: '', isError: false }
67
+ const badge = !done
68
+ ? { label: t('toolRunning'), css: style.badgeRun }
69
+ : isError
70
+ ? { label: t('toolFailed'), css: style.badgeErr }
71
+ : { label: t('toolDone'), css: style.badgeOk }
72
+ const canExpand = done && text !== ''
73
+ return (
74
+ <div style={style.row}>
75
+ <button type="button" style={style.head} onClick={() => { if (canExpand) setExpanded(v => !v) }}>
76
+ <span style={style.title}>🧭 Advisor {t('toolTitle')}</span>
77
+ <span style={{ ...style.badge, ...badge.css }}>{badge.label}</span>
78
+ <span style={style.state}>{!done ? t('toolConsulting') : canExpand ? (expanded ? t('toolCollapse') : t('toolExpand')) : ''}</span>
79
+ </button>
80
+ {expanded && canExpand ? <div style={style.body}>{text}</div> : null}
81
+ </div>
82
+ )
83
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * PatrolNodeView —— 巡逻裁决在会话消息流里的 🧭 卡片(keyed
3
+ * `conversation.chat.node` 槽,key 'advisor-patrol'——该 kind 只有本
4
+ * 插件的 definition 会物化,槽格子无官方占位)。
5
+ *
6
+ * 三种形态:ON-TRACK 低调灰卡(默认收起,视野完整但不打扰)、
7
+ * 纠偏琥珀卡、叫停红卡。后两者展开即裁决全文——用户点开就见
8
+ * "巡逻真的判了什么、为什么"。unclear 不写事件,天然不出卡片。
9
+ */
10
+
11
+ import { useState } from 'react'
12
+ import type { PatrolNodeState } from './patrol-chat.js'
13
+ import type { LocaleKey } from './locales.js'
14
+
15
+ export type PatrolNodeViewProps = {
16
+ node?: { data?: PatrolNodeState } | undefined
17
+ t?: (key: LocaleKey) => string
18
+ }
19
+
20
+ /** 卡片配色(正常灰 / 纠偏琥珀 / 叫停红) */
21
+ interface Accent {
22
+ readonly border: string
23
+ readonly head: string
24
+ readonly title: string
25
+ readonly badge: { background: string, color: string }
26
+ }
27
+
28
+ const ACCENT_ON_TRACK: Accent = {
29
+ border: 'rgba(107,114,128,.35)',
30
+ head: 'rgba(107,114,128,.06)',
31
+ title: '#4b5563',
32
+ badge: { background: 'rgba(107,114,128,.14)', color: '#4b5563' },
33
+ }
34
+
35
+ const ACCENT_CORRECTION: Accent = {
36
+ border: 'rgba(217,119,6,.45)',
37
+ head: 'rgba(217,119,6,.09)',
38
+ title: '#b45309',
39
+ badge: { background: 'rgba(217,119,6,.16)', color: '#b45309' },
40
+ }
41
+
42
+ const ACCENT_STOP: Accent = {
43
+ border: 'rgba(185,28,28,.4)',
44
+ head: 'rgba(185,28,28,.07)',
45
+ title: '#b91c1c',
46
+ badge: { background: 'rgba(185,28,28,.12)', color: '#b91c1c' },
47
+ }
48
+
49
+ const style = {
50
+ row: (accent: Accent) => ({ display: 'flex', flexDirection: 'column', gap: 4, margin: '6px 0', border: `1px solid ${accent.border}`, borderRadius: 8, overflow: 'hidden' } as const),
51
+ head: (accent: Accent) => ({ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', cursor: 'pointer', background: accent.head, border: 'none', font: 'inherit', textAlign: 'left', width: '100%' } as const),
52
+ title: (accent: Accent) => ({ fontWeight: 600, fontSize: 13, color: accent.title } as const),
53
+ badge: { fontSize: 11, padding: '1px 8px', borderRadius: 8, flex: 'none' } as const,
54
+ meta: { fontSize: 12, opacity: 0.7 } as const,
55
+ body: { padding: '8px 12px', fontSize: 13, lineHeight: 1.65, whiteSpace: 'pre-wrap', borderTop: '1px solid rgba(120,113,108,.25)', maxHeight: 320, overflowY: 'auto' } as const,
56
+ }
57
+
58
+ export function PatrolNodeView(props: PatrolNodeViewProps) {
59
+ const t = props.t ?? ((key: LocaleKey) => key)
60
+ const [expanded, setExpanded] = useState(false)
61
+ const data = props.node?.data
62
+ if (data === undefined) return null
63
+ const onTrack = data.verdict === 'on-track'
64
+ const accent = onTrack ? ACCENT_ON_TRACK : data.verdict === 'stop' ? ACCENT_STOP : ACCENT_CORRECTION
65
+ const badge = onTrack ? t('patrolVerdictOnTrack') : data.verdict === 'stop' ? t('patrolVerdictStop') : t('patrolVerdictCorrection')
66
+ const title = onTrack ? t('patrolCheckTitle') : t('patrolCardTitle')
67
+ const canExpand = data.detail.trim() !== ''
68
+ const fmtTok = (n: number): string => (n >= 1000 ? `${Math.round(n / 100) / 10}k` : String(n))
69
+ const usageText = data.usage === undefined
70
+ ? ''
71
+ : ` · ↓${fmtTok(data.usage.input)}${data.usage.cacheRead !== undefined && data.usage.cacheRead > 0 ? `(缓存${fmtTok(data.usage.cacheRead)})` : ''} ↑${fmtTok(data.usage.output)} tok`
72
+ // 冷却降级标记:concern 在 immuneTurns 窗口内只出了卡片、未注入执行模型
73
+ const downgradedTag = data.downgraded === true ? ` · ${t('patrolDowngradedTag')}` : ''
74
+ return (
75
+ <div style={style.row(accent)}>
76
+ <button type="button" style={style.head(accent)} onClick={() => { if (canExpand) setExpanded(v => !v) }}>
77
+ <span style={style.title(accent)}>{`🧭 Advisor ${title}`}</span>
78
+ <span style={{ ...style.badge, ...accent.badge }}>{badge}</span>
79
+ <span style={style.meta}>{`${data.reviewer} · ${t('patrolCardStepPrefix')}${data.step}${t('patrolCardStepSuffix')} · ${Math.round(data.patrolMs / 100) / 10}s${usageText}${downgradedTag}`}</span>
80
+ <span style={style.meta}>{canExpand ? (expanded ? t('patrolCollapse') : t('patrolExpand')) : ''}</span>
81
+ </button>
82
+ {expanded && canExpand ? <div style={style.body}>{data.detail}</div> : null}
83
+ </div>
84
+ )
85
+ }