@steerable/agent-shell-web 0.6.27 → 0.6.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +4 -4
  2. package/src/components/AgentSidebar.tsx +24 -24
  3. package/src/components/BrandLockup.tsx +1 -1
  4. package/src/components/ChatHeader.tsx +5 -15
  5. package/src/components/LocalLlmSettingsModal.tsx +5 -5
  6. package/src/components/TerminalPanel.tsx +1 -1
  7. package/src/components/chat/ApprovalModal.tsx +5 -5
  8. package/src/components/chat/AskUserQuestionMenu.tsx +13 -13
  9. package/src/components/chat/AssistantMessage.tsx +41 -13
  10. package/src/components/chat/ChatInput.tsx +24 -24
  11. package/src/components/chat/ExecPolicyPicker.tsx +4 -4
  12. package/src/components/chat/ExecutedActionsCard.tsx +10 -3
  13. package/src/components/chat/FilePathCode.tsx +2 -1
  14. package/src/components/chat/LocalChatPanel.tsx +39 -4
  15. package/src/components/chat/Markdown.tsx +15 -14
  16. package/src/components/chat/MessageList.tsx +16 -3
  17. package/src/components/chat/ModelPicker.tsx +6 -6
  18. package/src/components/chat/SessionTodoList.tsx +137 -0
  19. package/src/components/chat/SessionTreeModal.tsx +3 -3
  20. package/src/components/chat/SuggestedReplies.tsx +5 -4
  21. package/src/components/chat/TaskPanelModal.tsx +3 -3
  22. package/src/components/chat/TaskProcessPanel.tsx +5 -4
  23. package/src/components/chat/TurnFilesCard.tsx +2 -2
  24. package/src/components/chat/TurnProcessGroup.tsx +226 -99
  25. package/src/components/chat/UserMessage.tsx +4 -4
  26. package/src/components/chat/next-steps-tags.ts +28 -0
  27. package/src/components/chat/process-status.ts +125 -53
  28. package/src/components/chat/todo-list-model.ts +167 -0
  29. package/src/components/chat/turn-timeline.ts +100 -11
  30. package/src/components/settings/AgentsSettingsPanel.tsx +2 -2
  31. package/src/components/settings/AppearanceSettingsPanel.tsx +3 -3
  32. package/src/components/settings/DiagnoseSettingsPanel.tsx +2 -2
  33. package/src/components/settings/InsightsSettingsPanel.tsx +4 -4
  34. package/src/components/settings/LlmSettingsPanel.tsx +10 -10
  35. package/src/components/settings/McpSettingsPanel.tsx +3 -3
  36. package/src/components/settings/ModelIdCombobox.tsx +2 -2
  37. package/src/components/settings/SecuritySettingsPanel.tsx +1 -1
  38. package/src/components/settings/SettingsSaveButton.tsx +1 -1
  39. package/src/components/settings/SkillsSettingsPanel.tsx +2 -2
  40. package/src/components/settings/TelemetrySettingsPanel.tsx +2 -2
  41. package/src/components/settings/UsagePanel.tsx +14 -10
  42. package/src/components/settings/WebSearchSettingsPanel.tsx +2 -2
  43. package/src/layouts/AgentLayout.tsx +3 -3
  44. package/src/lib/chat-transport.ts +68 -12
  45. package/src/lib/show-thinking-content.ts +4 -4
  46. package/src/pages/AgentPage.tsx +56 -6
  47. package/src/pages/ChatPanelPreviewPage.tsx +26 -4
  48. package/src/pages/SettingsPage.tsx +39 -39
  49. package/src/styles/index.css +12 -2
@@ -397,9 +397,9 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
397
397
  }, [saving, savedOk, loading, onSaveUiChange]);
398
398
 
399
399
  return (
400
- <div className="space-y-4">
400
+ <div className="space-y-3">
401
401
  {loading ? (
402
- <div className="flex items-center gap-2 py-4 text-sm text-agent-muted-foreground">
402
+ <div className="flex items-center gap-2 py-2 text-xs text-agent-muted-foreground">
403
403
  <LuLoaderCircle className="h-4 w-4 animate-spin" />
404
404
  加载当前配置...
405
405
  </div>
@@ -413,7 +413,7 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
413
413
  data-testid="llm-vendor-select"
414
414
  value={selectedVendor?.id ?? 'custom'}
415
415
  onChange={(e) => handleSwitchVendor(e.target.value)}
416
- className="h-9 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
416
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
417
417
  >
418
418
  {featuredVendors.length > 0 && (
419
419
  <optgroup label="常用">
@@ -465,7 +465,7 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
465
465
  ? 'https://your-gateway.example/v1'
466
466
  : '该服务商没有默认地址,请手动填写')
467
467
  }
468
- className="h-9 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
468
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
469
469
  />
470
470
  <p className="mt-1.5 text-[11px] text-agent-muted-foreground">
471
471
  {selectedVendor?.apiBaseUrl
@@ -491,7 +491,7 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
491
491
  ? 'sk-... (可选,私有部署可留空)'
492
492
  : 'sk-... (必填,到服务商控制台创建)'
493
493
  }
494
- className="h-9 min-w-0 flex-1 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
494
+ className="h-8 min-w-0 flex-1 rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
495
495
  />
496
496
  <button
497
497
  type="button"
@@ -499,7 +499,7 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
499
499
  title="用当前 URL 和 Key 向网关拉一次模型目录"
500
500
  disabled={!settings.baseUrl?.trim() || keyTest.status === 'testing'}
501
501
  onClick={() => void handleTestKey()}
502
- className="inline-flex h-9 shrink-0 items-center justify-center rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs font-medium text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-50"
502
+ className="inline-flex h-8 shrink-0 items-center justify-center rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs font-medium text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-not-allowed disabled:opacity-50"
503
503
  >
504
504
  {keyTest.status === 'testing' ? (
505
505
  <LuLoaderCircle className="h-4 w-4 animate-spin" />
@@ -551,7 +551,7 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
551
551
  aria-label="刷新模型目录"
552
552
  disabled={!settings.baseUrl?.trim() || modelsRefreshing}
553
553
  onClick={() => void handleRefreshModels()}
554
- className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-agent-md border border-agent-border bg-agent-canvas text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
554
+ className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-agent-md border border-agent-border bg-agent-canvas text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
555
555
  >
556
556
  <LuRefreshCw className={`h-4 w-4 ${modelsRefreshing ? 'animate-spin' : ''}`} />
557
557
  </button>
@@ -819,7 +819,7 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
819
819
  setSettings((prev) => ({ ...prev, maxTotalTokens: isNaN(val) ? undefined : val }));
820
820
  }}
821
821
  placeholder="60000"
822
- className="h-9 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
822
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
823
823
  />
824
824
  <p className="mt-1.5 text-[11px] text-agent-muted-foreground">
825
825
  单次对话中累积消耗的最大 Token 数量(默认 60,000)。超限后将自动停止,防止模型死循环或意外消耗过多 Token。
@@ -827,7 +827,7 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
827
827
  </div>
828
828
 
829
829
  {/* ───── 超时设置 ───── */}
830
- <div className="border-t border-agent-border/60 pt-4 space-y-4">
830
+ <div className="border-t border-agent-border/60 pt-3 space-y-3">
831
831
  <h4 className="text-xs font-semibold text-agent-muted-foreground uppercase tracking-wide">
832
832
  超时设置
833
833
  </h4>
@@ -842,7 +842,7 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
842
842
  value={execTimeoutInput}
843
843
  onChange={(e) => setExecTimeoutInput(e.target.value)}
844
844
  placeholder="留空 = 默认(后台 30s / 终端 60s)"
845
- className="h-9 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
845
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
846
846
  />
847
847
  <p className="mt-1.5 text-[11px] text-agent-muted-foreground">
848
848
  执行本地命令时未显式指定超时的默认等待时长。命令行带 “gui” 的启动命令不受此限制:超时后按“程序已启动、仍在运行”处理,不会被判为失败或重复启动。
@@ -230,9 +230,9 @@ export function McpSettingsPanel() {
230
230
  };
231
231
 
232
232
  return (
233
- <div className="space-y-4">
233
+ <div className="space-y-3">
234
234
  {/* JSON 导入 */}
235
- <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-2">
235
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-2.5 space-y-2">
236
236
  <h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
237
237
  <LuPlug className="h-3.5 w-3.5 text-agent-muted-foreground" />
238
238
  粘贴 JSON 导入 (Claude Desktop 格式)
@@ -271,7 +271,7 @@ export function McpSettingsPanel() {
271
271
 
272
272
  {/* 手动添加 / 编辑表单 */}
273
273
  {mcpFormOpen ? (
274
- <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-2.5">
274
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-2.5 space-y-2.5">
275
275
  <h4 className="text-xs font-semibold text-agent-foreground">
276
276
  {mcpEditingId ? '编辑 MCP 服务' : '手动添加 MCP 服务'}
277
277
  </h4>
@@ -119,7 +119,7 @@ export function ModelIdCombobox({
119
119
  setOpen(false);
120
120
  }}
121
121
  className={[
122
- 'flex w-full flex-col gap-1 px-3 py-1.5 text-left text-sm',
122
+ 'flex w-full flex-col gap-0.5 px-2.5 py-1 text-left text-xs',
123
123
  selected
124
124
  ? 'bg-agent-foreground/10 text-agent-foreground'
125
125
  : 'text-agent-foreground hover:bg-agent-foreground/5',
@@ -159,7 +159,7 @@ export function ModelIdCombobox({
159
159
  }}
160
160
  onFocus={show}
161
161
  placeholder={placeholder}
162
- className="h-9 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 pr-8 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
162
+ className="h-8 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2.5 pr-8 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
163
163
  />
164
164
  <button
165
165
  type="button"
@@ -122,7 +122,7 @@ export function SecuritySettingsPanel() {
122
122
  const optOutReason = posture ? OPT_OUT_REASON_TEXT[posture.reason] : undefined;
123
123
 
124
124
  return (
125
- <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-3">
125
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-2.5 space-y-2">
126
126
  <h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
127
127
  <LuShieldCheck className="h-3.5 w-3.5 text-agent-muted-foreground" />
128
128
  Sidecar 进程沙箱
@@ -22,7 +22,7 @@ export function SettingsSaveButton({
22
22
  data-testid={testId}
23
23
  onClick={onClick}
24
24
  disabled={disabled}
25
- className="flex h-8 items-center gap-1.5 rounded-full bg-agent-foreground px-5 text-xs font-medium text-agent-canvas transition-all hover:opacity-90 disabled:opacity-50"
25
+ className="flex h-7 items-center gap-1.5 rounded-full bg-agent-foreground px-3 text-xs font-medium text-agent-canvas transition-all hover:opacity-90 disabled:opacity-50"
26
26
  >
27
27
  {saving ? (
28
28
  <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
@@ -153,9 +153,9 @@ export function SkillsSettingsPanel() {
153
153
  };
154
154
 
155
155
  return (
156
- <div className="space-y-4">
156
+ <div className="space-y-3">
157
157
  {/* Import Skill Bar */}
158
- <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-2">
158
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-2.5 space-y-2">
159
159
  <h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
160
160
  <LuBlocks className="h-3.5 w-3.5 text-agent-muted-foreground" />
161
161
  导入本地技能目录 (Import Local Skill Directory)
@@ -81,8 +81,8 @@ export function TelemetrySettingsPanel() {
81
81
  };
82
82
 
83
83
  return (
84
- <div className="space-y-4">
85
- <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-3">
84
+ <div className="space-y-3">
85
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-2.5 space-y-2">
86
86
  <h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
87
87
  <LuActivity className="h-3.5 w-3.5 text-agent-muted-foreground" />
88
88
  OTLP Collector
@@ -7,7 +7,7 @@ import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
7
7
  *
8
8
  * 每轮 CoreLoop 结束后,local-backend 把该轮的累计 token 用量(以及按
9
9
  * framework pricing 单价表估算的 USD 成本)落进 `usage_events` 表;本面板
10
- * 按 model 分桶聚合展示近 N 天的用量与成本。
10
+ * 按 model 分桶聚合展示近 N 天的用量,成本按固定汇率换算为人民币。
11
11
  *
12
12
  * 成本口径:只统计有单价的模型(framework `MODEL_PRICES`);本地/未知模型
13
13
  * 无单价,token 照常计入但成本列渲染为 "—",不计入总成本。
@@ -52,10 +52,14 @@ function formatTokens(n: number): string {
52
52
  return String(n);
53
53
  }
54
54
 
55
+ /** 展示用估算汇率。底层仍存 USD,仅在面板换成人民币。 */
56
+ const USD_TO_CNY = 7.2;
57
+
55
58
  function formatCost(usd: number | null): string {
56
59
  if (usd === null) return '—';
57
- if (usd < 0.01) return `$${usd.toFixed(4)}`;
58
- return `$${usd.toFixed(2)}`;
60
+ const cny = usd * USD_TO_CNY;
61
+ if (cny < 0.01) return `¥${cny.toFixed(4)}`;
62
+ return `¥${cny.toFixed(2)}`;
59
63
  }
60
64
 
61
65
  export function UsagePanel() {
@@ -86,8 +90,8 @@ export function UsagePanel() {
86
90
  }, [fetchSummary, days]);
87
91
 
88
92
  return (
89
- <div className="space-y-4">
90
- <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-3">
93
+ <div className="space-y-3">
94
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-2.5 space-y-2">
91
95
  <div className="flex items-center justify-between">
92
96
  <h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
93
97
  <LuChartBar className="h-3.5 w-3.5 text-agent-muted-foreground" />
@@ -135,19 +139,19 @@ export function UsagePanel() {
135
139
  <div className="grid grid-cols-4 gap-2">
136
140
  <div className="rounded-agent-md border border-agent-border bg-agent-canvas px-2.5 py-2">
137
141
  <div className="text-[10px] text-agent-muted-foreground">总轮次</div>
138
- <div className="text-sm font-semibold text-agent-foreground">{summary.totals.turns}</div>
142
+ <div className="text-xs font-semibold text-agent-foreground">{summary.totals.turns}</div>
139
143
  </div>
140
144
  <div className="rounded-agent-md border border-agent-border bg-agent-canvas px-2.5 py-2">
141
145
  <div className="text-[10px] text-agent-muted-foreground">总 Token</div>
142
- <div className="text-sm font-semibold text-agent-foreground">{formatTokens(summary.totals.totalTokens)}</div>
146
+ <div className="text-xs font-semibold text-agent-foreground">{formatTokens(summary.totals.totalTokens)}</div>
143
147
  </div>
144
148
  <div className="rounded-agent-md border border-agent-border bg-agent-canvas px-2.5 py-2">
145
149
  <div className="text-[10px] text-agent-muted-foreground">缓存命中</div>
146
- <div className="text-sm font-semibold text-agent-foreground">{formatTokens(summary.totals.cachedPromptTokens)}</div>
150
+ <div className="text-xs font-semibold text-agent-foreground">{formatTokens(summary.totals.cachedPromptTokens)}</div>
147
151
  </div>
148
152
  <div className="rounded-agent-md border border-agent-border bg-agent-canvas px-2.5 py-2">
149
153
  <div className="text-[10px] text-agent-muted-foreground">估算成本</div>
150
- <div className="text-sm font-semibold text-agent-foreground">{formatCost(summary.totals.costUsd)}</div>
154
+ <div className="text-xs font-semibold text-agent-foreground">{formatCost(summary.totals.costUsd)}</div>
151
155
  </div>
152
156
  </div>
153
157
 
@@ -180,7 +184,7 @@ export function UsagePanel() {
180
184
  </div>
181
185
 
182
186
  <p className="text-[10px] text-agent-muted-foreground">
183
- 成本按 framework 单价表估算,只统计有单价的模型;"—" 表示该模型无单价(本地/未知)。缓存命中为命中 prompt 缓存的 token 数。
187
+ 成本按 framework 单价表估算后按约 1 美元 = 7.2 元换算为人民币,只统计有单价的模型;"—" 表示该模型无单价(本地/未知)。缓存命中为命中 prompt 缓存的 token 数。
184
188
  </p>
185
189
  </>
186
190
  )}
@@ -85,8 +85,8 @@ export function WebSearchSettingsPanel() {
85
85
  };
86
86
 
87
87
  return (
88
- <div className="space-y-4">
89
- <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-3">
88
+ <div className="space-y-3">
89
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-2.5 space-y-2">
90
90
  <h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
91
91
  <LuSearch className="h-3.5 w-3.5 text-agent-muted-foreground" />
92
92
  网络搜索
@@ -394,14 +394,14 @@ function AgentLayoutContent() {
394
394
  }, [chatId, persistRightPanel]);
395
395
 
396
396
  // Same drag ergonomics as the sidebar handle, mirrored: the terminal's
397
- // right edge is pinned to the window's right padding (p-2 = 8px), so the
397
+ // right edge is pinned to the window's right padding (p-1.5 = 6px), so the
398
398
  // width is the distance from the cursor to that edge.
399
399
  useEffect(() => {
400
400
  if (!isTerminalResizing) return;
401
401
  const onMove = (e: MouseEvent) => {
402
402
  const next = Math.min(
403
403
  MAX_TERMINAL_WIDTH,
404
- Math.max(MIN_TERMINAL_WIDTH, window.innerWidth - 8 - e.clientX),
404
+ Math.max(MIN_TERMINAL_WIDTH, window.innerWidth - 6 - e.clientX),
405
405
  );
406
406
  terminalWidthRef.current = next;
407
407
  setTerminalWidth(next);
@@ -484,7 +484,7 @@ function AgentLayoutContent() {
484
484
  />
485
485
  </>
486
486
  )}
487
- <div className="h-full min-w-0 flex-1 p-2">
487
+ <div className="h-full min-w-0 flex-1 p-1.5">
488
488
  <div className="flex h-full w-full">
489
489
  <div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-agent-lg bg-agent-canvas shadow-sm">
490
490
  <div className="min-h-0 flex-1 overflow-hidden">
@@ -53,8 +53,18 @@ import {
53
53
  } from '@steerable/agent-ui/state';
54
54
  import type { SSEEvent } from '@steerable/agent-protocol';
55
55
  import { getElectronBridge } from './electron-bridge';
56
- import { appendDelta, syncTools, type TurnBlock } from '@/components/chat/turn-timeline';
56
+ import {
57
+ appendDelta,
58
+ freezeReasoningDurations,
59
+ sealLastBlock,
60
+ syncTools,
61
+ type TurnBlock,
62
+ } from '@/components/chat/turn-timeline';
57
63
  import type { ExecutedAction } from '@/components/chat/ExecutedActionsCard';
64
+ import {
65
+ LlmRequestSpeedTracker,
66
+ type LlmSpeedSnapshot,
67
+ } from '@/components/chat/process-status';
58
68
 
59
69
  // ---------------------------------------------------------------------------
60
70
  // Local-backend SSE adapter — feeds opaque IPC chunks into the framework's
@@ -68,6 +78,7 @@ class LocalBackendSseAdapter {
68
78
  private completed = false;
69
79
  private readonly parser: SSEParser;
70
80
  private timeline: TurnBlock[] = [];
81
+ private readonly speed = new LlmRequestSpeedTracker();
71
82
 
72
83
  constructor(private readonly onEvent: (event: SSEEvent) => void) {
73
84
  this.parser = new SSEParser({
@@ -78,10 +89,7 @@ class LocalBackendSseAdapter {
78
89
  if (ev) this.handleNormalised(ev);
79
90
  },
80
91
  onComplete: () => {
81
- if (!this.completed) {
82
- this.completed = true;
83
- this.onEvent({ type: 'done' });
84
- }
92
+ this.finishStream();
85
93
  },
86
94
  });
87
95
  }
@@ -92,10 +100,31 @@ class LocalBackendSseAdapter {
92
100
 
93
101
  end(): void {
94
102
  this.parser.end();
95
- if (!this.completed) {
96
- this.completed = true;
97
- this.onEvent({ type: 'done' });
103
+ this.finishStream();
104
+ }
105
+
106
+ private finishStream(): void {
107
+ if (this.completed) return;
108
+ this.completed = true;
109
+ const frozen = freezeReasoningDurations(this.timeline);
110
+ if (frozen !== this.timeline) {
111
+ this.timeline = frozen;
112
+ this.onEvent({
113
+ type: 'agent',
114
+ event: 'turn_timeline',
115
+ payload: { blocks: this.timeline },
116
+ });
98
117
  }
118
+ this.emitLlmSpeed(this.speed.endRequest());
119
+ this.onEvent({ type: 'done' });
120
+ }
121
+
122
+ private emitLlmSpeed(snapshot: LlmSpeedSnapshot): void {
123
+ this.onEvent({
124
+ type: 'agent',
125
+ event: 'llm_speed',
126
+ payload: snapshot,
127
+ });
99
128
  }
100
129
 
101
130
  private handleNormalised(event: SSEEvent): void {
@@ -108,13 +137,11 @@ class LocalBackendSseAdapter {
108
137
  event: 'budget_exhausted_suppressed',
109
138
  payload: { reason: event.message ?? 'budget_exhausted' },
110
139
  } as any);
111
- if (!this.completed) {
112
- this.completed = true;
113
- this.onEvent({ type: 'done' });
114
- }
140
+ this.finishStream();
115
141
  return;
116
142
  }
117
143
  const timelineChanged = this.applyToTimeline(event);
144
+ const speedSnapshot = this.applyLlmClock(event);
118
145
  this.onEvent(event);
119
146
  if (timelineChanged) {
120
147
  this.onEvent({
@@ -123,6 +150,28 @@ class LocalBackendSseAdapter {
123
150
  payload: { blocks: this.timeline },
124
151
  });
125
152
  }
153
+ if (speedSnapshot) this.emitLlmSpeed(speedSnapshot);
154
+ }
155
+
156
+ private applyLlmClock(event: SSEEvent): LlmSpeedSnapshot | null {
157
+ if (event.type === 'content' && typeof event.content === 'string' && event.content.length > 0) {
158
+ return this.speed.noteOutput(event.content);
159
+ }
160
+ if (event.type !== 'agent') return null;
161
+ if (event.event === 'reasoning') {
162
+ const delta =
163
+ typeof event.payload?.content === 'string'
164
+ ? event.payload.content
165
+ : typeof event.payload?.delta === 'string'
166
+ ? event.payload.delta
167
+ : '';
168
+ if (!delta) return null;
169
+ return this.speed.noteOutput(delta);
170
+ }
171
+ if (event.event === 'round_end') {
172
+ return this.speed.endRequest();
173
+ }
174
+ return null;
126
175
  }
127
176
 
128
177
  private applyToTimeline(event: SSEEvent): boolean {
@@ -148,6 +197,13 @@ class LocalBackendSseAdapter {
148
197
  this.timeline = syncTools(this.timeline, actions);
149
198
  return true;
150
199
  }
200
+ if (event.event === 'round_end') {
201
+ if (event.payload?.status === 'cancelled') return false;
202
+ const next = sealLastBlock(this.timeline);
203
+ if (next === this.timeline) return false;
204
+ this.timeline = next;
205
+ return true;
206
+ }
151
207
  return false;
152
208
  }
153
209
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 聊天里「显示思考内容」的本机偏好:隐藏 / 显示 5 行 / 完整显示。
3
3
  *
4
- * 默认「显示 5 行」:流式中露出固定行数,结束后折叠,只留状态行。
4
+ * 默认「显示 5 行」:流式中按轮次各露出 5 行,结束后折叠工作区,只留状态行。
5
5
  * 存在 localStorage,设置页切换立即生效,不走后端。
6
6
  */
7
7
 
@@ -19,9 +19,9 @@ export const THINKING_DISPLAY_OPTIONS: ReadonlyArray<{
19
19
  label: string;
20
20
  hint: string;
21
21
  }> = [
22
- { mode: 'hidden', label: '隐藏', hint: '只保留状态行,不展示思考正文。' },
23
- { mode: 'peek', label: '显示5行', hint: '流式中露出 5 行思考,结束后自动折叠。' },
24
- { mode: 'full', label: '完整显示', hint: '流式中展开全部思考与工具过程,结束后可手动折叠。' },
22
+ { mode: 'hidden', label: '隐藏', hint: '不展示思考正文,只保留工作状态行。' },
23
+ { mode: 'peek', label: '显示5行', hint: '当前轮思考最多露出 5 行,不足不撑开;该轮结束后自动折叠。完整正文请用「完整显示」。' },
24
+ { mode: 'full', label: '完整显示', hint: '当前轮思考完整展开,该轮结束后自动折叠;工作行结束后仍自动折叠。' },
25
25
  ];
26
26
 
27
27
  export function isThinkingDisplayMode(value: unknown): value is ThinkingDisplayMode {
@@ -26,6 +26,10 @@ import type { ExecutedAction } from "@/components/chat/ExecutedActionsCard";
26
26
  import type { ChildInfo } from "@/components/chat/OrchestrationChildrenCard";
27
27
  import { foldOrchestrationChildEvents } from "@/components/chat/orchestration-children-model";
28
28
  import { parseTurnBlocks, type TurnBlock } from "@/components/chat/turn-timeline";
29
+ import {
30
+ parseLlmSpeedPayload,
31
+ type LlmSpeedSnapshot,
32
+ } from "@/components/chat/process-status";
29
33
  import { parseTurnFiles, type TurnFile } from "@/components/chat/turn-files";
30
34
  import { readPersistedDurationMs } from "@/components/chat/elapsed";
31
35
  import {
@@ -297,7 +301,7 @@ function AgentChatLoader({
297
301
 
298
302
  if (initialMessages === null) {
299
303
  return (
300
- <div className="flex h-full w-full items-center justify-center text-sm text-agent-muted-foreground">
304
+ <div className="flex h-full w-full items-center justify-center text-xs text-agent-muted-foreground">
301
305
  加载对话历史…
302
306
  </div>
303
307
  );
@@ -449,6 +453,13 @@ function AgentChatView({
449
453
  number | undefined
450
454
  >(undefined);
451
455
  const pendingDurationMessageIdRef = useRef<string | null>(null);
456
+ const [currentLlmSpeed, setCurrentLlmSpeed] = useState<LlmSpeedSnapshot | undefined>(
457
+ undefined,
458
+ );
459
+ const currentLlmSpeedRef = useRef<LlmSpeedSnapshot | null>(null);
460
+ const [llmSpeedByMessageId, setLlmSpeedByMessageId] = useState<
461
+ Record<string, LlmSpeedSnapshot>
462
+ >({});
452
463
  // P3.1 编排:本轮子代理生命周期(orchestration_child SSE 事件累积),
453
464
  // 与 currentTurnActions 同款 reconcile 模式(message_id 落库后按键归档)。
454
465
  const [currentTurnChildren, setCurrentTurnChildren] = useState<ChildInfo[]>([]);
@@ -519,6 +530,13 @@ function AgentChatView({
519
530
  }
520
531
  return;
521
532
  }
533
+ if (ev.event === "llm_speed") {
534
+ const snap = parseLlmSpeedPayload(ev.payload);
535
+ if (!snap) return;
536
+ currentLlmSpeedRef.current = snap;
537
+ setCurrentLlmSpeed(snap);
538
+ return;
539
+ }
522
540
  if (ev.event === "turn_files") {
523
541
  // 回合收尾时后端发一次(在 message_id 之前);先挂 pending,
524
542
  // message_id 到达时随其他队列一起归档。
@@ -583,6 +601,13 @@ function AgentChatView({
583
601
  const messageId = ev.payload?.messageId as string | undefined;
584
602
  if (!messageId) return;
585
603
  pendingDurationMessageIdRef.current = messageId;
604
+ const queuedSpeed = currentLlmSpeedRef.current;
605
+ if (queuedSpeed) {
606
+ setLlmSpeedByMessageId((prev) => ({
607
+ ...prev,
608
+ [messageId]: queuedSpeed,
609
+ }));
610
+ }
586
611
  const queued = pendingActionsRef.current;
587
612
  if (queued.length > 0) {
588
613
  setExecutedActionsByMessageId((prev) => ({
@@ -727,6 +752,25 @@ function AgentChatView({
727
752
  }
728
753
  return next;
729
754
  });
755
+ const snap = currentLlmSpeedRef.current;
756
+ if (snap) {
757
+ const frozen: LlmSpeedSnapshot = {
758
+ ...snap,
759
+ live: false,
760
+ requestStartedAt: null,
761
+ elapsedMs: snap.elapsedMs,
762
+ };
763
+ currentLlmSpeedRef.current = frozen;
764
+ setCurrentLlmSpeed(frozen);
765
+ setLlmSpeedByMessageId((prev) => {
766
+ const next = { ...prev };
767
+ if (lastAssistant) next[lastAssistant.id] = frozen;
768
+ if (pendingDurationMessageIdRef.current) {
769
+ next[pendingDurationMessageIdRef.current] = frozen;
770
+ }
771
+ return next;
772
+ });
773
+ }
730
774
  }
731
775
  }
732
776
  wasStreamingRef.current = isStreaming;
@@ -770,6 +814,8 @@ function AgentChatView({
770
814
  turnStartedAtRef.current = started;
771
815
  setCurrentTurnStartedAtMs(started);
772
816
  pendingDurationMessageIdRef.current = null;
817
+ currentLlmSpeedRef.current = null;
818
+ setCurrentLlmSpeed(undefined);
773
819
  // 新一轮开始:记录本轮是否为 plan 模式,并隐藏上一份计划的操作条。
774
820
  planTurnRef.current = input.metadata?.mode === "plan";
775
821
  setPlanReady(false);
@@ -842,6 +888,8 @@ function AgentChatView({
842
888
  turnStartedAtRef.current = started;
843
889
  setCurrentTurnStartedAtMs(started);
844
890
  pendingDurationMessageIdRef.current = null;
891
+ currentLlmSpeedRef.current = null;
892
+ setCurrentLlmSpeed(undefined);
845
893
  planTurnRef.current = mode === "plan";
846
894
  setPlanReady(false);
847
895
  const metadata = {
@@ -987,7 +1035,7 @@ function AgentChatView({
987
1035
  return (
988
1036
  <div className="flex h-full w-full flex-col">
989
1037
  {hydrationError && (
990
- <div className="border-b border-agent-border bg-agent-muted/60 px-3 py-1 text-xs text-agent-destructive">
1038
+ <div className="border-b border-agent-border bg-agent-muted/60 px-2.5 py-1 text-xs text-agent-destructive">
991
1039
  加载对话历史失败:{hydrationError}
992
1040
  </div>
993
1041
  )}
@@ -1035,6 +1083,8 @@ function AgentChatView({
1035
1083
  currentTurnTimeline={effectiveCurrentTurnTimeline}
1036
1084
  currentTurnStartedAtMs={currentTurnStartedAtMs}
1037
1085
  durationByMessageId={durationByMessageId}
1086
+ currentLlmSpeed={currentLlmSpeed}
1087
+ llmSpeedByMessageId={llmSpeedByMessageId}
1038
1088
  turnFilesByMessageId={turnFilesByMessageId}
1039
1089
  currentTurnFiles={currentTurnFiles}
1040
1090
  currentTurnChildren={effectiveCurrentTurnChildren}
@@ -1083,7 +1133,7 @@ function AgentChatView({
1083
1133
  onTrustChanged={fetchProjects}
1084
1134
  />
1085
1135
  {planReady && !isStreaming ? (
1086
- <div className="mx-3 mb-1 flex items-center justify-between gap-3 rounded-agent-md border border-amber-400/50 bg-amber-400/10 px-3 py-2 text-xs">
1136
+ <div className="mx-2.5 mb-1 flex items-center justify-between gap-2 rounded-agent-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1.5 text-xs">
1087
1137
  <div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
1088
1138
  <LuListChecks className="h-4 w-4 shrink-0" />
1089
1139
  <span>计划已生成。确认无误后可切换到 Agent 模式开始执行。</span>
@@ -1264,15 +1314,15 @@ function EmptyChatGate() {
1264
1314
 
1265
1315
  return (
1266
1316
  <div
1267
- className="flex h-full w-full items-center justify-center p-4 sm:p-8"
1317
+ className="flex h-full w-full items-center justify-center p-3 sm:p-5"
1268
1318
  data-testid="empty-chat-home"
1269
1319
  >
1270
- <div className="flex w-full max-w-2xl flex-col items-center gap-6">
1320
+ <div className="flex w-full max-w-2xl flex-col items-center gap-4">
1271
1321
  <div className="text-center">
1272
1322
  <h1 className="text-xl font-semibold tracking-tight text-agent-foreground">
1273
1323
  {BRAND_NAME}
1274
1324
  </h1>
1275
- <p className="mt-1.5 text-sm text-agent-muted-foreground">
1325
+ <p className="mt-1.5 text-xs text-agent-muted-foreground">
1276
1326
  输入消息,直接开始一段新对话。
1277
1327
  </p>
1278
1328
  </div>
@@ -82,7 +82,28 @@ Tests: 1 failed, 2 passed, 3 total
82
82
  },
83
83
  ];
84
84
 
85
+ const MOCK_TODOS = [
86
+ { id: 'survey', content: '检查服务连通性', status: 'completed' as const },
87
+ { id: 'test', content: '跑本目录测试套件', status: 'in_progress' as const },
88
+ { id: 'fix', content: '修失败的断言颜色', status: 'pending' as const },
89
+ ];
90
+
91
+ const MOCK_TODO_ACTION: ExecutedAction = {
92
+ tool: 'todo_write',
93
+ arguments: { todos: MOCK_TODOS },
94
+ result: {
95
+ success: true,
96
+ data: {
97
+ value: {
98
+ todos: MOCK_TODOS,
99
+ summary: { total: 3, pending: 1, inProgress: 1, completed: 1 },
100
+ },
101
+ },
102
+ },
103
+ };
104
+
85
105
  const MOCK_ACTIONS: ExecutedAction[] = [
106
+ MOCK_TODO_ACTION,
86
107
  {
87
108
  tool: 'check_connection',
88
109
  arguments: { host: '127.0.0.1', port: 8765 },
@@ -107,11 +128,12 @@ const MOCK_ACTIONS: ExecutedAction[] = [
107
128
 
108
129
  const MOCK_TIMELINE: TurnBlock[] = [
109
130
  { type: 'reasoning', content: '先检查服务连通性,再跑测试套件。' },
110
- { type: 'tools', actions: [MOCK_ACTIONS[0]] },
111
- { type: 'text', content: '连接正常。接下来跑测试套件。' },
131
+ { type: 'tools', actions: [MOCK_TODO_ACTION] },
112
132
  { type: 'tools', actions: [MOCK_ACTIONS[1]] },
113
- { type: 'reasoning', content: '测试失败,读一下失败文件再给建议。' },
133
+ { type: 'text', content: '连接正常。接下来跑测试套件。' },
114
134
  { type: 'tools', actions: [MOCK_ACTIONS[2]] },
135
+ { type: 'reasoning', content: '测试失败,读一下失败文件再给建议。' },
136
+ { type: 'tools', actions: [MOCK_ACTIONS[3]] },
115
137
  { type: 'text', content: MOCK_MESSAGES[1].content ?? '' },
116
138
  ];
117
139
 
@@ -284,7 +306,7 @@ export function ChatPanelPreviewPage() {
284
306
 
285
307
  return (
286
308
  <div className="flex h-full w-full flex-col bg-agent-muted/30">
287
- <div className="flex shrink-0 items-center gap-2 border-b border-agent-border bg-agent-canvas px-3 py-2 text-xs">
309
+ <div className="flex shrink-0 items-center gap-2 border-b border-agent-border bg-agent-canvas px-2.5 py-1.5 text-xs">
288
310
  <span className="text-agent-muted-foreground">
289
311
  /preview/chat (dev harness)
290
312
  </span>