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'
|
||||
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) => {
|
||||
|
||||
@@ -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,
|
||||
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
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
额外 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'
|
||||
|
||||
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 请求失败')
|
||||
|
||||
|
||||
@@ -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