@nextclaw/ui 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/assets/ChannelsList-Dz8AGmaQ.js +1 -0
  3. package/dist/assets/ChatPage-BXDyt7BL.js +34 -0
  4. package/dist/assets/DocBrowser-CkKvzF7m.js +1 -0
  5. package/dist/assets/LogoBadge-C_ygxoGB.js +1 -0
  6. package/dist/assets/{MarketplacePage--wFfsNH0.js → MarketplacePage-DEvRs-Jc.js} +2 -2
  7. package/dist/assets/ModelConfig-BGfliN2Z.js +1 -0
  8. package/dist/assets/ProvidersList-BHLGLSvs.js +1 -0
  9. package/dist/assets/RuntimeConfig-Clltld_h.js +1 -0
  10. package/dist/assets/{SecretsConfig-B25P3J7V.js → SecretsConfig-CaJLf7oJ.js} +2 -2
  11. package/dist/assets/SessionsConfig-3QF7K9wm.js +2 -0
  12. package/dist/assets/{card-CCSDsedj.js → card-DXo3NsaB.js} +1 -1
  13. package/dist/assets/index-CGo5Vnh0.js +7 -0
  14. package/dist/assets/index-DcxYzrFm.css +1 -0
  15. package/dist/assets/input-CzTldMKo.js +1 -0
  16. package/dist/assets/{label-BxzAKPzU.js → label-De__vsU7.js} +1 -1
  17. package/dist/assets/{page-layout-DaLNSFKw.js → page-layout-BOgLC2tK.js} +1 -1
  18. package/dist/assets/session-run-status-DQVCDxTb.js +5 -0
  19. package/dist/assets/{switch-DHOCEi5L.js → switch-pMrS4heA.js} +1 -1
  20. package/dist/assets/{tabs-custom-zdFy3fnK.js → tabs-custom-DhOxWfCb.js} +1 -1
  21. package/dist/assets/{useConfirmDialog-D3ZVa92J.js → useConfirmDialog-CseKBGh5.js} +2 -2
  22. package/dist/assets/{vendor-Dj2ULvht.js → vendor-D33xZtEC.js} +6 -6
  23. package/dist/index.html +3 -3
  24. package/package.json +1 -1
  25. package/src/api/config.ts +53 -0
  26. package/src/api/types.ts +48 -0
  27. package/src/components/chat/ChatPage.tsx +15 -7
  28. package/src/components/chat/ChatSidebar.tsx +12 -7
  29. package/src/components/common/BrandHeader.tsx +23 -0
  30. package/src/components/common/SessionRunBadge.tsx +23 -0
  31. package/src/components/config/ProviderForm.tsx +193 -29
  32. package/src/components/config/ProvidersList.tsx +1 -2
  33. package/src/components/config/SessionsConfig.tsx +22 -2
  34. package/src/components/layout/Sidebar.tsx +2 -6
  35. package/src/hooks/useConfig.ts +31 -0
  36. package/src/lib/i18n.ts +17 -0
  37. package/src/lib/logos.ts +0 -19
  38. package/src/lib/session-run-status.ts +63 -0
  39. package/dist/assets/ChannelsList-VqzbAMCc.js +0 -1
  40. package/dist/assets/ChatPage-CjZqsBmn.js +0 -34
  41. package/dist/assets/DocBrowser-DvU-iUeB.js +0 -1
  42. package/dist/assets/ModelConfig-cY5UsbfA.js +0 -1
  43. package/dist/assets/ProvidersList-qZwaFoFt.js +0 -1
  44. package/dist/assets/RuntimeConfig-BY2Axlte.js +0 -1
  45. package/dist/assets/SessionsConfig-CxA9gIBw.js +0 -2
  46. package/dist/assets/chat-message-pw9oafI4.js +0 -5
  47. package/dist/assets/index-CD8a2KMH.js +0 -2
  48. package/dist/assets/index-DKOXGZc8.css +0 -1
  49. package/dist/assets/logos-C3oHQ9kv.js +0 -1
  50. package/dist/assets/useConfig-CDl9UK5m.js +0 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { api, API_BASE } from './client';
2
2
  import type {
3
+ AppMetaView,
3
4
  ConfigView,
4
5
  ConfigMetaView,
5
6
  ConfigSchemaResponse,
@@ -8,6 +9,10 @@ import type {
8
9
  ProviderConfigUpdate,
9
10
  ProviderConnectionTestRequest,
10
11
  ProviderConnectionTestResult,
12
+ ProviderAuthStartResult,
13
+ ProviderAuthPollRequest,
14
+ ProviderAuthPollResult,
15
+ ProviderAuthImportResult,
11
16
  ProviderCreateRequest,
12
17
  ProviderCreateResult,
13
18
  ProviderDeleteResult,
@@ -36,6 +41,15 @@ import type {
36
41
  ChatTurnStreamSessionEvent
37
42
  } from './types';
38
43
 
44
+ // GET /api/app/meta
45
+ export async function fetchAppMeta(): Promise<AppMetaView> {
46
+ const response = await api.get<AppMetaView>('/api/app/meta');
47
+ if (!response.ok) {
48
+ throw new Error(response.error.message);
49
+ }
50
+ return response.data;
51
+ }
52
+
39
53
  // GET /api/config
40
54
  export async function fetchConfig(): Promise<ConfigView> {
41
55
  const response = await api.get<ConfigView>('/api/config');
@@ -129,6 +143,45 @@ export async function testProviderConnection(
129
143
  return response.data;
130
144
  }
131
145
 
146
+ // POST /api/config/providers/:provider/auth/start
147
+ export async function startProviderAuth(provider: string): Promise<ProviderAuthStartResult> {
148
+ const response = await api.post<ProviderAuthStartResult>(
149
+ `/api/config/providers/${provider}/auth/start`,
150
+ {}
151
+ );
152
+ if (!response.ok) {
153
+ throw new Error(response.error.message);
154
+ }
155
+ return response.data;
156
+ }
157
+
158
+ // POST /api/config/providers/:provider/auth/poll
159
+ export async function pollProviderAuth(
160
+ provider: string,
161
+ data: ProviderAuthPollRequest
162
+ ): Promise<ProviderAuthPollResult> {
163
+ const response = await api.post<ProviderAuthPollResult>(
164
+ `/api/config/providers/${provider}/auth/poll`,
165
+ data
166
+ );
167
+ if (!response.ok) {
168
+ throw new Error(response.error.message);
169
+ }
170
+ return response.data;
171
+ }
172
+
173
+ // POST /api/config/providers/:provider/auth/import-cli
174
+ export async function importProviderAuthFromCli(provider: string): Promise<ProviderAuthImportResult> {
175
+ const response = await api.post<ProviderAuthImportResult>(
176
+ `/api/config/providers/${provider}/auth/import-cli`,
177
+ {}
178
+ );
179
+ if (!response.ok) {
180
+ throw new Error(response.error.message);
181
+ }
182
+ return response.data;
183
+ }
184
+
132
185
  // PUT /api/config/channels/:channel
133
186
  export async function updateChannel(
134
187
  channel: string,
package/src/api/types.ts CHANGED
@@ -9,6 +9,11 @@ export type ApiResponse<T> =
9
9
  | { ok: true; data: T }
10
10
  | { ok: false; error: ApiError };
11
11
 
12
+ export type AppMetaView = {
13
+ name: string;
14
+ productVersion: string;
15
+ };
16
+
12
17
  export type ProviderConfigView = {
13
18
  displayName?: string;
14
19
  apiKeySet: boolean;
@@ -69,6 +74,35 @@ export type ProviderConnectionTestResult = {
69
74
  hint?: string;
70
75
  };
71
76
 
77
+ export type ProviderAuthStartResult = {
78
+ provider: string;
79
+ kind: "device_code";
80
+ sessionId: string;
81
+ verificationUri: string;
82
+ userCode: string;
83
+ expiresAt: string;
84
+ intervalMs: number;
85
+ note?: string;
86
+ };
87
+
88
+ export type ProviderAuthPollRequest = {
89
+ sessionId: string;
90
+ };
91
+
92
+ export type ProviderAuthPollResult = {
93
+ provider: string;
94
+ status: "pending" | "authorized" | "denied" | "expired" | "error";
95
+ message?: string;
96
+ nextPollMs?: number;
97
+ };
98
+
99
+ export type ProviderAuthImportResult = {
100
+ provider: string;
101
+ status: "imported";
102
+ source: "cli";
103
+ expiresAt?: string;
104
+ };
105
+
72
106
  export type AgentProfileView = {
73
107
  id: string;
74
108
  default?: boolean;
@@ -394,6 +428,20 @@ export type ProviderSpecView = {
394
428
  isGateway?: boolean;
395
429
  isLocal?: boolean;
396
430
  defaultApiBase?: string;
431
+ logo?: string;
432
+ apiBaseHelp?: {
433
+ en?: string;
434
+ zh?: string;
435
+ };
436
+ auth?: {
437
+ kind: "device_code";
438
+ displayName?: string;
439
+ note?: {
440
+ en?: string;
441
+ zh?: string;
442
+ };
443
+ supportsCliImport?: boolean;
444
+ };
397
445
  defaultModels?: string[];
398
446
  supportsWireApi?: boolean;
399
447
  wireApiOptions?: Array<"auto" | "chat" | "responses">;
@@ -20,6 +20,7 @@ import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
20
20
  import { useChatStreamController } from '@/components/chat/useChatStreamController';
21
21
  import { buildFallbackEventsFromMessages } from '@/lib/chat-message';
22
22
  import { buildProviderModelCatalog, composeProviderModel } from '@/lib/provider-models';
23
+ import { buildActiveRunBySessionKey, buildSessionRunStatusByKey } from '@/lib/session-run-status';
23
24
  import { t } from '@/lib/i18n';
24
25
  import { useLocation, useNavigate, useParams } from 'react-router-dom';
25
26
 
@@ -351,22 +352,28 @@ export function ChatPage({ view }: ChatPageProps) {
351
352
  refetchHistory: historyQuery.refetch
352
353
  });
353
354
 
354
- const activeRunsQuery = useChatRuns(
355
- selectedSessionKey
355
+ const sessionStatusRunsQuery = useChatRuns(
356
+ view === 'chat'
356
357
  ? {
357
- sessionKey: selectedSessionKey,
358
358
  states: ['queued', 'running'],
359
- limit: 5
359
+ limit: 200
360
360
  }
361
361
  : undefined
362
362
  );
363
+ const activeRunBySessionKey = useMemo(
364
+ () => buildActiveRunBySessionKey(sessionStatusRunsQuery.data?.runs ?? []),
365
+ [sessionStatusRunsQuery.data?.runs]
366
+ );
367
+ const sessionRunStatusByKey = useMemo(
368
+ () => buildSessionRunStatusByKey(activeRunBySessionKey),
369
+ [activeRunBySessionKey]
370
+ );
363
371
  const activeRun = useMemo(() => {
364
- const candidates = activeRunsQuery.data?.runs ?? [];
365
372
  if (!selectedSessionKey) {
366
373
  return null;
367
374
  }
368
- return candidates.find((entry) => entry.sessionKey === selectedSessionKey) ?? null;
369
- }, [activeRunsQuery.data?.runs, selectedSessionKey]);
375
+ return activeRunBySessionKey.get(selectedSessionKey) ?? null;
376
+ }, [activeRunBySessionKey, selectedSessionKey]);
370
377
 
371
378
  useEffect(() => {
372
379
  if (view !== 'chat' || !selectedSessionKey || !activeRun) {
@@ -513,6 +520,7 @@ export function ChatPage({ view }: ChatPageProps) {
513
520
 
514
521
  const sidebarProps: ComponentProps<typeof ChatSidebar> = {
515
522
  sessions,
523
+ sessionRunStatusByKey,
516
524
  selectedSessionKey,
517
525
  onSelectSession: handleSelectSession,
518
526
  onCreateSession: createNewSession,
@@ -1,10 +1,13 @@
1
1
  import { useMemo } from 'react';
2
2
  import type { SessionEntryView } from '@/api/types';
3
3
  import { Button } from '@/components/ui/button';
4
+ import { BrandHeader } from '@/components/common/BrandHeader';
4
5
  import { Input } from '@/components/ui/input';
5
6
  import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
7
+ import { SessionRunBadge } from '@/components/common/SessionRunBadge';
6
8
  import { cn } from '@/lib/utils';
7
9
  import { LANGUAGE_OPTIONS, formatDateTime, t, type I18nLanguage } from '@/lib/i18n';
10
+ import type { SessionRunStatus } from '@/lib/session-run-status';
8
11
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
9
12
  import { useI18n } from '@/components/providers/I18nProvider';
10
13
  import { useTheme } from '@/components/providers/ThemeProvider';
@@ -13,6 +16,7 @@ import { AlarmClock, BrainCircuit, Languages, MessageSquareText, Palette, Plus,
13
16
 
14
17
  type ChatSidebarProps = {
15
18
  sessions: SessionEntryView[];
19
+ sessionRunStatusByKey: ReadonlyMap<string, SessionRunStatus>;
16
20
  selectedSessionKey: string | null;
17
21
  onSelectSession: (key: string) => void;
18
22
  onCreateSession: () => void;
@@ -82,12 +86,7 @@ export function ChatSidebar(props: ChatSidebarProps) {
82
86
  <aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
83
87
  {/* Logo */}
84
88
  <div className="px-5 pt-5 pb-3">
85
- <div className="flex items-center gap-2.5">
86
- <div className="h-7 w-7 rounded-lg overflow-hidden flex items-center justify-center">
87
- <img src="/logo.svg" alt="NextClaw" className="h-full w-full object-contain" />
88
- </div>
89
- <span className="text-[15px] font-semibold text-gray-800 tracking-[-0.01em]">NextClaw</span>
90
- </div>
89
+ <BrandHeader />
91
90
  </div>
92
91
 
93
92
  {/* New Task button */}
@@ -165,6 +164,7 @@ export function ChatSidebar(props: ChatSidebarProps) {
165
164
  <div className="space-y-0.5">
166
165
  {group.sessions.map((session) => {
167
166
  const active = props.selectedSessionKey === session.key;
167
+ const runStatus = props.sessionRunStatusByKey.get(session.key);
168
168
  return (
169
169
  <button
170
170
  key={session.key}
@@ -176,7 +176,12 @@ export function ChatSidebar(props: ChatSidebarProps) {
176
176
  : 'text-gray-700 hover:bg-gray-200/60 hover:text-gray-900'
177
177
  )}
178
178
  >
179
- <div className="truncate font-medium">{props.sessionTitle(session)}</div>
179
+ <div className="grid grid-cols-[minmax(0,1fr)_0.875rem] items-center gap-1.5">
180
+ <span className="truncate font-medium">{props.sessionTitle(session)}</span>
181
+ <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
182
+ {runStatus ? <SessionRunBadge status={runStatus} /> : null}
183
+ </span>
184
+ </div>
180
185
  <div className="mt-0.5 text-[11px] text-gray-400 truncate">
181
186
  {session.messageCount} · {formatDateTime(session.updatedAt)}
182
187
  </div>
@@ -0,0 +1,23 @@
1
+ import { useAppMeta } from '@/hooks/useConfig';
2
+
3
+ type BrandHeaderProps = {
4
+ className?: string;
5
+ };
6
+
7
+ export function BrandHeader({ className }: BrandHeaderProps) {
8
+ const { data } = useAppMeta();
9
+ const productName = data?.name ?? 'NextClaw';
10
+ const productVersion = data?.productVersion?.trim();
11
+
12
+ return (
13
+ <div className={className ?? 'flex items-center gap-2.5'}>
14
+ <div className="h-7 w-7 rounded-lg overflow-hidden flex items-center justify-center">
15
+ <img src="/logo.svg" alt={productName} className="h-full w-full object-contain" />
16
+ </div>
17
+ <div className="flex items-baseline gap-2 min-w-0">
18
+ <span className="truncate text-[15px] font-semibold tracking-[-0.01em] text-gray-800">{productName}</span>
19
+ {productVersion ? <span className="text-[13px] font-medium text-gray-500">v{productVersion}</span> : null}
20
+ </div>
21
+ </div>
22
+ );
23
+ }
@@ -0,0 +1,23 @@
1
+ import { Loader2 } from 'lucide-react';
2
+ import { cn } from '@/lib/utils';
3
+ import { t } from '@/lib/i18n';
4
+ import type { SessionRunStatus } from '@/lib/session-run-status';
5
+
6
+ type SessionRunBadgeProps = {
7
+ status: SessionRunStatus;
8
+ className?: string;
9
+ };
10
+
11
+ export function SessionRunBadge({ status, className }: SessionRunBadgeProps) {
12
+ const label = status === 'running' ? t('sessionsRunStatusRunning') : t('sessionsRunStatusQueued');
13
+ return (
14
+ <span
15
+ className={cn('inline-flex h-3.5 w-3.5 items-center justify-center text-gray-400', className)}
16
+ title={label}
17
+ aria-label={label}
18
+ >
19
+ <Loader2 className="h-3 w-3 animate-spin" />
20
+ <span className="sr-only">{label}</span>
21
+ </span>
22
+ );
23
+ }
@@ -1,9 +1,13 @@
1
- import { useEffect, useMemo, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
2
3
  import {
3
4
  useConfig,
4
5
  useConfigMeta,
5
6
  useConfigSchema,
6
7
  useDeleteProvider,
8
+ useImportProviderAuthFromCli,
9
+ usePollProviderAuth,
10
+ useStartProviderAuth,
7
11
  useTestProviderConnection,
8
12
  useUpdateProvider
9
13
  } from '@/hooks/useConfig';
@@ -14,9 +18,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
14
18
  import { MaskedInput } from '@/components/common/MaskedInput';
15
19
  import { KeyValueEditor } from '@/components/common/KeyValueEditor';
16
20
  import { StatusDot } from '@/components/ui/status-dot';
17
- import { t } from '@/lib/i18n';
21
+ import { getLanguage, t } from '@/lib/i18n';
18
22
  import { hintForPath } from '@/lib/config-hints';
19
- import type { ProviderConfigUpdate, ProviderConnectionTestRequest } from '@/api/types';
23
+ import type { ProviderConfigView, ProviderConfigUpdate, ProviderConnectionTestRequest } from '@/api/types';
20
24
  import { CircleDotDashed, Plus, X, Trash2, ChevronDown, Settings2 } from 'lucide-react';
21
25
  import { toast } from 'sonner';
22
26
  import { CONFIG_DETAIL_CARD_CLASS, CONFIG_EMPTY_DETAIL_CARD_CLASS } from './config-layout';
@@ -28,6 +32,16 @@ type ProviderFormProps = {
28
32
  onProviderDeleted?: (providerName: string) => void;
29
33
  };
30
34
 
35
+ const EMPTY_PROVIDER_CONFIG: ProviderConfigView = {
36
+ displayName: '',
37
+ apiKeySet: false,
38
+ apiKeyMasked: undefined,
39
+ apiBase: null,
40
+ extraHeaders: null,
41
+ wireApi: null,
42
+ models: []
43
+ };
44
+
31
45
  function normalizeHeaders(input: Record<string, string> | null | undefined): Record<string, string> | null {
32
46
  if (!input) {
33
47
  return null;
@@ -138,12 +152,16 @@ function serializeModelsForSave(models: string[], defaultModels: string[]): stri
138
152
  }
139
153
 
140
154
  export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormProps) {
155
+ const queryClient = useQueryClient();
141
156
  const { data: config } = useConfig();
142
157
  const { data: meta } = useConfigMeta();
143
158
  const { data: schema } = useConfigSchema();
144
159
  const updateProvider = useUpdateProvider();
145
160
  const deleteProvider = useDeleteProvider();
146
161
  const testProviderConnection = useTestProviderConnection();
162
+ const startProviderAuth = useStartProviderAuth();
163
+ const pollProviderAuth = usePollProviderAuth();
164
+ const importProviderAuthFromCli = useImportProviderAuthFromCli();
147
165
 
148
166
  const [apiKey, setApiKey] = useState('');
149
167
  const [apiBase, setApiBase] = useState('');
@@ -154,9 +172,13 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
154
172
  const [providerDisplayName, setProviderDisplayName] = useState('');
155
173
  const [showAdvanced, setShowAdvanced] = useState(false);
156
174
  const [showModelInput, setShowModelInput] = useState(false);
175
+ const [authSessionId, setAuthSessionId] = useState<string | null>(null);
176
+ const [authStatusMessage, setAuthStatusMessage] = useState('');
177
+ const authPollTimerRef = useRef<number | null>(null);
157
178
 
158
179
  const providerSpec = meta?.providers.find((p) => p.name === providerName);
159
180
  const providerConfig = providerName ? config?.providers[providerName] : null;
181
+ const resolvedProviderConfig = providerConfig ?? EMPTY_PROVIDER_CONFIG;
160
182
  const uiHints = schema?.uiHints;
161
183
  const isCustomProvider = Boolean(providerSpec?.isCustom);
162
184
 
@@ -165,7 +187,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
165
187
  const extraHeadersHint = providerName ? hintForPath(`providers.${providerName}.extraHeaders`, uiHints) : undefined;
166
188
  const wireApiHint = providerName ? hintForPath(`providers.${providerName}.wireApi`, uiHints) : undefined;
167
189
  const defaultDisplayName = providerSpec?.displayName || providerName || '';
168
- const currentDisplayName = (providerConfig?.displayName || '').trim();
190
+ const currentDisplayName = (resolvedProviderConfig.displayName || '').trim();
169
191
  const effectiveDisplayName = currentDisplayName || defaultDisplayName;
170
192
 
171
193
  const providerTitle = providerDisplayName.trim() || effectiveDisplayName || providerName || t('providersSelectPlaceholder');
@@ -175,9 +197,9 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
175
197
  [providerModelPrefix, providerName]
176
198
  );
177
199
  const defaultApiBase = providerSpec?.defaultApiBase || '';
178
- const currentApiBase = providerConfig?.apiBase || defaultApiBase;
179
- const currentHeaders = normalizeHeaders(providerConfig?.extraHeaders || null);
180
- const currentWireApi = (providerConfig?.wireApi || providerSpec?.defaultWireApi || 'auto') as WireApiType;
200
+ const currentApiBase = resolvedProviderConfig.apiBase || defaultApiBase;
201
+ const currentHeaders = normalizeHeaders(resolvedProviderConfig.extraHeaders || null);
202
+ const currentWireApi = (resolvedProviderConfig.wireApi || providerSpec?.defaultWireApi || 'auto') as WireApiType;
181
203
  const defaultModels = useMemo(
182
204
  () =>
183
205
  normalizeModelList(
@@ -188,17 +210,74 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
188
210
  const currentModels = useMemo(
189
211
  () =>
190
212
  normalizeModelList(
191
- (providerConfig?.models ?? []).map((model) => toProviderLocalModelId(model, providerModelAliases))
213
+ (resolvedProviderConfig.models ?? []).map((model) => toProviderLocalModelId(model, providerModelAliases))
192
214
  ),
193
- [providerConfig?.models, providerModelAliases]
215
+ [resolvedProviderConfig.models, providerModelAliases]
194
216
  );
195
217
  const currentEditableModels = useMemo(
196
218
  () => resolveEditableModels(defaultModels, currentModels),
197
219
  [defaultModels, currentModels]
198
220
  );
199
- const apiBaseHelpText = providerName === 'minimax'
200
- ? t('providerApiBaseHelpMinimax')
201
- : (apiBaseHint?.help || t('providerApiBaseHelp'));
221
+ const language = getLanguage();
222
+ const apiBaseHelpText =
223
+ providerSpec?.apiBaseHelp?.[language] ||
224
+ providerSpec?.apiBaseHelp?.en ||
225
+ apiBaseHint?.help ||
226
+ t('providerApiBaseHelp');
227
+ const providerAuth = providerSpec?.auth;
228
+ const providerAuthNote =
229
+ providerAuth?.note?.[language] ||
230
+ providerAuth?.note?.en ||
231
+ providerAuth?.displayName ||
232
+ '';
233
+
234
+ const clearAuthPollTimer = useCallback(() => {
235
+ if (authPollTimerRef.current !== null) {
236
+ window.clearTimeout(authPollTimerRef.current);
237
+ authPollTimerRef.current = null;
238
+ }
239
+ }, []);
240
+
241
+ const scheduleProviderAuthPoll = useCallback((sessionId: string, delayMs: number) => {
242
+ clearAuthPollTimer();
243
+ authPollTimerRef.current = window.setTimeout(() => {
244
+ void (async () => {
245
+ if (!providerName) {
246
+ return;
247
+ }
248
+ try {
249
+ const result = await pollProviderAuth.mutateAsync({
250
+ provider: providerName,
251
+ data: { sessionId }
252
+ });
253
+ if (result.status === 'pending') {
254
+ setAuthStatusMessage(t('providerAuthWaitingBrowser'));
255
+ scheduleProviderAuthPoll(sessionId, result.nextPollMs ?? delayMs);
256
+ return;
257
+ }
258
+ if (result.status === 'authorized') {
259
+ setAuthSessionId(null);
260
+ clearAuthPollTimer();
261
+ setAuthStatusMessage(t('providerAuthCompleted'));
262
+ toast.success(t('providerAuthCompleted'));
263
+ queryClient.invalidateQueries({ queryKey: ['config'] });
264
+ queryClient.invalidateQueries({ queryKey: ['config-meta'] });
265
+ return;
266
+ }
267
+ setAuthSessionId(null);
268
+ clearAuthPollTimer();
269
+ setAuthStatusMessage(result.message || `Authorization ${result.status}.`);
270
+ toast.error(result.message || `Authorization ${result.status}.`);
271
+ } catch (error) {
272
+ setAuthSessionId(null);
273
+ clearAuthPollTimer();
274
+ const message = error instanceof Error ? error.message : String(error);
275
+ setAuthStatusMessage(message);
276
+ toast.error(`Authorization failed: ${message}`);
277
+ }
278
+ })();
279
+ }, Math.max(1000, delayMs));
280
+ }, [clearAuthPollTimer, pollProviderAuth, providerName, queryClient]);
202
281
 
203
282
  useEffect(() => {
204
283
  if (!providerName) {
@@ -209,17 +288,25 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
209
288
  setModels([]);
210
289
  setModelDraft('');
211
290
  setProviderDisplayName('');
291
+ setAuthSessionId(null);
292
+ setAuthStatusMessage('');
293
+ clearAuthPollTimer();
212
294
  return;
213
295
  }
214
296
 
215
297
  setApiKey('');
216
298
  setApiBase(currentApiBase);
217
- setExtraHeaders(providerConfig?.extraHeaders || null);
299
+ setExtraHeaders(resolvedProviderConfig.extraHeaders || null);
218
300
  setWireApi(currentWireApi);
219
301
  setModels(currentEditableModels);
220
302
  setModelDraft('');
221
303
  setProviderDisplayName(effectiveDisplayName);
222
- }, [providerName, currentApiBase, providerConfig?.extraHeaders, currentWireApi, currentEditableModels, effectiveDisplayName]);
304
+ setAuthSessionId(null);
305
+ setAuthStatusMessage('');
306
+ clearAuthPollTimer();
307
+ }, [providerName, currentApiBase, resolvedProviderConfig.extraHeaders, currentWireApi, currentEditableModels, effectiveDisplayName, clearAuthPollTimer]);
308
+
309
+ useEffect(() => () => clearAuthPollTimer(), [clearAuthPollTimer]);
223
310
 
224
311
  const hasChanges = useMemo(() => {
225
312
  if (!providerName) {
@@ -252,16 +339,6 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
252
339
  currentEditableModels
253
340
  ]);
254
341
 
255
- const resetToDefault = () => {
256
- setApiKey('');
257
- setApiBase(defaultApiBase);
258
- setExtraHeaders(null);
259
- setWireApi((providerSpec?.defaultWireApi || 'auto') as WireApiType);
260
- setModels(defaultModels);
261
- setModelDraft('');
262
- setProviderDisplayName(defaultDisplayName);
263
- };
264
-
265
342
  const handleAddModel = () => {
266
343
  const next = toProviderLocalModelId(modelDraft, providerModelAliases);
267
344
  if (!next) {
@@ -369,7 +446,51 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
369
446
  }
370
447
  };
371
448
 
372
- if (!providerName || !providerSpec || !providerConfig) {
449
+ const handleStartProviderAuth = async () => {
450
+ if (!providerName || providerAuth?.kind !== 'device_code') {
451
+ return;
452
+ }
453
+
454
+ try {
455
+ setAuthStatusMessage('');
456
+ const result = await startProviderAuth.mutateAsync({ provider: providerName });
457
+ if (!result.sessionId || !result.verificationUri) {
458
+ throw new Error(t('providerAuthStartFailed'));
459
+ }
460
+ setAuthSessionId(result.sessionId);
461
+ setAuthStatusMessage(`${t('providerAuthOpenPrompt')}${result.userCode}${t('providerAuthOpenPromptSuffix')}`);
462
+ window.open(result.verificationUri, '_blank', 'noopener,noreferrer');
463
+ scheduleProviderAuthPoll(result.sessionId, result.intervalMs);
464
+ } catch (error) {
465
+ const message = error instanceof Error ? error.message : String(error);
466
+ setAuthSessionId(null);
467
+ clearAuthPollTimer();
468
+ setAuthStatusMessage(message);
469
+ toast.error(`${t('providerAuthStartFailed')}: ${message}`);
470
+ }
471
+ };
472
+
473
+ const handleImportProviderAuthFromCli = async () => {
474
+ if (!providerName || providerAuth?.kind !== 'device_code') {
475
+ return;
476
+ }
477
+ try {
478
+ clearAuthPollTimer();
479
+ setAuthSessionId(null);
480
+ const result = await importProviderAuthFromCli.mutateAsync({ provider: providerName });
481
+ const expiresText = result.expiresAt ? ` (expires: ${result.expiresAt})` : '';
482
+ setAuthStatusMessage(`${t('providerAuthImportStatusPrefix')}${expiresText}`);
483
+ toast.success(t('providerAuthImportSuccess'));
484
+ queryClient.invalidateQueries({ queryKey: ['config'] });
485
+ queryClient.invalidateQueries({ queryKey: ['config-meta'] });
486
+ } catch (error) {
487
+ const message = error instanceof Error ? error.message : String(error);
488
+ setAuthStatusMessage(message);
489
+ toast.error(`${t('providerAuthImportFailed')}: ${message}`);
490
+ }
491
+ };
492
+
493
+ if (!providerName || !providerSpec) {
373
494
  return (
374
495
  <div className={CONFIG_EMPTY_DETAIL_CARD_CLASS}>
375
496
  <div>
@@ -380,7 +501,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
380
501
  );
381
502
  }
382
503
 
383
- const statusLabel = providerConfig.apiKeySet ? t('statusReady') : t('statusSetup');
504
+ const statusLabel = resolvedProviderConfig.apiKeySet ? t('statusReady') : t('statusSetup');
384
505
 
385
506
  return (
386
507
  <div className={CONFIG_DETAIL_CARD_CLASS}>
@@ -399,7 +520,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
399
520
  <Trash2 className="h-4 w-4" />
400
521
  </button>
401
522
  )}
402
- <StatusDot status={providerConfig.apiKeySet ? 'ready' : 'setup'} label={statusLabel} />
523
+ <StatusDot status={resolvedProviderConfig.apiKeySet ? 'ready' : 'setup'} label={statusLabel} />
403
524
  </div>
404
525
  </div>
405
526
  </div>
@@ -430,7 +551,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
430
551
  <MaskedInput
431
552
  id="apiKey"
432
553
  value={apiKey}
433
- isSet={providerConfig.apiKeySet}
554
+ isSet={resolvedProviderConfig.apiKeySet}
434
555
  onChange={(e) => setApiKey(e.target.value)}
435
556
  placeholder={apiKeyHint?.placeholder ?? t('enterApiKey')}
436
557
  className="rounded-xl"
@@ -438,6 +559,49 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
438
559
  <p className="text-xs text-gray-500">{t('leaveBlankToKeepUnchanged')}</p>
439
560
  </div>
440
561
 
562
+ {providerAuth?.kind === 'device_code' && (
563
+ <div className="space-y-2 rounded-xl border border-primary/20 bg-primary-50/50 p-3">
564
+ <Label className="text-sm font-medium text-gray-900">
565
+ {providerAuth.displayName || t('providerAuthSectionTitle')}
566
+ </Label>
567
+ {providerAuthNote ? (
568
+ <p className="text-xs text-gray-600">{providerAuthNote}</p>
569
+ ) : null}
570
+ <div className="flex flex-wrap items-center gap-2">
571
+ <Button
572
+ type="button"
573
+ variant="outline"
574
+ size="sm"
575
+ onClick={handleStartProviderAuth}
576
+ disabled={startProviderAuth.isPending || Boolean(authSessionId)}
577
+ >
578
+ {startProviderAuth.isPending
579
+ ? t('providerAuthStarting')
580
+ : authSessionId
581
+ ? t('providerAuthAuthorizing')
582
+ : t('providerAuthAuthorizeInBrowser')}
583
+ </Button>
584
+ {providerAuth.supportsCliImport ? (
585
+ <Button
586
+ type="button"
587
+ variant="outline"
588
+ size="sm"
589
+ onClick={handleImportProviderAuthFromCli}
590
+ disabled={importProviderAuthFromCli.isPending}
591
+ >
592
+ {importProviderAuthFromCli.isPending ? t('providerAuthImporting') : t('providerAuthImportFromCli')}
593
+ </Button>
594
+ ) : null}
595
+ {authSessionId ? (
596
+ <span className="text-xs text-gray-500">{t('providerAuthSessionLabel')}: {authSessionId.slice(0, 8)}…</span>
597
+ ) : null}
598
+ </div>
599
+ {authStatusMessage ? (
600
+ <p className="text-xs text-gray-600">{authStatusMessage}</p>
601
+ ) : null}
602
+ </div>
603
+ )}
604
+
441
605
  <div className="space-y-2">
442
606
  <Label htmlFor="apiBase" className="text-sm font-medium text-gray-900">
443
607
  {apiBaseHint?.label ?? t('apiBase')}
@@ -450,7 +614,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
450
614
  placeholder={defaultApiBase || apiBaseHint?.placeholder || 'https://api.example.com'}
451
615
  className="rounded-xl"
452
616
  />
453
- <p className="text-xs text-gray-500">{t('providerApiBaseHelpShort')}</p>
617
+ <p className="text-xs text-gray-500">{apiBaseHelpText}</p>
454
618
  </div>
455
619
 
456
620
  <div className="space-y-2">