@steerable/agent-shell-web 0.7.4 → 0.8.1

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.
@@ -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 { diagnoseLlmConnection, type DiagnoseResult } from '@/lib/local-api';
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 }))
@@ -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;