Files
qwen-code/packages/web-shell/client/components/messages/AuthMessage.tsx
T
1853b1505f fix(subscription): isolate activation and restore sign-in recovery (#16453)
* 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]>
2026-11-06 20:11:52 +08:00

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
);
}