feat: 优化UI与代码结构

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
feie9456 2026-04-26 21:33:43 +08:00
parent 0e9e0c71b5
commit 6c4c743e5a
12 changed files with 2250 additions and 2185 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,64 @@
import { Moon, Sun } from "lucide-react"
import { Panel, SectionHeader } from "@/components/app/shared"
import { APP_VERSION } from "@/lib/default-data"
export function AboutView() {
return (
<section className="content-view overflow-y-auto p-4">
<div className="mx-auto grid max-w-4xl gap-4">
<SectionHeader
title="simple-llm-chat-ui"
description="轻量级 OpenAI-compatible AI 聊天面板客户端。"
/>
<Panel>
<div className="grid gap-3 text-sm leading-6 text-muted-foreground">
<p>
<span className="text-foreground">{APP_VERSION}</span>
</p>
<p>
Chat Completions API
</p>
<p>
Base URL
</p>
<p>
API Key
localStorage
</p>
<p>
API OpenAI-compatible Chat Completions Models
</p>
<p>MIT License</p>
</div>
</Panel>
<Panel>
<h2 className="text-sm font-semibold"></h2>
<div className="mt-3 grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
<div className="rounded-lg border px-3 py-2">Enter</div>
<div className="rounded-lg border px-3 py-2">
Shift + Enter
</div>
<div className="rounded-lg border px-3 py-2">
D/
</div>
</div>
</Panel>
<Panel>
<h2 className="text-sm font-semibold"></h2>
<div className="mt-3 flex flex-wrap gap-2 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2 rounded-lg border px-3 py-2">
<Sun className="size-4" />
</span>
<span className="inline-flex items-center gap-2 rounded-lg border px-3 py-2">
<Moon className="size-4" />
</span>
</div>
</Panel>
</div>
</section>
)
}

View File

@ -0,0 +1,889 @@
import * as React from "react"
import {
ChevronDown,
ChevronUp,
CircleStop,
Copy,
Edit3,
FileText,
GitFork,
MessageSquare,
Save,
Send,
Settings,
SlidersHorizontal,
Trash2,
X,
} from "lucide-react"
import { MarkdownMessage } from "@/components/markdown-message"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Select } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { LabelledField } from "@/components/app/shared"
import {
ASK_QUESTIONS_TOOL_NAME,
parseAskQuestionsArguments,
} from "@/lib/tools"
import { cn } from "@/lib/utils"
import type {
AskQuestionsAnswer,
AskQuestionsPayload,
ChatMessage,
ChatSession,
ChatToolCall,
ModelConfig,
PromptTemplate,
RequestState,
RuntimeConfig,
ServiceConfig,
} from "@/types"
const STATUS_TEXT: Record<RequestState | "done", string> = {
idle: "就绪",
requesting: "请求中",
generating: "生成中",
failed: "失败",
stopped: "已停止",
done: "完成",
}
function formatMessageTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value))
}
function formatDuration(ms?: number) {
if (!ms) {
return ""
}
if (ms < 1000) {
return `${Math.round(ms)}ms`
}
return `${(ms / 1000).toFixed(1)}s`
}
function formatUsage(message: ChatMessage) {
if (!message.usage) {
return ""
}
const parts = [
message.usage.promptTokens !== undefined
? `输入 ${message.usage.promptTokens}`
: "",
message.usage.completionTokens !== undefined
? `输出 ${message.usage.completionTokens}`
: "",
message.usage.totalTokens !== undefined
? `总计 ${message.usage.totalTokens}`
: "",
].filter(Boolean)
return parts.length ? parts.join(" / ") : ""
}
function getMessageCopyContent(message: ChatMessage) {
if (message.role !== "assistant" || !message.reasoningContent?.trim()) {
return message.content
}
return [
`reasoning_content:\n${message.reasoningContent.trim()}`,
message.content.trim() ? `content:\n${message.content.trim()}` : "",
]
.filter(Boolean)
.join("\n\n")
}
type ChatViewProps = {
currentSession: ChatSession
runtimeConfig: RuntimeConfig
services: ServiceConfig[]
models: ModelConfig[]
prompts: PromptTemplate[]
chatInput: string
editingMessageId: string | null
editingContent: string
showPromptEditor: boolean
quickConfigOpen: boolean
isBusy: boolean
configIsValid: boolean
onInputChange: (value: string) => void
onSend: () => void
onStop: () => void
onCopy: (text: string, label?: string) => void
onDeleteMessage: (messageId: string) => void
onEditMessage: (message: ChatMessage) => void
onForkMessage: (messageId: string) => void
onSubmitAskQuestions: (
messageId: string,
toolCall: ChatToolCall,
answers: AskQuestionsAnswer[]
) => void
onCancelEdit: () => void
onEditingContentChange: (value: string) => void
onSaveEditOnly: () => void
onSaveEditAndSend: () => void
onServiceChange: (serviceId: string) => void
onModelChange: (modelId: string) => void
onPromptChange: (promptId: string) => void
onQuickConfigOpenChange: (open: boolean) => void
onPromptEditorToggle: () => void
onSystemPromptChange: (value: string) => void
onOpenSettings: () => void
}
export function ChatView({
currentSession,
runtimeConfig,
services,
models,
prompts,
chatInput,
editingMessageId,
editingContent,
showPromptEditor,
quickConfigOpen,
isBusy,
configIsValid,
onInputChange,
onSend,
onStop,
onCopy,
onDeleteMessage,
onEditMessage,
onForkMessage,
onSubmitAskQuestions,
onCancelEdit,
onEditingContentChange,
onSaveEditOnly,
onSaveEditAndSend,
onServiceChange,
onModelChange,
onPromptChange,
onQuickConfigOpenChange,
onPromptEditorToggle,
onSystemPromptChange,
onOpenSettings,
}: ChatViewProps) {
const canSend = chatInput.trim().length > 0 && !isBusy
return (
<section className="content-view chat-view">
<div className="chat-header border-b px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-base font-semibold">
{currentSession.title}
</h1>
{currentSession.temporary ? <Badge tone="muted"></Badge> : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{runtimeConfig.service?.name || "未选择服务"}</span>
<span>{runtimeConfig.modelName || "未选择模型"}</span>
<span>{currentSession.messages.length} </span>
</div>
</div>
<div className="chat-header-actions flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
aria-expanded={quickConfigOpen}
onClick={() => onQuickConfigOpenChange(!quickConfigOpen)}
>
{quickConfigOpen ? <ChevronUp /> : <ChevronDown />}
{quickConfigOpen ? "收起配置" : "展开配置"}
</Button>
</div>
</div>
{quickConfigOpen ? (
<div className="quick-config border-b px-4 py-3">
<div className="grid gap-2 md:grid-cols-3">
<LabelledField label="服务">
<Select
value={runtimeConfig.service?.id ?? ""}
onChange={(event) => onServiceChange(event.target.value)}
>
{services.map((service) => (
<option key={service.id} value={service.id}>
{service.name}
</option>
))}
</Select>
</LabelledField>
<LabelledField label="模型">
<Select
value={runtimeConfig.model?.id ?? ""}
onChange={(event) => onModelChange(event.target.value)}
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.displayName || model.name}
</option>
))}
</Select>
</LabelledField>
<LabelledField label="提示词">
<Select
value={
currentSession.promptId ?? runtimeConfig.prompt?.id ?? ""
}
onChange={(event) => onPromptChange(event.target.value)}
>
{prompts.map((prompt) => (
<option key={prompt.id} value={prompt.id}>
{prompt.title}
</option>
))}
</Select>
</LabelledField>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onPromptEditorToggle}
>
<FileText />
</Button>
{!configIsValid ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={onOpenSettings}
>
<Settings />
</Button>
) : null}
</div>
{showPromptEditor ? (
<Textarea
value={runtimeConfig.systemPrompt}
onChange={(event) => onSystemPromptChange(event.target.value)}
className="mt-2 min-h-24"
placeholder="当前会话系统提示词"
/>
) : null}
</div>
) : null}
<div className="messages-scroll min-h-0 flex-1 overflow-y-auto px-4 py-4">
{currentSession.messages.length === 0 ? (
<div className="empty-state mx-auto flex max-w-2xl flex-col gap-4 py-16 text-center">
<div className="mx-auto flex size-12 items-center justify-center rounded-xl border bg-muted">
<MessageSquare className="size-5" />
</div>
<div>
<h2 className="text-lg font-semibold">
AI
</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
OpenAI-compatible API Key
</p>
</div>
{!configIsValid ? (
<div className="mx-auto flex flex-wrap justify-center gap-2">
{runtimeConfig.errors.map((error) => (
<Badge key={error} tone="warning">
{error}
</Badge>
))}
</div>
) : null}
</div>
) : (
<div className="mx-auto flex max-w-4xl flex-col gap-5">
{currentSession.messages.map((message) => (
<MessageItem
key={message.id}
message={message}
editing={editingMessageId === message.id}
editingContent={editingContent}
onCopy={onCopy}
onDelete={onDeleteMessage}
onEdit={onEditMessage}
onFork={onForkMessage}
onSubmitAskQuestions={onSubmitAskQuestions}
onCancelEdit={onCancelEdit}
onEditingContentChange={onEditingContentChange}
onSaveEditOnly={onSaveEditOnly}
onSaveEditAndSend={onSaveEditAndSend}
/>
))}
</div>
)}
</div>
{currentSession.error ? (
<div className="border-t px-4 py-2 text-sm text-destructive">
{currentSession.error}
</div>
) : null}
<div className="composer border-t bg-background px-4 py-3">
<div className="mx-auto max-w-4xl">
<Textarea
value={chatInput}
onChange={(event) => onInputChange(event.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault()
onSend()
}
}}
placeholder="输入消息Enter 发送Shift + Enter 换行"
className="min-h-24 resize-none"
disabled={isBusy}
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>
{runtimeConfig.parameters.stream ? "开启" : "关闭"}
</span>
<span>{runtimeConfig.parameters.temperature}</span>
<span>
{runtimeConfig.parameters.maxContextLength > 0
? `${runtimeConfig.parameters.maxContextLength} 字符`
: "无限"}
</span>
<span>
{runtimeConfig.parameters.reasoningEffort === "default"
? "默认"
: runtimeConfig.parameters.reasoningEffort}
</span>
</div>
<div className="flex gap-2">
{isBusy ? (
<Button type="button" variant="outline" onClick={onStop}>
<CircleStop />
</Button>
) : null}
<Button type="button" onClick={onSend} disabled={!canSend}>
<Send />
</Button>
</div>
</div>
</div>
</div>
</section>
)
}
type MessageItemProps = {
message: ChatMessage
editing: boolean
editingContent: string
onCopy: (text: string, label?: string) => void
onDelete: (messageId: string) => void
onEdit: (message: ChatMessage) => void
onFork: (messageId: string) => void
onSubmitAskQuestions: (
messageId: string,
toolCall: ChatToolCall,
answers: AskQuestionsAnswer[]
) => void
onCancelEdit: () => void
onEditingContentChange: (value: string) => void
onSaveEditOnly: () => void
onSaveEditAndSend: () => void
}
function MessageItem({
message,
editing,
editingContent,
onCopy,
onDelete,
onEdit,
onFork,
onSubmitAskQuestions,
onCancelEdit,
onEditingContentChange,
onSaveEditOnly,
onSaveEditAndSend,
}: MessageItemProps) {
const usage = formatUsage(message)
const duration = formatDuration(message.elapsedMs)
const isUserMessage = message.role === "user"
const roleLabel =
message.role === "user"
? "你"
: message.role === "tool"
? `工具:${message.toolName || "unknown"}`
: "助手"
const ToolIcon = message.role === "tool" ? SlidersHorizontal : MessageSquare
return (
<article
className={cn(
"message-row flex",
message.role === "tool" && "gap-3",
isUserMessage ? "justify-end" : "justify-start"
)}
>
{message.role === "tool" ? (
<div className="mt-1 flex size-8 shrink-0 items-center justify-center rounded-lg border bg-muted">
<ToolIcon className="size-4" />
</div>
) : null}
<div
className={cn(
"message-bubble min-w-0",
isUserMessage &&
"message-bubble-user max-w-[min(720px,88%)] px-4 py-3 text-foreground",
message.role === "assistant" &&
"message-bubble-assistant max-w-[min(760px,100%)] px-0 py-1 pl-2",
message.role === "tool" &&
"message-bubble-tool max-w-[min(760px,90%)] rounded-lg border bg-background px-3 py-3"
)}
>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs opacity-75">
<span>{roleLabel}</span>
<span>{formatMessageTime(message.createdAt)}</span>
{message.model ? <span>{message.model}</span> : null}
{message.status && message.status !== "done" ? (
<span>{STATUS_TEXT[message.status]}</span>
) : null}
</div>
{editing ? (
<div className="space-y-2">
<Textarea
value={editingContent}
onChange={(event) => onEditingContentChange(event.target.value)}
className="min-h-28 bg-background text-foreground"
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onCancelEdit}
>
<X />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onSaveEditOnly}
>
<Save />
</Button>
<Button type="button" size="sm" onClick={onSaveEditAndSend}>
<Save />
</Button>
</div>
</div>
) : message.role === "assistant" ? (
<>
{message.reasoningContent?.trim() ? (
<ReasoningContent
content={message.reasoningContent}
streaming={
message.status === "generating" ||
message.status === "requesting"
}
/>
) : null}
{message.content.trim() ? (
<MarkdownMessage content={message.content} />
) : null}
{message.toolCalls?.length ? (
<div className="mt-3 grid gap-3">
{message.toolCalls.map((toolCall) => (
<ToolCallPanel
key={toolCall.id}
messageId={message.id}
toolCall={toolCall}
onSubmitAskQuestions={onSubmitAskQuestions}
/>
))}
</div>
) : null}
{!message.content.trim() &&
!message.reasoningContent?.trim() &&
!message.toolCalls?.length ? (
<MarkdownMessage content="..." />
) : null}
</>
) : message.role === "tool" ? (
<div className="rounded-lg border bg-muted/40 p-2.5 text-xs">
<div className="mb-2 font-medium text-muted-foreground">
</div>
<pre className="max-h-64 overflow-auto leading-5 break-words whitespace-pre-wrap">
{message.content}
</pre>
</div>
) : (
<div className="text-sm leading-6 whitespace-pre-wrap">
{message.content}
</div>
)}
{message.error ? (
<div className="mt-2 rounded-lg border border-destructive/20 bg-destructive/10 px-2 py-1 text-sm text-destructive">
{message.error}
</div>
) : null}
{duration || usage ? (
<div className="mt-2 flex flex-wrap gap-2 text-xs opacity-70">
{duration ? <span> {duration}</span> : null}
{usage ? <span>Token {usage}</span> : null}
</div>
) : null}
{!editing ? (
<div
className={cn(
"message-actions mt-2 flex flex-wrap gap-1",
isUserMessage ? "justify-end" : "justify-start"
)}
>
<Button
type="button"
size="icon-xs"
variant={isUserMessage ? "secondary" : "ghost"}
title="复制消息"
onClick={() => onCopy(getMessageCopyContent(message))}
>
<Copy />
</Button>
{isUserMessage ? (
<Button
type="button"
size="icon-xs"
variant="secondary"
title="编辑消息"
onClick={() => onEdit(message)}
>
<Edit3 />
</Button>
) : null}
<Button
type="button"
size="icon-xs"
variant={isUserMessage ? "secondary" : "ghost"}
title="从此处分叉会话"
onClick={() => onFork(message.id)}
>
<GitFork />
</Button>
<Button
type="button"
size="icon-xs"
variant={isUserMessage ? "secondary" : "ghost"}
title="删除消息"
onClick={() => onDelete(message.id)}
>
<Trash2 />
</Button>
</div>
) : null}
</div>
</article>
)
}
function ReasoningContent({
content,
streaming,
}: {
content: string
streaming: boolean
}) {
return (
<details
className="mb-3 rounded-lg border bg-muted/30 px-3 py-2"
open={streaming}
>
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
reasoning_content{streaming ? " · 生成中" : ""}
</summary>
<pre className="mt-2 max-h-80 overflow-auto text-xs leading-5 break-words whitespace-pre-wrap text-muted-foreground">
{content}
</pre>
</details>
)
}
type ToolCallPanelProps = {
messageId: string
toolCall: ChatToolCall
onSubmitAskQuestions: (
messageId: string,
toolCall: ChatToolCall,
answers: AskQuestionsAnswer[]
) => void
}
function ToolCallPanel({
messageId,
toolCall,
onSubmitAskQuestions,
}: ToolCallPanelProps) {
const askQuestions =
toolCall.name === ASK_QUESTIONS_TOOL_NAME
? parseAskQuestionsArguments(toolCall.arguments)
: null
return (
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 font-medium">
<SlidersHorizontal className="size-4" />
<span>Tool call: {toolCall.name}</span>
</div>
<Badge
tone={
toolCall.status === "completed"
? "success"
: toolCall.status === "failed"
? "danger"
: "warning"
}
>
{toolCall.status === "completed"
? "已完成"
: toolCall.status === "failed"
? "失败"
: "等待用户"}
</Badge>
</div>
<details className="mt-3 rounded-lg border bg-background/70 px-2.5 py-2 text-xs">
<summary className="cursor-pointer text-muted-foreground">
</summary>
<pre className="mt-2 max-h-48 overflow-auto leading-5 break-words whitespace-pre-wrap">
{toolCall.arguments || "{}"}
</pre>
</details>
{toolCall.error ? (
<div className="mt-3 rounded-lg border border-destructive/20 bg-destructive/10 px-2.5 py-2 text-sm text-destructive">
{toolCall.error}
</div>
) : null}
{askQuestions?.error ? (
<div className="mt-3 rounded-lg border border-destructive/20 bg-destructive/10 px-2.5 py-2 text-sm text-destructive">
askQuestions {askQuestions.error}
</div>
) : null}
{askQuestions?.payload && toolCall.status !== "completed" ? (
<AskQuestionsForm
key={toolCall.id}
payload={askQuestions.payload}
onSubmit={(answers) =>
onSubmitAskQuestions(messageId, toolCall, answers)
}
/>
) : null}
{toolCall.result ? (
<details
className="mt-3 rounded-lg border bg-background/70 px-2.5 py-2 text-xs"
open
>
<summary className="cursor-pointer text-muted-foreground">
</summary>
<pre className="mt-2 max-h-48 overflow-auto leading-5 break-words whitespace-pre-wrap">
{toolCall.result}
</pre>
</details>
) : null}
</div>
)
}
type AskQuestionsFormProps = {
payload: AskQuestionsPayload
onSubmit: (answers: AskQuestionsAnswer[]) => void
}
function AskQuestionsForm({ payload, onSubmit }: AskQuestionsFormProps) {
const [answers, setAnswers] = React.useState<
Record<string, string | string[]>
>(() => {
const initialAnswers: Record<string, string | string[]> = {}
for (const question of payload.questions) {
initialAnswers[question.id] = question.type === "multiple" ? [] : ""
}
return initialAnswers
})
const [error, setError] = React.useState("")
function updateAnswer(questionId: string, value: string | string[]) {
setAnswers((current) => ({ ...current, [questionId]: value }))
setError("")
}
function toggleMultiValue(questionId: string, value: string) {
const currentValue = answers[questionId]
const currentValues = Array.isArray(currentValue) ? currentValue : []
const nextValues = currentValues.includes(value)
? currentValues.filter((item) => item !== value)
: [...currentValues, value]
updateAnswer(questionId, nextValues)
}
function submitAnswers() {
for (const question of payload.questions) {
const value = answers[question.id]
const isEmpty = Array.isArray(value)
? value.length === 0
: !String(value ?? "").trim()
if (question.required && isEmpty) {
setError(`请回答:${question.label}`)
return
}
}
onSubmit(
payload.questions.map((question) => ({
questionId: question.id,
label: question.label,
value: answers[question.id] ?? (question.type === "multiple" ? [] : ""),
}))
)
}
return (
<div className="mt-3 grid gap-3 rounded-lg border bg-background p-3">
<div>
<div className="text-sm font-semibold">
{payload.title || "需要你的确认"}
</div>
{payload.description ? (
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{payload.description}
</div>
) : null}
</div>
{payload.questions.map((question) => (
<div key={question.id} className="grid gap-2 rounded-lg border p-2.5">
<div className="text-sm font-medium">
{question.label}
{question.required ? (
<span className="text-destructive"> *</span>
) : null}
</div>
{question.type === "text" ? (
<Textarea
value={String(answers[question.id] ?? "")}
onChange={(event) =>
updateAnswer(question.id, event.target.value)
}
placeholder={question.placeholder}
className="min-h-20"
/>
) : null}
{question.type === "single" ? (
<div className="grid gap-2">
{(question.options ?? []).map((option) => (
<label
key={option.value}
className="flex gap-2 rounded-lg border px-2.5 py-2"
>
<input
type="radio"
name={question.id}
checked={answers[question.id] === option.value}
onChange={() => updateAnswer(question.id, option.value)}
className="mt-1 size-4 accent-primary"
/>
<span className="min-w-0 flex-1">
<span className="block text-sm">{option.label}</span>
{option.description ? (
<span className="block text-xs leading-5 text-muted-foreground">
{option.description}
</span>
) : null}
</span>
</label>
))}
</div>
) : null}
{question.type === "multiple" ? (
<div className="grid gap-2">
{(question.options ?? []).map((option) => {
const currentValue = answers[question.id]
const selectedValues = Array.isArray(currentValue)
? currentValue
: []
return (
<label
key={option.value}
className="flex gap-2 rounded-lg border px-2.5 py-2"
>
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
onChange={() =>
toggleMultiValue(question.id, option.value)
}
className="mt-1 size-4 accent-primary"
/>
<span className="min-w-0 flex-1">
<span className="block text-sm">{option.label}</span>
{option.description ? (
<span className="block text-xs leading-5 text-muted-foreground">
{option.description}
</span>
) : null}
</span>
</label>
)
})}
</div>
) : null}
</div>
))}
{error ? <div className="text-sm text-destructive">{error}</div> : null}
<div className="flex justify-end">
<Button type="button" onClick={submitAnswers}>
<Send />
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,146 @@
import type * as React from "react"
import { Download, Trash2, Upload } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Panel, SectionHeader } from "@/components/app/shared"
type DataViewProps = {
sessionsCount: number
modelsCount: number
promptsCount: number
sessionsImportRef: React.RefObject<HTMLInputElement | null>
settingsImportRef: React.RefObject<HTMLInputElement | null>
allImportRef: React.RefObject<HTMLInputElement | null>
onExportAll: () => void
onExportSessions: () => void
onExportSettings: () => void
onImportAll: (event: React.ChangeEvent<HTMLInputElement>) => void
onImportSessions: (event: React.ChangeEvent<HTMLInputElement>) => void
onImportSettings: (event: React.ChangeEvent<HTMLInputElement>) => void
onClearAll: () => void
onClearSessions: () => void
onClearSettings: () => void
}
export function DataView({
sessionsCount,
modelsCount,
promptsCount,
sessionsImportRef,
settingsImportRef,
allImportRef,
onExportAll,
onExportSessions,
onExportSettings,
onImportAll,
onImportSessions,
onImportSettings,
onClearAll,
onClearSessions,
onClearSettings,
}: DataViewProps) {
return (
<section className="content-view overflow-y-auto p-4">
<div className="mx-auto grid max-w-5xl gap-4">
<SectionHeader
title="数据"
description={`${sessionsCount} 个会话,${modelsCount} 个模型,${promptsCount} 个提示词`}
/>
<div className="grid gap-4 md:grid-cols-2">
<Panel>
<h2 className="text-sm font-semibold"></h2>
<div className="mt-4 grid gap-2">
<Button type="button" variant="outline" onClick={onExportAll}>
<Download />
</Button>
<Button
type="button"
variant="outline"
onClick={onExportSessions}
>
<Download />
</Button>
<Button
type="button"
variant="outline"
onClick={onExportSettings}
>
<Download />
</Button>
</div>
</Panel>
<Panel>
<h2 className="text-sm font-semibold"></h2>
<div className="mt-4 grid gap-2">
<Button
type="button"
variant="outline"
onClick={() => allImportRef.current?.click()}
>
<Upload />
</Button>
<Button
type="button"
variant="outline"
onClick={() => sessionsImportRef.current?.click()}
>
<Upload />
</Button>
<Button
type="button"
variant="outline"
onClick={() => settingsImportRef.current?.click()}
>
<Upload />
</Button>
</div>
<input
ref={allImportRef}
type="file"
accept="application/json,.json"
hidden
onChange={onImportAll}
/>
<input
ref={sessionsImportRef}
type="file"
accept="application/json,.json"
hidden
onChange={onImportSessions}
/>
<input
ref={settingsImportRef}
type="file"
accept="application/json,.json"
hidden
onChange={onImportSettings}
/>
</Panel>
</div>
<Panel>
<h2 className="text-sm font-semibold"></h2>
<div className="mt-4 flex flex-wrap gap-2">
<Button type="button" variant="destructive" onClick={onClearAll}>
<Trash2 />
</Button>
<Button type="button" variant="outline" onClick={onClearSessions}>
<Trash2 />
</Button>
<Button type="button" variant="outline" onClick={onClearSettings}>
<Trash2 />
</Button>
</div>
</Panel>
</div>
</section>
)
}

View File

@ -0,0 +1,261 @@
import { Check, Plus, RefreshCw, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select } from "@/components/ui/select"
import {
LabelledField,
Panel,
SectionHeader,
ToggleField,
} from "@/components/app/shared"
import { nowIso } from "@/lib/default-data"
import type { ModelConfig, ModelParameters, ServiceConfig } from "@/types"
type ModelsViewProps = {
activeService?: ServiceConfig
services: ServiceConfig[]
models: ModelConfig[]
activeModelId: string
modelNameDraft: string
modelDisplayDraft: string
onModelNameDraftChange: (value: string) => void
onModelDisplayDraftChange: (value: string) => void
onAddModel: () => void
onFetchModels: () => void
onDefaultModel: (model: ModelConfig) => void
onDeleteModel: (modelId: string) => void
onUpdateModel: (
modelId: string,
updater: (model: ModelConfig) => ModelConfig
) => void
onUpdateParameter: <K extends keyof ModelParameters>(
modelId: string,
key: K,
value: ModelParameters[K]
) => void
}
export function ModelsView({
activeService,
services,
models,
activeModelId,
modelNameDraft,
modelDisplayDraft,
onModelNameDraftChange,
onModelDisplayDraftChange,
onAddModel,
onFetchModels,
onDefaultModel,
onDeleteModel,
onUpdateModel,
onUpdateParameter,
}: ModelsViewProps) {
return (
<section className="content-view overflow-y-auto p-4">
<div className="mx-auto grid max-w-5xl gap-4">
<SectionHeader
title="模型"
description={
activeService
? `当前拉取来源:${activeService.name}`
: "当前服务未选择"
}
action={
<Button type="button" variant="outline" onClick={onFetchModels}>
<RefreshCw />
</Button>
}
/>
<Panel>
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<LabelledField label="模型名称">
<Input
value={modelNameDraft}
onChange={(event) => onModelNameDraftChange(event.target.value)}
placeholder="例如 llama-3.1-8b-instruct"
/>
</LabelledField>
<LabelledField label="显示名称">
<Input
value={modelDisplayDraft}
onChange={(event) =>
onModelDisplayDraftChange(event.target.value)
}
placeholder="可选"
/>
</LabelledField>
<div className="flex items-end">
<Button type="button" onClick={onAddModel}>
<Plus />
</Button>
</div>
</div>
</Panel>
<div className="grid gap-3">
{models.map((model) => (
<Panel key={model.id}>
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_220px_auto]">
<LabelledField label="显示名称">
<Input
value={model.displayName}
onChange={(event) =>
onUpdateModel(model.id, (item) => ({
...item,
displayName: event.target.value,
updatedAt: nowIso(),
}))
}
/>
</LabelledField>
<LabelledField label="模型名称">
<Input
value={model.name}
onChange={(event) =>
onUpdateModel(model.id, (item) => ({
...item,
name: event.target.value,
updatedAt: nowIso(),
}))
}
/>
</LabelledField>
<LabelledField label="服务来源">
<Select
value={model.serviceId ?? ""}
onChange={(event) =>
onUpdateModel(model.id, (item) => ({
...item,
serviceId: event.target.value || undefined,
updatedAt: nowIso(),
}))
}
>
<option value=""></option>
{services.map((service) => (
<option key={service.id} value={service.id}>
{service.name}
</option>
))}
</Select>
</LabelledField>
<div className="flex items-end gap-2">
<Button
type="button"
variant={
activeModelId === model.id || model.isDefault
? "default"
: "outline"
}
onClick={() => onDefaultModel(model)}
>
<Check />
</Button>
<Button
type="button"
variant="outline"
size="icon"
title="删除模型"
onClick={() => onDeleteModel(model.id)}
>
<Trash2 />
</Button>
</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3 lg:grid-cols-6">
<LabelledField label="温度">
<Input
type="number"
min="0"
max="2"
step="0.1"
value={model.parameters.temperature}
onChange={(event) =>
onUpdateParameter(
model.id,
"temperature",
Number(event.target.value)
)
}
/>
</LabelledField>
<LabelledField label="推理强度">
<Select
value={model.parameters.reasoningEffort}
onChange={(event) =>
onUpdateParameter(
model.id,
"reasoningEffort",
event.target.value as ModelParameters["reasoningEffort"]
)
}
>
<option value="default"></option>
<option value="minimal">minimal</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
</Select>
</LabelledField>
<LabelledField label="上下文长度">
<Input
type="number"
min="0"
value={model.parameters.maxContextLength}
onChange={(event) =>
onUpdateParameter(
model.id,
"maxContextLength",
Number(event.target.value)
)
}
/>
</LabelledField>
<LabelledField label="输出长度">
<Input
type="number"
min="1"
value={model.parameters.maxOutputTokens}
onChange={(event) =>
onUpdateParameter(
model.id,
"maxOutputTokens",
Number(event.target.value)
)
}
/>
</LabelledField>
<LabelledField label="超时">
<Input
type="number"
min="1"
value={model.parameters.timeoutSeconds}
onChange={(event) =>
onUpdateParameter(
model.id,
"timeoutSeconds",
Number(event.target.value)
)
}
/>
</LabelledField>
<ToggleField
label="流式"
checked={model.parameters.stream}
onChange={(checked) =>
onUpdateParameter(model.id, "stream", checked)
}
/>
</div>
</Panel>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,159 @@
import { Check, Plus, Save, Search, Trash2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { LabelledField, Panel, SectionHeader } from "@/components/app/shared"
import { nowIso } from "@/lib/default-data"
import { cn } from "@/lib/utils"
import type { PromptTemplate } from "@/types"
type PromptsViewProps = {
prompts: PromptTemplate[]
allPrompts: PromptTemplate[]
search: string
selectedPrompt?: PromptTemplate
onSearchChange: (value: string) => void
onSelectPrompt: (promptId: string) => void
onAddPrompt: () => void
onUpdatePrompt: (
promptId: string,
updater: (prompt: PromptTemplate) => PromptTemplate
) => void
onDeletePrompt: (promptId: string) => void
onDefaultPrompt: (promptId: string) => void
onApplyPrompt: (promptId: string) => void
}
export function PromptsView({
prompts,
allPrompts,
search,
selectedPrompt,
onSearchChange,
onSelectPrompt,
onAddPrompt,
onUpdatePrompt,
onDeletePrompt,
onDefaultPrompt,
onApplyPrompt,
}: PromptsViewProps) {
return (
<section className="content-view overflow-y-auto p-4">
<div className="mx-auto grid max-w-6xl gap-4">
<SectionHeader
title="提示词"
description={`${allPrompts.length} 个常用系统提示词`}
action={
<Button type="button" onClick={onAddPrompt}>
<Plus />
</Button>
}
/>
<div className="grid min-h-[560px] gap-4 lg:grid-cols-[320px_1fr]">
<Panel>
<div className="relative mb-3">
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="搜索提示词"
className="pl-7"
/>
</div>
<div className="grid gap-2">
{prompts.map((prompt) => (
<button
key={prompt.id}
type="button"
className={cn(
"rounded-lg border px-3 py-2 text-left text-sm",
selectedPrompt?.id === prompt.id
? "border-primary bg-muted"
: "border-border"
)}
onClick={() => onSelectPrompt(prompt.id)}
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">
{prompt.title || "未命名"}
</span>
{prompt.isDefault ? (
<Badge tone="success"></Badge>
) : null}
</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{prompt.content || "空提示词"}
</div>
</button>
))}
</div>
</Panel>
<Panel>
{selectedPrompt ? (
<div className="grid gap-3">
<LabelledField label="标题">
<Input
value={selectedPrompt.title}
onChange={(event) =>
onUpdatePrompt(selectedPrompt.id, (prompt) => ({
...prompt,
title: event.target.value,
updatedAt: nowIso(),
}))
}
/>
</LabelledField>
<LabelledField label="正文">
<Textarea
value={selectedPrompt.content}
onChange={(event) =>
onUpdatePrompt(selectedPrompt.id, (prompt) => ({
...prompt,
content: event.target.value,
updatedAt: nowIso(),
}))
}
className="min-h-80"
/>
</LabelledField>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => onApplyPrompt(selectedPrompt.id)}
>
<Check />
</Button>
<Button
type="button"
variant="outline"
onClick={() => onDefaultPrompt(selectedPrompt.id)}
>
<Save />
</Button>
<Button
type="button"
variant="outline"
onClick={() => onDeletePrompt(selectedPrompt.id)}
>
<Trash2 />
</Button>
</div>
</div>
) : (
<div className="py-16 text-center text-sm text-muted-foreground">
</div>
)}
</Panel>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,474 @@
import {
Check,
Eye,
EyeOff,
KeyRound,
Plus,
RotateCcw,
Save,
SlidersHorizontal,
Trash2,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import {
LabelledField,
Panel,
SectionHeader,
ToggleField,
} from "@/components/app/shared"
import { ASK_QUESTIONS_TOOL_NAME } from "@/lib/tools"
import { cn } from "@/lib/utils"
import type {
AppSettings,
ModelParameters,
ServiceConfig,
ThemeMode,
ToolChoiceMode,
} from "@/types"
const AVAILABLE_TOOL_ITEMS = [
{
id: ASK_QUESTIONS_TOOL_NAME,
label: "askQuestions",
description:
"向用户展示单选、多选和文本框,并把答案作为工具结果返回给模型。",
},
]
type SettingsViewProps = {
settings: AppSettings
activeService?: ServiceConfig
configIsValid: boolean
errors: string[]
showApiKey: boolean
onSettingsChange: (patch: Partial<AppSettings>) => void
onServiceChange: (serviceId: string, patch: Partial<ServiceConfig>) => void
onActiveServiceChange: (serviceId: string) => void
onToggleApiKey: () => void
onClearApiKey: () => void
onAddService: () => void
onDeleteService: (serviceId: string) => void
onSave: () => void
onReset: () => void
onTest: () => void
}
export function SettingsView({
settings,
activeService,
configIsValid,
errors,
showApiKey,
onSettingsChange,
onServiceChange,
onActiveServiceChange,
onToggleApiKey,
onClearApiKey,
onAddService,
onDeleteService,
onSave,
onReset,
onTest,
}: SettingsViewProps) {
function updateToolSettings(patch: Partial<AppSettings["tools"]>) {
onSettingsChange({ tools: { ...settings.tools, ...patch } })
}
function updateEnabledTool(toolId: string, enabled: boolean) {
const enabledToolIds = new Set(settings.tools.enabledToolIds)
if (enabled) {
enabledToolIds.add(toolId)
} else {
enabledToolIds.delete(toolId)
}
updateToolSettings({ enabledToolIds: [...enabledToolIds] })
}
return (
<section className="content-view overflow-y-auto p-4">
<div className="mx-auto grid max-w-5xl gap-4">
<SectionHeader
title="设置"
description="服务、默认参数、主题和本地体验选项。"
action={
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onSave}>
<Save />
</Button>
<Button type="button" variant="outline" onClick={onTest}>
<Check />
</Button>
<Button type="button" variant="outline" onClick={onReset}>
<RotateCcw />
</Button>
</div>
}
/>
<div className="grid gap-4 lg:grid-cols-[1fr_1fr]">
<Panel>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold"></h2>
<Badge tone={configIsValid ? "success" : "warning"}>
{configIsValid ? "有效" : errors.join(" / ") || "待配置"}
</Badge>
</div>
<div className="mt-4 grid gap-3">
<LabelledField label="当前服务">
<Select
value={settings.activeServiceId}
onChange={(event) =>
onActiveServiceChange(event.target.value)
}
>
{settings.services.map((service) => (
<option key={service.id} value={service.id}>
{service.name}
</option>
))}
</Select>
</LabelledField>
{activeService ? (
<>
<LabelledField label="服务名称">
<Input
value={activeService.name}
onChange={(event) =>
onServiceChange(activeService.id, {
name: event.target.value,
})
}
/>
</LabelledField>
<LabelledField label="Base URL">
<Input
value={activeService.baseUrl}
onChange={(event) =>
onServiceChange(activeService.id, {
baseUrl: event.target.value,
})
}
placeholder="https://api.example.com/v1"
/>
</LabelledField>
<LabelledField label="API Key">
<div className="flex gap-2">
<Input
type={showApiKey ? "text" : "password"}
value={activeService.apiKey}
onChange={(event) =>
onServiceChange(activeService.id, {
apiKey: event.target.value,
})
}
placeholder="sk-..."
/>
<Button
type="button"
size="icon-sm"
variant="outline"
title={showApiKey ? "隐藏 API Key" : "显示 API Key"}
onClick={onToggleApiKey}
>
{showApiKey ? <EyeOff /> : <Eye />}
</Button>
<Button
type="button"
size="icon-sm"
variant="outline"
title="清除 API Key"
onClick={onClearApiKey}
>
<KeyRound />
</Button>
</div>
</LabelledField>
<LabelledField label="默认模型名称">
<Input
value={activeService.defaultModel}
onChange={(event) =>
onServiceChange(activeService.id, {
defaultModel: event.target.value,
})
}
/>
</LabelledField>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={onAddService}
>
<Plus />
</Button>
<Button
type="button"
variant="outline"
onClick={() => onDeleteService(activeService.id)}
>
<Trash2 />
</Button>
</div>
</>
) : null}
</div>
</Panel>
<Panel>
<h2 className="text-sm font-semibold"></h2>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<LabelledField label="温度">
<Input
type="number"
min="0"
max="2"
step="0.1"
value={settings.temperature}
onChange={(event) =>
onSettingsChange({
temperature: Number(event.target.value),
})
}
/>
</LabelledField>
<LabelledField label="推理强度 reasoning_effort">
<Select
value={settings.reasoningEffort}
onChange={(event) =>
onSettingsChange({
reasoningEffort: event.target
.value as ModelParameters["reasoningEffort"],
})
}
>
<option value="default"></option>
<option value="minimal">minimal</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
</Select>
</LabelledField>
<LabelledField label="最大上下文长度">
<Input
type="number"
min="0"
value={settings.maxContextLength}
onChange={(event) =>
onSettingsChange({
maxContextLength: Number(event.target.value),
})
}
/>
</LabelledField>
<LabelledField label="最大输出长度">
<Input
type="number"
min="1"
value={settings.maxOutputTokens}
onChange={(event) =>
onSettingsChange({
maxOutputTokens: Number(event.target.value),
})
}
/>
</LabelledField>
<LabelledField label="请求超时(秒)">
<Input
type="number"
min="1"
value={settings.timeoutSeconds}
onChange={(event) =>
onSettingsChange({
timeoutSeconds: Number(event.target.value),
})
}
/>
</LabelledField>
<ToggleField
label="流式输出"
checked={settings.stream}
onChange={(checked) => onSettingsChange({ stream: checked })}
/>
</div>
<LabelledField label="系统提示词" className="mt-3">
<Textarea
value={settings.systemPrompt}
onChange={(event) =>
onSettingsChange({ systemPrompt: event.target.value })
}
/>
</LabelledField>
</Panel>
</div>
<Panel>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold">
Function Calling / Tools
</h2>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
</p>
</div>
<Badge
tone={settings.tools.functionCallingEnabled ? "success" : "muted"}
>
{settings.tools.functionCallingEnabled ? "已开启" : "已关闭"}
</Badge>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_220px]">
<ToggleField
label="启用 function calling"
checked={settings.tools.functionCallingEnabled}
onChange={(checked) =>
updateToolSettings({ functionCallingEnabled: checked })
}
/>
<LabelledField label="tool_choice">
<Select
value={settings.tools.toolChoice}
onChange={(event) =>
updateToolSettings({
toolChoice: event.target.value as ToolChoiceMode,
})
}
disabled={!settings.tools.functionCallingEnabled}
>
<option value="auto">auto</option>
<option value="none">none</option>
<option value="required">required</option>
</Select>
</LabelledField>
</div>
<div className="mt-4">
<div className="text-xs font-medium text-muted-foreground">
</div>
<div className="mt-2 grid gap-2">
{AVAILABLE_TOOL_ITEMS.map((tool) => {
const enabled = settings.tools.enabledToolIds.includes(tool.id)
return (
<label
key={tool.id}
className={cn(
"flex items-start gap-3 rounded-lg border px-3 py-2.5 text-sm",
!settings.tools.functionCallingEnabled && "opacity-60"
)}
>
<input
type="checkbox"
checked={enabled}
disabled={!settings.tools.functionCallingEnabled}
onChange={(event) =>
updateEnabledTool(tool.id, event.target.checked)
}
className="mt-1 size-4 accent-primary"
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{tool.label}</span>
<Badge tone={enabled ? "success" : "muted"}>
{enabled ? "已启用" : "未启用"}
</Badge>
</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
{tool.description}
</span>
</span>
</label>
)
})}
</div>
</div>
</Panel>
<Panel>
<div className="grid gap-3 sm:grid-cols-3">
<LabelledField label="主题">
<Select
value={settings.theme}
onChange={(event) =>
onSettingsChange({ theme: event.target.value as ThemeMode })
}
>
<option value="system"></option>
<option value="light"></option>
<option value="dark"></option>
</Select>
</LabelledField>
<LabelledField label="字体大小">
<Input
type="number"
min="0.85"
max="1.25"
step="0.05"
value={settings.fontScale}
onChange={(event) =>
onSettingsChange({ fontScale: Number(event.target.value) })
}
/>
</LabelledField>
<ToggleField
label="紧凑布局"
checked={settings.compactMode}
onChange={(checked) => onSettingsChange({ compactMode: checked })}
/>
</div>
<div className="mt-4">
<Button
type="button"
variant="ghost"
onClick={() =>
onSettingsChange({ showAdvanced: !settings.showAdvanced })
}
>
<SlidersHorizontal />
</Button>
</div>
{settings.showAdvanced ? (
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<ToggleField
label="启用代理字段"
checked={settings.proxyEnabled}
onChange={(checked) =>
onSettingsChange({ proxyEnabled: checked })
}
/>
<LabelledField label="代理地址">
<Input
value={settings.proxyUrl}
onChange={(event) =>
onSettingsChange({ proxyUrl: event.target.value })
}
/>
</LabelledField>
<LabelledField label="代理请求头 JSON" className="sm:col-span-2">
<Textarea
value={settings.proxyHeaders}
onChange={(event) =>
onSettingsChange({ proxyHeaders: event.target.value })
}
/>
</LabelledField>
</div>
) : null}
</Panel>
</div>
</section>
)
}

View File

@ -0,0 +1,68 @@
import type * as React from "react"
import { cn } from "@/lib/utils"
type LabelledFieldProps = {
label: string
children: React.ReactNode
className?: string
}
export function LabelledField({
label,
children,
className,
}: LabelledFieldProps) {
return (
<label className={cn("grid gap-1.5 text-sm", className)}>
<span className="text-xs font-medium text-muted-foreground">{label}</span>
{children}
</label>
)
}
type ToggleFieldProps = {
label: string
checked: boolean
onChange: (checked: boolean) => void
}
export function ToggleField({ label, checked, onChange }: ToggleFieldProps) {
return (
<label className="flex min-h-8 items-center justify-between gap-3 rounded-lg border px-2.5 text-sm">
<span>{label}</span>
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="size-4 accent-primary"
/>
</label>
)
}
type SectionHeaderProps = {
title: string
description: string
action?: React.ReactNode
}
export function SectionHeader({
title,
description,
action,
}: SectionHeaderProps) {
return (
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">{title}</h1>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
{action}
</div>
)
}
export function Panel({ children }: { children: React.ReactNode }) {
return <div className="rounded-lg border bg-background p-4">{children}</div>
}

View File

@ -0,0 +1,140 @@
import { Bot, Edit3, Plus, Search, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import type { ChatSession, RequestState } from "@/types"
function formatDateTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value))
}
function isBusy(status: RequestState) {
return status === "requesting" || status === "generating"
}
type AppSidebarProps = {
sessions: ChatSession[]
currentSession: ChatSession
search: string
onSearchChange: (value: string) => void
onNewSession: () => void
onClearSessions: () => void
onSwitchSession: (sessionId: string) => void
onRenameSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
}
export function AppSidebar({
sessions,
currentSession,
search,
onSearchChange,
onNewSession,
onClearSessions,
onSwitchSession,
onRenameSession,
onDeleteSession,
}: AppSidebarProps) {
return (
<aside className="sidebar border-r bg-sidebar text-sidebar-foreground">
<div className="flex items-center gap-2 border-b px-3 py-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Bot className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">
simple-llm-chat-ui
</div>
<div className="text-xs text-muted-foreground"> AI </div>
</div>
<Button
type="button"
size="icon-sm"
title="新建会话"
onClick={onNewSession}
>
<Plus />
</Button>
</div>
<div className="flex gap-2 border-b p-3">
<div className="relative flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="搜索会话"
className="pl-7"
/>
</div>
<Button
type="button"
size="icon-sm"
variant="outline"
title="清空所有会话"
onClick={onClearSessions}
>
<Trash2 />
</Button>
</div>
<div className="session-list min-h-0 flex-1 overflow-y-auto p-2">
{sessions.map((session) => (
<div
key={session.id}
className={cn(
"group relative mb-1 rounded-lg border border-transparent",
session.id === currentSession.id &&
"border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground"
)}
>
<button
type="button"
className="w-full min-w-0 px-2.5 py-2 pr-16 text-left"
onClick={() => onSwitchSession(session.id)}
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{session.title}
</span>
{isBusy(session.status) ? (
<span className="size-2 rounded-full bg-amber-500" />
) : null}
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(session.updatedAt)}</span>
<span>{session.messages.length} </span>
</div>
</button>
<div className="pointer-events-none absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<Button
type="button"
size="icon-xs"
variant="ghost"
title="重命名会话"
onClick={() => onRenameSession(session.id)}
>
<Edit3 />
</Button>
<Button
type="button"
size="icon-xs"
variant="ghost"
title="删除会话"
onClick={() => onDeleteSession(session.id)}
>
<Trash2 />
</Button>
</div>
</div>
))}
</div>
</aside>
)
}

View File

@ -118,6 +118,7 @@ export function createDefaultSettings(
activeServiceId: service.id, activeServiceId: service.id,
activeModelId: model.id, activeModelId: model.id,
systemPrompt: DEFAULT_SYSTEM_PROMPT, systemPrompt: DEFAULT_SYSTEM_PROMPT,
chatQuickConfigOpen: true,
theme: "system", theme: "system",
compactMode: false, compactMode: false,
fontScale: 1, fontScale: 1,

View File

@ -128,6 +128,10 @@ function normalizeData(value: unknown): AppData {
})), })),
settings: { settings: {
...settings, ...settings,
chatQuickConfigOpen:
typeof settings.chatQuickConfigOpen === "boolean"
? settings.chatQuickConfigOpen
: fallback.settings.chatQuickConfigOpen,
tools: normalizeToolSettings(settings.tools, fallback.settings.tools), tools: normalizeToolSettings(settings.tools, fallback.settings.tools),
services: safeServices, services: safeServices,
}, },

View File

@ -1,4 +1,10 @@
export type ViewKey = "chat" | "settings" | "models" | "prompts" | "data" | "about" export type ViewKey =
| "chat"
| "settings"
| "models"
| "prompts"
| "data"
| "about"
export type MessageRole = "user" | "assistant" | "tool" export type MessageRole = "user" | "assistant" | "tool"
@ -83,6 +89,18 @@ export type ToolSettings = {
enabledToolIds: string[] enabledToolIds: string[]
} }
export type RuntimeConfig = {
service?: ServiceConfig
model?: ModelConfig
prompt?: PromptTemplate
modelName: string
parameters: ModelParameters
systemPrompt: string
tools: ChatToolDefinition[]
toolChoice: ToolChoiceMode
errors: string[]
}
export type ChatMessage = { export type ChatMessage = {
id: string id: string
role: MessageRole role: MessageRole
@ -147,6 +165,7 @@ export type AppSettings = ModelParameters & {
activeServiceId: string activeServiceId: string
activeModelId: string activeModelId: string
systemPrompt: string systemPrompt: string
chatQuickConfigOpen: boolean
theme: ThemeMode theme: ThemeMode
compactMode: boolean compactMode: boolean
fontScale: number fontScale: number