@steerable/agent-shell-web 0.7.4 → 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/LocalLlmSettingsModal.tsx +40 -3
- package/src/components/chat/AssistantMessage.tsx +252 -15
- 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/turn-failure.ts +185 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +113 -2
- package/src/components/settings/LlmSettingsPanel.tsx +18 -0
- package/src/lib/local-api.ts +18 -0
- package/src/pages/AgentPage.tsx +100 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { motion } from 'framer-motion';
|
|
2
2
|
import { t } from '@/i18n';
|
|
3
|
+
import { RetryStatus, type RetryState } from './RetryStatus';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* StreamingStatus — informative placeholder for an assistant bubble that has
|
|
@@ -30,13 +31,21 @@ interface StreamingStatusProps {
|
|
|
30
31
|
* nothing (the content + cursor blink is enough).
|
|
31
32
|
*/
|
|
32
33
|
hasContent: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Set while the request is being retried. Outranks `hasContent`: a turn
|
|
36
|
+
* that already streamed text and then lost its connection is waiting, not
|
|
37
|
+
* writing, and the cursor alone would read as a stall.
|
|
38
|
+
*/
|
|
39
|
+
retry?: RetryState;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
export function StreamingStatus({
|
|
36
43
|
round,
|
|
37
44
|
actionCount,
|
|
38
45
|
hasContent,
|
|
46
|
+
retry,
|
|
39
47
|
}: StreamingStatusProps) {
|
|
48
|
+
if (retry) return <RetryStatus retry={retry} />;
|
|
40
49
|
// Once tokens are flowing the parent already has plenty of visual feedback
|
|
41
50
|
// (the streaming cursor); a separate status line would be noise.
|
|
42
51
|
if (hasContent) return null;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { LuChevronDown, LuChevronUp, LuCopy, LuTriangleAlert } from 'react-icons/lu';
|
|
3
|
+
import { t } from '@/i18n';
|
|
4
|
+
import {
|
|
5
|
+
failurePresentation,
|
|
6
|
+
type FailureAction,
|
|
7
|
+
type TurnFailureDetail,
|
|
8
|
+
} from './turn-failure';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* TurnFailureCard — what a failed turn shows in place of an answer.
|
|
12
|
+
*
|
|
13
|
+
* Two layers, chosen by who reads them. The headline and one sentence say
|
|
14
|
+
* what happened and what to do next; nobody needs `SSLEOFError` to press
|
|
15
|
+
* Continue. The details disclosure holds the raw cause, the hop the request
|
|
16
|
+
* took, and the trace id — the material a support reply or a bug report
|
|
17
|
+
* needs, which is exactly why it must not be in the way.
|
|
18
|
+
*
|
|
19
|
+
* The card never decides whether an action is available; it renders the ones
|
|
20
|
+
* its caller passed and shows the rest as disabled. That keeps the choice of
|
|
21
|
+
* "what can this build actually do" in one place (the message list) instead of
|
|
22
|
+
* duplicating capability checks per failure kind.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface TurnFailureCardProps {
|
|
26
|
+
/** Classified detail, when the backend recorded one. */
|
|
27
|
+
detail?: TurnFailureDetail;
|
|
28
|
+
/** Raw failure text, always present; the details layer's main content. */
|
|
29
|
+
reason: string;
|
|
30
|
+
/** Tool calls that already completed in this turn. */
|
|
31
|
+
completedSteps?: number;
|
|
32
|
+
/** ISO timestamp of the failed turn, for the details layer. */
|
|
33
|
+
happenedAt?: string;
|
|
34
|
+
/** Trace id, for the details layer and the copied report. */
|
|
35
|
+
traceId?: string;
|
|
36
|
+
/** Renders the action buttons this build can perform. */
|
|
37
|
+
action?: (action: FailureAction) => { onClick: () => void; disabled?: boolean; hint?: string } | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Details-layer field label/value pair. */
|
|
41
|
+
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<dt className="text-agent-muted-foreground">{label}</dt>
|
|
45
|
+
<dd className="min-w-0 break-words">{children}</dd>
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function TurnFailureCard({
|
|
51
|
+
detail,
|
|
52
|
+
reason,
|
|
53
|
+
completedSteps = 0,
|
|
54
|
+
happenedAt,
|
|
55
|
+
traceId,
|
|
56
|
+
action,
|
|
57
|
+
}: TurnFailureCardProps) {
|
|
58
|
+
const [open, setOpen] = useState(false);
|
|
59
|
+
const presentation = failurePresentation(detail, { completedSteps });
|
|
60
|
+
const actions = [presentation.primary, ...presentation.secondary]
|
|
61
|
+
.map((entry) => ({ entry, handler: action?.(entry) ?? null }))
|
|
62
|
+
.filter((row): row is { entry: FailureAction; handler: NonNullable<typeof row.handler> } =>
|
|
63
|
+
row.handler !== null,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
className="rounded-agent-lg border border-agent-destructive/40 bg-agent-destructive/5 px-2.5 py-2 text-xs leading-relaxed shadow-sm"
|
|
69
|
+
data-testid="turn-failure"
|
|
70
|
+
data-failure-kind={detail?.kind ?? 'unknown'}
|
|
71
|
+
>
|
|
72
|
+
<div className="flex items-start gap-2 font-medium text-agent-destructive">
|
|
73
|
+
<LuTriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
|
74
|
+
<span>{presentation.title}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<p className="mt-1 ml-5 text-agent-foreground/90">{presentation.body}</p>
|
|
77
|
+
<div className="mt-2 ml-5 flex flex-wrap items-center gap-2">
|
|
78
|
+
{actions.map(({ entry, handler }, index) => (
|
|
79
|
+
<button
|
|
80
|
+
key={entry.kind}
|
|
81
|
+
type="button"
|
|
82
|
+
disabled={handler.disabled}
|
|
83
|
+
title={handler.hint}
|
|
84
|
+
onClick={handler.onClick}
|
|
85
|
+
className={
|
|
86
|
+
index === 0
|
|
87
|
+
? 'rounded-agent-md bg-agent-primary px-3 py-1 text-xs font-medium text-agent-primary-foreground transition hover:bg-agent-primary/90 disabled:opacity-50'
|
|
88
|
+
: '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'
|
|
89
|
+
}
|
|
90
|
+
data-action={entry.kind}
|
|
91
|
+
>
|
|
92
|
+
{actionLabel(entry)}
|
|
93
|
+
</button>
|
|
94
|
+
))}
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => setOpen((value) => !value)}
|
|
98
|
+
className="inline-flex items-center gap-0.5 rounded px-1 text-agent-muted-foreground hover:text-agent-foreground"
|
|
99
|
+
aria-expanded={open}
|
|
100
|
+
data-action="details"
|
|
101
|
+
>
|
|
102
|
+
{open ? t('Hide details') : t('Details')}
|
|
103
|
+
{open ? <LuChevronUp className="h-3 w-3" /> : <LuChevronDown className="h-3 w-3" />}
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
{open && (
|
|
107
|
+
<div className="mt-2 ml-5 border-t border-agent-destructive/20 pt-2" data-testid="turn-failure-details">
|
|
108
|
+
<dl className="grid grid-cols-[5.5rem_1fr] gap-x-3 gap-y-1">
|
|
109
|
+
<DetailRow label={t('Category')}>
|
|
110
|
+
{presentation.classified ? categoryLabel(detail?.kind) : t('Not classified')}
|
|
111
|
+
</DetailRow>
|
|
112
|
+
<DetailRow label={t('Cause')}>
|
|
113
|
+
<span className="block">{causeSummary(detail, reason)}</span>
|
|
114
|
+
{presentation.classified && reason !== causeSummary(detail, reason) ? (
|
|
115
|
+
<code className="mt-0.5 block break-all font-mono text-[11px] text-agent-muted-foreground">
|
|
116
|
+
{reason}
|
|
117
|
+
</code>
|
|
118
|
+
) : null}
|
|
119
|
+
</DetailRow>
|
|
120
|
+
{detail?.proxy || detail?.kind === 'transport' ? (
|
|
121
|
+
<DetailRow label={t('Connection')}>
|
|
122
|
+
{detail?.proxy
|
|
123
|
+
? t('Through system proxy {proxy}', { proxy: detail.proxy })
|
|
124
|
+
: t('Direct')}
|
|
125
|
+
</DetailRow>
|
|
126
|
+
) : null}
|
|
127
|
+
{detail?.attempts ? (
|
|
128
|
+
<DetailRow label={t('Retries')}>
|
|
129
|
+
{t('{count} attempts', { count: detail.attempts })}
|
|
130
|
+
</DetailRow>
|
|
131
|
+
) : null}
|
|
132
|
+
{happenedAt ? (
|
|
133
|
+
<DetailRow label={t('Time')}>
|
|
134
|
+
<span className="font-mono text-[11px]">{happenedAt}</span>
|
|
135
|
+
</DetailRow>
|
|
136
|
+
) : null}
|
|
137
|
+
{traceId ? (
|
|
138
|
+
<DetailRow label={t('Trace ID')}>
|
|
139
|
+
<span className="font-mono text-[11px]">{traceId}</span>
|
|
140
|
+
</DetailRow>
|
|
141
|
+
) : null}
|
|
142
|
+
</dl>
|
|
143
|
+
{traceId ? <CopyDiagnostics detail={detail} reason={reason} traceId={traceId} /> : null}
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Button text per action, so the card does not repeat the copy three times. */
|
|
151
|
+
function actionLabel(action: FailureAction): string {
|
|
152
|
+
switch (action.kind) {
|
|
153
|
+
case 'retry':
|
|
154
|
+
return t('Continue');
|
|
155
|
+
case 'network-diagnostics':
|
|
156
|
+
return t('Network diagnostics');
|
|
157
|
+
case 'open-model-settings':
|
|
158
|
+
return t('Open model settings');
|
|
159
|
+
case 'new-chat':
|
|
160
|
+
return t('New chat');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The details layer's one-line Chinese cause, or the raw text when unknown. */
|
|
165
|
+
function causeSummary(detail: TurnFailureDetail | undefined, reason: string): string {
|
|
166
|
+
switch (detail?.kind) {
|
|
167
|
+
case 'transport':
|
|
168
|
+
return t('The connection to the model service dropped.');
|
|
169
|
+
case 'auth':
|
|
170
|
+
return t('The service rejected the credentials.');
|
|
171
|
+
case 'rate_limit':
|
|
172
|
+
return t('The service asked us to slow down.');
|
|
173
|
+
case 'server':
|
|
174
|
+
return t('The service reported its own failure.');
|
|
175
|
+
case 'context_overflow':
|
|
176
|
+
return t('The request exceeds the model window.');
|
|
177
|
+
case 'invalid_request':
|
|
178
|
+
return t('The service rejected the request.');
|
|
179
|
+
default:
|
|
180
|
+
return reason;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function categoryLabel(kind: string | undefined): string {
|
|
185
|
+
switch (kind) {
|
|
186
|
+
case 'transport':
|
|
187
|
+
return t('Network');
|
|
188
|
+
case 'auth':
|
|
189
|
+
return t('Credentials');
|
|
190
|
+
case 'rate_limit':
|
|
191
|
+
return t('Rate limit');
|
|
192
|
+
case 'server':
|
|
193
|
+
return t('Service error');
|
|
194
|
+
case 'context_overflow':
|
|
195
|
+
return t('Conversation too long');
|
|
196
|
+
case 'invalid_request':
|
|
197
|
+
return t('Rejected request');
|
|
198
|
+
default:
|
|
199
|
+
return t('Not classified');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* One-click report text. Everything a reader needs to reproduce the failure,
|
|
205
|
+
* and nothing that identifies the user: the proxy address is already stripped
|
|
206
|
+
* of credentials by the backend, and the API key never enters a failure.
|
|
207
|
+
*/
|
|
208
|
+
export function buildDiagnostics(input: {
|
|
209
|
+
detail?: TurnFailureDetail;
|
|
210
|
+
reason: string;
|
|
211
|
+
traceId: string;
|
|
212
|
+
version?: string;
|
|
213
|
+
}): string {
|
|
214
|
+
const lines = [
|
|
215
|
+
input.version ? t('Version: {version}', { version: input.version }) : null,
|
|
216
|
+
t('Category: {category}', {
|
|
217
|
+
category: input.detail ? categoryLabel(input.detail.kind) : t('Not classified'),
|
|
218
|
+
}),
|
|
219
|
+
t('Cause: {cause}', { cause: input.reason }),
|
|
220
|
+
input.detail?.proxy
|
|
221
|
+
? t('Connection: through system proxy {proxy}', { proxy: input.detail.proxy })
|
|
222
|
+
: t('Connection: direct'),
|
|
223
|
+
input.detail?.attempts
|
|
224
|
+
? t('Retries: {count}', { count: input.detail.attempts })
|
|
225
|
+
: null,
|
|
226
|
+
t('Trace ID: {traceId}', { traceId: input.traceId }),
|
|
227
|
+
].filter((line): line is string => line !== null);
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function CopyDiagnostics({
|
|
232
|
+
detail,
|
|
233
|
+
reason,
|
|
234
|
+
traceId,
|
|
235
|
+
}: {
|
|
236
|
+
detail?: TurnFailureDetail;
|
|
237
|
+
reason: string;
|
|
238
|
+
traceId: string;
|
|
239
|
+
}) {
|
|
240
|
+
const [copied, setCopied] = useState(false);
|
|
241
|
+
const copy = async () => {
|
|
242
|
+
const text = buildDiagnostics({ detail, reason, traceId });
|
|
243
|
+
try {
|
|
244
|
+
await navigator.clipboard.writeText(text);
|
|
245
|
+
setCopied(true);
|
|
246
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
247
|
+
} catch {
|
|
248
|
+
// Clipboard permission can be refused (browser, kiosk). The text is
|
|
249
|
+
// already on screen in the details layer, so reporting the failure is
|
|
250
|
+
// only a manual selection away; do not block on it.
|
|
251
|
+
setCopied(false);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
return (
|
|
255
|
+
<div className="mt-2 flex items-center gap-2">
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={() => void copy()}
|
|
259
|
+
className="inline-flex items-center gap-1 rounded-agent-md border border-agent-border bg-agent-card px-2 py-0.5 text-[11px] hover:bg-agent-muted"
|
|
260
|
+
data-action="copy-diagnostics"
|
|
261
|
+
>
|
|
262
|
+
<LuCopy className="h-3 w-3" />
|
|
263
|
+
{copied ? t('Copied') : t('Copy diagnostic info')}
|
|
264
|
+
</button>
|
|
265
|
+
<span className="text-[11px] text-agent-muted-foreground">
|
|
266
|
+
{t("Doesn't include your API key")}
|
|
267
|
+
</span>
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export default TurnFailureCard;
|
|
@@ -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 }))
|
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;
|