222 lines
8.2 KiB
TypeScript
222 lines
8.2 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { Eye, EyeOff, Loader2, RotateCcw, Trash2, Zap, CheckCircle2, XCircle } from "lucide-react"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import { cn } from "@/lib/utils"
|
|
import { useAISettings } from "./ai-settings-provider"
|
|
import { DEFAULT_AI_SETTINGS, type AISettings } from "@/lib/ai/types"
|
|
import { requestOpenAICompatible } from "@/lib/ai/client"
|
|
import { validateForRequest } from "@/lib/ai/settings"
|
|
|
|
type TestState = { status: "idle" | "loading" | "ok" | "error"; message: string }
|
|
|
|
export function AISettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
|
const { settings, setSettings } = useAISettings()
|
|
const [draft, setDraft] = useState<AISettings>(settings)
|
|
const [showKey, setShowKey] = useState(false)
|
|
const [test, setTest] = useState<TestState>({ status: "idle", message: "" })
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setDraft(settings)
|
|
setTest({ status: "idle", message: "" })
|
|
}
|
|
}, [open, settings])
|
|
|
|
function update<K extends keyof AISettings>(key: K, value: AISettings[K]) {
|
|
setDraft((d) => ({ ...d, [key]: value }))
|
|
}
|
|
|
|
function handleSave() {
|
|
setSettings(draft)
|
|
onOpenChange(false)
|
|
}
|
|
|
|
function handleReset() {
|
|
setDraft({ ...DEFAULT_AI_SETTINGS })
|
|
setTest({ status: "idle", message: "" })
|
|
}
|
|
|
|
function handleClearKey() {
|
|
update("apiKey", "")
|
|
}
|
|
|
|
async function handleTest() {
|
|
const err = validateForRequest(draft)
|
|
if (err) {
|
|
setTest({ status: "error", message: err })
|
|
return
|
|
}
|
|
setTest({ status: "loading", message: "正在测试连接..." })
|
|
try {
|
|
const reply = await requestOpenAICompatible({
|
|
baseUrl: draft.baseUrl,
|
|
apiKey: draft.apiKey,
|
|
model: draft.model,
|
|
messages: [{ role: "user", content: "ping,请只回复 ok" }],
|
|
temperature: 0,
|
|
maxTokens: 16,
|
|
})
|
|
setTest({ status: "ok", message: `连接成功 · 模型回复:${reply.slice(0, 40)}` })
|
|
} catch (e) {
|
|
setTest({ status: "error", message: (e as Error).message })
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="glass-panel flex max-h-[90vh] flex-col gap-0 overflow-hidden sm:max-w-2xl">
|
|
<DialogHeader className="shrink-0">
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Zap className="size-5 text-primary" />
|
|
AI 设置
|
|
</DialogTitle>
|
|
<DialogDescription>配置 OpenAI-compatible 接口。API Key 仅保存在本地浏览器,不会写入数据库。</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-3 pr-1">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="baseUrl">API Base URL</Label>
|
|
<Input
|
|
id="baseUrl"
|
|
value={draft.baseUrl}
|
|
onChange={(e) => update("baseUrl", e.target.value)}
|
|
placeholder="https://api.openai.com/v1"
|
|
className="bg-input/60 font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="apiKey">API Key</Label>
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<Input
|
|
id="apiKey"
|
|
type={showKey ? "text" : "password"}
|
|
value={draft.apiKey}
|
|
onChange={(e) => update("apiKey", e.target.value)}
|
|
placeholder="sk-..."
|
|
className="bg-input/60 pr-10 font-mono text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowKey((v) => !v)}
|
|
aria-label={showKey ? "隐藏" : "显示"}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showKey ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
</button>
|
|
</div>
|
|
<Button type="button" variant="outline" onClick={handleClearKey} className="gap-1.5">
|
|
<Trash2 className="size-4" />
|
|
清除
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="model">Model</Label>
|
|
<Input
|
|
id="model"
|
|
value={draft.model}
|
|
onChange={(e) => update("model", e.target.value)}
|
|
placeholder="gpt-4o-mini / deepseek-chat / qwen-plus"
|
|
className="bg-input/60 font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="temp">Temperature</Label>
|
|
<Input
|
|
id="temp"
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
max="2"
|
|
value={draft.temperature}
|
|
onChange={(e) => update("temperature", Number(e.target.value))}
|
|
className="bg-input/60 font-mono text-sm"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="maxTokens">Max Tokens</Label>
|
|
<Input
|
|
id="maxTokens"
|
|
type="number"
|
|
min="1"
|
|
value={draft.maxTokens}
|
|
onChange={(e) => update("maxTokens", Number(e.target.value))}
|
|
className="bg-input/60 font-mono text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="systemPrompt">System Prompt</Label>
|
|
<Textarea
|
|
id="systemPrompt"
|
|
value={draft.systemPrompt}
|
|
onChange={(e) => update("systemPrompt", e.target.value)}
|
|
className="min-h-24 bg-input/60 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
|
|
<div>
|
|
<Label htmlFor="stream" className="cursor-pointer">
|
|
启用流式输出 (stream)
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">部分接口需要关闭以兼容</p>
|
|
</div>
|
|
<Switch id="stream" checked={draft.stream} onCheckedChange={(v) => update("stream", v)} />
|
|
</div>
|
|
|
|
{test.status !== "idle" && (
|
|
<div
|
|
className={cn(
|
|
"flex items-start gap-2 rounded-lg border px-3 py-2 text-sm",
|
|
test.status === "ok" && "neon-success",
|
|
test.status === "error" && "neon-error",
|
|
test.status === "loading" && "border-border bg-muted/30 text-muted-foreground",
|
|
)}
|
|
>
|
|
{test.status === "loading" && <Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" />}
|
|
{test.status === "ok" && <CheckCircle2 className="mt-0.5 size-4 shrink-0" />}
|
|
{test.status === "error" && <XCircle className="mt-0.5 size-4 shrink-0" />}
|
|
<span className="break-words">{test.message}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="shrink-0 flex-col gap-2 sm:flex-row sm:justify-between">
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={handleReset} className="gap-1.5">
|
|
<RotateCcw className="size-4" />
|
|
恢复默认
|
|
</Button>
|
|
<Button variant="outline" onClick={handleTest} disabled={test.status === "loading"} className="gap-1.5">
|
|
{test.status === "loading" ? <Loader2 className="size-4 animate-spin" /> : <Zap className="size-4" />}
|
|
测试连接
|
|
</Button>
|
|
</div>
|
|
<Button onClick={handleSave}>保存设置</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|