fix(web): clean up restored chat transcripts and optimize chat UI (#2605)

Filter raw tool messages from session history and avoid duplicate summaries for visible message-tool output. Preserve final assistant replies after tool delivery and add coverage for visible transcript counts.

Also refine the chat UI with collapsible reasoning blocks, send shortcut hints, command-style user messages, stable scroll gutters, and updated i18n strings.
This commit is contained in:
wenjie
2026-04-21 11:52:58 +08:00
committed by GitHub
parent 329e68e017
commit dcb4b67e00
11 changed files with 233 additions and 133 deletions
+62 -37
View File
@@ -460,6 +460,9 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
for _, msg := range messages {
switch msg.Role {
case "tool":
continue
case "user":
if sessionMessageVisible(msg) {
transcript = append(transcript, sessionChatMessage{
@@ -501,7 +504,18 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
}
}
return transcript
return filterSessionChatMessages(transcript)
}
func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessage {
filtered := messages[:0]
for _, msg := range messages {
if msg.Role != "user" && msg.Role != "assistant" {
continue
}
filtered = append(filtered, msg)
}
return filtered
}
func assistantMessageTransientThought(msg providers.Message) bool {
@@ -528,22 +542,16 @@ func visibleAssistantToolSummaryMessages(
messages := make([]sessionChatMessage, 0, len(toolCalls))
for _, tc := range toolCalls {
name := tc.Name
argsJSON := ""
if tc.Function != nil {
if name == "" {
name = tc.Function.Name
}
argsJSON = tc.Function.Arguments
}
name, argsJSON := toolCallNameAndArguments(tc)
if strings.TrimSpace(name) == "" {
continue
}
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
argsJSON = string(encodedArgs)
if name == "web_search" || name == "web_fetch" {
continue
}
if name == "message" {
if _, ok := parseMessageToolContent(argsJSON); ok {
continue
}
}
@@ -568,36 +576,53 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM
messages := make([]sessionChatMessage, 0, len(toolCalls))
for _, tc := range toolCalls {
name := tc.Name
argsJSON := ""
if tc.Function != nil {
if name == "" {
name = tc.Function.Name
}
argsJSON = tc.Function.Arguments
name, argsJSON := toolCallNameAndArguments(tc)
if name != "message" {
continue
}
switch name {
case "message":
var args struct {
Content string `json:"content"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
continue
}
if strings.TrimSpace(args.Content) == "" {
continue
}
messages = append(messages, sessionChatMessage{
Role: "assistant",
Content: args.Content,
})
content, ok := parseMessageToolContent(argsJSON)
if !ok {
continue
}
messages = append(messages, sessionChatMessage{
Role: "assistant",
Content: content,
})
}
return messages
}
func toolCallNameAndArguments(tc providers.ToolCall) (string, string) {
name := tc.Name
argsJSON := ""
if tc.Function != nil {
if name == "" {
name = tc.Function.Name
}
argsJSON = tc.Function.Arguments
}
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
argsJSON = string(encodedArgs)
}
}
return name, argsJSON
}
func parseMessageToolContent(argsJSON string) (string, bool) {
var args struct {
Content string `json:"content"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", false
}
if strings.TrimSpace(args.Content) == "" {
return "", false
}
return args.Content, true
}
// sessionsDir resolves the path to the gateway's session storage directory.
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
func (h *Handler) sessionsDir() (string, error) {
+28 -17
View File
@@ -346,7 +346,7 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
}
}
func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -402,14 +402,19 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) != 3 {
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
if len(resp.Messages) != 2 {
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
}
if !strings.Contains(resp.Messages[1].Content, "`message`") {
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
}
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2])
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1])
}
for _, msg := range resp.Messages {
if msg.Role == "tool" || strings.Contains(msg.Content, "`message`") {
t.Fatalf("unexpected raw tool or duplicate message-tool summary: %#v", msg)
}
}
}
@@ -468,17 +473,17 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) != 4 {
t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages))
if len(resp.Messages) != 3 {
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
}
if !strings.Contains(resp.Messages[1].Content, "`message`") {
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
}
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" {
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2])
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1])
}
if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" {
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3])
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" {
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2])
}
}
@@ -535,8 +540,8 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
if items[0].MessageCount != 3 {
t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount)
if items[0].MessageCount != 2 {
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
}
}
@@ -567,6 +572,7 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T)
},
},
},
{Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
@@ -606,6 +612,11 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T)
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" {
t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2])
}
for _, msg := range resp.Messages {
if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") {
t.Fatalf("unexpected raw tool result in history: %#v", msg)
}
}
}
func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {
@@ -1,4 +1,10 @@
import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react"
import {
IconBrain,
IconCheck,
IconChevronDown,
IconCopy,
} from "@tabler/icons-react"
import { useAtom } from "jotai"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
@@ -10,6 +16,7 @@ import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat"
import { cn } from "@/lib/utils"
import { showThoughtsAtom } from "@/store/chat"
interface AssistantMessageProps {
content: string
@@ -24,6 +31,7 @@ export function AssistantMessage({
}: AssistantMessageProps) {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom)
const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : ""
@@ -36,64 +44,76 @@ export function AssistantMessage({
return (
<div className="group flex w-full flex-col gap-1.5">
<div className="text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70">
<div className="flex items-center gap-2">
<span>PicoClaw</span>
{isThought && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300/80 bg-amber-100/80 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-200">
<IconBrain className="size-3" />
<span>{t("chat.reasoningLabel")}</span>
</span>
)}
{formattedTimestamp && (
<>
<span className="opacity-50"></span>
<span>{formattedTimestamp}</span>
</>
)}
{!isThought && (
<div className="text-muted-foreground/60 flex items-center justify-between gap-2 px-1 text-xs opacity-70">
<div className="flex items-center gap-2">
<span>PicoClaw</span>
{formattedTimestamp && (
<>
<span className="opacity-50"></span>
<span>{formattedTimestamp}</span>
</>
)}
</div>
</div>
</div>
)}
<div
className={cn(
"relative overflow-hidden rounded-xl border",
isThought
? "border-amber-200/90 bg-amber-50/70 text-amber-950 dark:border-amber-500/35 dark:bg-amber-500/10 dark:text-amber-100"
: "bg-card text-card-foreground",
? "border-border/30 bg-muted/20 text-muted-foreground dark:border-border/20 dark:bg-muted/10"
: "bg-card text-card-foreground border-border/60",
)}
>
<div
className={cn(
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 dark:prose-pre:bg-zinc-950 max-w-none [overflow-wrap:anywhere] break-words",
isThought
? "prose-p:my-1.5 p-3 text-[13px] leading-relaxed opacity-90"
: "prose-p:my-2 p-4 text-[15px] leading-relaxed",
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
{isThought && (
<div
className="text-muted-foreground/60 hover:text-muted-foreground/80 flex cursor-pointer items-center justify-between px-3 py-2 text-[12px] font-medium transition-colors select-none"
onClick={() => setIsExpanded(!isExpanded)}
>
{content}
</ReactMarkdown>
</div>
<Button
variant="ghost"
size="icon"
className={cn(
"absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100",
isThought
? "bg-amber-100/70 hover:bg-amber-200/80 dark:bg-amber-500/20 dark:hover:bg-amber-400/30"
: "bg-background/50 hover:bg-background/80",
)}
onClick={handleCopy}
>
{isCopied ? (
<IconCheck className="h-4 w-4 text-green-500" />
) : (
<IconCopy className="text-muted-foreground h-4 w-4" />
)}
</Button>
<div className="flex items-center gap-1.5">
<IconBrain className="size-3.5" />
<span>{t("chat.reasoningLabel")}</span>
</div>
<IconChevronDown
className={cn(
"size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
isExpanded ? "rotate-180" : "",
)}
/>
</div>
)}
{(!isThought || isExpanded) && (
<div
className={cn(
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 prose-pre:text-zinc-900 dark:prose-pre:bg-zinc-950 dark:prose-pre:text-zinc-100 max-w-none [overflow-wrap:anywhere] break-words",
isThought
? "prose-p:my-1.5 px-3 pt-0 pb-3 text-[13px] leading-relaxed opacity-70"
: "prose-p:my-2 p-4 text-[15px] leading-relaxed",
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
>
{content}
</ReactMarkdown>
</div>
)}
{!isThought && (
<Button
variant="ghost"
size="icon"
className="bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
onClick={handleCopy}
>
{isCopied ? (
<IconCheck className="h-4 w-4 text-green-500" />
) : (
<IconCopy className="text-muted-foreground h-4 w-4" />
)}
</Button>
)}
</div>
</div>
)
@@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat"
@@ -57,8 +62,8 @@ export function ChatComposer({
}
return (
<div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card border-border/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md">
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] [scrollbar-gutter:stable] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm">
{attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-2">
{attachments.map((attachment, index) => (
@@ -93,17 +98,12 @@ export function ChatComposer({
disabled={!canInput}
title={disabledMessage || undefined}
className={cn(
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[64px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
!canInput && "cursor-not-allowed",
)}
minRows={1}
maxRows={8}
/>
{!canInput && disabledMessage && (
<div className="text-muted-foreground px-3 py-1 text-xs">
{disabledMessage}
</div>
)}
<div className="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1">
@@ -122,15 +122,28 @@ export function ChatComposer({
</div>
{canInput ? (
<Button
type="button"
size="icon"
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
onClick={onSend}
disabled={!canSend}
>
<IconArrowUp className="size-4" />
</Button>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<span tabIndex={!canSend ? 0 : undefined}>
<Button
type="button"
size="icon"
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
onClick={onSend}
disabled={!canSend}
aria-label={t("chat.sendMessage")}
>
<IconArrowUp className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent
className="border-border/70 bg-muted text-foreground border text-center whitespace-pre-line shadow-lg shadow-black/10 dark:shadow-black/30"
arrowClassName="bg-muted fill-muted"
>
{t("chat.sendHint")}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
@@ -153,7 +153,7 @@ export function ChatPage() {
})
const syncScrollState = (element: HTMLDivElement) => {
const { scrollTop, scrollHeight, clientHeight } = element
const { clientHeight, scrollHeight, scrollTop } = element
setHasScrolled(scrollTop > 0)
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
}
@@ -294,7 +294,7 @@ export function ChatPage() {
<div
ref={scrollRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-24 xl:px-48"
className="min-h-0 flex-1 overflow-y-auto px-4 py-6 [scrollbar-gutter:stable] md:px-8 lg:px-24 xl:px-48"
>
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
{messages.length === 0 && !isTyping && (
@@ -21,10 +21,7 @@ export function TypingIndicator() {
return (
<div className="flex w-full flex-col gap-1.5">
<div className="text-muted-foreground flex items-center gap-2 px-1 text-xs opacity-70">
<span>PicoClaw</span>
</div>
<div className="bg-card inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4">
<div className="bg-card border-border/50 inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4">
<div className="flex items-center gap-1.5">
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.3s]" />
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" />
@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat"
interface UserMessageProps {
@@ -7,6 +8,7 @@ interface UserMessageProps {
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
const hasText = content.trim().length > 0
const isCommand = content.trim().startsWith("/")
const imageAttachments = attachments.filter(
(attachment) => attachment.type === "image",
)
@@ -27,8 +29,24 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
)}
{hasText && (
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed wrap-break-word whitespace-pre-wrap text-white shadow-sm">
{content}
<div
className={cn(
"max-w-[70%] wrap-break-word whitespace-pre-wrap",
isCommand
? "rounded-xl border border-zinc-200 bg-transparent px-4 py-3 font-mono text-[14px] text-zinc-800 dark:border-zinc-800/60 dark:bg-[#121212] dark:text-zinc-200 dark:shadow-sm"
: "rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm",
)}
>
{isCommand ? (
<div className="flex items-start gap-2.5">
<span className="font-bold text-emerald-600 select-none dark:text-emerald-400">
</span>
<span className="mt-[1px]">{content}</span>
</div>
) : (
content
)}
</div>
)}
</div>
+10 -2
View File
@@ -30,10 +30,13 @@ function TooltipTrigger({
function TooltipContent({
className,
arrowClassName,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
arrowClassName?: string
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
@@ -46,7 +49,12 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
<TooltipPrimitive.Arrow
className={cn(
"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground",
arrowClassName
)}
/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
+4 -1
View File
@@ -38,7 +38,7 @@
"chat": {
"welcome": "How can I help you today?",
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.",
"placeholder": "Start a new message...\nPress Enter to send, Shift + Enter for a new line",
"placeholder": "Start a new message...",
"disabledPlaceholder": {
"gatewayUnknown": "Unable to chat: Gateway status is still being checked. Please wait, then refresh the page or restart Launcher if needed.",
"gatewayStarting": "Unable to chat: Gateway is starting. Wait for startup to complete, then try again.",
@@ -60,6 +60,7 @@
"step4": "Almost there..."
},
"reasoningLabel": "Reasoning",
"toolLabel": "Tool",
"history": "History",
"noHistory": "No chat history yet",
"historyLoadFailed": "Failed to load chat history",
@@ -72,6 +73,8 @@
"notConnected": "Gateway is not running. Start it to chat.",
"noModel": "No default model configured. Go to Models page to set one."
},
"sendMessage": "Send message",
"sendHint": "Press Enter to send\nShift + Enter for a new line",
"attachImage": "Add images",
"removeImage": "Remove image",
"uploadedImage": "Uploaded image",
+4 -1
View File
@@ -38,7 +38,7 @@
"chat": {
"welcome": "今天我能为您做些什么?",
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
"placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行",
"placeholder": "输入新消息...",
"disabledPlaceholder": {
"gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。",
"gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。",
@@ -60,6 +60,7 @@
"step4": "马上就好..."
},
"reasoningLabel": "思考",
"toolLabel": "工具",
"history": "历史记录",
"noHistory": "暂无对话历史",
"historyLoadFailed": "加载历史记录失败",
@@ -72,6 +73,8 @@
"notConnected": "服务未运行,请先启动以进行对话。",
"noModel": "未设置默认模型,请前往模型页面进行配置。"
},
"sendMessage": "发送消息",
"sendHint": "按 Enter 发送\nShift + Enter 换行",
"attachImage": "添加图片",
"removeImage": "移除图片",
"uploadedImage": "已上传图片",
+2
View File
@@ -48,6 +48,8 @@ const DEFAULT_CHAT_STATE: ChatStoreState = {
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
export const showThoughtsAtom = atom<boolean>(true)
const store = getDefaultStore()
export function getChatState() {