@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,205 @@
|
|
|
1
|
+
import { FusionHistory } from './FusionHistory.js'
|
|
2
|
+
import { FusionCache } from './FusionCache.js'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import type { PhysicalRoute } from '../contracts.js'
|
|
5
|
+
import type { PairChoice, SettingsCatalog } from '../host/settings.js'
|
|
6
|
+
import { settingsChanged } from './settings-events.js'
|
|
7
|
+
import { tr, useLang } from './i18n.js'
|
|
8
|
+
import { FUSION_API_PATH } from '../identity.js'
|
|
9
|
+
|
|
10
|
+
interface SettingsState {
|
|
11
|
+
revision: number
|
|
12
|
+
catalog: SettingsCatalog
|
|
13
|
+
pair: PairChoice | null
|
|
14
|
+
authorized: boolean
|
|
15
|
+
managedAuthorization?: boolean
|
|
16
|
+
reason?: string
|
|
17
|
+
}
|
|
18
|
+
const styles = `
|
|
19
|
+
.fusion-page{box-sizing:border-box;width:100%;height:100%;min-width:0;min-height:0;flex:1 1 auto;overflow:auto;display:flex;flex-direction:column;align-items:center;scrollbar-gutter:stable;padding:0 clamp(24px,4vw,48px) 48px;--dsh-scrollbar-width:9px;--dsh-scrollbar-thumb-border:2px}
|
|
20
|
+
.fusion-settings{box-sizing:border-box;width:100%;max-width:680px;margin:0 auto;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:18px}
|
|
21
|
+
.fusion-settings h2{margin:0;font-size:22px;line-height:30px;font-weight:600}
|
|
22
|
+
.fusion-settings p{margin:0;font-size:13px;line-height:21px;color:var(--dsw-alias-label-secondary)}
|
|
23
|
+
.fusion-settings header{display:flex;flex-direction:column;gap:8px;padding-top:28px}
|
|
24
|
+
[data-platform='darwin'] .fusion-settings header{padding-top:calc(28px + var(--dsh-frame-top-clearance,0px))}
|
|
25
|
+
.fusion-settings .fusion-card{padding:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;display:flex;flex-direction:column;gap:20px;background:var(--dsw-alias-bg-module-platform)}
|
|
26
|
+
.fusion-settings fieldset{padding:0;margin:0;border:0;min-inline-size:0;display:flex;flex-direction:column;gap:10px}
|
|
27
|
+
.fusion-settings legend{font-size:15px;font-weight:600;padding:0 0 4px}
|
|
28
|
+
.fusion-settings label{display:grid;grid-template-columns:85px minmax(0,1fr);align-items:center;gap:12px;font-size:13px}
|
|
29
|
+
.fusion-settings :is(select,option,optgroup){background-color:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);color-scheme:inherit}
|
|
30
|
+
.fusion-settings select{box-sizing:border-box;min-width:0;width:100%;min-height:36px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;padding:5px 9px;font:inherit}
|
|
31
|
+
.fusion-settings .fusion-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
32
|
+
.fusion-settings button{border:1px solid var(--dsw-alias-border-l2);border-radius:18px;background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary));color:var(--dsw-alias-label-primary-foreground,#fff);padding:7px 18px;font:inherit;font-size:13px;cursor:pointer}
|
|
33
|
+
.fusion-settings button:disabled{opacity:.5;cursor:default}
|
|
34
|
+
.fusion-settings :is(button,select):focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
35
|
+
.fusion-settings [role=alert]{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere}
|
|
36
|
+
.fusion-settings details{font-size:13px;line-height:21px}
|
|
37
|
+
.fusion-settings summary{cursor:pointer;font-weight:500}
|
|
38
|
+
.fusion-settings .fusion-limits{display:flex;flex-direction:column;gap:12px;padding-top:14px}
|
|
39
|
+
.fusion-settings input[type=number]{min-width:0;width:100%;box-sizing:border-box;background-color:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:7px;padding:8px;font:inherit}
|
|
40
|
+
.fusion-settings .fusion-consent{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:18px}
|
|
41
|
+
.fusion-cache{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:16px;display:flex;flex-direction:column;gap:12px}
|
|
42
|
+
.fusion-cache h3{margin:0}
|
|
43
|
+
.fusion-cache-summary{cursor:pointer;font-weight:600;font-size:14px;display:flex;align-items:center;justify-content:space-between;list-style:none;user-select:none;padding:2px 0}
|
|
44
|
+
.fusion-cache-summary::-webkit-details-marker{display:none}
|
|
45
|
+
.fusion-cache-summary:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;border-radius:4px}
|
|
46
|
+
.fusion-cache-title{display:inline-flex;align-items:center;gap:6px}
|
|
47
|
+
.fusion-cache-title h3{margin:0;font-size:15px;font-weight:600}
|
|
48
|
+
.fusion-cache-indicator{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:400;color:var(--dsw-alias-label-secondary)}
|
|
49
|
+
.fusion-cache-chevron{display:inline-block;width:7px;height:7px;border-right:2px solid var(--dsw-alias-label-secondary);border-bottom:2px solid var(--dsw-alias-label-secondary);transform:rotate(-45deg);transition:transform .15s ease;margin-left:2px}
|
|
50
|
+
.fusion-cache[open] .fusion-cache-chevron{transform:rotate(45deg)}
|
|
51
|
+
.fusion-cache-content{display:flex;flex-direction:column;gap:12px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2);margin-top:4px}
|
|
52
|
+
.fusion-cache-model{display:flex;flex-direction:column;gap:8px;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}
|
|
53
|
+
.fusion-cache-model small{display:block;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
|
|
54
|
+
.fusion-cache-controls{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr) auto;gap:10px;align-items:end}
|
|
55
|
+
.fusion-cache-controls label{grid-template-columns:1fr;gap:4px}
|
|
56
|
+
.fusion-cache a{color:var(--dsw-alias-brand-primary)}
|
|
57
|
+
@media(max-width:560px){.fusion-cache-controls{grid-template-columns:1fr}}
|
|
58
|
+
.fusion-history{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:16px;display:flex;flex-direction:column;gap:12px}
|
|
59
|
+
.fusion-history-summary{cursor:pointer;font-weight:600;font-size:14px;display:flex;align-items:center;justify-content:space-between;list-style:none;user-select:none;padding:2px 0}
|
|
60
|
+
.fusion-history-summary::-webkit-details-marker{display:none}
|
|
61
|
+
.fusion-history-summary:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;border-radius:4px}
|
|
62
|
+
.fusion-history-title{display:inline-flex;align-items:center;gap:6px}
|
|
63
|
+
.fusion-history-title h3{margin:0;font-size:15px;font-weight:600}
|
|
64
|
+
.fusion-history-indicator{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:400;color:var(--dsw-alias-label-secondary)}
|
|
65
|
+
.fusion-history-chevron{display:inline-block;width:7px;height:7px;border-right:2px solid var(--dsw-alias-label-secondary);border-bottom:2px solid var(--dsw-alias-label-secondary);transform:rotate(-45deg);transition:transform .15s ease;margin-left:2px}
|
|
66
|
+
.fusion-history[open] .fusion-history-chevron{transform:rotate(45deg)}
|
|
67
|
+
.fusion-history-content{display:flex;flex-direction:column;gap:12px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2);margin-top:4px}
|
|
68
|
+
.fusion-history-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
69
|
+
.fusion-history-panel{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:16px;display:flex;flex-direction:column;gap:12px}
|
|
70
|
+
.fusion-history-usage h3{margin:0;font-size:14px;font-weight:600}
|
|
71
|
+
.fusion-history table{width:100%;border-collapse:collapse;font-size:12px;text-align:left}
|
|
72
|
+
.fusion-history :is(th,td){padding:8px;border-bottom:1px solid var(--dsw-alias-border-l2);font-weight:400;vertical-align:top}
|
|
73
|
+
.fusion-history caption{text-align:left;font-size:13px;margin-bottom:8px}
|
|
74
|
+
.fusion-history small{display:block;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
|
|
75
|
+
.fusion-history ol{padding-left:20px;margin:0}
|
|
76
|
+
.fusion-history li{padding:10px 0;border-bottom:1px solid var(--dsw-alias-border-l2)}
|
|
77
|
+
.fusion-history li:last-child{border-bottom:0}
|
|
78
|
+
.fusion-history h3{margin:0;font-size:14px;font-weight:600}
|
|
79
|
+
.fusion-history p{overflow-wrap:anywhere}
|
|
80
|
+
.fusion-history-tasks-summary{cursor:pointer;font-weight:600;font-size:14px;display:flex;align-items:center;justify-content:space-between;list-style:none;user-select:none;padding:2px 0}
|
|
81
|
+
.fusion-history-tasks-summary::-webkit-details-marker{display:none}
|
|
82
|
+
.fusion-history-tasks-summary:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;border-radius:4px}
|
|
83
|
+
.fusion-history-tasks-title{display:inline-flex;align-items:center;gap:6px}
|
|
84
|
+
.fusion-history-count{font-size:13px;font-weight:400;color:var(--dsw-alias-label-secondary)}
|
|
85
|
+
.fusion-history-tasks-indicator{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:400;color:var(--dsw-alias-label-secondary)}
|
|
86
|
+
.fusion-history-tasks-chevron{display:inline-block;width:7px;height:7px;border-right:2px solid var(--dsw-alias-label-secondary);border-bottom:2px solid var(--dsw-alias-label-secondary);transform:rotate(-45deg);transition:transform .15s ease;margin-left:2px}
|
|
87
|
+
.fusion-history-tasks[open] .fusion-history-tasks-chevron{transform:rotate(45deg)}
|
|
88
|
+
.fusion-history-tasks-content{display:flex;flex-direction:column;gap:12px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2);margin-top:4px}
|
|
89
|
+
.fusion-history-item-header{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
90
|
+
.fusion-history-item-header h3{margin:0;font-size:14px;font-weight:600}
|
|
91
|
+
.fusion-history-actions{display:flex;align-items:center;gap:8px;flex-shrink:0}
|
|
92
|
+
.fusion-history-actions button{padding:3px 12px;font-size:12px;border-radius:14px}
|
|
93
|
+
.fusion-history-actions button.fusion-btn-subtle{background:transparent;color:var(--dsw-alias-label-primary)}
|
|
94
|
+
.fusion-history-confirm-text{font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
95
|
+
@media(max-width:480px){.fusion-page{padding:0 16px 32px}.fusion-settings label{grid-template-columns:1fr;gap:6px}.fusion-settings .fusion-card{padding:16px}}
|
|
96
|
+
`
|
|
97
|
+
|
|
98
|
+
const key = (route: PhysicalRoute | undefined) => route ? JSON.stringify([route.provider, route.model]) : ''
|
|
99
|
+
|
|
100
|
+
export function ModelChoice({ role, route, catalog, onChange }: {
|
|
101
|
+
role: 'Lead' | 'Sidekick' | 'compactor'; route?: PhysicalRoute; catalog: SettingsCatalog; onChange(route: PhysicalRoute | undefined): void
|
|
102
|
+
}) {
|
|
103
|
+
const lang = useLang()
|
|
104
|
+
const name = role === 'compactor' ? tr(lang, '压缩', 'Compaction') : role
|
|
105
|
+
const model = catalog.groups.find(group => group.id === route?.provider)?.models.find(model => model.id === route?.model)
|
|
106
|
+
return <fieldset>
|
|
107
|
+
<legend>{name}</legend>
|
|
108
|
+
<p>{role === 'Lead' ? tr(lang, '回答问题、制定方案并审查结果。', 'Answers, plans and reviews.') : role === 'Sidekick' ? tr(lang, '执行命令和修改,接收反馈后继续。', 'Runs commands and makes changes, then continues with feedback.') : tr(lang, '整理较早的上下文,供 Lead 或 Worker 继续工作。', 'Summarises older context so the Lead or Sidekick can continue.')}</p>
|
|
109
|
+
<label>{tr(lang, '模型', 'Model')}<select aria-label={tr(lang, `${name} 模型`, `${name} model`)} value={key(route)} onChange={event => {
|
|
110
|
+
if (!event.target.value) { onChange(undefined); return }
|
|
111
|
+
const [provider, model] = JSON.parse(event.target.value) as [string, string]
|
|
112
|
+
onChange({ provider, model })
|
|
113
|
+
}}>
|
|
114
|
+
<option value="" disabled={role !== 'compactor'}>{role === 'compactor' ? tr(lang, '使用当前角色模型', 'Use the role\'s own model') : tr(lang, '选择已连接的模型', 'Choose a connected model')}</option>
|
|
115
|
+
{catalog.groups.map(group => <optgroup key={group.id} label={group.name}>
|
|
116
|
+
{group.models.map(model => <option key={model.id} value={key({ provider: group.id, model: model.id })}>{model.name}</option>)}
|
|
117
|
+
</optgroup>)}
|
|
118
|
+
</select></label>
|
|
119
|
+
{model?.reasoning && route && <label>{tr(lang, '推理强度', 'Reasoning')}<select aria-label={tr(lang, `${name} 推理强度`, `${name} reasoning`)} value={route.reasoningEffort ?? ''} onChange={event => {
|
|
120
|
+
const { reasoningEffort: _old, ...physical } = route
|
|
121
|
+
onChange({ ...physical, ...(event.target.value ? { reasoningEffort: event.target.value } : {}) })
|
|
122
|
+
}}>
|
|
123
|
+
<option value="">{tr(lang, '模型默认', 'Model default')}{model.reasoning.defaultEffort ? ` · ${model.reasoning.defaultEffort}` : ''}</option>
|
|
124
|
+
{model.reasoning.efforts.map(effort => <option key={effort.id} value={effort.id}>{effort.name}</option>)}
|
|
125
|
+
</select></label>}
|
|
126
|
+
</fieldset>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function FusionSettings() {
|
|
130
|
+
const lang = useLang()
|
|
131
|
+
const [state, setState] = useState<SettingsState>()
|
|
132
|
+
const [draft, setDraft] = useState<Partial<PairChoice>>({})
|
|
133
|
+
const [error, setError] = useState<string>()
|
|
134
|
+
const [notice, setNotice] = useState<string>()
|
|
135
|
+
const [saving, setSaving] = useState(false)
|
|
136
|
+
const chooseModel = (role: 'lead' | 'worker', route: PhysicalRoute) => setDraft(value => ({ ...value, [role]: route,
|
|
137
|
+
...(value.cacheKeepalive ? { cacheKeepalive: { ...value.cacheKeepalive, [role]: role === 'lead' ? 'auto' : false } } : {}) }))
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const abort = new AbortController()
|
|
140
|
+
void fetch(`${FUSION_API_PATH}?view=settings`, { credentials: 'same-origin', cache: 'no-store', signal: abort.signal })
|
|
141
|
+
.then(async response => { if (!response.ok) throw new Error(tr(lang, '无法读取 Fusion 设置', 'Cannot read Fusion settings')); return response.json() as Promise<SettingsState> })
|
|
142
|
+
.then(value => { setState(value); setDraft(value.pair ?? {}) })
|
|
143
|
+
.catch(error => { if (!abort.signal.aborted) setError(String(error)) })
|
|
144
|
+
return () => abort.abort()
|
|
145
|
+
}, [])
|
|
146
|
+
const save = async () => {
|
|
147
|
+
if (!state || !draft.lead || !draft.worker) return
|
|
148
|
+
setSaving(true); setError(undefined); setNotice(undefined)
|
|
149
|
+
try {
|
|
150
|
+
const { outputTokens, ...rest } = draft
|
|
151
|
+
const limits = Object.fromEntries(Object.entries(outputTokens ?? {}).filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0))
|
|
152
|
+
const pair = { ...rest, ...(Object.keys(limits).length ? { outputTokens: limits } : {}) }
|
|
153
|
+
const response = await fetch(FUSION_API_PATH, { method: 'POST', credentials: 'same-origin',
|
|
154
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'configure', revision: state.revision, pair }) })
|
|
155
|
+
const result = await response.json() as { revision: number; error?: string }
|
|
156
|
+
if (!response.ok) throw new Error(result.error ?? tr(lang, '保存失败,请刷新后重试', 'Save failed; refresh and try again'))
|
|
157
|
+
const current = await fetch(`${FUSION_API_PATH}?view=settings`, { credentials: 'same-origin', cache: 'no-store' })
|
|
158
|
+
if (!current.ok) throw new Error(tr(lang, '配置已提交,但暂时无法确认,请刷新', 'Saved, but not confirmed yet; refresh'))
|
|
159
|
+
const confirmed = await current.json() as SettingsState
|
|
160
|
+
setState(confirmed); setDraft(confirmed.pair ?? {})
|
|
161
|
+
settingsChanged()
|
|
162
|
+
setNotice(tr(lang, '组合已保存并启用。可以在模型菜单中选择 Fusion · 自动。', 'Pair saved and enabled. Choose Fusion · auto in the model menu.'))
|
|
163
|
+
} catch (error) { setError(error instanceof Error ? error.message : String(error)) }
|
|
164
|
+
finally { setSaving(false) }
|
|
165
|
+
}
|
|
166
|
+
return <div className="fusion-page">
|
|
167
|
+
<style>{styles}</style>
|
|
168
|
+
<div className="fusion-settings">
|
|
169
|
+
<header data-window-drag><h2>Fusion</h2><p>{tr(lang, '选择 Lead 和 Sidekick。Lead 处理日常请求、安排工作并审查结果;Sidekick 执行委派的任务。', 'Choose a Lead and a Sidekick. The Lead handles requests, plans and reviews; the Sidekick does the delegated work.')}</p>
|
|
170
|
+
<p><b>{tr(lang, '搭配建议', 'Pairing tip')}</b>: {tr(lang, 'Fusion 省钱靠的是 Sidekick 比 Lead 便宜得多。选一个强的前沿模型当 Lead,再选一个价格低很多(最好 5 倍以上)的模型当 Sidekick;两者价格越接近,越省不了钱。',
|
|
171
|
+
'Fusion saves money because the Sidekick is much cheaper than the Lead. Pick a strong frontier Lead and a Sidekick that costs far less (ideally 5× or more); the closer their prices, the smaller the saving.')}</p></header>
|
|
172
|
+
{error && <p role="alert">{error}</p>}
|
|
173
|
+
{!state && !error && <p role="status">{tr(lang, '正在读取模型…', 'Loading models…')}</p>}
|
|
174
|
+
{state && <>
|
|
175
|
+
<fieldset className="fusion-card" disabled={saving}>
|
|
176
|
+
<ModelChoice role="Lead" route={draft.lead} catalog={state.catalog} onChange={route => { if (route) chooseModel('lead', route) }} />
|
|
177
|
+
<ModelChoice role="Sidekick" route={draft.worker} catalog={state.catalog} onChange={route => { if (route) chooseModel('worker', route) }} />
|
|
178
|
+
<details><summary>{tr(lang, '上下文压缩模型 · 可选', 'Compaction model · optional')}</summary>
|
|
179
|
+
<div className="fusion-limits">
|
|
180
|
+
<p>{tr(lang, '默认由当前角色模型压缩。也可以单独选择已连接的模型;请求由所选账号计费。', 'By default each role compacts with its own model. You can pick another connected model; its account is billed.')}</p>
|
|
181
|
+
<ModelChoice role="compactor" route={draft.compactor?.route} catalog={state.catalog} onChange={route => setDraft(value => {
|
|
182
|
+
const { compactor, ...rest } = value
|
|
183
|
+
return route ? { ...rest, compactor: { route, maxOutputTokens: compactor?.maxOutputTokens ?? 8192 } } : rest
|
|
184
|
+
})} />
|
|
185
|
+
{draft.compactor && <label>{tr(lang, '输出 Token 上限', 'Output token limit')}<input aria-label={tr(lang, '压缩输出 Token 上限', 'Compaction output token limit')} type="number" min="1" max="128000"
|
|
186
|
+
value={draft.compactor.maxOutputTokens} onChange={event => { const maxOutputTokens = Number(event.target.value)
|
|
187
|
+
setDraft(value => value.compactor ? { ...value, compactor: { ...value.compactor, maxOutputTokens } } : value) }} /></label>}
|
|
188
|
+
<p>{tr(lang, '已选中的会话仍使用原配置。', 'Conversations already using Fusion keep their setup.')}</p>
|
|
189
|
+
</div>
|
|
190
|
+
</details>
|
|
191
|
+
</fieldset>
|
|
192
|
+
<div className="fusion-actions"><p>{tr(lang, '已选中的会话保留原组合;新选择使用新组合。额度耗尽后要换当前任务的 Sidekick,用该会话的 Fusion 恢复面板,而不是只改这里。', 'Conversations already using Fusion keep their pair; new selections use the new one. After a quota stop, switch this conversation\'s Sidekick from its Fusion recovery panel, not only here.')}</p>
|
|
193
|
+
<button disabled={saving || !draft.lead || !draft.worker || JSON.stringify(draft) === JSON.stringify(state.pair)} onClick={() => { void save() }}>{saving ? tr(lang, '正在保存…', 'Saving…') : tr(lang, '保存组合', 'Save pair')}</button>
|
|
194
|
+
</div>
|
|
195
|
+
{notice && <p role="status">{notice}</p>}
|
|
196
|
+
<p>{tr(lang, '输出参数跟随原生模型设置,Fusion 不额外限制请求次数或任务轮数。', 'Output settings follow the native model settings; Fusion adds no request or round limits.')}</p>
|
|
197
|
+
<p>{tr(lang, '保存即表示允许 Fusion 使用所选模型的现有账号;费用由这些账号计入,当前未提供可靠的实际账单金额。', 'Saving lets Fusion use the chosen models\' existing accounts; they are billed there, and no reliable billed amount is available here.')}</p>
|
|
198
|
+
{state.pair && !state.authorized && <p role="status">{tr(lang, '组合需要重新保存后才能使用', 'Save the pair again before use')}{state.reason ? `: ${state.reason}` : ''}</p>}
|
|
199
|
+
{state.managedAuthorization && <p>{tr(lang, '运行额度由外部授权配置管理。', 'Run limits are managed by an external authorization file.')}</p>}
|
|
200
|
+
</>}
|
|
201
|
+
<FusionCache refresh={state?.revision ?? 0} />
|
|
202
|
+
<FusionHistory />
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { FusionRecovery } from './FusionRecovery.js'
|
|
2
|
+
import { Fragment, useState } from 'react'
|
|
3
|
+
import { useFusionStatus } from './status-polling.js'
|
|
4
|
+
import type { UsageSubtotal } from '../status.js'
|
|
5
|
+
import { tr, useLang, type Lang } from './i18n.js'
|
|
6
|
+
|
|
7
|
+
const styles = `
|
|
8
|
+
.fusion-status{margin:8px 12px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;overflow-wrap:anywhere}
|
|
9
|
+
.fusion-status>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;gap:12px;list-style:none;border-top:1px solid var(--dsw-alias-border-l2);outline-offset:2px}
|
|
10
|
+
.fusion-status>summary::before{content:'›';font-size:18px;flex:none}.fusion-status[open]>summary::before{content:'⌄'}
|
|
11
|
+
.fusion-status>summary:focus-visible{outline:2px solid var(--dsw-alias-brand-primary)}
|
|
12
|
+
.fusion-status .fusion-status-label{font-weight:600}.fusion-status .fusion-status-count{margin-left:auto;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
13
|
+
.fusion-status .fusion-status-detail{max-height:320px;overflow:auto;padding:4px 4px 12px 0;scrollbar-gutter:stable}
|
|
14
|
+
.fusion-status .fusion-recovery fieldset{border:0;padding:0;display:flex;flex-direction:column;gap:10px}.fusion-recovery :is(button,select,input){font:inherit;color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:7px;max-width:100%}.fusion-recovery :is(select,input,option,optgroup){background-color:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);color-scheme:inherit}.fusion-recovery button{cursor:pointer;background-color:var(--dsw-alias-button-elevated-fill)}.fusion-recovery :is(button,input,select):focus-visible{outline:2px solid var(--dsw-alias-brand-primary)}.fusion-recovery [role=alert]{color:var(--dsw-alias-state-error-primary)}
|
|
15
|
+
.fusion-status:has(.fusion-recovery) .fusion-status-detail{max-height:min(65vh,640px)}.fusion-recovery details>summary{padding:8px 0;cursor:pointer}
|
|
16
|
+
.fusion-status p{margin:4px 0 12px}.fusion-status h3{font-size:13px;margin:16px 0 4px;font-weight:600}
|
|
17
|
+
.fusion-status dl{margin:0;display:grid;grid-template-columns:minmax(72px,auto) minmax(0,1fr);gap:4px 12px}
|
|
18
|
+
.fusion-status dt{color:var(--dsw-alias-label-secondary)}.fusion-status dd{margin:0;min-width:0}
|
|
19
|
+
.fusion-status ul{list-style:none;margin:0;padding:0}.fusion-status li{padding:6px 0;border-bottom:1px solid var(--dsw-alias-border-l2)}
|
|
20
|
+
.fusion-status small{display:block;color:var(--dsw-alias-label-secondary);font-size:12px}
|
|
21
|
+
@media(max-width:400px){.fusion-status>summary{gap:8px}.fusion-status .fusion-status-detail{max-height:280px}}
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
const role = (value: string) => value === 'lead' ? 'Lead' : 'Sidekick'
|
|
25
|
+
const purposeOf = (lang: Lang, value: string) => ({ conversation: tr(lang, '任务', 'task'), compaction: tr(lang, '压缩', 'compaction'),
|
|
26
|
+
'cache-keepalive': tr(lang, '保活', 'keepalive'), 'session-title': tr(lang, '标题', 'title') } as Record<string, string>)[value] ?? value
|
|
27
|
+
const outcomeOf = (lang: Lang, value: string) => ({ entered: tr(lang, '请求中', 'in progress'), stop: tr(lang, '已返回', 'returned'), 'tool-calls': tr(lang, '调用工具', 'tool calls'),
|
|
28
|
+
'max-tokens': tr(lang, '输出截断', 'output truncated'), aborted: tr(lang, '已中断', 'aborted'), error: tr(lang, '请求失败', 'failed'), unknown: tr(lang, '结束状态未知', 'unknown end') } as Record<string, string>)[value] ?? value
|
|
29
|
+
const toolStateOf = (lang: Lang, value: string) => ({ 'dispatch-started': tr(lang, '执行未收尾', 'unfinished'), returned: tr(lang, '已返回', 'returned'),
|
|
30
|
+
'outcome-unknown': tr(lang, '结果未知', 'outcome unknown'), inspected: tr(lang, '已人工检查', 'inspected') } as Record<string, string>)[value] ?? value
|
|
31
|
+
const countOf = (lang: Lang, value: UsageSubtotal) => value.totalRequests === 0 ? '—' : value.reportedRequests === 0 ? tr(lang, '未提供', 'not reported')
|
|
32
|
+
: `${value.knownTokens.toLocaleString()}${value.reportedRequests < value.totalRequests ? tr(lang, `(${value.reportedRequests}/${value.totalRequests} 次有报告)`, ` (${value.reportedRequests}/${value.totalRequests} reported)`) : ''}`
|
|
33
|
+
const noteStyle = { margin: '8px 12px', color: 'var(--dsw-alias-label-secondary)', fontSize: 12, lineHeight: 1.5 }
|
|
34
|
+
|
|
35
|
+
/** Visible-session polling reads the plugin ledger; it never sends a chat message. */
|
|
36
|
+
export function FusionStatus({ sessionId, attentionOnly = false }: { sessionId: string; attentionOnly?: boolean }) {
|
|
37
|
+
const lang = useLang()
|
|
38
|
+
const purpose = (value: string) => purposeOf(lang, value), outcome = (value: string) => outcomeOf(lang, value)
|
|
39
|
+
const toolState = (value: string) => toolStateOf(lang, value), count = (value: UsageSubtotal) => countOf(lang, value)
|
|
40
|
+
const [expandedTask, setExpandedTask] = useState<string>()
|
|
41
|
+
const snapshot = useFusionStatus(sessionId, expandedTask, attentionOnly)
|
|
42
|
+
if (!snapshot || snapshot.sessionId !== sessionId) return attentionOnly ? null : <p style={noteStyle} role="status">{tr(lang, '正在读取 Fusion 状态…', 'Loading Fusion status…')}</p>
|
|
43
|
+
if (snapshot.failed) return attentionOnly ? null : <p style={noteStyle} role="status">{tr(lang, 'Fusion 状态暂时无法更新,恢复连接后会重试。可用 /fusion status 查看。', 'Fusion status cannot update right now; it retries when reconnected. Use /fusion status.')}</p>
|
|
44
|
+
const task = snapshot.status?.task
|
|
45
|
+
if (!snapshot.status?.selected || !task) return null
|
|
46
|
+
if (attentionOnly && !task.attention) return null
|
|
47
|
+
const { usage } = task
|
|
48
|
+
const verification = task.automatedChecks === 0 && task.verification === 'verified' ? tr(lang, '仅经 Lead 审查(未运行自动检查)', 'Lead review only (no automated checks ran)')
|
|
49
|
+
: ({ verified: tr(lang, '验证通过', 'verified'), partial: tr(lang, '部分验证', 'partly verified'), unverified: tr(lang, '尚未验证', 'unverified') })[task.verification]
|
|
50
|
+
const stage = lang === 'en' ? task.stageEn ?? task.stage : task.stage, detail = lang === 'en' && task.detailEn !== undefined ? task.detailEn : task.detail
|
|
51
|
+
const share = (role: 'lead' | 'worker') => {
|
|
52
|
+
const total = usage.byRole.lead.output.knownTokens + usage.byRole.worker.output.knownTokens
|
|
53
|
+
return total ? tr(lang, ` · 输出占 ${Math.round(usage.byRole[role].output.knownTokens / total * 100)}%`, ` · ${Math.round(usage.byRole[role].output.knownTokens / total * 100)}% of output`) : ''
|
|
54
|
+
}
|
|
55
|
+
return <details className="fusion-status" key={task.id} onToggle={event => setExpandedTask(event.currentTarget.open ? task.id : undefined)}>
|
|
56
|
+
<style>{styles}</style>
|
|
57
|
+
<summary aria-label={tr(lang, `Fusion · ${stage},任务详情`, `Fusion · ${stage}, task details`)} data-ud-check="fusion-status-summary">
|
|
58
|
+
<span className="fusion-status-label" role="status">Fusion · {stage}</span>
|
|
59
|
+
<span className="fusion-status-count">{tr(lang, `${usage.calls} 次请求`, `${usage.calls} requests`)}</span><span>{tr(lang, '详情', 'Details')}</span>
|
|
60
|
+
</summary>
|
|
61
|
+
<div className="fusion-status-detail" tabIndex={0} aria-label={tr(lang, 'Fusion 任务详情', 'Fusion task details')} data-ud-check="fusion-status-detail">
|
|
62
|
+
<p>{detail ?? (task.stage === '完成' ? tr(lang, `本轮任务已结束 · ${verification}`, `This task has finished · ${verification}`) : tr(lang, `本轮任务 · ${verification}`, `This task · ${verification}`))}</p>
|
|
63
|
+
{task.modelControl && task.stage !== '完成' && <FusionRecovery sessionId={sessionId} task={task} />}
|
|
64
|
+
<dl>
|
|
65
|
+
<dt>Lead</dt><dd>{task.models.lead.model}<small>{task.models.lead.provider}</small></dd>
|
|
66
|
+
<dt>Sidekick</dt><dd>{task.models.worker.model}<small>{task.models.worker.provider}{task.workerId ? tr(lang, ' · 已建立持续会话', ' · persistent session') : tr(lang, ' · 尚未委派', ' · not delegated yet')}</small></dd>
|
|
67
|
+
{task.models.compactor && <><dt>{tr(lang, '压缩模型', 'Compaction')}</dt><dd>{task.models.compactor.model}<small>{task.models.compactor.provider}</small></dd></>}
|
|
68
|
+
<dt>{tr(lang, '待确认', 'Pending approvals')}</dt><dd>{task.pendingApprovals}</dd>
|
|
69
|
+
<dt>{tr(lang, '未收尾工具', 'Unsettled tools')}</dt><dd>{task.unsettledTools}</dd>
|
|
70
|
+
</dl>
|
|
71
|
+
<h3>{tr(lang, '本轮用量', 'Usage this task')}</h3>
|
|
72
|
+
<dl>
|
|
73
|
+
<dt>{tr(lang, '模型请求', 'Model requests')}</dt><dd>{usage.calls}<small>{tr(lang, `含压缩 ${usage.compactionCalls} 次、保活 ${usage.keepaliveCalls} 次`, `incl. ${usage.compactionCalls} compaction, ${usage.keepaliveCalls} keepalive`)}</small></dd>
|
|
74
|
+
<dt>{tr(lang, '用量报告', 'Usage reports')}</dt><dd>{tr(lang, `${usage.finalCalls} 次已定稿 · ${usage.provisionalCalls} 次暂报 · ${usage.unreportedCalls} 次未提供`, `${usage.finalCalls} final · ${usage.provisionalCalls} provisional · ${usage.unreportedCalls} unreported`)}</dd>
|
|
75
|
+
<dt>{tr(lang, '输入 Token', 'Input tokens')}</dt><dd>{count(usage.input)}<small>{tr(lang, '已报告的未缓存输入', 'reported uncached input')}</small></dd>
|
|
76
|
+
<dt>{tr(lang, '缓存读取', 'Cache read')}</dt><dd>{count(usage.cacheRead)}</dd>
|
|
77
|
+
<dt>{tr(lang, '输出 Token', 'Output tokens')}</dt><dd>{count(usage.output)}</dd>
|
|
78
|
+
<dt>{tr(lang, '实际费用', 'Actual cost')}</dt><dd>{tr(lang, '未知', 'unknown')}<small>{tr(lang, '当前账号未提供可靠账单金额', 'the account reports no reliable billed amount')}</small></dd>
|
|
79
|
+
</dl>
|
|
80
|
+
<h3>{tr(lang, '按角色', 'By role')}</h3>
|
|
81
|
+
<dl>
|
|
82
|
+
{(['lead', 'worker'] as const).map(role => <Fragment key={role}>
|
|
83
|
+
<dt>{role === 'lead' ? 'Lead' : 'Sidekick'}</dt>
|
|
84
|
+
<dd>{tr(lang, `${usage.byRole[role].calls} 次`, `${usage.byRole[role].calls} requests`)} · {tr(lang, '输出', 'output')} {count(usage.byRole[role].output)}
|
|
85
|
+
<small>{tr(lang, '输入', 'input')} {count(usage.byRole[role].input)} · {tr(lang, '缓存读取', 'cache read')} {count(usage.byRole[role].cacheRead)}{share(role)}</small></dd>
|
|
86
|
+
</Fragment>)}
|
|
87
|
+
</dl>
|
|
88
|
+
<small>{tr(lang, 'Token 数包含暂报数据;缺失字段不计为 0。请求数来自 DSH,供应商内部重试次数未知。', 'Token counts include provisional data; missing fields are not counted as 0. Request counts come from DSH; provider-internal retries are unknown.')}</small>
|
|
89
|
+
<h3>{tr(lang, '最近上下文检查', 'Recent context checks')}</h3>
|
|
90
|
+
{task.contexts.length ? <ul>{task.contexts.map(context => <li key={context.role}>{role(context.role)} · {purpose(context.purpose)} · {context.admitted ? tr(lang, '可发送', 'admitted') : tr(lang, '超出预算', 'over budget')}
|
|
91
|
+
<small>{context.inputTokens.toLocaleString()} / {context.budget.toLocaleString()} Token · {context.quality === 'exact' ? tr(lang, '精确计数', 'exact') : tr(lang, '估算', 'estimated')}</small>
|
|
92
|
+
</li>)}</ul> : <small>{tr(lang, '尚无上下文检查记录。', 'No context checks yet.')}</small>}
|
|
93
|
+
<h3>{tr(lang, '最近模型请求', 'Recent model requests')}</h3>
|
|
94
|
+
{task.requests.length ? <ul>{task.requests.map(request => <li key={request.id}>{role(request.role)} · {purpose(request.purpose)} · {outcome(request.outcome)}
|
|
95
|
+
<small>{request.model} · {request.provider} · {request.authority === 'final' ? tr(lang, '用量已定稿', 'usage final') : request.authority === 'provisional' ? tr(lang, '用量暂报', 'usage provisional') : tr(lang, '用量未提供', 'usage not reported')}</small>
|
|
96
|
+
</li>)}</ul> : <small>{tr(lang, '尚未发出模型请求。', 'No model requests yet.')}</small>}
|
|
97
|
+
<h3>{tr(lang, '最近命令与写入', 'Recent commands and writes')}</h3>
|
|
98
|
+
{task.tools.length ? <ul>{task.tools.map(tool => <li key={tool.id}>{role(tool.role)} · {tool.name} · {tool.failed ? tr(lang, '返回错误', 'error') : toolState(tool.state)}</li>)}</ul>
|
|
99
|
+
: <small>{tr(lang, '尚无工具执行记录。', 'No tool runs yet.')}</small>}
|
|
100
|
+
<small>{tr(lang, '显示最近 12 次请求、8 项命令或写入操作;用量汇总覆盖本轮全部请求。读取操作仍可在会话中查看。', 'Shows the last 12 requests and 8 commands or writes; usage totals cover all requests of this task. Reads remain visible in the conversation.')}</small>
|
|
101
|
+
</div>
|
|
102
|
+
</details>
|
|
103
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
3
|
+
import type { FusionActivity, FusionActivityPage } from '../activity.js'
|
|
4
|
+
import { activityRows } from './activity-model.js'
|
|
5
|
+
import { tr, useLang } from './i18n.js'
|
|
6
|
+
import { ActivityCard, activityStyles } from './ActivityRows.js'
|
|
7
|
+
import { FUSION_API_PATH } from '../identity.js'
|
|
8
|
+
|
|
9
|
+
export const fusionControlTools = ['fusion_read_state', 'fusion_delegate_text', 'fusion_explore', 'fusion_delegate', 'fusion_rework', 'fusion_wait', 'fusion_review_result',
|
|
10
|
+
'fusion_submit_result', 'fusion_takeover', 'fusion_finish_direct', 'fusion_read_evidence'] as const
|
|
11
|
+
|
|
12
|
+
/** Keep internal coordination out of the normal conversation. Errors remain visible. */
|
|
13
|
+
export function FusionTool({ block, sessionId, callId, toolName, cwd, openFile }: ToolCallViewProps) {
|
|
14
|
+
const lang = useLang()
|
|
15
|
+
const settled = 'kind' in block
|
|
16
|
+
const tracksActivity = toolName === 'fusion_delegate_text' || toolName === 'fusion_explore' || toolName === 'fusion_delegate' || toolName === 'fusion_rework'
|
|
17
|
+
const [snapshot, setSnapshot] = useState<{ key: string; events: FusionActivity[]; done: boolean; failed?: boolean }>()
|
|
18
|
+
const key = `${sessionId}:${callId}`
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!tracksActivity) return
|
|
21
|
+
let active = true, busy = false, cursor = -1, timer: ReturnType<typeof setTimeout> | undefined
|
|
22
|
+
const events = new Map<number, FusionActivity>()
|
|
23
|
+
let abort: AbortController | undefined
|
|
24
|
+
const visible = () => document.visibilityState !== 'hidden'
|
|
25
|
+
const refresh = async () => {
|
|
26
|
+
if (!active || busy || !visible()) return
|
|
27
|
+
busy = true; abort = new AbortController()
|
|
28
|
+
const deadline = setTimeout(() => abort?.abort(), 10000)
|
|
29
|
+
let again = true, delay = 2000
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(`${FUSION_API_PATH}?view=activity&sessionId=${encodeURIComponent(sessionId)}&callId=${encodeURIComponent(callId)}&after=${cursor}`,
|
|
32
|
+
{ credentials: 'same-origin', cache: 'no-store', signal: abort.signal })
|
|
33
|
+
if (!response.ok) throw new Error('Activity unavailable')
|
|
34
|
+
const page = await response.json() as FusionActivityPage
|
|
35
|
+
if (!Array.isArray(page.events) || !Number.isSafeInteger(page.cursor) || page.cursor < cursor) throw new Error('Invalid activity page')
|
|
36
|
+
for (const event of page.events) if (event.parentCallId === callId) events.set(event.source.seq, event)
|
|
37
|
+
cursor = page.cursor; again = page.more || !page.done || !settled; delay = page.more ? 0 : 2000
|
|
38
|
+
if (active) setSnapshot({ key, events: [...events.values()].sort((a, b) => a.source.seq - b.source.seq), done: page.done })
|
|
39
|
+
} catch {
|
|
40
|
+
if (active) setSnapshot(previous => ({ key, events: previous?.key === key ? previous.events : [], done: false, failed: true }))
|
|
41
|
+
} finally {
|
|
42
|
+
clearTimeout(deadline); busy = false
|
|
43
|
+
if (active && again && visible()) timer = setTimeout(() => { void refresh() }, delay)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const visibility = () => { if (timer) clearTimeout(timer); if (document.visibilityState === 'hidden') abort?.abort(); else void refresh() }
|
|
47
|
+
void refresh(); document.addEventListener('visibilitychange', visibility)
|
|
48
|
+
return () => { active = false; abort?.abort(); if (timer) clearTimeout(timer); document.removeEventListener('visibilitychange', visibility) }
|
|
49
|
+
}, [sessionId, callId, key, tracksActivity, settled])
|
|
50
|
+
const current = tracksActivity && snapshot?.key === key ? snapshot : undefined
|
|
51
|
+
const rows = useMemo(() => activityRows(current?.events ?? []), [current?.events])
|
|
52
|
+
const text = settled ? block.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\n') : ''
|
|
53
|
+
let problem = settled && block.isError ? text : ''
|
|
54
|
+
if (!problem) {
|
|
55
|
+
try {
|
|
56
|
+
const result = JSON.parse(text) as { status?: string; reason?: string }
|
|
57
|
+
if (result.status === 'needs-decision') problem = result.reason ?? tr(lang, '任务需要进一步处理。', 'The task needs attention.')
|
|
58
|
+
} catch { /* Non-JSON successful evidence carries no user action. */ }
|
|
59
|
+
}
|
|
60
|
+
return <>
|
|
61
|
+
{rows.length > 0 && <style>{activityStyles}</style>}
|
|
62
|
+
{rows.map(row => <ActivityCard key={row.id} row={row} done={current?.done ?? false} cwd={cwd} openFile={openFile} />)}
|
|
63
|
+
{current?.failed && <p role="status">{tr(lang, '执行记录暂时无法加载,正在重试。', 'Activity is unavailable; retrying.')}</p>}
|
|
64
|
+
{problem && <p role="status" style={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>{problem}</p>}
|
|
65
|
+
</>
|
|
66
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
2
|
+
import type { FusionActivity } from '../activity.js'
|
|
3
|
+
import { activityCallId } from '../activity.js'
|
|
4
|
+
|
|
5
|
+
export interface ActivityRow {
|
|
6
|
+
id: string
|
|
7
|
+
call: Extract<FusionActivity['source'], { type: 'tool/call' }>
|
|
8
|
+
result?: Extract<FusionActivity['source'], { type: 'tool/result' }>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function activityRows(events: readonly FusionActivity[]): ActivityRow[] {
|
|
12
|
+
const rows = new Map<string, ActivityRow>()
|
|
13
|
+
for (const event of events) {
|
|
14
|
+
const id = activityCallId(event)
|
|
15
|
+
if (event.source.type === 'tool/call') rows.set(id, { id, call: event.source })
|
|
16
|
+
else {
|
|
17
|
+
const row = rows.get(id)
|
|
18
|
+
if (row) rows.set(id, { ...row, result: event.source })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return [...rows.values()]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Only actual result-time metadata is labelled an applied diff. */
|
|
25
|
+
export function activityDiffs(row: ActivityRow): DiffHunk[] {
|
|
26
|
+
if (!row.result || row.result.data.message.isError) return []
|
|
27
|
+
const meta = row.result.data.meta as { diffs?: unknown } | undefined
|
|
28
|
+
if (!Array.isArray(meta?.diffs)) return []
|
|
29
|
+
return meta.diffs.filter((hunk): hunk is DiffHunk => hunk && typeof hunk === 'object'
|
|
30
|
+
&& typeof hunk.path === 'string' && (typeof hunk.oldText === 'string' || hunk.oldText === null) && typeof hunk.newText === 'string')
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
/** The DSH client locale service (`ctx.locale`), used by structure only. */
|
|
4
|
+
interface LocaleSource { getSnapshot(): { active: string }; subscribe(listener: () => void): () => void }
|
|
5
|
+
|
|
6
|
+
let source: LocaleSource | undefined
|
|
7
|
+
const listeners = new Set<() => void>()
|
|
8
|
+
const notify = () => { for (const listener of listeners) listener() }
|
|
9
|
+
|
|
10
|
+
/** Called from the client entry when DSH's locale service is available; follows the user's language setting. */
|
|
11
|
+
export function bindLocale(next: LocaleSource): () => void {
|
|
12
|
+
source = next
|
|
13
|
+
notify()
|
|
14
|
+
const unsubscribe = next.subscribe(notify)
|
|
15
|
+
return () => { unsubscribe(); if (source === next) { source = undefined; notify() } }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Lang = 'zh' | 'en'
|
|
19
|
+
const current = (): Lang => {
|
|
20
|
+
const id = source?.getSnapshot().active ?? (typeof navigator === 'undefined' ? 'en' : navigator.language)
|
|
21
|
+
return id.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The UI language: Chinese for any zh locale, English otherwise (DSH's own fallback). */
|
|
25
|
+
export function useLang(): Lang {
|
|
26
|
+
return useSyncExternalStore(listener => { listeners.add(listener); return () => { listeners.delete(listener) } }, current, () => 'en')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Pick the text for the active language. */
|
|
30
|
+
export const tr = (lang: Lang, zh: string, en: string) => lang === 'zh' ? zh : en
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
3
|
+
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
4
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
5
|
+
import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
6
|
+
import { FusionSettings } from './FusionSettings.js'
|
|
7
|
+
import { FusionPanelIcon } from './FusionPanelIcon.js'
|
|
8
|
+
import { FusionHint } from './FusionHint.js'
|
|
9
|
+
import { FusionTool, fusionControlTools } from './FusionTool.js'
|
|
10
|
+
import { bindLocale } from './i18n.js'
|
|
11
|
+
|
|
12
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
13
|
+
interface SlotMap {
|
|
14
|
+
'main': { kind: 'keyed'; scope: 'root' }
|
|
15
|
+
'sidebar.panellist': {
|
|
16
|
+
kind: 'list'
|
|
17
|
+
scope: 'root'
|
|
18
|
+
owner: { size: number; active: boolean }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const name = 'dsh-fusion/client'
|
|
24
|
+
export const inject = ['slots']
|
|
25
|
+
export const FUSION_PANEL_ID = 'fusion'
|
|
26
|
+
|
|
27
|
+
export { FusionPanelIcon } from './FusionPanelIcon.js'
|
|
28
|
+
|
|
29
|
+
export function apply(ctx: Context): void {
|
|
30
|
+
// Follow DSH's language setting (Chinese or English) when the locale service is present.
|
|
31
|
+
ctx.inject(['locale'], scope => { scope.effect(() => bindLocale((scope as unknown as { locale: Parameters<typeof bindLocale>[0] }).locale)) })
|
|
32
|
+
for (const tool of fusionControlTools) ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
|
33
|
+
name: 'tool.call.toolview', key: tool,
|
|
34
|
+
}, FusionTool))
|
|
35
|
+
ctx.slots.inject('main', () => ctx.slots.register({
|
|
36
|
+
name: 'main', key: FUSION_PANEL_ID,
|
|
37
|
+
}, FusionSettings))
|
|
38
|
+
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
39
|
+
name: 'sidebar.panellist', id: FUSION_PANEL_ID, order: 40, label: () => 'Fusion',
|
|
40
|
+
}, FusionPanelIcon))
|
|
41
|
+
ctx.inject(['modelDirectories'], scope => {
|
|
42
|
+
scope.slots.inject('conversation.input.dock', () => scope.slots.register({
|
|
43
|
+
name: 'conversation.input.dock', id: 'fusion-configuration', order: 20,
|
|
44
|
+
inject: sessionId => {
|
|
45
|
+
const directory = scope.modelDirectories.directoryFor(sessionId)
|
|
46
|
+
return { sessionId, directory: directory.store, load: () => { void directory.load().catch(() => undefined) } }
|
|
47
|
+
},
|
|
48
|
+
}, FusionHint))
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
const listeners = new Set<() => void>()
|
|
2
|
+
export const onSettingsChanged = (listener: () => void): (() => void) => { listeners.add(listener); return () => { listeners.delete(listener) } }
|
|
3
|
+
export const settingsChanged = (): void => { for (const listener of listeners) listener() }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
|
|
2
|
+
import type { FusionStatus } from '../status.js'
|
|
3
|
+
import { FUSION_API_PATH } from '../identity.js'
|
|
4
|
+
|
|
5
|
+
interface Snapshot { sessionId: string; status?: FusionStatus; failed: boolean }
|
|
6
|
+
interface Entry {
|
|
7
|
+
snapshot?: Snapshot
|
|
8
|
+
listeners: Map<() => void, () => boolean>
|
|
9
|
+
busy: boolean
|
|
10
|
+
timer?: ReturnType<typeof setTimeout>
|
|
11
|
+
abort?: AbortController
|
|
12
|
+
visibility: () => void
|
|
13
|
+
}
|
|
14
|
+
const entries = new Map<string, Entry>()
|
|
15
|
+
const visible = () => document.visibilityState !== 'hidden'
|
|
16
|
+
const running = new Set(['分析', '执行', '修改', '审查', '验证', '正在停止'])
|
|
17
|
+
|
|
18
|
+
function subscribe(sessionId: string, listener: () => void, expanded: () => boolean): () => void {
|
|
19
|
+
let entry = entries.get(sessionId)
|
|
20
|
+
if (!entry) {
|
|
21
|
+
entry = { listeners: new Map(), busy: false, visibility: () => {} }
|
|
22
|
+
entries.set(sessionId, entry)
|
|
23
|
+
const own = entry
|
|
24
|
+
const active = () => entries.get(sessionId) === own && own.listeners.size > 0
|
|
25
|
+
const publish = (snapshot: Snapshot) => {
|
|
26
|
+
if (!active()) return
|
|
27
|
+
own.snapshot = snapshot
|
|
28
|
+
for (const notify of own.listeners.keys()) notify()
|
|
29
|
+
}
|
|
30
|
+
const refresh = async () => {
|
|
31
|
+
if (!active() || own.busy || !visible()) return
|
|
32
|
+
clearTimeout(own.timer)
|
|
33
|
+
own.busy = true
|
|
34
|
+
const abort = new AbortController(); own.abort = abort
|
|
35
|
+
const deadline = setTimeout(() => {
|
|
36
|
+
if (active() && visible()) publish({ sessionId, failed: true })
|
|
37
|
+
abort.abort()
|
|
38
|
+
}, 10000)
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(`${FUSION_API_PATH}?view=status&sessionId=${encodeURIComponent(sessionId)}`,
|
|
41
|
+
{ credentials: 'same-origin', cache: 'no-store', signal: abort.signal })
|
|
42
|
+
if (!response.ok) throw new Error('unavailable')
|
|
43
|
+
const status = await response.json() as FusionStatus
|
|
44
|
+
if (status.schemaVersion !== 1 || status.sessionId !== sessionId) throw new Error('mismatched status')
|
|
45
|
+
if (!abort.signal.aborted) publish({ sessionId, status, failed: false })
|
|
46
|
+
} catch {
|
|
47
|
+
if (!abort.signal.aborted) publish({ sessionId, failed: true })
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(deadline); own.busy = false
|
|
50
|
+
if (active() && visible()) {
|
|
51
|
+
const fast = [...own.listeners.values()].some(value => value()) && running.has(own.snapshot?.status?.task?.stage ?? '')
|
|
52
|
+
own.timer = setTimeout(() => { void refresh() }, fast ? 2000 : 15000)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
own.visibility = () => {
|
|
57
|
+
clearTimeout(own.timer)
|
|
58
|
+
if (!visible()) own.abort?.abort()
|
|
59
|
+
else void refresh()
|
|
60
|
+
}
|
|
61
|
+
document.addEventListener('visibilitychange', own.visibility)
|
|
62
|
+
}
|
|
63
|
+
entry.listeners.set(listener, expanded)
|
|
64
|
+
if (entry.listeners.size === 1 || expanded()) entry.visibility()
|
|
65
|
+
const own = entry
|
|
66
|
+
return () => {
|
|
67
|
+
own.listeners.delete(listener)
|
|
68
|
+
if (own.listeners.size) return
|
|
69
|
+
entries.delete(sessionId)
|
|
70
|
+
clearTimeout(own.timer); own.abort?.abort()
|
|
71
|
+
document.removeEventListener('visibilitychange', own.visibility)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One in-flight read per mounted session; collapsed hints still discover new attention states. */
|
|
76
|
+
export function useFusionStatus(sessionId: string, expandedTask: string | undefined, attentionOnly = false): Snapshot | undefined {
|
|
77
|
+
const expandedRef = useRef(expandedTask), attentionRef = useRef(attentionOnly)
|
|
78
|
+
expandedRef.current = expandedTask; attentionRef.current = attentionOnly
|
|
79
|
+
useEffect(() => { if (expandedTask) entries.get(sessionId)?.visibility() }, [sessionId, expandedTask])
|
|
80
|
+
return useSyncExternalStore(
|
|
81
|
+
useCallback(listener => subscribe(sessionId, listener, () => {
|
|
82
|
+
const task = entries.get(sessionId)?.snapshot?.status?.task
|
|
83
|
+
return expandedRef.current !== undefined && task?.id === expandedRef.current && (!attentionRef.current || task.attention)
|
|
84
|
+
}), [sessionId]),
|
|
85
|
+
useCallback(() => entries.get(sessionId)?.snapshot, [sessionId]),
|
|
86
|
+
() => undefined,
|
|
87
|
+
)
|
|
88
|
+
}
|