mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(web-shell): wait for session before checking subscription commands * fix(cli): keep signed-out subscription startup in the composer * fix(subscription): preserve model selection across sign-in * fix(subscription): align sign-in with model selection scope * fix(subscription): apply reasoning defaults across models * fix(subscription): tag Qwen web links by client * fix(subscription): prefix Qwen web client tags * fix(subscription): attribute Qwen web links by paywall scene * fix(subscription): recover historical sessions with saved API models * fix(cli): remove the signed-out subscription startup notice * fix(subscription): support standalone desktop activation and expose errors * fix(subscription): isolate activation and session model selection * fix(subscription): enforce auth policy and restore home login * test(cli): preserve the default initializer mock on restore --------- Co-authored-by: luoyun <[email protected]> Co-authored-by: 易良 <[email protected]>
1556 lines
46 KiB
TypeScript
1556 lines
46 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useId,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
useDaemonSessionOwnerGuard,
|
|
useActions,
|
|
useConnection,
|
|
useWorkspaceActions,
|
|
type DaemonAuthProviderBaseUrlOption,
|
|
type DaemonAuthProviderCatalog,
|
|
type DaemonAuthProviderDescriptor,
|
|
type DaemonAuthProviderInstallRequest,
|
|
} from '@qwen-code/web-shell/daemon-react-sdk';
|
|
import { useI18n } from '../../i18n';
|
|
import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener';
|
|
import { isDesktopShell, openExternalUrl } from '../../utils/externalOpen';
|
|
import { QwenSubscriptionFlow } from './QwenSubscriptionPanel';
|
|
import { DialogShell } from '../dialogs/DialogShell';
|
|
import { Checkbox } from '../ui/checkbox';
|
|
import {
|
|
Field,
|
|
FieldContent,
|
|
FieldDescription,
|
|
FieldGroup,
|
|
FieldLabel,
|
|
} from '../ui/field';
|
|
import { Input } from '../ui/input';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '../ui/select';
|
|
import { isVoiceModelId } from '../../voice/voiceModels';
|
|
import { Switch } from '../ui/switch';
|
|
import styles from './AuthMessage.module.css';
|
|
import { QwenSubscriptionEntry } from './QwenSubscriptionEntry';
|
|
|
|
const TOS_PRIVACY_URL =
|
|
'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/';
|
|
|
|
type AuthView = 'groups' | 'providers' | 'step' | 'review';
|
|
type AuthGroup = DaemonAuthProviderCatalog['groups'][number];
|
|
// Derived so the SDK union stays the single source of truth: a re-declared
|
|
// literal union here is what let the daemon emit a group id this file denied.
|
|
type AuthGroupId = AuthGroup['id'];
|
|
type ModelWireApi = 'chat-completions' | 'responses';
|
|
type AuthStep =
|
|
| 'protocol'
|
|
| 'wireApi'
|
|
| 'baseUrl'
|
|
| 'apiKey'
|
|
| 'models'
|
|
| 'advancedConfig';
|
|
|
|
interface AuthMessageProps {
|
|
dialog?: boolean;
|
|
standalone?: boolean;
|
|
allowAdd?: boolean;
|
|
onMessage: (text: string, type?: 'status' | 'error') => void;
|
|
onModelActivated?: (modelId: string) => void;
|
|
getWorkspaceCwd?: () => string | undefined;
|
|
activateSessionSubscriptionModel: (
|
|
workspaceCwd: string | undefined,
|
|
sessionId: string,
|
|
modelId: string,
|
|
) => Promise<void>;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface Option<T extends string> {
|
|
value: T;
|
|
label: string;
|
|
description?: string;
|
|
}
|
|
|
|
function getProtocolOptions(
|
|
t: (key: string, vars?: Record<string, string | number>) => string,
|
|
): Array<Option<string>> {
|
|
return [
|
|
{
|
|
value: 'openai',
|
|
label: t('auth.protocol.openai'),
|
|
description: t('auth.protocol.openaiDesc'),
|
|
},
|
|
{
|
|
value: 'anthropic',
|
|
label: t('auth.protocol.anthropic'),
|
|
description: t('auth.protocol.anthropicDesc'),
|
|
},
|
|
{
|
|
value: 'gemini',
|
|
label: t('auth.protocol.gemini'),
|
|
description: t('auth.protocol.geminiDesc'),
|
|
},
|
|
];
|
|
}
|
|
|
|
function defaultBaseUrl(protocol: string, wireApi?: ModelWireApi): string {
|
|
if (protocol === 'anthropic') return 'https://api.anthropic.com/v1';
|
|
if (protocol === 'gemini') return 'https://generativelanguage.googleapis.com';
|
|
// The Responses wire dials the /v1-less default endpoint (the pipeline
|
|
// appends /v1/responses itself); the Chat Completions wire keeps /v1.
|
|
if (protocol === 'openai-responses' || wireApi === 'responses') {
|
|
return 'https://api.openai.com';
|
|
}
|
|
return 'https://api.openai.com/v1';
|
|
}
|
|
|
|
function modelIds(provider: DaemonAuthProviderDescriptor | null): string {
|
|
return (
|
|
provider?.models
|
|
?.map(
|
|
(model: NonNullable<DaemonAuthProviderDescriptor['models']>[number]) =>
|
|
model.id,
|
|
)
|
|
.join(', ') ?? ''
|
|
);
|
|
}
|
|
|
|
function titleForStep(
|
|
step: AuthStep,
|
|
provider: DaemonAuthProviderDescriptor,
|
|
t: ReturnType<typeof useI18n>['t'],
|
|
): string {
|
|
if (step === 'protocol') return t('auth.step.protocol');
|
|
if (step === 'wireApi') return t('auth.step.api');
|
|
if (step === 'baseUrl') {
|
|
return provider.uiLabels?.baseUrlStepTitle ?? t('auth.step.baseUrl');
|
|
}
|
|
if (step === 'apiKey') return t('auth.step.apiKey');
|
|
if (step === 'models') return t('auth.step.models');
|
|
return t('auth.step.advanced');
|
|
}
|
|
|
|
function invalidTokenLimit(value: string): boolean {
|
|
return (
|
|
value.trim() !== '' &&
|
|
(!/^\d+$/.test(value.trim()) ||
|
|
Number(value) < 1 ||
|
|
Number(value) > 10_000_000)
|
|
);
|
|
}
|
|
|
|
function normalizeModelIds(value: string): string[] {
|
|
return [
|
|
...new Set(
|
|
value
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter((item) => item.length > 0),
|
|
),
|
|
];
|
|
}
|
|
|
|
export function AuthMessage({
|
|
onMessage,
|
|
onModelActivated,
|
|
getWorkspaceCwd,
|
|
activateSessionSubscriptionModel,
|
|
onClose,
|
|
allowAdd = true,
|
|
dialog = false,
|
|
standalone = false,
|
|
}: AuthMessageProps) {
|
|
const allowAddRef = useRef(allowAdd);
|
|
allowAddRef.current = allowAdd;
|
|
const { t } = useI18n();
|
|
const fieldId = useId();
|
|
const openExternalLink = useExternalLinkOpener();
|
|
const workspaceActions = useWorkspaceActions();
|
|
const sessionActions = useActions();
|
|
const connection = useConnection();
|
|
const sessionOwnerGuard = useDaemonSessionOwnerGuard();
|
|
const connectionRef = useRef(connection);
|
|
connectionRef.current = connection;
|
|
const ownerRef = useRef(sessionOwnerGuard.capture());
|
|
const ownerChanged = !ownerRef.current.isCurrent();
|
|
if (ownerChanged) ownerRef.current = sessionOwnerGuard.capture();
|
|
const saveOperationRef = useRef(0);
|
|
const [catalog, setCatalog] = useState<DaemonAuthProviderCatalog>();
|
|
const [subscriptionTarget, setSubscriptionTarget] = useState<{
|
|
workspaceCwd: string | undefined;
|
|
sessionId: string | undefined;
|
|
sessionWorkspaceCwd: string | undefined;
|
|
standalone: boolean;
|
|
}>();
|
|
const getSubscriptionContext = useCallback(() => {
|
|
const current = connectionRef.current;
|
|
const workspaceCwd = standalone
|
|
? undefined
|
|
: getWorkspaceCwd
|
|
? getWorkspaceCwd()
|
|
: current.workspaceCwd;
|
|
const sessionId =
|
|
(standalone && current.sessionContext?.kind === 'standalone') ||
|
|
(!standalone && workspaceCwd === current.workspaceCwd)
|
|
? current.sessionId
|
|
: undefined;
|
|
return {
|
|
workspaceCwd,
|
|
sessionId,
|
|
sessionWorkspaceCwd: sessionId ? current.workspaceCwd : undefined,
|
|
standalone,
|
|
};
|
|
}, [getWorkspaceCwd, standalone]);
|
|
const {
|
|
workspaceCwd: subscriptionWorkspace,
|
|
sessionId: subscriptionSession,
|
|
sessionWorkspaceCwd: subscriptionSessionWorkspace,
|
|
} = getSubscriptionContext();
|
|
const subscriptionTargetChanged =
|
|
subscriptionTarget !== undefined &&
|
|
(subscriptionTarget.workspaceCwd !== subscriptionWorkspace ||
|
|
subscriptionTarget.sessionId !== subscriptionSession ||
|
|
subscriptionTarget.sessionWorkspaceCwd !== subscriptionSessionWorkspace ||
|
|
subscriptionTarget.standalone !== standalone);
|
|
useEffect(() => {
|
|
if (subscriptionTargetChanged) setSubscriptionTarget(undefined);
|
|
}, [subscriptionTargetChanged]);
|
|
const startSubscription = useCallback(() => {
|
|
setSubscriptionTarget(getSubscriptionContext());
|
|
}, [getSubscriptionContext]);
|
|
const [subscriptionEnabled, setSubscriptionEnabled] = useState(false);
|
|
useEffect(() => {
|
|
const abort = new AbortController();
|
|
const owner = sessionOwnerGuard.capture();
|
|
setSubscriptionEnabled(false);
|
|
void workspaceActions
|
|
.getSubscriptionConfig({ signal: abort.signal })
|
|
.then((config) => {
|
|
if (!abort.signal.aborted && owner.isCurrent())
|
|
setSubscriptionEnabled(config.enabled);
|
|
})
|
|
.catch(() => undefined);
|
|
return () => abort.abort();
|
|
}, [workspaceActions, sessionOwnerGuard, ownerChanged]);
|
|
const [view, setView] = useState<AuthView>('groups');
|
|
const [groupIndex, setGroupIndex] = useState(0);
|
|
const [providerIndex, setProviderIndex] = useState(0);
|
|
const [setupBackView, setSetupBackView] = useState<AuthView>('providers');
|
|
const [stepIndex, setStepIndex] = useState(0);
|
|
const [groupId, setGroupId] = useState<AuthGroupId>('qwencloud');
|
|
const [provider, setProvider] = useState<DaemonAuthProviderDescriptor | null>(
|
|
null,
|
|
);
|
|
const [protocol, setProtocol] = useState('openai');
|
|
const [wireApi, setWireApi] = useState<ModelWireApi>('chat-completions');
|
|
const [baseUrl, setBaseUrl] = useState('');
|
|
const [apiKey, setApiKey] = useState('');
|
|
const [models, setModels] = useState('');
|
|
const [thinking, setThinking] = useState(false);
|
|
const [modality, setModality] = useState(false);
|
|
const [modalityImage, setModalityImage] = useState(true);
|
|
const [modalityVideo, setModalityVideo] = useState(true);
|
|
const [modalityAudio, setModalityAudio] = useState(false);
|
|
const [modalityPdf, setModalityPdf] = useState(false);
|
|
const [purpose, setPurpose] = useState<'chat' | 'image' | 'voice'>('chat');
|
|
const [contextWindow, setContextWindow] = useState('');
|
|
const [maxTokens, setMaxTokens] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const bodyRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
workspaceActions
|
|
.getAuthProviders()
|
|
.then((next) => {
|
|
setCatalog(next);
|
|
setLoading(false);
|
|
})
|
|
.catch((err: unknown) => {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setLoading(false);
|
|
});
|
|
}, [workspaceActions]);
|
|
|
|
const groups = useMemo(() => catalog?.groups ?? [], [catalog]);
|
|
const hasSubscriptionEntry = allowAdd && subscriptionEnabled;
|
|
const groupOffset = hasSubscriptionEntry ? 1 : 0;
|
|
const providers = useMemo(() => {
|
|
const ids =
|
|
groups.find((group: AuthGroup) => group.id === groupId)?.providerIds ??
|
|
[];
|
|
return ids
|
|
.map((id: string) =>
|
|
catalog?.providers.find(
|
|
(item: DaemonAuthProviderDescriptor) => item.id === id,
|
|
),
|
|
)
|
|
.filter(
|
|
(
|
|
item: DaemonAuthProviderDescriptor | undefined,
|
|
): item is DaemonAuthProviderDescriptor => !!item,
|
|
);
|
|
}, [catalog, groupId, groups]);
|
|
|
|
const steps = useMemo(
|
|
() =>
|
|
(provider?.steps ?? []).filter(
|
|
(step) => step !== 'wireApi' || protocol === 'openai',
|
|
),
|
|
[provider?.steps, protocol],
|
|
);
|
|
const protocolOptions = useMemo(
|
|
() =>
|
|
getProtocolOptions(t).filter((option) =>
|
|
(provider?.protocolOptions ?? [provider?.protocol]).includes(
|
|
option.value,
|
|
),
|
|
),
|
|
[provider, t],
|
|
);
|
|
const apiOptions: Array<Option<ModelWireApi>> = [
|
|
{ value: 'chat-completions', label: t('auth.api.chatCompletions') },
|
|
{ value: 'responses', label: t('auth.api.responses') },
|
|
];
|
|
const currentStep = steps[stepIndex] as AuthStep | undefined;
|
|
const shouldReview = provider?.showAdvancedConfig === true;
|
|
const isInputStep =
|
|
currentStep === 'apiKey' ||
|
|
currentStep === 'models' ||
|
|
(currentStep === 'baseUrl' && !Array.isArray(provider?.baseUrl));
|
|
|
|
const [optionIndex, setOptionIndex] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const index =
|
|
view === 'groups'
|
|
? groupIndex
|
|
: view === 'providers'
|
|
? providerIndex
|
|
: optionIndex;
|
|
bodyRef.current
|
|
?.querySelectorAll<HTMLButtonElement>('[data-web-shell-auth-option]')
|
|
[index]?.focus({ preventScroll: true });
|
|
}, [
|
|
view,
|
|
groupIndex,
|
|
providerIndex,
|
|
optionIndex,
|
|
currentStep,
|
|
loading,
|
|
hasSubscriptionEntry,
|
|
subscriptionTarget,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (currentStep === 'wireApi') {
|
|
setOptionIndex(wireApi === 'responses' ? 1 : 0);
|
|
}
|
|
}, [wireApi, currentStep]);
|
|
|
|
useEffect(() => {
|
|
if (!ownerChanged) return;
|
|
saveOperationRef.current += 1;
|
|
setSaving(false);
|
|
setError(null);
|
|
}, [ownerChanged]);
|
|
|
|
const startProvider = useCallback(
|
|
(
|
|
nextProvider: DaemonAuthProviderDescriptor,
|
|
backView: AuthView = 'providers',
|
|
) => {
|
|
setProvider(nextProvider);
|
|
setSetupBackView(backView);
|
|
const nextProtocol =
|
|
nextProvider.protocolOptions?.[0] ?? nextProvider.protocol;
|
|
const nextWireApi: ModelWireApi =
|
|
nextProtocol === 'openai-responses' ? 'responses' : 'chat-completions';
|
|
setProtocol(
|
|
nextProtocol === 'openai-responses' ? 'openai' : nextProtocol,
|
|
);
|
|
setWireApi(nextWireApi);
|
|
if (typeof nextProvider.baseUrl === 'string') {
|
|
setBaseUrl(nextProvider.baseUrl);
|
|
} else if (Array.isArray(nextProvider.baseUrl)) {
|
|
setBaseUrl(nextProvider.baseUrl[0]?.url ?? '');
|
|
} else {
|
|
setBaseUrl(defaultBaseUrl(nextProtocol, nextWireApi));
|
|
}
|
|
setApiKey('');
|
|
setModels(modelIds(nextProvider));
|
|
setThinking(false);
|
|
setModality(false);
|
|
setModalityImage(true);
|
|
setModalityVideo(true);
|
|
setModalityAudio(false);
|
|
setModalityPdf(false);
|
|
setPurpose('chat');
|
|
setContextWindow('');
|
|
setMaxTokens('');
|
|
setStepIndex(0);
|
|
setOptionIndex(0);
|
|
setError(null);
|
|
setView(nextProvider.steps.length > 0 ? 'step' : 'review');
|
|
},
|
|
[],
|
|
);
|
|
|
|
const goBack = useCallback(() => {
|
|
setError(null);
|
|
if (view === 'groups') {
|
|
onClose();
|
|
return;
|
|
}
|
|
if (view === 'providers') {
|
|
setView('groups');
|
|
return;
|
|
}
|
|
if (view === 'review') {
|
|
if (steps.length === 0) {
|
|
setView(setupBackView);
|
|
return;
|
|
}
|
|
setView('step');
|
|
setStepIndex(Math.max(0, steps.length - 1));
|
|
return;
|
|
}
|
|
if (stepIndex > 0) {
|
|
setStepIndex((idx) => idx - 1);
|
|
setOptionIndex(0);
|
|
return;
|
|
}
|
|
setView(setupBackView);
|
|
}, [onClose, setupBackView, stepIndex, steps.length, view]);
|
|
|
|
const advancedConfig = useMemo<
|
|
DaemonAuthProviderInstallRequest['advancedConfig']
|
|
>(() => {
|
|
if (!steps.includes('advancedConfig')) return undefined;
|
|
const config: NonNullable<
|
|
DaemonAuthProviderInstallRequest['advancedConfig']
|
|
> = {
|
|
replaceExisting: true,
|
|
...(purpose !== 'chat' ? { purpose } : {}),
|
|
...(purpose === 'chat' && thinking ? { enableThinking: true } : {}),
|
|
...(purpose === 'chat' && modality
|
|
? {
|
|
multimodal: {
|
|
...(modalityImage ? { image: true } : {}),
|
|
...(modalityVideo ? { video: true } : {}),
|
|
...(modalityAudio ? { audio: true } : {}),
|
|
...(modalityPdf ? { pdf: true } : {}),
|
|
},
|
|
}
|
|
: {}),
|
|
...(contextWindow.trim() && !invalidTokenLimit(contextWindow)
|
|
? { contextWindowSize: Number(contextWindow) }
|
|
: {}),
|
|
...(purpose === 'chat' &&
|
|
maxTokens.trim() &&
|
|
!invalidTokenLimit(maxTokens)
|
|
? { maxTokens: Number(maxTokens) }
|
|
: {}),
|
|
};
|
|
return config;
|
|
}, [
|
|
steps,
|
|
purpose,
|
|
thinking,
|
|
modality,
|
|
modalityImage,
|
|
modalityVideo,
|
|
modalityAudio,
|
|
modalityPdf,
|
|
contextWindow,
|
|
maxTokens,
|
|
]);
|
|
|
|
const validateAdvanced = useCallback(() => {
|
|
if (!steps.includes('advancedConfig')) return true;
|
|
if (
|
|
purpose === 'voice' &&
|
|
(protocol !== 'openai' ||
|
|
wireApi === 'responses' ||
|
|
!normalizeModelIds(models).every(isVoiceModelId))
|
|
) {
|
|
setError(t('auth.purpose.voiceHint'));
|
|
return false;
|
|
}
|
|
if (purpose === 'image') {
|
|
try {
|
|
const url = new URL(baseUrl.trim());
|
|
if (url.protocol !== 'https:' || url.search || url.hash)
|
|
throw new Error();
|
|
} catch {
|
|
setError(t('auth.purpose.imageHint'));
|
|
return false;
|
|
}
|
|
}
|
|
for (const [value, label] of [
|
|
[contextWindow, 'auth.advanced.contextWindow'],
|
|
[purpose === 'chat' ? maxTokens : '', 'auth.advanced.maxTokens'],
|
|
]) {
|
|
if (invalidTokenLimit(value)) {
|
|
setError(t('auth.advanced.tokenLimitInvalid', { field: t(label) }));
|
|
return false;
|
|
}
|
|
}
|
|
if (
|
|
purpose === 'chat' &&
|
|
modality &&
|
|
!modalityImage &&
|
|
!modalityVideo &&
|
|
!modalityAudio &&
|
|
!modalityPdf
|
|
) {
|
|
setError(t('auth.advanced.modalitiesRequired'));
|
|
return false;
|
|
}
|
|
return true;
|
|
}, [
|
|
purpose,
|
|
protocol,
|
|
wireApi,
|
|
models,
|
|
baseUrl,
|
|
steps,
|
|
contextWindow,
|
|
maxTokens,
|
|
modality,
|
|
modalityImage,
|
|
modalityVideo,
|
|
modalityAudio,
|
|
modalityPdf,
|
|
t,
|
|
]);
|
|
|
|
const save = useCallback(() => {
|
|
if (!allowAddRef.current || !provider || saving || !validateAdvanced())
|
|
return;
|
|
const owner = ownerRef.current;
|
|
const operation = ++saveOperationRef.current;
|
|
const isCurrent = () =>
|
|
saveOperationRef.current === operation && owner.isCurrent();
|
|
setSaving(true);
|
|
setError(null);
|
|
workspaceActions
|
|
.installAuthProvider({
|
|
providerId: provider.id,
|
|
protocol,
|
|
...(protocol === 'openai' && provider.steps.includes('wireApi')
|
|
? { wireApi }
|
|
: {}),
|
|
baseUrl: baseUrl.trim(),
|
|
apiKey: apiKey.trim(),
|
|
modelIds: normalizeModelIds(models),
|
|
advancedConfig,
|
|
})
|
|
.then(async (result) => {
|
|
if (!isCurrent()) return;
|
|
let syncFailed = result.runtimeSync?.status === 'failed';
|
|
if (!syncFailed) {
|
|
try {
|
|
await sessionActions.refreshModels(undefined, {
|
|
reloadProviders: false,
|
|
});
|
|
} catch {
|
|
syncFailed = true;
|
|
}
|
|
}
|
|
if (!isCurrent()) return;
|
|
onMessage(
|
|
syncFailed
|
|
? `${result.message}\n\n${t('settings.models.runtimeSyncFailed')}`
|
|
: result.message,
|
|
);
|
|
onClose();
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (!isCurrent()) return;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
setError(message);
|
|
onMessage(message, 'error');
|
|
})
|
|
.finally(() => {
|
|
if (isCurrent()) setSaving(false);
|
|
});
|
|
}, [
|
|
wireApi,
|
|
apiKey,
|
|
baseUrl,
|
|
advancedConfig,
|
|
validateAdvanced,
|
|
models,
|
|
onClose,
|
|
onMessage,
|
|
protocol,
|
|
provider,
|
|
saving,
|
|
sessionActions,
|
|
t,
|
|
workspaceActions,
|
|
]);
|
|
|
|
const goNext = useCallback(() => {
|
|
if (!provider || saving) return;
|
|
if (currentStep === 'baseUrl') {
|
|
const effective = baseUrl.trim() || defaultBaseUrl(protocol, wireApi);
|
|
if (!effective) {
|
|
setError(t('auth.baseUrlRequired'));
|
|
return;
|
|
}
|
|
if (!/^https?:\/\//i.test(effective)) {
|
|
setError(t('auth.baseUrlInvalid'));
|
|
return;
|
|
}
|
|
if (!baseUrl.trim()) setBaseUrl(effective);
|
|
}
|
|
if (currentStep === 'apiKey' && apiKey.trim().length === 0) {
|
|
setError(t('auth.apiKeyRequired'));
|
|
return;
|
|
}
|
|
if (currentStep === 'models' && normalizeModelIds(models).length === 0) {
|
|
setError(t('auth.modelsRequired'));
|
|
return;
|
|
}
|
|
if (currentStep === 'advancedConfig' && !validateAdvanced()) return;
|
|
setError(null);
|
|
if (stepIndex >= steps.length - 1) {
|
|
if (shouldReview) {
|
|
setView('review');
|
|
} else {
|
|
save();
|
|
}
|
|
} else {
|
|
setStepIndex((idx) => idx + 1);
|
|
setOptionIndex(0);
|
|
}
|
|
}, [
|
|
wireApi,
|
|
apiKey,
|
|
baseUrl,
|
|
currentStep,
|
|
models,
|
|
saving,
|
|
validateAdvanced,
|
|
protocol,
|
|
provider,
|
|
save,
|
|
shouldReview,
|
|
stepIndex,
|
|
steps.length,
|
|
t,
|
|
]);
|
|
|
|
const activate = useCallback(() => {
|
|
if (view === 'groups') {
|
|
if (hasSubscriptionEntry && groupIndex === 0) {
|
|
startSubscription();
|
|
return;
|
|
}
|
|
const group = groups[groupIndex - groupOffset];
|
|
if (!group) return;
|
|
if (group.id === 'custom') {
|
|
const customProvider = group.providerIds
|
|
.map((id: string) =>
|
|
catalog?.providers.find(
|
|
(item: DaemonAuthProviderDescriptor) => item.id === id,
|
|
),
|
|
)
|
|
.find(
|
|
(
|
|
item: DaemonAuthProviderDescriptor | undefined,
|
|
): item is DaemonAuthProviderDescriptor => !!item,
|
|
);
|
|
if (customProvider) startProvider(customProvider, 'groups');
|
|
return;
|
|
}
|
|
setGroupId(group.id);
|
|
setProviderIndex(0);
|
|
setView('providers');
|
|
return;
|
|
}
|
|
if (view === 'providers') {
|
|
const selected = providers[providerIndex];
|
|
if (selected) startProvider(selected, 'providers');
|
|
return;
|
|
}
|
|
if (view === 'review') {
|
|
save();
|
|
return;
|
|
}
|
|
if (!provider || !currentStep) return;
|
|
if (currentStep === 'protocol') {
|
|
const value = protocolOptions[optionIndex]?.value;
|
|
if (value) {
|
|
setProtocol(value);
|
|
setWireApi('chat-completions');
|
|
if (!provider.baseUrl) setBaseUrl(defaultBaseUrl(value));
|
|
}
|
|
goNext();
|
|
return;
|
|
}
|
|
if (currentStep === 'wireApi') {
|
|
const nextWireApi: ModelWireApi =
|
|
optionIndex === 1 ? 'responses' : 'chat-completions';
|
|
// A wire change re-derives the default endpoint; a no-op re-selection
|
|
// must not clobber a baseUrl the user already typed.
|
|
if (
|
|
nextWireApi !== wireApi &&
|
|
!provider.baseUrl &&
|
|
baseUrl === defaultBaseUrl(protocol, wireApi)
|
|
) {
|
|
setBaseUrl(defaultBaseUrl(protocol, nextWireApi));
|
|
}
|
|
setWireApi(nextWireApi);
|
|
goNext();
|
|
return;
|
|
}
|
|
if (currentStep === 'baseUrl' && Array.isArray(provider.baseUrl)) {
|
|
const selected = provider.baseUrl[optionIndex];
|
|
if (selected) setBaseUrl(selected.url);
|
|
goNext();
|
|
return;
|
|
}
|
|
goNext();
|
|
}, [
|
|
wireApi,
|
|
baseUrl,
|
|
currentStep,
|
|
catalog,
|
|
goNext,
|
|
hasSubscriptionEntry,
|
|
startSubscription,
|
|
groupOffset,
|
|
groupIndex,
|
|
groups,
|
|
optionIndex,
|
|
provider,
|
|
providerIndex,
|
|
protocol,
|
|
protocolOptions,
|
|
providers,
|
|
save,
|
|
startProvider,
|
|
view,
|
|
]);
|
|
|
|
const activateAtIndex = useCallback(
|
|
(index: number) => {
|
|
if (view === 'groups') {
|
|
const group = groups[index];
|
|
if (!group) return;
|
|
if (group.id === 'custom') {
|
|
const customProvider = group.providerIds
|
|
.map((id: string) =>
|
|
catalog?.providers.find(
|
|
(item: DaemonAuthProviderDescriptor) => item.id === id,
|
|
),
|
|
)
|
|
.find(
|
|
(
|
|
item: DaemonAuthProviderDescriptor | undefined,
|
|
): item is DaemonAuthProviderDescriptor => !!item,
|
|
);
|
|
if (customProvider) startProvider(customProvider, 'groups');
|
|
return;
|
|
}
|
|
setGroupId(group.id);
|
|
setProviderIndex(0);
|
|
setView('providers');
|
|
return;
|
|
}
|
|
if (view === 'providers') {
|
|
const selected = providers[index];
|
|
if (selected) startProvider(selected, 'providers');
|
|
return;
|
|
}
|
|
if (!provider || !currentStep) {
|
|
if (view === 'review') save();
|
|
return;
|
|
}
|
|
if (currentStep === 'protocol') {
|
|
const value = protocolOptions[index]?.value;
|
|
if (value) {
|
|
setProtocol(value);
|
|
setWireApi('chat-completions');
|
|
if (!provider.baseUrl) setBaseUrl(defaultBaseUrl(value));
|
|
}
|
|
goNext();
|
|
return;
|
|
}
|
|
if (currentStep === 'wireApi') {
|
|
const nextWireApi: ModelWireApi =
|
|
index === 1 ? 'responses' : 'chat-completions';
|
|
if (
|
|
nextWireApi !== wireApi &&
|
|
!provider.baseUrl &&
|
|
baseUrl === defaultBaseUrl(protocol, wireApi)
|
|
) {
|
|
setBaseUrl(defaultBaseUrl(protocol, nextWireApi));
|
|
}
|
|
setWireApi(nextWireApi);
|
|
goNext();
|
|
return;
|
|
}
|
|
if (currentStep === 'baseUrl' && Array.isArray(provider.baseUrl)) {
|
|
const selected = provider.baseUrl[index];
|
|
if (selected) setBaseUrl(selected.url);
|
|
goNext();
|
|
return;
|
|
}
|
|
},
|
|
[
|
|
wireApi,
|
|
baseUrl,
|
|
currentStep,
|
|
catalog,
|
|
goNext,
|
|
groups,
|
|
provider,
|
|
protocol,
|
|
protocolOptions,
|
|
providers,
|
|
save,
|
|
startProvider,
|
|
view,
|
|
],
|
|
);
|
|
|
|
const renderOptions = <T extends string>(
|
|
options: Array<Option<T>>,
|
|
selected: number,
|
|
onSelect: (index: number) => void,
|
|
) => (
|
|
<div className={styles.options}>
|
|
{options.map((option, index) => (
|
|
<button
|
|
type="button"
|
|
key={option.value}
|
|
data-web-shell-auth-option
|
|
disabled={saving}
|
|
className={`${styles.option} ${selected === index ? styles.optionActive : ''}`}
|
|
onFocus={() => onSelect(index)}
|
|
onClick={() => {
|
|
onSelect(index);
|
|
activateAtIndex(index);
|
|
}}
|
|
>
|
|
<div className={styles.optionText}>
|
|
<div className={styles.label}>{option.label}</div>
|
|
{option.description && (
|
|
<div className={styles.description}>{option.description}</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
const renderStep = () => {
|
|
if (!provider || !currentStep) return null;
|
|
if (currentStep === 'protocol') {
|
|
return renderOptions(protocolOptions, optionIndex, setOptionIndex);
|
|
}
|
|
if (currentStep === 'wireApi') {
|
|
return renderOptions(apiOptions, optionIndex, setOptionIndex);
|
|
}
|
|
if (currentStep === 'baseUrl') {
|
|
if (Array.isArray(provider.baseUrl)) {
|
|
return renderOptions(
|
|
provider.baseUrl.map((option: DaemonAuthProviderBaseUrlOption) => ({
|
|
value: option.url,
|
|
label: option.label,
|
|
description: option.url,
|
|
})),
|
|
optionIndex,
|
|
setOptionIndex,
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
<div className={styles.text}>{t('auth.baseUrlPrompt')}</div>
|
|
<input
|
|
className={styles.input}
|
|
value={baseUrl}
|
|
aria-label={t('auth.step.baseUrl')}
|
|
disabled={saving}
|
|
placeholder={defaultBaseUrl(protocol, wireApi)}
|
|
onChange={(event) => {
|
|
setBaseUrl(event.target.value);
|
|
setError(null);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
goNext();
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
{provider.documentationUrl && (
|
|
<a
|
|
className={styles.link}
|
|
href={provider.documentationUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
onClick={(event) =>
|
|
openExternalLink(event, provider.documentationUrl)
|
|
}
|
|
>
|
|
{t('auth.documentation')}
|
|
</a>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
if (currentStep === 'apiKey') {
|
|
return (
|
|
<>
|
|
{provider.documentationUrl && (
|
|
<a
|
|
className={styles.link}
|
|
href={provider.documentationUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
onClick={(event) =>
|
|
openExternalLink(event, provider.documentationUrl)
|
|
}
|
|
>
|
|
{t('auth.documentation')}: {provider.documentationUrl}
|
|
</a>
|
|
)}
|
|
<input
|
|
className={styles.input}
|
|
type="password"
|
|
value={apiKey}
|
|
aria-label={t('auth.step.apiKey')}
|
|
autoComplete="new-password"
|
|
disabled={saving}
|
|
placeholder={
|
|
provider.apiKeyPlaceholder ?? t('auth.apiKeyPlaceholder')
|
|
}
|
|
onChange={(event) => {
|
|
setApiKey(event.target.value);
|
|
setError(null);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
goNext();
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
if (currentStep === 'models') {
|
|
const defaultIds = modelIds(provider);
|
|
return (
|
|
<>
|
|
{defaultIds && (
|
|
<div className={styles.muted}>
|
|
{t('auth.modelsPrompt', { modelIds: defaultIds })}
|
|
</div>
|
|
)}
|
|
<input
|
|
className={styles.input}
|
|
value={models}
|
|
aria-label={t('auth.step.models')}
|
|
disabled={saving}
|
|
placeholder={defaultIds || t('auth.modelsPlaceholder')}
|
|
onChange={(event) => {
|
|
setModels(event.target.value);
|
|
setError(null);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
goNext();
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<FieldGroup>
|
|
<Field>
|
|
<FieldLabel htmlFor={`${fieldId}-purpose`}>
|
|
{t('auth.purpose.label')}
|
|
</FieldLabel>
|
|
<Select
|
|
value={purpose}
|
|
disabled={saving}
|
|
onValueChange={(value) => {
|
|
setPurpose(value as typeof purpose);
|
|
setError(null);
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
id={`${fieldId}-purpose`}
|
|
aria-label={t('auth.purpose.label')}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(['chat', 'image', 'voice'] as const).map((value) => (
|
|
<SelectItem key={value} value={value}>
|
|
{t(`auth.purpose.${value}`)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{purpose !== 'chat' && (
|
|
<FieldDescription>
|
|
{t(`auth.purpose.${purpose}Hint`)}
|
|
</FieldDescription>
|
|
)}
|
|
</Field>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('auth.advanced.prompt')}
|
|
</p>
|
|
{purpose === 'chat' &&
|
|
(
|
|
[
|
|
[
|
|
'thinking',
|
|
thinking,
|
|
setThinking,
|
|
'auth.advanced.thinking',
|
|
'auth.advanced.thinkingDesc',
|
|
],
|
|
[
|
|
'modality',
|
|
modality,
|
|
setModality,
|
|
'auth.advanced.modality',
|
|
'auth.advanced.modalityDesc',
|
|
],
|
|
] as const
|
|
).map(([key, checked, setChecked, label, description]) => (
|
|
<Field orientation="horizontal" key={key}>
|
|
<FieldContent>
|
|
<FieldLabel htmlFor={`${fieldId}-${key}`}>
|
|
{t(label)}
|
|
</FieldLabel>
|
|
<FieldDescription id={`${fieldId}-${key}-hint`}>
|
|
{t(description)}
|
|
</FieldDescription>
|
|
</FieldContent>
|
|
<Switch
|
|
id={`${fieldId}-${key}`}
|
|
checked={checked}
|
|
disabled={saving || purpose !== 'chat'}
|
|
aria-label={t(label)}
|
|
aria-describedby={`${fieldId}-${key}-hint`}
|
|
onCheckedChange={(value) => {
|
|
setChecked(value);
|
|
setError(null);
|
|
}}
|
|
/>
|
|
</Field>
|
|
))}
|
|
{purpose === 'chat' && modality && (
|
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
{(
|
|
[
|
|
[
|
|
'image',
|
|
modalityImage,
|
|
setModalityImage,
|
|
'auth.advanced.modalityImage',
|
|
],
|
|
[
|
|
'video',
|
|
modalityVideo,
|
|
setModalityVideo,
|
|
'auth.advanced.modalityVideo',
|
|
],
|
|
[
|
|
'audio',
|
|
modalityAudio,
|
|
setModalityAudio,
|
|
'auth.advanced.modalityAudio',
|
|
],
|
|
[
|
|
'pdf',
|
|
modalityPdf,
|
|
setModalityPdf,
|
|
'auth.advanced.modalityPdf',
|
|
],
|
|
] as const
|
|
).map(([key, checked, setChecked, label]) => (
|
|
<Field orientation="horizontal" key={key}>
|
|
<Checkbox
|
|
id={`${fieldId}-${key}`}
|
|
checked={checked}
|
|
disabled={saving || purpose !== 'chat'}
|
|
onCheckedChange={(value) => {
|
|
setChecked(value === true);
|
|
setError(null);
|
|
}}
|
|
/>
|
|
<FieldLabel htmlFor={`${fieldId}-${key}`}>
|
|
{t(label)}
|
|
</FieldLabel>
|
|
</Field>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="grid gap-5 sm:grid-cols-2">
|
|
{(
|
|
[
|
|
[
|
|
'context',
|
|
contextWindow,
|
|
setContextWindow,
|
|
'auth.advanced.contextWindow',
|
|
'auth.advanced.contextDesc',
|
|
],
|
|
[
|
|
'output',
|
|
maxTokens,
|
|
setMaxTokens,
|
|
'auth.advanced.maxTokens',
|
|
'auth.advanced.maxTokensDesc',
|
|
],
|
|
] as const
|
|
)
|
|
.filter(([key]) => purpose === 'chat' || key === 'context')
|
|
.map(([key, value, setValue, label, description]) => (
|
|
<Field key={key} data-invalid={invalidTokenLimit(value)}>
|
|
<FieldLabel htmlFor={`${fieldId}-${key}`}>
|
|
{t(label)}
|
|
</FieldLabel>
|
|
<Input
|
|
id={`${fieldId}-${key}`}
|
|
aria-label={t(label)}
|
|
inputMode="numeric"
|
|
value={value}
|
|
placeholder={t('common.auto')}
|
|
disabled={saving || (key === 'output' && purpose !== 'chat')}
|
|
aria-invalid={invalidTokenLimit(value)}
|
|
aria-describedby={`${fieldId}-${key}-hint`}
|
|
onChange={(event) => {
|
|
setValue(event.target.value);
|
|
setError(null);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
goNext();
|
|
}
|
|
}}
|
|
/>
|
|
<FieldDescription id={`${fieldId}-${key}-hint`}>
|
|
{invalidTokenLimit(value)
|
|
? t('auth.advanced.tokenLimitInvalid', { field: t(label) })
|
|
: t(description)}
|
|
</FieldDescription>
|
|
</Field>
|
|
))}
|
|
</div>
|
|
</FieldGroup>
|
|
);
|
|
};
|
|
|
|
const review = provider
|
|
? [
|
|
[t('auth.step.provider'), t(provider.label)],
|
|
[
|
|
t('auth.step.protocol'),
|
|
getProtocolOptions(t).find((option) => option.value === protocol)
|
|
?.label ?? protocol,
|
|
],
|
|
...(steps.includes('wireApi')
|
|
? [
|
|
[
|
|
t('auth.step.api'),
|
|
apiOptions.find((option) => option.value === wireApi)?.label ??
|
|
wireApi,
|
|
],
|
|
]
|
|
: []),
|
|
[t('auth.step.baseUrl'), baseUrl.trim()],
|
|
[
|
|
t('auth.step.apiKey'),
|
|
apiKey.trim() ? t('auth.apiKeySet') : t('auth.notSet'),
|
|
],
|
|
[t('auth.step.models'), normalizeModelIds(models).join(', ')],
|
|
...(steps.includes('advancedConfig')
|
|
? [[t('auth.purpose.label'), t(`auth.purpose.${purpose}`)]]
|
|
: []),
|
|
...(steps.includes('advancedConfig')
|
|
? [
|
|
[
|
|
t('auth.advanced.thinking'),
|
|
advancedConfig?.enableThinking
|
|
? t('common.enabled')
|
|
: t('auth.advanced.defaults'),
|
|
],
|
|
[
|
|
t('auth.advanced.modality'),
|
|
(
|
|
[
|
|
['image', 'auth.advanced.modalityImage'],
|
|
['video', 'auth.advanced.modalityVideo'],
|
|
['audio', 'auth.advanced.modalityAudio'],
|
|
['pdf', 'auth.advanced.modalityPdf'],
|
|
] as const
|
|
)
|
|
.filter(([key]) => advancedConfig?.multimodal?.[key])
|
|
.map(([, label]) => t(label))
|
|
.join(', ') || t('auth.advanced.defaults'),
|
|
],
|
|
[
|
|
t('auth.advanced.contextWindow'),
|
|
advancedConfig?.contextWindowSize ??
|
|
t('auth.advanced.defaults'),
|
|
],
|
|
[
|
|
t('auth.advanced.maxTokens'),
|
|
advancedConfig?.maxTokens ?? t('auth.advanced.defaults'),
|
|
],
|
|
]
|
|
: []),
|
|
]
|
|
: [];
|
|
|
|
const stepItems = useMemo(() => {
|
|
const items = [t('auth.step.group'), t('auth.step.provider')];
|
|
if (provider) {
|
|
items.push(
|
|
...steps.map((step) => titleForStep(step as AuthStep, provider, t)),
|
|
);
|
|
if (shouldReview) items.push(t('auth.review'));
|
|
}
|
|
return items;
|
|
}, [provider, shouldReview, steps, t]);
|
|
|
|
const activeStep = useMemo(() => {
|
|
if (view === 'groups') return 1;
|
|
if (view === 'providers') return 2;
|
|
if (view === 'step') return Math.min(3 + stepIndex, stepItems.length);
|
|
return stepItems.length;
|
|
}, [stepIndex, stepItems.length, view]);
|
|
|
|
const body = (() => {
|
|
if (loading)
|
|
return <div className={styles.muted}>{t('common.loading')}</div>;
|
|
if (view === 'groups') {
|
|
return (
|
|
<>
|
|
{hasSubscriptionEntry && (
|
|
<QwenSubscriptionEntry
|
|
selected={groupIndex === 0}
|
|
onFocus={() => setGroupIndex(0)}
|
|
onClick={() => {
|
|
setGroupIndex(0);
|
|
startSubscription();
|
|
}}
|
|
/>
|
|
)}
|
|
{renderOptions(
|
|
groups.map((group: AuthGroup) => ({
|
|
value: group.id,
|
|
label: t(group.label),
|
|
description: t(group.description),
|
|
})),
|
|
groupIndex - groupOffset,
|
|
(index) => setGroupIndex(index + groupOffset),
|
|
)}
|
|
<div className={styles.terms}>
|
|
<div>{t('auth.termsTitle')}:</div>
|
|
<a
|
|
className={styles.link}
|
|
href={TOS_PRIVACY_URL}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
onClick={(event) => openExternalLink(event, TOS_PRIVACY_URL)}
|
|
>
|
|
{TOS_PRIVACY_URL}
|
|
</a>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
if (view === 'providers') {
|
|
return renderOptions(
|
|
providers.map((item: DaemonAuthProviderDescriptor) => ({
|
|
value: item.id,
|
|
label: t(item.label),
|
|
description: t(item.description),
|
|
})),
|
|
providerIndex,
|
|
setProviderIndex,
|
|
);
|
|
}
|
|
if (view === 'step') return renderStep();
|
|
return (
|
|
<>
|
|
<div className={styles.text}>{t('auth.reviewText')}</div>
|
|
<dl className="grid gap-3 rounded-lg border border-border bg-background p-4 text-sm">
|
|
{review.map(([label, value]) => (
|
|
<div
|
|
key={label}
|
|
className="grid gap-1 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] sm:gap-4"
|
|
>
|
|
<dt className="text-muted-foreground">{label}</dt>
|
|
<dd className="m-0 break-words [overflow-wrap:anywhere]">
|
|
{value}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
{provider?.showAdvancedConfig && (
|
|
<div className={styles.text}>{t(`auth.purpose.${purpose}Hint`)}</div>
|
|
)}
|
|
</>
|
|
);
|
|
})();
|
|
|
|
const primaryAction = () => {
|
|
if (view === 'review') {
|
|
save();
|
|
return;
|
|
}
|
|
if (isInputStep || currentStep === 'advancedConfig') {
|
|
goNext();
|
|
return;
|
|
}
|
|
activate();
|
|
};
|
|
|
|
if (subscriptionTarget && !subscriptionTargetChanged)
|
|
return (
|
|
<QwenSubscriptionFlow
|
|
dialog={dialog}
|
|
desktopSuccess={dialog && isDesktopShell()}
|
|
transport={workspaceActions}
|
|
onActivate={async (signal, onSettingsSaved) => {
|
|
const { workspaceCwd, sessionId, sessionWorkspaceCwd, standalone } =
|
|
subscriptionTarget;
|
|
const assertTarget = () => {
|
|
signal.throwIfAborted();
|
|
const current = getSubscriptionContext();
|
|
if (
|
|
current.workspaceCwd !== workspaceCwd ||
|
|
current.sessionId !== sessionId ||
|
|
current.sessionWorkspaceCwd !== sessionWorkspaceCwd ||
|
|
current.standalone !== standalone
|
|
)
|
|
throw new DOMException(
|
|
'Subscription target changed.',
|
|
'AbortError',
|
|
);
|
|
};
|
|
assertTarget();
|
|
if (standalone) {
|
|
const { modelId } =
|
|
await workspaceActions.getSubscriptionDefaultModel({ signal });
|
|
assertTarget();
|
|
const selector = `${modelId}(qwen-subscription)`;
|
|
if (sessionId) {
|
|
await activateSessionSubscriptionModel(
|
|
sessionWorkspaceCwd,
|
|
sessionId,
|
|
selector,
|
|
);
|
|
assertTarget();
|
|
} else {
|
|
onModelActivated?.(selector);
|
|
}
|
|
return modelId;
|
|
}
|
|
if (!workspaceCwd)
|
|
throw new Error('Subscription activation requires a workspace.');
|
|
const result = await workspaceActions.activateSubscription({
|
|
workspaceCwd,
|
|
signal,
|
|
});
|
|
assertTarget();
|
|
onSettingsSaved();
|
|
if (result.runtimeSync.status === 'failed')
|
|
throw new Error('Subscription runtime activation failed.');
|
|
const modelId = `${result.modelId}(qwen-subscription)`;
|
|
if (sessionId) {
|
|
await activateSessionSubscriptionModel(
|
|
sessionWorkspaceCwd,
|
|
sessionId,
|
|
modelId,
|
|
);
|
|
assertTarget();
|
|
return result.modelId;
|
|
}
|
|
if (
|
|
connectionRef.current.status === 'connected' &&
|
|
connectionRef.current.workspaceCwd === workspaceCwd
|
|
) {
|
|
await sessionActions.refreshModels(
|
|
(target) => {
|
|
if (target.sessionId || target.workspaceCwd !== workspaceCwd)
|
|
throw new Error('Workspace changed.');
|
|
},
|
|
{ reloadProviders: false },
|
|
);
|
|
assertTarget();
|
|
}
|
|
onModelActivated?.(modelId);
|
|
return result.modelId;
|
|
}}
|
|
onActivated={(message) => {
|
|
if (message) onMessage(message);
|
|
onClose();
|
|
}}
|
|
onModelsUnavailable={onClose}
|
|
onClose={() => setSubscriptionTarget(undefined)}
|
|
onOtherProviders={() => {
|
|
setSubscriptionTarget(undefined);
|
|
setView('groups');
|
|
}}
|
|
onOpenExternal={(url) => {
|
|
if (isDesktopShell()) {
|
|
return openExternalUrl(url);
|
|
} else {
|
|
window.open(url, '_blank', 'noopener,noreferrer');
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
|
|
const content = (
|
|
<div
|
|
className={styles.panel}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
loading ||
|
|
saving ||
|
|
event.defaultPrevented ||
|
|
event.nativeEvent.isComposing ||
|
|
event.nativeEvent.keyCode === 229 ||
|
|
event.metaKey ||
|
|
event.ctrlKey ||
|
|
event.altKey ||
|
|
event.shiftKey
|
|
)
|
|
return;
|
|
const options = Array.from(
|
|
bodyRef.current?.querySelectorAll<HTMLButtonElement>(
|
|
'[data-web-shell-auth-option]',
|
|
) ?? [],
|
|
);
|
|
if (options.length === 0) return;
|
|
const target = event.target as HTMLElement;
|
|
const focusedOption = target.closest<HTMLButtonElement>(
|
|
'[data-web-shell-auth-option]',
|
|
);
|
|
if (
|
|
!focusedOption &&
|
|
target.closest('button, a, input, textarea, select')
|
|
)
|
|
return;
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (focusedOption) focusedOption.click();
|
|
else activate();
|
|
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const current =
|
|
view === 'groups'
|
|
? groupIndex
|
|
: view === 'providers'
|
|
? providerIndex
|
|
: optionIndex;
|
|
const index = Math.max(
|
|
0,
|
|
Math.min(
|
|
options.length - 1,
|
|
current + (event.key === 'ArrowDown' ? 1 : -1),
|
|
),
|
|
);
|
|
if (view === 'groups') setGroupIndex(index);
|
|
else if (view === 'providers') setProviderIndex(index);
|
|
else setOptionIndex(index);
|
|
options[index]?.focus({ preventScroll: true });
|
|
options[index]?.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}}
|
|
>
|
|
<div className={styles.steps}>
|
|
{stepItems.map((label, index) => {
|
|
const stepNumber = index + 1;
|
|
return (
|
|
<div
|
|
key={`${stepNumber}:${label}`}
|
|
className={`${styles.stepPill} ${
|
|
stepNumber === activeStep ? styles.stepPillActive : ''
|
|
} ${stepNumber < activeStep ? styles.stepPillDone : ''}`}
|
|
>
|
|
<span className={styles.stepNumber}>{stepNumber}</span>
|
|
<span className={styles.stepLabel}>{label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div ref={bodyRef} className={styles.body}>
|
|
{body}
|
|
</div>
|
|
{error && (
|
|
<div className={styles.error} role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<div className={styles.actions}>
|
|
<button
|
|
type="button"
|
|
className={styles.actionButton}
|
|
onClick={goBack}
|
|
disabled={view === 'groups' || loading || saving}
|
|
>
|
|
{t('common.previous')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.actionButton}
|
|
onClick={primaryAction}
|
|
disabled={loading || saving}
|
|
>
|
|
{view === 'review'
|
|
? saving
|
|
? t('auth.saving')
|
|
: t('auth.save')
|
|
: t('common.next')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
return dialog ? (
|
|
<DialogShell title={t('auth.title')} size="lg" onClose={onClose}>
|
|
{content}
|
|
</DialogShell>
|
|
) : (
|
|
content
|
|
);
|
|
}
|