🐛 fix(connection-modal): 支持编辑态回填已保存密码并保持默认遮罩

- 编辑连接前主动拉取可编辑配置,恢复主密码与 SSH 等已保存密钥
- 支持 AI 供应商编辑态回填 API Key,并保持默认遮罩展示
- 修正 AI 设置长错误提示换行展示,避免测试连接报错被裁切

Refs #489
This commit is contained in:
Syngnat
2026-05-30 17:25:58 +08:00
parent ebda018e13
commit fdcbadf918
19 changed files with 453 additions and 130 deletions

View File

@@ -124,6 +124,24 @@ describe('tool center menu entries', () => {
expect(appSource).toContain('{isSyncModalOpen && (');
});
it('loads editable connection details before opening the edit modal so stored secrets can be shown', () => {
expect(appSource).toContain("typeof backendApp?.GetEditableSavedConnection === 'function'");
expect(appSource).toContain('const editableConnection = await backendApp.GetEditableSavedConnection(conn.id);');
expect(appSource).toContain('setEditingConnection(nextConnection);');
expect(appSource).toContain('setIsModalOpen(true);');
});
it('loads editable AI provider details before opening the edit modal so stored api keys can be shown', () => {
expect(appSource).toContain('<AISettingsModal');
const modalSource = readFileSync(new URL('./components/AISettingsModal.tsx', import.meta.url), 'utf8');
expect(modalSource).toContain("typeof Service?.AIGetEditableProvider === 'function'");
expect(modalSource).toContain('await Service.AIGetEditableProvider(p.id)');
});
it('keeps edit-mode passwords masked by default instead of forcing the eye toggle open', () => {
expect(appSource).not.toContain('setPrimaryPasswordVisible(String(config.password || "").trim() !== "")');
});
it('keeps shortcut manager scrolling inside the modal body', () => {
expect(appSource).toContain('centered');
expect(appSource).toContain("height: 'min(760px, calc(100vh - 80px))'");

View File

@@ -493,6 +493,19 @@
left: 50% !important;
transform: translateX(-50%) !important;
right: auto !important;
width: max-content;
width: min(100%, 720px);
max-width: calc(100% - 32px);
z-index: 100;
}
.ai-settings-body .ant-message .ant-message-notice {
width: 100%;
}
.ai-settings-body .ant-message .ant-message-notice-content {
width: 100%;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
text-align: left;
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('./AISettingsModal.tsx', import.meta.url), 'utf8');
const aiChatPanelCss = readFileSync(new URL('./AIChatPanel.css', import.meta.url), 'utf8');
describe('AISettingsModal edit password behavior', () => {
it('loads editable provider details before opening the edit modal', () => {
expect(source).toContain("typeof Service?.AIGetEditableProvider === 'function'");
expect(source).toContain('await Service.AIGetEditableProvider(p.id)');
});
it('keeps the prefilled api key masked by default', () => {
expect(source).toContain('const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);');
expect(source).toContain('visible: primaryPasswordVisible,');
});
it('does not render the clear helper block anymore', () => {
expect(source).not.toContain('当前已保存 API Key。留空表示继续沿用输入新值表示替换。');
expect(source).not.toContain('清除已保存 API Key');
expect(source).not.toContain('留空表示继续沿用已保存密钥');
});
it('renders in-modal test errors through the local message host', () => {
expect(source).toContain('antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body })');
expect(source).toContain("void messageApi.error(`测试失败: ${res?.message || '未知错误'}`);");
});
it('keeps long ai settings toast errors wrapped within the modal body', () => {
expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message {');
expect(aiChatPanelCss).toContain('width: min(100%, 720px);');
expect(aiChatPanelCss).toContain('max-width: calc(100% - 32px);');
expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message .ant-message-notice-content {');
expect(aiChatPanelCss).toContain('white-space: normal;');
expect(aiChatPanelCss).toContain('overflow-wrap: anywhere;');
});
});

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Modal, Button, Input, Select, Form, Checkbox, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
import {
@@ -89,7 +89,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
const [clearProviderSecret, setClearProviderSecret] = useState(false);
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
const [form] = Form.useForm();
const modalBodyRef = useRef<HTMLDivElement>(null);
@@ -107,7 +107,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const watchedType = Form.useWatch('type', form);
const watchedPresetKey = Form.useWatch('presetKey', form);
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
const watchedApiKeyInput = Form.useWatch('apiKey', form);
const loadConfig = useCallback(async () => {
try {
@@ -149,7 +148,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
setEditingProvider(session.editingProvider as AIProviderConfig | null);
setIsEditing(session.isEditing);
setTestStatus(session.testStatus);
setClearProviderSecret(session.clearProviderSecret);
setPrimaryPasswordVisible(false);
form.resetFields();
if (session.formValues) {
form.setFieldsValue(session.formValues);
@@ -182,24 +181,32 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
}));
};
const handleEditProvider = (p: AIProviderConfig) => {
// 尝试根据 baseUrl 和 type 推断 preset
const matchedPreset = matchProviderPreset(p);
const resolvedTransport = resolvePresetTransport({
presetBackendType: matchedPreset.backendType,
presetFixedApiFormat: matchedPreset.fixedApiFormat,
valuesApiFormat: p.apiFormat,
});
applyProviderEditorSession(buildEditProviderEditorSession({
provider: { ...p, presetKey: matchedPreset.key } as any,
formValues: {
...p,
type: resolvedTransport.type,
models: p.models || [],
presetKey: matchedPreset.key,
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
},
}));
const handleEditProvider = async (p: AIProviderConfig) => {
try {
const Service = (window as any).go?.aiservice?.Service;
const editableProvider = typeof Service?.AIGetEditableProvider === 'function'
? await Service.AIGetEditableProvider(p.id)
: p;
// 尝试根据 baseUrl 和 type 推断 preset
const matchedPreset = matchProviderPreset(editableProvider);
const resolvedTransport = resolvePresetTransport({
presetBackendType: matchedPreset.backendType,
presetFixedApiFormat: matchedPreset.fixedApiFormat,
valuesApiFormat: editableProvider.apiFormat,
});
applyProviderEditorSession(buildEditProviderEditorSession({
provider: { ...editableProvider, presetKey: matchedPreset.key } as any,
formValues: {
...editableProvider,
type: resolvedTransport.type,
models: editableProvider.models || [],
presetKey: matchedPreset.key,
apiFormat: resolvedTransport.apiFormat || editableProvider.apiFormat || 'openai',
},
}));
} catch (e: any) {
void messageApi.error(e?.message || '读取供应商配置失败');
}
};
const handleDeleteProvider = async (id: string) => {
@@ -254,9 +261,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
valuesApiFormat: values.apiFormat,
});
const secretDraft = resolveProviderSecretDraft({
hasSecret: editingProvider?.hasSecret,
apiKeyInput: values.apiKey,
clearSecret: clearProviderSecret,
});
const payload = {
...editingProvider,
@@ -330,12 +335,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
valuesApiFormat: values.apiFormat,
});
const secretDraft = resolveProviderSecretDraft({
hasSecret: editingProvider?.hasSecret,
apiKeyInput: values.apiKey,
clearSecret: clearProviderSecret,
});
if (secretDraft.mode === 'clear') {
throw new Error('测试连接前请填写新的 API Key,或取消清除已保存密钥');
throw new Error('测试连接前请填写 API Key');
}
const res = await Service?.AITestProvider?.({
...editingProvider,
@@ -544,25 +547,15 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
<div style={fieldLabelStyle}>
<KeyOutlined style={{ fontSize: 14 }} /> &
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || clearProviderSecret || editingProvider?.hasSecret) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: editingProvider?.hasSecret ? 8 : 16 }}>
<Input.Password placeholder={editingProvider?.hasSecret ? '留空表示继续沿用已保存密钥' : 'sk-... / 你的 API Key'}
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || editingProvider?.id) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: 16 }}>
<Input.Password placeholder="sk-... / 你的 API Key"
size="middle"
visibilityToggle={{
visible: primaryPasswordVisible,
onVisibleChange: setPrimaryPasswordVisible,
}}
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
{editingProvider?.hasSecret && (
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: cardBg }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
API Key沿
</div>
<Checkbox
checked={clearProviderSecret}
disabled={String(watchedApiKeyInput || '').trim() !== ''}
onChange={(event) => setClearProviderSecret(event.target.checked)}
>
API Key
</Checkbox>
</div>
)}
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8');
describe('ConnectionModal edit password behavior', () => {
it('keeps the prefilled primary password masked by default', () => {
expect(source).toContain('const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);');
expect(source).not.toContain('setPrimaryPasswordVisible(String(config.password || "").trim() !== "")');
expect(source).toContain('visible: primaryPasswordVisible,');
});
it('does not render the primary-password clear helper block anymore', () => {
expect(source).not.toContain('description:\n "当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。"');
expect(source).not.toContain('description:\n "当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。"');
expect(source).toContain('String(config.password || "") === ""');
});
});

View File

@@ -193,6 +193,37 @@ const createEmptyConnectionSecretClearState =
opaqueDSN: false,
});
const resolveInitialSecretFieldValue = (
initialValues: SavedConnection | null | undefined,
fieldName: string,
): string => {
if (!initialValues) {
return "";
}
const config = initialValues.config || ({} as ConnectionConfig);
switch (fieldName) {
case "password":
return String(config.password || "");
case "sshPassword":
return String(config.ssh?.password || "");
case "proxyPassword":
return String(config.proxy?.password || "");
case "httpTunnelPassword":
return String(config.httpTunnel?.password || "");
case "mysqlReplicaPassword":
return String(config.mysqlReplicaPassword || "");
case "mongoReplicaPassword":
return String(config.mongoReplicaPassword || "");
case "uri":
return String(config.uri || "");
case "dsn":
return String(config.dsn || "");
default:
return "";
}
};
const getDefaultPortByType = (type: string) => {
switch (type) {
case "jvm":
@@ -475,6 +506,7 @@ const ConnectionModal: React.FC<{
const [clearSecrets, setClearSecrets] = useState<ConnectionSecretClearState>(
createEmptyConnectionSecretClearState,
);
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
const testInFlightRef = useRef(false);
const testTimerRef = useRef<number | null>(null);
const addConnection = useStore((state) => state.addConnection);
@@ -649,7 +681,16 @@ const ConnectionModal: React.FC<{
>
{({ getFieldValue }) => {
const draftValue = getFieldValue(fieldName);
const hasDraftValue = String(draftValue ?? "") !== "";
const initialSecretValue = resolveInitialSecretFieldValue(
initialValues,
fieldName,
);
const normalizedDraftValue = String(draftValue ?? "");
const matchesInitialSecret =
initialSecretValue !== "" &&
normalizedDraftValue === initialSecretValue;
const hasDraftValue =
normalizedDraftValue !== "" && !matchesInitialSecret;
const cardBorder = darkMode
? "1px solid rgba(255,255,255,0.12)"
: "1px solid rgba(16,24,40,0.08)";
@@ -684,6 +725,9 @@ const ConnectionModal: React.FC<{
disabled={hasDraftValue}
onChange={(event) => {
const checked = event.target.checked;
if (checked && matchesInitialSecret) {
form.setFieldValue(fieldName, "");
}
setClearSecrets((prev) => ({ ...prev, [clearKey]: checked }));
}}
>
@@ -2436,6 +2480,7 @@ const ConnectionModal: React.FC<{
setCustomIconType(undefined);
setCustomIconColor(undefined);
setClearSecrets(createEmptyConnectionSecretClearState());
setPrimaryPasswordVisible(false);
setTypeSelectWarning(null);
setDriverStatusLoaded(false);
void refreshDriverStatus();
@@ -2658,6 +2703,7 @@ const ConnectionModal: React.FC<{
? config.jvm?.jmx?.password || ""
: "",
});
setPrimaryPasswordVisible(false);
setUseSSL(!!config.useSSL);
setCustomIconType(initialValues.iconType);
setCustomIconColor(initialValues.iconColor);
@@ -2693,6 +2739,7 @@ const ConnectionModal: React.FC<{
setActiveGroup(0);
setActiveConfigSection("basic");
setActiveNetworkConfig("ssl");
setPrimaryPasswordVisible(false);
}
}
}, [open, initialValues]);
@@ -2712,7 +2759,10 @@ const ConnectionModal: React.FC<{
const primaryDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasPrimaryPassword,
valueInput: config.password,
clearSecret: clearSecrets.primaryPassword,
clearSecret:
clearSecrets.primaryPassword ||
(initialValues?.hasPrimaryPassword === true &&
String(config.password || "") === ""),
forceClear: values.type === "mongodb" && values.savePassword === false,
});
const sshDraft = resolveConnectionSecretDraft({
@@ -5504,6 +5554,10 @@ const ConnectionModal: React.FC<{
<Form.Item name="password" label="密码 (可选)">
<Input.Password
{...noAutoCapInputProps}
visibilityToggle={{
visible: primaryPasswordVisible,
onVisibleChange: setPrimaryPasswordVisible,
}}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret: initialValues?.hasPrimaryPassword,
emptyPlaceholder:
@@ -5512,14 +5566,6 @@ const ConnectionModal: React.FC<{
})}
/>
</Form.Item>
{renderStoredSecretControls({
fieldName: "password",
clearKey: "primaryPassword",
hasStoredSecret: initialValues?.hasPrimaryPassword,
clearLabel: "清除已保存密码",
description:
"当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。",
})}
</>
),
})}
@@ -5586,6 +5632,10 @@ const ConnectionModal: React.FC<{
>
<Input.Password
{...noAutoCapInputProps}
visibilityToggle={{
visible: primaryPasswordVisible,
onVisibleChange: setPrimaryPasswordVisible,
}}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret:
initialValues?.hasPrimaryPassword,
@@ -5632,14 +5682,6 @@ const ConnectionModal: React.FC<{
</div>
)}
</div>
{renderStoredSecretControls({
fieldName: "password",
clearKey: "primaryPassword",
hasStoredSecret: initialValues?.hasPrimaryPassword,
clearLabel: "清除已保存密码",
description:
"当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。",
})}
{dbType === "mongodb" && (
<Form.Item
name="savePassword"

View File

@@ -25,6 +25,10 @@ const resolveDevHarnessMode = (): string => {
if (typeof window !== 'undefined' && !(window as any).go) {
const mockConnections: any[] = [];
const mockConnectionSecrets = new Map<string, any>();
const mockProviders: any[] = [];
const mockProviderSecrets = new Map<string, string>();
let mockActiveProviderId = '';
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
let mockDataRootInfo: any = {
path: 'C:/mock/.gonavi',
@@ -45,11 +49,31 @@ if (typeof window !== 'undefined' && !(window as any).go) {
const saveMockConnection = (input: any) => {
const existing = mockConnections.find((item) => item.id === input?.id);
const existingSecrets = mockConnectionSecrets.get(existing?.id || input?.id || '') || {};
const config = (input?.config && typeof input.config === 'object') ? input.config : {};
const ssh = (config.ssh && typeof config.ssh === 'object') ? config.ssh : {};
const proxy = (config.proxy && typeof config.proxy === 'object') ? config.proxy : {};
const httpTunnel = (config.httpTunnel && typeof config.httpTunnel === 'object') ? config.httpTunnel : {};
const nextId = String(input?.id || existing?.id || `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const nextSecrets = {
password: String(config.password ?? existingSecrets.password ?? ''),
sshPassword: String(ssh.password ?? existingSecrets.sshPassword ?? ''),
proxyPassword: String(proxy.password ?? existingSecrets.proxyPassword ?? ''),
httpTunnelPassword: String(httpTunnel.password ?? existingSecrets.httpTunnelPassword ?? ''),
mysqlReplicaPassword: String(config.mysqlReplicaPassword ?? existingSecrets.mysqlReplicaPassword ?? ''),
mongoReplicaPassword: String(config.mongoReplicaPassword ?? existingSecrets.mongoReplicaPassword ?? ''),
uri: String(config.uri ?? existingSecrets.uri ?? ''),
dsn: String(config.dsn ?? existingSecrets.dsn ?? ''),
};
if (input?.clearPrimaryPassword) nextSecrets.password = '';
if (input?.clearSSHPassword) nextSecrets.sshPassword = '';
if (input?.clearProxyPassword) nextSecrets.proxyPassword = '';
if (input?.clearHttpTunnelPassword) nextSecrets.httpTunnelPassword = '';
if (input?.clearMySQLReplicaPassword) nextSecrets.mysqlReplicaPassword = '';
if (input?.clearMongoReplicaPassword) nextSecrets.mongoReplicaPassword = '';
if (input?.clearOpaqueURI) nextSecrets.uri = '';
if (input?.clearOpaqueDSN) nextSecrets.dsn = '';
mockConnectionSecrets.set(nextId, nextSecrets);
const view = {
id: nextId,
name: String(input?.name || existing?.name || '未命名连接'),
@@ -93,12 +117,63 @@ if (typeof window !== 'undefined' && !(window as any).go) {
return cloneBrowserMockValue(mockGlobalProxy);
};
const saveMockProvider = (input: any) => {
const existing = mockProviders.find((item) => item.id === input?.id);
const nextId = String(input?.id || existing?.id || `provider-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const apiKey = String(input?.apiKey ?? '');
if (apiKey !== '') {
mockProviderSecrets.set(nextId, apiKey);
} else if (input?.hasSecret === false) {
mockProviderSecrets.delete(nextId);
}
const hasSecret = mockProviderSecrets.has(nextId);
const view = {
...existing,
...input,
id: nextId,
apiKey: '',
hasSecret,
secretRef: '',
};
const index = mockProviders.findIndex((item) => item.id === nextId);
if (index >= 0) {
mockProviders[index] = view;
} else {
mockProviders.push(view);
}
if (!mockActiveProviderId) {
mockActiveProviderId = nextId;
}
return cloneBrowserMockValue(view);
};
(window as any).go = {
app: {
App: {
CheckUpdate: async () => ({ success: false }),
DownloadUpdate: async () => ({ success: false }),
GetSavedConnections: async () => cloneBrowserMockValue(mockConnections),
GetEditableSavedConnection: async (id: string) => {
const existing = mockConnections.find((item) => item.id === id);
if (!existing) {
throw new Error(`saved connection not found: ${id}`);
}
const secrets = mockConnectionSecrets.get(id) || {};
return cloneBrowserMockValue({
...existing,
config: {
...existing.config,
password: secrets.password || '',
ssh: { ...(existing.config?.ssh || {}), password: secrets.sshPassword || '' },
proxy: { ...(existing.config?.proxy || {}), password: secrets.proxyPassword || '' },
httpTunnel: { ...(existing.config?.httpTunnel || {}), password: secrets.httpTunnelPassword || '' },
mysqlReplicaPassword: secrets.mysqlReplicaPassword || '',
mongoReplicaPassword: secrets.mongoReplicaPassword || '',
uri: secrets.uri || '',
dsn: secrets.dsn || '',
},
});
},
ListInstalledFontFamilies: async () => ({ success: true, data: [] }),
SaveConnection: async (input: any) => saveMockConnection(input),
DeleteConnection: async (id: string) => {
@@ -171,6 +246,47 @@ if (typeof window !== 'undefined' && !(window as any).go) {
return { success: true, message: '数据目录已更新', data: cloneBrowserMockValue(mockDataRootInfo) };
},
}
},
aiservice: {
Service: {
AIGetProviders: async () => cloneBrowserMockValue(mockProviders),
AIGetEditableProvider: async (id: string) => {
const existing = mockProviders.find((item) => item.id === id);
if (!existing) {
throw new Error(`provider not found: ${id}`);
}
return cloneBrowserMockValue({
...existing,
apiKey: mockProviderSecrets.get(id) || '',
});
},
AISaveProvider: async (input: any) => saveMockProvider(input),
AIDeleteProvider: async (id: string) => {
const index = mockProviders.findIndex((item) => item.id === id);
if (index >= 0) {
mockProviders.splice(index, 1);
}
mockProviderSecrets.delete(id);
if (mockActiveProviderId === id) {
mockActiveProviderId = mockProviders[0]?.id || '';
}
return null;
},
AIGetActiveProvider: async () => mockActiveProviderId,
AISetActiveProvider: async (id: string) => {
mockActiveProviderId = id;
return null;
},
AIGetSafetyLevel: async () => 'readonly',
AIGetContextLevel: async () => 'schema_only',
AIGetBuiltinPrompts: async () => ({}),
AITestProvider: async (input: any) => ({
success: String(input?.apiKey || '').trim() !== '',
message: String(input?.apiKey || '').trim() !== '' ? '端点连通性测试成功!' : '连接测试失败: missing api key',
}),
AISetSafetyLevel: async () => null,
AISetContextLevel: async () => null,
},
}
};
}

View File

@@ -7,22 +7,19 @@ import {
} from './aiProviderEditorState';
describe('aiProviderEditorState', () => {
it('resets clearProviderSecret when starting add flow', () => {
it('starts add flow with idle test state', () => {
const session = buildAddProviderEditorSession({
previousClearProviderSecret: true,
presetBackendType: 'openai',
presetBaseUrl: 'https://api.openai.com/v1',
presetModel: 'gpt-4.1',
});
expect(session.clearProviderSecret).toBe(false);
expect(session.isEditing).toBe(true);
expect(session.testStatus).toBe('idle');
});
it('resets clearProviderSecret when starting edit flow', () => {
it('starts edit flow with the target provider', () => {
const session = buildEditProviderEditorSession({
previousClearProviderSecret: true,
provider: {
id: 'provider-1',
type: 'openai',
@@ -32,17 +29,13 @@ describe('aiProviderEditorState', () => {
},
});
expect(session.clearProviderSecret).toBe(false);
expect(session.isEditing).toBe(true);
expect(session.editingProvider?.id).toBe('provider-1');
});
it('resets clearProviderSecret when the modal closes', () => {
const session = buildClosedProviderEditorSession({
previousClearProviderSecret: true,
});
it('clears edit session when the modal closes', () => {
const session = buildClosedProviderEditorSession();
expect(session.clearProviderSecret).toBe(false);
expect(session.isEditing).toBe(false);
expect(session.editingProvider).toBeNull();
});

View File

@@ -8,12 +8,10 @@ export interface ProviderEditorSession {
editingProvider: ProviderEditorConfig | null;
formValues: Record<string, unknown> | null;
isEditing: boolean;
clearProviderSecret: boolean;
testStatus: ProviderEditorStatus;
}
interface BuildAddProviderEditorSessionInput {
previousClearProviderSecret?: boolean;
presetKey?: string;
presetBackendType: AIProviderType;
presetBaseUrl: string;
@@ -23,15 +21,10 @@ interface BuildAddProviderEditorSessionInput {
}
interface BuildEditProviderEditorSessionInput {
previousClearProviderSecret?: boolean;
provider: ProviderEditorConfig;
formValues?: Record<string, unknown>;
}
interface BuildClosedProviderEditorSessionInput {
previousClearProviderSecret?: boolean;
}
export const buildAddProviderEditorSession = ({
presetKey = 'openai',
presetBackendType,
@@ -61,7 +54,6 @@ export const buildAddProviderEditorSession = ({
apiFormat,
},
isEditing: true,
clearProviderSecret: false,
testStatus: 'idle',
};
};
@@ -78,15 +70,13 @@ export const buildEditProviderEditorSession = ({
apiFormat: provider.apiFormat || 'openai',
},
isEditing: true,
clearProviderSecret: false,
testStatus: 'idle',
});
export const buildClosedProviderEditorSession = (_input?: BuildClosedProviderEditorSessionInput): ProviderEditorSession => ({
export const buildClosedProviderEditorSession = (): ProviderEditorSession => ({
editingProvider: null,
formValues: null,
isEditing: false,
clearProviderSecret: false,
testStatus: 'idle',
});

View File

@@ -3,39 +3,23 @@ import { describe, expect, it } from 'vitest';
import { resolveProviderSecretDraft } from './providerSecretDraft';
describe('resolveProviderSecretDraft', () => {
it('keeps existing provider secret when edit form leaves apiKey blank', () => {
it('clears the stored provider secret when edit form leaves apiKey blank', () => {
const result = resolveProviderSecretDraft({
hasSecret: true,
apiKeyInput: '',
clearSecret: false,
});
expect(result.mode).toBe('keep');
expect(result.apiKey).toBe('');
expect(result.hasSecret).toBe(true);
});
it('replaces the provider secret when a new apiKey is entered', () => {
const result = resolveProviderSecretDraft({
hasSecret: true,
apiKeyInput: ' sk-new ',
clearSecret: false,
});
expect(result.mode).toBe('replace');
expect(result.apiKey).toBe('sk-new');
expect(result.hasSecret).toBe(true);
});
it('clears the stored provider secret when requested', () => {
const result = resolveProviderSecretDraft({
hasSecret: true,
apiKeyInput: '',
clearSecret: true,
});
expect(result.mode).toBe('clear');
expect(result.apiKey).toBe('');
expect(result.hasSecret).toBe(false);
});
it('replaces the provider secret when a new apiKey is entered', () => {
const result = resolveProviderSecretDraft({
apiKeyInput: ' sk-new ',
});
expect(result.mode).toBe('replace');
expect(result.apiKey).toBe('sk-new');
expect(result.hasSecret).toBe(true);
});
});

View File

@@ -1,9 +1,7 @@
export type ProviderSecretDraftMode = 'keep' | 'replace' | 'clear';
export type ProviderSecretDraftMode = 'replace' | 'clear';
export interface ProviderSecretDraftInput {
hasSecret?: boolean;
apiKeyInput?: string;
clearSecret?: boolean;
}
export interface ProviderSecretDraftResult {
@@ -15,14 +13,6 @@ export interface ProviderSecretDraftResult {
export function resolveProviderSecretDraft(input: ProviderSecretDraftInput): ProviderSecretDraftResult {
const apiKey = String(input.apiKeyInput || '').trim();
if (input.clearSecret) {
return {
mode: 'clear',
apiKey: '',
hasSecret: false,
};
}
if (apiKey) {
return {
mode: 'replace',
@@ -31,14 +21,6 @@ export function resolveProviderSecretDraft(input: ProviderSecretDraftInput): Pro
};
}
if (input.hasSecret) {
return {
mode: 'keep',
apiKey: '',
hasSecret: true,
};
}
return {
mode: 'clear',
apiKey: '',