feat: 优化UI与代码结构
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
0e9e0c71b5
commit
6c4c743e5a
2206
src/App.tsx
2206
src/App.tsx
File diff suppressed because it is too large
Load Diff
64
src/components/app/about-view.tsx
Normal file
64
src/components/app/about-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
889
src/components/app/chat-view.tsx
Normal file
889
src/components/app/chat-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
146
src/components/app/data-view.tsx
Normal file
146
src/components/app/data-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
261
src/components/app/models-view.tsx
Normal file
261
src/components/app/models-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
src/components/app/prompts-view.tsx
Normal file
159
src/components/app/prompts-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
474
src/components/app/settings-view.tsx
Normal file
474
src/components/app/settings-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
src/components/app/shared.tsx
Normal file
68
src/components/app/shared.tsx
Normal 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>
|
||||
}
|
||||
140
src/components/app/sidebar.tsx
Normal file
140
src/components/app/sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -118,6 +118,7 @@ export function createDefaultSettings(
|
||||
activeServiceId: service.id,
|
||||
activeModelId: model.id,
|
||||
systemPrompt: DEFAULT_SYSTEM_PROMPT,
|
||||
chatQuickConfigOpen: true,
|
||||
theme: "system",
|
||||
compactMode: false,
|
||||
fontScale: 1,
|
||||
|
||||
@ -128,6 +128,10 @@ function normalizeData(value: unknown): AppData {
|
||||
})),
|
||||
settings: {
|
||||
...settings,
|
||||
chatQuickConfigOpen:
|
||||
typeof settings.chatQuickConfigOpen === "boolean"
|
||||
? settings.chatQuickConfigOpen
|
||||
: fallback.settings.chatQuickConfigOpen,
|
||||
tools: normalizeToolSettings(settings.tools, fallback.settings.tools),
|
||||
services: safeServices,
|
||||
},
|
||||
|
||||
21
src/types.ts
21
src/types.ts
@ -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"
|
||||
|
||||
@ -83,6 +89,18 @@ export type ToolSettings = {
|
||||
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 = {
|
||||
id: string
|
||||
role: MessageRole
|
||||
@ -147,6 +165,7 @@ export type AppSettings = ModelParameters & {
|
||||
activeServiceId: string
|
||||
activeModelId: string
|
||||
systemPrompt: string
|
||||
chatQuickConfigOpen: boolean
|
||||
theme: ThemeMode
|
||||
compactMode: boolean
|
||||
fontScale: number
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user