1029 lines
32 KiB
TypeScript
1029 lines
32 KiB
TypeScript
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 { formatMessageCost } from "@/lib/pricing"
|
||
import { cn } from "@/lib/utils"
|
||
import type {
|
||
AskQuestionsAnswer,
|
||
AskQuestionsPayload,
|
||
ChatMessage,
|
||
ChatSession,
|
||
ChatToolCall,
|
||
ModelConfig,
|
||
PromptTemplate,
|
||
RequestState,
|
||
ReasoningEffort,
|
||
RuntimeConfig,
|
||
ServiceConfig,
|
||
} from "@/types"
|
||
|
||
const STATUS_TEXT: Record<RequestState | "done", string> = {
|
||
idle: "就绪",
|
||
requesting: "请求中",
|
||
generating: "生成中",
|
||
failed: "失败",
|
||
stopped: "已停止",
|
||
done: "完成",
|
||
}
|
||
|
||
const REASONING_EFFORT_OPTIONS: { value: ReasoningEffort; label: string }[] = [
|
||
{ value: "default", label: "默认" },
|
||
{ value: "minimal", label: "minimal" },
|
||
{ value: "low", label: "low" },
|
||
{ value: "medium", label: "medium" },
|
||
{ value: "high", label: "high" },
|
||
]
|
||
|
||
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 cachedPromptTokens =
|
||
message.usage.promptCacheHitTokens ?? message.usage.cachedPromptTokens
|
||
const parts = [
|
||
message.usage.promptTokens !== undefined
|
||
? `In: ${message.usage.promptTokens}`
|
||
: "",
|
||
cachedPromptTokens !== undefined ? `(Cached: ${cachedPromptTokens})` : "",
|
||
message.usage.completionTokens !== undefined
|
||
? `Out: ${message.usage.completionTokens}`
|
||
: "",
|
||
message.usage.totalTokens !== undefined
|
||
? `All: ${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
|
||
headerOpen: 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
|
||
onHeaderOpenChange: (open: boolean) => void
|
||
onQuickConfigOpenChange: (open: boolean) => void
|
||
onPromptEditorToggle: () => void
|
||
onSystemPromptChange: (value: string) => void
|
||
onReasoningEffortChange: (value: ReasoningEffort) => void
|
||
onOpenSettings: () => void
|
||
}
|
||
|
||
export function ChatView({
|
||
currentSession,
|
||
runtimeConfig,
|
||
services,
|
||
models,
|
||
prompts,
|
||
chatInput,
|
||
editingMessageId,
|
||
editingContent,
|
||
showPromptEditor,
|
||
headerOpen,
|
||
quickConfigOpen,
|
||
isBusy,
|
||
configIsValid,
|
||
onInputChange,
|
||
onSend,
|
||
onStop,
|
||
onCopy,
|
||
onDeleteMessage,
|
||
onEditMessage,
|
||
onForkMessage,
|
||
onSubmitAskQuestions,
|
||
onCancelEdit,
|
||
onEditingContentChange,
|
||
onSaveEditOnly,
|
||
onSaveEditAndSend,
|
||
onServiceChange,
|
||
onModelChange,
|
||
onPromptChange,
|
||
onHeaderOpenChange,
|
||
onQuickConfigOpenChange,
|
||
onPromptEditorToggle,
|
||
onSystemPromptChange,
|
||
onReasoningEffortChange,
|
||
onOpenSettings,
|
||
}: ChatViewProps) {
|
||
const canSend = chatInput.trim().length > 0 && !isBusy
|
||
const messagesScrollRef = React.useRef<HTMLDivElement>(null)
|
||
const reasoningMenuRef = React.useRef<HTMLDivElement>(null)
|
||
const [reasoningMenuOpen, setReasoningMenuOpen] = React.useState(false)
|
||
const selectedReasoningLabel =
|
||
REASONING_EFFORT_OPTIONS.find(
|
||
(option) => option.value === runtimeConfig.parameters.reasoningEffort
|
||
)?.label ?? runtimeConfig.parameters.reasoningEffort
|
||
|
||
React.useEffect(() => {
|
||
const element = messagesScrollRef.current
|
||
|
||
if (!element) {
|
||
return
|
||
}
|
||
|
||
// Keep session switching predictable by jumping to the latest message.
|
||
element.scrollTop = element.scrollHeight
|
||
}, [currentSession.id])
|
||
|
||
React.useEffect(() => {
|
||
if (!reasoningMenuOpen) {
|
||
return undefined
|
||
}
|
||
|
||
const handlePointerDown = (event: PointerEvent) => {
|
||
if (
|
||
reasoningMenuRef.current &&
|
||
!reasoningMenuRef.current.contains(event.target as Node)
|
||
) {
|
||
setReasoningMenuOpen(false)
|
||
}
|
||
}
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
setReasoningMenuOpen(false)
|
||
}
|
||
}
|
||
|
||
document.addEventListener("pointerdown", handlePointerDown)
|
||
document.addEventListener("keydown", handleKeyDown)
|
||
|
||
return () => {
|
||
document.removeEventListener("pointerdown", handlePointerDown)
|
||
document.removeEventListener("keydown", handleKeyDown)
|
||
}
|
||
}, [reasoningMenuOpen])
|
||
|
||
return (
|
||
<section className="content-view chat-view">
|
||
{headerOpen ? (
|
||
<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-1">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
title={quickConfigOpen ? "收起配置" : "展开配置"}
|
||
aria-label={quickConfigOpen ? "收起配置" : "展开配置"}
|
||
aria-expanded={quickConfigOpen}
|
||
onClick={() => onQuickConfigOpenChange(!quickConfigOpen)}
|
||
>
|
||
<Settings />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
title="收起聊天信息"
|
||
aria-label="收起聊天信息"
|
||
aria-expanded={headerOpen}
|
||
onClick={() => onHeaderOpenChange(false)}
|
||
>
|
||
<ChevronUp />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex justify-end gap-1 border-b px-4 py-1.5">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
title={quickConfigOpen ? "收起配置" : "展开配置"}
|
||
aria-label={quickConfigOpen ? "收起配置" : "展开配置"}
|
||
aria-expanded={quickConfigOpen}
|
||
onClick={() => onQuickConfigOpenChange(!quickConfigOpen)}
|
||
>
|
||
<Settings />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
title="展开聊天信息"
|
||
aria-label="展开聊天信息"
|
||
aria-expanded={headerOpen}
|
||
onClick={() => onHeaderOpenChange(true)}
|
||
>
|
||
<ChevronDown />
|
||
</Button>
|
||
</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
|
||
ref={messagesScrollRef}
|
||
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-1">
|
||
<div ref={reasoningMenuRef} className="relative">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
title={`推理强度 reasoning_effort:${selectedReasoningLabel}`}
|
||
aria-label="推理强度 reasoning_effort"
|
||
aria-haspopup="menu"
|
||
aria-expanded={reasoningMenuOpen}
|
||
disabled={isBusy}
|
||
onClick={() => setReasoningMenuOpen((open) => !open)}
|
||
>
|
||
<SlidersHorizontal />
|
||
</Button>
|
||
{reasoningMenuOpen ? (
|
||
<div className="absolute bottom-full left-0 z-30 mb-2 w-40 rounded-lg border bg-popover p-1 text-sm text-popover-foreground shadow-lg">
|
||
{REASONING_EFFORT_OPTIONS.map((option) => {
|
||
const selected =
|
||
option.value === runtimeConfig.parameters.reasoningEffort
|
||
|
||
return (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
role="menuitemradio"
|
||
aria-checked={selected}
|
||
className={cn(
|
||
"flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-sm hover:bg-muted focus-visible:bg-muted focus-visible:outline-none",
|
||
selected && "bg-muted font-medium"
|
||
)}
|
||
onClick={() => {
|
||
onReasoningEffortChange(option.value)
|
||
setReasoningMenuOpen(false)
|
||
}}
|
||
>
|
||
<span>{option.label}</span>
|
||
{selected ? <span className="text-xs">✓</span> : null}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</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 cost = formatMessageCost(message.cost)
|
||
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 flex-col",
|
||
message.role === "tool" && "gap-3",
|
||
isUserMessage ? "items-end" : "items-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 || cost ? (
|
||
<div className="mt-2 flex flex-wrap gap-2 text-xs opacity-70">
|
||
{duration ? <span>{duration}</span> : null}
|
||
{usage ? <span>Token {usage}</span> : null}
|
||
{cost ? <span>{cost}</span> : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{!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}
|
||
</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>
|
||
)
|
||
}
|