mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(pico): add support for tool_calls in chat messages
This commit is contained in:
+43
-141
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -53,6 +52,7 @@ type sessionChatMessage struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Media []string `json:"media,omitempty"`
|
||||
Attachments []sessionChatAttachment `json:"attachments,omitempty"`
|
||||
ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type sessionChatAttachment struct {
|
||||
@@ -456,7 +456,10 @@ func truncateRunes(s string, maxLen int) string {
|
||||
}
|
||||
|
||||
func sessionChatMessageVisible(msg sessionChatMessage) bool {
|
||||
return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0 || len(msg.Attachments) > 0
|
||||
return strings.TrimSpace(msg.Content) != "" ||
|
||||
len(msg.Media) > 0 ||
|
||||
len(msg.Attachments) > 0 ||
|
||||
len(msg.ToolCalls) > 0
|
||||
}
|
||||
|
||||
func sessionChatMessagePreview(msg sessionChatMessage) string {
|
||||
@@ -475,6 +478,9 @@ func sessionChatMessagePreview(msg sessionChatMessage) string {
|
||||
}
|
||||
return "[attachment]"
|
||||
}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
return "[tool call]"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -521,25 +527,11 @@ func sessionTranscriptMessages(
|
||||
}
|
||||
}
|
||||
|
||||
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
|
||||
if len(toolSummaryMessages) > 0 {
|
||||
transcript = append(transcript, toolSummaryMessages...)
|
||||
}
|
||||
|
||||
toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage(
|
||||
msg.ToolCalls,
|
||||
toolFeedbackMaxArgsLength,
|
||||
)
|
||||
visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls)
|
||||
if len(visibleToolMessages) > 0 {
|
||||
transcript = append(transcript, visibleToolMessages...)
|
||||
}
|
||||
|
||||
// When assistant content exactly matches the rendered tool summary or
|
||||
// tool-delivered message, skip it to avoid duplicates. Distinct content
|
||||
// must remain visible in restored session history.
|
||||
if len(msg.ToolCalls) > 0 &&
|
||||
len(msg.Media) == 0 &&
|
||||
len(attachments) == 0 &&
|
||||
assistantToolCallContentDuplicated(msg.Content, toolSummaryMessages, visibleToolMessages) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pico web chat can persist both visible `message` tool output and a
|
||||
// later plain assistant reply in the same turn. Hide only the fixed
|
||||
@@ -547,6 +539,12 @@ func sessionTranscriptMessages(
|
||||
content := msg.Content
|
||||
if assistantMessageInternalOnly(msg) {
|
||||
if len(attachments) == 0 {
|
||||
if hasToolCallsMsg {
|
||||
transcript = append(transcript, toolCallsMsg)
|
||||
}
|
||||
if len(visibleToolMessages) > 0 {
|
||||
transcript = append(transcript, visibleToolMessages...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
content = ""
|
||||
@@ -559,10 +557,22 @@ func sessionTranscriptMessages(
|
||||
Attachments: attachments,
|
||||
}
|
||||
if !sessionChatMessageVisible(chatMsg) {
|
||||
if hasToolCallsMsg {
|
||||
transcript = append(transcript, toolCallsMsg)
|
||||
}
|
||||
if len(visibleToolMessages) > 0 {
|
||||
transcript = append(transcript, visibleToolMessages...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
transcript = append(transcript, chatMsg)
|
||||
if hasToolCallsMsg {
|
||||
transcript = append(transcript, toolCallsMsg)
|
||||
}
|
||||
if len(visibleToolMessages) > 0 {
|
||||
transcript = append(transcript, visibleToolMessages...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,51 +590,6 @@ func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessa
|
||||
return filtered
|
||||
}
|
||||
|
||||
func assistantToolCallContentDuplicated(
|
||||
content string,
|
||||
toolSummaryMessages []sessionChatMessage,
|
||||
visibleToolMessages []sessionChatMessage,
|
||||
) bool {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, msg := range toolSummaryMessages {
|
||||
if toolSummaryContainsContent(msg.Content, content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, msg := range visibleToolMessages {
|
||||
if strings.TrimSpace(msg.Content) == content {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toolSummaryContainsContent(summary, content string) bool {
|
||||
summary = strings.TrimSpace(summary)
|
||||
content = strings.TrimSpace(content)
|
||||
if summary == "" || content == "" {
|
||||
return false
|
||||
}
|
||||
if summary == content {
|
||||
return true
|
||||
}
|
||||
|
||||
_, body, hasBody := strings.Cut(summary, "\n")
|
||||
if !hasBody {
|
||||
return false
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
if body == content {
|
||||
return true
|
||||
}
|
||||
firstSection, _, _ := strings.Cut(body, "\n```")
|
||||
return strings.TrimSpace(firstSection) == content
|
||||
}
|
||||
|
||||
func sessionAttachments(msg providers.Message) []sessionChatAttachment {
|
||||
if len(msg.Attachments) == 0 {
|
||||
return nil
|
||||
@@ -720,80 +685,34 @@ func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
func visibleAssistantToolSummaryMessages(
|
||||
func assistantToolCallsMessage(
|
||||
toolCalls []providers.ToolCall,
|
||||
toolFeedbackMaxArgsLength int,
|
||||
) []sessionChatMessage {
|
||||
) (sessionChatMessage, bool) {
|
||||
if len(toolCalls) == 0 {
|
||||
return nil
|
||||
return sessionChatMessage{}, false
|
||||
}
|
||||
if toolFeedbackMaxArgsLength <= 0 {
|
||||
toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength()
|
||||
}
|
||||
|
||||
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
name, argsJSON := toolCallNameAndArguments(tc)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
continue
|
||||
}
|
||||
if name == "web_search" || name == "web_fetch" {
|
||||
continue
|
||||
}
|
||||
if name == "message" {
|
||||
if _, ok := parseMessageToolContent(argsJSON); ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Content: utils.FormatToolFeedbackMessage(
|
||||
name,
|
||||
visibleAssistantToolFeedbackExplanation(tc, toolFeedbackMaxArgsLength),
|
||||
visibleAssistantToolArgsPreview(tc, toolFeedbackMaxArgsLength),
|
||||
),
|
||||
})
|
||||
visibleToolCalls := utils.BuildVisibleToolCalls(toolCalls, toolFeedbackMaxArgsLength)
|
||||
if len(visibleToolCalls) == 0 {
|
||||
return sessionChatMessage{}, false
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func visibleAssistantToolFeedbackExplanation(
|
||||
tc providers.ToolCall,
|
||||
toolFeedbackMaxArgsLength int,
|
||||
) string {
|
||||
if tc.ExtraContent != nil {
|
||||
if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" {
|
||||
return utils.Truncate(explanation, toolFeedbackMaxArgsLength)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return sessionChatMessage{
|
||||
Role: "assistant",
|
||||
Kind: "tool_calls",
|
||||
ToolCalls: visibleToolCalls,
|
||||
}, true
|
||||
}
|
||||
|
||||
func visibleAssistantToolArgsPreview(
|
||||
tc providers.ToolCall,
|
||||
toolFeedbackMaxArgsLength int,
|
||||
) string {
|
||||
argsJSON := ""
|
||||
if tc.Function != nil {
|
||||
argsJSON = tc.Function.Arguments
|
||||
}
|
||||
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
|
||||
if encodedArgs, err := json.MarshalIndent(tc.Arguments, "", " "); err == nil {
|
||||
argsJSON = string(encodedArgs)
|
||||
}
|
||||
}
|
||||
argsJSON = strings.TrimSpace(argsJSON)
|
||||
if argsJSON == "" {
|
||||
return ""
|
||||
}
|
||||
var pretty bytes.Buffer
|
||||
if err := json.Indent(&pretty, []byte(argsJSON), "", " "); err == nil {
|
||||
argsJSON = pretty.String()
|
||||
}
|
||||
|
||||
return utils.Truncate(argsJSON, toolFeedbackMaxArgsLength)
|
||||
return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength)
|
||||
}
|
||||
|
||||
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
|
||||
@@ -803,7 +722,7 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM
|
||||
|
||||
messages := make([]sessionChatMessage, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
name, argsJSON := toolCallNameAndArguments(tc)
|
||||
name, argsJSON := utils.VisibleToolCallNameAndArguments(tc)
|
||||
if name != "message" {
|
||||
continue
|
||||
}
|
||||
@@ -820,23 +739,6 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM
|
||||
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"`
|
||||
|
||||
+84
-121
@@ -32,6 +32,25 @@ func sessionsTestDir(t *testing.T, configPath string) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
func assertVisibleToolCallMessage(
|
||||
t *testing.T,
|
||||
msg sessionChatMessage,
|
||||
toolName string,
|
||||
) utils.VisibleToolCall {
|
||||
t.Helper()
|
||||
|
||||
if msg.Role != "assistant" || msg.Kind != "tool_calls" {
|
||||
t.Fatalf("message = %#v, want assistant/tool_calls", msg)
|
||||
}
|
||||
if len(msg.ToolCalls) != 1 {
|
||||
t.Fatalf("len(message.ToolCalls) = %d, want 1", len(msg.ToolCalls))
|
||||
}
|
||||
if got := msg.ToolCalls[0].Function; got == nil || got.Name != toolName {
|
||||
t.Fatalf("tool call = %#v, want function %q", msg.ToolCalls[0], toolName)
|
||||
}
|
||||
return msg.ToolCalls[0]
|
||||
}
|
||||
|
||||
func TestHandleListSessions_JSONLStorage(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
@@ -516,11 +535,7 @@ func TestHandleGetSession_SkipsTransientThoughtMessages(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"messages"`
|
||||
Messages []sessionChatMessage `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
@@ -569,11 +584,7 @@ func TestHandleGetSession_ReconstructsThoughtFromAssistantReasoningContent(t *te
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"messages"`
|
||||
Messages []sessionChatMessage `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
@@ -667,11 +678,7 @@ func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *t
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"messages"`
|
||||
Messages []sessionChatMessage `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
@@ -694,20 +701,14 @@ func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *t
|
||||
assertMessage(2, "assistant", "", "plain visible")
|
||||
assertMessage(3, "user", "", "turn2")
|
||||
assertMessage(4, "assistant", "thought", "tool thought")
|
||||
if !strings.Contains(resp.Messages[5].Content, "`read_file`") {
|
||||
t.Fatalf("messages[5] = %#v, want read_file tool summary", resp.Messages[5])
|
||||
}
|
||||
assertVisibleToolCallMessage(t, resp.Messages[5], "read_file")
|
||||
assertMessage(6, "user", "", "turn3")
|
||||
if !strings.Contains(resp.Messages[7].Content, "`list_dir`") {
|
||||
t.Fatalf("messages[7] = %#v, want list_dir tool summary", resp.Messages[7])
|
||||
}
|
||||
assertMessage(8, "assistant", "", "tool visible only")
|
||||
assertMessage(7, "assistant", "", "tool visible only")
|
||||
assertVisibleToolCallMessage(t, resp.Messages[8], "list_dir")
|
||||
assertMessage(9, "user", "", "turn4")
|
||||
assertMessage(10, "assistant", "thought", "tool mixed thought")
|
||||
if !strings.Contains(resp.Messages[11].Content, "`exec`") {
|
||||
t.Fatalf("messages[11] = %#v, want exec tool summary", resp.Messages[11])
|
||||
}
|
||||
assertMessage(12, "assistant", "", "tool visible and thought")
|
||||
assertMessage(11, "assistant", "", "tool visible and thought")
|
||||
assertVisibleToolCallMessage(t, resp.Messages[12], "exec")
|
||||
}
|
||||
|
||||
func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) {
|
||||
@@ -758,27 +759,20 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSu
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
Messages []sessionChatMessage `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Messages) != 2 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
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[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)
|
||||
}
|
||||
assertVisibleToolCallMessage(t, resp.Messages[1], "message")
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,25 +823,23 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
Messages []sessionChatMessage `json:"messages"`
|
||||
}
|
||||
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) != 4 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages))
|
||||
}
|
||||
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[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" {
|
||||
t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1])
|
||||
assertVisibleToolCallMessage(t, resp.Messages[1], "message")
|
||||
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[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" {
|
||||
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2])
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -904,8 +896,8 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("len(items) = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].MessageCount != 2 {
|
||||
t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount)
|
||||
if items[0].MessageCount != 3 {
|
||||
t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,25 +951,24 @@ func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
Messages []sessionChatMessage `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Messages) != 2 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" {
|
||||
t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
||||
t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1])
|
||||
if resp.Messages[1].Content != "Read the file before replying." {
|
||||
t.Fatalf("assistant content = %#v, want preserved assistant content", resp.Messages[1])
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, "Read the file before replying.") {
|
||||
t.Fatalf("tool summary message = %#v, want tool explanation", resp.Messages[1])
|
||||
toolCall := assertVisibleToolCallMessage(t, resp.Messages[2], "read_file")
|
||||
if toolCall.ExtraContent == nil ||
|
||||
toolCall.ExtraContent.ToolFeedbackExplanation != "Read the file before replying." {
|
||||
t.Fatalf("tool call = %#v, want explanation", toolCall)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,10 +1021,7 @@ func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
Messages []sessionChatMessage `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
@@ -1041,13 +1029,11 @@ func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
||||
t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1])
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" ||
|
||||
resp.Messages[2].Content != "I will summarize the findings after reading the file." {
|
||||
t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[2])
|
||||
if resp.Messages[1].Role != "assistant" ||
|
||||
resp.Messages[1].Content != "I will summarize the findings after reading the file." {
|
||||
t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[1])
|
||||
}
|
||||
assertVisibleToolCallMessage(t, resp.Messages[2], "read_file")
|
||||
}
|
||||
|
||||
func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) {
|
||||
@@ -1100,11 +1086,7 @@ func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSu
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Media []string `json:"media"`
|
||||
} `json:"messages"`
|
||||
Messages []sessionChatMessage `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
@@ -1112,23 +1094,16 @@ func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSu
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, "`view_image`") {
|
||||
t.Fatalf("tool summary message = %#v, want view_image summary", resp.Messages[1])
|
||||
if resp.Messages[1].Role != "assistant" {
|
||||
t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role)
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" {
|
||||
t.Fatalf("assistant message role = %q, want assistant", resp.Messages[2].Role)
|
||||
if resp.Messages[1].Content != "Reviewing the generated screenshot." {
|
||||
t.Fatalf("assistant content = %q, want preserved duplicated content with media", resp.Messages[1].Content)
|
||||
}
|
||||
if resp.Messages[2].Content != "Reviewing the generated screenshot." {
|
||||
t.Fatalf("assistant content = %q, want preserved duplicated content with media", resp.Messages[2].Content)
|
||||
}
|
||||
if len(resp.Messages[2].Media) != 1 || resp.Messages[2].Media[0] != "data:image/png;base64,abc123" {
|
||||
t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[2].Media)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if len(resp.Messages[1].Media) != 1 || resp.Messages[1].Media[0] != "data:image/png;base64,abc123" {
|
||||
t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[1].Media)
|
||||
}
|
||||
assertVisibleToolCallMessage(t, resp.Messages[2], "view_image")
|
||||
}
|
||||
|
||||
func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) {
|
||||
@@ -1198,21 +1173,19 @@ func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplic
|
||||
if len(resp.Messages) != 3 {
|
||||
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
||||
t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1])
|
||||
if resp.Messages[1].Role != "assistant" {
|
||||
t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role)
|
||||
}
|
||||
if resp.Messages[2].Role != "assistant" {
|
||||
t.Fatalf("assistant message role = %q, want assistant", resp.Messages[2].Role)
|
||||
if resp.Messages[1].Content != "Reviewing the generated report." {
|
||||
t.Fatalf("assistant content = %q, want preserved duplicated content", resp.Messages[1].Content)
|
||||
}
|
||||
if resp.Messages[2].Content != "Reviewing the generated report." {
|
||||
t.Fatalf("assistant content = %q, want preserved duplicated content", resp.Messages[2].Content)
|
||||
if len(resp.Messages[1].Attachments) != 1 {
|
||||
t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[1].Attachments))
|
||||
}
|
||||
if len(resp.Messages[2].Attachments) != 1 {
|
||||
t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[2].Attachments))
|
||||
}
|
||||
if resp.Messages[2].Attachments[0].URL != "https://example.com/report.txt" {
|
||||
t.Fatalf("attachment url = %q, want report URL", resp.Messages[2].Attachments[0].URL)
|
||||
if resp.Messages[1].Attachments[0].URL != "https://example.com/report.txt" {
|
||||
t.Fatalf("attachment url = %q, want report URL", resp.Messages[1].Attachments[0].URL)
|
||||
}
|
||||
assertVisibleToolCallMessage(t, resp.Messages[2], "read_file")
|
||||
}
|
||||
|
||||
func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) {
|
||||
@@ -1273,10 +1246,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
Messages []sessionChatMessage `json:"messages"`
|
||||
}
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if err != nil {
|
||||
@@ -1287,17 +1257,15 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
|
||||
}
|
||||
|
||||
wantPreview := utils.Truncate(explanation, 20)
|
||||
if !strings.Contains(resp.Messages[1].Content, wantPreview) {
|
||||
t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview)
|
||||
}
|
||||
wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
|
||||
Function: &providers.FunctionCall{Arguments: argsJSON},
|
||||
}, 20)
|
||||
if !strings.Contains(resp.Messages[1].Content, wantArgsPreview) {
|
||||
t.Fatalf("tool summary = %q, want args preview %q", resp.Messages[1].Content, wantArgsPreview)
|
||||
toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
|
||||
if toolCall.ExtraContent == nil || toolCall.ExtraContent.ToolFeedbackExplanation != wantPreview {
|
||||
t.Fatalf("tool call = %#v, want preview %q", toolCall, wantPreview)
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
||||
t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content)
|
||||
if toolCall.Function == nil || toolCall.Function.Arguments != wantArgsPreview {
|
||||
t.Fatalf("tool call = %#v, want args preview %q", toolCall, wantArgsPreview)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1357,10 +1325,7 @@ func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
Messages []sessionChatMessage `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
@@ -1372,11 +1337,9 @@ func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t
|
||||
wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
|
||||
Function: &providers.FunctionCall{Arguments: argsJSON},
|
||||
}, 20)
|
||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
||||
t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content)
|
||||
}
|
||||
if !strings.Contains(resp.Messages[1].Content, wantPreview) {
|
||||
t.Fatalf("tool summary = %q, want legacy args preview %q", resp.Messages[1].Content, wantPreview)
|
||||
toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file")
|
||||
if toolCall.Function == nil || toolCall.Function.Arguments != wantPreview {
|
||||
t.Fatalf("tool call = %#v, want legacy args preview %q", toolCall, wantPreview)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface SessionDetail {
|
||||
messages: {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
kind?: "normal" | "thought"
|
||||
kind?: "normal" | "thought" | "tool_calls"
|
||||
media?: string[]
|
||||
attachments?: {
|
||||
type?: "image" | "audio" | "video" | "file"
|
||||
@@ -22,6 +22,17 @@ export interface SessionDetail {
|
||||
filename?: string
|
||||
content_type?: string
|
||||
}[]
|
||||
tool_calls?: {
|
||||
id?: string
|
||||
type?: string
|
||||
function?: {
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
extra_content?: {
|
||||
tool_feedback_explanation?: string
|
||||
}
|
||||
}[]
|
||||
}[]
|
||||
summary: string
|
||||
created: string
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -17,24 +18,34 @@ import remarkGfm from "remark-gfm"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type ChatAttachment } from "@/store/chat"
|
||||
import {
|
||||
type AssistantMessageKind,
|
||||
type ChatAttachment,
|
||||
type ChatToolCall,
|
||||
} from "@/store/chat"
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string
|
||||
attachments?: ChatAttachment[]
|
||||
isThought?: boolean
|
||||
kind?: AssistantMessageKind
|
||||
toolCalls?: ChatToolCall[]
|
||||
timestamp?: string | number
|
||||
}
|
||||
|
||||
export function AssistantMessage({
|
||||
content,
|
||||
attachments = [],
|
||||
isThought = false,
|
||||
kind = "normal",
|
||||
toolCalls = [],
|
||||
timestamp = "",
|
||||
}: AssistantMessageProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const isThought = kind === "thought"
|
||||
const isToolCalls = kind === "tool_calls"
|
||||
const isCollapsedBlock = isThought || isToolCalls
|
||||
const hasText = content.trim().length > 0
|
||||
const hasToolCalls = toolCalls.length > 0
|
||||
const imageAttachments = attachments.filter(
|
||||
(attachment) => attachment.type === "image",
|
||||
)
|
||||
@@ -52,9 +63,13 @@ export function AssistantMessage({
|
||||
})
|
||||
}
|
||||
|
||||
const collapsedLabel = isThought
|
||||
? t("chat.reasoningLabel")
|
||||
: t("chat.toolCallsLabel")
|
||||
|
||||
return (
|
||||
<div className="group flex w-full flex-col gap-1.5">
|
||||
{!isThought && (
|
||||
{!isCollapsedBlock && (
|
||||
<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>
|
||||
@@ -68,23 +83,27 @@ export function AssistantMessage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(hasText || isThought) && (
|
||||
{(hasText || isCollapsedBlock || hasToolCalls) && (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border",
|
||||
isThought
|
||||
isCollapsedBlock
|
||||
? "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",
|
||||
)}
|
||||
>
|
||||
{isThought && (
|
||||
{isCollapsedBlock && (
|
||||
<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)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<IconBrain className="size-3.5" />
|
||||
<span>{t("chat.reasoningLabel")}</span>
|
||||
{isThought ? (
|
||||
<IconBrain className="size-3.5" />
|
||||
) : (
|
||||
<IconTool className="size-3.5" />
|
||||
)}
|
||||
<span>{collapsedLabel}</span>
|
||||
</div>
|
||||
<IconChevronDown
|
||||
className={cn(
|
||||
@@ -94,7 +113,77 @@ export function AssistantMessage({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(!isThought || isExpanded) && hasText && (
|
||||
{(!isCollapsedBlock || isExpanded) && isToolCalls && hasToolCalls && (
|
||||
<div className="space-y-3 px-3 pt-0 pb-3">
|
||||
{toolCalls.map((toolCall, index) => {
|
||||
const explanation =
|
||||
toolCall.extraContent?.toolFeedbackExplanation?.trim() ?? ""
|
||||
const toolName = toolCall.function?.name?.trim() ?? ""
|
||||
const toolArguments = toolCall.function?.arguments?.trim() ?? ""
|
||||
const hasFunctionSummary = toolName || toolArguments
|
||||
|
||||
if (!explanation && !hasFunctionSummary) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toolCall.id ?? `${toolName}-${index}`}
|
||||
className={cn(
|
||||
"space-y-3",
|
||||
index > 0 && "border-border/20 border-t pt-3",
|
||||
)}
|
||||
>
|
||||
{explanation && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-muted-foreground/55 text-[11px] font-medium tracking-wide uppercase">
|
||||
{t("chat.toolCallExplanationLabel")}
|
||||
</div>
|
||||
<div className="prose dark:prose-invert prose-p:my-1.5 prose-p:whitespace-pre-wrap max-w-none text-[13px] leading-relaxed [overflow-wrap:anywhere] break-words opacity-75">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
rehypeSanitize,
|
||||
rehypeHighlight,
|
||||
]}
|
||||
>
|
||||
{explanation}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFunctionSummary && (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1.5",
|
||||
explanation && "border-border/20 border-t pt-3",
|
||||
)}
|
||||
>
|
||||
<div className="text-muted-foreground/55 text-[11px] font-medium tracking-wide uppercase">
|
||||
{t("chat.toolCallFunctionLabel")}
|
||||
</div>
|
||||
<div className="bg-background/55 border-border/25 space-y-2 rounded-lg border px-3 py-2.5">
|
||||
{toolName && (
|
||||
<div className="text-foreground/75 font-mono text-[12px] font-semibold">
|
||||
{toolName}
|
||||
</div>
|
||||
)}
|
||||
{toolArguments && (
|
||||
<pre className="text-muted-foreground/75 overflow-x-auto font-mono text-[12px] leading-relaxed break-words whitespace-pre-wrap">
|
||||
{toolArguments}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(!isCollapsedBlock || isExpanded) && !isToolCalls && hasText && (
|
||||
<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",
|
||||
@@ -112,7 +201,7 @@ export function AssistantMessage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isThought && hasText && (
|
||||
{!isCollapsedBlock && hasText && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -139,7 +228,7 @@ export function AssistantMessage({
|
||||
href={attachment.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group/img relative overflow-hidden rounded-xl border border-border/50 bg-muted/30 shadow-sm transition-colors hover:border-border/80"
|
||||
className="group/img border-border/50 bg-muted/30 hover:border-border/80 relative overflow-hidden rounded-xl border shadow-sm transition-colors"
|
||||
>
|
||||
<img
|
||||
src={attachment.url}
|
||||
@@ -159,20 +248,21 @@ export function AssistantMessage({
|
||||
key={`${attachment.url}-${index}`}
|
||||
href={attachment.url}
|
||||
download={attachment.filename}
|
||||
className="group/file flex w-fit min-w-[220px] max-w-sm items-center gap-3.5 rounded-xl border border-border/60 bg-card px-4 py-3 transition-all duration-300 hover:-translate-y-0.5 hover:border-violet-500/30 hover:shadow-sm dark:hover:border-violet-500/40"
|
||||
className="group/file border-border/60 bg-card flex w-fit max-w-sm min-w-[220px] items-center gap-3.5 rounded-xl border px-4 py-3 transition-all duration-300 hover:-translate-y-0.5 hover:border-violet-500/30 hover:shadow-sm dark:hover:border-violet-500/40"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-violet-400 ring-1 ring-violet-500/10 dark:bg-violet-500/10 dark:text-violet-400 dark:ring-violet-500/30">
|
||||
<IconFileText className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col pr-1">
|
||||
<span className="truncate text-[14px] font-medium leading-tight text-foreground/90 transition-colors group-hover/file:text-violet-600 dark:group-hover/file:text-violet-400">
|
||||
<span className="text-foreground/90 truncate text-[14px] leading-tight font-medium transition-colors group-hover/file:text-violet-600 dark:group-hover/file:text-violet-400">
|
||||
{attachment.filename || "Download file"}
|
||||
</span>
|
||||
<span className="mt-1 text-[12px] font-medium text-muted-foreground/70">
|
||||
{attachment.filename?.split(".").pop()?.toUpperCase() || "FILE"}
|
||||
<span className="text-muted-foreground/70 mt-1 text-[12px] font-medium">
|
||||
{attachment.filename?.split(".").pop()?.toUpperCase() ||
|
||||
"FILE"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60 text-muted-foreground/50 transition-all duration-300 group-hover/file:bg-violet-400 group-hover/file:text-white group-hover/file:shadow-sm dark:bg-muted/20 dark:group-hover/file:bg-violet-400">
|
||||
<div className="bg-muted/60 text-muted-foreground/50 dark:bg-muted/20 flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-all duration-300 group-hover/file:bg-violet-400 group-hover/file:text-white group-hover/file:shadow-sm dark:group-hover/file:bg-violet-400">
|
||||
<IconDownload className="h-4 w-4 transition-transform duration-300 group-hover/file:-translate-y-[1px]" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -269,7 +269,7 @@ export function ChatPage() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="hidden items-center gap-2 rounded-lg border border-border/60 px-3 py-1.5 sm:flex">
|
||||
<div className="border-border/60 hidden items-center gap-2 rounded-lg border px-3 py-1.5 sm:flex">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("chat.showThoughts")}
|
||||
</span>
|
||||
@@ -333,7 +333,8 @@ export function ChatPage() {
|
||||
<AssistantMessage
|
||||
content={msg.content}
|
||||
attachments={msg.attachments}
|
||||
isThought={msg.kind === "thought"}
|
||||
kind={msg.kind}
|
||||
toolCalls={msg.toolCalls}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { AssistantMessageKind, ChatMessage } from "../../store/chat.ts"
|
||||
import { parseToolCallsFromContent, parseToolCallsValue } from "./tool-calls.ts"
|
||||
|
||||
type AssistantToolCalls = ChatMessage["toolCalls"]
|
||||
type ExistingAssistantMessageState = Pick<ChatMessage, "kind" | "toolCalls">
|
||||
|
||||
export interface AssistantMessageCreateState {
|
||||
content: string
|
||||
kind: AssistantMessageKind
|
||||
toolCalls?: AssistantToolCalls
|
||||
}
|
||||
|
||||
export interface AssistantMessageUpdateState {
|
||||
content: string
|
||||
kind?: AssistantMessageKind
|
||||
toolCalls?: AssistantToolCalls
|
||||
}
|
||||
|
||||
function parseAssistantMessageKind(
|
||||
payload: Record<string, unknown>,
|
||||
toolCalls?: AssistantToolCalls,
|
||||
): AssistantMessageKind {
|
||||
if (payload.thought === true) {
|
||||
return "thought"
|
||||
}
|
||||
if (payload.kind === "tool_calls" || toolCalls) {
|
||||
return "tool_calls"
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
|
||||
function hasExplicitAssistantKindPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): boolean {
|
||||
return (
|
||||
typeof payload.thought === "boolean" ||
|
||||
payload.kind === "tool_calls" ||
|
||||
payload.tool_calls !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function parseAssistantToolCalls(
|
||||
payload: Record<string, unknown>,
|
||||
content: string,
|
||||
): AssistantToolCalls {
|
||||
return (
|
||||
parseToolCallsValue(payload.tool_calls) ??
|
||||
parseToolCallsFromContent(content)
|
||||
)
|
||||
}
|
||||
|
||||
export function parseAssistantMessageCreateState(
|
||||
payload: Record<string, unknown>,
|
||||
): AssistantMessageCreateState {
|
||||
const content = typeof payload.content === "string" ? payload.content : ""
|
||||
const toolCalls = parseAssistantToolCalls(payload, content)
|
||||
|
||||
return {
|
||||
content,
|
||||
kind: parseAssistantMessageKind(payload, toolCalls),
|
||||
toolCalls,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAssistantMessageUpdateState(
|
||||
payload: Record<string, unknown>,
|
||||
existing?: ExistingAssistantMessageState,
|
||||
): AssistantMessageUpdateState {
|
||||
const content = typeof payload.content === "string" ? payload.content : ""
|
||||
const toolCalls = parseAssistantToolCalls(payload, content)
|
||||
|
||||
if (hasExplicitAssistantKindPayload(payload)) {
|
||||
const kind = parseAssistantMessageKind(payload, toolCalls)
|
||||
return {
|
||||
content,
|
||||
kind,
|
||||
toolCalls: kind === "tool_calls" ? toolCalls : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCalls) {
|
||||
return {
|
||||
content,
|
||||
kind: "tool_calls",
|
||||
toolCalls,
|
||||
}
|
||||
}
|
||||
|
||||
if (existing?.kind === "thought" || existing?.kind === "tool_calls") {
|
||||
return {
|
||||
content,
|
||||
kind: "normal",
|
||||
toolCalls: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (existing?.toolCalls) {
|
||||
return {
|
||||
content,
|
||||
toolCalls: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return { content }
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { getSessionHistory } from "@/api/sessions"
|
||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||
import {
|
||||
parseToolCallsValue,
|
||||
toolCallsSignature,
|
||||
} from "@/features/chat/tool-calls"
|
||||
import type { ChatAttachment, ChatMessage } from "@/store/chat"
|
||||
|
||||
function toChatAttachments({
|
||||
@@ -45,8 +49,11 @@ export async function loadSessionMessages(
|
||||
id: `hist-${index}-${Date.now()}`,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
kind:
|
||||
message.role === "assistant" ? (message.kind ?? "normal") : undefined,
|
||||
kind: message.role === "assistant" ? (message.kind ?? "normal") : undefined,
|
||||
toolCalls:
|
||||
message.role === "assistant"
|
||||
? parseToolCallsValue(message.tool_calls)
|
||||
: undefined,
|
||||
attachments: toChatAttachments({
|
||||
media: message.media,
|
||||
attachments: message.attachments,
|
||||
@@ -79,7 +86,9 @@ function messageSignature(message: ChatMessage): string {
|
||||
|
||||
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
|
||||
message.timestamp,
|
||||
)}\u0000${message.kind ?? ""}\u0000${attachmentSignature}`
|
||||
)}\u0000${message.kind ?? ""}\u0000${attachmentSignature}\u0000${toolCallsSignature(
|
||||
message.toolCalls,
|
||||
)}`
|
||||
}
|
||||
|
||||
function comparableTimestamp(timestamp: number | string): number {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
parseAssistantMessageCreateState,
|
||||
parseAssistantMessageUpdateState,
|
||||
} from "@/features/chat/assistant-message-state"
|
||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||
import {
|
||||
type AssistantMessageKind,
|
||||
type ChatAttachment,
|
||||
type ChatMessage,
|
||||
type ContextUsage,
|
||||
updateChatStore,
|
||||
} from "@/store/chat"
|
||||
@@ -17,16 +19,6 @@ export interface PicoMessage {
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function parseAssistantMessageKind(
|
||||
payload: Record<string, unknown>,
|
||||
): AssistantMessageKind {
|
||||
return payload.thought === true ? "thought" : "normal"
|
||||
}
|
||||
|
||||
function hasAssistantKindPayload(payload: Record<string, unknown>): boolean {
|
||||
return typeof payload.thought === "boolean"
|
||||
}
|
||||
|
||||
function parseAttachments(
|
||||
payload: Record<string, unknown>,
|
||||
): ChatAttachment[] | undefined {
|
||||
@@ -91,35 +83,6 @@ 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
|
||||
}
|
||||
|
||||
export function handlePicoMessage(
|
||||
message: PicoMessage,
|
||||
expectedSessionId: string,
|
||||
@@ -133,9 +96,9 @@ export function handlePicoMessage(
|
||||
switch (message.type) {
|
||||
case "message.create":
|
||||
case "media.create": {
|
||||
const content = (payload.content as string) || ""
|
||||
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
||||
const kind = parseAssistantMessageKind(payload)
|
||||
const { content, kind, toolCalls } =
|
||||
parseAssistantMessageCreateState(payload)
|
||||
const attachments = parseAttachments(payload)
|
||||
const contextUsage = parseContextUsage(payload)
|
||||
const timestamp =
|
||||
@@ -152,6 +115,7 @@ export function handlePicoMessage(
|
||||
role: "assistant",
|
||||
content,
|
||||
kind,
|
||||
...(toolCalls ? { toolCalls } : {}),
|
||||
attachments,
|
||||
timestamp,
|
||||
},
|
||||
@@ -163,10 +127,7 @@ export function handlePicoMessage(
|
||||
}
|
||||
|
||||
case "message.update": {
|
||||
const content = (payload.content as string) || ""
|
||||
const messageId = payload.message_id as string
|
||||
const hasKind = hasAssistantKindPayload(payload)
|
||||
const kind = parseAssistantMessageKind(payload)
|
||||
const attachments = parseAttachments(payload)
|
||||
const contextUsage = parseContextUsage(payload)
|
||||
const timestamp =
|
||||
@@ -186,11 +147,14 @@ export function handlePicoMessage(
|
||||
return msg
|
||||
}
|
||||
found = true
|
||||
const { content, kind, toolCalls } =
|
||||
parseAssistantMessageUpdateState(payload, msg)
|
||||
return {
|
||||
...msg,
|
||||
id: messageId,
|
||||
content,
|
||||
...(hasKind ? { kind } : {}),
|
||||
kind,
|
||||
toolCalls,
|
||||
...(attachments ? { attachments } : {}),
|
||||
}
|
||||
})
|
||||
@@ -198,20 +162,8 @@ export function handlePicoMessage(
|
||||
return messages
|
||||
}
|
||||
|
||||
const fallbackIndex = findToolFeedbackMessageIndex(messages)
|
||||
if (fallbackIndex >= 0) {
|
||||
return messages.map((msg, index) =>
|
||||
index === fallbackIndex
|
||||
? {
|
||||
...msg,
|
||||
id: messageId,
|
||||
content,
|
||||
...(hasKind ? { kind } : {}),
|
||||
...(attachments ? { attachments } : {}),
|
||||
}
|
||||
: msg,
|
||||
)
|
||||
}
|
||||
const { content, kind, toolCalls } =
|
||||
parseAssistantMessageUpdateState(payload)
|
||||
|
||||
return [
|
||||
...messages,
|
||||
@@ -219,7 +171,8 @@ export function handlePicoMessage(
|
||||
id: messageId,
|
||||
role: "assistant" as const,
|
||||
content,
|
||||
...(hasKind ? { kind } : {}),
|
||||
kind,
|
||||
toolCalls,
|
||||
...(attachments ? { attachments } : {}),
|
||||
timestamp,
|
||||
},
|
||||
@@ -237,19 +190,7 @@ export function handlePicoMessage(
|
||||
}
|
||||
|
||||
updateChatStore((prev) => ({
|
||||
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)
|
||||
})(),
|
||||
messages: prev.messages.filter((msg) => msg.id !== messageId),
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ChatToolCall } from "@/store/chat"
|
||||
|
||||
function parseLegacyToolFeedbackContent(
|
||||
content: string,
|
||||
): ChatToolCall[] | undefined {
|
||||
const trimmed = content.trim()
|
||||
const match = /^🔧\s+`([^`]+)`(?:\n([\s\S]*))?$/.exec(trimmed)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const toolName = match[1]?.trim() ?? ""
|
||||
const body = match[2]?.trim() ?? ""
|
||||
const codeFence = /```(?:json)?\n([\s\S]*?)\n```/m.exec(body)
|
||||
const argumentsText = codeFence?.[1]?.trim() ?? ""
|
||||
const explanation = body.replace(/```(?:json)?\n[\s\S]*?\n```/gm, "").trim()
|
||||
|
||||
return [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
...(argumentsText ? { arguments: argumentsText } : {}),
|
||||
},
|
||||
...(explanation
|
||||
? {
|
||||
extraContent: {
|
||||
toolFeedbackExplanation: explanation,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function parseToolCallsValue(raw: unknown): ChatToolCall[] | undefined {
|
||||
if (!Array.isArray(raw)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const toolCalls: ChatToolCall[] = []
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue
|
||||
}
|
||||
|
||||
const toolCall = item as Record<string, unknown>
|
||||
const rawFunction =
|
||||
toolCall.function && typeof toolCall.function === "object"
|
||||
? (toolCall.function as Record<string, unknown>)
|
||||
: null
|
||||
const rawExtraContent =
|
||||
toolCall.extra_content && typeof toolCall.extra_content === "object"
|
||||
? (toolCall.extra_content as Record<string, unknown>)
|
||||
: null
|
||||
|
||||
const nextToolCall: ChatToolCall = {
|
||||
...(typeof toolCall.id === "string" ? { id: toolCall.id } : {}),
|
||||
...(typeof toolCall.type === "string" ? { type: toolCall.type } : {}),
|
||||
}
|
||||
|
||||
if (rawFunction) {
|
||||
const name =
|
||||
typeof rawFunction.name === "string" ? rawFunction.name : undefined
|
||||
const argumentsText =
|
||||
typeof rawFunction.arguments === "string"
|
||||
? rawFunction.arguments
|
||||
: undefined
|
||||
|
||||
if (name || argumentsText) {
|
||||
nextToolCall.function = {
|
||||
...(name ? { name } : {}),
|
||||
...(argumentsText ? { arguments: argumentsText } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rawExtraContent) {
|
||||
const toolFeedbackExplanation =
|
||||
typeof rawExtraContent.tool_feedback_explanation === "string"
|
||||
? rawExtraContent.tool_feedback_explanation
|
||||
: undefined
|
||||
|
||||
if (toolFeedbackExplanation) {
|
||||
nextToolCall.extraContent = {
|
||||
toolFeedbackExplanation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
nextToolCall.id ||
|
||||
nextToolCall.type ||
|
||||
nextToolCall.function ||
|
||||
nextToolCall.extraContent
|
||||
) {
|
||||
toolCalls.push(nextToolCall)
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls.length > 0 ? toolCalls : undefined
|
||||
}
|
||||
|
||||
export function parseToolCallsFromContent(
|
||||
content: string,
|
||||
): ChatToolCall[] | undefined {
|
||||
return parseLegacyToolFeedbackContent(content)
|
||||
}
|
||||
|
||||
export function toolCallsSignature(toolCalls?: ChatToolCall[]): string {
|
||||
return (toolCalls ?? [])
|
||||
.map((toolCall) =>
|
||||
[
|
||||
toolCall.id ?? "",
|
||||
toolCall.type ?? "",
|
||||
toolCall.function?.name ?? "",
|
||||
toolCall.function?.arguments ?? "",
|
||||
toolCall.extraContent?.toolFeedbackExplanation ?? "",
|
||||
].join("\u0001"),
|
||||
)
|
||||
.join("\u0002")
|
||||
}
|
||||
@@ -60,6 +60,9 @@
|
||||
"step4": "Almost there..."
|
||||
},
|
||||
"reasoningLabel": "Reasoning",
|
||||
"toolCallsLabel": "Tool calls",
|
||||
"toolCallExplanationLabel": "Call note",
|
||||
"toolCallFunctionLabel": "Call summary",
|
||||
"showThoughts": "Show reasoning",
|
||||
"toolLabel": "Tool",
|
||||
"history": "History",
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"step4": "马上就好..."
|
||||
},
|
||||
"reasoningLabel": "思考",
|
||||
"toolCallsLabel": "工具调用",
|
||||
"toolCallExplanationLabel": "调用提示",
|
||||
"toolCallFunctionLabel": "调用摘要",
|
||||
"showThoughts": "展示思考过程",
|
||||
"toolLabel": "工具",
|
||||
"history": "历史记录",
|
||||
|
||||
@@ -13,7 +13,23 @@ export interface ChatAttachment {
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
export type AssistantMessageKind = "normal" | "thought"
|
||||
export interface ChatToolCallFunction {
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
|
||||
export interface ChatToolCallExtraContent {
|
||||
toolFeedbackExplanation?: string
|
||||
}
|
||||
|
||||
export interface ChatToolCall {
|
||||
id?: string
|
||||
type?: string
|
||||
function?: ChatToolCallFunction
|
||||
extraContent?: ChatToolCallExtraContent
|
||||
}
|
||||
|
||||
export type AssistantMessageKind = "normal" | "thought" | "tool_calls"
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
@@ -22,6 +38,7 @@ export interface ChatMessage {
|
||||
timestamp: number | string
|
||||
kind?: AssistantMessageKind
|
||||
attachments?: ChatAttachment[]
|
||||
toolCalls?: ChatToolCall[]
|
||||
}
|
||||
|
||||
export interface ContextUsage {
|
||||
|
||||
Reference in New Issue
Block a user