feat: 初版
knowledge-base / deploy (push) Failing after 11s

This commit is contained in:
2026-06-23 19:09:11 +08:00
commit 73b9c9fb7a
61 changed files with 9549 additions and 0 deletions
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Loader2, Sparkles, X, Database, AlertTriangle } from "lucide-react"
import { toast } from "sonner"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
import { useAISettings } from "./ai-settings-provider"
import { PromptEditor } from "./prompt-editor"
import { AIResultPreview, type DraftRow } from "./ai-result-preview"
import { requestOpenAICompatible } from "@/lib/ai/client"
import { validateForRequest } from "@/lib/ai/settings"
import { BATCH_IMPORT_PROMPT } from "@/lib/ai/prompts"
import { safeParseAIJson } from "@/lib/ai/json"
import type { KnowledgeItem, KnowledgeItemInput } from "@/lib/types"
export function AIBatchImportDialog({
open,
onOpenChange,
onInserted,
}: {
open: boolean
onOpenChange: (o: boolean) => void
onInserted: (items: KnowledgeItem[]) => void
}) {
const { settings } = useAISettings()
const [rawInput, setRawInput] = useState("")
const [customPrompt, setCustomPrompt] = useState(BATCH_IMPORT_PROMPT)
const [loading, setLoading] = useState(false)
const [inserting, setInserting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rawResponse, setRawResponse] = useState<string | null>(null)
const [rows, setRows] = useState<DraftRow[]>([])
const [controller, setController] = useState<AbortController | null>(null)
useEffect(() => {
if (open) {
setError(null)
setRawResponse(null)
}
}, [open])
const selectedCount = useMemo(() => rows.filter((r) => r._selected).length, [rows])
async function handleGenerate() {
const err = validateForRequest(settings)
if (err) {
toast.error(err)
return
}
if (!rawInput.trim()) {
toast.error("请先粘贴题目内容")
return
}
setLoading(true)
setError(null)
setRawResponse(null)
setRows([])
const ac = new AbortController()
setController(ac)
try {
const content = await requestOpenAICompatible({
baseUrl: settings.baseUrl,
apiKey: settings.apiKey,
model: settings.model,
systemPrompt: settings.systemPrompt,
customPrompt,
messages: [{ role: "user", content: rawInput }],
temperature: settings.temperature,
maxTokens: settings.maxTokens,
signal: ac.signal,
})
const parsed = safeParseAIJson(content)
if (!parsed.ok) {
setError(parsed.error)
setRawResponse(parsed.raw)
return
}
setRows(
parsed.items.map((it, i) => ({
...it,
_id: `${Date.now()}-${i}`,
_selected: true,
})),
)
toast.success(`已生成 ${parsed.items.length}`)
} catch (e) {
if ((e as Error).name === "AbortError") {
toast.message("已取消生成")
return
}
setError((e as Error).message)
} finally {
setLoading(false)
setController(null)
}
}
function handleCancel() {
controller?.abort()
}
async function handleInsert() {
const selected = rows.filter((r) => r._selected)
if (selected.length === 0) {
toast.error("请至少勾选一条")
return
}
setInserting(true)
try {
const { createItems } = await import("@/lib/knowledge")
const inputs: KnowledgeItemInput[] = selected.map((r) => ({
title: r.title,
summary: r.summary,
content: r.content,
code_snippet: r.code_snippet,
tags: r.tags,
category: r.category,
difficulty: r.difficulty,
mastery: r.mastery,
is_favorite: false,
source_url: r.source_url,
notes: r.notes,
}))
const created = await createItems(inputs)
onInserted(created)
toast.success(`成功插入 ${created.length} 条知识点`)
// remove inserted rows from preview
setRows((prev) => prev.filter((r) => !r._selected))
} catch (e) {
toast.error(`插入失败:${(e as Error).message}`)
console.log("[v0] batch insert error:", e)
} finally {
setInserting(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => (loading ? null : onOpenChange(o))}>
<DialogContent className="glass-panel flex max-h-[92vh] flex-col gap-0 overflow-hidden sm:max-w-3xl">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-5 text-primary" />
AI
</DialogTitle>
<DialogDescription>
/ / AI
</DialogDescription>
</DialogHeader>
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-3 pr-1">
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 font-mono text-xs text-muted-foreground">
<span className="text-primary">model</span> {settings.model || "—"}
<span className="mx-1 opacity-40">|</span>
<span className="text-primary">base</span>
<span className="truncate">{settings.baseUrl || "—"}</span>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rawInput"></Label>
<Textarea
id="rawInput"
value={rawInput}
onChange={(e) => setRawInput(e.target.value)}
placeholder={"1. 什么是闭包?\n2. Vue2 和 Vue3 的区别?\n3. Promise.all 和 Promise.race 的区别?\n4. 实现防抖函数"}
className="min-h-28 bg-input/60 text-sm"
/>
</div>
<PromptEditor value={customPrompt} onChange={setCustomPrompt} rows={5} />
<div className="flex gap-2">
{loading ? (
<Button variant="outline" onClick={handleCancel} className="gap-1.5">
<X className="size-4" />
</Button>
) : (
<Button onClick={handleGenerate} disabled={!rawInput.trim()} className="ai-gradient gap-1.5">
<Sparkles className="size-4" />
</Button>
)}
{loading && (
<span className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
AI ...
</span>
)}
</div>
{error && (
<div className="neon-error grid gap-2 rounded-lg border px-3 py-2 text-sm">
<div className="flex items-center gap-2 font-medium">
<AlertTriangle className="size-4" />
{error}
</div>
{rawResponse && (
<div className="grid gap-1">
<span className="text-xs opacity-80"></span>
<Textarea readOnly value={rawResponse} className="min-h-24 bg-background/40 font-mono text-xs" />
</div>
)}
</div>
)}
{rows.length > 0 && <AIResultPreview rows={rows} onChange={setRows} />}
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border pt-4">
<p className="font-mono text-xs text-muted-foreground"></p>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading || inserting}>
</Button>
<Button
onClick={handleInsert}
disabled={selectedCount === 0 || inserting}
className={cn("gap-1.5")}
>
{inserting ? <Loader2 className="size-4 animate-spin" /> : <Database className="size-4" />}
({selectedCount})
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+296
View File
@@ -0,0 +1,296 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Send, Loader2, Copy, RefreshCw, Trash2, Plus, MessageSquare, X, Sparkles } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { useAISettings } from "./ai-settings-provider"
import { Markdown } from "./markdown"
import { requestOpenAICompatible } from "@/lib/ai/client"
import { validateForRequest } from "@/lib/ai/settings"
import { buildChatSystemPrompt } from "@/lib/ai/prompts"
import {
addMessage,
clearMessages,
createConversation,
deleteLastAssistantMessage,
fetchMessages,
getLatestConversation,
type AIConversation,
} from "@/lib/ai/conversations"
import type { ChatMessage } from "@/lib/ai/types"
import type { KnowledgeItem } from "@/lib/types"
type UIMessage = { role: "user" | "assistant"; content: string }
export function AIChatPanel({ item }: { item: KnowledgeItem }) {
const { settings } = useAISettings()
const [conversation, setConversation] = useState<AIConversation | null>(null)
const [messages, setMessages] = useState<UIMessage[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const [booting, setBooting] = useState(true)
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// load latest conversation + messages on mount / item change
useEffect(() => {
let active = true
setBooting(true)
setMessages([])
setConversation(null)
;(async () => {
try {
const conv = await getLatestConversation(item.id)
if (!active) return
if (conv) {
setConversation(conv)
const rows = await fetchMessages(conv.id)
if (!active) return
setMessages(rows.filter((r) => r.role !== "system").map((r) => ({ role: r.role as "user" | "assistant", content: r.content })))
}
} catch (e) {
console.log("[v0] load conversation error:", e)
} finally {
if (active) setBooting(false)
}
})()
return () => {
active = false
controllerRef.current?.abort()
}
}, [item.id])
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
}, [messages, loading])
async function ensureConversation(): Promise<AIConversation> {
if (conversation) return conversation
const conv = await createConversation(item.id, item.title)
setConversation(conv)
return conv
}
async function runCompletion(history: UIMessage[]): Promise<string> {
const chatMessages: ChatMessage[] = history.map((m) => ({ role: m.role, content: m.content }))
const ac = new AbortController()
controllerRef.current = ac
return requestOpenAICompatible({
baseUrl: settings.baseUrl,
apiKey: settings.apiKey,
model: settings.model,
systemPrompt: buildChatSystemPrompt(item),
messages: chatMessages,
temperature: settings.temperature,
maxTokens: settings.maxTokens,
signal: ac.signal,
})
}
async function handleSend() {
const err = validateForRequest(settings)
if (err) {
toast.error(err)
return
}
const text = input.trim()
if (!text || loading) return
const userMsg: UIMessage = { role: "user", content: text }
const next = [...messages, userMsg]
setMessages(next)
setInput("")
setLoading(true)
try {
const conv = await ensureConversation()
await addMessage(conv.id, "user", text)
const reply = await runCompletion(next)
setMessages((prev) => [...prev, { role: "assistant", content: reply }])
await addMessage(conv.id, "assistant", reply)
} catch (e) {
if ((e as Error).name === "AbortError") {
toast.message("已取消回复")
setMessages((prev) => prev.slice(0, -1)) // remove the user msg we optimistically added? keep it instead
return
}
toast.error((e as Error).message)
setMessages((prev) => [...prev, { role: "assistant", content: `[出错] ${(e as Error).message}` }])
} finally {
setLoading(false)
controllerRef.current = null
}
}
async function handleRegenerate() {
if (loading) return
// find last user message
const lastUserIdx = [...messages].reverse().findIndex((m) => m.role === "user")
if (lastUserIdx === -1) return
const historyEnd = messages.length - lastUserIdx // index just after last user message
const history = messages.slice(0, historyEnd)
setMessages(history)
setLoading(true)
try {
const conv = await ensureConversation()
await deleteLastAssistantMessage(conv.id)
const reply = await runCompletion(history)
setMessages((prev) => [...prev, { role: "assistant", content: reply }])
await addMessage(conv.id, "assistant", reply)
} catch (e) {
if ((e as Error).name === "AbortError") return
toast.error((e as Error).message)
} finally {
setLoading(false)
controllerRef.current = null
}
}
async function handleNewConversation() {
controllerRef.current?.abort()
try {
const conv = await createConversation(item.id, item.title)
setConversation(conv)
setMessages([])
toast.success("已新建会话")
} catch (e) {
toast.error("新建会话失败")
console.log("[v0] new conversation error:", e)
}
}
async function handleClear() {
if (!conversation) {
setMessages([])
return
}
try {
await clearMessages(conversation.id)
setMessages([])
toast.success("已清空当前会话")
} catch (e) {
toast.error("清空失败")
console.log("[v0] clear error:", e)
}
}
function copyMessage(content: string) {
navigator.clipboard.writeText(content)
toast.success("已复制")
}
const hasAssistant = messages.some((m) => m.role === "assistant")
return (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/40 p-3">
<div className="flex items-center justify-between">
<h4 className="flex items-center gap-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground">
<Sparkles className="size-3.5 text-primary" />
AI
</h4>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={handleNewConversation} className="h-7 gap-1 px-2 text-xs">
<Plus className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
disabled={messages.length === 0}
className="h-7 gap-1 px-2 text-xs"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<div ref={scrollRef} className="flex max-h-72 flex-col gap-3 overflow-y-auto">
{booting ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
...
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center text-sm text-muted-foreground">
<MessageSquare className="size-6" />
{item.title} AI
</div>
) : (
messages.map((m, i) => (
<div key={i} className={cn("flex", m.role === "user" ? "justify-end" : "justify-start")}>
<div
className={cn(
"group relative max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed",
m.role === "user"
? "bg-primary/15 text-foreground"
: "border border-border bg-secondary/50 text-foreground",
)}
>
{m.role === "assistant" ? (
<Markdown content={m.content} />
) : (
<p className="whitespace-pre-wrap break-words">{m.content}</p>
)}
{m.role === "assistant" && (
<button
type="button"
onClick={() => copyMessage(m.content)}
aria-label="复制回复"
className="absolute -right-2 -top-2 hidden rounded-md border border-border bg-background p-1 text-muted-foreground hover:text-foreground group-hover:block"
>
<Copy className="size-3" />
</button>
)}
</div>
</div>
))
)}
{loading && (
<div className="flex justify-start">
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/50 px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
AI ...
</div>
</div>
)}
</div>
{hasAssistant && !loading && (
<div>
<Button variant="ghost" size="sm" onClick={handleRegenerate} className="h-7 gap-1 px-2 text-xs">
<RefreshCw className="size-3.5" />
</Button>
</div>
)}
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSend()
}
}}
placeholder="输入你的问题... (Ctrl/Cmd + Enter 发送)"
className="min-h-10 flex-1 bg-input/60 text-sm"
/>
{loading ? (
<Button variant="outline" size="icon" onClick={() => controllerRef.current?.abort()} aria-label="取消">
<X className="size-4" />
</Button>
) : (
<Button size="icon" onClick={handleSend} disabled={!input.trim()} aria-label="发送">
<Send className="size-4" />
</Button>
)}
</div>
</div>
)
}
+200
View File
@@ -0,0 +1,200 @@
"use client"
import { useState } from "react"
import { ChevronDown, ChevronRight, Copy, Download } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { DIFFICULTIES, DIFFICULTY_META } from "@/lib/types"
import { AI_CATEGORY_ENUM, type AIDraftItem } from "@/lib/ai/types"
export type DraftRow = AIDraftItem & { _id: string; _selected: boolean }
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
const CATEGORY_OPTIONS = AI_CATEGORY_ENUM.map((c) => ({ value: c, label: c }))
const DIFFICULTY_OPTIONS = DIFFICULTIES.map((d) => ({ value: d, label: DIFFICULTY_META[d].label }))
export function AIResultPreview({
rows,
onChange,
}: {
rows: DraftRow[]
onChange: (rows: DraftRow[]) => void
}) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
function patch(id: string, patch: Partial<DraftRow>) {
onChange(rows.map((r) => (r._id === id ? { ...r, ...patch } : r)))
}
function toggleAll(value: boolean) {
onChange(rows.map((r) => ({ ...r, _selected: value })))
}
function copyJson() {
const items = rows.map(({ _id, _selected, ...rest }) => rest)
navigator.clipboard.writeText(JSON.stringify({ items }, null, 2))
toast.success("已复制 JSON")
}
function exportJson() {
const items = rows.map(({ _id, _selected, ...rest }) => rest)
const blob = new Blob([JSON.stringify({ items }, null, 2)], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `knowledge-items-${Date.now()}.json`
a.click()
URL.revokeObjectURL(url)
}
const allSelected = rows.length > 0 && rows.every((r) => r._selected)
const selectedCount = rows.filter((r) => r._selected).length
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={allSelected} onCheckedChange={(v) => toggleAll(!!v)} />
· {selectedCount}/{rows.length}
</label>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={copyJson} className="gap-1.5">
<Copy className="size-3.5" />
JSON
</Button>
<Button variant="outline" size="sm" onClick={exportJson} className="gap-1.5">
<Download className="size-3.5" />
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
{rows.map((row) => {
const open = expanded[row._id]
return (
<div
key={row._id}
className={cn(
"rounded-lg border bg-card/60 transition-colors",
row._selected ? "border-primary/40" : "border-border",
)}
>
<div className="flex items-center gap-2 p-3">
<Checkbox checked={row._selected} onCheckedChange={(v) => patch(row._id, { _selected: !!v })} />
<Input
value={row.title}
onChange={(e) => patch(row._id, { title: e.target.value })}
className="h-8 flex-1 bg-input/60 text-sm font-medium"
/>
<Badge variant="outline" className="hidden font-mono text-[10px] sm:inline-flex">
{row.category}
</Badge>
<button
type="button"
onClick={() => setExpanded((e) => ({ ...e, [row._id]: !e[row._id] }))}
className="text-muted-foreground hover:text-foreground"
aria-label={open ? "收起" : "展开"}
>
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
</button>
</div>
{open && (
<div className="grid gap-3 border-t border-border p-3">
<div className="grid gap-1.5">
<span className="font-mono text-xs text-muted-foreground"></span>
<Input
value={row.summary}
onChange={(e) => patch(row._id, { summary: e.target.value })}
className="bg-input/60 text-sm"
/>
</div>
<div className="grid gap-1.5">
<span className="font-mono text-xs text-muted-foreground"></span>
<Textarea
value={row.content}
onChange={(e) => patch(row._id, { content: e.target.value })}
className="min-h-20 bg-input/60 text-sm"
/>
</div>
{row.code_snippet && (
<div className="grid gap-1.5">
<span className="font-mono text-xs text-muted-foreground"></span>
<Textarea
value={row.code_snippet}
onChange={(e) => patch(row._id, { code_snippet: e.target.value })}
className="min-h-20 bg-input/60 font-mono text-sm"
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<span className="font-mono text-xs text-muted-foreground"></span>
<Select
value={row.category}
onValueChange={(v) => patch(row._id, { category: v })}
items={CATEGORY_OPTIONS}
>
<SelectTrigger className="h-8 bg-input/60 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<span className="font-mono text-xs text-muted-foreground"></span>
<Select
value={row.difficulty}
onValueChange={(v) => patch(row._id, { difficulty: v as AIDraftItem["difficulty"] })}
items={DIFFICULTY_OPTIONS}
>
<SelectTrigger className="h-8 bg-input/60 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DIFFICULTY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{row.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{row.tags.map((t) => (
<Badge key={t} variant="secondary" className="font-mono text-xs">
#{t}
</Badge>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
+221
View File
@@ -0,0 +1,221 @@
"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>
)
}
+38
View File
@@ -0,0 +1,38 @@
"use client"
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
import { loadAISettings, saveAISettings } from "@/lib/ai/settings"
import { DEFAULT_AI_SETTINGS, type AISettings } from "@/lib/ai/types"
type Ctx = {
settings: AISettings
setSettings: (next: AISettings) => void
loaded: boolean
}
const AISettingsContext = createContext<Ctx | null>(null)
export function AISettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettingsState] = useState<AISettings>(DEFAULT_AI_SETTINGS)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
setSettingsState(loadAISettings())
setLoaded(true)
}, [])
const setSettings = useCallback((next: AISettings) => {
setSettingsState(next)
saveAISettings(next)
}, [])
return (
<AISettingsContext.Provider value={{ settings, setSettings, loaded }}>{children}</AISettingsContext.Provider>
)
}
export function useAISettings() {
const ctx = useContext(AISettingsContext)
if (!ctx) throw new Error("useAISettings must be used within AISettingsProvider")
return ctx
}
+49
View File
@@ -0,0 +1,49 @@
"use client"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { cn } from "@/lib/utils"
/**
* 统一的 AI Markdown 渲染组件,适配深色科技风主题。
* 用于面试回答版与 AI 追问的回复内容。
*/
export function Markdown({ content, className }: { content: string; className?: string }) {
return (
<div
className={cn(
"text-sm leading-relaxed break-words",
// 段落与标题
"[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
"[&_h1]:mt-3 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold",
"[&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-semibold",
"[&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold",
// 列表
"[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
"[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
"[&_li]:my-0.5 [&_li]:marker:text-muted-foreground",
// 强调
"[&_strong]:font-semibold [&_strong]:text-foreground",
"[&_em]:italic",
// 链接
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
// 行内代码
"[&_code]:rounded [&_code]:bg-secondary/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",
// 代码块
"[&_pre]:my-2 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:border-border [&_pre]:bg-secondary/40 [&_pre]:p-3",
"[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground",
// 引用
"[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
// 表格
"[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:text-xs",
"[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold",
"[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1",
// 分隔线
"[&_hr]:my-3 [&_hr]:border-border",
className,
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
)
}
+48
View File
@@ -0,0 +1,48 @@
"use client"
import { Copy } from "lucide-react"
import { toast } from "sonner"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
export function PromptEditor({
label = "自定义提示词 (customPrompt)",
value,
onChange,
placeholder,
rows = 6,
}: {
label?: string
value: string
onChange: (v: string) => void
placeholder?: string
rows?: number
}) {
function copy() {
navigator.clipboard.writeText(value)
toast.success("已复制 Prompt")
}
return (
<div className="grid gap-1.5">
<div className="flex items-center justify-between">
<Label>{label}</Label>
<button
type="button"
onClick={copy}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Copy className="size-3.5" />
</button>
</div>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{ minHeight: `${rows * 1.6}rem` }}
className="bg-input/60 text-sm"
/>
</div>
)
}