feat: add credential priority controls and OAuth defaults

This commit is contained in:
lzt404 committed 2026-07-30 01:21:48 +08:00
1 parent 0f3e5093e8
commit bbe22a96eb
13 files changed
+582 -416

No files matched your search

+1 -51
View File
@@ -85,10 +85,6 @@ type AgentConfigActionResult = {
conflictFiles: string[];
};
type CodexModelCatalogUpdateResult = {
outcome: 'updated' | 'unchanged';
};
type ChatGptCloseResult = {
wasRunning: boolean;
closedProcesses: number;
@@ -488,13 +484,11 @@ export function AgentsPage() {
const [launchTargetByClient, setLaunchTargetByClient] = useState<Partial<Record<AgentClientId, string>>>({});
const [loading, setLoading] = useState(true);
const [modelLoading, setModelLoading] = useState(false);
const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | 'close-app' | 'catalog' | null>(null);
const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | 'close-app' | null>(null);
const busy = busyAction !== null;
const [detectionError, setDetectionError] = useState('');
const [modelError, setModelError] = useState('');
const [modelSelectionError, setModelSelectionError] = useState('');
const [catalogUpdateError, setCatalogUpdateError] = useState('');
const [catalogUpdateNotice, setCatalogUpdateNotice] = useState('');
const [configurationError, setConfigurationError] = useState('');
const [launchError, setLaunchError] = useState('');
const [defaultError, setDefaultError] = useState('');
@@ -592,8 +586,6 @@ export function AgentsPage() {
useEffect(() => {
setActiveSubpage(DEFAULT_AGENT_SUBPAGE);
setModelSelectionError('');
setCatalogUpdateError('');
setCatalogUpdateNotice('');
setConfigurationError('');
setLaunchError('');
setDefaultError('');
@@ -688,23 +680,6 @@ export function AgentsPage() {
void loadModels(selected);
};
const updateCodexModelCatalog = async () => {
setCatalogUpdateError('');
setCatalogUpdateNotice('');
setBusyAction('catalog');
try {
const result = await invoke<CodexModelCatalogUpdateResult>('update_codex_model_catalog');
setCatalogUpdateNotice(result.outcome === 'updated'
? t('agents.catalog.updated')
: t('agents.catalog.unchanged'));
await loadModels('codex');
} catch (requestError) {
setCatalogUpdateError(String(requestError));
} finally {
setBusyAction(null);
}
};
const reloadStatusesAfterAction = async () => {
setDetectionError('');
try {
@@ -989,31 +964,6 @@ export function AgentsPage() {
{modelHint}
</span>
) : null}
{selected === 'codex' ? (
<div className="agent-catalog-update-control">
<button
type="button"
className="secondary-button compact-button"
onClick={() => void updateCodexModelCatalog()}
disabled={busy}
>
<RefreshCw size={15} className={busyAction === 'catalog' ? 'spin' : ''} />
{busyAction === 'catalog'
? t('agents.catalog.updating')
: t('agents.catalog.update')}
</button>
{catalogUpdateError ? (
<span className="agent-inline-message error" role="alert" aria-live="polite">
{catalogUpdateError}
</span>
) : null}
{catalogUpdateNotice ? (
<span className="agent-inline-message" role="status" aria-live="polite">
{catalogUpdateNotice}
</span>
) : null}
</div>
) : null}
</section>
<section className={`agent-core-setting-section agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
+107 -16
View File
@@ -2,9 +2,10 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from '
import {
Check,
Copy,
Download,
FileDown,
FolderOpen,
LoaderCircle,
Pencil,
RefreshCw,
Search,
Trash2,
@@ -45,6 +46,8 @@ import {
authFileName,
dedupeAuthFiles,
isRuntimeOnlyAuthFile,
normalizeAuthFilePriorityInput,
parseAuthFilePriority,
} from '../services/authFiles';
import {
exclusionsForOpenOAuthModels,
@@ -57,6 +60,13 @@ import { getCurrentLocale, translate, useI18n } from '../i18n';
type AuthFile = Record<string, unknown>;
type PriorityEditor = {
fileName: string;
originalPriority: number;
value: string;
error: string;
};
const providerIcons: Record<string, string> = {
antigravity: antigravityIcon,
claude: claudeIcon,
@@ -144,6 +154,7 @@ export function AuthFileManagementPage() {
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [copied, setCopied] = useState('');
const [priorityEditor, setPriorityEditor] = useState<PriorityEditor | null>(null);
const [oauthModelProvider, setOauthModelProvider] = useState('');
const [oauthModelProviderLabel, setOauthModelProviderLabel] = useState('');
const [oauthModels, setOauthModels] = useState<OAuthModelDefinition[]>([]);
@@ -361,6 +372,51 @@ export function AuthFileManagementPage() {
}
};
const openPriorityEditor = (file: AuthFile) => {
const priority = parseAuthFilePriority(file.priority);
setPriorityEditor({
fileName: fileName(file),
originalPriority: priority ?? 0,
value: priority === undefined || priority === 0 ? '' : String(priority),
error: '',
});
};
const closePriorityEditor = () => {
if (!busy) setPriorityEditor(null);
};
const savePriority = async () => {
if (!priorityEditor || busy) return;
const priority = normalizeAuthFilePriorityInput(priorityEditor.value);
if (priority === null) {
setPriorityEditor((current) => current
? { ...current, error: t('authFiles.priority.invalid') }
: current);
return;
}
if (priority === priorityEditor.originalPriority) {
setPriorityEditor(null);
return;
}
const name = priorityEditor.fileName;
setBusy(true);
setError('');
try {
await managementApi.patch('/auth-files/fields', { name, priority });
setPriorityEditor(null);
setNotice(t('authFiles.priority.updated', { name }));
await loadFiles();
} catch (requestError) {
setPriorityEditor((current) => current
? { ...current, error: String(requestError) }
: current);
} finally {
setBusy(false);
}
};
const deleteFile = async (file: AuthFile) => {
const name = fileName(file);
if (isRuntimeOnly(file)) {
@@ -396,23 +452,11 @@ export function AuthFileManagementPage() {
}
};
const downloadFile = async (file: AuthFile) => {
const name = fileName(file);
if (isRuntimeOnly(file)) {
setError(t('authFiles.runtimeDownloadError'));
return;
}
const openAuthFilesDirectory = async () => {
setBusy(true);
setError('');
try {
const content = await managementApi.downloadAuthFile(name);
const url = URL.createObjectURL(new Blob([content], { type: 'application/json' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
setNotice(t('authFiles.downloaded'));
await managementApi.openAuthFilesDirectory();
} catch (requestError) {
setError(String(requestError));
} finally {
@@ -485,6 +529,7 @@ export function AuthFileManagementPage() {
const name = fileName(file);
const icon = providerIcons[providerKey(file)] ?? geminiIcon;
const disabled = readBoolean(file, 'disabled');
const priority = parseAuthFilePriority(file.priority) ?? 0;
return (
<article className={`real-auth-file-row ${disabled ? 'disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
<img src={icon} alt="" className="provider-logo" />
@@ -500,8 +545,9 @@ export function AuthFileManagementPage() {
<div className="auth-file-actions">
{quotaProviderForFile(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void refreshQuota(file)} disabled={busy || disabled || quotas[quotaKey(file)]?.status === 'loading'}>{disabled ? t('authFiles.status.disabled') : quotas[quotaKey(file)]?.status === 'loading' ? t('authFiles.quota.querying') : quotas[quotaKey(file)]?.status === 'success' ? t('authFiles.quota.refresh') : t('authFiles.quota.fetch')}</button> : null}
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy} title={t('authFiles.models.settings')}>{t('authFiles.models.button')}</button> : null}
<button type="button" className="secondary-button compact-button auth-file-priority-button" onClick={() => openPriorityEditor(file)} disabled={busy} title={t('authFiles.priority.hint')}><Pencil size={14} />{t('authFiles.priority.button', { priority })}</button>
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')}>{copied === name ? <Check size={16} /> : <Copy size={16} />}</button>
<button type="button" className="icon-button quiet" onClick={() => void downloadFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.download')}><Download size={16} /></button>
<button type="button" className="icon-button quiet" onClick={() => void openAuthFilesDirectory()} disabled={busy} title={t('authFiles.openDirectory')}><FolderOpen size={16} /></button>
<button type="button" className="secondary-button compact-button" onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? t('common.enable') : t('common.disable')}</button>
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={16} /></button>
</div>
@@ -514,6 +560,51 @@ export function AuthFileManagementPage() {
</section>
{runtimeCount > 0 ? <p className="page-footnote">{t('authFiles.runtimeFootnote', { count: runtimeCount })}</p> : null}
{priorityEditor ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => event.currentTarget === event.target && closePriorityEditor()}>
<form
className="config-dialog auth-priority-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="auth-priority-title"
onSubmit={(event) => {
event.preventDefault();
void savePriority();
}}
>
<div className="config-dialog-heading">
<div><Pencil size={19} /><h2 id="auth-priority-title">{t('authFiles.priority.title')}</h2></div>
<button type="button" className="icon-button quiet" onClick={closePriorityEditor} disabled={busy} title={t('common.close')}><X size={18} /></button>
</div>
<div className="auth-priority-target" title={priorityEditor.fileName}>{priorityEditor.fileName}</div>
<label className="auth-priority-field" htmlFor="auth-file-priority-input">
<span>{t('authFiles.priority.label')}</span>
<input
id="auth-file-priority-input"
autoFocus
type="text"
inputMode="numeric"
value={priorityEditor.value}
placeholder={t('authFiles.priority.placeholder')}
disabled={busy}
aria-invalid={Boolean(priorityEditor.error)}
onChange={(event) => setPriorityEditor((current) => current
? { ...current, value: event.currentTarget.value, error: '' }
: current)}
/>
<small>{t('authFiles.priority.hint')}</small>
</label>
<div className={`config-form-message ${priorityEditor.error ? 'error' : ''}`} role={priorityEditor.error ? 'alert' : undefined}>
{priorityEditor.error || ' '}
</div>
<div className="config-dialog-actions two-actions">
<button type="button" className="secondary-button" onClick={closePriorityEditor} disabled={busy}>{t('common.cancel')}</button>
<button type="submit" className="primary-button" disabled={busy}>{busy ? <LoaderCircle size={16} className="spin" /> : <Check size={16} />}{busy ? t('common.saving') : t('common.save')}</button>
</div>
</form>
</div>
) : null}
{oauthModelProvider ? (
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !oauthModelSaving && closeOauthModels()}>
<section className="model-discovery-dialog" role="dialog" aria-modal="true" aria-labelledby="oauth-model-title">
+46
View File
@@ -48,6 +48,10 @@ type CoreInstallTask = {
result: CoreInstallResult | null;
};
type CodexModelCatalogUpdateResult = {
outcome: 'updated' | 'unchanged';
};
type MessageType = 'info' | 'success' | 'error';
type CoreProcessCommand = 'start_core_process' | 'stop_core_process' | 'restart_core_process';
@@ -131,6 +135,9 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
} | null>(null);
const [message, setMessage] = useState('');
const [messageType, setMessageType] = useState<MessageType>('info');
const [catalogUpdating, setCatalogUpdating] = useState(false);
const [catalogUpdateError, setCatalogUpdateError] = useState('');
const [catalogUpdateNotice, setCatalogUpdateNotice] = useState('');
const [progress, setProgress] = useState<CoreInstallTask | null>(null);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [cancellingInstall, setCancellingInstall] = useState(false);
@@ -551,6 +558,22 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
}
};
const updateCodexModelCatalog = async () => {
setCatalogUpdating(true);
setCatalogUpdateError('');
setCatalogUpdateNotice('');
try {
const result = await invoke<CodexModelCatalogUpdateResult>('update_codex_model_catalog');
setCatalogUpdateNotice(result.outcome === 'updated'
? t('appUpdate.catalog.updated')
: t('appUpdate.catalog.unchanged'));
} catch (error) {
setCatalogUpdateError(String(error));
} finally {
setCatalogUpdating(false);
}
};
const latestVersion = latest?.version ?? '';
const currentVersion = coreStatus?.currentVersion ?? '';
const coreInstalled = Boolean(coreStatus?.installed);
@@ -779,6 +802,18 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
: t('appUpdate.phase.checking')}
</dd>
</div>
<div className="panel-detail-row">
<dt>{t('appUpdate.catalog.label')}</dt>
<dd
className={catalogUpdateError ? 'error' : catalogUpdateNotice ? 'success' : ''}
role={catalogUpdateError ? 'alert' : undefined}
aria-live="polite"
>
{catalogUpdateError
|| catalogUpdateNotice
|| t('appUpdate.catalog.description')}
</dd>
</div>
</dl>
<div className="button-row panel-action-row software-update-actions">
@@ -804,6 +839,17 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
{t('appUpdate.openRelease')} <ExternalLink size={14} aria-hidden="true" />
</button>
)}
<button
type="button"
className="secondary-button"
disabled={catalogUpdating || appUpdateTask.running}
onClick={() => void updateCodexModelCatalog()}
title={t('appUpdate.catalog.updateHint')}
>
{catalogUpdating
? t('appUpdate.catalog.updating')
: t('appUpdate.catalog.update')}
</button>
</div>
</div>
) : null}
+41 -2
View File
@@ -13,6 +13,12 @@ import codexIcon from '../assets/icons/codex.svg';
import grokIcon from '../assets/icons/grok.svg';
import kimiIcon from '../assets/icons/kimi-light.svg';
import { useI18n } from '../i18n';
import {
changedOAuthAuthFileNames,
snapshotAuthFiles,
type AuthFileSnapshot,
} from '../services/authFiles';
import { managementApi, responseList } from '../services/managementApi';
type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai';
type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error';
@@ -69,6 +75,7 @@ export function OAuthLoginPage() {
const pollingTimers = useRef<Partial<Record<OAuthProviderId, number>>>({});
const pollingRequests = useRef<Partial<Record<OAuthProviderId, boolean>>>({});
const successResetTimers = useRef<Partial<Record<OAuthProviderId, number>>>({});
const credentialSnapshots = useRef<Partial<Record<OAuthProviderId, AuthFileSnapshot>>>({});
const noticeTimerRef = useRef<number | null>(null);
const showNotice = useCallback((message: string, tone: 'success' | 'error' | 'info') => {
@@ -118,6 +125,7 @@ export function OAuthLoginPage() {
const resetProviderAttempt = useCallback(
(provider: OAuthProviderId) => {
clearProviderTimers(provider);
delete credentialSnapshots.current[provider];
setStates((current) => ({ ...current, [provider]: { status: 'idle' } }));
},
[clearProviderTimers],
@@ -144,6 +152,24 @@ export function OAuthLoginPage() {
[clearProviderTimers, resetProviderAttempt, updateProviderState],
);
const captureCredentialSnapshot = useCallback(async (provider: OAuthProviderId) => {
const payload = await managementApi.get('/auth-files');
credentialSnapshots.current[provider] = snapshotAuthFiles(responseList(payload, 'files'));
}, []);
const applyDefaultCredentialPriority = useCallback(async (provider: OAuthProviderId) => {
const before = credentialSnapshots.current[provider];
delete credentialSnapshots.current[provider];
if (!before) return;
const payload = await managementApi.get('/auth-files');
const names = changedOAuthAuthFileNames(before, responseList(payload, 'files'), provider);
await Promise.all(names.map((name) => managementApi.patch('/auth-files/fields', {
name,
priority: 0,
})));
}, []);
const startPolling = useCallback(
(provider: OAuthProviderId, state: string) => {
clearPollingTimer(provider);
@@ -154,8 +180,19 @@ export function OAuthLoginPage() {
const result = await invoke<OAuthStatusResult>('get_oauth_status', { state });
const status = (result.status || '').toLowerCase();
if (status === 'ok') {
let priorityError = '';
try {
await applyDefaultCredentialPriority(provider);
} catch (error) {
priorityError = String(error);
}
completeProviderAuth(provider);
showNotice(t('oauth.loginSuccess', { provider: providerLabel(provider) }), 'success');
showNotice(
priorityError
? t('oauth.priorityApplyFailed', { error: priorityError })
: t('oauth.loginSuccess', { provider: providerLabel(provider) }),
priorityError ? 'error' : 'success',
);
} else if (status === 'error') {
updateProviderState(provider, {
status: 'error',
@@ -188,7 +225,7 @@ export function OAuthLoginPage() {
OAUTH_POLL_INTERVAL_MS,
);
},
[clearPollingTimer, completeProviderAuth, showNotice, t, updateProviderState],
[applyDefaultCredentialPriority, clearPollingTimer, completeProviderAuth, showNotice, t, updateProviderState],
);
useEffect(() => {
@@ -217,6 +254,7 @@ export function OAuthLoginPage() {
});
try {
await captureCredentialSnapshot(provider);
const result = await invoke<OAuthStartResult>('start_oauth_login', { provider });
if (!result.state) {
updateProviderState(provider, {
@@ -247,6 +285,7 @@ export function OAuthLoginPage() {
);
}
} catch (error) {
delete credentialSnapshots.current[provider];
updateProviderState(provider, {
status: 'error',
error: String(error),
+55 -67
View File
@@ -248,46 +248,23 @@ export function ThinkingAliasesPage() {
return (
<section className="page management-page thinking-alias-page">
<header className="management-header">
<div>
<span>Model Alias</span>
<h1>{t('aliases.title')}</h1>
</div>
</header>
<div className="thinking-alias-feedback" aria-live="polite">
{error ? <div className="management-alert error">{error}</div> : null}
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
</div>
<div className="thinking-alias-guide">
<BrainCircuit size={20} />
<div className="thinking-alias-guide-copy">
<strong>{t('aliases.intro.title')}</strong>
<p>{t('aliases.intro.description')}</p>
<span>{t('aliases.intro.badge')}</span>
</div>
<div className="thinking-alias-flow" aria-label={t('aliases.flow.aria')}>
<span><small>{t('aliases.flow.original')}</small><code>deepseek-v4-pro</code></span>
<ArrowRight size={14} />
<div>
<span><small>{t('aliases.flow.direct')}</small><code>{t('aliases.flow.directValue')}</code></span>
<span><small>{t('aliases.flow.alias')}</small><code>{t('aliases.flow.aliasValue')}</code></span>
</div>
</div>
</div>
<div className="thinking-alias-workbench">
<section className="panel thinking-alias-builder">
<div className="thinking-alias-panel-heading">
<span><GitFork size={18} /></span>
<div>
<h2>{t('aliases.create.title')}</h2>
<p>{t('aliases.create.description')}</p>
<div className="thinking-alias-left-stack">
<section className="panel thinking-alias-builder">
<div className="thinking-alias-panel-heading">
<span><GitFork size={18} /></span>
<div>
<h2>{t('aliases.create.title')}</h2>
<p>{t('aliases.create.description')}</p>
</div>
</div>
</div>
<div className="thinking-alias-field thinking-model-field">
<div className="thinking-alias-field thinking-model-field">
<label htmlFor="thinking-model-search">{t('aliases.originalModel')}</label>
<div className="thinking-model-picker" ref={modelPickerRef}>
<div className="thinking-model-search">
@@ -379,9 +356,9 @@ export function ThinkingAliasesPage() {
) : (
<small className="thinking-model-hint">{t('aliases.sourceHint')}</small>
)}
</div>
</div>
<div className="thinking-alias-field">
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.effort.title')}</strong>
<span>{t('aliases.effort.description')}</span>
@@ -439,43 +416,54 @@ export function ThinkingAliasesPage() {
</button>
<small>{t('aliases.customHelp')}</small>
</div> : null}
</div>
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.aliasName.title')}</strong>
<span>{t('aliases.aliasName.description')}</span>
</div>
<input
id="thinking-alias-name"
className="thinking-alias-input"
value={alias}
onChange={(event) => setAlias(event.currentTarget.value)}
placeholder={selectedSource
? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort || 'high' })
: t('aliases.aliasName.selectFirst')}
disabled={Boolean(busyAlias)}
/>
</div>
</section>
<div className="thinking-alias-preview">
<BrainCircuit size={18} />
<div>
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
<code>{selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || t('aliases.notSet')}</code>
<section className="panel thinking-alias-name-panel">
<div className="thinking-alias-panel-heading">
<span><BrainCircuit size={18} /></span>
<div>
<h2>{t('aliases.modelAlias.title')}</h2>
<p>{t('aliases.modelAlias.description')}</p>
</div>
</div>
</div>
<button
type="button"
className="primary-button thinking-alias-create"
onClick={() => void createAlias()}
disabled={loading || Boolean(busyAlias) || !selectedSource || !effort.trim() || !alias.trim()}
>
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <GitFork size={16} />}
{busyAction === 'create' ? t('aliases.creating') : t('aliases.create')}
</button>
</section>
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.aliasName.title')}</strong>
<span>{t('aliases.aliasName.description')}</span>
</div>
<input
id="thinking-alias-name"
className="thinking-alias-input"
value={alias}
onChange={(event) => setAlias(event.currentTarget.value)}
placeholder={selectedSource
? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort || 'high' })
: t('aliases.aliasName.selectFirst')}
disabled={Boolean(busyAlias)}
/>
</div>
<div className="thinking-alias-preview">
<BrainCircuit size={18} />
<div>
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
<code>{selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || t('aliases.notSet')}</code>
</div>
</div>
<button
type="button"
className="primary-button thinking-alias-create"
onClick={() => void createAlias()}
disabled={loading || Boolean(busyAlias) || !selectedSource || !effort.trim() || !alias.trim()}
>
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <GitFork size={16} />}
{busyAction === 'create' ? t('aliases.creating') : t('aliases.create')}
</button>
</section>
</div>
<section className="panel thinking-alias-list-panel">
<div className="thinking-alias-list-heading">