feie9456 0003e0b58f feat: 加入预估价格,优化移动UI
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 23:33:08 +08:00

1029 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}