diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index d46f4e8..60f06cd 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -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(' { + 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))'"); diff --git a/frontend/src/components/AIChatPanel.css b/frontend/src/components/AIChatPanel.css index 75c2f1f..f8487da 100644 --- a/frontend/src/components/AIChatPanel.css +++ b/frontend/src/components/AIChatPanel.css @@ -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; +} diff --git a/frontend/src/components/AISettingsModal.edit-password.test.tsx b/frontend/src/components/AISettingsModal.edit-password.test.tsx new file mode 100644 index 0000000..8bf7074 --- /dev/null +++ b/frontend/src/components/AISettingsModal.edit-password.test.tsx @@ -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;'); + }); +}); diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 69a24c0..c4976f3 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -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 = ({ open, onClose, darkMo const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [builtinPrompts, setBuiltinPrompts] = useState>({}); 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(null); @@ -107,7 +107,6 @@ const AISettingsModal: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ open, onClose, darkMo
认证 & 连接
- API Key} 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 }}> - API Key} 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 }}> + - {editingProvider?.hasSecret && ( -
-
- 当前已保存 API Key。留空表示继续沿用,输入新值表示替换。 -
- setClearProviderSecret(event.target.checked)} - > - 清除已保存 API Key - -
- )} {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( API Endpoint (URL)} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}> diff --git a/frontend/src/components/ConnectionModal.edit-password.test.tsx b/frontend/src/components/ConnectionModal.edit-password.test.tsx new file mode 100644 index 0000000..55b50d8 --- /dev/null +++ b/frontend/src/components/ConnectionModal.edit-password.test.tsx @@ -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 || "") === ""'); + }); +}); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 6ada63a..3f5b4a3 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -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( createEmptyConnectionSecretClearState, ); + const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false); const testInFlightRef = useRef(false); const testTimerRef = useRef(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<{ - {renderStoredSecretControls({ - fieldName: "password", - clearKey: "primaryPassword", - hasStoredSecret: initialValues?.hasPrimaryPassword, - clearLabel: "清除已保存密码", - description: - "当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。", - })} ), })} @@ -5586,6 +5632,10 @@ const ConnectionModal: React.FC<{ > )} - {renderStoredSecretControls({ - fieldName: "password", - clearKey: "primaryPassword", - hasStoredSecret: initialValues?.hasPrimaryPassword, - clearLabel: "清除已保存密码", - description: - "当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。", - })} {dbType === "mongodb" && ( { if (typeof window !== 'undefined' && !(window as any).go) { const mockConnections: any[] = []; + const mockConnectionSecrets = new Map(); + const mockProviders: any[] = []; + const mockProviderSecrets = new Map(); + 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, + }, } }; } diff --git a/frontend/src/utils/aiProviderEditorState.test.ts b/frontend/src/utils/aiProviderEditorState.test.ts index f869d4b..3796f4c 100644 --- a/frontend/src/utils/aiProviderEditorState.test.ts +++ b/frontend/src/utils/aiProviderEditorState.test.ts @@ -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(); }); diff --git a/frontend/src/utils/aiProviderEditorState.ts b/frontend/src/utils/aiProviderEditorState.ts index 6ce5e0f..a1e56f7 100644 --- a/frontend/src/utils/aiProviderEditorState.ts +++ b/frontend/src/utils/aiProviderEditorState.ts @@ -8,12 +8,10 @@ export interface ProviderEditorSession { editingProvider: ProviderEditorConfig | null; formValues: Record | 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; } -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', }); diff --git a/frontend/src/utils/providerSecretDraft.test.ts b/frontend/src/utils/providerSecretDraft.test.ts index 09d652a..48d93bd 100644 --- a/frontend/src/utils/providerSecretDraft.test.ts +++ b/frontend/src/utils/providerSecretDraft.test.ts @@ -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); + }); }); diff --git a/frontend/src/utils/providerSecretDraft.ts b/frontend/src/utils/providerSecretDraft.ts index be0ce45..5e077a6 100644 --- a/frontend/src/utils/providerSecretDraft.ts +++ b/frontend/src/utils/providerSecretDraft.ts @@ -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: '', diff --git a/frontend/wailsjs/go/aiservice/Service.d.ts b/frontend/wailsjs/go/aiservice/Service.d.ts index 54c462d..4b6342c 100755 --- a/frontend/wailsjs/go/aiservice/Service.d.ts +++ b/frontend/wailsjs/go/aiservice/Service.d.ts @@ -20,6 +20,8 @@ export function AIGetBuiltinPrompts():Promise>; export function AIGetContextLevel():Promise; +export function AIGetEditableProvider(arg1:string):Promise; + export function AIGetProviders():Promise>; export function AIGetSafetyLevel():Promise; diff --git a/frontend/wailsjs/go/aiservice/Service.js b/frontend/wailsjs/go/aiservice/Service.js index 42100f1..c8e0dce 100755 --- a/frontend/wailsjs/go/aiservice/Service.js +++ b/frontend/wailsjs/go/aiservice/Service.js @@ -38,6 +38,10 @@ export function AIGetContextLevel() { return window['go']['aiservice']['Service']['AIGetContextLevel'](); } +export function AIGetEditableProvider(arg1) { + return window['go']['aiservice']['Service']['AIGetEditableProvider'](arg1); +} + export function AIGetProviders() { return window['go']['aiservice']['Service']['AIGetProviders'](); } diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 423b9e6..e51bfd3 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -108,6 +108,8 @@ export function GetDriverVersionList(arg1:string,arg2:string):Promise; +export function GetEditableSavedConnection(arg1:string):Promise; + export function GetGlobalProxyConfig():Promise; export function GetSavedConnections():Promise>; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index a127c5d..5befaf7 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -206,6 +206,10 @@ export function GetDriverVersionPackageSize(arg1, arg2) { return window['go']['app']['App']['GetDriverVersionPackageSize'](arg1, arg2); } +export function GetEditableSavedConnection(arg1) { + return window['go']['app']['App']['GetEditableSavedConnection'](arg1); +} + export function GetGlobalProxyConfig() { return window['go']['app']['App']['GetGlobalProxyConfig'](); } diff --git a/internal/ai/service/provider_secret_test.go b/internal/ai/service/provider_secret_test.go index 8c0d6d2..ea8696b 100644 --- a/internal/ai/service/provider_secret_test.go +++ b/internal/ai/service/provider_secret_test.go @@ -319,6 +319,44 @@ func TestAISaveProviderPersistsSecretlessConfigAndReturnsSecretlessView(t *testi } } +func TestAIGetEditableProviderReturnsResolvedSecretsForEdit(t *testing.T) { + service := NewServiceWithSecretStore(failOnUseSecretStore{}) + service.configDir = t.TempDir() + + if err := service.AISaveProvider(ai.ProviderConfig{ + ID: "openai-main", + Type: "openai", + Name: "OpenAI", + APIKey: "sk-test", + BaseURL: "https://api.openai.com/v1", + Headers: map[string]string{ + "Authorization": "Bearer test", + "X-Team": "db", + }, + }); err != nil { + t.Fatalf("AISaveProvider returned error: %v", err) + } + + providers := service.AIGetProviders() + if len(providers) != 1 { + t.Fatalf("expected 1 provider, got %d", len(providers)) + } + if providers[0].APIKey != "" { + t.Fatalf("expected provider list to stay secretless, got %q", providers[0].APIKey) + } + + editable, err := service.AIGetEditableProvider("openai-main") + if err != nil { + t.Fatalf("AIGetEditableProvider returned error: %v", err) + } + if editable.APIKey != "sk-test" { + t.Fatalf("expected editable provider to restore apiKey, got %q", editable.APIKey) + } + if editable.Headers["Authorization"] != "Bearer test" { + t.Fatalf("expected editable provider to restore sensitive header, got %#v", editable.Headers) + } +} + func TestAISaveProviderKeepsExistingSecretWhenInputOmitsAPIKey(t *testing.T) { service := NewServiceWithSecretStore(failOnUseSecretStore{}) service.configDir = t.TempDir() diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index e85f962..fb157f8 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -144,6 +144,25 @@ func (s *Service) AIGetProviders() []ai.ProviderConfig { return result } +// AIGetEditableProvider 获取用于编辑的 Provider 配置,包含已解析的 secret +func (s *Service) AIGetEditableProvider(id string) (ai.ProviderConfig, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, providerConfig := range s.providers { + if providerConfig.ID != id { + continue + } + resolved, err := s.resolveProviderConfigSecrets(providerConfig) + if err != nil { + return ai.ProviderConfig{}, fmt.Errorf("读取 Provider secret 失败: %w", err) + } + return resolved, nil + } + + return ai.ProviderConfig{}, fmt.Errorf("provider not found: %s", id) +} + // AISaveProvider 保存/更新 Provider 配置 func (s *Service) AISaveProvider(config ai.ProviderConfig) error { s.mu.Lock() diff --git a/internal/app/methods_saved_connections.go b/internal/app/methods_saved_connections.go index 75fc060..0e31ed4 100644 --- a/internal/app/methods_saved_connections.go +++ b/internal/app/methods_saved_connections.go @@ -18,6 +18,19 @@ func (a *App) GetSavedConnections() ([]connection.SavedConnectionView, error) { return sanitizeSavedConnectionViews(items), nil } +func (a *App) GetEditableSavedConnection(id string) (connection.SavedConnectionView, error) { + view, err := a.savedConnectionRepository().Find(id) + if err != nil { + return connection.SavedConnectionView{}, err + } + resolvedConfig, err := a.resolveConnectionSecrets(view.Config) + if err != nil { + return connection.SavedConnectionView{}, err + } + view.Config = resolvedConfig + return view, nil +} + func (a *App) SaveConnection(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) { view, err := a.savedConnectionRepository().Save(input) if err != nil { diff --git a/internal/app/methods_saved_connections_test.go b/internal/app/methods_saved_connections_test.go index 840a3d2..849891e 100644 --- a/internal/app/methods_saved_connections_test.go +++ b/internal/app/methods_saved_connections_test.go @@ -54,6 +54,61 @@ func TestSaveConnectionMethodReturnsSecretlessView(t *testing.T) { } } +func TestGetEditableSavedConnectionReturnsResolvedSecretsForEdit(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + if _, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "conn-edit", + Name: "Editable", + Config: connection.ConnectionConfig{ + ID: "conn-edit", + Type: "mysql", + Host: "db.local", + Port: 3306, + User: "root", + Password: "mysql-secret", + UseSSH: true, + SSH: connection.SSHConfig{ + Host: "jump.local", + Port: 22, + User: "ops", + Password: "ssh-secret", + }, + }, + }); err != nil { + t.Fatal(err) + } + + view, err := app.GetEditableSavedConnection("conn-edit") + if err != nil { + t.Fatal(err) + } + if view.Config.Password != "mysql-secret" { + t.Fatalf("expected editable primary password, got %q", view.Config.Password) + } + if view.Config.SSH.Password != "ssh-secret" { + t.Fatalf("expected editable SSH password, got %q", view.Config.SSH.Password) + } + if !view.HasPrimaryPassword || !view.HasSSHPassword { + t.Fatalf("expected secret flags to stay true, got %#v", view) + } + + saved, err := app.GetSavedConnections() + if err != nil { + t.Fatal(err) + } + if len(saved) != 1 { + t.Fatalf("expected one saved connection, got %d", len(saved)) + } + if saved[0].Config.Password != "" { + t.Fatalf("expected saved connection list to remain secretless, got %q", saved[0].Config.Password) + } + if saved[0].Config.SSH.Password != "" { + t.Fatalf("expected saved connection list SSH password to remain secretless, got %q", saved[0].Config.SSH.Password) + } +} + func TestSaveConnectionOnDarwinPersistsSecretsInlineButReturnsSecretlessView(t *testing.T) { app := NewAppWithSecretStore(failOnUseSecretStore{}) app.configDir = t.TempDir()