From d380f0b0ba7b467e95936f5f447ccb6dad4c5779 Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Sun, 19 Apr 2026 14:27:40 +0800 Subject: [PATCH] Add streaming chat rendering for pico --- pkg/agent/streaming_test.go | 244 ++++++++++++++++++ pkg/channels/pico/pico.go | 90 +++++++ pkg/channels/pico/protocol.go | 3 + pkg/config/config.go | 1 + pkg/config/defaults.go | 3 +- .../src/components/chat/assistant-message.tsx | 23 +- .../src/components/chat/chat-page.tsx | 21 ++ .../components/chat/streaming-markdown.tsx | 109 ++++++++ .../src/components/config/config-page.tsx | 1 + .../src/components/config/config-sections.tsx | 10 + .../src/components/config/form-model.ts | 6 + web/frontend/src/features/chat/protocol.ts | 68 ++++- web/frontend/src/i18n/locales/en.json | 2 + web/frontend/src/i18n/locales/zh.json | 2 + web/frontend/src/store/chat.ts | 1 + 15 files changed, 568 insertions(+), 16 deletions(-) create mode 100644 pkg/agent/streaming_test.go create mode 100644 web/frontend/src/components/chat/streaming-markdown.tsx diff --git a/pkg/agent/streaming_test.go b/pkg/agent/streaming_test.go new file mode 100644 index 000000000..4b4e8616b --- /dev/null +++ b/pkg/agent/streaming_test.go @@ -0,0 +1,244 @@ +package agent + +import ( + "context" + "os" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type streamingTestProvider struct { + chatCalled bool + chatStreamCalled bool + chunks []string + response *providers.LLMResponse +} + +func (p *streamingTestProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, +) (*providers.LLMResponse, error) { + p.chatCalled = true + if p.response != nil { + return p.response, nil + } + return &providers.LLMResponse{Content: "fallback"}, nil +} + +func (p *streamingTestProvider) ChatStream( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*providers.LLMResponse, error) { + p.chatStreamCalled = true + for _, chunk := range p.chunks { + onChunk(chunk) + } + if p.response != nil { + return p.response, nil + } + return &providers.LLMResponse{Content: "streamed"}, nil +} + +func (p *streamingTestProvider) GetDefaultModel() string { + return "test-model" +} + +type recordingStreamer struct { + updates []string + finalized []string + canceled bool +} + +func (s *recordingStreamer) Update(ctx context.Context, content string) error { + s.updates = append(s.updates, content) + return nil +} + +func (s *recordingStreamer) Finalize(ctx context.Context, content string) error { + s.finalized = append(s.finalized, content) + return nil +} + +func (s *recordingStreamer) Cancel(ctx context.Context) { + s.canceled = true +} + +type recordingStreamDelegate struct { + streamer *recordingStreamer + ok bool +} + +func (d *recordingStreamDelegate) GetStreamer( + ctx context.Context, + channel, chatID string, +) (bus.Streamer, bool) { + if !d.ok { + return nil, false + } + return d.streamer, true +} + +func newStreamingTestLoop( + t *testing.T, + streamingEnabled bool, + provider *streamingTestProvider, + streamer *recordingStreamer, +) (*AgentLoop, func()) { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "agent-streaming-test-*") + if err != nil { + t.Fatalf("MkdirTemp() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + StreamingEnabled: streamingEnabled, + }, + }, + } + + msgBus := bus.NewMessageBus() + msgBus.SetStreamDelegate(&recordingStreamDelegate{ + streamer: streamer, + ok: true, + }) + + return NewAgentLoop(cfg, msgBus, provider), func() { + os.RemoveAll(tmpDir) + } +} + +func TestProcessMessage_UsesStreamingProviderWhenEnabled(t *testing.T) { + provider := &streamingTestProvider{ + chunks: []string{"Hel", "Hello"}, + response: &providers.LLMResponse{ + Content: "Hello", + }, + } + streamer := &recordingStreamer{} + al, cleanup := newStreamingTestLoop(t, true, provider, streamer) + defer cleanup() + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "pico", + ChatID: "pico:test-session", + SenderID: "user-1", + Content: "hello", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + + if response != "Hello" { + t.Fatalf("response = %q, want %q", response, "Hello") + } + if !provider.chatStreamCalled { + t.Fatal("expected ChatStream to be used") + } + if provider.chatCalled { + t.Fatal("expected Chat() not to be used when streaming is enabled") + } + if len(streamer.updates) != 2 { + t.Fatalf("stream updates = %#v, want 2 accumulated updates", streamer.updates) + } + if len(streamer.finalized) != 1 || streamer.finalized[0] != "Hello" { + t.Fatalf("finalized = %#v, want final Hello", streamer.finalized) + } + if streamer.canceled { + t.Fatal("streamer should not be canceled on direct response") + } +} + +func TestProcessMessage_SkipsStreamingProviderWhenDisabled(t *testing.T) { + provider := &streamingTestProvider{ + response: &providers.LLMResponse{ + Content: "No stream", + }, + } + streamer := &recordingStreamer{} + al, cleanup := newStreamingTestLoop(t, false, provider, streamer) + defer cleanup() + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "pico", + ChatID: "pico:test-session", + SenderID: "user-1", + Content: "hello", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + + if response != "No stream" { + t.Fatalf("response = %q, want %q", response, "No stream") + } + if provider.chatStreamCalled { + t.Fatal("expected ChatStream not to be used when streaming is disabled") + } + if !provider.chatCalled { + t.Fatal("expected Chat() to be used when streaming is disabled") + } + if len(streamer.updates) != 0 || len(streamer.finalized) != 0 || streamer.canceled { + t.Fatalf("streamer should stay unused, got updates=%#v finalized=%#v canceled=%v", streamer.updates, streamer.finalized, streamer.canceled) + } +} + +func TestProcessMessage_CancelsStreamingWhenToolCallsFollow(t *testing.T) { + provider := &streamingTestProvider{ + chunks: []string{"Thinking"}, + response: &providers.LLMResponse{ + Content: "Thinking", + ToolCalls: []providers.ToolCall{ + { + ID: "call-1", + Name: "missing_tool", + Arguments: map[string]any{ + "q": "stream", + }, + }, + }, + }, + } + streamer := &recordingStreamer{} + al, cleanup := newStreamingTestLoop(t, true, provider, streamer) + defer cleanup() + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "pico", + ChatID: "pico:test-session", + SenderID: "user-1", + Content: "hello", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + + if response == "" { + t.Fatal("expected follow-up tool result content after tool-call path") + } + if !provider.chatStreamCalled { + t.Fatal("expected ChatStream to be used") + } + if !streamer.canceled { + t.Fatal("expected streamer to be canceled when tool calls follow streamed text") + } + if len(streamer.finalized) != 0 { + t.Fatalf("finalized = %#v, want no finalize after tool calls", streamer.finalized) + } +} diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index d1de8f4d5..ef87ff641 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -107,6 +107,16 @@ type PicoChannel struct { deleteMessageFn func(context.Context, string, string) error } +type picoStreamer struct { + channel *PicoChannel + chatID string + messageID string + + mu sync.Mutex + started bool + closed bool +} + // NewPicoChannel creates a new Pico Protocol channel. func NewPicoChannel( bc *config.Channel, @@ -354,6 +364,18 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri return []string{msgID}, nil } +func (c *PicoChannel) BeginStream(_ context.Context, chatID string) (channels.Streamer, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + return &picoStreamer{ + channel: c, + chatID: chatID, + messageID: uuid.New().String(), + }, nil +} + // EditMessage implements channels.MessageEditor. func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { return c.editMessage(ctx, chatID, messageID, content, nil) @@ -440,6 +462,74 @@ func (c *PicoChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.O return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.editMessage, msg.ContextUsage) } +func (s *picoStreamer) Update(_ context.Context, content string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return nil + } + + if !s.started { + s.started = true + return s.channel.broadcastToSession(s.chatID, newMessage(TypeMessageCreate, map[string]any{ + "message_id": s.messageID, + PayloadKeyContent: content, + PayloadKeyThought: false, + PayloadKeyStreaming: true, + })) + } + + return s.channel.broadcastToSession(s.chatID, newMessage(TypeMessageUpdate, map[string]any{ + "message_id": s.messageID, + "content": content, + "streaming": true, + })) +} + +func (s *picoStreamer) Finalize(_ context.Context, content string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return nil + } + s.closed = true + + if !s.started { + return s.channel.broadcastToSession(s.chatID, newMessage(TypeMessageCreate, map[string]any{ + "message_id": s.messageID, + PayloadKeyContent: content, + PayloadKeyThought: false, + PayloadKeyStreaming: false, + })) + } + + return s.channel.broadcastToSession(s.chatID, newMessage(TypeMessageUpdate, map[string]any{ + "message_id": s.messageID, + "content": content, + "streaming": false, + })) +} + +func (s *picoStreamer) Cancel(_ context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return + } + s.closed = true + + if !s.started { + return + } + + _ = s.channel.broadcastToSession(s.chatID, newMessage(TypeMessageDelete, map[string]any{ + "message_id": s.messageID, + })) +} + // StartTyping implements channels.TypingCapable. func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { startMsg := newMessage(TypeTypingStart, nil) diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index 6e3a5ca89..6f4435264 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -22,10 +22,13 @@ const ( TypeError = "error" TypePong = "pong" + PicoTokenPrefix = "pico-" + PayloadKeyContent = "content" PayloadKeyThought = "thought" PayloadKeyKind = "kind" PayloadKeyToolCalls = "tool_calls" + PayloadKeyStreaming = "streaming" MessageKindThought = "thought" MessageKindToolCalls = "tool_calls" diff --git a/pkg/config/config.go b/pkg/config/config.go index dc9e88949..d0b3f9207 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -273,6 +273,7 @@ type AgentDefaults struct { MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential) SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` + StreamingEnabled bool `json:"streaming_enabled" env:"PICOCLAW_AGENTS_DEFAULTS_STREAMING_ENABLED"` SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"` ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index be8c32495..13a6b567f 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -39,7 +39,8 @@ func DefaultConfig() *Config { MaxArgsLength: 300, SeparateMessages: false, }, - SplitOnMarker: false, + StreamingEnabled: true, + SplitOnMarker: false, }, }, Session: SessionConfig{ diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 07a3c0abc..f3969ea37 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -9,12 +9,8 @@ import { } from "@tabler/icons-react" import { useState } from "react" import { useTranslation } from "react-i18next" -import ReactMarkdown from "react-markdown" -import rehypeHighlight from "rehype-highlight" -import rehypeRaw from "rehype-raw" -import rehypeSanitize from "rehype-sanitize" -import remarkGfm from "remark-gfm" +import { StreamingMarkdown } from "@/components/chat/streaming-markdown" import { Button } from "@/components/ui/button" import { formatMessageTime } from "@/hooks/use-pico-chat" import { cn } from "@/lib/utils" @@ -29,6 +25,8 @@ interface AssistantMessageProps { attachments?: ChatAttachment[] kind?: AssistantMessageKind toolCalls?: ChatToolCall[] + streamingEnabled?: boolean + isStreaming?: boolean timestamp?: string | number } @@ -37,6 +35,8 @@ export function AssistantMessage({ attachments = [], kind = "normal", toolCalls = [], + streamingEnabled = true, + isStreaming = false, timestamp = "", }: AssistantMessageProps) { const { t } = useTranslation() @@ -184,21 +184,16 @@ export function AssistantMessage({ )} {(!isCollapsedBlock || isExpanded) && !isToolCalls && hasText && ( -
- - {content} - -
+ /> )} {!isCollapsedBlock && hasText && ( diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 3ad811dae..21f381fa7 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -4,6 +4,7 @@ import { type ChangeEvent, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" +import { launcherFetch } from "@/api/http" import { AssistantMessage } from "@/components/chat/assistant-message" import { ChatComposer, @@ -21,6 +22,7 @@ import { useChatModels } from "@/hooks/use-chat-models" import { useGateway } from "@/hooks/use-gateway" import { usePicoChat } from "@/hooks/use-pico-chat" import { useSessionHistory } from "@/hooks/use-session-history" +import { useQuery } from "@tanstack/react-query" import type { ConnectionState } from "@/store/chat" import type { ChatAttachment } from "@/store/chat" import { showAssistantDetailsAtom } from "@/store/chat" @@ -159,6 +161,23 @@ export function ChatPage() { onDeletedActiveSession: newChat, }) + const { data: appConfig } = useQuery({ + queryKey: ["config"], + queryFn: async () => { + const res = await launcherFetch("/api/config") + if (!res.ok) { + throw new Error("Failed to load config") + } + return res.json() as Promise> + }, + staleTime: 60_000, + }) + const streamingEnabled = + ( + (appConfig?.agents as { defaults?: { streaming_enabled?: boolean } }) + ?.defaults?.streaming_enabled + ) !== false + const syncScrollState = (element: HTMLDivElement) => { const { clientHeight, scrollHeight, scrollTop } = element setHasScrolled(scrollTop > 0) @@ -340,6 +359,8 @@ export function ChatPage() { attachments={msg.attachments} kind={msg.kind} toolCalls={msg.toolCalls} + isStreaming={msg.streaming} + streamingEnabled={streamingEnabled} timestamp={msg.timestamp} /> ) : ( diff --git a/web/frontend/src/components/chat/streaming-markdown.tsx b/web/frontend/src/components/chat/streaming-markdown.tsx new file mode 100644 index 000000000..05c2317e4 --- /dev/null +++ b/web/frontend/src/components/chat/streaming-markdown.tsx @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useState } from "react" +import ReactMarkdown from "react-markdown" +import rehypeHighlight from "rehype-highlight" +import rehypeRaw from "rehype-raw" +import rehypeSanitize from "rehype-sanitize" +import remarkGfm from "remark-gfm" + +import { cn } from "@/lib/utils" + +interface StreamingMarkdownProps { + content: string + animate: boolean + className?: string +} + +function nextChunkSize(remaining: number) { + if (remaining > 480) return 6 + if (remaining > 240) return 4 + if (remaining > 120) return 3 + return 1 +} + +function nextFrameDelay(nextChunk: string, remaining: number) { + if (/[,.!?;:\n]$/.test(nextChunk)) { + return 90 + } + if (remaining > 480) return 32 + if (remaining > 240) return 38 + if (remaining > 120) return 44 + return 52 +} + +export function StreamingMarkdown({ + content, + animate, + className, +}: StreamingMarkdownProps) { + const [displayedContent, setDisplayedContent] = useState(() => + animate ? "" : content, + ) + + useEffect(() => { + if (!animate) { + setDisplayedContent(content) + return + } + + setDisplayedContent((current) => { + if (current === "" && content.length > 0) { + return "" + } + if (content.length < current.length) { + return content + } + return current + }) + }, [animate, content]) + + useEffect(() => { + if (!animate || displayedContent === content) { + return + } + + const remaining = content.length - displayedContent.length + const chunkSize = nextChunkSize(remaining) + const nextChunk = content.slice( + displayedContent.length, + displayedContent.length + chunkSize, + ) + + const timer = window.setTimeout(() => { + setDisplayedContent((current) => { + if (current === content) { + return current + } + + const nextLength = Math.min( + content.length, + current.length + chunkSize, + ) + return content.slice(0, nextLength) + }) + }, nextFrameDelay(nextChunk, remaining)) + + return () => window.clearTimeout(timer) + }, [animate, content, displayedContent]) + + const markdown = useMemo( + () => (animate ? displayedContent : content), + [animate, content, displayedContent], + ) + + return ( +
+ + {markdown} + + {animate && ( +
+ ) +} diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 0b5665640..cf9b61dad 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -242,6 +242,7 @@ export function ConfigPage() { defaults: { workspace, restrict_to_workspace: form.restrictToWorkspace, + streaming_enabled: form.streamingEnabled, split_on_marker: form.splitOnMarker, tool_feedback: { enabled: form.toolFeedbackEnabled, diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index fa6b3a079..2b0ee2029 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -95,6 +95,16 @@ export function AgentDefaultsSection({ } /> + + onFieldChange("streamingEnabled", checked) + } + /> + export interface CoreConfigForm { workspace: string restrictToWorkspace: boolean + streamingEnabled: boolean splitOnMarker: boolean toolFeedbackEnabled: boolean toolFeedbackMaxArgsLength: string @@ -69,6 +70,7 @@ export const DM_SCOPE_OPTIONS = [ export const EMPTY_FORM: CoreConfigForm = { workspace: "", restrictToWorkspace: true, + streamingEnabled: true, splitOnMarker: false, toolFeedbackEnabled: false, toolFeedbackMaxArgsLength: "300", @@ -144,6 +146,10 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { defaults.restrict_to_workspace === undefined ? EMPTY_FORM.restrictToWorkspace : asBool(defaults.restrict_to_workspace), + streamingEnabled: + defaults.streaming_enabled === undefined + ? EMPTY_FORM.streamingEnabled + : asBool(defaults.streaming_enabled), splitOnMarker: defaults.split_on_marker === undefined ? EMPTY_FORM.splitOnMarker diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts index 7b5c5e17b..174c47cd8 100644 --- a/web/frontend/src/features/chat/protocol.ts +++ b/web/frontend/src/features/chat/protocol.ts @@ -7,6 +7,7 @@ import { import { normalizeUnixTimestamp } from "@/features/chat/state" import { type ChatAttachment, + type ChatMessage, type ContextUsage, updateChatStore, } from "@/store/chat" @@ -83,6 +84,38 @@ function parseContextUsage( } } +function isToolFeedbackMessage(message: ChatMessage): boolean { + if (message.role !== "assistant") { + return false + } + + const firstLine = message.content.split("\n", 1)[0]?.trim() ?? "" + return /^🔧\s+`[^`]+`/.test(firstLine) +} + +function findToolFeedbackMessageIndex(messages: ChatMessage[]): number { + let lastUserIndex = -1 + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (messages[i].role === "user") { + lastUserIndex = i + break + } + } + + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (i <= lastUserIndex) { + break + } + if (isToolFeedbackMessage(messages[i])) { + return i + } + } + return -1 +} + +function parseStreamingFlag(payload: Record): boolean | undefined { + return typeof payload.streaming === "boolean" ? payload.streaming : undefined +} export function handlePicoMessage( message: PicoMessage, expectedSessionId: string, @@ -101,6 +134,7 @@ export function handlePicoMessage( parseAssistantMessageCreateState(payload) const attachments = parseAttachments(payload) const contextUsage = parseContextUsage(payload) + const streaming = parseStreamingFlag(payload) const timestamp = message.timestamp !== undefined && Number.isFinite(Number(message.timestamp)) @@ -117,6 +151,7 @@ export function handlePicoMessage( kind, ...(toolCalls ? { toolCalls } : {}), attachments, + ...(streaming !== undefined ? { streaming } : {}), timestamp, }, ], @@ -135,6 +170,7 @@ export function handlePicoMessage( Number.isFinite(Number(message.timestamp)) ? normalizeUnixTimestamp(Number(message.timestamp)) : Date.now() + const streaming = parseStreamingFlag(payload) if (!messageId) { break } @@ -156,6 +192,7 @@ export function handlePicoMessage( kind, toolCalls, ...(attachments ? { attachments } : {}), + ...(streaming !== undefined ? { streaming } : {}), } }) if (found) { @@ -164,6 +201,22 @@ export function handlePicoMessage( const { content, kind, toolCalls } = parseAssistantMessageUpdateState(payload) + const fallbackIndex = findToolFeedbackMessageIndex(messages) + if (fallbackIndex >= 0) { + return messages.map((msg, index) => + index === fallbackIndex + ? { + ...msg, + id: messageId, + content, + kind, + toolCalls, + ...(attachments ? { attachments } : {}), + ...(streaming !== undefined ? { streaming } : {}), + } + : msg, + ) + } return [ ...messages, @@ -174,6 +227,7 @@ export function handlePicoMessage( kind, toolCalls, ...(attachments ? { attachments } : {}), + ...(streaming !== undefined ? { streaming } : {}), timestamp, }, ] @@ -190,7 +244,19 @@ export function handlePicoMessage( } updateChatStore((prev) => ({ - messages: prev.messages.filter((msg) => msg.id !== messageId), + messages: (() => { + const exactMessages = prev.messages.filter((msg) => msg.id !== messageId) + if (exactMessages.length !== prev.messages.length) { + return exactMessages + } + + const fallbackIndex = findToolFeedbackMessageIndex(prev.messages) + if (fallbackIndex < 0) { + return prev.messages + } + + return prev.messages.filter((_, index) => index !== fallbackIndex) + })(), })) break } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 4e7a0c818..59f20c5ab 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -617,6 +617,8 @@ "workspace_hint": "Base directory for agent file operations.", "restrict_workspace": "Restrict to Workspace", "restrict_workspace_hint": "Only allow file operations inside workspace.", + "streaming_enabled": "Streaming Response", + "streaming_enabled_hint": "Render live model output in chat and keep markdown updating as new text arrives.", "split_on_marker": "Chatty Mode", "split_on_marker_hint": "Split long messages into short ones like real human chatting.", "tool_feedback_enabled": "Tool Feedback", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index fa7d56418..c0f433330 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -617,6 +617,8 @@ "workspace_hint": "智能体执行文件读写操作时使用的基础目录", "restrict_workspace": "限制工作目录访问", "restrict_workspace_hint": "仅允许在工作目录内执行文件操作", + "streaming_enabled": "流式响应", + "streaming_enabled_hint": "在对话中实时渲染模型输出,并在新文本到达时持续更新 Markdown 内容", "split_on_marker": "连续短消息", "split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出", "tool_feedback_enabled": "工具反馈", diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts index 7a078d097..9da586af0 100644 --- a/web/frontend/src/store/chat.ts +++ b/web/frontend/src/store/chat.ts @@ -37,6 +37,7 @@ export interface ChatMessage { content: string timestamp: number | string kind?: AssistantMessageKind + streaming?: boolean attachments?: ChatAttachment[] toolCalls?: ChatToolCall[] }