feat(api-access): support multiline API key editing

This commit is contained in:
lzt404 committed 2026-08-04 01:25:22 +08:00
1 parent eb7fe25dd2
commit cc2636d815
2 files changed
+50 -36

No files matched your search

+45 -36
View File
@@ -321,12 +321,14 @@ 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())
.filter((item, index, values) => item && values.indexOf(item) === index);
const mergeOpenAiApiKeyEntries = (current: unknown, apiKey: string) => {
const entries = Array.isArray(current) ? current.filter(isRecord) : [];
const keys = apiKey
.split(/\r?\n/)
.map((value) => value.trim())
.filter((value, index, values) => value && values.indexOf(value) === index);
const keys = parseProviderApiKeys(apiKey);
const usedIndexes = new Set<number>();
return keys.map((key, index) => {
let matchedIndex = entries.findIndex(
@@ -782,10 +784,7 @@ export function ApiAccessPage() {
};
const baseUrlRequired = definition.openAi || definition.section === 'codex-api-key';
const remarkRequired = definition.openAi && activeCategory !== 'deepseek';
const parsedApiKeys = preparedDraft.apiKey
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean);
const parsedApiKeys = parseProviderApiKeys(preparedDraft.apiKey);
if (
parsedApiKeys.length === 0
|| (remarkRequired && !preparedDraft.remark.trim())
@@ -837,34 +836,42 @@ export function ApiAccessPage() {
const latestConfig = await managementApi.get('/config');
const current = sectionRecordsFromConfig(latestConfig, activeSection);
let nextList: Record<string, unknown>[];
let targetIndex = -1;
let currentRecord: Record<string, unknown> | undefined;
if (editingRow) {
const targetIndex = resolveProviderRecordIndex(current, editingRow);
targetIndex = resolveProviderRecordIndex(current, editingRow);
if (targetIndex < 0) {
throw new Error(t('apiAccess.error.stale'));
}
const nextRecord = buildProviderRecord(
activeSection,
draftToSave,
current[targetIndex],
);
nextList = current.map((record, index) =>
index === targetIndex ? nextRecord : record,
);
} else {
const duplicate = current.some((record) =>
definition.openAi
? readString(record, 'name') === preparedDraft.name.trim()
: readString(record, 'api-key', 'apiKey') === preparedDraft.apiKey.trim()
&& readString(record, 'base-url', 'baseUrl') === baseUrl,
);
if (duplicate) throw new Error(t('apiAccess.error.duplicate'));
nextList = [
...current,
buildProviderRecord(activeSection, draftToSave),
];
currentRecord = current[targetIndex];
}
const duplicate = current.some((record, index) => {
if (index === targetIndex) return false;
if (definition.openAi) return readString(record, 'name') === preparedDraft.name.trim();
return parsedApiKeys.some((apiKey) => (
readString(record, 'api-key', 'apiKey').trim() === apiKey
&& readString(record, 'base-url', 'baseUrl').trim() === baseUrl
));
});
if (duplicate) throw new Error(t('apiAccess.error.duplicate'));
const recordsToSave = definition.openAi
? [buildProviderRecord(activeSection, draftToSave, currentRecord)]
: parsedApiKeys.map((apiKey) => buildProviderRecord(
activeSection,
{ ...draftToSave, apiKey },
currentRecord,
));
nextList = editingRow
? [
...current.slice(0, targetIndex),
...recordsToSave,
...current.slice(targetIndex + 1),
]
: [...current, ...recordsToSave];
await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields));
await invoke('save_api_access_remark', {
update: {
@@ -1547,13 +1554,15 @@ function ApiProviderDialog({
</button>
</div>
<label><span>{t('apiAccess.field.remark')}</span><input autoFocus={definition.openAi} value={draft.remark} maxLength={80} onChange={(event) => updateTextField('remark', event.currentTarget.value)} placeholder={t('apiAccess.remarkPlaceholder')} /></label>
<label className={definition.openAi ? 'multiline-field' : undefined}>
<span>{definition.openAi ? t('apiAccess.field.keysMany') : t('apiAccess.field.key')}</span>
{definition.openAi ? (
<textarea value={draft.apiKey} onChange={(event) => updateTextField('apiKey', event.currentTarget.value)} placeholder={'sk-...\nsk-...'} rows={3} />
) : (
<input autoFocus={!definition.openAi} type="password" value={draft.apiKey} onChange={(event) => updateTextField('apiKey', event.currentTarget.value)} placeholder="sk-..." />
)}
<label className="multiline-field">
<span>{t('apiAccess.field.keysMany')}</span>
<textarea
autoFocus={!definition.openAi}
value={draft.apiKey}
onChange={(event) => updateTextField('apiKey', event.currentTarget.value)}
placeholder={'sk-...\nsk-...'}
rows={3}
/>
</label>
<label><span>Base URL</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? t('apiAccess.baseRequiredPlaceholder') : t('apiAccess.baseOptionalPlaceholder')} /></label>
{activeCategory === 'deepseek' ? (
+5
View File
@@ -10,6 +10,7 @@ import {
exclusionsForModelSelection,
modelSelectionForDiscovery,
parseProviderHeaders,
parseProviderApiKeys,
providerCategoryMatchesRecord,
providerRecordWithDisabledState,
providerSectionOrder,
@@ -34,6 +35,10 @@ it('saves non-empty custom model names and removes duplicate or blank entries',
expect(result.models).toEqual([{ name: 'custom-model', alias: 'Custom Alias' }]);
});
it('parses multiline API keys into unique trimmed entries', () => {
expect(parseProviderApiKeys(' key-a\n\nkey-b\r\nkey-a ')).toEqual(['key-a', 'key-b']);
});
describe('API 接入配置合并', () => {
it('固定使用 Codex、OpenAI、DeepSeek、Claude、Gemini 顺序且不包含 Vertex', () => {
expect(providerSectionOrder).toEqual([