feat(api-access): unify DeepSeek model discovery and selection
- require explicit model discovery before saving DeepSeek connections - preserve selected models when refreshing the catalog - mark model lists stale when connection details change - add model refresh support to easy mode - apply built-in DeepSeek reasoning levels consistently
This commit is contained in:
1 parent
c5f68157e5
commit
2a99db2908
10 files changed
+287
-68
No files matched your search
+6
-1
@@ -26,6 +26,7 @@ export const jaOverrides = {
|
||||
'easyMode.api.fetchingModels': 'モデル一覧を取得中…',
|
||||
'easyMode.api.modelListTitle': 'モデルを選択',
|
||||
'easyMode.api.modelListHint': '接続するモデルを選択してください',
|
||||
'easyMode.api.modelListStale': '接続情報が変更されました。保存する前にモデル一覧を更新してください',
|
||||
'easyMode.api.credentialsRequired': '先に API の URL と API キーを入力してください',
|
||||
'easyMode.api.modelRequired': 'モデル一覧を取得し、モデルを選択してください',
|
||||
'easyMode.api.testSuccess': '接続に成功しました。{count} 件のモデルが見つかりました',
|
||||
@@ -1126,6 +1127,7 @@ export const jaOverrides = {
|
||||
'apiAccess.error.requiredKey': 'API キーを入力してください',
|
||||
'apiAccess.error.baseRequired': '{provider} 接続には Base URL が必要です',
|
||||
'apiAccess.error.noModels': '公開可能なモデルが見つかりません。Base URL と API キーを確認してください',
|
||||
'apiAccess.error.fetchModelsBeforeSave': 'モデル一覧を正常に取得し、少なくとも 1 件選択してください',
|
||||
'apiAccess.error.stale': '接続設定が別の操作で変更されました。更新してから再試行してください',
|
||||
'apiAccess.error.duplicate': '同じ接続設定がすでに存在します',
|
||||
'apiAccess.error.remarkInvalid': 'メモは 80 文字以内で、改行や制御文字を含めないでください',
|
||||
@@ -1186,6 +1188,9 @@ export const jaOverrides = {
|
||||
'apiAccess.error.noAvailableModels': '利用可能なモデルが見つかりません',
|
||||
'apiAccess.error.baseBeforeModels': 'モデルを取得する前に Base URL を入力してください',
|
||||
'apiAccess.models.selected': '{count} 件のモデルを選択済み',
|
||||
'apiAccess.models.selectionRequired': 'モデルが選択されていません',
|
||||
'apiAccess.models.selectionRequiredHint': 'モデル一覧を取得し、少なくとも 1 件選択してください',
|
||||
'apiAccess.models.staleHint': '接続情報が変更されました。モデル一覧を再取得してください',
|
||||
'apiAccess.models.restricted': 'モデル制限を設定済み',
|
||||
'apiAccess.models.autoAll': '保存時にすべてのモデルを既定で公開',
|
||||
'apiAccess.models.upstreamDefault': '上流の既定モデルを使用',
|
||||
@@ -1201,7 +1206,7 @@ export const jaOverrides = {
|
||||
'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com',
|
||||
'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用',
|
||||
'apiAccess.preset.title': 'OpenAI 互換プリセット',
|
||||
'apiAccess.preset.description': 'DeepSeek 公式アドレスを入力済みです。保存時にすべてのモデルを自動検出して公開します',
|
||||
'apiAccess.preset.description': 'DeepSeek 公式アドレスを入力済みです。モデル一覧を取得し、公開するモデルを選択してください',
|
||||
'apiAccess.thinking.builtIn': '組み込み推論レベル',
|
||||
'apiAccess.thinking.builtInDescription': '現在公開中のすべてのモデルに自動適用します',
|
||||
'apiAccess.thinking.title': '推論レベル',
|
||||
|
||||
@@ -244,6 +244,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'easyMode.api.fetchingModels': 'Fetching model list...',
|
||||
'easyMode.api.modelListTitle': 'Select models',
|
||||
'easyMode.api.modelListHint': 'Select the models to connect',
|
||||
'easyMode.api.modelListStale': 'Connection details changed. Refresh the model list before saving.',
|
||||
'easyMode.api.credentialsRequired': 'Enter an API URL and API key first',
|
||||
'easyMode.api.modelRequired': 'Fetch and select a model',
|
||||
'easyMode.api.testSuccess': 'Connection successful! Found {count} models',
|
||||
@@ -1126,6 +1127,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.error.requiredKey': 'API key is required',
|
||||
'apiAccess.error.baseRequired': 'A Base URL is required for {provider}',
|
||||
'apiAccess.error.noModels': 'No models were discovered. Check the Base URL and API key.',
|
||||
'apiAccess.error.fetchModelsBeforeSave': 'Fetch the model list successfully and select at least one model',
|
||||
'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.',
|
||||
'apiAccess.error.duplicate': 'An identical connection already exists',
|
||||
'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters',
|
||||
@@ -1186,6 +1188,9 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.error.noAvailableModels': 'No available models found',
|
||||
'apiAccess.error.baseBeforeModels': 'Enter a Base URL before fetching models',
|
||||
'apiAccess.models.selected': '{count} models selected',
|
||||
'apiAccess.models.selectionRequired': 'No models selected',
|
||||
'apiAccess.models.selectionRequiredHint': 'Fetch the model list and select at least one model',
|
||||
'apiAccess.models.staleHint': 'Connection details changed. Fetch the model list again.',
|
||||
'apiAccess.models.restricted': 'Model restrictions configured',
|
||||
'apiAccess.models.autoAll': 'All models will be opened by default when saved',
|
||||
'apiAccess.models.upstreamDefault': 'Using upstream default models',
|
||||
@@ -1201,7 +1206,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com',
|
||||
'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address',
|
||||
'apiAccess.preset.title': 'OpenAI-Compatible Preset',
|
||||
'apiAccess.preset.description': 'The official DeepSeek address is prefilled; all discovered models are opened when saved',
|
||||
'apiAccess.preset.description': 'The official DeepSeek address is prefilled; fetch the list and select the models to open',
|
||||
'apiAccess.thinking.builtIn': 'Built-in Reasoning Levels',
|
||||
'apiAccess.thinking.builtInDescription': 'Applied automatically to all currently open models',
|
||||
'apiAccess.thinking.title': 'Reasoning Levels',
|
||||
|
||||
@@ -243,6 +243,7 @@ export const zhCN = {
|
||||
'easyMode.api.fetchingModels': '正在获取模型列表...',
|
||||
'easyMode.api.modelListTitle': '选择模型',
|
||||
'easyMode.api.modelListHint': '勾选需要接入的模型',
|
||||
'easyMode.api.modelListStale': '连接信息已变化,请刷新模型列表后再保存',
|
||||
'easyMode.api.credentialsRequired': '请先填写 API URL 和 API 密钥',
|
||||
'easyMode.api.modelRequired': '请先获取模型列表并选择模型',
|
||||
'easyMode.api.testSuccess': '连接成功!发现 {count} 个模型',
|
||||
@@ -1125,6 +1126,7 @@ export const zhCN = {
|
||||
'apiAccess.error.requiredKey': 'API 密钥不能为空',
|
||||
'apiAccess.error.baseRequired': '{provider} 接入必须填写 Base URL',
|
||||
'apiAccess.error.noModels': '没有发现可放行的模型,请确认 Base URL 和 API 密钥',
|
||||
'apiAccess.error.fetchModelsBeforeSave': '请先成功获取模型列表,并至少选择一个模型',
|
||||
'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试',
|
||||
'apiAccess.error.duplicate': '相同的接入配置已经存在',
|
||||
'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符',
|
||||
@@ -1185,6 +1187,9 @@ export const zhCN = {
|
||||
'apiAccess.error.noAvailableModels': '未发现可用模型',
|
||||
'apiAccess.error.baseBeforeModels': '请先填写 Base URL,再获取模型',
|
||||
'apiAccess.models.selected': '已选择 {count} 个模型',
|
||||
'apiAccess.models.selectionRequired': '尚未选择模型',
|
||||
'apiAccess.models.selectionRequiredHint': '请先获取模型列表,并选择至少一个模型',
|
||||
'apiAccess.models.staleHint': '连接信息已变化,请重新获取模型列表',
|
||||
'apiAccess.models.restricted': '已配置模型限制',
|
||||
'apiAccess.models.autoAll': '保存时默认开放全部模型',
|
||||
'apiAccess.models.upstreamDefault': '使用上游默认模型',
|
||||
@@ -1200,7 +1205,7 @@ export const zhCN = {
|
||||
'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com',
|
||||
'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址',
|
||||
'apiAccess.preset.title': 'OpenAI 兼容预设',
|
||||
'apiAccess.preset.description': '已预填 DeepSeek 官方地址,保存时自动发现并开放全部模型',
|
||||
'apiAccess.preset.description': '已预填 DeepSeek 官方地址;请获取模型列表并选择需要开放的模型',
|
||||
'apiAccess.thinking.builtIn': '内置思考等级',
|
||||
'apiAccess.thinking.builtInDescription': '自动应用到当前开放的全部模型',
|
||||
'apiAccess.thinking.title': '思考等级',
|
||||
|
||||
+71
-38
@@ -1,3 +1,4 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import {
|
||||
type CSSProperties,
|
||||
@@ -53,9 +54,14 @@ import {
|
||||
responseList,
|
||||
} from '../services/managementApi';
|
||||
import {
|
||||
applyDeepSeekModelPreset,
|
||||
DEEPSEEK_BASE_URL,
|
||||
DEEPSEEK_THINKING_LEVELS,
|
||||
fetchModels,
|
||||
mergeModelOptions,
|
||||
modelsFromRecord,
|
||||
normalizeBaseUrl,
|
||||
reconcileModelSelection,
|
||||
type ModelOption,
|
||||
type ModelProvider,
|
||||
} from '../services/modelService';
|
||||
@@ -80,9 +86,8 @@ export type ProviderSection =
|
||||
|
||||
export type ProviderCategory = ProviderSection | 'deepseek';
|
||||
|
||||
export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
|
||||
export const OPENAI_THINKING_LEVELS = ['low', 'medium', 'high', 'xhigh'] as const;
|
||||
export const DEEPSEEK_THINKING_LEVELS = ['low', 'high', 'max'] as const;
|
||||
export { DEEPSEEK_BASE_URL, DEEPSEEK_THINKING_LEVELS };
|
||||
|
||||
type ProviderDefinition = {
|
||||
id: ProviderCategory;
|
||||
@@ -430,9 +435,6 @@ export const modelSelectionForDiscovery = (
|
||||
);
|
||||
};
|
||||
|
||||
export const allModelSelectionForDiscovery = (models: ModelOption[]) =>
|
||||
new Set(models.map((model) => model.name.trim().toLowerCase()).filter(Boolean));
|
||||
|
||||
export const parseProviderApiKeys = (value: string) => value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
@@ -556,10 +558,15 @@ export const applyProviderPreset = (
|
||||
category: ProviderCategory,
|
||||
draft: ProviderDraft,
|
||||
): ProviderDraft => {
|
||||
if (category === 'deepseek') {
|
||||
return {
|
||||
...draft,
|
||||
thinkingLevels: [...DEEPSEEK_THINKING_LEVELS],
|
||||
models: applyDeepSeekModelPreset(draft.models),
|
||||
};
|
||||
}
|
||||
if (!definitionFor(category).openAi || draft.thinkingLevels === undefined) return draft;
|
||||
const levels = category === 'deepseek'
|
||||
? [...DEEPSEEK_THINKING_LEVELS]
|
||||
: draft.thinkingLevels;
|
||||
const levels = draft.thinkingLevels;
|
||||
return {
|
||||
...draft,
|
||||
models: draft.models.map((model) => {
|
||||
@@ -805,7 +812,6 @@ export function ApiAccessPage() {
|
||||
const [error, setError] = useState('');
|
||||
const feedback = useAppNotice();
|
||||
const { showNotice: setNotice } = feedback;
|
||||
const [feedbackRow, setFeedbackRow] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingRow, setEditingRow] = useState<ProviderRow | null>(null);
|
||||
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
|
||||
@@ -908,7 +914,6 @@ export function ApiAccessPage() {
|
||||
|
||||
const openCreate = () => {
|
||||
feedback.clearNotice();
|
||||
setFeedbackRow(null);
|
||||
setError('');
|
||||
setEditingRow(null);
|
||||
setDialogDraft(createProviderDraft(activeCategory));
|
||||
@@ -917,7 +922,6 @@ export function ApiAccessPage() {
|
||||
|
||||
const openEdit = (row: ProviderRow) => {
|
||||
feedback.clearNotice();
|
||||
setFeedbackRow(null);
|
||||
setError('');
|
||||
setEditingRow(row);
|
||||
const draft = draftFromRow(row);
|
||||
@@ -927,8 +931,10 @@ export function ApiAccessPage() {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const saveProvider = async (nextDraft: ProviderDraft): Promise<ProviderSaveResult> => {
|
||||
setFeedbackRow(null);
|
||||
const saveProvider = async (
|
||||
nextDraft: ProviderDraft,
|
||||
modelDiscoveryReady: boolean,
|
||||
): Promise<ProviderSaveResult> => {
|
||||
const definition = activeDefinition;
|
||||
const preparedDraft = applyProviderRemarkIdentity(
|
||||
activeCategory,
|
||||
@@ -956,6 +962,16 @@ export function ApiAccessPage() {
|
||||
: t('apiAccess.error.requiredKey'),
|
||||
};
|
||||
}
|
||||
if (
|
||||
activeCategory === 'deepseek'
|
||||
&& (!modelDiscoveryReady || preparedDraftForSave.models.length === 0)
|
||||
) {
|
||||
return {
|
||||
saved: false,
|
||||
target: 'models',
|
||||
error: t('apiAccess.error.fetchModelsBeforeSave'),
|
||||
};
|
||||
}
|
||||
if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) {
|
||||
return { saved: false, target: 'form', error: t('apiAccess.error.remarkInvalid') };
|
||||
}
|
||||
@@ -972,7 +988,11 @@ export function ApiAccessPage() {
|
||||
setError('');
|
||||
try {
|
||||
let draftToSave = { ...preparedDraftForSave, baseUrl };
|
||||
if (definition.openAi && draftToSave.models.length === 0) {
|
||||
if (
|
||||
definition.openAi
|
||||
&& activeCategory !== 'deepseek'
|
||||
&& draftToSave.models.length === 0
|
||||
) {
|
||||
let fetchedModels: ModelOption[];
|
||||
try {
|
||||
fetchedModels = await fetchModels(
|
||||
@@ -1057,7 +1077,6 @@ export function ApiAccessPage() {
|
||||
|
||||
const deleteRow = async (row: ProviderRow) => {
|
||||
if (!await askConfirmation({ title: t('common.delete'), message: t('apiAccess.deleteConfirm', { remark: row.remark || row.name }), confirmText: t('common.delete'), variant: 'danger' })) return;
|
||||
setFeedbackRow(providerDragId(row));
|
||||
feedback.clearNotice();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
@@ -1077,7 +1096,6 @@ export function ApiAccessPage() {
|
||||
remark: '',
|
||||
},
|
||||
});
|
||||
setFeedbackRow(null);
|
||||
setNotice({ key: 'apiAccess.notice.deleted' });
|
||||
await loadProviders();
|
||||
} catch (requestError) {
|
||||
@@ -1088,7 +1106,6 @@ export function ApiAccessPage() {
|
||||
};
|
||||
|
||||
const toggleProvider = async (row: ProviderRow) => {
|
||||
setFeedbackRow(providerDragId(row));
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setNotice('');
|
||||
@@ -1131,7 +1148,6 @@ export function ApiAccessPage() {
|
||||
|
||||
const reorderProviders = async (source: ProviderRow, target: ProviderRow) => {
|
||||
if (source.section !== target.section || source.index === target.index) return;
|
||||
setFeedbackRow(providerDragId(source));
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setNotice('');
|
||||
@@ -1197,7 +1213,6 @@ export function ApiAccessPage() {
|
||||
</header>
|
||||
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
|
||||
<div className="provider-workbench real-provider-workbench">
|
||||
<aside className="panel provider-category-panel">
|
||||
{providerDefinitions.map((definition) => (
|
||||
@@ -1208,7 +1223,6 @@ export function ApiAccessPage() {
|
||||
onClick={() => {
|
||||
setActiveCategory(definition.id);
|
||||
feedback.clearNotice();
|
||||
setFeedbackRow(null);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
@@ -1231,7 +1245,6 @@ export function ApiAccessPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedbackRow === null ? <InlineNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} /> : null}
|
||||
{loading ? (
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('apiAccess.loading')}</div>
|
||||
) : rows.length === 0 ? (
|
||||
@@ -1273,7 +1286,6 @@ export function ApiAccessPage() {
|
||||
</code>
|
||||
<span className="provider-row-url" title={row.baseUrl || undefined}>{row.baseUrl || t('apiAccess.defaultUrl')}</span>
|
||||
{row.models.length > 0 ? <span className="provider-row-models">{t('apiAccess.models.summary', { count: row.models.length })}</span> : null}
|
||||
{feedbackRow === providerDragId(row) ? <InlineNotice key={feedback.revision} notice={feedback.notice} onDismiss={feedback.clearNotice} /> : null}
|
||||
</div>
|
||||
{row.priority === null ? null : (
|
||||
<div className="provider-row-meta">
|
||||
@@ -1335,6 +1347,17 @@ export function ApiAccessPage() {
|
||||
onClose={() => setHealthDialogRow(null)}
|
||||
/>
|
||||
) : null}
|
||||
{typeof document === 'undefined'
|
||||
? null
|
||||
: createPortal(
|
||||
<InlineNotice
|
||||
key={feedback.revision}
|
||||
notice={feedback.notice}
|
||||
onDismiss={feedback.clearNotice}
|
||||
className="api-access-page-notice"
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1563,7 +1586,7 @@ type ApiProviderDialogProps = {
|
||||
initialDraft: ProviderDraft;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (draft: ProviderDraft) => Promise<ProviderSaveResult>;
|
||||
onSave: (draft: ProviderDraft, modelDiscoveryReady: boolean) => Promise<ProviderSaveResult>;
|
||||
};
|
||||
|
||||
function ApiProviderDialog({
|
||||
@@ -1583,21 +1606,22 @@ function ApiProviderDialog({
|
||||
const [formError, setFormError] = useState('');
|
||||
const [discoveredModels, setDiscoveredModels] = useState<ModelOption[]>([]);
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryReady, setModelDiscoveryReady] = useState(
|
||||
() => activeCategory !== 'deepseek'
|
||||
|| Boolean(editingRow && initialDraft.models.some((model) => model.name.trim())),
|
||||
);
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
const [thinkingLevelInput, setThinkingLevelInput] = useState('');
|
||||
const [selectedModelNames, setSelectedModelNames] = useState<Set<string>>(
|
||||
() => new Set(initialDraft.models.map((model) => model.name.toLowerCase())),
|
||||
);
|
||||
const modelCardRef = useRef<HTMLDivElement>(null);
|
||||
const discoverySelectionInitializedRef = useRef(false);
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
const options = new Map<string, ModelOption>();
|
||||
[...discoveredModels, ...draft.models].forEach((model) => {
|
||||
const name = model.name.trim();
|
||||
if (name) options.set(name.toLowerCase(), { ...model, name });
|
||||
});
|
||||
return Array.from(options.values());
|
||||
}, [discoveredModels, draft.models]);
|
||||
const modelOptions = useMemo(
|
||||
() => mergeModelOptions(discoveredModels, draft.models),
|
||||
[discoveredModels, draft.models],
|
||||
);
|
||||
|
||||
const configuredModels = useMemo(
|
||||
() => draft.models.filter((model) => model.name.trim()),
|
||||
@@ -1622,6 +1646,7 @@ function ApiProviderDialog({
|
||||
setFormError('');
|
||||
if (field === 'apiKey' || field === 'baseUrl' || field === 'headersText') {
|
||||
setModelError('');
|
||||
if (activeCategory === 'deepseek') setModelDiscoveryReady(false);
|
||||
}
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
@@ -1707,12 +1732,12 @@ function ApiProviderDialog({
|
||||
activeCategory,
|
||||
{ ...draft, models: fetchedModels },
|
||||
).models;
|
||||
const selectionMode = discoverySelectionInitializedRef.current ? 'refresh' : 'initial';
|
||||
setDiscoveredModels(models);
|
||||
setSelectedModelNames(new Set(
|
||||
[...models, ...draft.models]
|
||||
.map((model) => model.name.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
));
|
||||
setSelectedModelNames((current) =>
|
||||
reconcileModelSelection(models, draft.models, current, selectionMode));
|
||||
discoverySelectionInitializedRef.current = true;
|
||||
if (activeCategory === 'deepseek') setModelDiscoveryReady(true);
|
||||
if (!models.length) setModelError(t('apiAccess.error.noAvailableModels'));
|
||||
} catch (requestError) {
|
||||
setModelError(requestErrorMessage(requestError));
|
||||
@@ -1729,6 +1754,7 @@ function ApiProviderDialog({
|
||||
return;
|
||||
}
|
||||
setModelSearch('');
|
||||
discoverySelectionInitializedRef.current = false;
|
||||
setSelectedModelNames(new Set(draft.models.map((model) => model.name.toLowerCase())));
|
||||
setModelDiscoveryOpen(true);
|
||||
void discoverModels();
|
||||
@@ -1777,7 +1803,7 @@ function ApiProviderDialog({
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError('');
|
||||
const result = await onSave(draft);
|
||||
const result = await onSave(draft, modelDiscoveryReady);
|
||||
if (result.saved) {
|
||||
onClose();
|
||||
return;
|
||||
@@ -1794,15 +1820,22 @@ function ApiProviderDialog({
|
||||
|
||||
const hasModelExclusions = activeSection !== 'openai-compatibility'
|
||||
&& Boolean(draft.excludedModelsText?.trim());
|
||||
const deepSeekModelsStale = activeCategory === 'deepseek' && !modelDiscoveryReady;
|
||||
const modelSummaryTitle = configuredModels.length > 0
|
||||
? t('apiAccess.models.selected', { count: configuredModels.length })
|
||||
: activeCategory === 'deepseek'
|
||||
? t('apiAccess.models.selectionRequired')
|
||||
: hasModelExclusions
|
||||
? t('apiAccess.models.restricted')
|
||||
: activeSection === 'openai-compatibility'
|
||||
? t('apiAccess.models.autoAll')
|
||||
: t('apiAccess.models.upstreamDefault');
|
||||
const modelSummaryDetail = configuredModels.length > 0
|
||||
? configuredModels.slice(0, 3).map((model) => model.name).join('、')
|
||||
? deepSeekModelsStale
|
||||
? t('apiAccess.models.staleHint')
|
||||
: configuredModels.slice(0, 3).map((model) => model.name).join('、')
|
||||
: activeCategory === 'deepseek'
|
||||
? t('apiAccess.models.selectionRequiredHint')
|
||||
: hasModelExclusions
|
||||
? t('apiAccess.models.hiddenHint')
|
||||
: activeSection === 'openai-compatibility'
|
||||
|
||||
+62
-11
@@ -17,6 +17,7 @@ import {
|
||||
LoaderCircle,
|
||||
Monitor,
|
||||
Moon,
|
||||
RefreshCw,
|
||||
Sun,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -30,8 +31,12 @@ import {
|
||||
responseList,
|
||||
} from "../services/managementApi";
|
||||
import {
|
||||
applyDeepSeekModelPreset,
|
||||
DEEPSEEK_BASE_URL,
|
||||
fetchModels,
|
||||
mergeModelOptions,
|
||||
normalizeBaseUrl,
|
||||
reconcileModelSelection,
|
||||
type ModelOption,
|
||||
type ModelProvider,
|
||||
} from "../services/modelService";
|
||||
@@ -84,7 +89,7 @@ const apiSectionOptions: ApiSectionOption[] = [
|
||||
{ id: "claude", managementSection: "claude-api-key", nameKey: "easyMode.api.platformName.claude", provider: "claude", defaultBaseUrl: "", icon: claudeIcon },
|
||||
{ id: "codex", managementSection: "codex-api-key", nameKey: "easyMode.api.platformName.codex", provider: "codex", defaultBaseUrl: "", icon: codexIcon },
|
||||
{ id: "gemini", managementSection: "gemini-api-key", nameKey: "easyMode.api.platformName.gemini", provider: "gemini", defaultBaseUrl: "", icon: geminiIcon },
|
||||
{ id: "deepseek", managementSection: "openai-compatibility", nameKey: "easyMode.api.platformName.deepseek", provider: "openai", defaultBaseUrl: "https://api.deepseek.com", icon: deepseekIcon },
|
||||
{ id: "deepseek", managementSection: "openai-compatibility", nameKey: "easyMode.api.platformName.deepseek", provider: "openai", defaultBaseUrl: DEEPSEEK_BASE_URL, icon: deepseekIcon },
|
||||
];
|
||||
|
||||
const isDeepSeekRecord = (record: Record<string, unknown>) => {
|
||||
@@ -135,6 +140,7 @@ export function EasyModePage({
|
||||
const [apiTesting, setApiTesting] = useState(false);
|
||||
const [apiTestedModels, setApiTestedModels] = useState<ModelOption[]>([]);
|
||||
const [apiSelectedModels, setApiSelectedModels] = useState<ModelOption[]>([]);
|
||||
const [apiModelsReady, setApiModelsReady] = useState(false);
|
||||
const [apiErrorMessage, setApiTestError] = useState<NoticeMessage>("");
|
||||
const apiTestError = typeof apiErrorMessage === "string"
|
||||
? apiErrorMessage
|
||||
@@ -321,6 +327,7 @@ export function EasyModePage({
|
||||
if (opt) setApiBaseUrl(opt.defaultBaseUrl);
|
||||
setApiTestedModels([]);
|
||||
setApiSelectedModels([]);
|
||||
setApiModelsReady(false);
|
||||
setApiTestError("");
|
||||
clearApiNotice();
|
||||
setGuideApiSaved(false);
|
||||
@@ -338,10 +345,7 @@ export function EasyModePage({
|
||||
}
|
||||
setApiTesting(true);
|
||||
setApiTestError("");
|
||||
setApiTestedModels([]);
|
||||
setApiSelectedModels([]);
|
||||
setGuideApiSaved(false);
|
||||
setGuideApiModelsFetched(false);
|
||||
|
||||
const opt = apiSectionOptions.find((o) => o.id === selectedApiSection);
|
||||
const providerType = opt ? opt.provider : "openai";
|
||||
@@ -356,8 +360,18 @@ export function EasyModePage({
|
||||
10000,
|
||||
);
|
||||
if (models.length > 0) {
|
||||
setApiTestedModels(models);
|
||||
setApiSelectedModels(models);
|
||||
const mergedModels = mergeModelOptions(models);
|
||||
const selectedNames = reconcileModelSelection(
|
||||
mergedModels,
|
||||
[],
|
||||
apiSelectedModels.map((model) => model.name),
|
||||
apiModelsReady ? "refresh" : "initial",
|
||||
);
|
||||
setApiTestedModels(mergedModels);
|
||||
setApiSelectedModels(
|
||||
mergedModels.filter((model) => selectedNames.has(model.name.trim().toLowerCase())),
|
||||
);
|
||||
setApiModelsReady(true);
|
||||
setGuideApiModelsFetched(true);
|
||||
} else {
|
||||
setApiTestError({ key: "easyMode.api.noModelsFound" });
|
||||
@@ -390,6 +404,10 @@ export function EasyModePage({
|
||||
setApiTestError({ key: "easyMode.api.apiKeyRequired" });
|
||||
return;
|
||||
}
|
||||
if (selectedApiSection === "deepseek" && !apiModelsReady) {
|
||||
setApiTestError({ key: "easyMode.api.fetchListFirst" });
|
||||
return;
|
||||
}
|
||||
if (apiSelectedModels.length === 0) {
|
||||
setApiTestError({ key: "easyMode.api.modelRequired" });
|
||||
return;
|
||||
@@ -408,7 +426,10 @@ export function EasyModePage({
|
||||
const managementSection = selectedOption?.managementSection ?? "openai-compatibility";
|
||||
const configPayload = await managementApi.get("/config");
|
||||
const list = responseList(configPayload, managementSection);
|
||||
const models = apiSelectedModels.map((model) => ({ name: model.name.trim() }));
|
||||
const selectedModels = apiSelectedModels.map((model) => ({ name: model.name.trim() }));
|
||||
const models = selectedApiSection === "deepseek"
|
||||
? applyDeepSeekModelPreset(selectedModels)
|
||||
: selectedModels;
|
||||
const newEntry = managementSection === "openai-compatibility"
|
||||
? {
|
||||
name: apiRemark.trim() || `${selectedApiSection} (${list.length + 1})`,
|
||||
@@ -923,7 +944,12 @@ export function EasyModePage({
|
||||
type="text"
|
||||
className="text-input"
|
||||
value={apiBaseUrl}
|
||||
onChange={(e) => { setApiBaseUrl(e.target.value); setGuideApiSaved(false); }}
|
||||
onChange={(e) => {
|
||||
setApiBaseUrl(e.target.value);
|
||||
setApiModelsReady(false);
|
||||
setGuideApiModelsFetched(false);
|
||||
setGuideApiSaved(false);
|
||||
}}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
@@ -933,7 +959,12 @@ export function EasyModePage({
|
||||
type="password"
|
||||
className="text-input"
|
||||
value={apiKey}
|
||||
onChange={(e) => { setApiKey(e.target.value); setGuideApiSaved(false); }}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
setApiModelsReady(false);
|
||||
setGuideApiModelsFetched(false);
|
||||
setGuideApiSaved(false);
|
||||
}}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
@@ -963,11 +994,24 @@ export function EasyModePage({
|
||||
<>
|
||||
<div className="simple-mode-api-model-heading">
|
||||
<strong>{t("easyMode.api.modelListTitle")}</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={apiTesting || !apiBaseUrl.trim() || !apiKey.trim()}
|
||||
onClick={() => void handleTestApi()}
|
||||
>
|
||||
<RefreshCw size={14} className={apiTesting ? "spin" : ""} />
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-api-model-selection">
|
||||
<div className="simple-mode-api-model-selection-heading">
|
||||
<span>{t("easyMode.api.modelListHint")}</span>
|
||||
<span>
|
||||
{selectedApiSection === "deepseek" && !apiModelsReady
|
||||
? t("easyMode.api.modelListStale")
|
||||
: t("easyMode.api.modelListHint")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="simple-mode-api-model-options">
|
||||
{apiTestedModels.map((model) => {
|
||||
@@ -983,6 +1027,7 @@ export function EasyModePage({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
disabled={apiTesting}
|
||||
onChange={() => handleToggleApiModel(model)}
|
||||
/>
|
||||
<span title={model.name}>{model.name}</span>
|
||||
@@ -996,7 +1041,13 @@ export function EasyModePage({
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={apiSaving || !apiBaseUrl.trim() || !apiKey.trim() || apiSelectedModels.length === 0}
|
||||
disabled={
|
||||
apiSaving
|
||||
|| !apiBaseUrl.trim()
|
||||
|| !apiKey.trim()
|
||||
|| apiSelectedModels.length === 0
|
||||
|| (selectedApiSection === "deepseek" && !apiModelsReady)
|
||||
}
|
||||
onClick={() => void handleSaveApi()}
|
||||
>
|
||||
{t("easyMode.api.saveAndConnect")}
|
||||
|
||||
@@ -13,8 +13,58 @@ export type ModelOption = {
|
||||
};
|
||||
export type ModelProvider = 'gemini' | 'codex' | 'claude' | 'openai';
|
||||
|
||||
export type ModelSelectionMode = 'initial' | 'refresh';
|
||||
|
||||
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
|
||||
const DEFAULT_CLAUDE_BASE_URL = 'https://api.anthropic.com';
|
||||
export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
|
||||
export const DEEPSEEK_THINKING_LEVELS = ['low', 'high', 'max'] as const;
|
||||
|
||||
const modelKey = (name: string) => name.trim().toLowerCase();
|
||||
|
||||
export function mergeModelOptions(...groups: ModelOption[][]): ModelOption[] {
|
||||
const merged = new Map<string, ModelOption>();
|
||||
groups.flat().forEach((model) => {
|
||||
const name = model.name.trim();
|
||||
if (!name) return;
|
||||
merged.set(modelKey(name), { ...model, name });
|
||||
});
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
export function reconcileModelSelection(
|
||||
discoveredModels: ModelOption[],
|
||||
configuredModels: ModelOption[],
|
||||
selectedModelNames: Iterable<string>,
|
||||
mode: ModelSelectionMode,
|
||||
): Set<string> {
|
||||
const availableNames = new Set(
|
||||
mergeModelOptions(discoveredModels, configuredModels).map((model) => modelKey(model.name)),
|
||||
);
|
||||
const configuredNames = new Set(
|
||||
configuredModels.map((model) => modelKey(model.name)).filter(Boolean),
|
||||
);
|
||||
const previousSelection = new Set(
|
||||
Array.from(selectedModelNames, modelKey).filter(Boolean),
|
||||
);
|
||||
const requestedSelection = mode === 'refresh'
|
||||
? previousSelection
|
||||
: configuredNames.size > 0
|
||||
? configuredNames
|
||||
: new Set(discoveredModels.map((model) => modelKey(model.name)).filter(Boolean));
|
||||
|
||||
return new Set(Array.from(requestedSelection).filter((name) => availableNames.has(name)));
|
||||
}
|
||||
|
||||
export function applyDeepSeekModelPreset(models: ModelOption[]): ModelOption[] {
|
||||
return models.map((model) => ({
|
||||
...model,
|
||||
thinking: {
|
||||
...model.thinking,
|
||||
levels: [...DEEPSEEK_THINKING_LEVELS],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(value: string): string {
|
||||
let raw = value.trim();
|
||||
|
||||
@@ -4284,6 +4284,16 @@ textarea:focus-visible {
|
||||
color: var(--theme-2f6b3f);
|
||||
}
|
||||
|
||||
.action-feedback.inline-notice.api-access-page-notice {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 80;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
margin: 0;
|
||||
box-shadow: 0 14px 34px rgba(45, 42, 38, 0.18);
|
||||
}
|
||||
|
||||
.real-provider-workbench {
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
applyDeepSeekModelPreset,
|
||||
DEEPSEEK_THINKING_LEVELS,
|
||||
mergeModelOptions,
|
||||
reconcileModelSelection,
|
||||
} from '../src/services/modelService';
|
||||
|
||||
describe('model discovery selection', () => {
|
||||
const discovered = [
|
||||
{ name: 'deepseek-chat' },
|
||||
{ name: 'deepseek-reasoner' },
|
||||
{ name: 'deepseek-new-model' },
|
||||
];
|
||||
|
||||
it('selects every discovered model on the first fetch for a new connection', () => {
|
||||
expect(Array.from(reconcileModelSelection(discovered, [], [], 'initial'))).toEqual([
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek-new-model',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps saved selections and leaves newly discovered models unchecked', () => {
|
||||
expect(Array.from(reconcileModelSelection(
|
||||
discovered,
|
||||
[{ name: 'deepseek-chat' }],
|
||||
['deepseek-chat'],
|
||||
'initial',
|
||||
))).toEqual(['deepseek-chat']);
|
||||
});
|
||||
|
||||
it('preserves refresh selections, drops unavailable discoveries, and keeps configured models', () => {
|
||||
expect(Array.from(reconcileModelSelection(
|
||||
[{ name: 'deepseek-reasoner' }, { name: 'deepseek-new-model' }],
|
||||
[{ name: 'custom-model' }],
|
||||
['DEEPSEEK-REASONER', 'removed-model', 'custom-model'],
|
||||
'refresh',
|
||||
))).toEqual(['deepseek-reasoner', 'custom-model']);
|
||||
});
|
||||
|
||||
it('deduplicates model names case-insensitively and keeps configured metadata', () => {
|
||||
expect(mergeModelOptions(
|
||||
[{ name: 'deepseek-chat' }],
|
||||
[{ name: ' DEEPSEEK-CHAT ', alias: 'Chat' }],
|
||||
)).toEqual([{ name: 'DEEPSEEK-CHAT', alias: 'Chat' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepSeek model preset', () => {
|
||||
it('adds built-in thinking levels while preserving existing metadata', () => {
|
||||
expect(applyDeepSeekModelPreset([{
|
||||
name: 'deepseek-reasoner',
|
||||
thinking: { custom: true, levels: ['legacy'] },
|
||||
}])).toEqual([{
|
||||
name: 'deepseek-reasoner',
|
||||
thinking: { custom: true, levels: [...DEEPSEEK_THINKING_LEVELS] },
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,21 @@ describe('控件自身反馈', () => {
|
||||
expect(nonEmptyCalls[0].arguments[1].getText()).toBe("'error'");
|
||||
});
|
||||
|
||||
it('API 接入提示固定在右下角,不插入页面布局', () => {
|
||||
const source = readFileSync(new URL('../src/pages/ApiAccessPage.tsx', import.meta.url), 'utf8');
|
||||
expect(source).toContain("import { createPortal } from 'react-dom';");
|
||||
expect(source).toContain('className="api-access-page-notice"');
|
||||
expect(source).toContain('createPortal(');
|
||||
expect(source).toContain('document.body');
|
||||
expect(source).not.toContain('feedbackRow');
|
||||
expect(source.indexOf('createPortal(')).toBeGreaterThan(source.indexOf('provider-workbench real-provider-workbench'));
|
||||
expect(source.slice(source.indexOf('provider-workbench real-provider-workbench'), source.lastIndexOf('createPortal('))).not.toContain('<InlineNotice');
|
||||
const styles = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8');
|
||||
expect(styles).toMatch(/\.action-feedback\.inline-notice\.api-access-page-notice\s*\{[\s\S]*?position:\s*fixed;/);
|
||||
expect(styles).toMatch(/\.action-feedback\.inline-notice\.api-access-page-notice\s*\{[\s\S]*?right:\s*24px;/);
|
||||
expect(styles).toMatch(/\.action-feedback\.inline-notice\.api-access-page-notice\s*\{[\s\S]*?bottom:\s*24px;/);
|
||||
});
|
||||
|
||||
it('凭证文件的启用停用由按钮与状态标签反馈', () => {
|
||||
expect(actionCalls('AuthFileManagementPage.tsx', 'toggleStatus', 'showNotice').calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
allModelSelectionForDiscovery,
|
||||
applyProviderRemarkIdentity,
|
||||
applyProviderPreset,
|
||||
buildProviderRecord,
|
||||
@@ -51,7 +50,7 @@ describe('API 接入配置合并', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('DeepSeek 新增预设默认发现全部模型并应用内置思考等级', () => {
|
||||
it('DeepSeek 对选定模型应用内置思考等级', () => {
|
||||
expect(DEEPSEEK_THINKING_LEVELS).toEqual(['low', 'high', 'max']);
|
||||
const draft = createProviderDraft('deepseek');
|
||||
const discovered = [
|
||||
@@ -387,18 +386,4 @@ describe('API 接入配置合并', () => {
|
||||
|
||||
expect(Array.from(selected)).toEqual(['model-a', 'model-b']);
|
||||
});
|
||||
|
||||
it('模型选择窗口每次打开都以接口返回的全部模型作为默认选择', () => {
|
||||
const selected = allModelSelectionForDiscovery([
|
||||
{ name: 'deepseek-chat' },
|
||||
{ name: 'deepseek-reasoner' },
|
||||
{ name: 'deepseek-new-model' },
|
||||
]);
|
||||
|
||||
expect(Array.from(selected)).toEqual([
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek-new-model',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user