mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
重构 AI 模型配置中心
This commit is contained in:
@@ -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
@@ -27,8 +27,10 @@ import {
|
|||||||
} from './report-history-service'
|
} from './report-history-service'
|
||||||
import type { GroupReportExportRequest } from '../shared/group-report'
|
import type { GroupReportExportRequest } from '../shared/group-report'
|
||||||
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
||||||
|
import type { AIChatRequestOptions, AIProviderConfig, LegacyAIConfig } from '../shared/ai-provider'
|
||||||
import { DatabaseKeyStore } from './database-key-store'
|
import { DatabaseKeyStore } from './database-key-store'
|
||||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||||
|
import { AIProviderService } from './services/ai-provider-service'
|
||||||
import { KeyServiceMac } from './key-service-mac'
|
import { KeyServiceMac } from './key-service-mac'
|
||||||
import { KeyService as KeyServiceWin } from './key-service-win'
|
import { KeyService as KeyServiceWin } from './key-service-win'
|
||||||
import * as chat from './services/chat-service'
|
import * as chat from './services/chat-service'
|
||||||
@@ -63,6 +65,7 @@ let imageDecryptService: ImageDecryptService | null = null
|
|||||||
let stickerService: StickerService | null = null
|
let stickerService: StickerService | null = null
|
||||||
const databaseKeyStore = new DatabaseKeyStore()
|
const databaseKeyStore = new DatabaseKeyStore()
|
||||||
const imageKeyConfigService = new ImageKeyConfigService()
|
const imageKeyConfigService = new ImageKeyConfigService()
|
||||||
|
const aiProviderService = new AIProviderService()
|
||||||
const keyServiceMac = new KeyServiceMac()
|
const keyServiceMac = new KeyServiceMac()
|
||||||
const keyServiceWin = new KeyServiceWin()
|
const keyServiceWin = new KeyServiceWin()
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
@@ -353,52 +356,24 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'ai:chat',
|
'ai:chat',
|
||||||
async (
|
async (_, messages: { role: string; content: string }[], options?: AIChatRequestOptions) =>
|
||||||
_,
|
aiProviderService.chat(messages, options)
|
||||||
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'
|
|
||||||
|
|
||||||
if (!apiKey) {
|
ipcMain.handle('ai:listProviders', () => aiProviderService.list())
|
||||||
return { success: false, error: '未配置 API Key' }
|
ipcMain.handle('ai:getRuntimeConfig', () => aiProviderService.getRuntimeConfig())
|
||||||
}
|
ipcMain.handle('ai:saveProvider', (_, provider: AIProviderConfig) =>
|
||||||
|
aiProviderService.save(provider)
|
||||||
// 鍔ㄦ€佸鍏ヤ互閬垮厤濡傛灉鏈畨瑁呮垨鍒濆绫诲瀷缂哄け鐨勯棶棰?
|
)
|
||||||
const { OpenAI } = await import('openai')
|
ipcMain.handle('ai:deleteProvider', (_, providerId: string) =>
|
||||||
|
aiProviderService.delete(providerId)
|
||||||
const openai = new OpenAI({
|
)
|
||||||
baseURL,
|
ipcMain.handle('ai:setDefaultProvider', (_, providerId: string) =>
|
||||||
apiKey
|
aiProviderService.setDefault(providerId)
|
||||||
})
|
)
|
||||||
try {
|
ipcMain.handle('ai:testProvider', (_, providerId: string) => aiProviderService.test(providerId))
|
||||||
const completion = await openai.chat.completions.create({
|
ipcMain.handle('ai:migrateLegacy', (_, config: LegacyAIConfig) =>
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
aiProviderService.migrateLegacy(config)
|
||||||
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('copy-image', async (_, base64String) => {
|
ipcMain.handle('copy-image', async (_, base64String) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Vendored
+16
-1
@@ -20,6 +20,14 @@ import type {
|
|||||||
SaveImageKeyRequest,
|
SaveImageKeyRequest,
|
||||||
TestImageDecryptionRequest
|
TestImageDecryptionRequest
|
||||||
} from '../shared/image-decryption'
|
} from '../shared/image-decryption'
|
||||||
|
import type {
|
||||||
|
AIChatRequestOptions,
|
||||||
|
AIConnectionTestResult,
|
||||||
|
AIProviderConfig,
|
||||||
|
AIProviderListResult,
|
||||||
|
AIRuntimeModelConfig,
|
||||||
|
LegacyAIConfig
|
||||||
|
} from '../shared/ai-provider'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
@@ -82,7 +90,7 @@ declare global {
|
|||||||
search: (keyword: string) => Promise<string | null>
|
search: (keyword: string) => Promise<string | null>
|
||||||
aiChat: (
|
aiChat: (
|
||||||
messages: { role: string; content: string }[],
|
messages: { role: string; content: string }[],
|
||||||
options?: { apiKey?: string; model?: string; baseURL?: string }
|
options?: AIChatRequestOptions
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: string
|
data?: string
|
||||||
@@ -94,6 +102,13 @@ declare global {
|
|||||||
}
|
}
|
||||||
error?: string
|
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 }>
|
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
|
||||||
getVoiceData: (
|
getVoiceData: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
+11
-4
@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron'
|
|||||||
import { electronAPI } from '@electron-toolkit/preload'
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
import type { GroupReportExportRequest } from '../shared/group-report'
|
import type { GroupReportExportRequest } from '../shared/group-report'
|
||||||
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
||||||
|
import type { AIChatRequestOptions, AIProviderConfig, LegacyAIConfig } from '../shared/ai-provider'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
@@ -19,10 +20,16 @@ const api = {
|
|||||||
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
|
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
|
||||||
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
||||||
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
|
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
|
||||||
aiChat: (
|
aiChat: (messages: { role: string; content: string }[], options?: AIChatRequestOptions) =>
|
||||||
messages: { role: string; content: string }[],
|
ipcRenderer.invoke('ai:chat', messages, options),
|
||||||
options?: { apiKey?: string; model?: string; baseURL?: string }
|
listAIProviders: () => ipcRenderer.invoke('ai:listProviders'),
|
||||||
) => ipcRenderer.invoke('ai:chat', messages, options),
|
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),
|
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String),
|
||||||
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
|
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
|
||||||
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
|
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AppShell } from './components/layout/AppShell'
|
|||||||
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
|
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
|
||||||
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
||||||
import type { SettingsCategoryId } from './features/settings/model/types'
|
import type { SettingsCategoryId } from './features/settings/model/types'
|
||||||
|
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
|
||||||
import { AppPage } from './components/layout/navigation'
|
import { AppPage } from './components/layout/navigation'
|
||||||
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
|
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
|
||||||
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
|
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
|
||||||
@@ -175,11 +176,13 @@ function App(): React.ReactElement {
|
|||||||
const [reportNotice, setReportNotice] = useState('')
|
const [reportNotice, setReportNotice] = useState('')
|
||||||
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
||||||
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
||||||
const [aiModelConfig] = useState<AiModelConfig>(() => ({
|
const [aiModelConfig, setAiModelConfig] = useState<AiModelConfig>({
|
||||||
apiKey: localStorage.getItem('ai_api_key') || '',
|
providerName: '尚未配置',
|
||||||
baseURL: localStorage.getItem('ai_base_url') || 'https://api.deepseek.com',
|
model: '',
|
||||||
model: localStorage.getItem('ai_model') || 'deepseek-chat'
|
modelName: '尚未选择模型',
|
||||||
}))
|
configured: false,
|
||||||
|
status: 'untested'
|
||||||
|
})
|
||||||
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
||||||
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||||
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
||||||
@@ -193,6 +196,29 @@ function App(): React.ReactElement {
|
|||||||
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [reportNotice])
|
}, [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 selectedContactMd5Ref = React.useRef<string>('')
|
||||||
const contactAvatarHydrationRunRef = React.useRef(0)
|
const contactAvatarHydrationRunRef = React.useRef(0)
|
||||||
const reportGeneration = useGroupReportGeneration({
|
const reportGeneration = useGroupReportGeneration({
|
||||||
@@ -812,6 +838,11 @@ function App(): React.ReactElement {
|
|||||||
setActivePage('settings')
|
setActivePage('settings')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openModelSettings = (): void => {
|
||||||
|
setSettingsCategory('ai-model')
|
||||||
|
setActivePage('settings')
|
||||||
|
}
|
||||||
|
|
||||||
const openReport = (reportId: string): void => {
|
const openReport = (reportId: string): void => {
|
||||||
setSelectedReportId(reportId)
|
setSelectedReportId(reportId)
|
||||||
setReportWorkspaceView('result')
|
setReportWorkspaceView('result')
|
||||||
@@ -1060,7 +1091,7 @@ function App(): React.ReactElement {
|
|||||||
isGenerating={reportGeneration.isGenerating}
|
isGenerating={reportGeneration.isGenerating}
|
||||||
onSummaryDateRangeChange={setSummaryDateRange}
|
onSummaryDateRangeChange={setSummaryDateRange}
|
||||||
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
onSummaryMessageTypesChange={setSummaryMessageTypes}
|
||||||
onOpenModelSettings={openSettings}
|
onOpenModelSettings={openModelSettings}
|
||||||
onGenerate={() => {
|
onGenerate={() => {
|
||||||
reportGeneration.resetGenerationStatus()
|
reportGeneration.resetGenerationStatus()
|
||||||
void reportGeneration.generate()
|
void reportGeneration.generate()
|
||||||
@@ -1109,6 +1140,7 @@ function App(): React.ReactElement {
|
|||||||
onSelfInfoChange={setSelfInfo}
|
onSelfInfoChange={setSelfInfo}
|
||||||
onContactsChange={setContacts}
|
onContactsChange={setContacts}
|
||||||
onFilteredContactsChange={setFilteredContacts}
|
onFilteredContactsChange={setFilteredContacts}
|
||||||
|
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
|
||||||
onNotice={setReportNotice}
|
onNotice={setReportNotice}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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-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-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; }
|
.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); } }
|
@keyframes settings-spin { to { transform:rotate(360deg); } }
|
||||||
@media (max-width:900px) {
|
@media (max-width:900px) {
|
||||||
.settings-sidebar { width:248px; flex-basis:248px; }
|
.settings-sidebar { width:248px; flex-basis:248px; }
|
||||||
@@ -3664,6 +3667,7 @@ body {
|
|||||||
.settings-account-actions { grid-row:1; grid-column:2; }
|
.settings-account-actions { grid-row:1; grid-column:2; }
|
||||||
.database-key-security-info { grid-template-columns:1fr; }
|
.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; }
|
.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) {
|
@media (max-width:700px) {
|
||||||
.settings-account-overview { grid-template-columns:minmax(0,1fr); }
|
.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-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; }
|
.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; }
|
.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-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); }
|
.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 configDisabled = isGenerating
|
||||||
const disabledReason = useMemo(() => {
|
const disabledReason = useMemo(() => {
|
||||||
if (!sourceContact) return '请先选择群聊'
|
if (!sourceContact) return '请先选择群聊'
|
||||||
if (!modelConfig.apiKey.trim()) return '请先配置 API Key'
|
if (!modelConfig.configured) return '请先配置默认 AI 模型'
|
||||||
if (rangeState.status === 'loading') return '正在计算消息数量'
|
if (rangeState.status === 'loading') return '正在计算消息数量'
|
||||||
if (rangeState.status === 'error') return rangeState.error
|
if (rangeState.status === 'error') return rangeState.error
|
||||||
if (!reportMessageCount) return '当前范围没有可总结消息'
|
if (!reportMessageCount) return '当前范围没有可总结消息'
|
||||||
if (!summaryMessageTypes.length) return '请至少选择一种消息类型'
|
if (!summaryMessageTypes.length) return '请至少选择一种消息类型'
|
||||||
return ''
|
return ''
|
||||||
}, [modelConfig.apiKey, rangeState, reportMessageCount, sourceContact, summaryMessageTypes.length])
|
}, [
|
||||||
|
modelConfig.configured,
|
||||||
|
rangeState,
|
||||||
|
reportMessageCount,
|
||||||
|
sourceContact,
|
||||||
|
summaryMessageTypes.length
|
||||||
|
])
|
||||||
const canGenerate = !isGenerating && !disabledReason
|
const canGenerate = !isGenerating && !disabledReason
|
||||||
const modelStatus =
|
const modelStatus = modelConfig.configured ? '配置正常' : '尚未配置'
|
||||||
modelConfig.apiKey.trim() && modelConfig.baseURL.trim()
|
|
||||||
? '配置正常'
|
|
||||||
: modelConfig.apiKey.trim() || modelConfig.baseURL.trim()
|
|
||||||
? '配置不完整'
|
|
||||||
: '尚未配置'
|
|
||||||
|
|
||||||
const handleCopy = async (): Promise<void> => {
|
const handleCopy = async (): Promise<void> => {
|
||||||
const result = await onCopyImage()
|
const result = await onCopyImage()
|
||||||
@@ -143,10 +144,7 @@ export function AiReportWorkspace({
|
|||||||
/>
|
/>
|
||||||
<ReportSectionSelector />
|
<ReportSectionSelector />
|
||||||
<ReportDensitySelector />
|
<ReportDensitySelector />
|
||||||
<ModelSummary
|
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
|
||||||
config={modelConfig}
|
|
||||||
onOpenSettings={onOpenModelSettings}
|
|
||||||
/>
|
|
||||||
<section className="report-privacy-note">
|
<section className="report-privacy-note">
|
||||||
<h3>隐私说明</h3>
|
<h3>隐私说明</h3>
|
||||||
<p>微信数据库和聊天记录默认从本机读取。</p>
|
<p>微信数据库和聊天记录默认从本机读取。</p>
|
||||||
@@ -184,7 +182,8 @@ export function AiReportWorkspace({
|
|||||||
|
|
||||||
<footer className="ai-report-footer">
|
<footer className="ai-report-footer">
|
||||||
<span className="report-footer-note">
|
<span className="report-footer-note">
|
||||||
{modelLabel(modelConfig.model)} · {modelStatus} · 所选内容会发送至你配置的模型服务
|
{modelConfig.modelName || modelLabel(modelConfig.model)} · {modelStatus} ·
|
||||||
|
所选内容会发送至你配置的模型服务
|
||||||
</span>
|
</span>
|
||||||
<div className="report-footer-actions">
|
<div className="report-footer-actions">
|
||||||
{disabledReason && !isGenerating && <span>{disabledReason}</span>}
|
{disabledReason && !isGenerating && <span>{disabledReason}</span>}
|
||||||
|
|||||||
@@ -6,25 +6,8 @@ interface ModelSummaryProps {
|
|||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODEL_OPTIONS = [
|
export function ModelSummary({ config, onOpenSettings }: ModelSummaryProps): React.ReactElement {
|
||||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
|
const statusText = config.configured ? '配置正常' : '尚未配置'
|
||||||
{ 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 ? '配置不完整' : '尚未配置'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="report-config-section">
|
<section className="report-config-section">
|
||||||
@@ -32,7 +15,7 @@ export function ModelSummary({
|
|||||||
<div>
|
<div>
|
||||||
<h3>模型配置</h3>
|
<h3>模型配置</h3>
|
||||||
<p>
|
<p>
|
||||||
{modelLabel(config.model)} · {statusText}
|
{config.modelName || config.model || '未选择模型'} · {statusText}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onOpenSettings}>
|
<button type="button" onClick={onOpenSettings}>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import type { SettingsCategoryId, SettingsSelfInfo } from './model/types'
|
|||||||
import { AccountDatabasePage } from './pages/AccountDatabasePage'
|
import { AccountDatabasePage } from './pages/AccountDatabasePage'
|
||||||
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
|
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
|
||||||
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
||||||
|
import { AIModelPage } from './pages/AIModelPage'
|
||||||
import type { Contact } from '../../../../shared/types'
|
import type { Contact } from '../../../../shared/types'
|
||||||
|
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||||
|
|
||||||
export function SettingsWorkspace({
|
export function SettingsWorkspace({
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
@@ -18,6 +20,7 @@ export function SettingsWorkspace({
|
|||||||
onSelfInfoChange,
|
onSelfInfoChange,
|
||||||
onContactsChange,
|
onContactsChange,
|
||||||
onFilteredContactsChange,
|
onFilteredContactsChange,
|
||||||
|
onAIRuntimeChange,
|
||||||
onNotice,
|
onNotice,
|
||||||
onOpenSettings
|
onOpenSettings
|
||||||
}: {
|
}: {
|
||||||
@@ -31,6 +34,7 @@ export function SettingsWorkspace({
|
|||||||
onSelfInfoChange: (info: SettingsSelfInfo | null) => void
|
onSelfInfoChange: (info: SettingsSelfInfo | null) => void
|
||||||
onContactsChange: (contacts: Contact[]) => void
|
onContactsChange: (contacts: Contact[]) => void
|
||||||
onFilteredContactsChange: (contacts: Contact[]) => void
|
onFilteredContactsChange: (contacts: Contact[]) => void
|
||||||
|
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
|
||||||
onNotice: (message: string) => void
|
onNotice: (message: string) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
@@ -64,6 +68,8 @@ export function SettingsWorkspace({
|
|||||||
/>
|
/>
|
||||||
) : selectedCategory === 'image-key' ? (
|
) : selectedCategory === 'image-key' ? (
|
||||||
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
||||||
|
) : selectedCategory === 'ai-model' ? (
|
||||||
|
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||||
) : (
|
) : (
|
||||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
<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">
|
||||||
|
额外 Headers(JSON)
|
||||||
|
<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>添加 OpenAI、Anthropic、DeepSeek、Ollama 或兼容服务。</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,9 +23,12 @@ export type ReportGenerationPhase =
|
|||||||
| 'error'
|
| 'error'
|
||||||
|
|
||||||
export interface AiModelConfig {
|
export interface AiModelConfig {
|
||||||
apiKey: string
|
providerId?: string
|
||||||
baseURL: string
|
providerName: string
|
||||||
model: string
|
model: string
|
||||||
|
modelName: string
|
||||||
|
configured: boolean
|
||||||
|
status: 'untested' | 'connected' | 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportPaths {
|
export interface ReportPaths {
|
||||||
@@ -277,9 +280,9 @@ export function useGroupReportGeneration({
|
|||||||
setError('AI 群聊日报仅支持群聊')
|
setError('AI 群聊日报仅支持群聊')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!modelConfig.apiKey.trim()) {
|
if (!modelConfig.configured) {
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
setError('尚未配置 API Key')
|
setError('尚未配置可用的默认 AI 模型')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!summaryMessageTypes.length) {
|
if (!summaryMessageTypes.length) {
|
||||||
@@ -339,7 +342,13 @@ export function useGroupReportGeneration({
|
|||||||
{ role: 'user', content: input.prompt }
|
{ role: 'user', content: input.prompt }
|
||||||
]
|
]
|
||||||
const result = await trackStep('AI 生成', () =>
|
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 请求失败')
|
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user