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