重构 AI 模型配置中心

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
parent 7b54b611d3
commit 31df97237d
19 changed files with 1489 additions and 94 deletions
+79
View File
@@ -0,0 +1,79 @@
import { app, safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
interface StoredKeys {
version: 1
keys: Record<string, string>
}
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 密钥保存失败' }
}
}
}
+20 -45
View File
@@ -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) => {
+442
View File
@@ -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<Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>>
}
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<AIProviderSummary, 'hasApiKey' | 'isDefault'> = {
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<AIConnectionTestResult> {
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<AIRequestResult> {
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<AIProviderSummary, 'hasApiKey' | 'isDefault'>,
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<AIProviderSummary, 'hasApiKey' | 'isDefault'> {
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<AIProviderConfig, 'type' | 'auth'>): 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<string, string> {
const headers: Record<string, string> = {
'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<AIRequestResult> {
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<AIRequestResult> {
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<Response> {
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)
}
+16 -1
View File
@@ -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<string | null>
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<AIProviderListResult>
getAIRuntimeConfig: () => Promise<AIRuntimeModelConfig>
saveAIProvider: (provider: AIProviderConfig) => Promise<AIProviderListResult>
deleteAIProvider: (providerId: string) => Promise<AIProviderListResult>
setDefaultAIProvider: (providerId: string) => Promise<AIProviderListResult>
testAIProvider: (providerId: string) => Promise<AIConnectionTestResult>
migrateLegacyAIConfig: (config: LegacyAIConfig) => Promise<AIProviderListResult>
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
getVoiceData: (
sessionId: string,
+11 -4
View File
@@ -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),
+38 -6
View File
@@ -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<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const [aiModelConfig] = useState<AiModelConfig>(() => ({
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<AiModelConfig>({
providerName: '尚未配置',
model: '',
modelName: '尚未选择模型',
configured: false,
status: 'untested'
})
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(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<void> => {
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<string>('')
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}
/>
+5
View File
@@ -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); }
@@ -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<void> => {
const result = await onCopyImage()
@@ -143,10 +144,7 @@ export function AiReportWorkspace({
/>
<ReportSectionSelector />
<ReportDensitySelector />
<ModelSummary
config={modelConfig}
onOpenSettings={onOpenModelSettings}
/>
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
<section className="report-privacy-note">
<h3></h3>
<p></p>
@@ -184,7 +182,8 @@ export function AiReportWorkspace({
<footer className="ai-report-footer">
<span className="report-footer-note">
{modelLabel(modelConfig.model)} · {modelStatus} ·
{modelConfig.modelName || modelLabel(modelConfig.model)} · {modelStatus} ·
</span>
<div className="report-footer-actions">
{disabledReason && !isGenerating && <span>{disabledReason}</span>}
@@ -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 (
<section className="report-config-section">
@@ -32,7 +15,7 @@ export function ModelSummary({
<div>
<h3></h3>
<p>
{modelLabel(config.model)} · {statusText}
{config.modelName || config.model || '未选择模型'} · {statusText}
</p>
</div>
<button type="button" onClick={onOpenSettings}>
@@ -5,7 +5,9 @@ import type { SettingsCategoryId, SettingsSelfInfo } from './model/types'
import { AccountDatabasePage } from './pages/AccountDatabasePage'
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
import { AIModelPage } from './pages/AIModelPage'
import type { Contact } from '../../../../shared/types'
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
export function SettingsWorkspace({
selectedCategory,
@@ -18,6 +20,7 @@ export function SettingsWorkspace({
onSelfInfoChange,
onContactsChange,
onFilteredContactsChange,
onAIRuntimeChange,
onNotice,
onOpenSettings
}: {
@@ -31,6 +34,7 @@ export function SettingsWorkspace({
onSelfInfoChange: (info: SettingsSelfInfo | null) => void
onContactsChange: (contacts: Contact[]) => void
onFilteredContactsChange: (contacts: Contact[]) => void
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
onNotice: (message: string) => void
onOpenSettings: () => void
}): React.ReactElement {
@@ -64,6 +68,8 @@ export function SettingsWorkspace({
/>
) : selectedCategory === 'image-key' ? (
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
) : selectedCategory === 'ai-model' ? (
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
) : (
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
)}
@@ -0,0 +1,64 @@
import type { AIProviderSummary } from '../../../../../shared/ai-provider'
import { PROVIDER_TYPE_LABELS } from './presets'
const STATUS_LABELS = { untested: '未测试', connected: '已连接', error: '连接失败' }
export function AIProviderCard({
provider,
testing,
onEdit,
onTest,
onDefault,
onDelete
}: {
provider: AIProviderSummary
testing: boolean
onEdit: () => void
onTest: () => void
onDefault: () => void
onDelete: () => void
}): React.ReactElement {
const model = provider.models.find((item) => item.id === provider.defaultModel)
return (
<article className="ai-provider-card">
<header>
<div>
<h3>{provider.name}</h3>
<span>{PROVIDER_TYPE_LABELS[provider.type]}</span>
</div>
<span className={`ai-provider-status ${provider.status}`}>
{STATUS_LABELS[provider.status]}
</span>
</header>
<dl>
<div>
<dt></dt>
<dd title={provider.baseUrl}>{provider.baseUrl}</dd>
</div>
<div>
<dt></dt>
<dd>{model?.name || provider.defaultModel}</dd>
</div>
<div>
<dt>API Key</dt>
<dd>
{provider.hasApiKey ? '已安全保存' : provider.type === 'ollama' ? '无需配置' : '未配置'}
</dd>
</div>
</dl>
{provider.lastError ? <p className="ai-provider-error">{provider.lastError}</p> : null}
<footer>
<button onClick={onEdit}></button>
<button disabled={testing} onClick={onTest}>
{testing ? '测试中…' : '测试连接'}
</button>
<button disabled={provider.isDefault} onClick={onDefault}>
{provider.isDefault ? '当前默认' : '设为默认'}
</button>
<button className="danger" onClick={onDelete}>
</button>
</footer>
</article>
)
}
@@ -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<AIProviderConfig>): void => onChange({ ...provider, ...value })
const patchModel = (index: number, value: Partial<AIModelDefinition>): 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 (
<section className="settings-card ai-provider-editor">
<header>
<div>
<h2>{editing ? '编辑供应商' : '新增供应商'}</h2>
<p>API Key </p>
</div>
<button onClick={onCancel}></button>
</header>
{!editing ? (
<label>
<select value={presetId} onChange={(event) => onPreset(event.target.value)}>
{PROVIDER_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
</label>
) : null}
<div className="ai-provider-form-grid">
<label>
<input value={provider.name} onChange={(event) => patch({ name: event.target.value })} />
</label>
<label>
ID
<input
value={provider.id}
disabled={editing}
onChange={(event) => patch({ id: event.target.value })}
/>
</label>
<label>
<select
value={provider.type}
onChange={(event) => patch({ type: event.target.value as AIProviderType })}
>
{Object.entries(PROVIDER_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<label>
<select
value={provider.auth.type}
onChange={(event) =>
patch({
auth: {
...provider.auth,
type: event.target.value as AIProviderConfig['auth']['type']
}
})
}
>
<option value="bearer">Authorization Bearer</option>
<option value="x-api-key">X-API-Key</option>
<option value="custom-header"> Header</option>
<option value="none"></option>
</select>
</label>
<label className="wide">
Base URL
<input
value={provider.baseUrl}
onChange={(event) => patch({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
/>
</label>
<label>
API Key
<input
type="password"
value={provider.apiKey || ''}
onChange={(event) => patch({ apiKey: event.target.value })}
placeholder="留空则保留已保存的 Key"
autoComplete="off"
/>
</label>
<label>
<input
value={provider.auth.headerName || ''}
disabled={provider.auth.type !== 'custom-header'}
onChange={(event) =>
patch({ auth: { ...provider.auth, headerName: event.target.value } })
}
placeholder="Authorization"
/>
</label>
</div>
<div className="ai-model-table-heading">
<h3></h3>
<button onClick={() => patch({ models: [...provider.models, emptyModel()] })}>
</button>
</div>
<div className="ai-model-table">
{provider.models.map((model, index) => (
<div className="ai-model-row" key={`${index}-${model.id}`}>
<input
value={model.name}
onChange={(event) => patchModel(index, { name: event.target.value })}
placeholder="显示名称"
/>
<input
value={model.id}
onChange={(event) => patchModel(index, { id: event.target.value })}
placeholder="实际模型 ID"
/>
<label>
<input
type="checkbox"
checked={model.capabilities.chat}
onChange={(event) =>
patchModel(index, {
capabilities: { ...model.capabilities, chat: event.target.checked }
})
}
/>
</label>
<label>
<input
type="checkbox"
checked={model.capabilities.vision}
onChange={(event) =>
patchModel(index, {
capabilities: { ...model.capabilities, vision: event.target.checked }
})
}
/>
</label>
<label>
<input
type="checkbox"
checked={model.capabilities.longContext}
onChange={(event) =>
patchModel(index, {
capabilities: { ...model.capabilities, longContext: event.target.checked }
})
}
/>
</label>
<input
type="number"
value={model.maxTokens || ''}
onChange={(event) =>
patchModel(index, { maxTokens: Number(event.target.value) || undefined })
}
placeholder="最大 Token"
/>
<label className="ai-model-default">
<input
type="radio"
name="default-model"
checked={provider.defaultModel === model.id}
onChange={() => patch({ defaultModel: model.id })}
/>
</label>
<button
className="danger"
disabled={provider.models.length === 1}
onClick={() =>
patch({ models: provider.models.filter((_, itemIndex) => itemIndex !== index) })
}
>
</button>
</div>
))}
</div>
<details className="ai-provider-advanced">
<summary></summary>
<div>
<label>
ms
<input
type="number"
value={provider.advanced.timeoutMs}
onChange={(event) =>
patch({ advanced: { ...provider.advanced, timeoutMs: Number(event.target.value) } })
}
/>
</label>
<label>
Temperature
<input
type="number"
step="0.1"
value={provider.advanced.temperature ?? ''}
onChange={(event) =>
patch({
advanced: { ...provider.advanced, temperature: Number(event.target.value) }
})
}
/>
</label>
<label>
Max Tokens
<input
type="number"
value={provider.advanced.maxTokens ?? ''}
onChange={(event) =>
patch({ advanced: { ...provider.advanced, maxTokens: Number(event.target.value) } })
}
/>
</label>
<label className="wide">
HeadersJSON
<textarea
key={JSON.stringify(provider.advanced.extraHeaders)}
defaultValue={JSON.stringify(provider.advanced.extraHeaders, null, 2)}
onBlur={(event) => {
try {
patch({
advanced: {
...provider.advanced,
extraHeaders: JSON.parse(event.target.value) as Record<string, string>
}
})
} catch {
/* Keep the last valid headers. */
}
}}
/>
</label>
</div>
</details>
<details className="ai-provider-preview">
<summary></summary>
<pre>{preview}</pre>
</details>
<footer>
<button onClick={onCancel}></button>
<button className="database-key-primary" disabled={saving} onClick={onSave}>
{saving ? '保存中…' : '保存供应商'}
</button>
</footer>
</section>
)
}
function emptyModel(): AIModelDefinition {
return { name: '', id: '', capabilities: { chat: true, vision: false, longContext: false } }
}
@@ -0,0 +1,48 @@
import type { AIModelSettingsAction, AIModelSettingsState } from './types'
export const initialAIModelSettingsState: AIModelSettingsState = {
loading: true,
saving: false,
providers: [],
runtime: null,
editor: null,
presetId: 'deepseek'
}
export function aiModelSettingsReducer(
state: AIModelSettingsState,
action: AIModelSettingsAction
): AIModelSettingsState {
switch (action.type) {
case 'LOADED':
return {
...state,
loading: false,
saving: false,
testingId: undefined,
providers: action.providers,
runtime: action.runtime,
error: undefined
}
case 'ERROR':
return { ...state, loading: false, saving: false, testingId: undefined, error: action.error }
case 'OPEN_EDITOR':
return {
...state,
editor: action.editor,
presetId: action.presetId,
originalProviderId: action.originalProviderId,
error: undefined
}
case 'CLOSE_EDITOR':
return { ...state, editor: null, originalProviderId: undefined, error: undefined }
case 'EDIT':
return { ...state, editor: action.editor, error: undefined }
case 'SAVE_START':
return { ...state, saving: true, error: undefined }
case 'TEST_START':
return { ...state, testingId: action.providerId, error: undefined }
default:
return state
}
}
@@ -0,0 +1,97 @@
import type { AIProviderConfig, AIProviderType } from '../../../../../shared/ai-provider'
export const PROVIDER_TYPE_LABELS: Record<AIProviderType, string> = {
'openai-compatible': 'OpenAI Compatible',
'anthropic-messages': 'Anthropic Messages',
'azure-openai': 'Azure OpenAI',
ollama: 'Ollama',
custom: '自定义'
}
export const PROVIDER_PRESETS = [
{
id: 'openai',
label: 'OpenAI',
type: 'openai-compatible',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini'
},
{
id: 'anthropic',
label: 'Anthropic',
type: 'anthropic-messages',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude-3-5-sonnet-latest'
},
{
id: 'deepseek',
label: 'DeepSeek',
type: 'openai-compatible',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat'
},
{
id: 'qwen',
label: '通义千问',
type: 'openai-compatible',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
model: 'qwen-plus'
},
{
id: 'moonshot',
label: 'Moonshot',
type: 'openai-compatible',
baseUrl: 'https://api.moonshot.cn/v1',
model: 'moonshot-v1-8k'
},
{
id: 'minimax',
label: 'MiniMax',
type: 'openai-compatible',
baseUrl: 'https://api.minimax.chat/v1',
model: 'MiniMax-Text-01'
},
{
id: 'ollama',
label: 'Ollama',
type: 'ollama',
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen2.5'
},
{
id: 'custom',
label: '自建 OpenAI Compatible API',
type: 'openai-compatible',
baseUrl: '',
model: ''
}
] as const
export function createProviderFromPreset(presetId = 'deepseek'): AIProviderConfig {
const preset = PROVIDER_PRESETS.find((item) => item.id === presetId) || PROVIDER_PRESETS[2]
const id = `${preset.id}-${Date.now().toString(36)}`
return {
id,
name: preset.label,
type: preset.type,
baseUrl: preset.baseUrl,
apiKey: '',
auth: {
type:
preset.type === 'ollama'
? 'none'
: preset.type === 'anthropic-messages'
? 'x-api-key'
: 'bearer'
},
models: [
{
name: preset.model || '默认模型',
id: preset.model,
capabilities: { chat: true, vision: false, longContext: false }
}
],
defaultModel: preset.model,
advanced: { timeoutMs: 120000, temperature: 0.7, maxTokens: 4096, extraHeaders: {} }
}
}
@@ -0,0 +1,44 @@
import type {
AIProviderConfig,
AIProviderSummary,
AIRuntimeModelConfig
} from '../../../../../shared/ai-provider'
export interface AIModelSettingsState {
loading: boolean
saving: boolean
providers: AIProviderSummary[]
runtime: AIRuntimeModelConfig | null
editor: AIProviderConfig | null
originalProviderId?: string
presetId: string
testingId?: string
error?: string
}
export type AIModelSettingsAction =
| { type: 'LOADED'; providers: AIProviderSummary[]; runtime: AIRuntimeModelConfig }
| { type: 'ERROR'; error: string }
| {
type: 'OPEN_EDITOR'
editor: AIProviderConfig
presetId: string
originalProviderId?: string
}
| { type: 'CLOSE_EDITOR' }
| { type: 'EDIT'; editor: AIProviderConfig }
| { type: 'SAVE_START' }
| { type: 'TEST_START'; providerId: string }
export interface AIModelSettingsController {
state: AIModelSettingsState
openNew: () => void
openEdit: (provider: AIProviderSummary) => void
closeEditor: () => void
selectPreset: (presetId: string) => void
updateEditor: (editor: AIProviderConfig) => void
save: () => Promise<void>
remove: (providerId: string) => Promise<void>
setDefault: (providerId: string) => Promise<void>
test: (providerId: string) => Promise<void>
}
@@ -0,0 +1,123 @@
import { useCallback, useEffect, useReducer } from 'react'
import type {
AIProviderConfig,
AIProviderSummary,
AIRuntimeModelConfig
} from '../../../../../shared/ai-provider'
import { aiModelSettingsReducer, initialAIModelSettingsState } from './aiModelSettingsReducer'
import { createProviderFromPreset } from './presets'
import type { AIModelSettingsController } from './types'
export function useAIModelSettingsController({
onRuntimeChange,
onNotice
}: {
onRuntimeChange: (config: AIRuntimeModelConfig) => void
onNotice: (message: string) => void
}): AIModelSettingsController {
const [state, dispatch] = useReducer(aiModelSettingsReducer, initialAIModelSettingsState)
const refresh = useCallback(async (): Promise<void> => {
const [list, runtime] = await Promise.all([
window.api.listAIProviders(),
window.api.getAIRuntimeConfig()
])
if (!list.success) return dispatch({ type: 'ERROR', error: list.error || '供应商配置读取失败' })
dispatch({ type: 'LOADED', providers: list.providers, runtime })
onRuntimeChange(runtime)
}, [onRuntimeChange])
useEffect(() => {
void refresh()
}, [refresh])
const openNew = useCallback(() => {
dispatch({ type: 'OPEN_EDITOR', editor: createProviderFromPreset(), presetId: 'deepseek' })
}, [])
const openEdit = useCallback((provider: AIProviderSummary) => {
dispatch({
type: 'OPEN_EDITOR',
editor: {
id: provider.id,
name: provider.name,
type: provider.type,
baseUrl: provider.baseUrl,
apiKey: '',
auth: provider.auth,
models: provider.models,
defaultModel: provider.defaultModel,
advanced: provider.advanced
},
presetId: 'custom',
originalProviderId: provider.id
})
}, [])
const closeEditor = useCallback(() => dispatch({ type: 'CLOSE_EDITOR' }), [])
const selectPreset = useCallback((presetId: string) => {
dispatch({ type: 'OPEN_EDITOR', editor: createProviderFromPreset(presetId), presetId })
}, [])
const updateEditor = useCallback(
(editor: AIProviderConfig) => dispatch({ type: 'EDIT', editor }),
[]
)
const save = useCallback(async (): Promise<void> => {
if (!state.editor) return
dispatch({ type: 'SAVE_START' })
const result = await window.api.saveAIProvider(state.editor)
if (!result.success) return dispatch({ type: 'ERROR', error: result.error || '供应商保存失败' })
dispatch({ type: 'CLOSE_EDITOR' })
await refresh()
onNotice('AI 供应商已安全保存')
}, [onNotice, refresh, state.editor])
const remove = useCallback(
async (providerId: string): Promise<void> => {
if (!window.confirm('确认删除这个 AI 供应商?安全存储中的 API Key 也会一并清除。')) return
const result = await window.api.deleteAIProvider(providerId)
if (!result.success)
return dispatch({ type: 'ERROR', error: result.error || '供应商删除失败' })
await refresh()
onNotice('AI 供应商已删除')
},
[onNotice, refresh]
)
const setDefault = useCallback(
async (providerId: string): Promise<void> => {
const result = await window.api.setDefaultAIProvider(providerId)
if (!result.success)
return dispatch({ type: 'ERROR', error: result.error || '默认模型更新失败' })
await refresh()
onNotice('默认 AI 模型已更新')
},
[onNotice, refresh]
)
const test = useCallback(
async (providerId: string): Promise<void> => {
dispatch({ type: 'TEST_START', providerId })
const result = await window.api.testAIProvider(providerId)
await refresh()
onNotice(
result.success
? `连接成功,耗时 ${result.latencyMs || 0} ms`
: result.error || '连接测试失败'
)
},
[onNotice, refresh]
)
return {
state,
openNew,
openEdit,
closeEditor,
selectPreset,
updateEditor,
save,
remove,
setDefault,
test
}
}
@@ -0,0 +1,77 @@
import type { AIRuntimeModelConfig } from '../../../../../shared/ai-provider'
import { AIProviderCard } from '../ai-model/AIProviderCard'
import { AIProviderEditor } from '../ai-model/AIProviderEditor'
import { useAIModelSettingsController } from '../ai-model/useAIModelSettingsController'
export function AIModelPage({
onRuntimeChange,
onNotice
}: {
onRuntimeChange: (config: AIRuntimeModelConfig) => void
onNotice: (message: string) => void
}): React.ReactElement {
const controller = useAIModelSettingsController({ onRuntimeChange, onNotice })
const runtime = controller.state.runtime
return (
<div className="settings-page ai-model-page">
<header className="settings-page-header">
<div>
<h1>AI </h1>
<p></p>
</div>
<button className="database-key-primary" onClick={controller.openNew}>
</button>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content ai-model-content">
<section className="settings-card ai-model-default">
<div>
<span></span>
<strong>{runtime?.modelName || '尚未配置'}</strong>
<small>{runtime?.providerName || '请添加供应商'}</small>
</div>
<span className={`settings-status-badge ${runtime?.configured ? '' : 'unavailable'}`}>
{runtime?.configured ? '可用' : '未配置'}
</span>
</section>
{controller.state.error ? (
<p className="ai-model-page-error">{controller.state.error}</p>
) : null}
{controller.state.editor ? (
<AIProviderEditor
provider={controller.state.editor}
presetId={controller.state.presetId}
editing={Boolean(controller.state.originalProviderId)}
saving={controller.state.saving}
onPreset={controller.selectPreset}
onChange={controller.updateEditor}
onCancel={controller.closeEditor}
onSave={() => void controller.save()}
/>
) : null}
<h2 className="settings-section-heading"></h2>
<div className="ai-provider-list">
{controller.state.providers.map((provider) => (
<AIProviderCard
key={provider.id}
provider={provider}
testing={controller.state.testingId === provider.id}
onEdit={() => controller.openEdit(provider)}
onTest={() => void controller.test(provider.id)}
onDefault={() => void controller.setDefault(provider.id)}
onDelete={() => void controller.remove(provider.id)}
/>
))}
{!controller.state.loading && !controller.state.providers.length ? (
<div className="settings-card ai-provider-empty">
<strong> AI </strong>
<p> OpenAIAnthropicDeepSeekOllama </p>
</div>
) : null}
</div>
</div>
</div>
</div>
)
}
@@ -23,9 +23,12 @@ export type ReportGenerationPhase =
| 'error'
export interface AiModelConfig {
apiKey: string
baseURL: string
providerId?: string
providerName: string
model: string
modelName: string
configured: boolean
status: 'untested' | 'connected' | 'error'
}
export interface ReportPaths {
@@ -277,9 +280,9 @@ export function useGroupReportGeneration({
setError('AI 群聊日报仅支持群聊')
return
}
if (!modelConfig.apiKey.trim()) {
if (!modelConfig.configured) {
setPhase('error')
setError('尚未配置 API Key')
setError('尚未配置可用的默认 AI 模型')
return
}
if (!summaryMessageTypes.length) {
@@ -339,7 +342,13 @@ export function useGroupReportGeneration({
{ role: 'user', content: input.prompt }
]
const result = await trackStep('AI 生成', () =>
withTimeout(window.api.aiChat(aiMessages, modelConfig), 'AI 生成日报')
withTimeout(
window.api.aiChat(aiMessages, {
providerId: modelConfig.providerId,
modelId: modelConfig.model
}),
'AI 生成日报'
)
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
+90
View File
@@ -0,0 +1,90 @@
export type AIProviderType =
| 'openai-compatible'
| 'anthropic-messages'
| 'azure-openai'
| 'ollama'
| 'custom'
export type AIAuthType = 'bearer' | 'x-api-key' | 'custom-header' | 'none'
export interface AIProviderAuth {
type: AIAuthType
headerName?: string
}
export interface AIModelCapabilities {
chat: boolean
vision: boolean
longContext: boolean
}
export interface AIModelDefinition {
name: string
id: string
capabilities: AIModelCapabilities
maxTokens?: number
}
export interface AIProviderAdvancedSettings {
timeoutMs: number
temperature?: number
maxTokens?: number
extraHeaders: Record<string, string>
}
export interface AIProviderConfig {
id: string
name: string
type: AIProviderType
baseUrl: string
apiKey?: string
auth: AIProviderAuth
models: AIModelDefinition[]
defaultModel: string
advanced: AIProviderAdvancedSettings
}
export interface AIProviderSummary extends Omit<AIProviderConfig, 'apiKey'> {
hasApiKey: boolean
isDefault: boolean
status: 'untested' | 'connected' | 'error'
lastTestedAt?: number
lastError?: string
}
export interface AIProviderListResult {
success: boolean
providers: AIProviderSummary[]
defaultProviderId?: string
error?: string
}
export interface AIRuntimeModelConfig {
providerId?: string
providerName: string
model: string
modelName: string
configured: boolean
status: AIProviderSummary['status']
}
export interface LegacyAIConfig {
apiKey?: string
baseUrl?: string
model?: string
}
export interface AIChatRequestOptions {
providerId?: string
modelId?: string
// Legacy compatibility only. New callers must use providerId/modelId.
apiKey?: string
baseURL?: string
model?: string
}
export interface AIConnectionTestResult {
success: boolean
error?: string
latencyMs?: number
}