重构 AI 模型配置中心

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
parent 7b54b611d3
commit 31df97237d
19 changed files with 1489 additions and 94 deletions
+38 -6
View File
@@ -5,6 +5,7 @@ import { AppShell } from './components/layout/AppShell'
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
import type { SettingsCategoryId } from './features/settings/model/types'
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
import { AppPage } from './components/layout/navigation'
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
@@ -175,11 +176,13 @@ function App(): React.ReactElement {
const [reportNotice, setReportNotice] = useState('')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const [aiModelConfig] = useState<AiModelConfig>(() => ({
apiKey: localStorage.getItem('ai_api_key') || '',
baseURL: localStorage.getItem('ai_base_url') || 'https://api.deepseek.com',
model: localStorage.getItem('ai_model') || 'deepseek-chat'
}))
const [aiModelConfig, setAiModelConfig] = useState<AiModelConfig>({
providerName: '尚未配置',
model: '',
modelName: '尚未选择模型',
configured: false,
status: 'untested'
})
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
@@ -193,6 +196,29 @@ function App(): React.ReactElement {
const timer = window.setTimeout(() => setReportNotice(''), 3200)
return () => window.clearTimeout(timer)
}, [reportNotice])
React.useEffect(() => {
const loadAIConfig = async (): Promise<void> => {
try {
const legacy = {
apiKey: localStorage.getItem('ai_api_key') || undefined,
baseUrl: localStorage.getItem('ai_base_url') || undefined,
model: localStorage.getItem('ai_model') || undefined
}
if (legacy.apiKey || legacy.baseUrl || legacy.model) {
const migrated = await window.api.migrateLegacyAIConfig(legacy)
if (migrated.success) {
localStorage.removeItem('ai_api_key')
localStorage.removeItem('ai_base_url')
localStorage.removeItem('ai_model')
}
}
setAiModelConfig(await window.api.getAIRuntimeConfig())
} catch (error) {
console.warn('[AI Provider] 配置加载失败:', error)
}
}
void loadAIConfig()
}, [])
const selectedContactMd5Ref = React.useRef<string>('')
const contactAvatarHydrationRunRef = React.useRef(0)
const reportGeneration = useGroupReportGeneration({
@@ -812,6 +838,11 @@ function App(): React.ReactElement {
setActivePage('settings')
}
const openModelSettings = (): void => {
setSettingsCategory('ai-model')
setActivePage('settings')
}
const openReport = (reportId: string): void => {
setSelectedReportId(reportId)
setReportWorkspaceView('result')
@@ -1060,7 +1091,7 @@ function App(): React.ReactElement {
isGenerating={reportGeneration.isGenerating}
onSummaryDateRangeChange={setSummaryDateRange}
onSummaryMessageTypesChange={setSummaryMessageTypes}
onOpenModelSettings={openSettings}
onOpenModelSettings={openModelSettings}
onGenerate={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.generate()
@@ -1109,6 +1140,7 @@ function App(): React.ReactElement {
onSelfInfoChange={setSelfInfo}
onContactsChange={setContacts}
onFilteredContactsChange={setFilteredContacts}
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
onNotice={setReportNotice}
onOpenSettings={openSettings}
/>
+5
View File
@@ -3654,6 +3654,9 @@ body {
.image-test-section>div:first-child strong,.image-auto-detect strong,.image-auto-unavailable strong { color:#35403b; font-size:14px; }.image-test-section>div:first-child p,.image-auto-detect p,.image-auto-unavailable p { margin:6px 0 0; color:#66706b; font-size:12px; line-height:1.6; }.image-test-section>label { display:block; margin:18px 0 8px; color:#46514c; font-size:12px; font-weight:600; }.image-test-actions { display:flex; gap:9px; margin-top:14px; }.image-test-result { display:flex; flex-wrap:wrap; gap:8px 18px; margin-top:14px; padding:12px 14px; border-radius:8px; color:#2e765d; font-size:12px; }.image-test-result.success { border:1px solid #bfded3; background:#eaf5f1; }.image-test-result.error { border:1px solid #efcaca; background:#fff2f2; color:#a84444; }.image-test-result p { width:100%; margin:2px 0 0; }.image-inline-error { margin:14px 0 0; color:#a84444; font-size:12px; }
.image-auto-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:24px; }.image-auto-detect ul { display:flex; flex-wrap:wrap; gap:10px 22px; margin:17px 0 0; padding:14px 0 0; border-top:1px solid #edf0ee; list-style:none; color:#8b5c25; font-size:12px; }.image-auto-detect li::before { content:'○'; margin-right:6px; }.image-auto-detect li.ok { color:#2e8b68; }.image-auto-detect li.ok::before { content:'✓'; }.image-auto-progress { margin-top:12px!important; padding:9px 12px; border-radius:7px; background:#f1f4f3; }.image-auto-unavailable { padding-top:20px; padding-bottom:20px; }.image-key-danger>div { border-radius:9px!important; }.image-key-danger { margin-top:30px; }
.image-auto-phases { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; margin:18px 0 0; padding:0; counter-reset:image-auto-phase; list-style:none; }.image-auto-phases li { position:relative; padding-top:24px; color:#929a96; text-align:center; font-size:11px; }.image-auto-phases li::before { position:absolute; top:0; left:50%; display:grid; width:18px; height:18px; place-items:center; border:1px solid #d5ddda; border-radius:50%; background:#fff; content:counter(image-auto-phase); counter-increment:image-auto-phase; transform:translateX(-50%); }.image-auto-phases li.active { color:#247a63; }.image-auto-phases li.active::before { border-color:#247a63; background:#247a63; color:#fff; }.image-auto-error { margin:12px 0 0!important; padding:10px 12px; border-left:3px solid #c85a5a; background:#fff2f2; color:#a84444!important; }.image-auto-success { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:7px 20px; align-items:center; margin-top:14px; padding:14px 16px; border:1px solid #bfded3; border-radius:8px; background:#eaf5f1; color:#2e765d; font-size:12px; }.image-auto-success strong { grid-column:1; color:#2e765d; }.image-auto-success span { grid-column:1; }.image-auto-success button { grid-column:2; grid-row:1/4; }
/* SETTINGS-04: AI provider center */
.ai-model-content { padding-bottom:48px; }.ai-model-default { display:flex; align-items:center; justify-content:space-between; gap:20px; }.ai-model-default>div { display:grid; gap:5px; }.ai-model-default>div>span,.ai-model-default small { color:#66706b; font-size:12px; }.ai-model-default strong { color:#202724; font-size:18px; }.ai-model-page-error { padding:11px 14px; border-left:3px solid #c85a5a; background:#fff2f2; color:#a84444; font-size:12px; }.ai-provider-list { display:grid; gap:12px; }.ai-provider-card { border:1px solid #dde3e0; border-radius:10px; background:#fff; padding:19px 20px; }.ai-provider-card header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }.ai-provider-card h3 { margin:0 0 5px; color:#202724; font-size:15px; }.ai-provider-card header span { color:#66706b; font-size:12px; }.ai-provider-status { border-radius:12px; padding:4px 8px; background:#f1f3f2; }.ai-provider-status.connected { background:#eaf5f1; color:#2e8b68!important; }.ai-provider-status.error { background:#fff2f2; color:#c85a5a!important; }.ai-provider-card dl { display:grid; grid-template-columns:2fr 1fr 1fr; gap:18px; margin:18px 0; }.ai-provider-card dt { color:#929a96; font-size:11px; }.ai-provider-card dd { overflow:hidden; margin:5px 0 0; color:#3d4742; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }.ai-provider-card footer { display:flex; flex-wrap:wrap; gap:8px; padding-top:14px; border-top:1px solid #edf0ee; }.ai-provider-card button,.ai-provider-editor button { min-height:32px; border:1px solid #d5ddda; border-radius:7px; padding:0 11px; background:#fff; color:#46514c; cursor:pointer; font:600 12px inherit; }.ai-provider-card button:hover,.ai-provider-editor button:hover { border-color:#247a63; color:#247a63; }.ai-provider-card button:disabled,.ai-provider-editor button:disabled { cursor:not-allowed; opacity:.45; }.ai-provider-card button.danger,.ai-provider-editor button.danger { color:#c85a5a; }.ai-provider-error { margin:0 0 12px; color:#a84444; font-size:12px; }.ai-provider-empty { color:#66706b; text-align:center; }.ai-provider-empty p { margin:6px 0 0; font-size:12px; }
.ai-provider-editor { display:grid; gap:18px; margin-top:22px; }.ai-provider-editor>header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }.ai-provider-editor h2,.ai-provider-editor h3 { margin:0; color:#35403b; font-size:15px; }.ai-provider-editor header p { margin:5px 0 0; color:#66706b; font-size:12px; }.ai-provider-editor label { display:grid; gap:7px; color:#46514c; font-size:12px; font-weight:600; }.ai-provider-editor input:not([type=checkbox]):not([type=radio]),.ai-provider-editor select,.ai-provider-editor textarea { box-sizing:border-box; width:100%; border:1px solid #d5ddda; border-radius:7px; outline:0; padding:9px 10px; background:#f4f7f5; color:#202724; font:12px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; }.ai-provider-editor input:focus,.ai-provider-editor select:focus,.ai-provider-editor textarea:focus { border-color:#247a63; box-shadow:0 0 0 2px rgba(36,122,99,.08); }.ai-provider-form-grid,.ai-provider-advanced>div { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }.ai-provider-form-grid .wide,.ai-provider-advanced .wide { grid-column:1/-1; }.ai-model-table-heading { display:flex; align-items:center; justify-content:space-between; }.ai-model-table { display:grid; gap:9px; }.ai-model-row { display:grid; grid-template-columns:1fr 1fr repeat(3,auto) 110px auto auto; gap:8px; align-items:center; padding:11px; border:1px solid #e3e8e5; border-radius:8px; }.ai-model-row label { display:flex; align-items:center; gap:4px; white-space:nowrap; font-weight:400; }.ai-provider-advanced,.ai-provider-preview { border:1px solid #e3e8e5; border-radius:8px; }.ai-provider-advanced summary,.ai-provider-preview summary { padding:12px 14px; cursor:pointer; color:#46514c; font-size:13px; font-weight:600; }.ai-provider-advanced>div { padding:2px 14px 14px; }.ai-provider-advanced textarea { min-height:90px; resize:vertical; }.ai-provider-preview pre { overflow:auto; max-height:280px; margin:0; padding:0 14px 14px; color:#46514c; font:11px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; }.ai-provider-editor>footer { display:flex; justify-content:flex-end; gap:8px; }
@keyframes settings-spin { to { transform:rotate(360deg); } }
@media (max-width:900px) {
.settings-sidebar { width:248px; flex-basis:248px; }
@@ -3664,6 +3667,7 @@ body {
.settings-account-actions { grid-row:1; grid-column:2; }
.database-key-security-info { grid-template-columns:1fr; }
.image-decrypt-status dl { grid-template-columns:repeat(2,minmax(0,1fr)); }.image-decrypt-status .image-decrypt-wide { grid-column:span 2; }
.ai-model-row { grid-template-columns:1fr 1fr; }.ai-model-row label,.ai-model-row button { justify-self:start; }
}
@media (max-width:700px) {
.settings-account-overview { grid-template-columns:minmax(0,1fr); }
@@ -3672,6 +3676,7 @@ body {
.database-key-status-card dl,.database-key-diagnostics dl { grid-template-columns:1fr; }
.database-key-auto-heading { flex-direction:column; }.database-key-phases { grid-template-columns:1fr; }.database-key-phases li { padding:0 0 0 28px; text-align:left; }.database-key-phases li::before { top:-2px; left:0; transform:none; }
.image-decrypt-status dl,.image-key-grid { grid-template-columns:1fr; }.image-decrypt-status .image-decrypt-wide { grid-column:1; }.image-auto-heading { flex-direction:column; }.image-auto-phases { grid-template-columns:1fr; }.image-auto-phases li { padding:0 0 0 28px; text-align:left; }.image-auto-phases li::before { top:-2px; left:0; transform:none; }.image-auto-success { grid-template-columns:1fr; }.image-auto-success button { grid-column:1; grid-row:auto; justify-self:start; }.image-resource-checks>div { align-items:flex-start; }.image-resource-checks small { white-space:normal; text-align:right; }
.ai-provider-card dl,.ai-provider-form-grid,.ai-provider-advanced>div { grid-template-columns:1fr; }.ai-provider-form-grid .wide,.ai-provider-advanced .wide { grid-column:1; }.ai-model-row { grid-template-columns:1fr; }
}
.api-center-layout > * { min-width: 0; min-height: 0; box-sizing: border-box; }
.api-section-heading h2, .api-introduction h2, .api-integrations h2, .api-runtime-title h2 { margin: 0; color: var(--wxex-text-primary); font: 700 17px/24px var(--wxex-font); }
@@ -83,20 +83,21 @@ export function AiReportWorkspace({
const configDisabled = isGenerating
const disabledReason = useMemo(() => {
if (!sourceContact) return '请先选择群聊'
if (!modelConfig.apiKey.trim()) return '请先配置 API Key'
if (!modelConfig.configured) return '请先配置默认 AI 模型'
if (rangeState.status === 'loading') return '正在计算消息数量'
if (rangeState.status === 'error') return rangeState.error
if (!reportMessageCount) return '当前范围没有可总结消息'
if (!summaryMessageTypes.length) return '请至少选择一种消息类型'
return ''
}, [modelConfig.apiKey, rangeState, reportMessageCount, sourceContact, summaryMessageTypes.length])
}, [
modelConfig.configured,
rangeState,
reportMessageCount,
sourceContact,
summaryMessageTypes.length
])
const canGenerate = !isGenerating && !disabledReason
const modelStatus =
modelConfig.apiKey.trim() && modelConfig.baseURL.trim()
? '配置正常'
: modelConfig.apiKey.trim() || modelConfig.baseURL.trim()
? '配置不完整'
: '尚未配置'
const modelStatus = modelConfig.configured ? '配置正常' : '尚未配置'
const handleCopy = async (): Promise<void> => {
const result = await onCopyImage()
@@ -143,10 +144,7 @@ export function AiReportWorkspace({
/>
<ReportSectionSelector />
<ReportDensitySelector />
<ModelSummary
config={modelConfig}
onOpenSettings={onOpenModelSettings}
/>
<ModelSummary config={modelConfig} onOpenSettings={onOpenModelSettings} />
<section className="report-privacy-note">
<h3></h3>
<p></p>
@@ -184,7 +182,8 @@ export function AiReportWorkspace({
<footer className="ai-report-footer">
<span className="report-footer-note">
{modelLabel(modelConfig.model)} · {modelStatus} ·
{modelConfig.modelName || modelLabel(modelConfig.model)} · {modelStatus} ·
</span>
<div className="report-footer-actions">
{disabledReason && !isGenerating && <span>{disabledReason}</span>}
@@ -6,25 +6,8 @@ interface ModelSummaryProps {
onOpenSettings: () => void
}
const MODEL_OPTIONS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
{ value: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet' },
{ value: 'moonshot-v1-8k', label: 'Moonshot V1' }
]
const modelLabel = (model: string): string =>
MODEL_OPTIONS.find((option) => option.value === model)?.label || model || '未选择模型'
export function ModelSummary({
config,
onOpenSettings
}: ModelSummaryProps): React.ReactElement {
const hasApiKey = Boolean(config.apiKey.trim())
const hasBaseUrl = Boolean(config.baseURL.trim())
const statusText = hasApiKey && hasBaseUrl ? '配置正常' : hasApiKey || hasBaseUrl ? '配置不完整' : '尚未配置'
export function ModelSummary({ config, onOpenSettings }: ModelSummaryProps): React.ReactElement {
const statusText = config.configured ? '配置正常' : '尚未配置'
return (
<section className="report-config-section">
@@ -32,7 +15,7 @@ export function ModelSummary({
<div>
<h3></h3>
<p>
{modelLabel(config.model)} · {statusText}
{config.modelName || config.model || '未选择模型'} · {statusText}
</p>
</div>
<button type="button" onClick={onOpenSettings}>
@@ -5,7 +5,9 @@ import type { SettingsCategoryId, SettingsSelfInfo } from './model/types'
import { AccountDatabasePage } from './pages/AccountDatabasePage'
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
import { AIModelPage } from './pages/AIModelPage'
import type { Contact } from '../../../../shared/types'
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
export function SettingsWorkspace({
selectedCategory,
@@ -18,6 +20,7 @@ export function SettingsWorkspace({
onSelfInfoChange,
onContactsChange,
onFilteredContactsChange,
onAIRuntimeChange,
onNotice,
onOpenSettings
}: {
@@ -31,6 +34,7 @@ export function SettingsWorkspace({
onSelfInfoChange: (info: SettingsSelfInfo | null) => void
onContactsChange: (contacts: Contact[]) => void
onFilteredContactsChange: (contacts: Contact[]) => void
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
onNotice: (message: string) => void
onOpenSettings: () => void
}): React.ReactElement {
@@ -64,6 +68,8 @@ export function SettingsWorkspace({
/>
) : selectedCategory === 'image-key' ? (
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
) : selectedCategory === 'ai-model' ? (
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
) : (
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
)}
@@ -0,0 +1,64 @@
import type { AIProviderSummary } from '../../../../../shared/ai-provider'
import { PROVIDER_TYPE_LABELS } from './presets'
const STATUS_LABELS = { untested: '未测试', connected: '已连接', error: '连接失败' }
export function AIProviderCard({
provider,
testing,
onEdit,
onTest,
onDefault,
onDelete
}: {
provider: AIProviderSummary
testing: boolean
onEdit: () => void
onTest: () => void
onDefault: () => void
onDelete: () => void
}): React.ReactElement {
const model = provider.models.find((item) => item.id === provider.defaultModel)
return (
<article className="ai-provider-card">
<header>
<div>
<h3>{provider.name}</h3>
<span>{PROVIDER_TYPE_LABELS[provider.type]}</span>
</div>
<span className={`ai-provider-status ${provider.status}`}>
{STATUS_LABELS[provider.status]}
</span>
</header>
<dl>
<div>
<dt></dt>
<dd title={provider.baseUrl}>{provider.baseUrl}</dd>
</div>
<div>
<dt></dt>
<dd>{model?.name || provider.defaultModel}</dd>
</div>
<div>
<dt>API Key</dt>
<dd>
{provider.hasApiKey ? '已安全保存' : provider.type === 'ollama' ? '无需配置' : '未配置'}
</dd>
</div>
</dl>
{provider.lastError ? <p className="ai-provider-error">{provider.lastError}</p> : null}
<footer>
<button onClick={onEdit}></button>
<button disabled={testing} onClick={onTest}>
{testing ? '测试中…' : '测试连接'}
</button>
<button disabled={provider.isDefault} onClick={onDefault}>
{provider.isDefault ? '当前默认' : '设为默认'}
</button>
<button className="danger" onClick={onDelete}>
</button>
</footer>
</article>
)
}
@@ -0,0 +1,300 @@
import type {
AIModelDefinition,
AIProviderConfig,
AIProviderType
} from '../../../../../shared/ai-provider'
import { PROVIDER_PRESETS, PROVIDER_TYPE_LABELS } from './presets'
export function AIProviderEditor({
provider,
presetId,
editing,
saving,
onPreset,
onChange,
onCancel,
onSave
}: {
provider: AIProviderConfig
presetId: string
editing: boolean
saving: boolean
onPreset: (id: string) => void
onChange: (provider: AIProviderConfig) => void
onCancel: () => void
onSave: () => void
}): React.ReactElement {
const patch = (value: Partial<AIProviderConfig>): void => onChange({ ...provider, ...value })
const patchModel = (index: number, value: Partial<AIModelDefinition>): void => {
const models = provider.models.map((model, modelIndex) =>
modelIndex === index ? { ...model, ...value } : model
)
patch({
models,
defaultModel: models.some((model) => model.id === provider.defaultModel)
? provider.defaultModel
: models[0]?.id || ''
})
}
const preview = JSON.stringify(
{ ...provider, apiKey: provider.apiKey ? '***' : undefined },
null,
2
)
return (
<section className="settings-card ai-provider-editor">
<header>
<div>
<h2>{editing ? '编辑供应商' : '新增供应商'}</h2>
<p>API Key </p>
</div>
<button onClick={onCancel}></button>
</header>
{!editing ? (
<label>
<select value={presetId} onChange={(event) => onPreset(event.target.value)}>
{PROVIDER_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
</label>
) : null}
<div className="ai-provider-form-grid">
<label>
<input value={provider.name} onChange={(event) => patch({ name: event.target.value })} />
</label>
<label>
ID
<input
value={provider.id}
disabled={editing}
onChange={(event) => patch({ id: event.target.value })}
/>
</label>
<label>
<select
value={provider.type}
onChange={(event) => patch({ type: event.target.value as AIProviderType })}
>
{Object.entries(PROVIDER_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<label>
<select
value={provider.auth.type}
onChange={(event) =>
patch({
auth: {
...provider.auth,
type: event.target.value as AIProviderConfig['auth']['type']
}
})
}
>
<option value="bearer">Authorization Bearer</option>
<option value="x-api-key">X-API-Key</option>
<option value="custom-header"> Header</option>
<option value="none"></option>
</select>
</label>
<label className="wide">
Base URL
<input
value={provider.baseUrl}
onChange={(event) => patch({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
/>
</label>
<label>
API Key
<input
type="password"
value={provider.apiKey || ''}
onChange={(event) => patch({ apiKey: event.target.value })}
placeholder="留空则保留已保存的 Key"
autoComplete="off"
/>
</label>
<label>
<input
value={provider.auth.headerName || ''}
disabled={provider.auth.type !== 'custom-header'}
onChange={(event) =>
patch({ auth: { ...provider.auth, headerName: event.target.value } })
}
placeholder="Authorization"
/>
</label>
</div>
<div className="ai-model-table-heading">
<h3></h3>
<button onClick={() => patch({ models: [...provider.models, emptyModel()] })}>
</button>
</div>
<div className="ai-model-table">
{provider.models.map((model, index) => (
<div className="ai-model-row" key={`${index}-${model.id}`}>
<input
value={model.name}
onChange={(event) => patchModel(index, { name: event.target.value })}
placeholder="显示名称"
/>
<input
value={model.id}
onChange={(event) => patchModel(index, { id: event.target.value })}
placeholder="实际模型 ID"
/>
<label>
<input
type="checkbox"
checked={model.capabilities.chat}
onChange={(event) =>
patchModel(index, {
capabilities: { ...model.capabilities, chat: event.target.checked }
})
}
/>
</label>
<label>
<input
type="checkbox"
checked={model.capabilities.vision}
onChange={(event) =>
patchModel(index, {
capabilities: { ...model.capabilities, vision: event.target.checked }
})
}
/>
</label>
<label>
<input
type="checkbox"
checked={model.capabilities.longContext}
onChange={(event) =>
patchModel(index, {
capabilities: { ...model.capabilities, longContext: event.target.checked }
})
}
/>
</label>
<input
type="number"
value={model.maxTokens || ''}
onChange={(event) =>
patchModel(index, { maxTokens: Number(event.target.value) || undefined })
}
placeholder="最大 Token"
/>
<label className="ai-model-default">
<input
type="radio"
name="default-model"
checked={provider.defaultModel === model.id}
onChange={() => patch({ defaultModel: model.id })}
/>
</label>
<button
className="danger"
disabled={provider.models.length === 1}
onClick={() =>
patch({ models: provider.models.filter((_, itemIndex) => itemIndex !== index) })
}
>
</button>
</div>
))}
</div>
<details className="ai-provider-advanced">
<summary></summary>
<div>
<label>
ms
<input
type="number"
value={provider.advanced.timeoutMs}
onChange={(event) =>
patch({ advanced: { ...provider.advanced, timeoutMs: Number(event.target.value) } })
}
/>
</label>
<label>
Temperature
<input
type="number"
step="0.1"
value={provider.advanced.temperature ?? ''}
onChange={(event) =>
patch({
advanced: { ...provider.advanced, temperature: Number(event.target.value) }
})
}
/>
</label>
<label>
Max Tokens
<input
type="number"
value={provider.advanced.maxTokens ?? ''}
onChange={(event) =>
patch({ advanced: { ...provider.advanced, maxTokens: Number(event.target.value) } })
}
/>
</label>
<label className="wide">
HeadersJSON
<textarea
key={JSON.stringify(provider.advanced.extraHeaders)}
defaultValue={JSON.stringify(provider.advanced.extraHeaders, null, 2)}
onBlur={(event) => {
try {
patch({
advanced: {
...provider.advanced,
extraHeaders: JSON.parse(event.target.value) as Record<string, string>
}
})
} catch {
/* Keep the last valid headers. */
}
}}
/>
</label>
</div>
</details>
<details className="ai-provider-preview">
<summary></summary>
<pre>{preview}</pre>
</details>
<footer>
<button onClick={onCancel}></button>
<button className="database-key-primary" disabled={saving} onClick={onSave}>
{saving ? '保存中…' : '保存供应商'}
</button>
</footer>
</section>
)
}
function emptyModel(): AIModelDefinition {
return { name: '', id: '', capabilities: { chat: true, vision: false, longContext: false } }
}
@@ -0,0 +1,48 @@
import type { AIModelSettingsAction, AIModelSettingsState } from './types'
export const initialAIModelSettingsState: AIModelSettingsState = {
loading: true,
saving: false,
providers: [],
runtime: null,
editor: null,
presetId: 'deepseek'
}
export function aiModelSettingsReducer(
state: AIModelSettingsState,
action: AIModelSettingsAction
): AIModelSettingsState {
switch (action.type) {
case 'LOADED':
return {
...state,
loading: false,
saving: false,
testingId: undefined,
providers: action.providers,
runtime: action.runtime,
error: undefined
}
case 'ERROR':
return { ...state, loading: false, saving: false, testingId: undefined, error: action.error }
case 'OPEN_EDITOR':
return {
...state,
editor: action.editor,
presetId: action.presetId,
originalProviderId: action.originalProviderId,
error: undefined
}
case 'CLOSE_EDITOR':
return { ...state, editor: null, originalProviderId: undefined, error: undefined }
case 'EDIT':
return { ...state, editor: action.editor, error: undefined }
case 'SAVE_START':
return { ...state, saving: true, error: undefined }
case 'TEST_START':
return { ...state, testingId: action.providerId, error: undefined }
default:
return state
}
}
@@ -0,0 +1,97 @@
import type { AIProviderConfig, AIProviderType } from '../../../../../shared/ai-provider'
export const PROVIDER_TYPE_LABELS: Record<AIProviderType, string> = {
'openai-compatible': 'OpenAI Compatible',
'anthropic-messages': 'Anthropic Messages',
'azure-openai': 'Azure OpenAI',
ollama: 'Ollama',
custom: '自定义'
}
export const PROVIDER_PRESETS = [
{
id: 'openai',
label: 'OpenAI',
type: 'openai-compatible',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini'
},
{
id: 'anthropic',
label: 'Anthropic',
type: 'anthropic-messages',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude-3-5-sonnet-latest'
},
{
id: 'deepseek',
label: 'DeepSeek',
type: 'openai-compatible',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat'
},
{
id: 'qwen',
label: '通义千问',
type: 'openai-compatible',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
model: 'qwen-plus'
},
{
id: 'moonshot',
label: 'Moonshot',
type: 'openai-compatible',
baseUrl: 'https://api.moonshot.cn/v1',
model: 'moonshot-v1-8k'
},
{
id: 'minimax',
label: 'MiniMax',
type: 'openai-compatible',
baseUrl: 'https://api.minimax.chat/v1',
model: 'MiniMax-Text-01'
},
{
id: 'ollama',
label: 'Ollama',
type: 'ollama',
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen2.5'
},
{
id: 'custom',
label: '自建 OpenAI Compatible API',
type: 'openai-compatible',
baseUrl: '',
model: ''
}
] as const
export function createProviderFromPreset(presetId = 'deepseek'): AIProviderConfig {
const preset = PROVIDER_PRESETS.find((item) => item.id === presetId) || PROVIDER_PRESETS[2]
const id = `${preset.id}-${Date.now().toString(36)}`
return {
id,
name: preset.label,
type: preset.type,
baseUrl: preset.baseUrl,
apiKey: '',
auth: {
type:
preset.type === 'ollama'
? 'none'
: preset.type === 'anthropic-messages'
? 'x-api-key'
: 'bearer'
},
models: [
{
name: preset.model || '默认模型',
id: preset.model,
capabilities: { chat: true, vision: false, longContext: false }
}
],
defaultModel: preset.model,
advanced: { timeoutMs: 120000, temperature: 0.7, maxTokens: 4096, extraHeaders: {} }
}
}
@@ -0,0 +1,44 @@
import type {
AIProviderConfig,
AIProviderSummary,
AIRuntimeModelConfig
} from '../../../../../shared/ai-provider'
export interface AIModelSettingsState {
loading: boolean
saving: boolean
providers: AIProviderSummary[]
runtime: AIRuntimeModelConfig | null
editor: AIProviderConfig | null
originalProviderId?: string
presetId: string
testingId?: string
error?: string
}
export type AIModelSettingsAction =
| { type: 'LOADED'; providers: AIProviderSummary[]; runtime: AIRuntimeModelConfig }
| { type: 'ERROR'; error: string }
| {
type: 'OPEN_EDITOR'
editor: AIProviderConfig
presetId: string
originalProviderId?: string
}
| { type: 'CLOSE_EDITOR' }
| { type: 'EDIT'; editor: AIProviderConfig }
| { type: 'SAVE_START' }
| { type: 'TEST_START'; providerId: string }
export interface AIModelSettingsController {
state: AIModelSettingsState
openNew: () => void
openEdit: (provider: AIProviderSummary) => void
closeEditor: () => void
selectPreset: (presetId: string) => void
updateEditor: (editor: AIProviderConfig) => void
save: () => Promise<void>
remove: (providerId: string) => Promise<void>
setDefault: (providerId: string) => Promise<void>
test: (providerId: string) => Promise<void>
}
@@ -0,0 +1,123 @@
import { useCallback, useEffect, useReducer } from 'react'
import type {
AIProviderConfig,
AIProviderSummary,
AIRuntimeModelConfig
} from '../../../../../shared/ai-provider'
import { aiModelSettingsReducer, initialAIModelSettingsState } from './aiModelSettingsReducer'
import { createProviderFromPreset } from './presets'
import type { AIModelSettingsController } from './types'
export function useAIModelSettingsController({
onRuntimeChange,
onNotice
}: {
onRuntimeChange: (config: AIRuntimeModelConfig) => void
onNotice: (message: string) => void
}): AIModelSettingsController {
const [state, dispatch] = useReducer(aiModelSettingsReducer, initialAIModelSettingsState)
const refresh = useCallback(async (): Promise<void> => {
const [list, runtime] = await Promise.all([
window.api.listAIProviders(),
window.api.getAIRuntimeConfig()
])
if (!list.success) return dispatch({ type: 'ERROR', error: list.error || '供应商配置读取失败' })
dispatch({ type: 'LOADED', providers: list.providers, runtime })
onRuntimeChange(runtime)
}, [onRuntimeChange])
useEffect(() => {
void refresh()
}, [refresh])
const openNew = useCallback(() => {
dispatch({ type: 'OPEN_EDITOR', editor: createProviderFromPreset(), presetId: 'deepseek' })
}, [])
const openEdit = useCallback((provider: AIProviderSummary) => {
dispatch({
type: 'OPEN_EDITOR',
editor: {
id: provider.id,
name: provider.name,
type: provider.type,
baseUrl: provider.baseUrl,
apiKey: '',
auth: provider.auth,
models: provider.models,
defaultModel: provider.defaultModel,
advanced: provider.advanced
},
presetId: 'custom',
originalProviderId: provider.id
})
}, [])
const closeEditor = useCallback(() => dispatch({ type: 'CLOSE_EDITOR' }), [])
const selectPreset = useCallback((presetId: string) => {
dispatch({ type: 'OPEN_EDITOR', editor: createProviderFromPreset(presetId), presetId })
}, [])
const updateEditor = useCallback(
(editor: AIProviderConfig) => dispatch({ type: 'EDIT', editor }),
[]
)
const save = useCallback(async (): Promise<void> => {
if (!state.editor) return
dispatch({ type: 'SAVE_START' })
const result = await window.api.saveAIProvider(state.editor)
if (!result.success) return dispatch({ type: 'ERROR', error: result.error || '供应商保存失败' })
dispatch({ type: 'CLOSE_EDITOR' })
await refresh()
onNotice('AI 供应商已安全保存')
}, [onNotice, refresh, state.editor])
const remove = useCallback(
async (providerId: string): Promise<void> => {
if (!window.confirm('确认删除这个 AI 供应商?安全存储中的 API Key 也会一并清除。')) return
const result = await window.api.deleteAIProvider(providerId)
if (!result.success)
return dispatch({ type: 'ERROR', error: result.error || '供应商删除失败' })
await refresh()
onNotice('AI 供应商已删除')
},
[onNotice, refresh]
)
const setDefault = useCallback(
async (providerId: string): Promise<void> => {
const result = await window.api.setDefaultAIProvider(providerId)
if (!result.success)
return dispatch({ type: 'ERROR', error: result.error || '默认模型更新失败' })
await refresh()
onNotice('默认 AI 模型已更新')
},
[onNotice, refresh]
)
const test = useCallback(
async (providerId: string): Promise<void> => {
dispatch({ type: 'TEST_START', providerId })
const result = await window.api.testAIProvider(providerId)
await refresh()
onNotice(
result.success
? `连接成功,耗时 ${result.latencyMs || 0} ms`
: result.error || '连接测试失败'
)
},
[onNotice, refresh]
)
return {
state,
openNew,
openEdit,
closeEditor,
selectPreset,
updateEditor,
save,
remove,
setDefault,
test
}
}
@@ -0,0 +1,77 @@
import type { AIRuntimeModelConfig } from '../../../../../shared/ai-provider'
import { AIProviderCard } from '../ai-model/AIProviderCard'
import { AIProviderEditor } from '../ai-model/AIProviderEditor'
import { useAIModelSettingsController } from '../ai-model/useAIModelSettingsController'
export function AIModelPage({
onRuntimeChange,
onNotice
}: {
onRuntimeChange: (config: AIRuntimeModelConfig) => void
onNotice: (message: string) => void
}): React.ReactElement {
const controller = useAIModelSettingsController({ onRuntimeChange, onNotice })
const runtime = controller.state.runtime
return (
<div className="settings-page ai-model-page">
<header className="settings-page-header">
<div>
<h1>AI </h1>
<p></p>
</div>
<button className="database-key-primary" onClick={controller.openNew}>
</button>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content ai-model-content">
<section className="settings-card ai-model-default">
<div>
<span></span>
<strong>{runtime?.modelName || '尚未配置'}</strong>
<small>{runtime?.providerName || '请添加供应商'}</small>
</div>
<span className={`settings-status-badge ${runtime?.configured ? '' : 'unavailable'}`}>
{runtime?.configured ? '可用' : '未配置'}
</span>
</section>
{controller.state.error ? (
<p className="ai-model-page-error">{controller.state.error}</p>
) : null}
{controller.state.editor ? (
<AIProviderEditor
provider={controller.state.editor}
presetId={controller.state.presetId}
editing={Boolean(controller.state.originalProviderId)}
saving={controller.state.saving}
onPreset={controller.selectPreset}
onChange={controller.updateEditor}
onCancel={controller.closeEditor}
onSave={() => void controller.save()}
/>
) : null}
<h2 className="settings-section-heading"></h2>
<div className="ai-provider-list">
{controller.state.providers.map((provider) => (
<AIProviderCard
key={provider.id}
provider={provider}
testing={controller.state.testingId === provider.id}
onEdit={() => controller.openEdit(provider)}
onTest={() => void controller.test(provider.id)}
onDefault={() => void controller.setDefault(provider.id)}
onDelete={() => void controller.remove(provider.id)}
/>
))}
{!controller.state.loading && !controller.state.providers.length ? (
<div className="settings-card ai-provider-empty">
<strong> AI </strong>
<p> OpenAIAnthropicDeepSeekOllama </p>
</div>
) : null}
</div>
</div>
</div>
</div>
)
}
@@ -23,9 +23,12 @@ export type ReportGenerationPhase =
| 'error'
export interface AiModelConfig {
apiKey: string
baseURL: string
providerId?: string
providerName: string
model: string
modelName: string
configured: boolean
status: 'untested' | 'connected' | 'error'
}
export interface ReportPaths {
@@ -277,9 +280,9 @@ export function useGroupReportGeneration({
setError('AI 群聊日报仅支持群聊')
return
}
if (!modelConfig.apiKey.trim()) {
if (!modelConfig.configured) {
setPhase('error')
setError('尚未配置 API Key')
setError('尚未配置可用的默认 AI 模型')
return
}
if (!summaryMessageTypes.length) {
@@ -339,7 +342,13 @@ export function useGroupReportGeneration({
{ role: 'user', content: input.prompt }
]
const result = await trackStep('AI 生成', () =>
withTimeout(window.api.aiChat(aiMessages, modelConfig), 'AI 生成日报')
withTimeout(
window.api.aiChat(aiMessages, {
providerId: modelConfig.providerId,
modelId: modelConfig.model
}),
'AI 生成日报'
)
)
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')