@nextclaw/ui 0.6.9 → 0.6.11

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 (83) hide show
  1. package/.eslintrc.cjs +10 -0
  2. package/CHANGELOG.md +15 -0
  3. package/dist/assets/{ChannelsList-DACqpUYZ.js → ChannelsList-C49JQ-Zt.js} +1 -1
  4. package/dist/assets/ChatPage-DIx05c6s.js +36 -0
  5. package/dist/assets/{DocBrowser-D7mjKkGe.js → DocBrowser-CpOosDEI.js} +1 -1
  6. package/dist/assets/{LogoBadge-BlDT-g9R.js → LogoBadge-CL_8ZPXU.js} +1 -1
  7. package/dist/assets/MarketplacePage-BOzko5s9.js +49 -0
  8. package/dist/assets/{ModelConfig-DwRU5qrw.js → ModelConfig-BZ4ZfaQB.js} +1 -1
  9. package/dist/assets/ProvidersList-fPpJ5gl6.js +1 -0
  10. package/dist/assets/{RuntimeConfig-C7BRLGSC.js → RuntimeConfig-Dt9pLB9P.js} +1 -1
  11. package/dist/assets/{SecretsConfig-D5xZh7VF.js → SecretsConfig-C1PU0Yy8.js} +2 -2
  12. package/dist/assets/{SessionsConfig-ovpj_otA.js → SessionsConfig-EskBOofQ.js} +2 -2
  13. package/dist/assets/{card-Bf4CtrW8.js → card-C7Gtw2Vs.js} +1 -1
  14. package/dist/assets/index-Cn6_2To7.js +8 -0
  15. package/dist/assets/index-nEYGCJTC.css +1 -0
  16. package/dist/assets/{input-CaKJyoWZ.js → input-oBvxsnV9.js} +1 -1
  17. package/dist/assets/{label-BaXSWTKI.js → label-C7F8lMpQ.js} +1 -1
  18. package/dist/assets/{page-layout-DA6PFRtQ.js → page-layout-DO8BlScF.js} +1 -1
  19. package/dist/assets/session-run-status-Kg0FwAPn.js +3 -0
  20. package/dist/assets/{switch-Cvd5wZs-.js → switch-C6a5GyZB.js} +1 -1
  21. package/dist/assets/{tabs-custom-0PybLkXs.js → tabs-custom-BatFap5k.js} +1 -1
  22. package/dist/assets/{useConfirmDialog-DdtpSju1.js → useConfirmDialog-zJzVKMdu.js} +2 -2
  23. package/dist/assets/{vendor-C--HHaLf.js → vendor-TlME1INH.js} +84 -84
  24. package/dist/index.html +3 -3
  25. package/package.json +4 -2
  26. package/src/App.tsx +1 -2
  27. package/src/api/config.ts +205 -202
  28. package/src/api/types.ts +54 -24
  29. package/src/components/chat/ChatConversationPanel.tsx +102 -121
  30. package/src/components/chat/ChatPage.tsx +165 -437
  31. package/src/components/chat/ChatSidebar.tsx +30 -36
  32. package/src/components/chat/ChatThread.tsx +73 -131
  33. package/src/components/chat/chat-input/ChatInputBarView.tsx +82 -0
  34. package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +71 -0
  35. package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +39 -0
  36. package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +31 -0
  37. package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +112 -0
  38. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +24 -0
  39. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +58 -0
  40. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +56 -0
  41. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +40 -0
  42. package/src/components/chat/chat-input/useChatInputBarController.ts +313 -0
  43. package/src/components/chat/chat-input.types.ts +15 -0
  44. package/src/components/chat/chat-page-data.ts +121 -0
  45. package/src/components/chat/chat-page-runtime.ts +221 -0
  46. package/src/components/chat/chat-session-route.ts +59 -0
  47. package/src/components/chat/chat-stream/nextbot-parsers.ts +52 -0
  48. package/src/components/chat/chat-stream/nextbot-runtime-agent.ts +413 -0
  49. package/src/components/chat/chat-stream/stream-event-adapter.ts +98 -0
  50. package/src/components/chat/chat-stream/transport.ts +159 -0
  51. package/src/components/chat/chat-stream/types.ts +76 -0
  52. package/src/components/chat/managers/chat-input.manager.ts +142 -0
  53. package/src/components/chat/managers/chat-run-status.manager.ts +32 -0
  54. package/src/components/chat/managers/chat-session-list.manager.ts +77 -0
  55. package/src/components/chat/managers/chat-stream-actions.manager.ts +34 -0
  56. package/src/components/chat/managers/chat-thread.manager.ts +86 -0
  57. package/src/components/chat/managers/chat-ui.manager.ts +65 -0
  58. package/src/components/chat/presenter/chat-presenter-context.tsx +25 -0
  59. package/src/components/chat/presenter/chat.presenter.ts +32 -0
  60. package/src/components/chat/stores/chat-input.store.ts +62 -0
  61. package/src/components/chat/stores/chat-run-status.store.ts +30 -0
  62. package/src/components/chat/stores/chat-session-list.store.ts +34 -0
  63. package/src/components/chat/stores/chat-thread.store.ts +52 -0
  64. package/src/components/chat/useChatRuntimeController.ts +134 -0
  65. package/src/components/chat/useChatSessionTypeState.ts +148 -0
  66. package/src/components/common/MaskedInput.tsx +1 -1
  67. package/src/components/config/ProviderForm.tsx +221 -14
  68. package/src/hooks/useConfig.ts +33 -2
  69. package/src/hooks/useObservable.ts +20 -0
  70. package/src/hooks/useWebSocket.ts +23 -1
  71. package/src/lib/chat-message.ts +2 -202
  72. package/src/lib/chat-runtime-utils.ts +250 -0
  73. package/src/lib/i18n.ts +11 -0
  74. package/tsconfig.json +2 -1
  75. package/vite.config.ts +2 -1
  76. package/dist/assets/ChatPage-iji0RkTR.js +0 -34
  77. package/dist/assets/MarketplacePage-CZq3jVgg.js +0 -49
  78. package/dist/assets/ProvidersList-DFxN3pjx.js +0 -1
  79. package/dist/assets/index-C_DhisNo.css +0 -1
  80. package/dist/assets/index-dKTqKCJo.js +0 -7
  81. package/dist/assets/session-run-status-CllIZxNf.js +0 -5
  82. package/src/components/chat/ChatInputBar.tsx +0 -590
  83. package/src/components/chat/useChatStreamController.ts +0 -591
@@ -0,0 +1,34 @@
1
+ import { create } from 'zustand';
2
+ import type { SessionEntryView } from '@/api/types';
3
+
4
+ export type ChatSessionListSnapshot = {
5
+ sessions: SessionEntryView[];
6
+ selectedSessionKey: string | null;
7
+ selectedAgentId: string;
8
+ query: string;
9
+ isLoading: boolean;
10
+ };
11
+
12
+ type ChatSessionListStore = {
13
+ snapshot: ChatSessionListSnapshot;
14
+ setSnapshot: (patch: Partial<ChatSessionListSnapshot>) => void;
15
+ };
16
+
17
+ const initialSnapshot: ChatSessionListSnapshot = {
18
+ sessions: [],
19
+ selectedSessionKey: null,
20
+ selectedAgentId: 'main',
21
+ query: '',
22
+ isLoading: false
23
+ };
24
+
25
+ export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({
26
+ snapshot: initialSnapshot,
27
+ setSnapshot: (patch) =>
28
+ set((state) => ({
29
+ snapshot: {
30
+ ...state.snapshot,
31
+ ...patch
32
+ }
33
+ }))
34
+ }));
@@ -0,0 +1,52 @@
1
+ import { create } from 'zustand';
2
+ import type { MutableRefObject } from 'react';
3
+ import type { UiMessage } from '@nextclaw/agent-chat';
4
+ import type { ChatModelOption } from '@/components/chat/chat-input.types';
5
+
6
+ export type ChatThreadSnapshot = {
7
+ isProviderStateResolved: boolean;
8
+ modelOptions: ChatModelOption[];
9
+ sessionTypeUnavailable: boolean;
10
+ sessionTypeUnavailableMessage?: string | null;
11
+ selectedSessionKey: string | null;
12
+ sessionDisplayName?: string;
13
+ canDeleteSession: boolean;
14
+ isDeletePending: boolean;
15
+ threadRef: MutableRefObject<HTMLDivElement | null> | null;
16
+ isHistoryLoading: boolean;
17
+ uiMessages: UiMessage[];
18
+ isSending: boolean;
19
+ isAwaitingAssistantOutput: boolean;
20
+ };
21
+
22
+ type ChatThreadStore = {
23
+ snapshot: ChatThreadSnapshot;
24
+ setSnapshot: (patch: Partial<ChatThreadSnapshot>) => void;
25
+ };
26
+
27
+ const initialSnapshot: ChatThreadSnapshot = {
28
+ isProviderStateResolved: false,
29
+ modelOptions: [],
30
+ sessionTypeUnavailable: false,
31
+ sessionTypeUnavailableMessage: null,
32
+ selectedSessionKey: null,
33
+ sessionDisplayName: undefined,
34
+ canDeleteSession: false,
35
+ isDeletePending: false,
36
+ threadRef: null,
37
+ isHistoryLoading: false,
38
+ uiMessages: [],
39
+ isSending: false,
40
+ isAwaitingAssistantOutput: false
41
+ };
42
+
43
+ export const useChatThreadStore = create<ChatThreadStore>((set) => ({
44
+ snapshot: initialSnapshot,
45
+ setSnapshot: (patch) =>
46
+ set((state) => ({
47
+ snapshot: {
48
+ ...state.snapshot,
49
+ ...patch
50
+ }
51
+ }))
52
+ }));
@@ -0,0 +1,134 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { type AgentChatController, getStopDisabledReason } from '@nextclaw/agent-chat';
3
+ import type { ChatRunView, SessionMessageView } from '@/api/types';
4
+ import type { SendMessageParams, UseChatStreamControllerParams } from '@/components/chat/chat-stream/types';
5
+ import { buildResumeMetadata, buildSendMetadata } from '@/components/chat/chat-stream/nextbot-parsers';
6
+ import { buildUiMessagesFromHistoryMessages, normalizeRequestedSkills } from '@/lib/chat-runtime-utils';
7
+ import { useValueFromBehaviorSubject, useValueFromObservable } from '@/hooks/useObservable';
8
+
9
+ export function useChatRuntimeController(
10
+ params: UseChatStreamControllerParams,
11
+ controller: AgentChatController
12
+ ) {
13
+ const paramsRef = useRef(params);
14
+ useEffect(() => {
15
+ paramsRef.current = params;
16
+ });
17
+
18
+ const activeHistorySessionKeyRef = useRef<string | null>(null);
19
+
20
+ // Bind callbacks to controller
21
+ useEffect(() => {
22
+ controller.setCallbacks({
23
+ onRunSettled: async ({ sourceSessionId, resultSessionId }) => {
24
+ const bindings = paramsRef.current;
25
+ await bindings.refetchSessions();
26
+ const activeSessionKey = bindings.selectedSessionKeyRef.current;
27
+ if (!activeSessionKey || activeSessionKey === sourceSessionId || (resultSessionId && activeSessionKey === resultSessionId)) {
28
+ await bindings.refetchHistory();
29
+ }
30
+ },
31
+ onRunError: ({ sourceMessage, restoreDraft }) => {
32
+ if (restoreDraft) {
33
+ paramsRef.current.setDraft((prev) => (prev.trim().length === 0 && sourceMessage ? sourceMessage : prev));
34
+ }
35
+ },
36
+ onSessionChanged: (sessionId) => {
37
+ paramsRef.current.setSelectedSessionKey((prev) => (prev === sessionId ? prev : sessionId));
38
+ }
39
+ });
40
+ }, [controller]);
41
+
42
+ // Reactive state from controller observables
43
+ const uiMessages = useValueFromObservable(controller.messages$, controller.getMessages());
44
+ const isSending = useValueFromBehaviorSubject(controller.isAgentResponding$);
45
+ const isAwaitingAssistantOutput = useValueFromBehaviorSubject(controller.isAwaitingResponse$);
46
+ const activeRun = useValueFromBehaviorSubject(controller.activeRun$);
47
+ const lastSendError = useValueFromBehaviorSubject(controller.lastError$);
48
+
49
+ // Derived state
50
+ const activeBackendRunId = activeRun?.remoteRunId ?? null;
51
+ const stopDisabledReason = getStopDisabledReason(activeRun);
52
+ const canStopCurrentRun = Boolean(
53
+ activeRun && (stopDisabledReason === null || (activeRun.remoteStopCapable && !activeBackendRunId))
54
+ );
55
+
56
+ const sendMessage = useCallback(async (payload: SendMessageParams) => {
57
+ const requestedSkills = normalizeRequestedSkills(payload.requestedSkills);
58
+ const metadata = buildSendMetadata(payload, requestedSkills);
59
+ await controller.send({
60
+ message: payload.message,
61
+ sessionId: payload.sessionKey,
62
+ agentId: payload.agentId,
63
+ metadata,
64
+ restoreDraftOnError: payload.restoreDraftOnError,
65
+ stopCapable: payload.stopSupported,
66
+ stopReason: payload.stopReason
67
+ });
68
+ }, [controller]);
69
+
70
+ const resumeRun = useCallback(async (run: ChatRunView) => {
71
+ const backendRunId = run.runId?.trim();
72
+ const sessionKey = run.sessionKey?.trim();
73
+ if (!backendRunId || !sessionKey) {
74
+ return;
75
+ }
76
+ const metadata = buildResumeMetadata(run);
77
+ await controller.resume({
78
+ remoteRunId: backendRunId,
79
+ sessionId: sessionKey,
80
+ agentId: run.agentId,
81
+ metadata,
82
+ stopCapable: run.stopSupported,
83
+ stopReason: run.stopReason
84
+ });
85
+ }, [controller]);
86
+
87
+ const stopCurrentRun = useCallback(async () => {
88
+ await controller.stop();
89
+ }, [controller]);
90
+
91
+ const resetStreamState = useCallback(() => {
92
+ activeHistorySessionKeyRef.current = null;
93
+ controller.reset();
94
+ }, [controller]);
95
+
96
+ const applyHistoryMessages = useCallback((messages: SessionMessageView[], options?: { isLoading?: boolean }) => {
97
+ const isRunActive = Boolean(controller.activeRun$.getValue() || controller.isAgentResponding$.getValue());
98
+ if (isRunActive) {
99
+ return;
100
+ }
101
+ const selectedSessionKey = paramsRef.current.selectedSessionKeyRef.current;
102
+ if (selectedSessionKey !== activeHistorySessionKeyRef.current) {
103
+ activeHistorySessionKeyRef.current = selectedSessionKey;
104
+ if (controller.getMessages().length > 0) {
105
+ controller.setMessages([]);
106
+ }
107
+ }
108
+ if (!selectedSessionKey) {
109
+ if (controller.getMessages().length > 0) {
110
+ controller.setMessages([]);
111
+ }
112
+ return;
113
+ }
114
+ if (options?.isLoading && messages.length === 0) {
115
+ return;
116
+ }
117
+ controller.setMessages(buildUiMessagesFromHistoryMessages(messages));
118
+ }, [controller]);
119
+
120
+ return {
121
+ uiMessages,
122
+ isSending,
123
+ isAwaitingAssistantOutput,
124
+ canStopCurrentRun,
125
+ stopDisabledReason,
126
+ lastSendError,
127
+ activeBackendRunId,
128
+ sendMessage,
129
+ stopCurrentRun,
130
+ resumeRun,
131
+ resetStreamState,
132
+ applyHistoryMessages
133
+ };
134
+ }
@@ -0,0 +1,148 @@
1
+ import { useEffect, useMemo } from 'react';
2
+ import type { Dispatch, SetStateAction } from 'react';
3
+ import type { SessionEntryView } from '@/api/types';
4
+ import { t } from '@/lib/i18n';
5
+
6
+ export const DEFAULT_SESSION_TYPE = 'native';
7
+
8
+ export type ChatSessionTypeOption = {
9
+ value: string;
10
+ label: string;
11
+ };
12
+
13
+ type UseChatSessionTypeStateParams = {
14
+ selectedSession: SessionEntryView | null;
15
+ selectedSessionKey: string | null;
16
+ pendingSessionType: string;
17
+ setPendingSessionType: Dispatch<SetStateAction<string>>;
18
+ sessionTypesData?: {
19
+ defaultType?: string;
20
+ options?: Array<{ value: string; label: string }>;
21
+ } | null;
22
+ };
23
+
24
+ export function normalizeSessionType(value: unknown): string {
25
+ if (typeof value !== 'string') {
26
+ return DEFAULT_SESSION_TYPE;
27
+ }
28
+ const normalized = value.trim().toLowerCase();
29
+ return normalized || DEFAULT_SESSION_TYPE;
30
+ }
31
+
32
+ export function resolveSessionTypeLabel(sessionType: string, fallbackLabel?: string): string {
33
+ if (sessionType === 'native') {
34
+ return t('chatSessionTypeNative');
35
+ }
36
+ if (sessionType === 'codex-sdk') {
37
+ return t('chatSessionTypeCodex');
38
+ }
39
+ if (sessionType === 'claude-agent-sdk') {
40
+ return t('chatSessionTypeClaude');
41
+ }
42
+ return fallbackLabel?.trim() || sessionType;
43
+ }
44
+
45
+ function buildSessionTypeOptions(
46
+ options: Array<{ value: string; label: string }>
47
+ ): ChatSessionTypeOption[] {
48
+ const deduped = new Map<string, ChatSessionTypeOption>();
49
+ for (const option of options) {
50
+ const value = normalizeSessionType(option.value);
51
+ deduped.set(value, {
52
+ value,
53
+ label: option.label?.trim() || resolveSessionTypeLabel(value)
54
+ });
55
+ }
56
+ if (!deduped.has(DEFAULT_SESSION_TYPE)) {
57
+ deduped.set(DEFAULT_SESSION_TYPE, {
58
+ value: DEFAULT_SESSION_TYPE,
59
+ label: resolveSessionTypeLabel(DEFAULT_SESSION_TYPE)
60
+ });
61
+ }
62
+ return Array.from(deduped.values()).sort((left, right) => {
63
+ if (left.value === DEFAULT_SESSION_TYPE) {
64
+ return -1;
65
+ }
66
+ if (right.value === DEFAULT_SESSION_TYPE) {
67
+ return 1;
68
+ }
69
+ return left.value.localeCompare(right.value);
70
+ });
71
+ }
72
+
73
+ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams): {
74
+ sessionTypeOptions: ChatSessionTypeOption[];
75
+ defaultSessionType: string;
76
+ selectedSessionType: string;
77
+ canEditSessionType: boolean;
78
+ sessionTypeUnavailable: boolean;
79
+ sessionTypeUnavailableMessage: string | null;
80
+ } {
81
+ const {
82
+ selectedSession,
83
+ selectedSessionKey,
84
+ pendingSessionType,
85
+ setPendingSessionType,
86
+ sessionTypesData
87
+ } = params;
88
+
89
+ const runtimeSessionTypeOptions = useMemo(
90
+ () => buildSessionTypeOptions(sessionTypesData?.options ?? []),
91
+ [sessionTypesData?.options]
92
+ );
93
+ const sessionTypeOptions = useMemo(() => {
94
+ const options = [...runtimeSessionTypeOptions];
95
+ const currentSessionType = normalizeSessionType(selectedSession?.sessionType);
96
+ if (!options.some((option) => option.value === currentSessionType)) {
97
+ options.push({
98
+ value: currentSessionType,
99
+ label: resolveSessionTypeLabel(currentSessionType)
100
+ });
101
+ }
102
+ return options.sort((left, right) => {
103
+ if (left.value === DEFAULT_SESSION_TYPE) {
104
+ return -1;
105
+ }
106
+ if (right.value === DEFAULT_SESSION_TYPE) {
107
+ return 1;
108
+ }
109
+ return left.value.localeCompare(right.value);
110
+ });
111
+ }, [runtimeSessionTypeOptions, selectedSession?.sessionType]);
112
+ const defaultSessionType = useMemo(
113
+ () => normalizeSessionType(sessionTypesData?.defaultType ?? DEFAULT_SESSION_TYPE),
114
+ [sessionTypesData?.defaultType]
115
+ );
116
+ const selectedSessionType = useMemo(
117
+ () => normalizeSessionType(selectedSession?.sessionType ?? pendingSessionType ?? defaultSessionType),
118
+ [defaultSessionType, pendingSessionType, selectedSession?.sessionType]
119
+ );
120
+
121
+ useEffect(() => {
122
+ if (selectedSessionKey) {
123
+ return;
124
+ }
125
+ setPendingSessionType(defaultSessionType);
126
+ }, [defaultSessionType, selectedSessionKey, setPendingSessionType]);
127
+
128
+ const canEditSessionType = !selectedSessionKey || Boolean(selectedSession?.sessionTypeMutable);
129
+ const availableSessionTypeSet = useMemo(
130
+ () => new Set(runtimeSessionTypeOptions.map((option) => option.value)),
131
+ [runtimeSessionTypeOptions]
132
+ );
133
+ const sessionTypeUnavailable = Boolean(
134
+ selectedSession && !availableSessionTypeSet.has(normalizeSessionType(selectedSession.sessionType))
135
+ );
136
+ const sessionTypeUnavailableMessage = sessionTypeUnavailable
137
+ ? `${resolveSessionTypeLabel(selectedSessionType)} ${t('chatSessionTypeUnavailableSuffix')}`
138
+ : null;
139
+
140
+ return {
141
+ sessionTypeOptions,
142
+ defaultSessionType,
143
+ selectedSessionType,
144
+ canEditSessionType,
145
+ sessionTypeUnavailable,
146
+ sessionTypeUnavailableMessage
147
+ };
148
+ }
@@ -9,7 +9,7 @@ interface MaskedInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
9
9
  isSet?: boolean;
10
10
  }
11
11
 
12
- export function MaskedInput({ maskedValue, isSet, className, value, onChange, placeholder, ...props }: MaskedInputProps) {
12
+ export function MaskedInput({ isSet, className, value, onChange, placeholder, ...props }: MaskedInputProps) {
13
13
  const [showKey, setShowKey] = useState(false);
14
14
  const [isEditing, setIsEditing] = useState(false);
15
15
  const hasUserInput = typeof value === 'string' && value.length > 0;
@@ -32,6 +32,15 @@ type ProviderFormProps = {
32
32
  onProviderDeleted?: (providerName: string) => void;
33
33
  };
34
34
 
35
+ type ProviderAuthMethodOption = {
36
+ id: string;
37
+ };
38
+
39
+ type PillSelectOption = {
40
+ value: string;
41
+ label: string;
42
+ };
43
+
35
44
  const EMPTY_PROVIDER_CONFIG: ProviderConfigView = {
36
45
  displayName: '',
37
46
  apiKeySet: false,
@@ -151,6 +160,101 @@ function serializeModelsForSave(models: string[], defaultModels: string[]): stri
151
160
  return models;
152
161
  }
153
162
 
163
+ function resolvePreferredAuthMethodId(params: {
164
+ providerName?: string;
165
+ methods: ProviderAuthMethodOption[];
166
+ defaultMethodId?: string;
167
+ language: 'zh' | 'en';
168
+ }): string {
169
+ const { providerName, methods, defaultMethodId, language } = params;
170
+ if (methods.length === 0) {
171
+ return '';
172
+ }
173
+
174
+ const methodIdMap = new Map<string, string>();
175
+ for (const method of methods) {
176
+ const methodId = method.id.trim();
177
+ if (methodId) {
178
+ methodIdMap.set(methodId.toLowerCase(), methodId);
179
+ }
180
+ }
181
+
182
+ const pick = (...candidates: string[]): string | undefined => {
183
+ for (const candidate of candidates) {
184
+ const resolved = methodIdMap.get(candidate.toLowerCase());
185
+ if (resolved) {
186
+ return resolved;
187
+ }
188
+ }
189
+ return undefined;
190
+ };
191
+
192
+ const normalizedDefault = defaultMethodId?.trim();
193
+ if (providerName === 'minimax-portal') {
194
+ if (language === 'zh') {
195
+ return pick('cn', 'china-mainland') ?? pick(normalizedDefault ?? '') ?? methods[0]?.id ?? '';
196
+ }
197
+ if (language === 'en') {
198
+ return pick('global', 'intl', 'international') ?? pick(normalizedDefault ?? '') ?? methods[0]?.id ?? '';
199
+ }
200
+ }
201
+
202
+ if (normalizedDefault) {
203
+ const matchedDefault = pick(normalizedDefault);
204
+ if (matchedDefault) {
205
+ return matchedDefault;
206
+ }
207
+ }
208
+
209
+ if (language === 'zh') {
210
+ return pick('cn') ?? methods[0]?.id ?? '';
211
+ }
212
+ if (language === 'en') {
213
+ return pick('global') ?? methods[0]?.id ?? '';
214
+ }
215
+
216
+ return methods[0]?.id ?? '';
217
+ }
218
+
219
+ function shouldUsePillSelector(params: {
220
+ required: boolean;
221
+ hasDefault: boolean;
222
+ optionCount: number;
223
+ }): boolean {
224
+ return params.required && params.hasDefault && params.optionCount > 1 && params.optionCount <= 3;
225
+ }
226
+
227
+ function PillSelector(props: {
228
+ value: string;
229
+ onChange: (value: string) => void;
230
+ options: PillSelectOption[];
231
+ }) {
232
+ const { value, onChange, options } = props;
233
+
234
+ return (
235
+ <div className="flex flex-wrap gap-2">
236
+ {options.map((option) => {
237
+ const selected = option.value === value;
238
+ return (
239
+ <button
240
+ key={option.value}
241
+ type="button"
242
+ onClick={() => onChange(option.value)}
243
+ aria-pressed={selected}
244
+ className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
245
+ selected
246
+ ? 'border-primary bg-primary text-white shadow-sm'
247
+ : 'border-gray-200 bg-white text-gray-700 hover:border-primary/40 hover:text-primary'
248
+ }`}
249
+ >
250
+ {option.label}
251
+ </button>
252
+ );
253
+ })}
254
+ </div>
255
+ );
256
+ }
257
+
154
258
  export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormProps) {
155
259
  const queryClient = useQueryClient();
156
260
  const { data: config } = useConfig();
@@ -174,6 +278,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
174
278
  const [showModelInput, setShowModelInput] = useState(false);
175
279
  const [authSessionId, setAuthSessionId] = useState<string | null>(null);
176
280
  const [authStatusMessage, setAuthStatusMessage] = useState('');
281
+ const [authMethodId, setAuthMethodId] = useState('');
177
282
  const authPollTimerRef = useRef<number | null>(null);
178
283
 
179
284
  const providerSpec = meta?.providers.find((p) => p.name === providerName);
@@ -225,11 +330,63 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
225
330
  apiBaseHint?.help ||
226
331
  t('providerApiBaseHelp');
227
332
  const providerAuth = providerSpec?.auth;
333
+ const providerAuthMethods = useMemo(
334
+ () => providerAuth?.methods ?? [],
335
+ [providerAuth?.methods]
336
+ );
337
+ const providerAuthMethodOptions = useMemo(
338
+ () =>
339
+ providerAuthMethods.map((method) => ({
340
+ value: method.id,
341
+ label: method.label?.[language] || method.label?.en || method.id
342
+ })),
343
+ [providerAuthMethods, language]
344
+ );
345
+ const preferredAuthMethodId = useMemo(
346
+ () => resolvePreferredAuthMethodId({
347
+ providerName,
348
+ methods: providerAuthMethods,
349
+ defaultMethodId: providerAuth?.defaultMethodId,
350
+ language
351
+ }),
352
+ [providerName, providerAuth?.defaultMethodId, providerAuthMethods, language]
353
+ );
354
+ const resolvedAuthMethodId = useMemo(() => {
355
+ if (!providerAuthMethods.length) {
356
+ return '';
357
+ }
358
+ const normalizedCurrent = authMethodId.trim();
359
+ if (normalizedCurrent && providerAuthMethods.some((method) => method.id === normalizedCurrent)) {
360
+ return normalizedCurrent;
361
+ }
362
+ return preferredAuthMethodId || providerAuthMethods[0]?.id || '';
363
+ }, [authMethodId, preferredAuthMethodId, providerAuthMethods]);
364
+ const selectedAuthMethod = useMemo(
365
+ () => providerAuthMethods.find((method) => method.id === resolvedAuthMethodId),
366
+ [providerAuthMethods, resolvedAuthMethodId]
367
+ );
368
+ const selectedAuthMethodHint =
369
+ selectedAuthMethod?.hint?.[language] || selectedAuthMethod?.hint?.en || '';
370
+ const shouldUseAuthMethodPills = shouldUsePillSelector({
371
+ required: providerAuth?.kind === 'device_code',
372
+ hasDefault: Boolean(providerAuth?.defaultMethodId?.trim()),
373
+ optionCount: providerAuthMethods.length
374
+ });
228
375
  const providerAuthNote =
229
376
  providerAuth?.note?.[language] ||
230
377
  providerAuth?.note?.en ||
231
378
  providerAuth?.displayName ||
232
379
  '';
380
+ const wireApiOptions = providerSpec?.wireApiOptions || ['auto', 'chat', 'responses'];
381
+ const wireApiSelectOptions: PillSelectOption[] = wireApiOptions.map((option) => ({
382
+ value: option,
383
+ label: option === 'chat' ? t('wireApiChat') : option === 'responses' ? t('wireApiResponses') : t('wireApiAuto')
384
+ }));
385
+ const shouldUseWireApiPills = shouldUsePillSelector({
386
+ required: Boolean(providerSpec?.supportsWireApi),
387
+ hasDefault: typeof providerSpec?.defaultWireApi === 'string' && providerSpec.defaultWireApi.length > 0,
388
+ optionCount: wireApiSelectOptions.length
389
+ });
233
390
 
234
391
  const clearAuthPollTimer = useCallback(() => {
235
392
  if (authPollTimerRef.current !== null) {
@@ -290,6 +447,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
290
447
  setProviderDisplayName('');
291
448
  setAuthSessionId(null);
292
449
  setAuthStatusMessage('');
450
+ setAuthMethodId('');
293
451
  clearAuthPollTimer();
294
452
  return;
295
453
  }
@@ -303,8 +461,18 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
303
461
  setProviderDisplayName(effectiveDisplayName);
304
462
  setAuthSessionId(null);
305
463
  setAuthStatusMessage('');
464
+ setAuthMethodId(preferredAuthMethodId);
306
465
  clearAuthPollTimer();
307
- }, [providerName, currentApiBase, resolvedProviderConfig.extraHeaders, currentWireApi, currentEditableModels, effectiveDisplayName, clearAuthPollTimer]);
466
+ }, [
467
+ providerName,
468
+ currentApiBase,
469
+ resolvedProviderConfig.extraHeaders,
470
+ currentWireApi,
471
+ currentEditableModels,
472
+ effectiveDisplayName,
473
+ preferredAuthMethodId,
474
+ clearAuthPollTimer
475
+ ]);
308
476
 
309
477
  useEffect(() => () => clearAuthPollTimer(), [clearAuthPollTimer]);
310
478
 
@@ -453,7 +621,10 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
453
621
 
454
622
  try {
455
623
  setAuthStatusMessage('');
456
- const result = await startProviderAuth.mutateAsync({ provider: providerName });
624
+ const result = await startProviderAuth.mutateAsync({
625
+ provider: providerName,
626
+ data: resolvedAuthMethodId ? { methodId: resolvedAuthMethodId } : {}
627
+ });
457
628
  if (!result.sessionId || !result.verificationUri) {
458
629
  throw new Error(t('providerAuthStartFailed'));
459
630
  }
@@ -567,6 +738,34 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
567
738
  {providerAuthNote ? (
568
739
  <p className="text-xs text-gray-600">{providerAuthNote}</p>
569
740
  ) : null}
741
+ {providerAuthMethods.length > 1 ? (
742
+ <div className="space-y-2">
743
+ <Label className="text-xs font-medium text-gray-700">{t('providerAuthMethodLabel')}</Label>
744
+ {shouldUseAuthMethodPills ? (
745
+ <PillSelector
746
+ value={resolvedAuthMethodId}
747
+ onChange={setAuthMethodId}
748
+ options={providerAuthMethodOptions}
749
+ />
750
+ ) : (
751
+ <Select value={resolvedAuthMethodId} onValueChange={setAuthMethodId}>
752
+ <SelectTrigger className="h-8 rounded-lg bg-white">
753
+ <SelectValue placeholder={t('providerAuthMethodPlaceholder')} />
754
+ </SelectTrigger>
755
+ <SelectContent>
756
+ {providerAuthMethodOptions.map((method) => (
757
+ <SelectItem key={method.value} value={method.value}>
758
+ {method.label}
759
+ </SelectItem>
760
+ ))}
761
+ </SelectContent>
762
+ </Select>
763
+ )}
764
+ {selectedAuthMethodHint ? (
765
+ <p className="text-xs text-gray-500">{selectedAuthMethodHint}</p>
766
+ ) : null}
767
+ </div>
768
+ ) : null}
570
769
  <div className="flex flex-wrap items-center gap-2">
571
770
  <Button
572
771
  type="button"
@@ -718,18 +917,26 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
718
917
  <Label htmlFor="wireApi" className="text-sm font-medium text-gray-900">
719
918
  {wireApiHint?.label ?? t('wireApi')}
720
919
  </Label>
721
- <Select value={wireApi} onValueChange={(v) => setWireApi(v as WireApiType)}>
722
- <SelectTrigger className="rounded-xl">
723
- <SelectValue />
724
- </SelectTrigger>
725
- <SelectContent>
726
- {(providerSpec.wireApiOptions || ['auto', 'chat', 'responses']).map((option) => (
727
- <SelectItem key={option} value={option}>
728
- {option === 'chat' ? t('wireApiChat') : option === 'responses' ? t('wireApiResponses') : t('wireApiAuto')}
729
- </SelectItem>
730
- ))}
731
- </SelectContent>
732
- </Select>
920
+ {shouldUseWireApiPills ? (
921
+ <PillSelector
922
+ value={wireApi}
923
+ onChange={(v) => setWireApi(v as WireApiType)}
924
+ options={wireApiSelectOptions}
925
+ />
926
+ ) : (
927
+ <Select value={wireApi} onValueChange={(v) => setWireApi(v as WireApiType)}>
928
+ <SelectTrigger className="rounded-xl">
929
+ <SelectValue />
930
+ </SelectTrigger>
931
+ <SelectContent>
932
+ {wireApiSelectOptions.map((option) => (
933
+ <SelectItem key={option.value} value={option.value}>
934
+ {option.label}
935
+ </SelectItem>
936
+ ))}
937
+ </SelectContent>
938
+ </Select>
939
+ )}
733
940
  </div>
734
941
  )}
735
942