@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
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failed-turn presentation: what to say and what to offer, per failure kind.
|
|
3
|
+
*
|
|
4
|
+
* The backend classifies every provider failure (`LLMError.kind`) and records
|
|
5
|
+
* it with the turn. Matching on that field — never on the message text — keeps
|
|
6
|
+
* the copy stable while error strings change, and keeps untranslated exception
|
|
7
|
+
* names out of the headline. The message itself belongs in the details
|
|
8
|
+
* disclosure, which is for whoever reads it: the user reporting a problem and
|
|
9
|
+
* the person on support.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { t } from '@/i18n';
|
|
13
|
+
|
|
14
|
+
export type FailureKind =
|
|
15
|
+
| 'transport'
|
|
16
|
+
| 'rate_limit'
|
|
17
|
+
| 'server'
|
|
18
|
+
| 'auth'
|
|
19
|
+
| 'invalid_request'
|
|
20
|
+
| 'context_overflow'
|
|
21
|
+
| 'unknown';
|
|
22
|
+
|
|
23
|
+
/** The provider classifications the backend can send. */
|
|
24
|
+
const KINDS: readonly FailureKind[] = [
|
|
25
|
+
'transport',
|
|
26
|
+
'rate_limit',
|
|
27
|
+
'server',
|
|
28
|
+
'auth',
|
|
29
|
+
'invalid_request',
|
|
30
|
+
'context_overflow',
|
|
31
|
+
'unknown',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** One action a failed turn can offer. */
|
|
35
|
+
export type FailureAction =
|
|
36
|
+
| { kind: 'retry' }
|
|
37
|
+
| { kind: 'network-diagnostics' }
|
|
38
|
+
| { kind: 'open-model-settings' }
|
|
39
|
+
| { kind: 'new-chat' };
|
|
40
|
+
|
|
41
|
+
/** Structured failure detail persisted with the turn (`messageMetadata.failure`). */
|
|
42
|
+
export interface TurnFailureDetail {
|
|
43
|
+
kind: FailureKind;
|
|
44
|
+
/** Proxy the failing request used, credentials already stripped. */
|
|
45
|
+
proxy?: string;
|
|
46
|
+
/** Provider attempts spent on the request, including the last. */
|
|
47
|
+
attempts?: number;
|
|
48
|
+
statusCode?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Everything the card renders, derived from one failure. */
|
|
52
|
+
export interface FailurePresentation {
|
|
53
|
+
title: string;
|
|
54
|
+
body: string;
|
|
55
|
+
/** The primary action: the most likely thing to fix this failure. */
|
|
56
|
+
primary: FailureAction;
|
|
57
|
+
secondary: FailureAction[];
|
|
58
|
+
/** True when the details disclosure carries a classified cause. */
|
|
59
|
+
classified: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Narrows an unknown metadata value to a failure kind.
|
|
64
|
+
*
|
|
65
|
+
* Returns undefined for anything unrecognized — an older sidecar that sent
|
|
66
|
+
* nothing, a value from a newer one, a corrupted record. The caller then shows
|
|
67
|
+
* the generic card with the raw message, which is still actionable when the
|
|
68
|
+
* only remedy is reporting it.
|
|
69
|
+
*/
|
|
70
|
+
export function readFailureKind(value: unknown): FailureKind | undefined {
|
|
71
|
+
return typeof value === 'string' && (KINDS as readonly string[]).includes(value)
|
|
72
|
+
? (value as FailureKind)
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Copy and actions for one failure.
|
|
78
|
+
*
|
|
79
|
+
* The primary action is the one most likely to make the next attempt work:
|
|
80
|
+
* a rejected key needs settings, an over-long conversation needs a fresh one,
|
|
81
|
+
* and everything else is worth retrying once the cause clears. Actions whose
|
|
82
|
+
* handler is unavailable at render time (`available`) are dropped by the card,
|
|
83
|
+
* so this function can describe the ideal set without knowing the shell.
|
|
84
|
+
*/
|
|
85
|
+
export function failurePresentation(
|
|
86
|
+
detail: TurnFailureDetail | undefined,
|
|
87
|
+
options: { completedSteps?: number } = {},
|
|
88
|
+
): FailurePresentation {
|
|
89
|
+
const steps = options.completedSteps ?? 0;
|
|
90
|
+
const attempts = detail?.attempts;
|
|
91
|
+
const retried =
|
|
92
|
+
typeof attempts === 'number' && attempts > 1
|
|
93
|
+
? t('Retried {count} times. ', { count: attempts - 1 })
|
|
94
|
+
: '';
|
|
95
|
+
switch (detail?.kind) {
|
|
96
|
+
case 'transport': {
|
|
97
|
+
const next =
|
|
98
|
+
steps > 0
|
|
99
|
+
? t(
|
|
100
|
+
"When your network is back, select Continue. The {count} completed steps won't run again.",
|
|
101
|
+
{ count: steps },
|
|
102
|
+
)
|
|
103
|
+
: t('When your network is back, select Continue.');
|
|
104
|
+
return {
|
|
105
|
+
title: t("Can't reach the model service right now"),
|
|
106
|
+
body: t('The connection kept dropping. ') + retried + next,
|
|
107
|
+
primary: { kind: 'retry' },
|
|
108
|
+
secondary: [{ kind: 'network-diagnostics' }],
|
|
109
|
+
classified: true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
case 'auth':
|
|
113
|
+
return {
|
|
114
|
+
title: t('The model service rejected the API key'),
|
|
115
|
+
body: t('Check the API key in settings, then select Continue.'),
|
|
116
|
+
primary: { kind: 'open-model-settings' },
|
|
117
|
+
secondary: [{ kind: 'retry' }],
|
|
118
|
+
classified: true,
|
|
119
|
+
};
|
|
120
|
+
case 'rate_limit':
|
|
121
|
+
return {
|
|
122
|
+
title: t('The model service is limiting requests'),
|
|
123
|
+
body: t('Select Continue in a moment.'),
|
|
124
|
+
primary: { kind: 'retry' },
|
|
125
|
+
secondary: [],
|
|
126
|
+
classified: true,
|
|
127
|
+
};
|
|
128
|
+
case 'server':
|
|
129
|
+
return {
|
|
130
|
+
title: t('The model service had an error'),
|
|
131
|
+
body: t('Select Continue in a moment.'),
|
|
132
|
+
primary: { kind: 'retry' },
|
|
133
|
+
secondary: [],
|
|
134
|
+
classified: true,
|
|
135
|
+
};
|
|
136
|
+
case 'context_overflow':
|
|
137
|
+
return {
|
|
138
|
+
title: t('This conversation is too long for the model'),
|
|
139
|
+
body: t('Start a new chat, or select Regenerate to try a different approach.'),
|
|
140
|
+
primary: { kind: 'new-chat' },
|
|
141
|
+
secondary: [{ kind: 'retry' }],
|
|
142
|
+
classified: true,
|
|
143
|
+
};
|
|
144
|
+
case 'invalid_request':
|
|
145
|
+
return {
|
|
146
|
+
title: t('The model service rejected this request'),
|
|
147
|
+
body: t('Select Continue to try again, or copy the diagnostic info to report it.'),
|
|
148
|
+
primary: { kind: 'retry' },
|
|
149
|
+
secondary: [],
|
|
150
|
+
classified: true,
|
|
151
|
+
};
|
|
152
|
+
default:
|
|
153
|
+
return {
|
|
154
|
+
title: t('Reply failed'),
|
|
155
|
+
body: t('Select Continue to try again, or copy the diagnostic info to report it.'),
|
|
156
|
+
primary: { kind: 'retry' },
|
|
157
|
+
secondary: [],
|
|
158
|
+
classified: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Reads the persisted failure block, dropping fields of the wrong type. */
|
|
164
|
+
export function readFailureDetail(value: unknown): TurnFailureDetail | undefined {
|
|
165
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
166
|
+
const record = value as Record<string, unknown>;
|
|
167
|
+
const kind = readFailureKind(record.errorKind);
|
|
168
|
+
if (!kind) return undefined;
|
|
169
|
+
const proxy =
|
|
170
|
+
typeof record.proxy === 'string' && record.proxy ? record.proxy : undefined;
|
|
171
|
+
const attempts =
|
|
172
|
+
typeof record.attempts === 'number' && Number.isFinite(record.attempts)
|
|
173
|
+
? record.attempts
|
|
174
|
+
: undefined;
|
|
175
|
+
const statusCode =
|
|
176
|
+
typeof record.statusCode === 'number' && Number.isFinite(record.statusCode)
|
|
177
|
+
? record.statusCode
|
|
178
|
+
: undefined;
|
|
179
|
+
return {
|
|
180
|
+
kind,
|
|
181
|
+
...(proxy ? { proxy } : {}),
|
|
182
|
+
...(attempts !== undefined ? { attempts } : {}),
|
|
183
|
+
...(statusCode !== undefined ? { statusCode } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
2
|
import { LuCircleCheck, LuCircleX, LuLoader, LuPlay } from 'react-icons/lu';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
diagnoseLlmConnection,
|
|
5
|
+
getReliabilitySummary,
|
|
6
|
+
type DiagnoseResult,
|
|
7
|
+
type ReliabilitySummary,
|
|
8
|
+
} from '@/lib/local-api';
|
|
4
9
|
import { t } from '@/i18n';
|
|
5
10
|
|
|
6
11
|
/**
|
|
@@ -8,6 +13,110 @@ import { t } from '@/i18n';
|
|
|
8
13
|
* (DNS → TCP → TLS → HTTP /models → chat completion),并报告
|
|
9
14
|
* 宿主机的 ambient 代理配置。
|
|
10
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* 「最近 7 天」:失败与重试的统计。
|
|
18
|
+
*
|
|
19
|
+
* 与下面的连接检查分开:检查回答「现在能不能连上」,统计回答「这一周
|
|
20
|
+
* 到底发生了什么」。没有回复时显示空状态,而不是一排 0——0 会被读成
|
|
21
|
+
* 「一切正常」,实际是「还没数据」。
|
|
22
|
+
*/
|
|
23
|
+
function ReliabilityPanel() {
|
|
24
|
+
const [summary, setSummary] = useState<ReliabilitySummary | null>(null);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
void getReliabilitySummary(7)
|
|
30
|
+
.then((value) => {
|
|
31
|
+
if (!cancelled) setSummary(value);
|
|
32
|
+
})
|
|
33
|
+
.catch((err: unknown) => {
|
|
34
|
+
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
|
35
|
+
});
|
|
36
|
+
return () => {
|
|
37
|
+
cancelled = true;
|
|
38
|
+
};
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
if (error) {
|
|
42
|
+
return (
|
|
43
|
+
<p className="mt-2 text-xs text-agent-muted-foreground" data-testid="reliability-error">
|
|
44
|
+
{t('Could not load the statistics: {error}', { error })}
|
|
45
|
+
</p>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (!summary) {
|
|
49
|
+
return (
|
|
50
|
+
<p className="mt-2 text-xs text-agent-muted-foreground" data-testid="reliability-loading">
|
|
51
|
+
{t('Loading…')}
|
|
52
|
+
</p>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (summary.total === 0) {
|
|
56
|
+
return (
|
|
57
|
+
<p className="mt-2 text-xs text-agent-muted-foreground" data-testid="reliability-empty">
|
|
58
|
+
{t('No replies in the last 7 days')}
|
|
59
|
+
</p>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="mt-2 space-y-2" data-testid="reliability-summary">
|
|
65
|
+
<div className="grid grid-cols-3 gap-2">
|
|
66
|
+
<Stat value={summary.total} label={t('Replies')} />
|
|
67
|
+
<Stat value={summary.failed} label={t('Failed')} />
|
|
68
|
+
<Stat value={summary.recoveredByRetry} label={t('Recovered by retry')} />
|
|
69
|
+
</div>
|
|
70
|
+
{summary.failuresByKind.length > 0 ? (
|
|
71
|
+
<ul className="space-y-1 rounded-agent-sm bg-agent-muted/40 px-2.5 py-1.5 text-xs">
|
|
72
|
+
{summary.failuresByKind.map((row) => (
|
|
73
|
+
<li key={row.kind} className="flex items-center justify-between gap-2">
|
|
74
|
+
<span className="text-agent-muted-foreground">{kindLabel(row.kind)}</span>
|
|
75
|
+
<span className="font-mono text-agent-foreground">{row.count}</span>
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
) : null}
|
|
80
|
+
{summary.usedDirect > 0 ? (
|
|
81
|
+
<p className="text-xs text-agent-muted-foreground">
|
|
82
|
+
{t('{count} replies went direct instead of through the system proxy.', {
|
|
83
|
+
count: summary.usedDirect,
|
|
84
|
+
})}
|
|
85
|
+
</p>
|
|
86
|
+
) : null}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Stat({ value, label }: { value: number; label: string }) {
|
|
92
|
+
return (
|
|
93
|
+
<div className="rounded-agent-sm bg-agent-muted/40 px-2.5 py-1.5">
|
|
94
|
+
<div className="font-mono text-sm text-agent-foreground">{value}</div>
|
|
95
|
+
<div className="text-[11px] text-agent-muted-foreground">{label}</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Provider failure kinds, in the words a user reads. */
|
|
101
|
+
function kindLabel(kind: string): string {
|
|
102
|
+
switch (kind) {
|
|
103
|
+
case 'transport':
|
|
104
|
+
return t('Network');
|
|
105
|
+
case 'auth':
|
|
106
|
+
return t('Credentials');
|
|
107
|
+
case 'rate_limit':
|
|
108
|
+
return t('Rate limit');
|
|
109
|
+
case 'server':
|
|
110
|
+
return t('Service error');
|
|
111
|
+
case 'context_overflow':
|
|
112
|
+
return t('Conversation too long');
|
|
113
|
+
case 'invalid_request':
|
|
114
|
+
return t('Rejected request');
|
|
115
|
+
default:
|
|
116
|
+
return t('Other');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
11
120
|
export function DiagnoseSettingsPanel() {
|
|
12
121
|
const [running, setRunning] = useState(false);
|
|
13
122
|
const [result, setResult] = useState<DiagnoseResult | null>(null);
|
|
@@ -36,6 +145,8 @@ export function DiagnoseSettingsPanel() {
|
|
|
36
145
|
{t('The diagnosis runs in the host process and also reports system and environment proxy settings.')}
|
|
37
146
|
</p>
|
|
38
147
|
|
|
148
|
+
<ReliabilityPanel />
|
|
149
|
+
|
|
39
150
|
<button
|
|
40
151
|
type="button"
|
|
41
152
|
onClick={() => void run()}
|
|
@@ -52,6 +52,11 @@ import {
|
|
|
52
52
|
|
|
53
53
|
export interface LlmSettingsPanelHandle {
|
|
54
54
|
save: () => Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* 把光标放到 API Key 输入框。返回 false 表示字段当前不存在(面板还在
|
|
57
|
+
* 加载,或该服务商没有这个字段),调用方据此决定是否稍后再试。
|
|
58
|
+
*/
|
|
59
|
+
focusApiKey: () => boolean;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
export interface LlmSaveUi {
|
|
@@ -389,8 +394,16 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
|
|
|
389
394
|
|
|
390
395
|
const handleSaveRef = useRef(handleSave);
|
|
391
396
|
handleSaveRef.current = handleSave;
|
|
397
|
+
const apiKeyRef = useRef<HTMLInputElement | null>(null);
|
|
398
|
+
|
|
392
399
|
useImperativeHandle(ref, () => ({
|
|
393
400
|
save: () => handleSaveRef.current(),
|
|
401
|
+
focusApiKey: () => {
|
|
402
|
+
const field = apiKeyRef.current;
|
|
403
|
+
if (!field) return false;
|
|
404
|
+
field.focus();
|
|
405
|
+
return document.activeElement === field;
|
|
406
|
+
},
|
|
394
407
|
}), []);
|
|
395
408
|
|
|
396
409
|
useEffect(() => {
|
|
@@ -482,7 +495,12 @@ export const LlmSettingsPanel = forwardRef<LlmSettingsPanelHandle, LlmSettingsPa
|
|
|
482
495
|
</label>
|
|
483
496
|
<div className="flex items-center gap-2">
|
|
484
497
|
<input
|
|
498
|
+
ref={apiKeyRef}
|
|
485
499
|
type="password"
|
|
500
|
+
// The failure card's 「打开模型设置」 targets this field:
|
|
501
|
+
// an authentication failure is fixed here, so the panel
|
|
502
|
+
// focuses it instead of leaving the reader to hunt.
|
|
503
|
+
data-settings-field="llm-api-key"
|
|
486
504
|
value={settings.apiKey || ''}
|
|
487
505
|
onChange={(e) =>
|
|
488
506
|
setSettings((prev) => ({ ...prev, apiKey: e.target.value }))
|
|
@@ -143,7 +143,7 @@ export function normalizeRightPanelEntry(
|
|
|
143
143
|
*
|
|
144
144
|
* 兼容这些历史格式:
|
|
145
145
|
* - v2:{"version":2,"sessions":{"<chatId>":{"tabs":[...],"activeTabId":"..."}}};
|
|
146
|
-
* - 旧多标签:{"<chatId>": { "tabs": ["
|
|
146
|
+
* - 旧多标签:{"<chatId>": { "tabs": ["<slotId>", "terminal"], "active": "<slotId>" }};
|
|
147
147
|
* - 单值映射:{"<chatId>": "terminal" | "<slotId>"} → 一个标签;
|
|
148
148
|
* - 整个值就是单个字符串 → 迁到当前会话;
|
|
149
149
|
* - 更老:只记录终端是否打开过的布尔 key。
|
package/src/lib/local-api.ts
CHANGED
|
@@ -915,6 +915,24 @@ export async function installCommandLineTool(name: string) {
|
|
|
915
915
|
});
|
|
916
916
|
}
|
|
917
917
|
|
|
918
|
+
/** Reliability numbers for the settings page's network section (R4). */
|
|
919
|
+
export interface ReliabilitySummary {
|
|
920
|
+
sinceDays: number;
|
|
921
|
+
total: number;
|
|
922
|
+
failed: number;
|
|
923
|
+
cancelled: number;
|
|
924
|
+
recoveredByRetry: number;
|
|
925
|
+
usedDirect: number;
|
|
926
|
+
failuresByKind: Array<{ kind: string; count: number }>;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
export async function getReliabilitySummary(days = 7) {
|
|
930
|
+
return bridge().localBackend.request<ReliabilitySummary>({
|
|
931
|
+
method: 'GET',
|
|
932
|
+
path: `/api/v2/usage/reliability?days=${encodeURIComponent(days)}`,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
918
936
|
export async function diagnoseLlmConnection(input: {
|
|
919
937
|
baseUrl?: string;
|
|
920
938
|
apiKey?: string;
|
package/src/pages/AgentPage.tsx
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from "@/lib/chat-transport";
|
|
11
11
|
import { ChatHeader } from "@/components/ChatHeader";
|
|
12
12
|
import { EmptyChatHero } from "@/components/chat/EmptyChatHero";
|
|
13
|
+
import type { RetryState } from "@/components/chat/RetryStatus";
|
|
14
|
+
import type { FailureAction } from "@/components/chat/turn-failure";
|
|
13
15
|
import { LocalChatPanel } from "@/components/chat/LocalChatPanel";
|
|
14
16
|
import { ModelPicker } from "@/components/chat/ModelPicker";
|
|
15
17
|
import {
|
|
@@ -540,6 +542,11 @@ function AgentChatView({
|
|
|
540
542
|
// before this signal, the UI looked frozen whenever the model paused to
|
|
541
543
|
// wait for tool results.
|
|
542
544
|
const [currentRound, setCurrentRound] = useState(1);
|
|
545
|
+
// Live "reconnecting" state for the in-flight turn. Set by the backend's
|
|
546
|
+
// `turn_retry` notice, cleared when the round ends or the stream settles —
|
|
547
|
+
// a retry that succeeded leaves no trace in the UI or in history.
|
|
548
|
+
const [retryState, setRetryState] = useState<RetryState | null>(null);
|
|
549
|
+
const navigate = useNavigate();
|
|
543
550
|
// Holds the latest in-flight actions for the `message_id` reconciliation
|
|
544
551
|
// — `useState`'s setter sees the latest value via the functional update
|
|
545
552
|
// form, but we keep this ref so the reconciliation event handler can read
|
|
@@ -624,6 +631,21 @@ function AgentChatView({
|
|
|
624
631
|
// 这不是新一轮,跳过计数。
|
|
625
632
|
if (ev.payload?.status === "cancelled") return;
|
|
626
633
|
setCurrentRound((r) => r + 1);
|
|
634
|
+
// A new round means the previous request finished (or was replaced), so
|
|
635
|
+
// any "reconnecting" line belongs to the round that is now over.
|
|
636
|
+
setRetryState(null);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (ev.event === "turn_retry") {
|
|
640
|
+
// The backend announces each retry; the first one starts the clock the
|
|
641
|
+
// status line counts from. Later ones keep it so the count is the whole
|
|
642
|
+
// wait, not just the latest attempt.
|
|
643
|
+
const delayMs =
|
|
644
|
+
typeof ev.payload?.delayMs === "number" ? ev.payload.delayMs : undefined;
|
|
645
|
+
setRetryState((prev) => ({
|
|
646
|
+
startedAt: prev?.startedAt ?? Date.now(),
|
|
647
|
+
...(delayMs !== undefined ? { delayMs } : {}),
|
|
648
|
+
}));
|
|
627
649
|
return;
|
|
628
650
|
}
|
|
629
651
|
if (ev.event === "orchestration_child") {
|
|
@@ -942,6 +964,7 @@ function AgentChatView({
|
|
|
942
964
|
pendingTurnFilesRef.current = [];
|
|
943
965
|
setCurrentTurnFiles([]);
|
|
944
966
|
setCurrentRound(1);
|
|
967
|
+
setRetryState(null);
|
|
945
968
|
const started = Date.now();
|
|
946
969
|
turnStartedAtRef.current = started;
|
|
947
970
|
setCurrentTurnStartedAtMs(started);
|
|
@@ -1059,6 +1082,7 @@ function AgentChatView({
|
|
|
1059
1082
|
pendingTurnFilesRef.current = [];
|
|
1060
1083
|
setCurrentTurnFiles([]);
|
|
1061
1084
|
setCurrentRound(1);
|
|
1085
|
+
setRetryState(null);
|
|
1062
1086
|
const started = Date.now();
|
|
1063
1087
|
turnStartedAtRef.current = started;
|
|
1064
1088
|
setCurrentTurnStartedAtMs(started);
|
|
@@ -1081,6 +1105,63 @@ function AgentChatView({
|
|
|
1081
1105
|
setInterrupted(false);
|
|
1082
1106
|
}, []);
|
|
1083
1107
|
|
|
1108
|
+
// 「新建对话」失败时的就地提示;点一次清一次。
|
|
1109
|
+
const [newChatError, setNewChatError] = useState<string | null>(null);
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* 失败卡片的按钮。和「继续上次回复」走同一条 resume 通道:sidecar 回放
|
|
1113
|
+
* durable record,已完成的工具调用不会再执行一遍——这正是失败后点
|
|
1114
|
+
* 「继续」想要的语义。本轮正在跑时按钮禁用(同一会话不能同时两轮)。
|
|
1115
|
+
*/
|
|
1116
|
+
const failureAction = useCallback(
|
|
1117
|
+
(action: FailureAction) => {
|
|
1118
|
+
switch (action.kind) {
|
|
1119
|
+
case "retry":
|
|
1120
|
+
return {
|
|
1121
|
+
onClick: () => void handleContinueInterrupted(),
|
|
1122
|
+
disabled: isStreaming,
|
|
1123
|
+
hint: isStreaming ? t("This chat is running. Continue when it finishes.") : undefined,
|
|
1124
|
+
};
|
|
1125
|
+
case "network-diagnostics":
|
|
1126
|
+
// 诊断区挂在设置页「通用」下(settingsChrome('diagnose') 控制显隐)。
|
|
1127
|
+
return settingsChrome("diagnose")
|
|
1128
|
+
? { onClick: () => navigate("/settings") }
|
|
1129
|
+
: null;
|
|
1130
|
+
case "open-model-settings":
|
|
1131
|
+
return settingsChrome("llm") && hasHostBridge()
|
|
1132
|
+
? { onClick: () => setLlmSettingsOpen(true) }
|
|
1133
|
+
: null;
|
|
1134
|
+
case "new-chat":
|
|
1135
|
+
// 同项目的空会话:对话过长时的新出路,而不是把用户丢回落地页再
|
|
1136
|
+
// 重选项目、重填输入。失败时提示并留在原地,不吞掉这次点击。
|
|
1137
|
+
return {
|
|
1138
|
+
onClick: () => {
|
|
1139
|
+
void ctx
|
|
1140
|
+
.createChat({
|
|
1141
|
+
...(chat?.projectId ? { projectId: chat.projectId } : {}),
|
|
1142
|
+
...(chat?.agentId ? { agentId: chat.agentId } : {}),
|
|
1143
|
+
})
|
|
1144
|
+
.then((id) => {
|
|
1145
|
+
if (id) navigate(`/agent/${id}`);
|
|
1146
|
+
else setNewChatError(t("Failed to create the chat. Please try again."));
|
|
1147
|
+
})
|
|
1148
|
+
.catch((err: unknown) =>
|
|
1149
|
+
setNewChatError(err instanceof Error ? err.message : String(err)),
|
|
1150
|
+
);
|
|
1151
|
+
},
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
[
|
|
1156
|
+
chat?.agentId,
|
|
1157
|
+
chat?.projectId,
|
|
1158
|
+
ctx,
|
|
1159
|
+
handleContinueInterrupted,
|
|
1160
|
+
isStreaming,
|
|
1161
|
+
navigate,
|
|
1162
|
+
],
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1084
1165
|
// 轮中插队 + 失败兜底(W6-2):streaming 期间 ⌘/Ctrl+Enter 优先注入运行中的
|
|
1085
1166
|
// CoreLoop 回合;注入不了时 hook 按回合实况降级——仍在跑则排入待发队列
|
|
1086
1167
|
// ('queued',ChatInput 提示"已改为排队"),恰好已结束则作为新回合直发
|
|
@@ -1322,6 +1403,8 @@ function AgentChatView({
|
|
|
1322
1403
|
currentTurnChildren={effectiveCurrentTurnChildren}
|
|
1323
1404
|
orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
|
|
1324
1405
|
currentRound={currentRound}
|
|
1406
|
+
retryState={retryState}
|
|
1407
|
+
failureAction={failureAction}
|
|
1325
1408
|
interrupted={interrupted}
|
|
1326
1409
|
onContinueInterrupted={handleContinueInterrupted}
|
|
1327
1410
|
onDismissInterrupted={handleDismissInterrupted}
|
|
@@ -1369,6 +1452,23 @@ function AgentChatView({
|
|
|
1369
1452
|
}
|
|
1370
1453
|
inputBanner={
|
|
1371
1454
|
<>
|
|
1455
|
+
{newChatError ? (
|
|
1456
|
+
<div
|
|
1457
|
+
role="status"
|
|
1458
|
+
data-testid="new-chat-error"
|
|
1459
|
+
className="mb-1 flex items-center justify-between gap-2 rounded-agent-md border border-agent-destructive/20 bg-agent-destructive/10 px-2.5 py-1.5 text-xs text-agent-destructive"
|
|
1460
|
+
>
|
|
1461
|
+
<span>{newChatError}</span>
|
|
1462
|
+
<button
|
|
1463
|
+
type="button"
|
|
1464
|
+
onClick={() => setNewChatError(null)}
|
|
1465
|
+
className="rounded px-1 hover:bg-agent-destructive/10"
|
|
1466
|
+
aria-label={t("Dismiss")}
|
|
1467
|
+
>
|
|
1468
|
+
✕
|
|
1469
|
+
</button>
|
|
1470
|
+
</div>
|
|
1471
|
+
) : null}
|
|
1372
1472
|
<GoalLoopStatusBar
|
|
1373
1473
|
chatId={chatId}
|
|
1374
1474
|
goal={goal}
|
|
@@ -220,7 +220,7 @@ export function SettingsPage() {
|
|
|
220
220
|
for (const panel of packPanels) {
|
|
221
221
|
list.push({
|
|
222
222
|
id: panel.panelId,
|
|
223
|
-
label: panel.title,
|
|
223
|
+
label: t(panel.title),
|
|
224
224
|
Icon: panel.Icon ?? LuSettings,
|
|
225
225
|
});
|
|
226
226
|
}
|
|
@@ -503,7 +503,7 @@ export function SettingsPage() {
|
|
|
503
503
|
{panel.Icon ? (
|
|
504
504
|
<panel.Icon className="h-3.5 w-3.5 text-agent-muted-foreground" />
|
|
505
505
|
) : null}
|
|
506
|
-
{panel.title}
|
|
506
|
+
{t(panel.title)}
|
|
507
507
|
</h2>
|
|
508
508
|
<panel.Component />
|
|
509
509
|
</section>
|