Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ export function AiProvidersClaudeEditPage() {
</div>

<div className={styles.sectionHint}>{t('ai_providers.claude_models_hint')}</div>
<div className={styles.sectionHint}>{t('ai_providers.model_alias_scope_hint')}</div>

<ModelInputList
entries={form.modelEntries}
Expand Down
54 changes: 40 additions & 14 deletions apps/web/src/features/aiProviders/AiProvidersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export function AiProvidersPage() {
);

const [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null);
/** Synchronous lock so double-clicks in the same tick cannot start two enable/disable writes. */
const configSwitchingLockRef = useRef(false);

// 表格筛选 / 排序 / 详情状态
const [kindFilter, setKindFilter] = useState<ProviderKindFilter>('all');
Expand Down Expand Up @@ -319,6 +321,7 @@ export function AiProvidersPage() {
actions: Map<string, ProviderHealthCheckApplyAction>
) => {
if (actions.size === 0) return;
if (configSwitchingLockRef.current || configSwitchingKey) return;

const rowByKey = new Map(rows.map((row) => [row.key, row]));
const previous = {
Expand Down Expand Up @@ -428,6 +431,7 @@ export function AiProvidersPage() {
return;
}

configSwitchingLockRef.current = true;
setConfigSwitchingKey('health-check');

const applyLocalState = (
Expand Down Expand Up @@ -552,8 +556,9 @@ export function AiProvidersPage() {
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
throw err;
} finally {
setConfigSwitchingKey(null);
}
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
};

const setHealthCheckProviderEnabled = async (providerKey: string, enabled: boolean) => {
Expand All @@ -566,12 +571,15 @@ export function AiProvidersPage() {
index: number,
enabled: boolean
) => {
if (configSwitchingLockRef.current || configSwitchingKey) return;

if (provider === 'gemini' || provider === 'interactions') {
const source = provider === 'gemini' ? geminiKeys : interactionsKeys;
const current = source[index];
if (!current) return;

const switchingKey = `${provider}:${current.apiKey}`;
configSwitchingLockRef.current = true;
setConfigSwitchingKey(switchingKey);

const previousList = source;
Expand Down Expand Up @@ -615,6 +623,7 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
return;
Expand All @@ -632,6 +641,7 @@ export function AiProvidersPage() {
if (!current) return;

const switchingKey = `${provider}:${current.apiKey}`;
configSwitchingLockRef.current = true;
setConfigSwitchingKey(switchingKey);

const previousList = source;
Expand Down Expand Up @@ -695,15 +705,18 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setConfigSwitchingKey(null);
}
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
};

const setOpenAIProviderEnabled = async (index: number, enabled: boolean) => {
if (configSwitchingLockRef.current || configSwitchingKey) return;
const current = openaiProviders[index];
if (!current) return;

const switchingKey = `openai:${current.name}:${index}`;
configSwitchingLockRef.current = true;
setConfigSwitchingKey(switchingKey);

const previousList = openaiProviders;
Expand All @@ -728,8 +741,9 @@ export function AiProvidersPage() {
clearCache('openai-compatibility');
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setConfigSwitchingKey(null);
}
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
};

const setProviderWebsocketsEnabled = async (
Expand All @@ -742,7 +756,9 @@ export function AiProvidersPage() {
const current = source[index];
if (!current) return;

if (configSwitchingLockRef.current || configSwitchingKey) return;
const switchingKey = `${provider}:${current.apiKey}:websockets`;
configSwitchingLockRef.current = true;
setConfigSwitchingKey(switchingKey);

const previousList = source;
Expand Down Expand Up @@ -794,8 +810,9 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setConfigSwitchingKey(null);
}
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
};

const setProviderCloakEnabled = async (
Expand All @@ -807,7 +824,9 @@ export function AiProvidersPage() {
const current = source[index];
if (!current) return;

if (configSwitchingLockRef.current || configSwitchingKey) return;
const switchingKey = `${provider}:${current.apiKey}:cloak`;
configSwitchingLockRef.current = true;
setConfigSwitchingKey(switchingKey);

const previousList = source;
Expand Down Expand Up @@ -852,8 +871,9 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setConfigSwitchingKey(null);
}
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
};

const setProviderDisableCoolingEnabled = async (
Expand Down Expand Up @@ -911,6 +931,7 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
return;
Expand Down Expand Up @@ -942,6 +963,7 @@ export function AiProvidersPage() {
clearCache('openai-compatibility');
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
return;
Expand Down Expand Up @@ -1004,8 +1026,9 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setConfigSwitchingKey(null);
}
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
};

const setProviderPriority = async (row: ProviderRow, priority: number) => {
Expand Down Expand Up @@ -1062,6 +1085,7 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
return;
Expand Down Expand Up @@ -1095,6 +1119,7 @@ export function AiProvidersPage() {
clearCache('openai-compatibility');
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
return;
Expand Down Expand Up @@ -1174,8 +1199,9 @@ export function AiProvidersPage() {
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setConfigSwitchingKey(null);
}
configSwitchingLockRef.current = false;
setConfigSwitchingKey(null);
}
};

// 删除(按 provider 分派,沿用既有 API 契约)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import { generateId, getErrorMessage } from '@/utils/helpers';
import styles from './AuthFilesOAuthModelAliasEditPage.module.scss';
import { isOAuthAliasDraftDirty } from './oauthEditorState';
import { normalizeOAuthAliasEntries } from './oauthAliasValidation';

type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };

Expand Down Expand Up @@ -336,23 +337,34 @@ export function AuthFilesOAuthModelAliasEditPage() {
return;
}

const seen = new Set<string>();
const normalized = mappings
.map((entry) => {
const name = String(entry.name ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}::${entry.forceMapping ? '1' : '0'}`;
if (seen.has(key)) return null;
seen.add(key);
return {
name,
alias,
...(entry.fork ? { fork: true } : {}),
...(entry.forceMapping ? { forceMapping: true } : {}),
};
})
.filter(Boolean) as OAuthModelAliasEntry[];
const normalization = normalizeOAuthAliasEntries(mappings);
// Whole-form validation: refuse to save when any row would be dropped by CPA rules.
// This keeps the draft visible so the user can fix invalid rows instead of silently
// persisting a partial subset.
const firstIssue = normalization.issues[0];
if (firstIssue) {
if (firstIssue.code === 'same_as_name') {
showNotification(t('oauth_model_alias.alias_same_as_name'), 'error');
return;
}
if (firstIssue.code === 'duplicate_alias') {
showNotification(
t('oauth_model_alias.alias_duplicate', { alias: firstIssue.alias ?? '' }),
'error'
);
return;
}
if (firstIssue.code === 'empty_fields' || firstIssue.code === 'duplicate_entry') {
showNotification(t('oauth_model_alias.alias_incomplete'), 'error');
return;
}
}

const normalized = normalization.accepted;
if (normalized.length === 0 && mappings.some((entry) => entry.name.trim() || entry.alias.trim())) {
showNotification(t('oauth_model_alias.alias_incomplete'), 'error');
return;
}

setSaving(true);
try {
Expand Down Expand Up @@ -434,6 +446,11 @@ export function AuthFilesOAuthModelAliasEditPage() {
<div className={styles.settingsHeaderHint}>{headerHint}</div>
</div>

<div className={styles.settingsSection}>
<div className={styles.settingsDesc}>{t('oauth_model_alias.scope_hint')}</div>
<div className={styles.settingsDesc}>{t('oauth_model_alias.usage_hint')}</div>
</div>

<div className={styles.settingsSection}>
<div className={styles.settingsRow}>
<div className={styles.settingsInfo}>
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/features/authFiles/AuthFilesPage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1949,6 +1949,13 @@
gap: $spacing-sm;
}

.cardScopeHint {
margin: 0 0 $spacing-sm;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
}

.excludedItem {
display: flex;
justify-content: space-between;
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/features/authFiles/components/OAuthExcludedCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export function OAuthExcludedCard(props: OAuthExcludedCardProps) {
</Button>
}
>
{loadState === 'ready' ? (
<div className={styles.cardScopeHint}>{t('oauth_excluded.scope_hint')}</div>
) : null}
{loadState === 'unsupported' ? (
<EmptyState
title={t('oauth_excluded.upgrade_required_title')}
Expand Down Expand Up @@ -58,6 +61,15 @@ export function OAuthExcludedCard(props: OAuthExcludedCardProps) {
? t('oauth_excluded.model_count', { count: models.length })
: t('oauth_excluded.no_models')}
</div>
{models?.length ? (
<div
className={styles.excludedModels}
title={models.join(', ')}
>
{models.slice(0, 3).join(' · ')}
{models.length > 3 ? ` · +${models.length - 3}` : ''}
</div>
) : null}
</div>
<div className={styles.excludedActions}>
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/m
import { IconChevronUp } from '@/components/ui/icons';
import type { OAuthModelAliasEntry } from '@/types';
import type { AuthFileModelItem, OAuthConfigLoadState } from '@/features/authFiles/constants';
import { formatOAuthAliasPreview } from '@/features/authFiles/oauthAliasValidation';
import styles from '@/features/authFiles/AuthFilesPage.module.scss';
type ViewMode = 'diagram' | 'list';

Expand Down Expand Up @@ -84,6 +85,9 @@ export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
</div>
}
>
{loadState === 'ready' ? (
<div className={styles.cardScopeHint}>{t('oauth_model_alias.scope_hint')}</div>
) : null}
{loadState === 'unsupported' ? (
<EmptyState
title={t('oauth_model_alias.upgrade_required_title')}
Expand Down Expand Up @@ -147,6 +151,11 @@ export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
? t('oauth_model_alias.model_count', { count: mappings.length })
: t('oauth_model_alias.no_models')}
</div>
{mappings?.length ? (
<div className={styles.excludedModels} title={formatOAuthAliasPreview(mappings, 20)}>
{formatOAuthAliasPreview(mappings)}
</div>
) : null}
</div>
<div className={styles.excludedActions}>
<Button
Expand Down
Loading
Loading