@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.
- package/CHANGELOG.md +6 -0
- package/dist/assets/ChannelsList-Dz8AGmaQ.js +1 -0
- package/dist/assets/ChatPage-BXDyt7BL.js +34 -0
- package/dist/assets/DocBrowser-CkKvzF7m.js +1 -0
- package/dist/assets/LogoBadge-C_ygxoGB.js +1 -0
- package/dist/assets/{MarketplacePage--wFfsNH0.js → MarketplacePage-DEvRs-Jc.js} +2 -2
- package/dist/assets/ModelConfig-BGfliN2Z.js +1 -0
- package/dist/assets/ProvidersList-BHLGLSvs.js +1 -0
- package/dist/assets/RuntimeConfig-Clltld_h.js +1 -0
- package/dist/assets/{SecretsConfig-B25P3J7V.js → SecretsConfig-CaJLf7oJ.js} +2 -2
- package/dist/assets/SessionsConfig-3QF7K9wm.js +2 -0
- package/dist/assets/{card-CCSDsedj.js → card-DXo3NsaB.js} +1 -1
- package/dist/assets/index-CGo5Vnh0.js +7 -0
- package/dist/assets/index-DcxYzrFm.css +1 -0
- package/dist/assets/input-CzTldMKo.js +1 -0
- package/dist/assets/{label-BxzAKPzU.js → label-De__vsU7.js} +1 -1
- package/dist/assets/{page-layout-DaLNSFKw.js → page-layout-BOgLC2tK.js} +1 -1
- package/dist/assets/session-run-status-DQVCDxTb.js +5 -0
- package/dist/assets/{switch-DHOCEi5L.js → switch-pMrS4heA.js} +1 -1
- package/dist/assets/{tabs-custom-zdFy3fnK.js → tabs-custom-DhOxWfCb.js} +1 -1
- package/dist/assets/{useConfirmDialog-D3ZVa92J.js → useConfirmDialog-CseKBGh5.js} +2 -2
- package/dist/assets/{vendor-Dj2ULvht.js → vendor-D33xZtEC.js} +6 -6
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/api/config.ts +53 -0
- package/src/api/types.ts +48 -0
- package/src/components/chat/ChatPage.tsx +15 -7
- package/src/components/chat/ChatSidebar.tsx +12 -7
- package/src/components/common/BrandHeader.tsx +23 -0
- package/src/components/common/SessionRunBadge.tsx +23 -0
- package/src/components/config/ProviderForm.tsx +193 -29
- package/src/components/config/ProvidersList.tsx +1 -2
- package/src/components/config/SessionsConfig.tsx +22 -2
- package/src/components/layout/Sidebar.tsx +2 -6
- package/src/hooks/useConfig.ts +31 -0
- package/src/lib/i18n.ts +17 -0
- package/src/lib/logos.ts +0 -19
- package/src/lib/session-run-status.ts +63 -0
- package/dist/assets/ChannelsList-VqzbAMCc.js +0 -1
- package/dist/assets/ChatPage-CjZqsBmn.js +0 -34
- package/dist/assets/DocBrowser-DvU-iUeB.js +0 -1
- package/dist/assets/ModelConfig-cY5UsbfA.js +0 -1
- package/dist/assets/ProvidersList-qZwaFoFt.js +0 -1
- package/dist/assets/RuntimeConfig-BY2Axlte.js +0 -1
- package/dist/assets/SessionsConfig-CxA9gIBw.js +0 -2
- package/dist/assets/chat-message-pw9oafI4.js +0 -5
- package/dist/assets/index-CD8a2KMH.js +0 -2
- package/dist/assets/index-DKOXGZc8.css +0 -1
- package/dist/assets/logos-C3oHQ9kv.js +0 -1
- package/dist/assets/useConfig-CDl9UK5m.js +0 -6
package/package.json
CHANGED
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
|
|
355
|
-
|
|
355
|
+
const sessionStatusRunsQuery = useChatRuns(
|
|
356
|
+
view === 'chat'
|
|
356
357
|
? {
|
|
357
|
-
sessionKey: selectedSessionKey,
|
|
358
358
|
states: ['queued', 'running'],
|
|
359
|
-
limit:
|
|
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
|
|
369
|
-
}, [
|
|
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
|
-
<
|
|
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="
|
|
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 = (
|
|
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 =
|
|
179
|
-
const currentHeaders = normalizeHeaders(
|
|
180
|
-
const currentWireApi = (
|
|
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
|
-
(
|
|
213
|
+
(resolvedProviderConfig.models ?? []).map((model) => toProviderLocalModelId(model, providerModelAliases))
|
|
192
214
|
),
|
|
193
|
-
[
|
|
215
|
+
[resolvedProviderConfig.models, providerModelAliases]
|
|
194
216
|
);
|
|
195
217
|
const currentEditableModels = useMemo(
|
|
196
218
|
() => resolveEditableModels(defaultModels, currentModels),
|
|
197
219
|
[defaultModels, currentModels]
|
|
198
220
|
);
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
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(
|
|
299
|
+
setExtraHeaders(resolvedProviderConfig.extraHeaders || null);
|
|
218
300
|
setWireApi(currentWireApi);
|
|
219
301
|
setModels(currentEditableModels);
|
|
220
302
|
setModelDraft('');
|
|
221
303
|
setProviderDisplayName(effectiveDisplayName);
|
|
222
|
-
|
|
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
|
-
|
|
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 =
|
|
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={
|
|
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={
|
|
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">{
|
|
617
|
+
<p className="text-xs text-gray-500">{apiBaseHelpText}</p>
|
|
454
618
|
</div>
|
|
455
619
|
|
|
456
620
|
<div className="space-y-2">
|