feat: 优化UI与代码结构
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
0e9e0c71b5
commit
6c4c743e5a
2204
src/App.tsx
2204
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,
|
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,
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
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"
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user