diff --git a/src/main/ai-provider-key-store.ts b/src/main/ai-provider-key-store.ts new file mode 100644 index 0000000..eca109c --- /dev/null +++ b/src/main/ai-provider-key-store.ts @@ -0,0 +1,79 @@ +import { app, safeStorage } from 'electron' +import fs from 'fs-extra' +import path from 'path' + +interface StoredKeys { + version: 1 + keys: Record +} + +export class AIProviderKeyStore { + private get filePath(): string { + return path.join(app.getPath('userData'), 'ai-provider-keys.bin') + } + + get(providerId: string): { success: boolean; key?: string; error?: string; available: boolean } { + const result = this.read() + return { ...result, key: result.data?.keys[providerId] } + } + + save(providerId: string, key: string): { success: boolean; error?: string } { + if (!safeStorage.isEncryptionAvailable()) { + return { success: false, error: '系统安全存储不可用' } + } + const current = this.read() + if (!current.success) return { success: false, error: current.error } + const data = current.data || { version: 1 as const, keys: {} } + data.keys[providerId] = key + return this.write(data) + } + + clear(providerId: string): { success: boolean; error?: string } { + const current = this.read() + if (!current.success) return { success: false, error: current.error } + if (!current.data?.keys[providerId]) return { success: true } + delete current.data.keys[providerId] + try { + if (Object.keys(current.data.keys).length === 0) fs.removeSync(this.filePath) + else return this.write(current.data) + return { success: true } + } catch { + return { success: false, error: '无法清除 AI Provider 密钥' } + } + } + + private read(): { + success: boolean + data?: StoredKeys + error?: string + available: boolean + } { + const available = safeStorage.isEncryptionAvailable() + if (!fs.existsSync(this.filePath)) { + return { success: true, data: { version: 1, keys: {} }, available } + } + if (!available) return { success: false, error: '系统安全存储不可用', available } + try { + const data = JSON.parse( + safeStorage.decryptString(fs.readFileSync(this.filePath)) + ) as StoredKeys + if (data.version !== 1 || !data.keys) throw new Error('invalid AI key store') + return { success: true, data, available } + } catch { + return { success: false, error: 'AI Provider 安全存储不可读取', available } + } + } + + private write(data: StoredKeys): { success: boolean; error?: string } { + try { + fs.ensureDirSync(path.dirname(this.filePath)) + fs.writeFileSync(this.filePath, safeStorage.encryptString(JSON.stringify(data)), { + mode: 0o600 + }) + fs.chmodSync(this.filePath, 0o600) + return { success: true } + } catch { + return { success: false, error: 'AI Provider 密钥保存失败' } + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 7bbee53..8a9d1f9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,8 +27,10 @@ import { } from './report-history-service' import type { GroupReportExportRequest } from '../shared/group-report' import type { SaveGeneratedReportRequest } from '../shared/report-history' +import type { AIChatRequestOptions, AIProviderConfig, LegacyAIConfig } from '../shared/ai-provider' import { DatabaseKeyStore } from './database-key-store' import { ImageKeyConfigService } from './services/image-key-config-service' +import { AIProviderService } from './services/ai-provider-service' import { KeyServiceMac } from './key-service-mac' import { KeyService as KeyServiceWin } from './key-service-win' import * as chat from './services/chat-service' @@ -63,6 +65,7 @@ let imageDecryptService: ImageDecryptService | null = null let stickerService: StickerService | null = null const databaseKeyStore = new DatabaseKeyStore() const imageKeyConfigService = new ImageKeyConfigService() +const aiProviderService = new AIProviderService() const keyServiceMac = new KeyServiceMac() const keyServiceWin = new KeyServiceWin() let tray: Tray | null = null @@ -353,52 +356,24 @@ app.whenReady().then(async () => { ipcMain.handle( 'ai:chat', - async ( - _, - messages: { role: string; content: string }[], - options?: { apiKey?: string; model?: string; baseURL?: string } - ) => { - // @ts-ignore: vite env - const apiKey = options?.apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY - const model = options?.model || import.meta.env.VITE_AI_MODEL || 'deepseek-chat' - const baseURL = - options?.baseURL || import.meta.env.VITE_AI_BASE_URL || 'https://api.deepseek.com' + async (_, messages: { role: string; content: string }[], options?: AIChatRequestOptions) => + aiProviderService.chat(messages, options) + ) - if (!apiKey) { - return { success: false, error: '未配置 API Key' } - } - - // 鍔ㄦ€佸鍏ヤ互閬垮厤濡傛灉鏈畨瑁呮垨鍒濆绫诲瀷缂哄け鐨勯棶棰? - const { OpenAI } = await import('openai') - - const openai = new OpenAI({ - baseURL, - apiKey - }) - try { - const completion = await openai.chat.completions.create({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - messages: messages as any, - model: model - }) - return { - success: true, - data: completion.choices[0].message.content, - usage: completion.usage - ? { - input: completion.usage.prompt_tokens, - output: completion.usage.completion_tokens, - total: completion.usage.total_tokens, - estimated: false - } - : undefined - } - } catch (error: unknown) { - console.error('AI API Error:', error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - return { success: false, error: errorMessage } - } - } + ipcMain.handle('ai:listProviders', () => aiProviderService.list()) + ipcMain.handle('ai:getRuntimeConfig', () => aiProviderService.getRuntimeConfig()) + ipcMain.handle('ai:saveProvider', (_, provider: AIProviderConfig) => + aiProviderService.save(provider) + ) + ipcMain.handle('ai:deleteProvider', (_, providerId: string) => + aiProviderService.delete(providerId) + ) + ipcMain.handle('ai:setDefaultProvider', (_, providerId: string) => + aiProviderService.setDefault(providerId) + ) + ipcMain.handle('ai:testProvider', (_, providerId: string) => aiProviderService.test(providerId)) + ipcMain.handle('ai:migrateLegacy', (_, config: LegacyAIConfig) => + aiProviderService.migrateLegacy(config) ) ipcMain.handle('copy-image', async (_, base64String) => { diff --git a/src/main/services/ai-provider-service.ts b/src/main/services/ai-provider-service.ts new file mode 100644 index 0000000..70d86f3 --- /dev/null +++ b/src/main/services/ai-provider-service.ts @@ -0,0 +1,442 @@ +import { app } from 'electron' +import fs from 'fs-extra' +import path from 'path' +import type { + AIChatRequestOptions, + AIConnectionTestResult, + AIProviderConfig, + AIProviderListResult, + AIProviderSummary, + AIRuntimeModelConfig, + LegacyAIConfig +} from '../../shared/ai-provider' +import { AIProviderKeyStore } from '../ai-provider-key-store' + +interface AIProviderMetadataFile { + version: 1 + defaultProviderId?: string + providers: Array> +} + +type AIMessage = { role: string; content: string } +type AIRequestResult = { + data: string + usage?: { input?: number; output?: number; total?: number; estimated?: boolean } +} +interface OpenAIResponsePayload { + error?: { message?: string } + choices?: Array<{ message?: { content?: string } }> + usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } +} +interface AnthropicResponsePayload { + error?: { message?: string } + content?: Array<{ type?: string; text?: string }> + usage?: { input_tokens?: number; output_tokens?: number } +} + +export class AIProviderService { + constructor(private readonly keyStore = new AIProviderKeyStore()) {} + + list(): AIProviderListResult { + this.ensureEnvironmentMigration() + try { + const data = this.readMetadata() + return { + success: true, + defaultProviderId: data.defaultProviderId, + providers: data.providers.map((provider) => + this.toSummary(provider, data.defaultProviderId) + ) + } + } catch { + return { success: false, providers: [], error: 'AI Provider 配置无法读取' } + } + } + + getRuntimeConfig(): AIRuntimeModelConfig { + const result = this.list() + const provider = + result.providers.find((item) => item.id === result.defaultProviderId) || result.providers[0] + const model = provider?.models.find((item) => item.id === provider.defaultModel) + return { + providerId: provider?.id, + providerName: provider?.name || '尚未配置', + model: provider?.defaultModel || '', + modelName: model?.name || provider?.defaultModel || '尚未选择模型', + configured: Boolean( + provider && provider.models.length && (provider.hasApiKey || !needsApiKey(provider)) + ), + status: provider?.status || 'untested' + } + } + + save(input: AIProviderConfig): AIProviderListResult { + const validationError = validateProvider(input) + if (validationError) return { success: false, providers: [], error: validationError } + const data = this.readMetadata() + const existing = data.providers.find((provider) => provider.id === input.id) + if (input.apiKey?.trim()) { + const saved = this.keyStore.save(input.id, input.apiKey.trim()) + if (!saved.success) return { success: false, providers: [], error: saved.error } + } else if (needsApiKey(input) && !this.keyStore.get(input.id).key) { + return { success: false, providers: [], error: '请填写 API Key' } + } + + const metadata: Omit = { + id: input.id, + name: input.name.trim(), + type: input.type, + baseUrl: input.baseUrl.trim().replace(/\/+$/, ''), + auth: input.auth, + models: input.models, + defaultModel: input.defaultModel, + advanced: input.advanced, + status: existing?.status || 'untested', + lastTestedAt: existing?.lastTestedAt, + lastError: existing?.lastError + } + const index = data.providers.findIndex((provider) => provider.id === input.id) + if (index >= 0) data.providers[index] = metadata + else data.providers.push(metadata) + if (!data.defaultProviderId) data.defaultProviderId = input.id + this.writeMetadata(data) + return this.list() + } + + delete(providerId: string): AIProviderListResult { + const data = this.readMetadata() + data.providers = data.providers.filter((provider) => provider.id !== providerId) + if (data.defaultProviderId === providerId) data.defaultProviderId = data.providers[0]?.id + const cleared = this.keyStore.clear(providerId) + if (!cleared.success) return { success: false, providers: [], error: cleared.error } + this.writeMetadata(data) + return this.list() + } + + setDefault(providerId: string): AIProviderListResult { + const data = this.readMetadata() + if (!data.providers.some((provider) => provider.id === providerId)) { + return { success: false, providers: [], error: '供应商不存在' } + } + data.defaultProviderId = providerId + this.writeMetadata(data) + return this.list() + } + + migrateLegacy(config: LegacyAIConfig): AIProviderListResult { + const data = this.readMetadata() + if (data.providers.length) return this.list() + const provider = deepSeekProvider(config.baseUrl, config.model) + if (config.apiKey?.trim()) { + const saved = this.keyStore.save(provider.id, config.apiKey.trim()) + if (!saved.success) return { success: false, providers: [], error: saved.error } + } + data.providers = [stripRuntimeFields(provider)] + data.defaultProviderId = provider.id + this.writeMetadata(data) + return this.list() + } + + async test(providerId: string): Promise { + const startedAt = Date.now() + try { + await this.request([{ role: 'user', content: 'Reply with OK.' }], { providerId }, true) + this.updateTestStatus(providerId, 'connected') + return { success: true, latencyMs: Date.now() - startedAt } + } catch (error) { + const message = safeAIError(error) + this.updateTestStatus(providerId, 'error', message) + return { success: false, error: message, latencyMs: Date.now() - startedAt } + } + } + + async chat( + messages: AIMessage[], + options?: AIChatRequestOptions + ): Promise<{ + success: boolean + data?: string + usage?: { input?: number; output?: number; total?: number; estimated?: boolean } + error?: string + }> { + try { + return { success: true, ...(await this.request(messages, options)) } + } catch (error) { + return { success: false, error: safeAIError(error) } + } + } + + private async request( + messages: AIMessage[], + options?: AIChatRequestOptions, + testing = false + ): Promise<{ + data: string + usage?: { input?: number; output?: number; total?: number; estimated?: boolean } + }> { + if (options?.apiKey) return this.requestLegacy(messages, options) + const list = this.list() + const provider = + list.providers.find((item) => item.id === options?.providerId) || + list.providers.find((item) => item.id === list.defaultProviderId) + if (!provider) throw new Error('尚未配置 AI Provider') + const model = options?.modelId || provider.defaultModel + const key = this.keyStore.get(provider.id).key || '' + if (needsApiKey(provider) && !key) throw new Error('当前供应商尚未配置 API Key') + return provider.type === 'anthropic-messages' + ? requestAnthropic(provider, key, model, messages, testing) + : requestOpenAICompatible(provider, key, model, messages, testing) + } + + private async requestLegacy( + messages: AIMessage[], + options: AIChatRequestOptions + ): Promise { + const provider = deepSeekProvider(options.baseURL, options.model) + return requestOpenAICompatible( + provider, + options.apiKey || '', + options.model || provider.defaultModel, + messages + ) + } + + private updateTestStatus( + providerId: string, + status: 'connected' | 'error', + lastError?: string + ): void { + const data = this.readMetadata() + const provider = data.providers.find((item) => item.id === providerId) + if (!provider) return + provider.status = status + provider.lastTestedAt = Date.now() + provider.lastError = lastError + this.writeMetadata(data) + } + + private ensureEnvironmentMigration(): void { + const data = this.readMetadata() + if (data.providers.length) return + const apiKey = String(import.meta.env.VITE_DEEPSEEK_API_KEY || '').trim() + if (!apiKey) return + this.migrateLegacy({ + apiKey, + baseUrl: String(import.meta.env.VITE_AI_BASE_URL || ''), + model: String(import.meta.env.VITE_AI_MODEL || '') + }) + } + + private toSummary( + provider: Omit, + defaultProviderId?: string + ): AIProviderSummary { + return { + ...provider, + hasApiKey: Boolean(this.keyStore.get(provider.id).key), + isDefault: provider.id === defaultProviderId + } + } + + private readMetadata(): AIProviderMetadataFile { + const filePath = this.metadataPath + if (!fs.existsSync(filePath)) return { version: 1, providers: [] } + const data = fs.readJsonSync(filePath) as AIProviderMetadataFile + if (data.version !== 1 || !Array.isArray(data.providers)) + throw new Error('invalid provider metadata') + return data + } + + private writeMetadata(data: AIProviderMetadataFile): void { + fs.ensureDirSync(path.dirname(this.metadataPath)) + fs.writeJsonSync(this.metadataPath, data, { spaces: 2 }) + } + + private get metadataPath(): string { + return path.join(app.getPath('userData'), 'ai-providers.json') + } +} + +function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary { + const modelId = model?.trim() || 'deepseek-chat' + return { + id: 'deepseek', + name: 'DeepSeek', + type: 'openai-compatible', + baseUrl: baseUrl?.trim() || 'https://api.deepseek.com', + auth: { type: 'bearer' }, + models: [ + { + name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId, + id: modelId, + capabilities: { chat: true, vision: false, longContext: true } + } + ], + defaultModel: modelId, + advanced: { timeoutMs: 120_000, temperature: 0.7, maxTokens: 4096, extraHeaders: {} }, + hasApiKey: false, + isDefault: true, + status: 'untested' + } +} + +function stripRuntimeFields( + provider: AIProviderSummary +): Omit { + return { + id: provider.id, + name: provider.name, + type: provider.type, + baseUrl: provider.baseUrl, + auth: provider.auth, + models: provider.models, + defaultModel: provider.defaultModel, + advanced: provider.advanced, + status: provider.status, + lastTestedAt: provider.lastTestedAt, + lastError: provider.lastError + } +} + +function needsApiKey(provider: Pick): boolean { + return provider.type !== 'ollama' && provider.auth.type !== 'none' +} + +function validateProvider(provider: AIProviderConfig): string | undefined { + if (!provider.id.trim() || !/^[a-z0-9][a-z0-9-_]*$/i.test(provider.id)) + return '供应商 ID 格式不正确' + if (!provider.name.trim()) return '供应商名称不能为空' + if (!provider.baseUrl.trim()) return 'Base URL 不能为空' + if (!provider.models.length) return '请至少添加一个模型' + if (provider.models.some((model) => !model.name.trim() || !model.id.trim())) + return '模型名称和 ID 不能为空' + if (!provider.models.some((model) => model.id === provider.defaultModel)) + return '默认模型不在模型列表中' + if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim()) + return '请填写自定义认证字段' + return undefined +} + +function buildHeaders(provider: AIProviderSummary, apiKey: string): Record { + const headers: Record = { + 'content-type': 'application/json', + ...provider.advanced.extraHeaders + } + if (!apiKey || provider.auth.type === 'none') return headers + if (provider.auth.type === 'bearer') headers.authorization = `Bearer ${apiKey}` + else if (provider.auth.type === 'x-api-key') headers['x-api-key'] = apiKey + else headers[provider.auth.headerName || 'authorization'] = apiKey + return headers +} + +async function requestOpenAICompatible( + provider: AIProviderSummary, + apiKey: string, + model: string, + messages: AIMessage[], + testing = false +): Promise { + const endpoint = provider.baseUrl.endsWith('/chat/completions') + ? provider.baseUrl + : `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions` + const response = await fetchWithTimeout( + endpoint, + { + method: 'POST', + headers: buildHeaders(provider, apiKey), + body: JSON.stringify({ + model, + messages, + temperature: provider.advanced.temperature, + max_tokens: testing ? 8 : provider.advanced.maxTokens + }) + }, + provider.advanced.timeoutMs + ) + const payload = (await response.json()) as OpenAIResponsePayload + if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`) + return { + data: String(payload.choices?.[0]?.message?.content || ''), + usage: payload.usage + ? { + input: payload.usage.prompt_tokens, + output: payload.usage.completion_tokens, + total: payload.usage.total_tokens, + estimated: false + } + : undefined + } +} + +async function requestAnthropic( + provider: AIProviderSummary, + apiKey: string, + model: string, + messages: AIMessage[], + testing = false +): Promise { + const system = messages + .filter((message) => message.role === 'system') + .map((message) => message.content) + .join('\n\n') + const anthropicMessages = messages.filter((message) => message.role !== 'system') + const headers = buildHeaders(provider, apiKey) + if (!headers['anthropic-version']) headers['anthropic-version'] = '2023-06-01' + const endpoint = provider.baseUrl.endsWith('/messages') + ? provider.baseUrl + : `${provider.baseUrl.replace(/\/+$/, '')}/messages` + const response = await fetchWithTimeout( + endpoint, + { + method: 'POST', + headers, + body: JSON.stringify({ + model, + system: system || undefined, + messages: anthropicMessages, + temperature: provider.advanced.temperature, + max_tokens: testing ? 8 : provider.advanced.maxTokens || 4096 + }) + }, + provider.advanced.timeoutMs + ) + const payload = (await response.json()) as AnthropicResponsePayload + if (!response.ok) + throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`) + return { + data: Array.isArray(payload.content) + ? payload.content + .filter((item) => item.type === 'text') + .map((item) => item.text || '') + .join('\n') + : '', + usage: payload.usage + ? { + input: payload.usage.input_tokens, + output: payload.usage.output_tokens, + total: Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0), + estimated: false + } + : undefined + } +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs || 120_000)) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +function safeAIError(error: unknown): string { + if (error instanceof DOMException && error.name === 'AbortError') return 'AI 请求超时' + const message = error instanceof Error ? error.message : String(error) + return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300) +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 09dbe91..4614298 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -20,6 +20,14 @@ import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption' +import type { + AIChatRequestOptions, + AIConnectionTestResult, + AIProviderConfig, + AIProviderListResult, + AIRuntimeModelConfig, + LegacyAIConfig +} from '../shared/ai-provider' export type ParsedContent = | { type: 'text'; content: string } @@ -82,7 +90,7 @@ declare global { search: (keyword: string) => Promise aiChat: ( messages: { role: string; content: string }[], - options?: { apiKey?: string; model?: string; baseURL?: string } + options?: AIChatRequestOptions ) => Promise<{ success: boolean data?: string @@ -94,6 +102,13 @@ declare global { } error?: string }> + listAIProviders: () => Promise + getAIRuntimeConfig: () => Promise + saveAIProvider: (provider: AIProviderConfig) => Promise + deleteAIProvider: (providerId: string) => Promise + setDefaultAIProvider: (providerId: string) => Promise + testAIProvider: (providerId: string) => Promise + migrateLegacyAIConfig: (config: LegacyAIConfig) => Promise copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }> getVoiceData: ( sessionId: string, diff --git a/src/preload/index.ts b/src/preload/index.ts index e865cf4..8aa1e8a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import type { GroupReportExportRequest } from '../shared/group-report' import type { SaveGeneratedReportRequest } from '../shared/report-history' +import type { AIChatRequestOptions, AIProviderConfig, LegacyAIConfig } from '../shared/ai-provider' // 渲染器的自定义 API const api = { @@ -19,10 +20,16 @@ const api = { ) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options), getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5), search: (keyword: string) => ipcRenderer.invoke('db:search', keyword), - aiChat: ( - messages: { role: string; content: string }[], - options?: { apiKey?: string; model?: string; baseURL?: string } - ) => ipcRenderer.invoke('ai:chat', messages, options), + aiChat: (messages: { role: string; content: string }[], options?: AIChatRequestOptions) => + ipcRenderer.invoke('ai:chat', messages, options), + listAIProviders: () => ipcRenderer.invoke('ai:listProviders'), + getAIRuntimeConfig: () => ipcRenderer.invoke('ai:getRuntimeConfig'), + saveAIProvider: (provider: AIProviderConfig) => ipcRenderer.invoke('ai:saveProvider', provider), + deleteAIProvider: (providerId: string) => ipcRenderer.invoke('ai:deleteProvider', providerId), + setDefaultAIProvider: (providerId: string) => + ipcRenderer.invoke('ai:setDefaultProvider', providerId), + testAIProvider: (providerId: string) => ipcRenderer.invoke('ai:testProvider', providerId), + migrateLegacyAIConfig: (config: LegacyAIConfig) => ipcRenderer.invoke('ai:migrateLegacy', config), copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String), getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) => ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7076aca..ff40922 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,6 +5,7 @@ import { AppShell } from './components/layout/AppShell' import { ApiWorkspace } from './features/api-center/ApiWorkspace' import { SettingsWorkspace } from './features/settings/SettingsWorkspace' import type { SettingsCategoryId } from './features/settings/model/types' +import type { AIRuntimeModelConfig } from '../../shared/ai-provider' import { AppPage } from './components/layout/navigation' import { AiReportWorkspace } from './components/reports/AiReportWorkspace' import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar' @@ -175,11 +176,13 @@ function App(): React.ReactElement { const [reportNotice, setReportNotice] = useState('') const [summaryDateRange, setSummaryDateRange] = useState('today') const [summaryMessageTypes, setSummaryMessageTypes] = useState(['text']) - const [aiModelConfig] = useState(() => ({ - apiKey: localStorage.getItem('ai_api_key') || '', - baseURL: localStorage.getItem('ai_base_url') || 'https://api.deepseek.com', - model: localStorage.getItem('ai_model') || 'deepseek-chat' - })) + const [aiModelConfig, setAiModelConfig] = useState({ + providerName: '尚未配置', + model: '', + modelName: '尚未选择模型', + configured: false, + status: 'untested' + }) const [selfInfo, setSelfInfo] = useState(null) const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false) const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading') @@ -193,6 +196,29 @@ function App(): React.ReactElement { const timer = window.setTimeout(() => setReportNotice(''), 3200) return () => window.clearTimeout(timer) }, [reportNotice]) + React.useEffect(() => { + const loadAIConfig = async (): Promise => { + try { + const legacy = { + apiKey: localStorage.getItem('ai_api_key') || undefined, + baseUrl: localStorage.getItem('ai_base_url') || undefined, + model: localStorage.getItem('ai_model') || undefined + } + if (legacy.apiKey || legacy.baseUrl || legacy.model) { + const migrated = await window.api.migrateLegacyAIConfig(legacy) + if (migrated.success) { + localStorage.removeItem('ai_api_key') + localStorage.removeItem('ai_base_url') + localStorage.removeItem('ai_model') + } + } + setAiModelConfig(await window.api.getAIRuntimeConfig()) + } catch (error) { + console.warn('[AI Provider] 配置加载失败:', error) + } + } + void loadAIConfig() + }, []) const selectedContactMd5Ref = React.useRef('') const contactAvatarHydrationRunRef = React.useRef(0) const reportGeneration = useGroupReportGeneration({ @@ -812,6 +838,11 @@ function App(): React.ReactElement { setActivePage('settings') } + const openModelSettings = (): void => { + setSettingsCategory('ai-model') + setActivePage('settings') + } + const openReport = (reportId: string): void => { setSelectedReportId(reportId) setReportWorkspaceView('result') @@ -1060,7 +1091,7 @@ function App(): React.ReactElement { isGenerating={reportGeneration.isGenerating} onSummaryDateRangeChange={setSummaryDateRange} onSummaryMessageTypesChange={setSummaryMessageTypes} - onOpenModelSettings={openSettings} + onOpenModelSettings={openModelSettings} onGenerate={() => { reportGeneration.resetGenerationStatus() void reportGeneration.generate() @@ -1109,6 +1140,7 @@ function App(): React.ReactElement { onSelfInfoChange={setSelfInfo} onContactsChange={setContacts} onFilteredContactsChange={setFilteredContacts} + onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)} onNotice={setReportNotice} onOpenSettings={openSettings} /> diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 8155f10..1fcc525 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -3654,6 +3654,9 @@ body { .image-test-section>div:first-child strong,.image-auto-detect strong,.image-auto-unavailable strong { color:#35403b; font-size:14px; }.image-test-section>div:first-child p,.image-auto-detect p,.image-auto-unavailable p { margin:6px 0 0; color:#66706b; font-size:12px; line-height:1.6; }.image-test-section>label { display:block; margin:18px 0 8px; color:#46514c; font-size:12px; font-weight:600; }.image-test-actions { display:flex; gap:9px; margin-top:14px; }.image-test-result { display:flex; flex-wrap:wrap; gap:8px 18px; margin-top:14px; padding:12px 14px; border-radius:8px; color:#2e765d; font-size:12px; }.image-test-result.success { border:1px solid #bfded3; background:#eaf5f1; }.image-test-result.error { border:1px solid #efcaca; background:#fff2f2; color:#a84444; }.image-test-result p { width:100%; margin:2px 0 0; }.image-inline-error { margin:14px 0 0; color:#a84444; font-size:12px; } .image-auto-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:24px; }.image-auto-detect ul { display:flex; flex-wrap:wrap; gap:10px 22px; margin:17px 0 0; padding:14px 0 0; border-top:1px solid #edf0ee; list-style:none; color:#8b5c25; font-size:12px; }.image-auto-detect li::before { content:'○'; margin-right:6px; }.image-auto-detect li.ok { color:#2e8b68; }.image-auto-detect li.ok::before { content:'✓'; }.image-auto-progress { margin-top:12px!important; padding:9px 12px; border-radius:7px; background:#f1f4f3; }.image-auto-unavailable { padding-top:20px; padding-bottom:20px; }.image-key-danger>div { border-radius:9px!important; }.image-key-danger { margin-top:30px; } .image-auto-phases { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; margin:18px 0 0; padding:0; counter-reset:image-auto-phase; list-style:none; }.image-auto-phases li { position:relative; padding-top:24px; color:#929a96; text-align:center; font-size:11px; }.image-auto-phases li::before { position:absolute; top:0; left:50%; display:grid; width:18px; height:18px; place-items:center; border:1px solid #d5ddda; border-radius:50%; background:#fff; content:counter(image-auto-phase); counter-increment:image-auto-phase; transform:translateX(-50%); }.image-auto-phases li.active { color:#247a63; }.image-auto-phases li.active::before { border-color:#247a63; background:#247a63; color:#fff; }.image-auto-error { margin:12px 0 0!important; padding:10px 12px; border-left:3px solid #c85a5a; background:#fff2f2; color:#a84444!important; }.image-auto-success { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:7px 20px; align-items:center; margin-top:14px; padding:14px 16px; border:1px solid #bfded3; border-radius:8px; background:#eaf5f1; color:#2e765d; font-size:12px; }.image-auto-success strong { grid-column:1; color:#2e765d; }.image-auto-success span { grid-column:1; }.image-auto-success button { grid-column:2; grid-row:1/4; } +/* SETTINGS-04: AI provider center */ +.ai-model-content { padding-bottom:48px; }.ai-model-default { display:flex; align-items:center; justify-content:space-between; gap:20px; }.ai-model-default>div { display:grid; gap:5px; }.ai-model-default>div>span,.ai-model-default small { color:#66706b; font-size:12px; }.ai-model-default strong { color:#202724; font-size:18px; }.ai-model-page-error { padding:11px 14px; border-left:3px solid #c85a5a; background:#fff2f2; color:#a84444; font-size:12px; }.ai-provider-list { display:grid; gap:12px; }.ai-provider-card { border:1px solid #dde3e0; border-radius:10px; background:#fff; padding:19px 20px; }.ai-provider-card header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }.ai-provider-card h3 { margin:0 0 5px; color:#202724; font-size:15px; }.ai-provider-card header span { color:#66706b; font-size:12px; }.ai-provider-status { border-radius:12px; padding:4px 8px; background:#f1f3f2; }.ai-provider-status.connected { background:#eaf5f1; color:#2e8b68!important; }.ai-provider-status.error { background:#fff2f2; color:#c85a5a!important; }.ai-provider-card dl { display:grid; grid-template-columns:2fr 1fr 1fr; gap:18px; margin:18px 0; }.ai-provider-card dt { color:#929a96; font-size:11px; }.ai-provider-card dd { overflow:hidden; margin:5px 0 0; color:#3d4742; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }.ai-provider-card footer { display:flex; flex-wrap:wrap; gap:8px; padding-top:14px; border-top:1px solid #edf0ee; }.ai-provider-card button,.ai-provider-editor button { min-height:32px; border:1px solid #d5ddda; border-radius:7px; padding:0 11px; background:#fff; color:#46514c; cursor:pointer; font:600 12px inherit; }.ai-provider-card button:hover,.ai-provider-editor button:hover { border-color:#247a63; color:#247a63; }.ai-provider-card button:disabled,.ai-provider-editor button:disabled { cursor:not-allowed; opacity:.45; }.ai-provider-card button.danger,.ai-provider-editor button.danger { color:#c85a5a; }.ai-provider-error { margin:0 0 12px; color:#a84444; font-size:12px; }.ai-provider-empty { color:#66706b; text-align:center; }.ai-provider-empty p { margin:6px 0 0; font-size:12px; } +.ai-provider-editor { display:grid; gap:18px; margin-top:22px; }.ai-provider-editor>header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }.ai-provider-editor h2,.ai-provider-editor h3 { margin:0; color:#35403b; font-size:15px; }.ai-provider-editor header p { margin:5px 0 0; color:#66706b; font-size:12px; }.ai-provider-editor label { display:grid; gap:7px; color:#46514c; font-size:12px; font-weight:600; }.ai-provider-editor input:not([type=checkbox]):not([type=radio]),.ai-provider-editor select,.ai-provider-editor textarea { box-sizing:border-box; width:100%; border:1px solid #d5ddda; border-radius:7px; outline:0; padding:9px 10px; background:#f4f7f5; color:#202724; font:12px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; }.ai-provider-editor input:focus,.ai-provider-editor select:focus,.ai-provider-editor textarea:focus { border-color:#247a63; box-shadow:0 0 0 2px rgba(36,122,99,.08); }.ai-provider-form-grid,.ai-provider-advanced>div { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }.ai-provider-form-grid .wide,.ai-provider-advanced .wide { grid-column:1/-1; }.ai-model-table-heading { display:flex; align-items:center; justify-content:space-between; }.ai-model-table { display:grid; gap:9px; }.ai-model-row { display:grid; grid-template-columns:1fr 1fr repeat(3,auto) 110px auto auto; gap:8px; align-items:center; padding:11px; border:1px solid #e3e8e5; border-radius:8px; }.ai-model-row label { display:flex; align-items:center; gap:4px; white-space:nowrap; font-weight:400; }.ai-provider-advanced,.ai-provider-preview { border:1px solid #e3e8e5; border-radius:8px; }.ai-provider-advanced summary,.ai-provider-preview summary { padding:12px 14px; cursor:pointer; color:#46514c; font-size:13px; font-weight:600; }.ai-provider-advanced>div { padding:2px 14px 14px; }.ai-provider-advanced textarea { min-height:90px; resize:vertical; }.ai-provider-preview pre { overflow:auto; max-height:280px; margin:0; padding:0 14px 14px; color:#46514c; font:11px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; }.ai-provider-editor>footer { display:flex; justify-content:flex-end; gap:8px; } @keyframes settings-spin { to { transform:rotate(360deg); } } @media (max-width:900px) { .settings-sidebar { width:248px; flex-basis:248px; } @@ -3664,6 +3667,7 @@ body { .settings-account-actions { grid-row:1; grid-column:2; } .database-key-security-info { grid-template-columns:1fr; } .image-decrypt-status dl { grid-template-columns:repeat(2,minmax(0,1fr)); }.image-decrypt-status .image-decrypt-wide { grid-column:span 2; } + .ai-model-row { grid-template-columns:1fr 1fr; }.ai-model-row label,.ai-model-row button { justify-self:start; } } @media (max-width:700px) { .settings-account-overview { grid-template-columns:minmax(0,1fr); } @@ -3672,6 +3676,7 @@ body { .database-key-status-card dl,.database-key-diagnostics dl { grid-template-columns:1fr; } .database-key-auto-heading { flex-direction:column; }.database-key-phases { grid-template-columns:1fr; }.database-key-phases li { padding:0 0 0 28px; text-align:left; }.database-key-phases li::before { top:-2px; left:0; transform:none; } .image-decrypt-status dl,.image-key-grid { grid-template-columns:1fr; }.image-decrypt-status .image-decrypt-wide { grid-column:1; }.image-auto-heading { flex-direction:column; }.image-auto-phases { grid-template-columns:1fr; }.image-auto-phases li { padding:0 0 0 28px; text-align:left; }.image-auto-phases li::before { top:-2px; left:0; transform:none; }.image-auto-success { grid-template-columns:1fr; }.image-auto-success button { grid-column:1; grid-row:auto; justify-self:start; }.image-resource-checks>div { align-items:flex-start; }.image-resource-checks small { white-space:normal; text-align:right; } + .ai-provider-card dl,.ai-provider-form-grid,.ai-provider-advanced>div { grid-template-columns:1fr; }.ai-provider-form-grid .wide,.ai-provider-advanced .wide { grid-column:1; }.ai-model-row { grid-template-columns:1fr; } } .api-center-layout > * { min-width: 0; min-height: 0; box-sizing: border-box; } .api-section-heading h2, .api-introduction h2, .api-integrations h2, .api-runtime-title h2 { margin: 0; color: var(--wxex-text-primary); font: 700 17px/24px var(--wxex-font); } diff --git a/src/renderer/src/components/reports/AiReportWorkspace.tsx b/src/renderer/src/components/reports/AiReportWorkspace.tsx index 4d72d15..8e8782b 100644 --- a/src/renderer/src/components/reports/AiReportWorkspace.tsx +++ b/src/renderer/src/components/reports/AiReportWorkspace.tsx @@ -83,20 +83,21 @@ export function AiReportWorkspace({ const configDisabled = isGenerating const disabledReason = useMemo(() => { if (!sourceContact) return '请先选择群聊' - if (!modelConfig.apiKey.trim()) return '请先配置 API Key' + if (!modelConfig.configured) return '请先配置默认 AI 模型' if (rangeState.status === 'loading') return '正在计算消息数量' if (rangeState.status === 'error') return rangeState.error if (!reportMessageCount) return '当前范围没有可总结消息' if (!summaryMessageTypes.length) return '请至少选择一种消息类型' return '' - }, [modelConfig.apiKey, rangeState, reportMessageCount, sourceContact, summaryMessageTypes.length]) + }, [ + modelConfig.configured, + rangeState, + reportMessageCount, + sourceContact, + summaryMessageTypes.length + ]) const canGenerate = !isGenerating && !disabledReason - const modelStatus = - modelConfig.apiKey.trim() && modelConfig.baseURL.trim() - ? '配置正常' - : modelConfig.apiKey.trim() || modelConfig.baseURL.trim() - ? '配置不完整' - : '尚未配置' + const modelStatus = modelConfig.configured ? '配置正常' : '尚未配置' const handleCopy = async (): Promise => { const result = await onCopyImage() @@ -143,10 +144,7 @@ export function AiReportWorkspace({ /> - +

隐私说明

微信数据库和聊天记录默认从本机读取。

@@ -184,7 +182,8 @@ export function AiReportWorkspace({
- {modelLabel(modelConfig.model)} · {modelStatus} · 所选内容会发送至你配置的模型服务 + {modelConfig.modelName || modelLabel(modelConfig.model)} · {modelStatus} · + 所选内容会发送至你配置的模型服务
{disabledReason && !isGenerating && {disabledReason}} diff --git a/src/renderer/src/components/reports/ModelSummary.tsx b/src/renderer/src/components/reports/ModelSummary.tsx index d0d44de..4ff8d9a 100644 --- a/src/renderer/src/components/reports/ModelSummary.tsx +++ b/src/renderer/src/components/reports/ModelSummary.tsx @@ -6,25 +6,8 @@ interface ModelSummaryProps { onOpenSettings: () => void } -const MODEL_OPTIONS = [ - { value: 'deepseek-chat', label: 'DeepSeek Chat' }, - { value: 'gpt-4o', label: 'GPT-4o' }, - { value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, - { value: 'gpt-4-turbo', label: 'GPT-4 Turbo' }, - { value: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet' }, - { value: 'moonshot-v1-8k', label: 'Moonshot V1' } -] - -const modelLabel = (model: string): string => - MODEL_OPTIONS.find((option) => option.value === model)?.label || model || '未选择模型' - -export function ModelSummary({ - config, - onOpenSettings -}: ModelSummaryProps): React.ReactElement { - const hasApiKey = Boolean(config.apiKey.trim()) - const hasBaseUrl = Boolean(config.baseURL.trim()) - const statusText = hasApiKey && hasBaseUrl ? '配置正常' : hasApiKey || hasBaseUrl ? '配置不完整' : '尚未配置' +export function ModelSummary({ config, onOpenSettings }: ModelSummaryProps): React.ReactElement { + const statusText = config.configured ? '配置正常' : '尚未配置' return (
@@ -32,7 +15,7 @@ export function ModelSummary({

模型配置

- {modelLabel(config.model)} · {statusText} + {config.modelName || config.model || '未选择模型'} · {statusText}

+ + + +
+ + ) +} diff --git a/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx b/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx new file mode 100644 index 0000000..0e98c5b --- /dev/null +++ b/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx @@ -0,0 +1,300 @@ +import type { + AIModelDefinition, + AIProviderConfig, + AIProviderType +} from '../../../../../shared/ai-provider' +import { PROVIDER_PRESETS, PROVIDER_TYPE_LABELS } from './presets' + +export function AIProviderEditor({ + provider, + presetId, + editing, + saving, + onPreset, + onChange, + onCancel, + onSave +}: { + provider: AIProviderConfig + presetId: string + editing: boolean + saving: boolean + onPreset: (id: string) => void + onChange: (provider: AIProviderConfig) => void + onCancel: () => void + onSave: () => void +}): React.ReactElement { + const patch = (value: Partial): void => onChange({ ...provider, ...value }) + const patchModel = (index: number, value: Partial): void => { + const models = provider.models.map((model, modelIndex) => + modelIndex === index ? { ...model, ...value } : model + ) + patch({ + models, + defaultModel: models.some((model) => model.id === provider.defaultModel) + ? provider.defaultModel + : models[0]?.id || '' + }) + } + const preview = JSON.stringify( + { ...provider, apiKey: provider.apiKey ? '***' : undefined }, + null, + 2 + ) + + return ( +
+
+
+

{editing ? '编辑供应商' : '新增供应商'}

+

API Key 保存后不会再次显示。

+
+ +
+ {!editing ? ( + + ) : null} +
+ + + + + + + +
+ +
+

模型配置

+ +
+
+ {provider.models.map((model, index) => ( +
+ patchModel(index, { name: event.target.value })} + placeholder="显示名称" + /> + patchModel(index, { id: event.target.value })} + placeholder="实际模型 ID" + /> + + + + + patchModel(index, { maxTokens: Number(event.target.value) || undefined }) + } + placeholder="最大 Token" + /> + + +
+ ))} +
+ +
+ 高级设置 +
+ + + +