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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 233 additions and 133 deletions

View file

@ -460,6 +460,9 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
for _, msg := range messages { for _, msg := range messages {
switch msg.Role { switch msg.Role {
case "tool":
continue
case "user": case "user":
if sessionMessageVisible(msg) { if sessionMessageVisible(msg) {
transcript = append(transcript, sessionChatMessage{ 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 { func assistantMessageTransientThought(msg providers.Message) bool {
@ -528,22 +542,16 @@ func visibleAssistantToolSummaryMessages(
messages := make([]sessionChatMessage, 0, len(toolCalls)) messages := make([]sessionChatMessage, 0, len(toolCalls))
for _, tc := range toolCalls { for _, tc := range toolCalls {
name := tc.Name name, argsJSON := toolCallNameAndArguments(tc)
argsJSON := ""
if tc.Function != nil {
if name == "" {
name = tc.Function.Name
}
argsJSON = tc.Function.Arguments
}
if strings.TrimSpace(name) == "" { if strings.TrimSpace(name) == "" {
continue continue
} }
if name == "web_search" || name == "web_fetch" {
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { continue
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { }
argsJSON = string(encodedArgs) 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)) messages := make([]sessionChatMessage, 0, len(toolCalls))
for _, tc := range toolCalls { for _, tc := range toolCalls {
name := tc.Name name, argsJSON := toolCallNameAndArguments(tc)
argsJSON := "" if name != "message" {
if tc.Function != nil { continue
if name == "" {
name = tc.Function.Name
}
argsJSON = tc.Function.Arguments
} }
content, ok := parseMessageToolContent(argsJSON)
switch name { if !ok {
case "message": continue
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,
})
} }
messages = append(messages, sessionChatMessage{
Role: "assistant",
Content: content,
})
} }
return messages 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. // sessionsDir resolves the path to the gateway's session storage directory.
// It reads the workspace from config, falling back to ~/.picoclaw/workspace. // It reads the workspace from config, falling back to ~/.picoclaw/workspace.
func (h *Handler) sessionsDir() (string, error) { func (h *Handler) sessionsDir() (string, error) {

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) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -402,14 +402,19 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err) t.Fatalf("Unmarshal() error = %v", err)
} }
if len(resp.Messages) != 3 { if len(resp.Messages) != 2 {
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
} }
if !strings.Contains(resp.Messages[1].Content, "`message`") { if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
} }
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2]) 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 { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err) t.Fatalf("Unmarshal() error = %v", err)
} }
if len(resp.Messages) != 4 { if len(resp.Messages) != 3 {
t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages)) t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
} }
if !strings.Contains(resp.Messages[1].Content, "`message`") { if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" {
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) t.Fatalf("first message = %#v, want user/test", resp.Messages[0])
} }
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { 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[2]) 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" { 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[3]) 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 { if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items)) t.Fatalf("len(items) = %d, want 1", len(items))
} }
if items[0].MessageCount != 3 { if items[0].MessageCount != 2 {
t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount) 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 { if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err) 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" { 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]) 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) { func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {

View file

@ -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 { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown"
@ -10,6 +16,7 @@ import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat" import { formatMessageTime } from "@/hooks/use-pico-chat"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { showThoughtsAtom } from "@/store/chat"
interface AssistantMessageProps { interface AssistantMessageProps {
content: string content: string
@ -24,6 +31,7 @@ export function AssistantMessage({
}: AssistantMessageProps) { }: AssistantMessageProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false) const [isCopied, setIsCopied] = useState(false)
const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom)
const formattedTimestamp = const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : "" timestamp !== "" ? formatMessageTime(timestamp) : ""
@ -36,64 +44,76 @@ export function AssistantMessage({
return ( return (
<div className="group flex w-full flex-col gap-1.5"> <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"> {!isThought && (
<div className="flex items-center gap-2"> <div className="text-muted-foreground/60 flex items-center justify-between gap-2 px-1 text-xs opacity-70">
<span>PicoClaw</span> <div className="flex items-center gap-2">
{isThought && ( <span>PicoClaw</span>
<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"> {formattedTimestamp && (
<IconBrain className="size-3" /> <>
<span>{t("chat.reasoningLabel")}</span> <span className="opacity-50"></span>
</span> <span>{formattedTimestamp}</span>
)} </>
{formattedTimestamp && ( )}
<> </div>
<span className="opacity-50"></span>
<span>{formattedTimestamp}</span>
</>
)}
</div> </div>
</div> )}
<div <div
className={cn( className={cn(
"relative overflow-hidden rounded-xl border", "relative overflow-hidden rounded-xl border",
isThought 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" ? "border-border/30 bg-muted/20 text-muted-foreground dark:border-border/20 dark:bg-muted/10"
: "bg-card text-card-foreground", : "bg-card text-card-foreground border-border/60",
)} )}
> >
<div {isThought && (
className={cn( <div
"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", 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"
isThought onClick={() => setIsExpanded(!isExpanded)}
? "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]}
> >
{content} <div className="flex items-center gap-1.5">
</ReactMarkdown> <IconBrain className="size-3.5" />
</div> <span>{t("chat.reasoningLabel")}</span>
<Button </div>
variant="ghost" <IconChevronDown
size="icon" className={cn(
className={cn( "size-3.5 opacity-0 transition-all duration-200 group-hover:opacity-100",
"absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100", isExpanded ? "rotate-180" : "",
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", </div>
)} )}
onClick={handleCopy} {(!isThought || isExpanded) && (
> <div
{isCopied ? ( className={cn(
<IconCheck className="h-4 w-4 text-green-500" /> "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
<IconCopy className="text-muted-foreground h-4 w-4" /> ? "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",
</Button> )}
>
<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>
</div> </div>
) )

View file

@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize" import TextareaAutosize from "react-textarea-autosize"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat" import type { ChatAttachment } from "@/store/chat"
@ -57,8 +62,8 @@ export function ChatComposer({
} }
return ( 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="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/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md"> <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 && ( {attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-2"> <div className="mb-3 flex flex-wrap gap-2 px-2">
{attachments.map((attachment, index) => ( {attachments.map((attachment, index) => (
@ -93,17 +98,12 @@ export function ChatComposer({
disabled={!canInput} disabled={!canInput}
title={disabledMessage || undefined} title={disabledMessage || undefined}
className={cn( 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", !canInput && "cursor-not-allowed",
)} )}
minRows={1} minRows={1}
maxRows={8} 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="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -122,15 +122,28 @@ export function ChatComposer({
</div> </div>
{canInput ? ( {canInput ? (
<Button <Tooltip delayDuration={700}>
type="button" <TooltipTrigger asChild>
size="icon" <span tabIndex={!canSend ? 0 : undefined}>
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95" <Button
onClick={onSend} type="button"
disabled={!canSend} size="icon"
> className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
<IconArrowUp className="size-4" /> onClick={onSend}
</Button> 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} ) : null}
</div> </div>
</div> </div>

View file

@ -153,7 +153,7 @@ export function ChatPage() {
}) })
const syncScrollState = (element: HTMLDivElement) => { const syncScrollState = (element: HTMLDivElement) => {
const { scrollTop, scrollHeight, clientHeight } = element const { clientHeight, scrollHeight, scrollTop } = element
setHasScrolled(scrollTop > 0) setHasScrolled(scrollTop > 0)
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
} }
@ -294,7 +294,7 @@ export function ChatPage() {
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} 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"> <div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
{messages.length === 0 && !isTyping && ( {messages.length === 0 && !isTyping && (

View file

@ -21,10 +21,7 @@ export function TypingIndicator() {
return ( return (
<div className="flex w-full flex-col gap-1.5"> <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"> <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">
<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="flex items-center gap-1.5"> <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.3s]" />
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" /> <span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" />

View file

@ -1,3 +1,4 @@
import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat" import type { ChatAttachment } from "@/store/chat"
interface UserMessageProps { interface UserMessageProps {
@ -7,6 +8,7 @@ interface UserMessageProps {
export function UserMessage({ content, attachments = [] }: UserMessageProps) { export function UserMessage({ content, attachments = [] }: UserMessageProps) {
const hasText = content.trim().length > 0 const hasText = content.trim().length > 0
const isCommand = content.trim().startsWith("/")
const imageAttachments = attachments.filter( const imageAttachments = attachments.filter(
(attachment) => attachment.type === "image", (attachment) => attachment.type === "image",
) )
@ -27,8 +29,24 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
)} )}
{hasText && ( {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"> <div
{content} 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>
)} )}
</div> </div>

View file

@ -30,10 +30,13 @@ function TooltipTrigger({
function TooltipContent({ function TooltipContent({
className, className,
arrowClassName,
sideOffset = 0, sideOffset = 0,
children, children,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) { }: React.ComponentProps<typeof TooltipPrimitive.Content> & {
arrowClassName?: string
}) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content
@ -46,7 +49,12 @@ function TooltipContent({
{...props} {...props}
> >
{children} {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.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) )

View file

@ -38,7 +38,7 @@
"chat": { "chat": {
"welcome": "How can I help you today?", "welcome": "How can I help you today?",
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.", "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": { "disabledPlaceholder": {
"gatewayUnknown": "Unable to chat: Gateway status is still being checked. Please wait, then refresh the page or restart Launcher if needed.", "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.", "gatewayStarting": "Unable to chat: Gateway is starting. Wait for startup to complete, then try again.",
@ -60,6 +60,7 @@
"step4": "Almost there..." "step4": "Almost there..."
}, },
"reasoningLabel": "Reasoning", "reasoningLabel": "Reasoning",
"toolLabel": "Tool",
"history": "History", "history": "History",
"noHistory": "No chat history yet", "noHistory": "No chat history yet",
"historyLoadFailed": "Failed to load chat history", "historyLoadFailed": "Failed to load chat history",
@ -72,6 +73,8 @@
"notConnected": "Gateway is not running. Start it to chat.", "notConnected": "Gateway is not running. Start it to chat.",
"noModel": "No default model configured. Go to Models page to set one." "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", "attachImage": "Add images",
"removeImage": "Remove image", "removeImage": "Remove image",
"uploadedImage": "Uploaded image", "uploadedImage": "Uploaded image",

View file

@ -38,7 +38,7 @@
"chat": { "chat": {
"welcome": "今天我能为您做些什么?", "welcome": "今天我能为您做些什么?",
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。", "welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
"placeholder": "输入新消息...\n按 Enter 发送Shift + Enter 换行", "placeholder": "输入新消息...",
"disabledPlaceholder": { "disabledPlaceholder": {
"gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。", "gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。",
"gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。", "gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。",
@ -60,6 +60,7 @@
"step4": "马上就好..." "step4": "马上就好..."
}, },
"reasoningLabel": "思考", "reasoningLabel": "思考",
"toolLabel": "工具",
"history": "历史记录", "history": "历史记录",
"noHistory": "暂无对话历史", "noHistory": "暂无对话历史",
"historyLoadFailed": "加载历史记录失败", "historyLoadFailed": "加载历史记录失败",
@ -72,6 +73,8 @@
"notConnected": "服务未运行,请先启动以进行对话。", "notConnected": "服务未运行,请先启动以进行对话。",
"noModel": "未设置默认模型,请前往模型页面进行配置。" "noModel": "未设置默认模型,请前往模型页面进行配置。"
}, },
"sendMessage": "发送消息",
"sendHint": "按 Enter 发送\nShift + Enter 换行",
"attachImage": "添加图片", "attachImage": "添加图片",
"removeImage": "移除图片", "removeImage": "移除图片",
"uploadedImage": "已上传图片", "uploadedImage": "已上传图片",

View file

@ -48,6 +48,8 @@ const DEFAULT_CHAT_STATE: ChatStoreState = {
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE) export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
export const showThoughtsAtom = atom<boolean>(true)
const store = getDefaultStore() const store = getDefaultStore()
export function getChatState() { export function getChatState() {