@steerable/agent-shell-web 0.7.2 → 0.8.0
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/package.json +4 -4
- package/src/components/AgentSidebar.tsx +1 -0
- package/src/components/LocalLlmSettingsModal.tsx +40 -3
- package/src/components/chat/ApprovalModal.tsx +10 -0
- package/src/components/chat/AssistantMessage.tsx +252 -15
- package/src/components/chat/ExecutedActionsCard.tsx +39 -8
- package/src/components/chat/LocalChatPanel.tsx +10 -0
- package/src/components/chat/MessageList.tsx +21 -0
- package/src/components/chat/RetryStatus.tsx +77 -0
- package/src/components/chat/StreamingStatus.tsx +9 -0
- package/src/components/chat/TurnFailureCard.tsx +272 -0
- package/src/components/chat/executed-actions-model.ts +119 -1
- package/src/components/chat/turn-failure.ts +185 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +113 -2
- package/src/components/settings/LlmSettingsPanel.tsx +18 -0
- package/src/layouts/AgentLayout.tsx +1 -1
- package/src/layouts/RightPanelTabBar.tsx +1 -1
- package/src/layouts/right-panel-tabs.ts +1 -1
- package/src/lib/local-api.ts +18 -0
- package/src/pages/AgentPage.tsx +100 -0
- package/src/pages/SettingsPage.tsx +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"react-router-dom": "^6.30.0",
|
|
46
46
|
"rehype-raw": "^7.0.0",
|
|
47
47
|
"remark-gfm": "^4.0.1",
|
|
48
|
-
"@steerable/agent-protocol": "0.
|
|
49
|
-
"@steerable/agent-ui": "0.
|
|
48
|
+
"@steerable/agent-protocol": "0.8.0",
|
|
49
|
+
"@steerable/agent-ui": "0.8.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@tailwindcss/vite": "^4.3.0",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"typescript": "^5.8.3",
|
|
60
60
|
"vite": "^5.4.21",
|
|
61
61
|
"vitest": "^3.2.4",
|
|
62
|
-
"@steerable/pack-sdk": "0.
|
|
62
|
+
"@steerable/pack-sdk": "0.8.0"
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
65
|
"gen": "echo agent-shell-web",
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import { LuX } from 'react-icons/lu';
|
|
4
4
|
import type { LlmSettings } from '@/lib/local-api';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
LlmSettingsPanel,
|
|
7
|
+
type LlmSettingsPanelHandle,
|
|
8
|
+
} from '@/components/settings/LlmSettingsPanel';
|
|
6
9
|
import { t } from '@/i18n';
|
|
7
10
|
|
|
8
11
|
interface LocalLlmSettingsModalProps {
|
|
@@ -16,6 +19,7 @@ interface LocalLlmSettingsModalProps {
|
|
|
16
19
|
* 侧栏「设置」走 `/settings` 页(模型 + 洞察 / 遥测 / 用量 / 搜索 / 安全)。
|
|
17
20
|
*/
|
|
18
21
|
export function LocalLlmSettingsModal({ open, onClose, onSaved }: LocalLlmSettingsModalProps) {
|
|
22
|
+
const panelRef = useRef<LlmSettingsPanelHandle | null>(null);
|
|
19
23
|
useEffect(() => {
|
|
20
24
|
if (!open) return;
|
|
21
25
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -25,6 +29,39 @@ export function LocalLlmSettingsModal({ open, onClose, onSaved }: LocalLlmSettin
|
|
|
25
29
|
return () => window.removeEventListener('keydown', onKey);
|
|
26
30
|
}, [open, onClose]);
|
|
27
31
|
|
|
32
|
+
// An authentication failure opens this modal specifically to fix the key,
|
|
33
|
+
// so put the cursor there instead of at the top of a form the reader then
|
|
34
|
+
// has to scan.
|
|
35
|
+
//
|
|
36
|
+
// It goes through the panel's handle rather than a document query: the panel
|
|
37
|
+
// owns the field, mounts it asynchronously once settings load, and knows when
|
|
38
|
+
// it is actually focusable. Polling the DOM found an element that had already
|
|
39
|
+
// been replaced by a re-render, so the focus call landed on a detached node.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!open) return;
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
let timer: number | undefined;
|
|
44
|
+
const focusKey = () => {
|
|
45
|
+
if (cancelled) return;
|
|
46
|
+
// 用户已经开始在别处输入时不要抢焦点。
|
|
47
|
+
const active = document.activeElement;
|
|
48
|
+
const userTyping =
|
|
49
|
+
active instanceof HTMLElement &&
|
|
50
|
+
active !== document.body &&
|
|
51
|
+
(active.tagName === 'INPUT' ||
|
|
52
|
+
active.tagName === 'TEXTAREA' ||
|
|
53
|
+
active.isContentEditable);
|
|
54
|
+
if (userTyping) return;
|
|
55
|
+
if (panelRef.current?.focusApiKey()) return;
|
|
56
|
+
timer = window.setTimeout(focusKey, 50);
|
|
57
|
+
};
|
|
58
|
+
focusKey();
|
|
59
|
+
return () => {
|
|
60
|
+
cancelled = true;
|
|
61
|
+
if (timer !== undefined) window.clearTimeout(timer);
|
|
62
|
+
};
|
|
63
|
+
}, [open]);
|
|
64
|
+
|
|
28
65
|
if (!open) return null;
|
|
29
66
|
if (typeof document === 'undefined') return null;
|
|
30
67
|
|
|
@@ -55,7 +92,7 @@ export function LocalLlmSettingsModal({ open, onClose, onSaved }: LocalLlmSettin
|
|
|
55
92
|
</button>
|
|
56
93
|
</div>
|
|
57
94
|
<div className="flex-1 overflow-y-auto p-3">
|
|
58
|
-
<LlmSettingsPanel onSaved={onSaved} />
|
|
95
|
+
<LlmSettingsPanel ref={panelRef} onSaved={onSaved} />
|
|
59
96
|
</div>
|
|
60
97
|
<div className="flex h-9 flex-shrink-0 items-center justify-end border-t border-agent-border px-3 bg-agent-muted/10">
|
|
61
98
|
<button
|
|
@@ -167,6 +167,16 @@ export function ApprovalPromptMenu({
|
|
|
167
167
|
</span>
|
|
168
168
|
) : null}
|
|
169
169
|
</>
|
|
170
|
+
) : isBrowser && current.toolName === 'browser_navigate' ? (
|
|
171
|
+
<>
|
|
172
|
+
{t('Agent requests to open a page')}
|
|
173
|
+
<span className="mx-1 font-mono">{current.toolName}</span>
|
|
174
|
+
{current.categoryLabel ? (
|
|
175
|
+
<span className="text-agent-muted-foreground">
|
|
176
|
+
{t('({category})', { category: categoryName(current.categoryLabel) })}
|
|
177
|
+
</span>
|
|
178
|
+
) : null}
|
|
179
|
+
</>
|
|
170
180
|
) : (
|
|
171
181
|
<>
|
|
172
182
|
{t('Agent requests to run')}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { motion } from 'framer-motion';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
LuCheck,
|
|
5
|
+
LuChevronDown,
|
|
6
|
+
LuChevronUp,
|
|
7
|
+
LuCopy,
|
|
8
|
+
LuListChecks,
|
|
9
|
+
LuLoaderCircle,
|
|
10
|
+
LuRefreshCw,
|
|
11
|
+
LuShare2,
|
|
12
|
+
LuTriangleAlert,
|
|
13
|
+
} from 'react-icons/lu';
|
|
4
14
|
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
5
15
|
import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
|
|
6
16
|
import { t } from '@/i18n';
|
|
@@ -10,6 +20,9 @@ import type { ChildInfo } from './OrchestrationChildrenCard';
|
|
|
10
20
|
import { collectTurnParticipants, extractMentionedAgentIds } from './executed-actions-model';
|
|
11
21
|
import type { InspectTaskInput } from './executed-actions-model';
|
|
12
22
|
import { StreamingStatus } from './StreamingStatus';
|
|
23
|
+
import { TurnFailureCard, type TurnFailureCardProps } from './TurnFailureCard';
|
|
24
|
+
import { readFailureDetail, type TurnFailureDetail } from './turn-failure';
|
|
25
|
+
import type { RetryState } from './RetryStatus';
|
|
13
26
|
import { getFriendlyDate } from './timestamp';
|
|
14
27
|
import { useCopy } from './useCopy';
|
|
15
28
|
import type { TurnBlock } from './turn-timeline';
|
|
@@ -101,6 +114,12 @@ interface AssistantMessageProps {
|
|
|
101
114
|
* `StreamingStatus` to render "Round 2 · 继续推理..." between LLM bursts.
|
|
102
115
|
*/
|
|
103
116
|
currentRound?: number;
|
|
117
|
+
/**
|
|
118
|
+
* Live retry state of the in-flight request. While set, the status line
|
|
119
|
+
* says "reconnecting" instead of "thinking": a seconds-scale backoff and a
|
|
120
|
+
* stalled request look the same without it.
|
|
121
|
+
*/
|
|
122
|
+
retryState?: RetryState | null;
|
|
104
123
|
/** True iff the message was generated in Plan mode. */
|
|
105
124
|
isPlanMode?: boolean;
|
|
106
125
|
/**
|
|
@@ -110,6 +129,12 @@ interface AssistantMessageProps {
|
|
|
110
129
|
* has fully streamed; the caller then re-hydrates the message list.
|
|
111
130
|
*/
|
|
112
131
|
onRegenerate?: (messageId: string) => Promise<void>;
|
|
132
|
+
/**
|
|
133
|
+
* Resolves a failed turn's action into a handler. The message list decides
|
|
134
|
+
* what this build can do (Continue needs a backend route, opening settings
|
|
135
|
+
* needs the modal); the card only draws what it is given.
|
|
136
|
+
*/
|
|
137
|
+
failureAction?: TurnFailureCardProps['action'];
|
|
113
138
|
/** Epoch ms when this turn started — live ticker while streaming. */
|
|
114
139
|
startedAtMs?: number;
|
|
115
140
|
/** Frozen wall-clock of a finished turn. */
|
|
@@ -188,11 +213,71 @@ function bubbleClass(isPlanMode: boolean): string {
|
|
|
188
213
|
// 两处汇合到同一个错误气泡,失败回合不再只显示"(空消息)"。
|
|
189
214
|
const STREAM_ERROR_PREFIX = '[stream error] ';
|
|
190
215
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
216
|
+
interface TurnFailure {
|
|
217
|
+
reason: string;
|
|
218
|
+
contentIsError: boolean;
|
|
219
|
+
/** Classified detail; undefined for live stream errors and old records. */
|
|
220
|
+
detail?: TurnFailureDetail;
|
|
221
|
+
traceId?: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Terminal state of a persisted turn, for the card it should show.
|
|
226
|
+
*
|
|
227
|
+
* `failed` and `cancelled` are different for the reader even though both can
|
|
228
|
+
* be continued: one is the network's fault and comes back as a failure card,
|
|
229
|
+
* the other is the user's own Stop and reads as a neutral notice. Absent
|
|
230
|
+
* metadata means the turn finished normally (older records carry no status).
|
|
231
|
+
*/
|
|
232
|
+
/**
|
|
233
|
+
* Whether this failed/stopped turn was continued afterwards.
|
|
234
|
+
*
|
|
235
|
+
* The host stamps `continuedAt` when a resume starts from this turn. The card
|
|
236
|
+
* then collapses into one line: the failure is history the user already acted
|
|
237
|
+
* on, and re-showing the full card after a reload would read as "it failed
|
|
238
|
+
* again". Expanding it still yields the original diagnostics.
|
|
239
|
+
*/
|
|
240
|
+
function readContinued(message: ChatMessage): boolean {
|
|
241
|
+
const raw = (message as { messageMetadata?: unknown }).messageMetadata;
|
|
242
|
+
if (typeof raw !== 'string' || !raw) return false;
|
|
243
|
+
try {
|
|
244
|
+
const at = (JSON.parse(raw) as { continuedAt?: unknown }).continuedAt;
|
|
245
|
+
return typeof at === 'string' && at.length > 0;
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** True when the user's Stop landed while a retry was pending. */
|
|
252
|
+
function readStoppedDuringRetry(message: ChatMessage): boolean {
|
|
253
|
+
const raw = (message as { messageMetadata?: unknown }).messageMetadata;
|
|
254
|
+
if (typeof raw !== 'string' || !raw) return false;
|
|
255
|
+
try {
|
|
256
|
+
const reason = (JSON.parse(raw) as { completionReason?: unknown }).completionReason;
|
|
257
|
+
return reason === 'aborted_during_retry';
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readTurnStatus(message: ChatMessage): 'failed' | 'cancelled' | 'completed' {
|
|
264
|
+
const raw = (message as { messageMetadata?: unknown }).messageMetadata;
|
|
265
|
+
if (typeof raw !== 'string' || !raw) return 'completed';
|
|
266
|
+
try {
|
|
267
|
+
const status = (JSON.parse(raw) as { completionStatus?: unknown }).completionStatus;
|
|
268
|
+
if (status === 'failed') return 'failed';
|
|
269
|
+
if (status === 'cancelled') return 'cancelled';
|
|
270
|
+
return 'completed';
|
|
271
|
+
} catch {
|
|
272
|
+
// 元数据损坏按正常完成处理——落回原有的空消息展示。
|
|
273
|
+
return 'completed';
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function readTurnFailure(message: ChatMessage, content: string): TurnFailure | null {
|
|
195
278
|
if (content.startsWith(STREAM_ERROR_PREFIX)) {
|
|
279
|
+
// Live path: the framework stamped the reason into the content before the
|
|
280
|
+
// turn was persisted, so nothing structured is available yet.
|
|
196
281
|
return { reason: content.slice(STREAM_ERROR_PREFIX.length), contentIsError: true };
|
|
197
282
|
}
|
|
198
283
|
const raw = (message as { messageMetadata?: unknown }).messageMetadata;
|
|
@@ -201,13 +286,20 @@ function readTurnFailure(
|
|
|
201
286
|
const meta = JSON.parse(raw) as {
|
|
202
287
|
completionStatus?: unknown;
|
|
203
288
|
completionReason?: unknown;
|
|
289
|
+
failure?: unknown;
|
|
290
|
+
traceId?: unknown;
|
|
204
291
|
};
|
|
205
292
|
if (
|
|
206
293
|
meta?.completionStatus === 'failed' &&
|
|
207
294
|
typeof meta.completionReason === 'string' &&
|
|
208
295
|
meta.completionReason
|
|
209
296
|
) {
|
|
210
|
-
return {
|
|
297
|
+
return {
|
|
298
|
+
reason: meta.completionReason,
|
|
299
|
+
contentIsError: false,
|
|
300
|
+
detail: readFailureDetail(meta.failure),
|
|
301
|
+
traceId: typeof meta.traceId === 'string' && meta.traceId ? meta.traceId : undefined,
|
|
302
|
+
};
|
|
211
303
|
}
|
|
212
304
|
} catch {
|
|
213
305
|
// 元数据损坏按无失败处理——落回原有的空消息展示。
|
|
@@ -226,10 +318,102 @@ function readPersistedAgentId(message: ChatMessage): string | undefined {
|
|
|
226
318
|
}
|
|
227
319
|
}
|
|
228
320
|
|
|
229
|
-
function TurnErrorBubble({
|
|
321
|
+
function TurnErrorBubble({
|
|
322
|
+
failure,
|
|
323
|
+
completedSteps,
|
|
324
|
+
happenedAt,
|
|
325
|
+
onAction,
|
|
326
|
+
}: {
|
|
327
|
+
failure: TurnFailure;
|
|
328
|
+
completedSteps: number;
|
|
329
|
+
happenedAt?: string;
|
|
330
|
+
onAction?: TurnFailureCardProps['action'];
|
|
331
|
+
}) {
|
|
332
|
+
return (
|
|
333
|
+
<TurnFailureCard
|
|
334
|
+
detail={failure.detail}
|
|
335
|
+
reason={failure.reason}
|
|
336
|
+
completedSteps={completedSteps}
|
|
337
|
+
happenedAt={happenedAt}
|
|
338
|
+
traceId={failure.traceId}
|
|
339
|
+
action={onAction}
|
|
340
|
+
/>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* One collapsed line standing in for a failure the user already continued.
|
|
346
|
+
*
|
|
347
|
+
* It stays expandable rather than disappearing: the diagnostics are the only
|
|
348
|
+
* record of what went wrong, and someone filing a report a day later still
|
|
349
|
+
* needs them. The default is collapsed so a resumed conversation reads as
|
|
350
|
+
* continuous work rather than a stack of past failures.
|
|
351
|
+
*/
|
|
352
|
+
function ContinuedHereLine({ children }: { children: React.ReactNode }) {
|
|
353
|
+
const [open, setOpen] = useState(false);
|
|
230
354
|
return (
|
|
231
|
-
<div
|
|
232
|
-
|
|
355
|
+
<div data-testid="continued-here">
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
onClick={() => setOpen((value) => !value)}
|
|
359
|
+
aria-expanded={open}
|
|
360
|
+
className="inline-flex items-center gap-1 text-[11px] text-agent-muted-foreground hover:text-agent-foreground"
|
|
361
|
+
data-action="continued-here"
|
|
362
|
+
>
|
|
363
|
+
<LuTriangleAlert className="h-3 w-3" />
|
|
364
|
+
{t('Connection failed here. Continued.')}
|
|
365
|
+
{open ? <LuChevronUp className="h-3 w-3" /> : <LuChevronDown className="h-3 w-3" />}
|
|
366
|
+
</button>
|
|
367
|
+
{open ? <div className="mt-1.5">{children}</div> : null}
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Neutral notice for a turn the user stopped.
|
|
374
|
+
*
|
|
375
|
+
* Deliberately not the failure card: nothing went wrong, the user chose to
|
|
376
|
+
* stop. It still offers Continue, because the usual reason to stop a turn is
|
|
377
|
+
* impatience with a retry, and the work done so far is worth keeping. The copy
|
|
378
|
+
* distinguishes a stop during reconnection — the case where "continue" is
|
|
379
|
+
* almost certainly wanted — from a stop while the model was writing.
|
|
380
|
+
*/
|
|
381
|
+
function TurnStoppedCard({
|
|
382
|
+
duringRetry,
|
|
383
|
+
action,
|
|
384
|
+
}: {
|
|
385
|
+
duringRetry: boolean;
|
|
386
|
+
action?: TurnFailureCardProps['action'];
|
|
387
|
+
}) {
|
|
388
|
+
const handler = action?.({ kind: 'retry' }) ?? null;
|
|
389
|
+
return (
|
|
390
|
+
<div
|
|
391
|
+
className="rounded-agent-lg border border-agent-border bg-agent-muted/40 px-2.5 py-2 text-xs leading-relaxed"
|
|
392
|
+
data-testid="turn-stopped"
|
|
393
|
+
>
|
|
394
|
+
<div className="flex items-start gap-2 font-medium text-agent-foreground">
|
|
395
|
+
<span className="mt-[3px] h-2 w-2 flex-shrink-0 rounded-[2px] bg-agent-muted-foreground" />
|
|
396
|
+
<span>{t('Stopped')}</span>
|
|
397
|
+
</div>
|
|
398
|
+
<p className="mt-1 ml-5 text-agent-muted-foreground">
|
|
399
|
+
{duringRetry
|
|
400
|
+
? t('You stopped this task while it was reconnecting.')
|
|
401
|
+
: t('You stopped this task.')}
|
|
402
|
+
</p>
|
|
403
|
+
{handler ? (
|
|
404
|
+
<div className="mt-2 ml-5">
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
disabled={handler.disabled}
|
|
408
|
+
title={handler.hint}
|
|
409
|
+
onClick={handler.onClick}
|
|
410
|
+
className="rounded-agent-md border border-agent-border bg-agent-card px-3 py-1 text-xs text-agent-foreground transition hover:bg-agent-muted disabled:opacity-50"
|
|
411
|
+
data-action="continue"
|
|
412
|
+
>
|
|
413
|
+
{t('Continue')}
|
|
414
|
+
</button>
|
|
415
|
+
</div>
|
|
416
|
+
) : null}
|
|
233
417
|
</div>
|
|
234
418
|
);
|
|
235
419
|
}
|
|
@@ -263,8 +447,10 @@ export function AssistantMessage({
|
|
|
263
447
|
orchestrationChildren,
|
|
264
448
|
previousUser,
|
|
265
449
|
currentRound = 1,
|
|
450
|
+
retryState,
|
|
266
451
|
isPlanMode = false,
|
|
267
452
|
onRegenerate,
|
|
453
|
+
failureAction,
|
|
268
454
|
startedAtMs,
|
|
269
455
|
durationMs,
|
|
270
456
|
llmSpeed,
|
|
@@ -274,6 +460,9 @@ export function AssistantMessage({
|
|
|
274
460
|
}: AssistantMessageProps) {
|
|
275
461
|
const content = message.content || '';
|
|
276
462
|
const failure = readTurnFailure(message, content);
|
|
463
|
+
const stopped = !failure && readTurnStatus(message) === 'cancelled';
|
|
464
|
+
const stoppedDuringRetry = stopped && readStoppedDuringRetry(message);
|
|
465
|
+
const continued = readContinued(message);
|
|
277
466
|
// live 错误文本("[stream error] ...")不当正文渲染;落库的失败回合可能
|
|
278
467
|
// 留有部分正文,此时正文与错误气泡都展示。
|
|
279
468
|
const displayContent = failure?.contentIsError ? '' : content;
|
|
@@ -308,6 +497,10 @@ export function AssistantMessage({
|
|
|
308
497
|
llmRequestElapsedMs(llmSpeed, now),
|
|
309
498
|
);
|
|
310
499
|
|
|
500
|
+
// A continued failure renders as one line; expanding it yields the card.
|
|
501
|
+
const wrapContinued = (card: React.ReactNode) =>
|
|
502
|
+
continued ? <ContinuedHereLine>{card}</ContinuedHereLine> : card;
|
|
503
|
+
|
|
311
504
|
return (
|
|
312
505
|
<motion.div
|
|
313
506
|
className="group/message"
|
|
@@ -359,15 +552,25 @@ export function AssistantMessage({
|
|
|
359
552
|
orchestrationChildren={orchestrationChildren}
|
|
360
553
|
emptyFallback={
|
|
361
554
|
failure ? (
|
|
362
|
-
|
|
555
|
+
wrapContinued(
|
|
556
|
+
<TurnErrorBubble
|
|
557
|
+
failure={failure}
|
|
558
|
+
completedSteps={toolActions.length}
|
|
559
|
+
happenedAt={message.createdAt}
|
|
560
|
+
onAction={failureAction}
|
|
561
|
+
/>,
|
|
562
|
+
)
|
|
363
563
|
) : isStreaming ? (
|
|
364
564
|
<div className={bubbleClass(isPlanMode)}>
|
|
365
565
|
<StreamingStatus
|
|
366
566
|
round={currentRound}
|
|
367
567
|
actionCount={executedActions?.length ?? 0}
|
|
368
568
|
hasContent={false}
|
|
569
|
+
retry={retryState ?? undefined}
|
|
369
570
|
/>
|
|
370
571
|
</div>
|
|
572
|
+
) : stopped ? (
|
|
573
|
+
<TurnStoppedCard duringRetry={stoppedDuringRetry} action={failureAction} />
|
|
371
574
|
) : (
|
|
372
575
|
<div className={`${bubbleClass(isPlanMode)} text-xs italic text-agent-muted-foreground`}>
|
|
373
576
|
{t('(empty message)')}
|
|
@@ -379,6 +582,7 @@ export function AssistantMessage({
|
|
|
379
582
|
round={currentRound}
|
|
380
583
|
actionCount={executedActions?.length ?? 0}
|
|
381
584
|
hasContent={false}
|
|
585
|
+
retry={retryState ?? undefined}
|
|
382
586
|
/>
|
|
383
587
|
}
|
|
384
588
|
renderAnswer={(block, isLast) => (
|
|
@@ -394,7 +598,19 @@ export function AssistantMessage({
|
|
|
394
598
|
(无块时走上面的 emptyFallback,不会重复)。 */}
|
|
395
599
|
{failure && blocks.length > 0 ? (
|
|
396
600
|
<div className="mt-1.5">
|
|
397
|
-
|
|
601
|
+
{wrapContinued(
|
|
602
|
+
<TurnErrorBubble
|
|
603
|
+
failure={failure}
|
|
604
|
+
completedSteps={toolActions.length}
|
|
605
|
+
happenedAt={message.createdAt}
|
|
606
|
+
onAction={failureAction}
|
|
607
|
+
/>,
|
|
608
|
+
)}
|
|
609
|
+
</div>
|
|
610
|
+
) : null}
|
|
611
|
+
{stopped && blocks.length > 0 ? (
|
|
612
|
+
<div className="mt-1.5">
|
|
613
|
+
<TurnStoppedCard duringRetry={stoppedDuringRetry} action={failureAction} />
|
|
398
614
|
</div>
|
|
399
615
|
) : null}
|
|
400
616
|
</>
|
|
@@ -417,15 +633,25 @@ export function AssistantMessage({
|
|
|
417
633
|
{isStreaming && <StreamingCursor />}
|
|
418
634
|
</div>
|
|
419
635
|
) : failure ? (
|
|
420
|
-
|
|
636
|
+
wrapContinued(
|
|
637
|
+
<TurnErrorBubble
|
|
638
|
+
failure={failure}
|
|
639
|
+
completedSteps={toolActions.length}
|
|
640
|
+
happenedAt={message.createdAt}
|
|
641
|
+
onAction={failureAction}
|
|
642
|
+
/>,
|
|
643
|
+
)
|
|
421
644
|
) : isStreaming ? (
|
|
422
645
|
<div className={bubbleClass(isPlanMode)}>
|
|
423
646
|
<StreamingStatus
|
|
424
647
|
round={currentRound}
|
|
425
648
|
actionCount={executedActions?.length ?? 0}
|
|
426
649
|
hasContent={false}
|
|
650
|
+
retry={retryState ?? undefined}
|
|
427
651
|
/>
|
|
428
652
|
</div>
|
|
653
|
+
) : stopped ? (
|
|
654
|
+
<TurnStoppedCard duringRetry={stoppedDuringRetry} action={failureAction} />
|
|
429
655
|
) : (
|
|
430
656
|
<div className={`${bubbleClass(isPlanMode)} text-xs italic text-agent-muted-foreground`}>
|
|
431
657
|
{t('(empty message)')}
|
|
@@ -433,7 +659,14 @@ export function AssistantMessage({
|
|
|
433
659
|
)}
|
|
434
660
|
{displayContent && failure ? (
|
|
435
661
|
<div className="mt-1.5">
|
|
436
|
-
|
|
662
|
+
{wrapContinued(
|
|
663
|
+
<TurnErrorBubble
|
|
664
|
+
failure={failure}
|
|
665
|
+
completedSteps={toolActions.length}
|
|
666
|
+
happenedAt={message.createdAt}
|
|
667
|
+
onAction={failureAction}
|
|
668
|
+
/>,
|
|
669
|
+
)}
|
|
437
670
|
</div>
|
|
438
671
|
) : null}
|
|
439
672
|
</>
|
|
@@ -474,7 +707,11 @@ export function AssistantMessage({
|
|
|
474
707
|
)}
|
|
475
708
|
</button>
|
|
476
709
|
)}
|
|
477
|
-
{
|
|
710
|
+
{/* Regenerate is offered for a failed or stopped turn even when it
|
|
711
|
+
produced no text: the reply the user wanted is missing, and
|
|
712
|
+
regenerating from the turn's start is the alternative to
|
|
713
|
+
Continue when Continue is not the right answer. */}
|
|
714
|
+
{(content || failure || stopped) && !isStreaming && onRegenerate && (
|
|
478
715
|
<button
|
|
479
716
|
type="button"
|
|
480
717
|
disabled={regenerating}
|
|
@@ -497,7 +734,7 @@ export function AssistantMessage({
|
|
|
497
734
|
title={
|
|
498
735
|
regenerating
|
|
499
736
|
? t('Regenerating...')
|
|
500
|
-
: t('Regenerate (the old reply is kept as a branch)')
|
|
737
|
+
: t('Regenerate (start this turn over; the old reply is kept as a branch)')
|
|
501
738
|
}
|
|
502
739
|
aria-label={regenerating ? t('Regenerating') : t('Regenerate')}
|
|
503
740
|
data-action="regenerate"
|
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
isTaskFamilyTool,
|
|
22
22
|
inspectableTaskFromAction,
|
|
23
23
|
shortTaskId,
|
|
24
|
+
askReplay,
|
|
25
|
+
askRowSummary,
|
|
26
|
+
egressRowSummary,
|
|
24
27
|
type InspectTaskInput,
|
|
25
28
|
} from './executed-actions-model';
|
|
26
29
|
import {
|
|
@@ -60,8 +63,8 @@ export interface ExecutedAction {
|
|
|
60
63
|
* port-only egress); none = no backend on this platform.
|
|
61
64
|
*/
|
|
62
65
|
sandbox?: { backend?: string; enforcement: string };
|
|
63
|
-
/** Set when a session allow covered this call, so the row can say so. */
|
|
64
|
-
approval?: { kind?: string; category?: string; cached?: boolean; waitMs?: number };
|
|
66
|
+
/** Set when a session allow or an unattended allow covered this call, so the row can say so. */
|
|
67
|
+
approval?: { kind?: string; category?: string; cached?: boolean; waitMs?: number; auto?: boolean };
|
|
65
68
|
/**
|
|
66
69
|
* 工具注册时声明的卡片。`declared` 为真时行标题用 `title`,
|
|
67
70
|
* 不再按工具名猜测。旧事件没有这个字段,仍走原来的摘要。
|
|
@@ -108,7 +111,7 @@ function summarizeArguments(args: unknown): string | null {
|
|
|
108
111
|
if (typeof args === 'string') return args.length > 60 ? args.slice(0, 57) + '…' : args;
|
|
109
112
|
if (typeof args !== 'object') return String(args);
|
|
110
113
|
const obj = args as Record<string, unknown>;
|
|
111
|
-
for (const key of ['command', 'cmd', 'query', 'path', 'file', 'target', 'message']) {
|
|
114
|
+
for (const key of ['command', 'cmd', 'url', 'query', 'path', 'file', 'target', 'message']) {
|
|
112
115
|
const v = obj[key];
|
|
113
116
|
if (typeof v === 'string' && v.length > 0) {
|
|
114
117
|
return v.length > 60 ? v.slice(0, 57) + '…' : v;
|
|
@@ -129,10 +132,16 @@ function sandboxBadge(sandbox: ExecutedAction['sandbox']): string | null {
|
|
|
129
132
|
}
|
|
130
133
|
}
|
|
131
134
|
|
|
132
|
-
function
|
|
135
|
+
function toolProgressNote(action: ExecutedAction): string | undefined {
|
|
133
136
|
if (action.approval?.kind === 'allow_for_session' && action.approval.cached === true) {
|
|
134
137
|
return t('Allowed for this chat');
|
|
135
138
|
}
|
|
139
|
+
if (action.tool === 'ask_user' && askReplay(action.result)?.unattended && deriveStatus(action) !== 'failed') {
|
|
140
|
+
return t('Answered automatically');
|
|
141
|
+
}
|
|
142
|
+
if (action.approval?.auto === true && deriveStatus(action) !== 'failed') {
|
|
143
|
+
return t('Auto');
|
|
144
|
+
}
|
|
136
145
|
return undefined;
|
|
137
146
|
}
|
|
138
147
|
|
|
@@ -145,8 +154,13 @@ function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload
|
|
|
145
154
|
? humanizeDelegateError(env.error)
|
|
146
155
|
: env.error
|
|
147
156
|
: null;
|
|
157
|
+
// 追问行用第一条问题。出网行用主机名。已保存的 view.title 往往只是开场白或工具名。
|
|
158
|
+
const asked = askRowSummary(action);
|
|
159
|
+
const egress = egressRowSummary(action);
|
|
148
160
|
// 声明过的工具用 view.title。没有 view 的旧事件仍按工具名做摘要。
|
|
149
|
-
const summary =
|
|
161
|
+
const summary = asked
|
|
162
|
+
|| egress
|
|
163
|
+
|| (action.view?.declared
|
|
150
164
|
? action.view.title
|
|
151
165
|
: ((action.tool === 'delegate_subagent'
|
|
152
166
|
? summarizeDelegateSubagent(action.arguments)
|
|
@@ -156,7 +170,7 @@ function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload
|
|
|
156
170
|
: null) ??
|
|
157
171
|
summarizeRunCodeAction(action.tool, action.arguments, action.result) ??
|
|
158
172
|
summarizeWebAction(action.tool, action.arguments, action.result) ??
|
|
159
|
-
summarizeArguments(action.arguments));
|
|
173
|
+
summarizeArguments(action.arguments)));
|
|
160
174
|
const badge = sandboxBadge(action.sandbox);
|
|
161
175
|
return {
|
|
162
176
|
id: action.id ?? `${action.tool}-${idx}`,
|
|
@@ -177,6 +191,20 @@ function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload
|
|
|
177
191
|
* unified `diff` (local_edit_file), render it as a coloured diff; otherwise
|
|
178
192
|
* fall back to the default JSON/text view.
|
|
179
193
|
*/
|
|
194
|
+
function renderAskOutput(output: unknown): ReactNode {
|
|
195
|
+
const replay = askReplay(output);
|
|
196
|
+
if (!replay) return renderActionOutput(output);
|
|
197
|
+
return (
|
|
198
|
+
<div className="space-y-1 text-[11px] text-agent-foreground">
|
|
199
|
+
{replay.lines.map((line, index) => (
|
|
200
|
+
<p key={`${index}-${line}`} className="whitespace-pre-wrap break-words">
|
|
201
|
+
{line}
|
|
202
|
+
</p>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
180
208
|
function renderActionOutput(output: unknown): ReactNode {
|
|
181
209
|
if (output && typeof output === 'object') {
|
|
182
210
|
const obj = output as Record<string, unknown>;
|
|
@@ -444,6 +472,9 @@ function toolCardExtras(
|
|
|
444
472
|
renderOutput: renderDelegateOutput,
|
|
445
473
|
};
|
|
446
474
|
}
|
|
475
|
+
if (action.tool === 'ask_user') {
|
|
476
|
+
return { renderOutput: renderAskOutput };
|
|
477
|
+
}
|
|
447
478
|
if (isTaskFamilyTool(action.tool)) {
|
|
448
479
|
return {
|
|
449
480
|
label: taskToolLabel(action.tool, action.arguments) ?? undefined,
|
|
@@ -543,7 +574,7 @@ export function ToolsFlow({
|
|
|
543
574
|
renderOutput={extras.renderOutput ?? renderActionOutput}
|
|
544
575
|
onActivate={inspect.onActivate}
|
|
545
576
|
activateTitle={inspect.activateTitle}
|
|
546
|
-
note={
|
|
577
|
+
note={toolProgressNote(action)}
|
|
547
578
|
className="rounded-none border-0 border-t border-agent-border first:border-t-0"
|
|
548
579
|
/>
|
|
549
580
|
);
|
|
@@ -606,7 +637,7 @@ export function ExecutedActionsCard({
|
|
|
606
637
|
renderOutput={extras.renderOutput ?? renderActionOutput}
|
|
607
638
|
onActivate={inspect.onActivate}
|
|
608
639
|
activateTitle={inspect.activateTitle}
|
|
609
|
-
note={
|
|
640
|
+
note={toolProgressNote(action)}
|
|
610
641
|
className="rounded-none border-0 border-t border-agent-border first:border-t-0"
|
|
611
642
|
/>
|
|
612
643
|
);
|