feat: release OpsXShell 2.1.0 native Tauri edition

This commit is contained in:
Logan.Li committed 2026-07-30 23:32:51 +08:00
1 parent e9a0d21758
commit a3dba0c6ed
311 files changed
+31748 -14897

No files matched your search

@@ -0,0 +1,391 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, CheckCircle2, ChevronDown, KeyRound, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
import type { AiModelConfig, AiProviderConfig } from '../../../shared/types/ai.types'
import {
AI_PROVIDER_PRESETS,
createCustomProvider,
createProviderFromPreset,
} from '../../ai/providerPresets'
import { useAiProviderStore } from '../../stores/aiProviderStore'
type TestState = 'idle' | 'testing' | 'success' | 'error'
function selectedModel(provider: AiProviderConfig | null): AiModelConfig | null {
if (!provider) return null
return provider.models.find((model) => model.id === provider.defaultModelId) || null
}
function connectionSnapshot(provider: AiProviderConfig, apiKey: string): string {
const model = selectedModel(provider)
return JSON.stringify({
id: provider.id,
name: provider.name.trim(),
protocol: provider.protocol,
authStyle: provider.authStyle,
baseUrl: provider.baseUrl.trim(),
allowInsecureHttp: provider.allowInsecureHttp,
model,
credential: apiKey.trim() ? `entered:${apiKey.trim()}` : provider.hasApiKey ? 'stored' : 'missing',
})
}
function validDraft(provider: AiProviderConfig | null, apiKey: string): boolean {
if (!provider?.name.trim() || !provider.baseUrl.trim() || !selectedModel(provider)?.model.trim()) return false
return provider.authStyle === 'none' || provider.hasApiKey === true || apiKey.trim().length > 0
}
export default function AiProviderSettings() {
const store = useAiProviderStore()
const [selectedId, setSelectedId] = useState('')
const [presetId, setPresetId] = useState('deepseek')
const [draft, setDraft] = useState<AiProviderConfig | null>(null)
const [apiKey, setApiKey] = useState('')
const [manualModel, setManualModel] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [testState, setTestState] = useState<TestState>('idle')
const [verifiedSnapshot, setVerifiedSnapshot] = useState<string | null>(null)
const [message, setMessage] = useState('')
const [testAnswer, setTestAnswer] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
void store.load()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!selectedId && store.providers[0]) setSelectedId(store.providers[0].id)
}, [selectedId, store.providers])
useEffect(() => {
const provider = store.providers.find((item) => item.id === selectedId)
if (!provider) return
setDraft(structuredClone(provider))
setApiKey('')
setManualModel(provider.models.length === 0)
setTestState('idle')
setVerifiedSnapshot(null)
setMessage('')
setTestAnswer('')
}, [selectedId, store.providers])
const currentSnapshot = useMemo(() => (draft ? connectionSnapshot(draft, apiKey) : ''), [apiKey, draft])
const verified = !!currentSnapshot && verifiedSnapshot === currentSnapshot && testState === 'success'
const currentModel = selectedModel(draft)
const isStored = !!draft && store.providers.some((provider) => provider.id === draft.id)
const canTest = validDraft(draft, apiKey) && testState !== 'testing'
const chooseDraft = (provider: AiProviderConfig) => {
setSelectedId(provider.id)
setDraft(provider)
setApiKey('')
setManualModel(provider.models.length === 0)
setAdvancedOpen(false)
setTestState('idle')
setVerifiedSnapshot(null)
setMessage('请填写配置并测试连接;测试通过前不会保存任何内容')
setTestAnswer('')
}
const addPreset = () => chooseDraft(createProviderFromPreset(presetId))
const addCustom = () => chooseDraft(createCustomProvider())
const updateDraft = (changes: Partial<AiProviderConfig>) => {
if (!draft) return
setDraft({ ...draft, ...changes })
if (testState !== 'idle') setMessage('连接字段已变更,请重新测试')
}
const chooseModel = (modelId: string) => {
if (!draft) return
if (modelId === '__manual') {
setManualModel(true)
return
}
setManualModel(false)
updateDraft({ defaultModelId: modelId })
}
const updateManualModel = (modelId: string) => {
if (!draft) return
const active = selectedModel(draft)
if (active) {
const models = draft.models.map((model) =>
model.id === active.id ? { ...model, model: modelId, label: modelId || '自定义模型' } : model
)
updateDraft({ models })
return
}
const id = `manual-${Date.now().toString(36)}`
updateDraft({
models: [{ id, model: modelId, label: modelId || '自定义模型', capability: 'chat', billing: 'payg' }],
defaultModelId: id,
})
}
const testConnection = async () => {
if (!draft || !currentModel || !canTest) return
setTestState('testing')
setVerifiedSnapshot(null)
setMessage('正在发送最小请求验证 Endpoint、模型和凭据...')
setTestAnswer('')
const snapshot = currentSnapshot
try {
const result = await window.opsxApi.aiTestProvider({
provider: draft,
model: currentModel,
apiKey: apiKey.trim() || undefined,
})
if (!result.success) throw new Error(result.error || '连接测试失败')
setTestState('success')
setVerifiedSnapshot(snapshot)
setTestAnswer(result.answer?.trim() || '')
setMessage(`测试成功 · HTTP ${result.status ?? 200} · ${result.model || currentModel.model} · ${result.latencyMs ?? '-'} ms`)
} catch (error) {
setTestState('error')
setMessage(error instanceof Error ? error.message : String(error))
}
}
const save = async () => {
if (!draft || !verified) return
setSaving(true)
try {
await store.saveProvider(draft, apiKey)
if (!store.activeProviderId || !isStored) await store.setActiveProvider(draft.id)
setApiKey('')
setMessage('配置已安全保存,并可用于终端助手')
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error))
setTestState('error')
} finally {
setSaving(false)
}
}
const remove = async () => {
if (!draft) return
if (isStored) await store.deleteProvider(draft.id)
setDraft(null)
setSelectedId('')
setApiKey('')
setVerifiedSnapshot(null)
setMessage('')
setTestAnswer('')
}
return (
<div className="space-y-4">
<section className="border-dark-700/50 bg-dark-800/45 rounded-xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="供应商预设"
value={presetId}
onChange={(event) => setPresetId(event.target.value)}
className="ui-input-sm min-w-52 flex-1"
>
{AI_PROVIDER_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name} · {preset.description}
</option>
))}
</select>
<button type="button" onClick={addPreset} className="ui-btn-primary flex items-center gap-1.5">
<Plus className="h-3.5 w-3.5" /> 添加
</button>
<button type="button" onClick={addCustom} className="ui-btn-secondary">
自定义
</button>
</div>
<p className="mt-2 text-xs text-txt-muted">
新配置只在当前表单中暂存。真实请求测试成功后才可保存;API Key 仅由 Rust 加密存储。
</p>
</section>
<div className="border-dark-700/50 bg-dark-800/45 grid min-h-[440px] grid-cols-[180px_minmax(0,1fr)] overflow-hidden rounded-xl border">
<aside className="border-dark-700/50 border-r p-2">
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">已配置</div>
{store.providers.length === 0 ? (
<p className="px-2 py-4 text-xs leading-relaxed text-txt-faint">尚无配置,从上方选择供应商添加。</p>
) : (
store.providers.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => setSelectedId(provider.id)}
className={`mb-1 w-full rounded-lg px-3 py-2 text-left ${selectedId === provider.id ? 'bg-accent-blue/15 text-accent-blue' : 'text-content-secondary hover:bg-dark-700/60'}`}
>
<div className="flex items-center gap-1.5">
{store.activeProviderId === provider.id && <Check className="h-3 w-3 flex-shrink-0" />}
<span className="truncate text-sm font-medium">{provider.name}</span>
</div>
<div className={`mt-0.5 truncate text-[10px] ${provider.hasApiKey || provider.authStyle === 'none' ? 'text-emerald-400' : 'text-amber-400'}`}>
{provider.models.find((model) => model.id === provider.defaultModelId)?.label || '未选择模型'}
</div>
</button>
))
)}
</aside>
<main className="min-w-0 p-5">
{!draft ? (
<div className="flex h-full items-center justify-center text-sm text-txt-faint">选择已有配置,或添加新供应商</div>
) : (
<div className="mx-auto max-w-2xl space-y-4">
<div className="grid grid-cols-2 gap-3">
<Field label="供应商">
<input value={draft.name} onChange={(event) => updateDraft({ name: event.target.value })} className="ui-input-sm" />
</Field>
<Field label="模型">
{manualModel || draft.models.length === 0 ? (
<input
aria-label="模型"
value={currentModel?.model || ''}
onChange={(event) => updateManualModel(event.target.value)}
placeholder="例如 deepseek-chat"
className="ui-input-sm font-mono"
/>
) : (
<select value={draft.defaultModelId} onChange={(event) => chooseModel(event.target.value)} className="ui-input-sm">
{draft.models.map((model) => (
<option key={model.id} value={model.id}>{model.label || model.model}</option>
))}
<option value="__manual">手动输入模型...</option>
</select>
)}
</Field>
</div>
{manualModel && draft.models.length > 0 && (
<button type="button" className="text-xs text-accent-blue hover:underline" onClick={() => setManualModel(false)}>
返回预设模型列表
</button>
)}
<Field label="Endpoint">
<input
value={draft.baseUrl}
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
className="ui-input-sm font-mono"
/>
</Field>
{currentModel?.baseUrl && (
<p className="-mt-2 text-[10px] text-txt-faint">当前模型使用专用 Endpoint:{currentModel.baseUrl}</p>
)}
<Field label={draft.authStyle === 'none' ? 'API Key(无需填写)' : 'API Key'}>
<div className="relative">
<KeyRound className="absolute left-2.5 top-2 h-3.5 w-3.5 text-txt-faint" />
<input
type="password"
autoComplete="off"
value={apiKey}
disabled={draft.authStyle === 'none'}
onChange={(event) => setApiKey(event.target.value)}
placeholder={draft.hasApiKey ? '已保存;留空使用现有密钥' : draft.authStyle === 'none' ? '本地服务无需认证' : 'sk-...'}
className="ui-input-sm !pl-8"
/>
</div>
</Field>
<button
type="button"
onClick={() => setAdvancedOpen((open) => !open)}
className="flex items-center gap-1 text-xs text-txt-muted hover:text-content-secondary"
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
高级设置
</button>
{advancedOpen && (
<div className="border-dark-700/40 bg-dark-900/30 grid grid-cols-2 gap-3 rounded-lg border p-3">
<Field label="接口协议">
<select
value={draft.protocol}
onChange={(event) => updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })}
className="ui-input-sm"
>
<option value="openai-chat">OpenAI Chat</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic">Anthropic Messages</option>
</select>
</Field>
<Field label="认证方式">
<select
value={draft.authStyle}
onChange={(event) => updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })}
className="ui-input-sm"
>
<option value="bearer">Bearer Token</option>
<option value="x-api-key">x-api-key</option>
<option value="both">Bearer + x-api-key</option>
<option value="none">无需认证</option>
</select>
</Field>
<label className="col-span-2 flex items-start gap-2 text-xs text-txt-muted">
<input
type="checkbox"
checked={draft.allowInsecureHttp}
onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })}
className="mt-0.5"
/>
允许 HTTP(只用于可信本机或内网模型服务)
</label>
</div>
)}
<div className="border-dark-700/40 flex flex-wrap items-center gap-2 border-t pt-4">
<button
type="button"
onClick={() => void testConnection()}
disabled={!canTest}
className="ui-btn-secondary flex items-center gap-1.5"
>
{testState === 'testing' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wifi className="h-3.5 w-3.5" />}
{testState === 'testing' ? '测试中...' : '测试连接'}
</button>
<button
type="button"
onClick={() => void save()}
disabled={!verified || saving}
className="ui-btn-primary flex items-center gap-1.5"
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
保存
</button>
{isStored && store.activeProviderId !== draft.id && (
<button type="button" onClick={() => void store.setActiveProvider(draft.id)} className="ui-btn-secondary">
设为当前
</button>
)}
<div className="flex-1" />
<button type="button" title="删除配置" onClick={() => void remove()} className="ui-btn-secondary text-red-300">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className={`flex min-h-5 items-center gap-1.5 text-xs ${verified ? 'text-emerald-400' : testState === 'error' ? 'text-red-300' : 'text-txt-muted'}`} role={testState === 'error' ? 'alert' : 'status'}>
{verified && <CheckCircle2 className="h-3.5 w-3.5" />}
<span>{message || '修改连接字段后需要重新测试'}</span>
</div>
{verified && testAnswer && (
<div className="border-emerald-500/20 bg-emerald-500/5 rounded-lg border px-3 py-2 text-xs text-content-secondary">
<span className="text-txt-faint">模型回答:</span>{testAnswer}
</div>
)}
</div>
)}
</main>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label>
<span className="mb-1 block text-[11px] text-txt-muted">{label}</span>
{children}
</label>
)
}