@sqdr2/dsh-fusion 0.0.0-stage → 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/INSTALL.md +89 -0
- package/INSTALL.zh.md +86 -0
- package/LICENSE +201 -0
- package/README.en.md +193 -0
- package/README.md +182 -2
- package/RELEASING.md +123 -0
- package/RELEASING.zh.md +115 -0
- package/contracts/fusion.ts +279 -0
- package/contracts/storage.sql +86 -0
- package/cordis.patch.yml +3 -0
- package/docs/COMPATIBILITY.md +162 -0
- package/docs/EVIDENCE.md +141 -0
- package/dshx.yml +39 -0
- package/lib/client.js +1920 -0
- package/lib/client.js.map +1 -0
- package/lib/dsh-fusion.js +12664 -0
- package/lib/types/activity.d.ts +20 -0
- package/lib/types/approval/logical.d.ts +10 -0
- package/lib/types/benchmark/naive.d.ts +37 -0
- package/lib/types/benchmark/native-run.d.ts +68 -0
- package/lib/types/benchmark/output-limits.d.ts +19 -0
- package/lib/types/bilingual.d.ts +2 -0
- package/lib/types/client/ActivityRows.d.ts +8 -0
- package/lib/types/client/FusionCache.d.ts +4 -0
- package/lib/types/client/FusionHint.d.ts +9 -0
- package/lib/types/client/FusionHistory.d.ts +5 -0
- package/lib/types/client/FusionPanelIcon.d.ts +13 -0
- package/lib/types/client/FusionRecovery.d.ts +5 -0
- package/lib/types/client/FusionSettings.d.ts +9 -0
- package/lib/types/client/FusionStatus.d.ts +5 -0
- package/lib/types/client/FusionTool.d.ts +4 -0
- package/lib/types/client/activity-model.d.ts +14 -0
- package/lib/types/client/i18n.d.ts +15 -0
- package/lib/types/client/index.d.ts +22 -0
- package/lib/types/client/settings-events.d.ts +2 -0
- package/lib/types/client/status-polling.d.ts +9 -0
- package/lib/types/context/guard.d.ts +21 -0
- package/lib/types/context/handoff.d.ts +17 -0
- package/lib/types/contracts.d.ts +449 -0
- package/lib/types/digest.d.ts +5 -0
- package/lib/types/dsh-fusion.d.ts +13 -0
- package/lib/types/errors.d.ts +28 -0
- package/lib/types/evidence/gate.d.ts +23 -0
- package/lib/types/evidence/receipts.d.ts +65 -0
- package/lib/types/execution/write-lease.d.ts +42 -0
- package/lib/types/host/bindings.d.ts +30 -0
- package/lib/types/host/cache-defaults.d.ts +48 -0
- package/lib/types/host/cache-policy.d.ts +155 -0
- package/lib/types/host/change-evidence.d.ts +38 -0
- package/lib/types/host/compaction-routing.d.ts +15 -0
- package/lib/types/host/coordinator.d.ts +169 -0
- package/lib/types/host/evidence-page.d.ts +13 -0
- package/lib/types/host/history.d.ts +88 -0
- package/lib/types/host/http.d.ts +4 -0
- package/lib/types/host/model-output.d.ts +13 -0
- package/lib/types/host/native-activity.d.ts +26 -0
- package/lib/types/host/native-approvals.d.ts +29 -0
- package/lib/types/host/native-auxiliary.d.ts +17 -0
- package/lib/types/host/native-budget.d.ts +33 -0
- package/lib/types/host/native-checks.d.ts +104 -0
- package/lib/types/host/native-compaction.d.ts +8 -0
- package/lib/types/host/native-context.d.ts +36 -0
- package/lib/types/host/native-effects.d.ts +75 -0
- package/lib/types/host/native-evidence.d.ts +4 -0
- package/lib/types/host/native-exploration.d.ts +15 -0
- package/lib/types/host/native-keepalive.d.ts +82 -0
- package/lib/types/host/native-model-control.d.ts +99 -0
- package/lib/types/host/native-on-demand-context.d.ts +39 -0
- package/lib/types/host/native-request.d.ts +5 -0
- package/lib/types/host/native-review.d.ts +11 -0
- package/lib/types/host/native-role-sandbox.d.ts +49 -0
- package/lib/types/host/native-scopes.d.ts +49 -0
- package/lib/types/host/native-selection.d.ts +29 -0
- package/lib/types/host/native-task-context.d.ts +39 -0
- package/lib/types/host/native-usage.d.ts +46 -0
- package/lib/types/host/native-worker-brief.d.ts +74 -0
- package/lib/types/host/native-worker-notices.d.ts +15 -0
- package/lib/types/host/native-worker.d.ts +44 -0
- package/lib/types/host/native-workflow.d.ts +41 -0
- package/lib/types/host/route-transport.d.ts +22 -0
- package/lib/types/host/settings.d.ts +39 -0
- package/lib/types/host/shell.d.ts +11 -0
- package/lib/types/host/status.d.ts +7 -0
- package/lib/types/host/subagent-seam.d.ts +10 -0
- package/lib/types/host/tool-table.d.ts +35 -0
- package/lib/types/host/worker-terminal.d.ts +68 -0
- package/lib/types/host/workspace-guard.d.ts +13 -0
- package/lib/types/host/workspace.d.ts +52 -0
- package/lib/types/identity.d.ts +64 -0
- package/lib/types/index.d.ts +34 -0
- package/lib/types/message-source.d.ts +29 -0
- package/lib/types/profile/compactor.d.ts +5 -0
- package/lib/types/profile/keepalive.d.ts +3 -0
- package/lib/types/profile/resolve.d.ts +17 -0
- package/lib/types/prompts.d.ts +8 -0
- package/lib/types/recovery/reconcile.d.ts +28 -0
- package/lib/types/review/dispatch.d.ts +20 -0
- package/lib/types/review/protocol.d.ts +51 -0
- package/lib/types/review/ticket.d.ts +32 -0
- package/lib/types/status.d.ts +88 -0
- package/lib/types/store-migration.d.ts +29 -0
- package/lib/types/task/deletion.d.ts +64 -0
- package/lib/types/task/reducer.d.ts +6 -0
- package/lib/types/task/sqlite-store.d.ts +71 -0
- package/lib/types/task/state.d.ts +51 -0
- package/lib/types/task/status-index.d.ts +28 -0
- package/lib/types/task/store.d.ts +48 -0
- package/lib/types/usage/budget.d.ts +27 -0
- package/lib/types/usage/ledger.d.ts +66 -0
- package/lib/types/usage/normalize.d.ts +50 -0
- package/lib/types/usage/pricing.d.ts +18 -0
- package/package.json +178 -4
- package/profiles/fusion-auto.example.json +45 -0
- package/prompts/compact.md +7 -0
- package/prompts/lead.md +42 -0
- package/prompts/model-lead.md +13 -0
- package/prompts/model-worker.md +5 -0
- package/prompts/worker.md +21 -0
- package/src/activity.ts +23 -0
- package/src/approval/logical.ts +44 -0
- package/src/benchmark/naive.ts +216 -0
- package/src/benchmark/native-run.ts +215 -0
- package/src/benchmark/output-limits.ts +33 -0
- package/src/bilingual.ts +2 -0
- package/src/client/ActivityRows.tsx +59 -0
- package/src/client/FusionCache.tsx +130 -0
- package/src/client/FusionHint.tsx +39 -0
- package/src/client/FusionHistory.tsx +247 -0
- package/src/client/FusionPanelIcon.tsx +39 -0
- package/src/client/FusionRecovery.tsx +69 -0
- package/src/client/FusionSettings.tsx +205 -0
- package/src/client/FusionStatus.tsx +103 -0
- package/src/client/FusionTool.tsx +66 -0
- package/src/client/activity-model.ts +31 -0
- package/src/client/i18n.ts +30 -0
- package/src/client/index.ts +50 -0
- package/src/client/settings-events.ts +3 -0
- package/src/client/status-polling.ts +88 -0
- package/src/context/guard.ts +81 -0
- package/src/context/handoff.ts +28 -0
- package/src/contracts.ts +449 -0
- package/src/digest.ts +28 -0
- package/src/dsh-fusion.ts +321 -0
- package/src/errors.ts +34 -0
- package/src/evidence/gate.ts +290 -0
- package/src/evidence/receipts.ts +175 -0
- package/src/execution/write-lease.ts +187 -0
- package/src/host/bindings.ts +91 -0
- package/src/host/cache-defaults.ts +103 -0
- package/src/host/cache-policy.ts +168 -0
- package/src/host/change-evidence.ts +156 -0
- package/src/host/compaction-routing.ts +46 -0
- package/src/host/coordinator.ts +2160 -0
- package/src/host/evidence-page.ts +25 -0
- package/src/host/history.ts +114 -0
- package/src/host/http.ts +30 -0
- package/src/host/model-output.ts +22 -0
- package/src/host/native-activity.ts +199 -0
- package/src/host/native-approvals.ts +196 -0
- package/src/host/native-auxiliary.ts +19 -0
- package/src/host/native-budget.ts +112 -0
- package/src/host/native-checks.ts +474 -0
- package/src/host/native-compaction.ts +34 -0
- package/src/host/native-context.ts +204 -0
- package/src/host/native-effects.ts +260 -0
- package/src/host/native-evidence.ts +31 -0
- package/src/host/native-exploration.ts +114 -0
- package/src/host/native-keepalive.ts +292 -0
- package/src/host/native-model-control.ts +298 -0
- package/src/host/native-on-demand-context.ts +74 -0
- package/src/host/native-request.ts +12 -0
- package/src/host/native-review.ts +51 -0
- package/src/host/native-role-sandbox.ts +171 -0
- package/src/host/native-scopes.ts +203 -0
- package/src/host/native-selection.ts +110 -0
- package/src/host/native-task-context.ts +264 -0
- package/src/host/native-usage.ts +121 -0
- package/src/host/native-worker-brief.ts +122 -0
- package/src/host/native-worker-notices.ts +72 -0
- package/src/host/native-worker.ts +215 -0
- package/src/host/native-workflow.ts +103 -0
- package/src/host/route-transport.ts +54 -0
- package/src/host/settings.ts +93 -0
- package/src/host/shell.ts +13 -0
- package/src/host/status.ts +112 -0
- package/src/host/subagent-seam.ts +20 -0
- package/src/host/tool-table.ts +56 -0
- package/src/host/worker-terminal.ts +91 -0
- package/src/host/workspace-guard.ts +17 -0
- package/src/host/workspace.ts +184 -0
- package/src/identity.ts +76 -0
- package/src/index.ts +50 -0
- package/src/message-source.ts +31 -0
- package/src/profile/compactor.ts +26 -0
- package/src/profile/keepalive.ts +13 -0
- package/src/profile/resolve.ts +131 -0
- package/src/prompts.ts +32 -0
- package/src/recovery/reconcile.ts +38 -0
- package/src/review/dispatch.ts +48 -0
- package/src/review/protocol.ts +212 -0
- package/src/review/ticket.ts +166 -0
- package/src/status.ts +55 -0
- package/src/store-migration.ts +65 -0
- package/src/task/deletion.ts +105 -0
- package/src/task/reducer.ts +679 -0
- package/src/task/sqlite-store.ts +386 -0
- package/src/task/state.ts +72 -0
- package/src/task/status-index.ts +198 -0
- package/src/task/store.ts +287 -0
- package/src/usage/budget.ts +88 -0
- package/src/usage/ledger.ts +218 -0
- package/src/usage/normalize.ts +308 -0
- package/src/usage/pricing.ts +95 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { tr, useLang, type Lang } from './i18n.js'
|
|
3
|
+
import { FUSION_API_PATH } from '../identity.js'
|
|
4
|
+
|
|
5
|
+
type Mode = 'auto' | 'on' | 'off'
|
|
6
|
+
interface Totals { waits: number; waitHits: number; pings: number; pingHits: number; pingTokens: number; pingOutputTokens?: number; keptWarmTokens: number; resentTokens: number }
|
|
7
|
+
interface CacheModel {
|
|
8
|
+
provider: string; model: string; role?: 'lead' | 'worker'
|
|
9
|
+
mode: Mode; intervalSeconds: number
|
|
10
|
+
modeSource: 'user' | 'pair' | 'role' | 'route' | 'family' | 'generic'
|
|
11
|
+
intervalSource: 'user' | 'learned' | 'route' | 'family' | 'generic'
|
|
12
|
+
setting: { mode?: Mode; intervalSeconds?: number }
|
|
13
|
+
defaults: { mode: Mode; intervalSeconds: number; source: string
|
|
14
|
+
family?: { label: string; lifetime: { zh: string; en: string }; discount: string; docs: string }
|
|
15
|
+
route?: { label: string; reason: { zh: string; en: string } } }
|
|
16
|
+
totals: Totals | null; learnedIntervalSeconds: number | null; suggestion: number | null
|
|
17
|
+
recent: { gapSeconds: number; ping: boolean; hit: boolean }[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const tokens = (value: number) => value >= 1e6 ? `${(value / 1e6).toFixed(1)}M` : value >= 1e3 ? `${Math.round(value / 1e3)}k` : String(value)
|
|
21
|
+
const minutes = (seconds: number) => seconds % 60 ? `${(seconds / 60).toFixed(1)} min` : `${seconds / 60} min`
|
|
22
|
+
const modeName = (lang: Lang, mode: Mode) => ({ auto: tr(lang, '自动', 'Auto'), on: tr(lang, '开启', 'On'), off: tr(lang, '关闭', 'Off') })[mode]
|
|
23
|
+
|
|
24
|
+
function modeSource(lang: Lang, row: CacheModel): string {
|
|
25
|
+
switch (row.modeSource) {
|
|
26
|
+
case 'user': return tr(lang, '你的设置', 'your setting')
|
|
27
|
+
case 'pair': return tr(lang, '旧版组合设置', 'older pair setting')
|
|
28
|
+
case 'role': return tr(lang, 'Sidekick 默认关闭(它很少长时间等待)', 'Sidekick default off (it rarely waits long)')
|
|
29
|
+
case 'route': return `${row.defaults.route!.label}: ${lang === 'zh' ? row.defaults.route!.reason.zh : row.defaults.route!.reason.en}`
|
|
30
|
+
case 'family': return tr(lang, `${row.defaults.family!.label} 官方默认`, `${row.defaults.family!.label} documented default`)
|
|
31
|
+
default: return tr(lang, '通用默认', 'generic default')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function intervalSource(lang: Lang, row: CacheModel): string {
|
|
35
|
+
if (row.intervalSource === 'user') return tr(lang, '你的设置', 'your setting')
|
|
36
|
+
if (row.intervalSource === 'learned') return tr(lang, '自动学习:保活多次未命中,已缩短', 'learned: pings kept missing, shortened')
|
|
37
|
+
return tr(lang, '默认', 'default')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ModelCard({ row, onSaved }: { row: CacheModel; onSaved(): void }) {
|
|
41
|
+
const lang = useLang()
|
|
42
|
+
const [interval, setInterval] = useState(String(row.setting.intervalSeconds ? row.setting.intervalSeconds / 60 : ''))
|
|
43
|
+
const [busy, setBusy] = useState(false), [error, setError] = useState<string>()
|
|
44
|
+
const save = async (body: Record<string, unknown>) => {
|
|
45
|
+
setBusy(true); setError(undefined)
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(FUSION_API_PATH, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ action: 'cache-setting', provider: row.provider, model: row.model, ...body }) })
|
|
49
|
+
const result = await response.json() as { error?: string }
|
|
50
|
+
if (!response.ok) throw new Error(result.error ?? tr(lang, '保存失败', 'Save failed'))
|
|
51
|
+
onSaved()
|
|
52
|
+
} catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) }
|
|
53
|
+
finally { setBusy(false) }
|
|
54
|
+
}
|
|
55
|
+
const t = row.totals
|
|
56
|
+
const family = row.defaults.family
|
|
57
|
+
return <div className="fusion-cache-model">
|
|
58
|
+
<strong>{row.model}<small>{row.provider}{row.role ? ` · ${row.role === 'lead' ? 'Lead' : 'Sidekick'}` : ''}</small></strong>
|
|
59
|
+
<p>{tr(lang, '现在', 'Now')}: <b>{modeName(lang, row.mode)}</b>{row.mode !== 'off' && <>{tr(lang, ',每 ', ', every ')}<b>{minutes(row.intervalSeconds)}</b>{tr(lang, ' 续一次', '')}</>}
|
|
60
|
+
<small>{tr(lang, '开关来源', 'Mode')}: {modeSource(lang, row)} · {tr(lang, '间隔来源', 'Interval')}: {intervalSource(lang, row)}</small></p>
|
|
61
|
+
{family && <p><small>{tr(lang, '官方文档', 'Documented')}: {lang === 'zh' ? family.lifetime.zh : family.lifetime.en};{tr(lang, '缓存价约为全价', 'cached price ≈')} {family.discount} · <a href={family.docs} target="_blank" rel="noreferrer">{tr(lang, '来源', 'source')}</a></small></p>}
|
|
62
|
+
<div className="fusion-cache-controls">
|
|
63
|
+
<label>{tr(lang, '保活', 'Keepalive')}<select aria-label={`${row.model} keepalive`} disabled={busy} value={row.setting.mode ?? ''}
|
|
64
|
+
onChange={event => { void save({ mode: event.target.value || null, intervalSeconds: row.setting.intervalSeconds ?? null }) }}>
|
|
65
|
+
<option value="">{tr(lang, `默认(${modeName(lang, row.defaults.mode)})`, `Default (${modeName(lang, row.defaults.mode)})`)}</option>
|
|
66
|
+
<option value="auto">{tr(lang, '自动:该模型返回过缓存命中后才开启', 'Auto: only after this model reports cache hits')}</option>
|
|
67
|
+
<option value="on">{tr(lang, '开启', 'On')}</option>
|
|
68
|
+
<option value="off">{tr(lang, '关闭(按请求次数计费的套餐选这个)', 'Off (choose for plans billed per request)')}</option>
|
|
69
|
+
</select></label>
|
|
70
|
+
<label>{tr(lang, '间隔(分钟)', 'Interval (min)')}<input type="number" min="1" max="59" step="0.5" disabled={busy} value={interval}
|
|
71
|
+
placeholder={String(+(row.intervalSeconds / 60).toFixed(2))} onChange={event => setInterval(event.target.value)}
|
|
72
|
+
onBlur={() => { if (interval !== String(row.setting.intervalSeconds ? row.setting.intervalSeconds / 60 : ''))
|
|
73
|
+
void save({ mode: row.setting.mode ?? null, intervalSeconds: interval ? Math.round(Number(interval) * 60) : null }) }} /></label>
|
|
74
|
+
<button type="button" disabled={busy || (!row.setting.mode && !row.setting.intervalSeconds)} onClick={() => { setInterval(''); void save({ reset: true }) }}>{tr(lang, '恢复默认', 'Reset')}</button>
|
|
75
|
+
</div>
|
|
76
|
+
{row.suggestion && <p role="status">{tr(lang, `观察到等待 ${minutes(row.suggestion)} 以上缓存仍然命中,可以把间隔放宽到 ${minutes(row.suggestion)}(不会自动修改)。`,
|
|
77
|
+
`Cache still hit after waits over ${minutes(row.suggestion)}; you can lengthen the interval to ${minutes(row.suggestion)} (not changed automatically).`)}</p>}
|
|
78
|
+
{t ? <p>{tr(lang, '效果', 'Effect')}: {tr(lang, `等待后命中 ${t.waitHits}/${t.waits} 次`, `hit after ${t.waitHits}/${t.waits} waits`)}{t.waits ? ` (${Math.round(100 * t.waitHits / t.waits)}%)` : ''};
|
|
79
|
+
{tr(lang, ` 保活 ${t.pings} 次(${t.pingHits} 次命中,读取 ${tokens(t.pingTokens)} token,多为缓存价${t.pingOutputTokens != null ? `,输出 ${tokens(t.pingOutputTokens)} token` : ''});`, ` ${t.pings} pings (${t.pingHits} hit, ${tokens(t.pingTokens)} tokens read, mostly at cache price${t.pingOutputTokens != null ? `, ${tokens(t.pingOutputTokens)} output` : ''}); `)}
|
|
80
|
+
{tr(lang, ` 等待后仍从缓存读取 ${tokens(t.keptWarmTokens)} token,按全价重发 ${tokens(t.resentTokens)} token。`, ` after waits ${tokens(t.keptWarmTokens)} tokens served from cache, ${tokens(t.resentTokens)} resent at full price.`)}
|
|
81
|
+
{t.pings >= 5 && t.pingHits / t.pings < 0.5 && <b>{tr(lang, ' 保活大多没命中,建议关闭或缩短间隔。', ' Most pings missed: turn it off or shorten the interval.')}</b>}</p>
|
|
82
|
+
: <p>{tr(lang, '还没有数据:Lead 等待 Sidekick 之后才会产生记录。', 'No data yet: records appear after the Lead waits for the Sidekick.')}</p>}
|
|
83
|
+
{!!row.recent.length && <p><small>{tr(lang, '最近', 'Recent')}: {row.recent.map((item, i) => <span key={i}>{item.ping ? '↻' : ''}{minutes(item.gapSeconds)}{item.hit ? ' ✓' : ' ✗'}{i < row.recent.length - 1 ? ' · ' : ''}</span>)}</small></p>}
|
|
84
|
+
{error && <p role="alert">{error}</p>}
|
|
85
|
+
</div>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Per-model cache keepalive: what applies and why, the documented default, and what was observed. */
|
|
89
|
+
export function FusionCache({ refresh }: { refresh: number }) {
|
|
90
|
+
const lang = useLang()
|
|
91
|
+
const [rows, setRows] = useState<CacheModel[]>(), [checked, setChecked] = useState<string>(), [error, setError] = useState<string>()
|
|
92
|
+
const [reload, setReload] = useState(0)
|
|
93
|
+
const [open, setOpen] = useState(false)
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const abort = new AbortController()
|
|
96
|
+
void fetch(`${FUSION_API_PATH}?view=cache`, { credentials: 'same-origin', cache: 'no-store', signal: abort.signal })
|
|
97
|
+
.then(async response => { if (!response.ok) throw new Error(tr(lang, '无法读取缓存设置', 'Cannot read cache settings')); return response.json() as Promise<{ checked: string; models: CacheModel[] }> })
|
|
98
|
+
.then(value => { setRows(value.models); setChecked(value.checked) })
|
|
99
|
+
.catch(reason => { if (!abort.signal.aborted) setError(String(reason)) })
|
|
100
|
+
return () => abort.abort()
|
|
101
|
+
}, [refresh, reload, lang])
|
|
102
|
+
return <details
|
|
103
|
+
className="fusion-cache"
|
|
104
|
+
aria-label={tr(lang, '缓存保活', 'Cache keepalive')}
|
|
105
|
+
data-ud-check="fusion-cache"
|
|
106
|
+
{...(open ? { open: true } : {})}
|
|
107
|
+
onToggle={event => {
|
|
108
|
+
if (!event.target || event.target === event.currentTarget) {
|
|
109
|
+
setOpen(event.currentTarget.open)
|
|
110
|
+
}
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<summary className="fusion-cache-summary">
|
|
114
|
+
<span className="fusion-cache-title">
|
|
115
|
+
<h3>{tr(lang, '缓存保活', 'Cache keepalive')}</h3>
|
|
116
|
+
</span>
|
|
117
|
+
<span className="fusion-cache-indicator">
|
|
118
|
+
<small>{open ? tr(lang, '点击收起', 'Collapse') : tr(lang, '点击展开', 'Expand')}</small>
|
|
119
|
+
<span className="fusion-cache-chevron" aria-hidden="true" />
|
|
120
|
+
</span>
|
|
121
|
+
</summary>
|
|
122
|
+
<div className="fusion-cache-content">
|
|
123
|
+
<p>{tr(lang, 'Lead 等 Sidekick 干活时可能要等几分钟,模型的输入缓存过期后,下一次请求会按全价重读整段对话。保活在等待期间定时把 Lead 上一次的请求原样再发一遍,末尾只加一句“Reply OK”,模型只回一个 OK,缓存就不会过期。默认值来自各家官方文档,每个模型都可以单独调整。',
|
|
124
|
+
'While the Lead waits for the Sidekick, the model\'s prompt cache can expire and the next request re-reads the whole conversation at full price. Keepalive resends the Lead\'s previous request at intervals with only “Reply OK” appended; the model answers OK and the cache stays warm. Defaults come from each provider\'s documentation; every model can be adjusted.')}</p>
|
|
125
|
+
{checked && <p><small>{tr(lang, `官方默认值核对日期:${checked}。`, `Documented defaults checked on ${checked}.`)}</small></p>}
|
|
126
|
+
{error && <p role="alert">{error}</p>}
|
|
127
|
+
{rows?.map(row => <ModelCard key={`${row.provider}/${row.model}/${row.setting.mode ?? ''}/${row.setting.intervalSeconds ?? ''}`} row={row} onSaved={() => setReload(value => value + 1)} />)}
|
|
128
|
+
</div>
|
|
129
|
+
</details>
|
|
130
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { tr, useLang } from './i18n.js'
|
|
2
|
+
import { useEffect, useState, useSyncExternalStore } from 'react'
|
|
3
|
+
import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
4
|
+
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
|
5
|
+
import { onSettingsChanged } from './settings-events.js'
|
|
6
|
+
import { FusionStatus } from './FusionStatus.js'
|
|
7
|
+
import { FUSION_API_PATH, isFusionSelection } from '../identity.js'
|
|
8
|
+
|
|
9
|
+
export interface FusionHintProps { sessionId: string; directory: ObservableSnapshot<ModelDirectoryState>; load(): void }
|
|
10
|
+
|
|
11
|
+
/** Additive composer hint. The original selector and its selection state stay native. */
|
|
12
|
+
export function FusionHint({ sessionId, directory, load }: FusionHintProps) {
|
|
13
|
+
const selected = useSyncExternalStore(directory.subscribe, directory.getSnapshot).current
|
|
14
|
+
// A session that selected Fusion before the rename stored the old route.
|
|
15
|
+
const fusion = isFusionSelection(selected ?? undefined)
|
|
16
|
+
const lang = useLang()
|
|
17
|
+
const [message, setMessage] = useState<string>()
|
|
18
|
+
useEffect(() => { load() }, [load])
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!fusion) { setMessage(undefined); return }
|
|
21
|
+
let active = true
|
|
22
|
+
const abort = new AbortController()
|
|
23
|
+
const refresh = () => {
|
|
24
|
+
void fetch(`${FUSION_API_PATH}?view=settings`, { credentials: 'same-origin', cache: 'no-store', signal: abort.signal })
|
|
25
|
+
.then(async response => { if (!response.ok) throw new Error('unavailable'); return response.json() as Promise<{ pair: unknown; authorized: boolean }> })
|
|
26
|
+
.then(state => { if (active) setMessage(!state.pair ? tr(lang, 'Fusion 尚未配置模型,请前往设置 → Fusion 选择 Lead 和 Sidekick。', 'Fusion has no models yet: open Settings → Fusion and choose a Lead and a Sidekick.')
|
|
27
|
+
: !state.authorized ? tr(lang, 'Fusion 当前配置未就绪,请前往设置 → Fusion 查看。', 'The Fusion setup is not ready: see Settings → Fusion.') : undefined) })
|
|
28
|
+
.catch(() => { if (active) setMessage(tr(lang, '暂时无法确认 Fusion 配置,请前往设置 → Fusion 查看。', 'Cannot confirm the Fusion setup right now: see Settings → Fusion.')) })
|
|
29
|
+
}
|
|
30
|
+
refresh()
|
|
31
|
+
const off = onSettingsChanged(refresh)
|
|
32
|
+
return () => { active = false; abort.abort(); off() }
|
|
33
|
+
}, [fusion, lang])
|
|
34
|
+
if (!fusion) return null
|
|
35
|
+
return <>
|
|
36
|
+
{message && <p role="status" style={{ margin: '8px 12px', color: 'var(--dsw-alias-label-secondary)', fontSize: 12, lineHeight: '18px' }}>{message}</p>}
|
|
37
|
+
<FusionStatus sessionId={sessionId} attentionOnly />
|
|
38
|
+
</>
|
|
39
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { HistoryView, HistoryTotals } from '../host/history.js'
|
|
3
|
+
import { tr, useLang, type Lang } from './i18n.js'
|
|
4
|
+
import { FUSION_API_PATH } from '../identity.js'
|
|
5
|
+
|
|
6
|
+
export function formatCompact(tokens: number): string {
|
|
7
|
+
if (tokens < 1000) return `${tokens}`
|
|
8
|
+
if (tokens < 1_000_000) {
|
|
9
|
+
const k = tokens / 1000
|
|
10
|
+
return `${k >= 100 ? Math.round(k) : Number(k.toFixed(1))}K`
|
|
11
|
+
}
|
|
12
|
+
const m = tokens / 1_000_000
|
|
13
|
+
return `${m >= 100 ? Math.round(m) : Number(m.toFixed(m >= 10 ? 1 : 2))}M`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function outputTokens(lang: Lang, usage: HistoryTotals, calls: number): string {
|
|
17
|
+
if (!calls) return '—'
|
|
18
|
+
const total = usage.totalOutput
|
|
19
|
+
const hasTotal = Boolean(total && total.reported > 0)
|
|
20
|
+
const hasVisible = Boolean(usage.output && usage.output.reported > 0)
|
|
21
|
+
const hasReasoning = Boolean(usage.reasoning && usage.reasoning.reported > 0)
|
|
22
|
+
|
|
23
|
+
if (!hasTotal) {
|
|
24
|
+
if (hasVisible) {
|
|
25
|
+
const vis = formatCompact(usage.output.known)
|
|
26
|
+
const cov = usage.output.reported < calls ? tr(lang, `(${usage.output.reported}/${calls} 次有报告)`, ` (${usage.output.reported}/${calls} reported)`) : ''
|
|
27
|
+
return `${tr(lang, `至少 ${vis},推理未提供`, `at least ${vis}, reasoning unavailable`)}${cov}`
|
|
28
|
+
}
|
|
29
|
+
return tr(lang, '未提供', 'not reported')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const totalStr = formatCompact(total!.known)
|
|
33
|
+
const visibleStr = hasVisible ? formatCompact(usage.output.known) : tr(lang, '未提供', 'not reported')
|
|
34
|
+
const reasoningStr = hasReasoning ? formatCompact(usage.reasoning!.known) : tr(lang, '未提供', 'not reported')
|
|
35
|
+
const coverageStr = total!.reported < calls ? tr(lang, `(${total!.reported}/${calls} 次有报告)`, ` (${total!.reported}/${calls} reported)`) : ''
|
|
36
|
+
const splitStr = tr(lang, `(可见 ${visibleStr} + 推理 ${reasoningStr})`, ` (visible ${visibleStr} + reasoning ${reasoningStr})`)
|
|
37
|
+
return `${totalStr} ${splitStr}${coverageStr}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const takeoverReason = (lang: Lang, reason: string) => ({
|
|
41
|
+
'checks-still-failing': tr(lang, '返工后检查仍失败', 'checks still failing after rework'),
|
|
42
|
+
'worker-step-limit': tr(lang, 'Sidekick 步数用尽', 'Sidekick step limit'),
|
|
43
|
+
'worker-stalled': tr(lang, 'Sidekick 反复停滞', 'Sidekick stalled repeatedly'),
|
|
44
|
+
} as Record<string, string>)[reason] ?? reason
|
|
45
|
+
const tokens = (lang: Lang, field: HistoryTotals['input'], calls: number) => !calls ? '—' : !field.reported ? tr(lang, '未提供', 'not reported')
|
|
46
|
+
: `${formatCompact(field.known)} (${field.known.toLocaleString()})${field.reported < calls ? tr(lang, `(${field.reported}/${calls} 次有报告)`, ` (${field.reported}/${calls} reported)`) : ''}`
|
|
47
|
+
const purpose = (lang: Lang, value: string) => ({ conversation: tr(lang, '任务', 'task'), compaction: tr(lang, '压缩', 'compaction'),
|
|
48
|
+
'cache-keepalive': tr(lang, '保活', 'keepalive'), 'session-title': tr(lang, '标题', 'title') } as Record<string, string>)[value] ?? value
|
|
49
|
+
/** What still holds the task, so a refusal says why instead of only that it refused. */
|
|
50
|
+
const blockerText = (lang: Lang, blocker?: string) => ({
|
|
51
|
+
agent: tr(lang, 'Lead 或 Sidekick 仍在运行', 'its Lead or Sidekick is still running'),
|
|
52
|
+
tool: tr(lang, '有原生工具调用尚未返回', 'a native tool call has not returned'),
|
|
53
|
+
job: tr(lang, '有后台任务尚未停止', 'a background job has not stopped'),
|
|
54
|
+
lease: tr(lang, '仍持有工作区写入租约', 'it still holds the workspace write lease'),
|
|
55
|
+
'foreign-process': tr(lang, '另一个 Host 进程仍在处理它', 'another Host process is still working on it'),
|
|
56
|
+
} as Record<string, string>)[blocker ?? ''] ?? tr(lang, '仍有活动写入方', 'something is still writing to it')
|
|
57
|
+
|
|
58
|
+
export function FusionHistory() {
|
|
59
|
+
const lang = useLang()
|
|
60
|
+
const [page, setPage] = useState<HistoryView>()
|
|
61
|
+
const [cursor, setCursor] = useState('')
|
|
62
|
+
const [error, setError] = useState('')
|
|
63
|
+
const [refresh, setRefresh] = useState(0)
|
|
64
|
+
const [loading, setLoading] = useState(false)
|
|
65
|
+
const [confirmingId, setConfirmingId] = useState<string | null>(null)
|
|
66
|
+
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
67
|
+
const [rowError, setRowError] = useState<{ id: string; message: string } | null>(null)
|
|
68
|
+
const [open, setOpen] = useState(false)
|
|
69
|
+
const [tasksOpen, setTasksOpen] = useState(false)
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const abort = new AbortController()
|
|
73
|
+
setLoading(true); setError('')
|
|
74
|
+
void fetch(`${FUSION_API_PATH}?view=history&cursor=${encodeURIComponent(cursor)}`, { credentials: 'same-origin', cache: 'no-store', signal: abort.signal })
|
|
75
|
+
.then(async response => {
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const body = await response.json().catch(() => null) as { error?: string } | null
|
|
78
|
+
if (cursor && (response.status === 409 || body?.error?.includes('cursor') || body?.error?.includes('Cursor'))) {
|
|
79
|
+
setCursor('')
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
throw new Error(body?.error || tr(lang, '历史记录暂时无法读取', 'History is unavailable right now'))
|
|
83
|
+
}
|
|
84
|
+
return response.json() as Promise<HistoryView>
|
|
85
|
+
})
|
|
86
|
+
.then(data => { if (data) setPage(data) })
|
|
87
|
+
.catch(error => { if (!abort.signal.aborted) setError(String(error)) })
|
|
88
|
+
.finally(() => { if (!abort.signal.aborted) setLoading(false) })
|
|
89
|
+
return () => abort.abort()
|
|
90
|
+
}, [cursor, refresh, lang])
|
|
91
|
+
|
|
92
|
+
const handleDelete = async (taskId: string) => {
|
|
93
|
+
setDeletingId(taskId)
|
|
94
|
+
setRowError(null)
|
|
95
|
+
setError('')
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(`${FUSION_API_PATH}?taskId=${encodeURIComponent(taskId)}`, {
|
|
98
|
+
method: 'DELETE',
|
|
99
|
+
credentials: 'same-origin',
|
|
100
|
+
})
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
const data = await res.json().catch(() => null) as { error?: string; blocker?: string } | null
|
|
103
|
+
const raw = data?.error || ''
|
|
104
|
+
const msg = raw === 'Cannot delete a running task'
|
|
105
|
+
? tr(lang, `当前任务仍在运行或尚未安全终止,无法清除:${blockerText(lang, data?.blocker)}`,
|
|
106
|
+
`Cannot delete while it still runs: ${blockerText(lang, data?.blocker)}`)
|
|
107
|
+
: (raw || tr(lang, '清除任务失败', 'Failed to delete task'))
|
|
108
|
+
throw new Error(msg)
|
|
109
|
+
}
|
|
110
|
+
setPage(prev => prev ? {
|
|
111
|
+
...prev,
|
|
112
|
+
totalTasks: Math.max(0, prev.totalTasks - 1),
|
|
113
|
+
tasks: prev.tasks.filter(task => task.id !== taskId),
|
|
114
|
+
} : prev)
|
|
115
|
+
setConfirmingId(null)
|
|
116
|
+
setRowError(null)
|
|
117
|
+
if (cursor === taskId) setCursor('')
|
|
118
|
+
setRefresh(v => v + 1)
|
|
119
|
+
} catch (err) {
|
|
120
|
+
setRowError({ id: taskId, message: err instanceof Error ? err.message : String(err) })
|
|
121
|
+
} finally {
|
|
122
|
+
setDeletingId(null)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return <details
|
|
127
|
+
className="fusion-history"
|
|
128
|
+
aria-label={tr(lang, 'Fusion 历史与统计', 'Fusion history and statistics')}
|
|
129
|
+
data-ud-check="fusion-history"
|
|
130
|
+
{...(open ? { open: true } : {})}
|
|
131
|
+
onToggle={event => {
|
|
132
|
+
if (!event.target || event.target === event.currentTarget) {
|
|
133
|
+
setOpen(event.currentTarget.open)
|
|
134
|
+
}
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<summary className="fusion-history-summary">
|
|
138
|
+
<span className="fusion-history-title">
|
|
139
|
+
<h3>{tr(lang, '历史与统计', 'History and statistics')}</h3>
|
|
140
|
+
</span>
|
|
141
|
+
<span className="fusion-history-indicator">
|
|
142
|
+
<small>{open ? tr(lang, '点击收起', 'Collapse') : tr(lang, '点击展开', 'Expand')}</small>
|
|
143
|
+
<span className="fusion-history-chevron" aria-hidden="true" />
|
|
144
|
+
</span>
|
|
145
|
+
</summary>
|
|
146
|
+
<div className="fusion-history-content">
|
|
147
|
+
<div className="fusion-history-header">
|
|
148
|
+
<p>{tr(lang, '按实际模型请求汇总,包含任务、压缩、保活及已有标题请求。未报告的用量不算作 0;金额与节省率尚无可靠数据。',
|
|
149
|
+
'Totals of actual model requests: tasks, compaction, keepalive and title requests. Unreported usage is not counted as 0; no reliable billed amount or saving rate is available.')}</p>
|
|
150
|
+
<button disabled={loading} onClick={() => setRefresh(value => value + 1)}>{tr(lang, '刷新记录', 'Refresh')}</button>
|
|
151
|
+
</div>
|
|
152
|
+
{error && <p role="alert">{error}</p>}{loading && !page && <p role="status">{tr(lang, '正在读取记录…', 'Loading…')}</p>}
|
|
153
|
+
{page && <>
|
|
154
|
+
<div className="fusion-history-panel fusion-history-usage" aria-label={tr(lang, '用量统计', 'Usage statistics')}>
|
|
155
|
+
<h3>{tr(lang, '用量统计', 'Usage statistics')}</h3>
|
|
156
|
+
<p>{tr(lang, `${page.totalTasks} 项任务 · ${page.totals.calls} 次原生请求 · ${page.totals.final} 次用量定稿 · ${page.totals.provisional} 次暂报 · ${page.totals.unreported} 次未提供`,
|
|
157
|
+
`${page.totalTasks} tasks · ${page.totals.calls} native requests · ${page.totals.final} final usage · ${page.totals.provisional} provisional · ${page.totals.unreported} unreported`)}</p>
|
|
158
|
+
<p>{tr(lang, '未缓存输入', 'Uncached input')} {tokens(lang, page.totals.input, page.totals.calls)} · {tr(lang, '缓存读取', 'cache read')} {tokens(lang, page.totals.cacheRead, page.totals.calls)} · {tr(lang, '总输出', 'Total output')} {outputTokens(lang, page.totals, page.totals.calls)} Token</p>
|
|
159
|
+
<p>{tr(lang, `Lead 接手改代码 ${page.takeovers?.total ?? 0} 次(${page.takeovers?.tasks ?? 0} 项任务)`, `Lead takeovers: ${page.takeovers?.total ?? 0} (${page.takeovers?.tasks ?? 0} tasks)`)}
|
|
160
|
+
{page.takeovers?.total ? `: ${Object.entries(page.takeovers.reasons).map(([reason, count]) => `${takeoverReason(lang, reason)} ${count}`).join(', ')}` : ''}
|
|
161
|
+
{tr(lang, '。只有 Sidekick 确实完成不了时,程序才会解锁接手。', '. The Host unlocks a takeover only when the Sidekick demonstrably cannot finish.')}</p>
|
|
162
|
+
<div style={{ overflowX: 'auto' }} tabIndex={0} aria-label={tr(lang, '按实际模型统计', 'By actual model')}>
|
|
163
|
+
<table><caption>{tr(lang, '按角色、实际模型和请求用途', 'By role, actual model and purpose')}</caption><thead><tr>
|
|
164
|
+
<th scope="col">{tr(lang, '角色 / 模型', 'Role / model')}</th><th scope="col">{tr(lang, '用途', 'Purpose')}</th><th scope="col">{tr(lang, '请求', 'Requests')}</th><th scope="col">{tr(lang, '总输出 Token(可见 / 推理)', 'Total output tokens (visible / reasoning)')}</th></tr></thead>
|
|
165
|
+
<tbody>{page.groups.map(group => <tr key={JSON.stringify([group.role, group.provider, group.model, group.purpose])}>
|
|
166
|
+
<th scope="row">{group.role === 'lead' ? 'Lead' : 'Sidekick'} · {group.model}<small>{group.provider}</small></th>
|
|
167
|
+
<td>{purpose(lang, group.purpose)}</td><td>{group.calls}</td><td>{outputTokens(lang, group, group.calls)}</td>
|
|
168
|
+
</tr>)}</tbody>
|
|
169
|
+
</table>
|
|
170
|
+
</div>
|
|
171
|
+
<p><small>{tr(lang, `更新于 ${new Date(page.observedAt).toLocaleString()}。供应商内部重试次数未知。`, `Updated ${new Date(page.observedAt).toLocaleString()}. Provider-internal retries are unknown.`)}</small></p>
|
|
172
|
+
</div>
|
|
173
|
+
<details
|
|
174
|
+
className="fusion-history-panel fusion-history-tasks"
|
|
175
|
+
{...(tasksOpen ? { open: true } : {})}
|
|
176
|
+
onToggle={event => setTasksOpen(event.currentTarget.open)}
|
|
177
|
+
>
|
|
178
|
+
<summary className="fusion-history-tasks-summary">
|
|
179
|
+
<span className="fusion-history-tasks-title">
|
|
180
|
+
<strong>{tr(lang, '任务记录', 'Tasks')}</strong>{' '}
|
|
181
|
+
<span className="fusion-history-count">({page.totalTasks})</span>
|
|
182
|
+
</span>
|
|
183
|
+
<span className="fusion-history-tasks-indicator">
|
|
184
|
+
<small>{tasksOpen ? tr(lang, '点击收起', 'Collapse') : tr(lang, '点击展开', 'Expand')}</small>
|
|
185
|
+
<span className="fusion-history-tasks-chevron" aria-hidden="true" />
|
|
186
|
+
</span>
|
|
187
|
+
</summary>
|
|
188
|
+
<div className="fusion-history-tasks-content">
|
|
189
|
+
{!page.tasks.length && <p>{tr(lang, '还没有 Fusion 任务。', 'No Fusion tasks yet.')}</p>}
|
|
190
|
+
<ol>{page.tasks.map(task => <li key={task.id}>
|
|
191
|
+
<div className="fusion-history-item-header">
|
|
192
|
+
<div>
|
|
193
|
+
<strong>{task.title}</strong>
|
|
194
|
+
<small>
|
|
195
|
+
{new Date(task.createdAt).toLocaleString()} · {task.mode === 'completed' ? tr(lang, '已结束', 'finished') : task.phase} · {tr(lang, `${task.usage.calls} 次请求`, `${task.usage.calls} requests`)}{task.takeovers ? tr(lang, ` · Lead 接手 ${task.takeovers} 次`, ` · ${task.takeovers} Lead takeovers`) : ''}
|
|
196
|
+
</small>
|
|
197
|
+
</div>
|
|
198
|
+
<div className="fusion-history-actions">
|
|
199
|
+
{confirmingId === task.id ? (
|
|
200
|
+
<>
|
|
201
|
+
<span className="fusion-history-confirm-text">{tr(lang, '确定清除?', 'Delete?')}</span>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
disabled={Boolean(deletingId)}
|
|
205
|
+
onClick={() => void handleDelete(task.id)}
|
|
206
|
+
>
|
|
207
|
+
{deletingId === task.id ? tr(lang, '正在清除…', 'Deleting…') : tr(lang, '确认', 'Confirm')}
|
|
208
|
+
</button>
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
className="fusion-btn-subtle"
|
|
212
|
+
disabled={Boolean(deletingId)}
|
|
213
|
+
onClick={() => { setConfirmingId(null); setRowError(null) }}
|
|
214
|
+
>
|
|
215
|
+
{tr(lang, '取消', 'Cancel')}
|
|
216
|
+
</button>
|
|
217
|
+
</>
|
|
218
|
+
) : (
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
disabled={Boolean(deletingId)}
|
|
222
|
+
onClick={() => { setError(''); setRowError(null); setConfirmingId(task.id) }}
|
|
223
|
+
aria-label={tr(lang, `清除任务 ${task.title}`, `Delete task ${task.title}`)}
|
|
224
|
+
>
|
|
225
|
+
{tr(lang, '清除记录', 'Clear')}
|
|
226
|
+
</button>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
{rowError?.id === task.id && (
|
|
231
|
+
<p role="alert" className="fusion-history-row-error" style={{ color: 'var(--dsw-alias-state-error-primary, #e5484d)', margin: '6px 0 0', fontSize: '12px' }}>
|
|
232
|
+
{rowError.message}
|
|
233
|
+
</p>
|
|
234
|
+
)}
|
|
235
|
+
<details><summary>{tr(lang, '记录详情', 'Details')}</summary><p>{tr(lang, '任务', 'Task')}: {task.id}</p><p>{tr(lang, '会话', 'Session')}: {task.sessionId}</p>
|
|
236
|
+
<p>{tr(lang, `验证状态:${task.verification}。历史运行状态不代表当前仍在执行。`, `Verification: ${task.verification}. A recorded state does not mean it is still running.`)}</p>
|
|
237
|
+
<p>{tr(lang, '输入', 'Input')} {tokens(lang, task.usage.input, task.usage.calls)} · {tr(lang, '缓存读取', 'cache read')} {tokens(lang, task.usage.cacheRead, task.usage.calls)} · {tr(lang, '总输出', 'Total output')} {outputTokens(lang, task.usage, task.usage.calls)} Token</p>
|
|
238
|
+
</details>
|
|
239
|
+
</li>)}</ol>
|
|
240
|
+
<div className="fusion-actions"><button disabled={loading || !cursor} onClick={() => setCursor('')}>{tr(lang, '最新记录', 'Newest')}</button>
|
|
241
|
+
<button disabled={loading || !page.nextCursor} onClick={() => setCursor(page.nextCursor!)}>{tr(lang, '更早记录', 'Older')}</button></div>
|
|
242
|
+
</div>
|
|
243
|
+
</details>
|
|
244
|
+
</>}
|
|
245
|
+
</div>
|
|
246
|
+
</details>
|
|
247
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface FusionPanelIconProps {
|
|
4
|
+
/** Square size in pixels requested by the host sidebar (defaults to 16). */
|
|
5
|
+
size?: number
|
|
6
|
+
/** Whether the panel is currently active/selected in the main column. */
|
|
7
|
+
active?: boolean
|
|
8
|
+
className?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compact, accessible icon for the Fusion sidebar entry.
|
|
13
|
+
* Renders a dual-node convergence (Lead + Sidekick models fusing into one) SVG glyph.
|
|
14
|
+
*/
|
|
15
|
+
export function FusionPanelIcon({ size = 16, active = false, className }: FusionPanelIconProps): ReactNode {
|
|
16
|
+
return (
|
|
17
|
+
<svg
|
|
18
|
+
role="img"
|
|
19
|
+
aria-label="Fusion"
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
focusable="false"
|
|
22
|
+
viewBox="0 0 16 16"
|
|
23
|
+
width={size}
|
|
24
|
+
height={size}
|
|
25
|
+
fill="none"
|
|
26
|
+
stroke="currentColor"
|
|
27
|
+
strokeWidth={active ? 1.5 : 1.2}
|
|
28
|
+
strokeLinecap="round"
|
|
29
|
+
strokeLinejoin="round"
|
|
30
|
+
className={className}
|
|
31
|
+
>
|
|
32
|
+
<circle cx="4" cy="4.5" r="2" />
|
|
33
|
+
<circle cx="4" cy="11.5" r="2" />
|
|
34
|
+
<circle cx="12" cy="8" r="2" />
|
|
35
|
+
<path d="M6 4.5h1.5a3 3 0 0 1 2.7 1.7L10.5 6.8" />
|
|
36
|
+
<path d="M6 11.5h1.5a3 3 0 0 0 2.7-1.7l.3-.6" />
|
|
37
|
+
</svg>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { tr, useLang } from './i18n.js'
|
|
3
|
+
import type { FusionStatus } from '../status.js'
|
|
4
|
+
import type { PhysicalRoute } from '../contracts.js'
|
|
5
|
+
import type { SettingsCatalog } from '../host/settings.js'
|
|
6
|
+
import { ModelChoice } from './FusionSettings.js'
|
|
7
|
+
import { FUSION_API_PATH, isFusionProvider } from '../identity.js'
|
|
8
|
+
|
|
9
|
+
export function FusionRecovery({ sessionId, task }: { sessionId: string; task: NonNullable<FusionStatus['task']> }) {
|
|
10
|
+
const lang = useLang()
|
|
11
|
+
const control = task.modelControl!
|
|
12
|
+
const [role, setRole] = useState<'lead' | 'worker'>('lead')
|
|
13
|
+
const [route, setRoute] = useState<PhysicalRoute>()
|
|
14
|
+
const [catalog, setCatalog] = useState<SettingsCatalog>()
|
|
15
|
+
const [dueAt, setDueAt] = useState('')
|
|
16
|
+
const [busy, setBusy] = useState(false)
|
|
17
|
+
const [notice, setNotice] = useState('')
|
|
18
|
+
const [error, setError] = useState('')
|
|
19
|
+
const action = async (name: string) => {
|
|
20
|
+
setBusy(true); setError(''); setNotice('')
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(FUSION_API_PATH, { method: 'POST', credentials: 'same-origin',
|
|
23
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: name, sessionId, taskId: task.id,
|
|
24
|
+
revision: control.revision, role, route, ...(dueAt ? { dueAt: new Date(dueAt).toISOString() } : {}) }) })
|
|
25
|
+
const result = await response.json() as { error?: string }
|
|
26
|
+
if (!response.ok) throw new Error(result.error ?? tr(lang, '操作未完成,请刷新状态', 'Not completed; refresh the status'))
|
|
27
|
+
setNotice(name === 'schedule' ? tr(lang, '已设置一次定时继续。Host 需要运行;新消息会取消安排。', 'One scheduled continuation is set. The Host must be running; a new message cancels it.') : name === 'cancel-schedule' ? tr(lang, '已取消定时继续。', 'Scheduled continuation cancelled.') : tr(lang, '已提交继续操作,状态将自动更新。', 'Continuation submitted; the status updates automatically.'))
|
|
28
|
+
} catch (error) { setError(error instanceof Error ? error.message : String(error)) }
|
|
29
|
+
finally { setBusy(false) }
|
|
30
|
+
}
|
|
31
|
+
const loadCatalog = async () => {
|
|
32
|
+
setError('')
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(`${FUSION_API_PATH}?view=catalog`, { credentials: 'same-origin', cache: 'no-store' })
|
|
35
|
+
if (!response.ok) throw new Error(tr(lang, '无法读取模型列表', 'Cannot read the model list'))
|
|
36
|
+
const value = await response.json() as SettingsCatalog
|
|
37
|
+
setCatalog({ ...value, groups: value.groups.filter(group => !isFusionProvider(group.id)) })
|
|
38
|
+
} catch (error) { setError(String(error)) }
|
|
39
|
+
}
|
|
40
|
+
return <section className="fusion-recovery" aria-label={tr(lang, 'Fusion 恢复操作', 'Fusion recovery')}>
|
|
41
|
+
<h3>{tr(lang, '继续当前任务', 'Continue this task')}</h3>
|
|
42
|
+
{Object.entries(control.waits).filter(([, wait]) => wait?.taskId === task.id).map(([key, wait]) => <p key={key}>
|
|
43
|
+
{key === 'lead' ? 'Lead' : 'Sidekick'} · {wait!.code} · {wait!.code === 'NO_PROGRESS' ? tr(lang, '重复操作没有进展,请调整做法', 'Repeated steps made no progress; change the approach') : wait!.code === 'WORKFLOW_INCOMPLETE' ? tr(lang, '报告、委派或审查尚未完成,请检查任务后继续', 'A report, handoff or review is unfinished; check the task, then continue') : wait!.code.includes('QUOTA') ? tr(lang, '额度重置时间未知', 'Quota reset time unknown') : tr(lang, '需要处理模型错误', 'A model error needs attention')}
|
|
44
|
+
{wait!.retryNotBefore && <small>{tr(lang, '服务商建议重试时间', 'Provider retry hint')}: {new Date(wait!.retryNotBefore).toLocaleString()} {tr(lang, '(不代表额度重置)', '(not a quota reset)')}</small>}
|
|
45
|
+
</p>)}
|
|
46
|
+
{control.schedule && <p role="status">{tr(lang, '定时继续', 'Scheduled continuation')}: {new Date(control.schedule.dueAt).toLocaleString()} · {
|
|
47
|
+
({ scheduled: tr(lang, '已安排', 'scheduled'), cancelled: tr(lang, '已取消', 'cancelled'), fired: tr(lang, '已执行一次', 'ran once'), failed: tr(lang, '未能执行', 'failed') })[control.schedule.state]}{control.schedule.reason ? ` · ${control.schedule.reason}` : ''}</p>}
|
|
48
|
+
{control.operation && ['prepared', 'dispatching'].includes(control.operation.state) && <p role="alert">{tr(lang, '上次继续操作的投递结果尚未确认。请检查会话;系统不会自动重发。', 'Delivery of the last continuation is unconfirmed. Check the conversation; nothing is resent automatically.')}</p>}
|
|
49
|
+
{(task.recovering || task.outcomeUnknown) && <p role="alert">{tr(lang, '中断的操作尚未核对。继续和更换模型都不会生效,本次也不会切换模型。请先用 /fusion recover <检查记录> 提交核对,再继续或更换 Sidekick。设置里保存的新组合不会改写这个会话。', 'Interrupted operations are not inspected yet. Continue and model switching do nothing, and this attempt does not change the model. Record the inspection with /fusion recover <note>, then continue or switch the Sidekick. A new pair saved in Settings does not rewrite this conversation.')}</p>}
|
|
50
|
+
{error && <p role="alert">{error}</p>}{notice && <p role="status">{notice}</p>}
|
|
51
|
+
<fieldset disabled={busy || task.unsettledTools > 0 || task.pendingApprovals > 0 || task.recovering || task.outcomeUnknown}>
|
|
52
|
+
<button onClick={() => { void action('continue') }}>{tr(lang, '继续一次', 'Continue once')}</button>
|
|
53
|
+
<details><summary onClick={() => { if (!catalog) void loadCatalog() }}>{tr(lang, '更换角色模型并继续', 'Switch a role model and continue')}</summary>
|
|
54
|
+
<label>{tr(lang, '角色', 'Role')} <select value={role} onChange={event => { setRole(event.target.value as 'lead' | 'worker'); setRoute(undefined) }}>
|
|
55
|
+
<option value="lead">Lead</option><option value="worker">Sidekick</option>
|
|
56
|
+
</select></label>
|
|
57
|
+
{catalog && <ModelChoice role={role === 'lead' ? 'Lead' : 'Sidekick'} route={route} catalog={catalog} onChange={setRoute} />}
|
|
58
|
+
<p>{tr(lang, '仅更换当前会话的角色模型,保留任务与 Sidekick 会话。后续请求使用新模型的账号。', 'Changes this conversation\'s role model only; the task and the Sidekick session are kept. Later requests use the new model\'s account.')}</p>
|
|
59
|
+
<button disabled={!route} onClick={() => { void action('switch-role') }}>{tr(lang, '更换并继续一次', 'Switch and continue once')}</button>
|
|
60
|
+
</details>
|
|
61
|
+
<details><summary>{tr(lang, '定时继续一次', 'Continue once at a time')}</summary>
|
|
62
|
+
<label>{tr(lang, '本地时间', 'Local time')} <input aria-label={tr(lang, '定时继续时间', 'Continuation time')} type="datetime-local" value={dueAt} onChange={event => setDueAt(event.target.value)} /></label>
|
|
63
|
+
<p>{tr(lang, '按你选择的时间尝试一次;再次遇到额度错误会停止。此时间不代表服务商承诺的恢复时间。', 'Tries once at the time you choose and stops on another quota error. It is not a provider-promised reset time.')}</p>
|
|
64
|
+
<button disabled={!dueAt} onClick={() => { void action('schedule') }}>{tr(lang, '设置时间', 'Set time')}</button>
|
|
65
|
+
{control.schedule?.state === 'scheduled' && <button onClick={() => { void action('cancel-schedule') }}>{tr(lang, '取消安排', 'Cancel')}</button>}
|
|
66
|
+
</details>
|
|
67
|
+
</fieldset>
|
|
68
|
+
</section>
|
|
69
|
+
}
|