Files
Veyra/web/admin/src/components/ProvidersPanel.tsx
T

43 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {useEffect,useState} from "react"
import {request,ApiError} from "@/lib/auth"
import {getProviders,saveProvider,type ProviderConfig,type ProviderKey} from "@/lib/providers"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
import {Card,CardContent,CardHeader,CardTitle,CardDescription} from "@/components/ui/card"
import {AIReviewPanel} from "./AIReviewPanel"
const titles:Record<ProviderKey,string>={model_primary:"主模型",model_backup:"备用模型",model_review:"审核模型",market_primary:"行情源"}
export function ProvidersPanel({kind,onSessionExpired}:{kind:"model"|"market";onSessionExpired:()=>void}){
const [configs,setConfigs]=useState<ProviderConfig[]>([])
const [loading,setLoading]=useState(true)
const [error,setError]=useState("")
const [generation,setGeneration]=useState(0)
const [notice,setNotice]=useState("")
useEffect(()=>{const controller=new AbortController();setLoading(true);setError("");getProviders(controller.signal).then(setConfigs).catch(e=>{if(controller.signal.aborted)return;if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"配置读取失败")}).finally(()=>{if(!controller.signal.aborted)setLoading(false)});return()=>controller.abort()},[generation,onSessionExpired])
const keys:ProviderKey[]=kind==="model"?["model_primary","model_review","model_backup"]:["market_primary"]
if(loading)return <p className="text-sm text-muted-foreground">正在读取配置…</p>
if(error)return <div role="alert" className="space-y-4"><p className="text-sm text-destructive">{error}</p><Button variant="outline" onClick={()=>setGeneration(v=>v+1)}>重新读取</Button></div>
async function copyReview(){setError("");try{const saved=await request<ProviderConfig>("/api/admin/v1/providers/model_review/copy-primary",{method:"POST",body:JSON.stringify({expected_version:configs.find(c=>c.config_key==="model_review")?.version??0})});setConfigs(prev=>[...prev.filter(c=>c.config_key!=="model_review"),saved]);setNotice("审核配置已复用主模型,仍执行独立复核调用。")}catch(e){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"复制失败")}}
return <div className="space-y-6">{kind==="model"&&<AIReviewPanel onSessionExpired={onSessionExpired}/>} {notice&&<p role="status" className="text-sm">{notice}</p>}<p className="text-sm leading-7 text-muted-foreground">供应商信息由管理员填写,初始留空。启用表示允许后续业务使用该配置,不代表连接已验证。{kind==="model"&&"模型接口按 OpenAI 兼容协议配置。"}</p>{kind==="model"&&<Button variant="outline" onClick={()=>void copyReview()}>审核模型复用主模型配置</Button>}{keys.map(key=><ProviderForm key={`${key}:${configs.find(c=>c.config_key===key)?.version??0}`} configKey={key} config={configs.find(c=>c.config_key===key)} onSessionExpired={onSessionExpired} onSaved={saved=>{setConfigs(prev=>[...prev.filter(c=>c.config_key!==key),saved]);setNotice(`${titles[key]}配置已保存。`)}} onReload={()=>setGeneration(v=>v+1)}/>)}</div>
}
function ProviderForm({configKey,config,onSaved,onReload,onSessionExpired}:{configKey:ProviderKey;config?:ProviderConfig;onSaved:(c:ProviderConfig)=>void;onReload:()=>void;onSessionExpired:()=>void}){
const [quoteURL,setQuoteURL]=useState(config?.quote_base_url??"")
const [marketAdapter,setMarketAdapter]=useState(config?.market_adapter??""),[historyURL,setHistoryURL]=useState(config?.history_base_url??"")
const [name,setName]=useState(config?.name??"")
const [baseURL,setBaseURL]=useState(config?.base_url??"")
const [modelName,setModelName]=useState(config?.model_name??"")
const [wireAPI,setWireAPI]=useState<"responses"|"chat_completions">(config?.wire_api??"responses")
const [catalogURL,setCatalogURL]=useState(config?.model_catalog_url??"")
const [disableStorage,setDisableStorage]=useState(config?.disable_response_storage??true)
const [requiresAuth,setRequiresAuth]=useState(config?.requires_openai_auth??true)
const [apiKey,setAPIKey]=useState("")
const [clearKey,setClearKey]=useState(false)
const [enabled,setEnabled]=useState(config?.enabled??false)
const [timeout,setTimeoutValue]=useState(config?.timeout_seconds??15)
const [saving,setSaving]=useState(false)
const [error,setError]=useState("")
async function submit(event:React.FormEvent){event.preventDefault();setSaving(true);setError("");try{const result=await saveProvider(configKey,{quote_base_url:quoteURL,market_adapter:marketAdapter,history_base_url:historyURL,name,base_url:baseURL,model_name:modelName,wire_api:wireAPI,model_catalog_url:catalogURL,disable_response_storage:disableStorage,requires_openai_auth:requiresAuth,enabled,timeout_seconds:timeout,clear_api_key:clearKey,expected_version:config?.version??0,...(apiKey?{api_key:apiKey}:{})});setAPIKey("");setClearKey(false);onSaved(result)}catch(e){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof ApiError&&e.status===409?"配置已被其他管理员修改,请重新读取。":e instanceof Error?e.message:"保存失败")}finally{setSaving(false)}}
const fieldClass="space-y-2 text-sm"
return <Card><CardHeader><div className="flex items-center justify-between"><CardTitle>{titles[configKey]}</CardTitle><Badge variant="outline">{config?.enabled?"已启用 · 未验证":"未启用"}</Badge></div><CardDescription>{config?`版本 ${config.version} · 更新于 ${new Date(config.updated_at).toLocaleString("zh-CN")}`:"尚未配置"}</CardDescription></CardHeader><CardContent><form onSubmit={submit} className="space-y-5"><fieldset disabled={saving} className="grid gap-5 sm:grid-cols-2">{configKey==="market_primary"&&<><label className={fieldClass}>行情适配器<select className="flex h-10 w-full rounded-md border bg-background px-3 text-sm" value={marketAdapter} onChange={e=>setMarketAdapter(e.target.value)}><option value="">尚未选择</option><option value="sina_public">新浪公开数据(开发研究)</option><option value="eastmoney_public">东方财富公开数据(开发研究)</option></select></label><label className={fieldClass}>报价地址(HTTPS)<Input type="url" value={quoteURL} onChange={e=>setQuoteURL(e.target.value)}/></label><label className={fieldClass}>历史行情地址(HTTPS)<Input type="url" value={historyURL} onChange={e=>setHistoryURL(e.target.value)}/></label></>}<label className={fieldClass}>供应商名称<Input value={name} onChange={e=>setName(e.target.value)} maxLength={80}/></label><label className={fieldClass}>接口地址(HTTPS)<Input value={baseURL} onChange={e=>setBaseURL(e.target.value)} type="url" maxLength={2048}/></label>{configKey!=="market_primary"&&<label className={fieldClass}>模型名称<Input value={modelName} onChange={e=>setModelName(e.target.value)} maxLength={120}/></label>}{configKey!=="market_primary"&&<><label className={fieldClass}>API 协议<select aria-label="API 协议" value={wireAPI} onChange={e=>setWireAPI(e.target.value as "responses"|"chat_completions")} className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"><option value="responses">Responses</option><option value="chat_completions">Chat Completions</option></select></label><label className={`${fieldClass} sm:col-span-2`}>模型目录地址(同源 HTTPS,可留空)<Input value={catalogURL} onChange={e=>setCatalogURL(e.target.value)} type="url" maxLength={2048}/></label><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={disableStorage} onChange={e=>setDisableStorage(e.target.checked)}/>禁止供应商存储响应</label><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={requiresAuth} onChange={e=>setRequiresAuth(e.target.checked)}/>使用 API 密钥认证</label></>}<label className={fieldClass}>请求超时(秒)<Input value={timeout} onChange={e=>setTimeoutValue(Number(e.target.value))} type="number" min={1} max={120} required/></label><label className={`${fieldClass} sm:col-span-2`}>API 密钥<Input type="password" autoComplete="new-password" value={apiKey} onChange={e=>{setAPIKey(e.target.value);setClearKey(false)}} maxLength={4096} placeholder={config?.has_api_key?"已保存密钥;留空保留原值":"尚未填写"}/><span className="block text-xs leading-6 text-muted-foreground">密钥加密保存。更换接口地址时请重新填写或清除已有密钥。</span></label><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={e=>setEnabled(e.target.checked)}/>启用此配置</label>{config?.has_api_key&&<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={clearKey} onChange={e=>{setClearKey(e.target.checked);if(e.target.checked)setAPIKey("")}}/>清除已保存密钥</label>}</fieldset>{error&&<div role="alert" className="space-y-3 text-sm text-destructive"><p>{error}</p>{error.includes("重新读取")&&<Button type="button" variant="outline" onClick={onReload}>重新读取</Button>}</div>}<Button type="submit" disabled={saving}>{saving?"正在保存…":"保存配置"}</Button></form></CardContent></Card>
}