add structed ui

This commit is contained in:
tra4less 2026-04-21 21:03:49 +08:00
parent 71c877a67f
commit f46fa17178
38 changed files with 5622 additions and 493 deletions

View file

@ -111,6 +111,7 @@ const (
sessionKeyAgentPrefix = "agent:" sessionKeyAgentPrefix = "agent:"
pendingTurnPrefix = "pending-" pendingTurnPrefix = "pending-"
metadataKeyMessageKind = "message_kind" metadataKeyMessageKind = "message_kind"
metadataKeyStructuredData = "structured_data"
messageKindThought = "thought" messageKindThought = "thought"
metadataKeyAccountID = "account_id" metadataKeyAccountID = "account_id"
metadataKeyGuildID = "guild_id" metadataKeyGuildID = "guild_id"
@ -520,13 +521,15 @@ func (al *AgentLoop) runAgentLoop(
opts.Dispatch.SessionKey, opts.Dispatch.SessionKey,
opts.Dispatch.SessionScope, opts.Dispatch.SessionScope,
) )
outboundCtx := outboundContextFromInbound(
opts.Dispatch.InboundContext,
opts.Dispatch.Channel(),
opts.Dispatch.ChatID(),
opts.Dispatch.ReplyToMessageID(),
)
attachPlanTodoFallback(&outboundCtx, result.finalContent)
al.bus.PublishOutbound(ctx, bus.OutboundMessage{ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Context: outboundContextFromInbound( Context: outboundCtx,
opts.Dispatch.InboundContext,
opts.Dispatch.Channel(),
opts.Dispatch.ChatID(),
opts.Dispatch.ReplyToMessageID(),
),
AgentID: agentID, AgentID: agentID,
SessionKey: sessionKey, SessionKey: sessionKey,
Scope: scope, Scope: scope,

View file

@ -4,6 +4,7 @@ package agent
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"time" "time"
@ -157,14 +158,22 @@ func registerSharedTools(
// Message tool // Message tool
if cfg.Tools.IsToolEnabled("message") { if cfg.Tools.IsToolEnabled("message") {
messageTool := tools.NewMessageTool() sendStructuredMessage := func(
messageTool.SetSendCallback(func(
ctx context.Context, ctx context.Context,
channel, chatID, content, replyToMessageID string, channel, chatID, content, replyToMessageID string,
structured any,
) error { ) error {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID) outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID)
if structured != nil {
if encoded, err := json.Marshal(structured); err == nil {
if outboundCtx.Raw == nil {
outboundCtx.Raw = make(map[string]string, 1)
}
outboundCtx.Raw[metadataKeyStructuredData] = string(encoded)
}
}
outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata( outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata(
tools.ToolAgentID(ctx), tools.ToolAgentID(ctx),
tools.ToolSessionKey(ctx), tools.ToolSessionKey(ctx),
@ -178,7 +187,10 @@ func registerSharedTools(
Content: content, Content: content,
ReplyToMessageID: replyToMessageID, ReplyToMessageID: replyToMessageID,
}) })
}) }
messageTool := tools.NewMessageTool()
messageTool.SetStructuredSendCallback(sendStructuredMessage)
agent.Tools.Register(messageTool) agent.Tools.Register(messageTool)
} }
if cfg.Tools.IsToolEnabled("reaction") { if cfg.Tools.IsToolEnabled("reaction") {

View file

@ -8,8 +8,10 @@ import (
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels/pico"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
@ -149,12 +151,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey) scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey)
sessionKey := scopeKey sessionKey := scopeKey
// Reset message-tool state for this round so we don't skip publishing due to a previous round. // Reset message-like tool state for this round so we don't skip publishing due to a previous round.
if tool, ok := agent.Tools.Get("message"); ok { resetSentTrackingTools(agent, sessionKey)
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
resetter.ResetSentInRound(sessionKey)
}
}
logger.InfoCF("agent", "Routed message", logger.InfoCF("agent", "Routed message",
map[string]any{ map[string]any{
@ -182,7 +180,10 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
DefaultResponse: defaultResponse, DefaultResponse: defaultResponse,
EnableSummary: true, EnableSummary: true,
SendResponse: false, SendResponse: false,
AllowInterimPicoPublish: true, AllowInterimPicoPublish: false,
}
if steering := modeSteeringMessages(msg.Context.Raw); len(steering) > 0 {
opts.InitialSteeringMessages = append(opts.InitialSteeringMessages, steering...)
} }
// context-dependent commands check their own Runtime fields and report // context-dependent commands check their own Runtime fields and report
@ -203,6 +204,30 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.runAgentLoop(ctx, agent, opts) return al.runAgentLoop(ctx, agent, opts)
} }
func modeSteeringMessages(raw map[string]string) []providers.Message {
if len(raw) == 0 {
return nil
}
mode := strings.ToLower(strings.TrimSpace(raw[pico.PayloadKeyMode]))
if mode == "" {
return nil
}
var instruction string
switch mode {
case pico.ChatModeAsk:
instruction = "Chat mode is ASK. Prioritize answering the user's question directly and clearly. Prefer explanation, diagnosis, and guidance over taking actions. Avoid tool calls unless they are required to answer accurately or the user explicitly asks you to inspect something. Do not make code changes, execute tasks, or act on behalf of the user unless they clearly request that."
case pico.ChatModePlan:
instruction = "Chat mode is PLAN. Produce a concrete plan, design, or approach before execution. Do not make code changes, do not run tools that change state, and do not carry out the plan. Limit tool use to minimal read-only inspection only when necessary to produce a better plan. Emphasize steps, tradeoffs, assumptions, and risks. When the plan has discrete tasks, you must call the message tool exactly once with a structured todo payload so the Web UI can render a task list. Do not answer with plain markdown bullets or prose only when a task list is possible. Use structured payload shape {type:'todo', title:string, content?:string, items:[{title:string, status:'not-started'|'in-progress'|'completed', detail?:string}]}. Keep at most one item in-progress. Include a short plain-text content fallback in the same message tool call. If there are no discrete tasks, then a normal text answer is acceptable."
case pico.ChatModeAgent:
instruction = "Chat mode is AGENT. You may inspect the workspace, call tools, make code changes, and complete the task end-to-end when appropriate. Prefer execution over discussion when the user is asking for work to be done."
default:
return nil
}
return []providers.Message{{Role: "user", Content: instruction}}
}
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
registry := al.GetRegistry() registry := al.GetRegistry()
inboundCtx := normalizedInboundContext(msg) inboundCtx := normalizedInboundContext(msg)

View file

@ -4,13 +4,14 @@ package agent
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/tools"
) )
func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool { func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool {
@ -37,6 +38,14 @@ func (al *AgentLoop) publishResponseOrError(
} }
func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) { func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) {
al.publishResponseWithContextIfNeeded(ctx, nil, channel, chatID, sessionKey, response)
}
func (al *AgentLoop) publishResponseWithContextIfNeeded(
ctx context.Context,
inboundCtx *bus.InboundContext,
channel, chatID, sessionKey, response string,
) {
if response == "" { if response == "" {
return return
} }
@ -44,11 +53,7 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
alreadySentToSameChat := false alreadySentToSameChat := false
defaultAgent := al.GetRegistry().GetDefaultAgent() defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent != nil { if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok { alreadySentToSameChat = anySentTrackingToolSentTo(defaultAgent, sessionKey, channel, chatID)
if mt, ok := tool.(*tools.MessageTool); ok {
alreadySentToSameChat = mt.HasSentTo(sessionKey, channel, chatID)
}
}
} }
if alreadySentToSameChat { if alreadySentToSameChat {
@ -60,8 +65,14 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
return return
} }
outboundCtx := bus.NewOutboundContext(channel, chatID, "")
if inboundCtx != nil {
outboundCtx = outboundContextFromInbound(inboundCtx, channel, chatID, "")
}
attachPlanTodoFallback(&outboundCtx, response)
msg := bus.OutboundMessage{ msg := bus.OutboundMessage{
Context: bus.NewOutboundContext(channel, chatID, ""), Context: outboundCtx,
Content: response, Content: response,
} }
if sessionKey != "" { if sessionKey != "" {
@ -70,9 +81,10 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
al.bus.PublishOutbound(ctx, msg) al.bus.PublishOutbound(ctx, msg)
logger.InfoCF("agent", "Published outbound response", logger.InfoCF("agent", "Published outbound response",
map[string]any{ map[string]any{
"channel": channel, "channel": channel,
"chat_id": chatID, "chat_id": chatID,
"content_len": len(response), "content_len": len(response),
"has_structured": strings.TrimSpace(outboundCtx.Raw[metadataKeyStructuredData]) != "",
}) })
} }
@ -123,6 +135,68 @@ func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent,
} }
} }
func (al *AgentLoop) publishPicoStructured(ctx context.Context, chatID, content string, structured any) {
if chatID == "" || structured == nil {
return
}
if ctx.Err() != nil {
return
}
rawStructured, err := json.Marshal(structured)
if err != nil {
logger.WarnCF("agent", "Failed to encode pico structured payload", map[string]any{
"channel": "pico",
"error": err.Error(),
})
return
}
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
defer pubCancel()
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
Context: bus.InboundContext{
Channel: "pico",
ChatID: chatID,
Raw: map[string]string{
metadataKeyStructuredData: string(rawStructured),
},
},
Content: content,
}); err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
errors.Is(err, bus.ErrBusClosed) {
logger.DebugCF("agent", "Pico structured publish skipped (timeout/cancel)", map[string]any{
"channel": "pico",
"error": err.Error(),
})
} else {
logger.WarnCF("agent", "Failed to publish pico structured payload", map[string]any{
"channel": "pico",
"error": err.Error(),
})
}
}
}
func (al *AgentLoop) publishPicoToolProgress(ctx context.Context, chatID, toolName, status, detail string) {
if toolName == "" || chatID == "" {
return
}
content := fmt.Sprintf("%s: %s", toolName, status)
if detail != "" {
content = fmt.Sprintf("%s\n%s", content, detail)
}
al.publishPicoStructured(ctx, chatID, content, map[string]any{
"type": "progress",
"kind": "agent/tool-exec",
"title": toolName,
"status": status,
"content": detail,
})
}
func (al *AgentLoop) handleReasoning( func (al *AgentLoop) handleReasoning(
ctx context.Context, ctx context.Context,
reasoningContent, channelName, channelID string, reasoningContent, channelName, channelID string,

View file

@ -10,15 +10,23 @@ import (
) )
func (al *AgentLoop) processMessageSync(ctx context.Context, msg bus.InboundMessage) { func (al *AgentLoop) processMessageSync(ctx context.Context, msg bus.InboundMessage) {
msg = bus.NormalizeInboundMessage(msg)
if al.channelManager != nil { if al.channelManager != nil {
defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
} }
response, err := al.processMessage(ctx, msg) response, err := al.processMessage(ctx, msg)
al.publishResponseOrError(ctx, msg.Channel, msg.ChatID, msg.SessionKey, response, err) if err != nil {
if !al.maybePublishError(ctx, msg.Channel, msg.ChatID, msg.SessionKey, err) {
return
}
return
}
al.publishResponseWithContextIfNeeded(ctx, &msg.Context, msg.Channel, msg.ChatID, msg.SessionKey, response)
} }
func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.InboundMessage) { func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.InboundMessage) {
initialMsg = bus.NormalizeInboundMessage(initialMsg)
// Process the initial message // Process the initial message
response, err := al.processMessage(ctx, initialMsg) response, err := al.processMessage(ctx, initialMsg)
if err != nil { if err != nil {
@ -77,7 +85,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
// Publish final response // Publish final response
if finalResponse != "" { if finalResponse != "" {
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) al.publishResponseWithContextIfNeeded(ctx, &initialMsg.Context, target.Channel, target.ChatID, target.SessionKey, finalResponse)
} }
} }

View file

@ -104,6 +104,56 @@ func (r *recordingProvider) GetDefaultModel() string {
return "mock-model" return "mock-model"
} }
func TestProcessMessage_IncludesChatModeSteering(t *testing.T) {
tmpDir := t.TempDir()
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = tmpDir
cfg.Agents.Defaults.ModelName = "test-model"
provider := &recordingProvider{}
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
_, err := al.processMessage(context.Background(), bus.InboundMessage{
Context: bus.InboundContext{
Channel: "pico",
ChatID: "pico:test-session",
ChatType: "direct",
SenderID: "pico-user",
Raw: map[string]string{
"mode": "plan",
},
},
Content: "帮我修一下这个功能",
})
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if len(provider.lastMessages) < 3 {
t.Fatalf("provider messages len = %d, want at least 3", len(provider.lastMessages))
}
var foundSteering bool
var foundOriginal bool
for _, message := range provider.lastMessages {
if message.Role == "user" && strings.Contains(message.Content, "Chat mode is PLAN") {
foundSteering = true
if !strings.Contains(message.Content, "structured todo payload") {
t.Fatalf("plan steering = %q, want structured todo guidance", message.Content)
}
if !strings.Contains(message.Content, "must call the message tool exactly once") {
t.Fatalf("plan steering = %q, want mandatory message tool guidance", message.Content)
}
}
if message.Role == "user" && message.Content == "帮我修一下这个功能" {
foundOriginal = true
}
}
if !foundSteering {
t.Fatalf("provider messages = %#v, want plan steering instruction present", provider.lastMessages)
}
if !foundOriginal {
t.Fatalf("provider messages = %#v, want original user message present", provider.lastMessages)
}
}
type modelRewriteHook struct { type modelRewriteHook struct {
model string model string
} }
@ -1634,6 +1684,36 @@ func (m *handledUserProvider) GetDefaultModel() string {
return "handled-user-model" return "handled-user-model"
} }
type toolCallPreferredProvider struct {
calls int
}
func (m *toolCallPreferredProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
m.calls++
if m.calls == 1 {
return &providers.LLMResponse{
Content: "Checking the workspace.",
ToolCalls: []providers.ToolCall{{
ID: "call_plain_user_result",
Type: "function",
Name: "plain_user_result_tool",
Arguments: map[string]any{},
}},
}, nil
}
return &providers.LLMResponse{Content: "Final answer from model after tool result."}, nil
}
func (m *toolCallPreferredProvider) GetDefaultModel() string {
return "tool-call-preferred-model"
}
type messageToolProvider struct { type messageToolProvider struct {
calls int calls int
} }
@ -1885,6 +1965,89 @@ func (m *handledUserTool) Execute(ctx context.Context, args map[string]any) *too
return tools.UserResult("Handled user output from tool.").WithResponseHandled() return tools.UserResult("Handled user output from tool.").WithResponseHandled()
} }
type plainUserResultTool struct{}
func (m *plainUserResultTool) Name() string { return "plain_user_result_tool" }
func (m *plainUserResultTool) Description() string {
return "Returns a raw user-facing summary without explicit direct delivery"
}
func (m *plainUserResultTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
func (m *plainUserResultTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
return tools.UserResult("Raw tool output that should stay inside the tool-result loop.")
}
func TestRunAgentLoop_PlainUserResultDoesNotPublishDirectlyWhenSendResponseEnabled(t *testing.T) {
tmpDir := t.TempDir()
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = tmpDir
cfg.Agents.Defaults.ModelName = "test-model"
cfg.Agents.Defaults.MaxTokens = 4096
cfg.Agents.Defaults.MaxToolIterations = 10
msgBus := bus.NewMessageBus()
provider := &toolCallPreferredProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}}
al.SetChannelManager(newStartedTestChannelManager(t, msgBus, nil, "telegram", telegramChannel))
al.RegisterTool(&plainUserResultTool{})
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
response, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{
Dispatch: DispatchRequest{
SessionKey: "session-plain-user-result",
UserMessage: "inspect and answer",
SessionScope: &session.SessionScope{
Version: session.ScopeVersionV1,
AgentID: defaultAgent.ID,
Channel: "telegram",
Dimensions: []string{"chat"},
Values: map[string]string{
"chat": "direct:chat1",
},
},
InboundContext: &bus.InboundContext{
Channel: "telegram",
ChatID: "chat1",
ChatType: "direct",
SenderID: "user1",
},
},
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: true,
})
if err != nil {
t.Fatalf("runAgentLoop() error = %v", err)
}
if response != "Final answer from model after tool result." {
t.Fatalf("response = %q, want final model answer", response)
}
deadline := time.Now().Add(2 * time.Second)
for len(telegramChannel.sentMessages) == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if len(telegramChannel.sentMessages) != 1 {
t.Fatalf("expected exactly 1 published final answer, got %+v", telegramChannel.sentMessages)
}
if telegramChannel.sentMessages[0].Content != "Final answer from model after tool result." {
t.Fatalf("expected only final model answer to be published, got %+v", telegramChannel.sentMessages[0])
}
if provider.calls != 2 {
t.Fatalf("expected 2 provider calls, got %d", provider.calls)
}
}
type handledMediaWithSteeringProvider struct { type handledMediaWithSteeringProvider struct {
calls int calls int
} }
@ -3554,6 +3717,45 @@ func TestProcessMessage_PicoPublishesReasoningAsThoughtMessage(t *testing.T) {
} }
} }
func TestPublishPicoToolProgress_PublishesStructuredPayload(t *testing.T) {
msgBus := bus.NewMessageBus()
al := NewAgentLoop(config.DefaultConfig(), msgBus, &recordingProvider{})
al.publishPicoToolProgress(context.Background(), "pico:test-session", "search_files", "running", "Tool execution started.")
select {
case outbound := <-msgBus.OutboundChan():
if outbound.Channel != "pico" || outbound.ChatID != "pico:test-session" {
t.Fatalf("outbound route = %s/%s, want pico/pico:test-session", outbound.Channel, outbound.ChatID)
}
rawStructured := outbound.Context.Raw[metadataKeyStructuredData]
if rawStructured == "" {
t.Fatal("expected structured_data metadata on pico progress message")
}
var structured struct {
Type string `json:"type"`
Kind string `json:"kind"`
Title string `json:"title"`
Status string `json:"status"`
Content string `json:"content"`
}
if err := json.Unmarshal([]byte(rawStructured), &structured); err != nil {
t.Fatalf("unmarshal structured_data: %v", err)
}
if structured.Type != "progress" || structured.Kind != "agent/tool-exec" {
t.Fatalf("structured = %#v, want progress/agent-tool-exec", structured)
}
if structured.Title != "search_files" || structured.Status != "running" {
t.Fatalf("structured = %#v, want title/status search_files/running", structured)
}
if structured.Content != "Tool execution started." {
t.Fatalf("structured content = %q, want %q", structured.Content, "Tool execution started.")
}
case <-time.After(3 * time.Second):
t.Fatal("expected pico progress outbound message")
}
}
func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) { func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt") heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt")
@ -3719,7 +3921,7 @@ func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.
} }
} }
func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t *testing.T) { func TestRun_PicoPublishesProgressDuringToolCallsWithoutFinalDuplicate(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
cfg := &config.Config{ cfg := &config.Config{
@ -3760,22 +3962,42 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
t.Fatalf("PublishInbound() error = %v", err) t.Fatalf("PublishInbound() error = %v", err)
} }
outputs := make([]string, 0, 2) type outboundSnapshot struct {
content string
structured string
}
outputs := make([]outboundSnapshot, 0, 4)
deadline := time.After(2 * time.Second) deadline := time.After(2 * time.Second)
for len(outputs) < 2 { for {
select { select {
case outbound := <-msgBus.OutboundChan(): case outbound := <-msgBus.OutboundChan():
outputs = append(outputs, outbound.Content) outputs = append(outputs, outboundSnapshot{
content: outbound.Content,
structured: outbound.Context.Raw["structured_data"],
})
if outbound.Content == "final model text" {
goto assertions
}
case <-deadline: case <-deadline:
t.Fatalf("timed out waiting for pico outputs, got %v", outputs) t.Fatalf("timed out waiting for pico outputs, got %v", outputs)
} }
} }
if outputs[0] != "intermediate model text" { assertions:
t.Fatalf("first outbound content = %q, want %q", outputs[0], "intermediate model text") if len(outputs) < 2 {
t.Fatalf("expected progress and final outputs, got %v", outputs)
} }
if outputs[1] != "final model text" { if outputs[0].content != "tool_limit_test_tool: running\nTool execution started." {
t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text") t.Fatalf("first outbound content = %q, want progress payload content", outputs[0].content)
}
if outputs[0].structured == "" {
t.Fatalf("expected first outbound to include structured progress payload, got %+v", outputs[0])
}
if strings.Contains(outputs[0].structured, "intermediate model text") {
t.Fatalf("unexpected interim assistant text in structured progress payload: %s", outputs[0].structured)
}
if outputs[len(outputs)-1].content != "final model text" {
t.Fatalf("last outbound content = %q, want %q", outputs[len(outputs)-1].content, "final model text")
} }
runCancel() runCancel()
@ -3788,12 +4010,14 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
t.Fatal("timed out waiting for Run() to exit") t.Fatal("timed out waiting for Run() to exit")
} }
select { finalCount := 0
case outbound := <-msgBus.OutboundChan(): for _, outbound := range outputs {
if outbound.Content == "final model text" { if outbound.content == "final model text" {
t.Fatalf("unexpected duplicate final pico output: %+v", outbound) finalCount++
} }
case <-time.After(200 * time.Millisecond): }
if finalCount != 1 {
t.Fatalf("expected exactly one final pico output, got %d from %+v", finalCount, outputs)
} }
} }
@ -3839,10 +4063,25 @@ func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) {
t.Fatalf("runAgentLoop() response = %q, want %q", response, "final model text") t.Fatalf("runAgentLoop() response = %q, want %q", response, "final model text")
} }
select { outputs := make([]bus.OutboundMessage, 0, 4)
case outbound := <-msgBus.OutboundChan(): collectUntil := time.After(300 * time.Millisecond)
t.Fatalf("unexpected outbound message when interim publish disabled: %+v", outbound) for {
case <-time.After(200 * time.Millisecond): select {
case outbound := <-msgBus.OutboundChan():
outputs = append(outputs, outbound)
case <-collectUntil:
goto verifyNoInterim
}
}
verifyNoInterim:
if len(outputs) == 0 {
t.Fatal("expected structured progress outbound even when interim publish is disabled")
}
for _, outbound := range outputs {
if outbound.Content == "intermediate model text" {
t.Fatalf("unexpected interim assistant text when disabled: %+v", outbound)
}
} }
} }

View file

@ -0,0 +1,40 @@
package agent
import "github.com/sipeed/picoclaw/pkg/tools"
func resetSentTrackingTools(agent *AgentInstance, sessionKey string) {
if agent == nil {
return
}
for _, name := range agent.Tools.List() {
tool, ok := agent.Tools.Get(name)
if !ok {
continue
}
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
resetter.ResetSentInRound(sessionKey)
}
}
}
func anySentTrackingToolSentTo(agent *AgentInstance, sessionKey, channel, chatID string) bool {
if agent == nil {
return false
}
for _, name := range agent.Tools.List() {
tool, ok := agent.Tools.Get(name)
if !ok {
continue
}
if tracker, ok := tool.(interface {
HasSentTo(sessionKey, channel, chatID string) bool
}); ok {
if tracker.HasSentTo(sessionKey, channel, chatID) {
return true
}
}
}
return false
}
var _ tools.Tool = (*tools.MessageTool)(nil)

View file

@ -0,0 +1,199 @@
package agent
import (
"encoding/json"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels/pico"
)
var (
planHeadingRe = regexp.MustCompile(`^\s*(#{1,6})\s+(.+?)\s*$`)
planCheckboxRe = regexp.MustCompile(`^\s*[-*+]\s*\[( |x|X)\]\s+(.+?)\s*$`)
planBulletRe = regexp.MustCompile(`^\s*[-*+]\s+(.+?)\s*$`)
planNumberRe = regexp.MustCompile(`^\s*\d+[\.)]\s+(.+?)\s*$`)
)
func attachPlanTodoFallback(outboundCtx *bus.InboundContext, response string) {
if outboundCtx == nil {
return
}
structured := parsePlanTodoFallback(outboundCtx, response)
if structured == nil {
return
}
rawStructured, err := json.Marshal(structured)
if err != nil {
return
}
if outboundCtx.Raw == nil {
outboundCtx.Raw = make(map[string]string, 1)
}
outboundCtx.Raw[metadataKeyStructuredData] = string(rawStructured)
}
func parsePlanTodoFallback(inboundCtx *bus.InboundContext, response string) map[string]any {
if inboundCtx == nil || strings.TrimSpace(response) == "" {
return nil
}
if inboundCtx.Channel != "pico" {
return nil
}
if strings.ToLower(strings.TrimSpace(inboundCtx.Raw[pico.PayloadKeyMode])) != pico.ChatModePlan {
return nil
}
title, content, items := extractPlanTodoItems(response)
if len(items) == 0 {
return nil
}
payload := map[string]any{
"type": "todo",
"title": title,
"items": items,
}
if content != "" {
payload["content"] = content
}
return payload
}
func extractPlanTodoItems(response string) (string, string, []map[string]any) {
lines := strings.Split(strings.ReplaceAll(response, "\r\n", "\n"), "\n")
title := "Plan"
content := ""
headingTasks := make([]map[string]any, 0, 8)
listTasks := make([]map[string]any, 0, 12)
firstHeadingSeen := false
firstParagraphSeen := false
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" || line == "---" {
continue
}
if matches := planHeadingRe.FindStringSubmatch(line); len(matches) == 3 {
headingText := cleanPlanLine(matches[2])
if headingText == "" {
continue
}
if !firstHeadingSeen {
title = headingText
firstHeadingSeen = true
continue
}
if isTaskHeading(headingText) {
headingTasks = append(headingTasks, map[string]any{
"title": headingText,
"status": "not-started",
})
}
continue
}
if !firstParagraphSeen && !looksLikeListLine(line) {
content = cleanPlanLine(line)
firstParagraphSeen = content != ""
}
if matches := planCheckboxRe.FindStringSubmatch(line); len(matches) == 3 {
status := "not-started"
if strings.EqualFold(matches[1], "x") {
status = "completed"
}
if item := cleanPlanLine(matches[2]); item != "" {
listTasks = append(listTasks, map[string]any{
"title": item,
"status": status,
})
}
continue
}
if matches := planBulletRe.FindStringSubmatch(line); len(matches) == 2 {
if item := cleanPlanLine(matches[1]); item != "" {
listTasks = append(listTasks, map[string]any{
"title": item,
"status": inferPlanStatus(item),
})
}
continue
}
if matches := planNumberRe.FindStringSubmatch(line); len(matches) == 2 {
if item := cleanPlanLine(matches[1]); item != "" {
listTasks = append(listTasks, map[string]any{
"title": item,
"status": inferPlanStatus(item),
})
}
}
}
items := headingTasks
if len(items) == 0 {
items = listTasks
}
if len(items) == 0 {
return title, content, nil
}
if len(items) > 8 {
items = items[:8]
}
hasExplicitProgress := false
for _, item := range items {
status, _ := item["status"].(string)
if status == "in-progress" || status == "completed" {
hasExplicitProgress = true
break
}
}
if !hasExplicitProgress && len(items) > 0 {
items[0]["status"] = "in-progress"
}
return title, content, items
}
func isTaskHeading(text string) bool {
lower := strings.ToLower(text)
if strings.Contains(lower, "项目目标") || strings.Contains(lower, "任务拆解") || strings.Contains(lower, "core goal") {
return false
}
return strings.Contains(lower, "阶段") ||
strings.Contains(lower, "phase") ||
strings.Contains(lower, "step") ||
strings.Contains(lower, "milestone") ||
strings.Contains(lower, "实现") ||
strings.Contains(lower, "测试") ||
strings.Contains(lower, "验证") ||
strings.Contains(lower, "优化")
}
func looksLikeListLine(line string) bool {
return planCheckboxRe.MatchString(line) || planBulletRe.MatchString(line) || planNumberRe.MatchString(line)
}
func inferPlanStatus(text string) string {
lower := strings.ToLower(text)
if strings.Contains(lower, "completed") || strings.Contains(lower, "done") || strings.Contains(lower, "已完成") {
return "completed"
}
if strings.Contains(lower, "in-progress") || strings.Contains(lower, "running") || strings.Contains(lower, "进行中") {
return "in-progress"
}
return "not-started"
}
func cleanPlanLine(text string) string {
text = strings.TrimSpace(text)
text = strings.Trim(text, "*")
text = strings.TrimSpace(text)
text = strings.Trim(text, "`")
text = strings.ReplaceAll(text, "**", "")
text = strings.ReplaceAll(text, "__", "")
return strings.TrimSpace(text)
}

View file

@ -0,0 +1,91 @@
package agent
import (
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels/pico"
)
func TestParsePlanTodoFallback_UsesPhaseHeadings(t *testing.T) {
payload := parsePlanTodoFallback(&bus.InboundContext{
Channel: "pico",
Raw: map[string]string{
pico.PayloadKeyMode: pico.ChatModePlan,
},
}, `# 项目规划
先给出实施方案
## 任务拆解
### 阶段 1: 调研与设计
- 分析现有实现
- 明确目标体验
### 阶段 2: 后端实现
- 增加 structured todo
### 阶段 3: 测试与验证
- 补测试
- 手工验证
`)
if payload == nil {
t.Fatal("expected structured payload")
}
if payload["type"] != "todo" {
t.Fatalf("type = %v, want todo", payload["type"])
}
items, ok := payload["items"].([]map[string]any)
if !ok {
t.Fatalf("items = %#v, want []map[string]any", payload["items"])
}
if len(items) != 3 {
t.Fatalf("len(items) = %d, want 3", len(items))
}
if items[0]["title"] != "阶段 1: 调研与设计" {
t.Fatalf("first title = %v, want 阶段 1: 调研与设计", items[0]["title"])
}
if items[0]["status"] != "in-progress" {
t.Fatalf("first status = %v, want in-progress", items[0]["status"])
}
if items[1]["status"] != "not-started" {
t.Fatalf("second status = %v, want not-started", items[1]["status"])
}
}
func TestParsePlanTodoFallback_UsesBulletFallback(t *testing.T) {
payload := parsePlanTodoFallback(&bus.InboundContext{
Channel: "pico",
Raw: map[string]string{
pico.PayloadKeyMode: pico.ChatModePlan,
},
}, `Plan:
- [x] Review current plan mode
- Implement structured todo renderer
- Validate in browser
`)
if payload == nil {
t.Fatal("expected structured payload")
}
items, ok := payload["items"].([]map[string]any)
if !ok || len(items) != 3 {
t.Fatalf("items = %#v, want 3 entries", payload["items"])
}
if items[0]["status"] != "completed" {
t.Fatalf("first status = %v, want completed", items[0]["status"])
}
if items[1]["status"] != "not-started" {
t.Fatalf("second status = %v, want not-started", items[1]["status"])
}
}
func TestAttachPlanTodoFallback_SkipsNonPlanMessages(t *testing.T) {
ctx := bus.InboundContext{Channel: "pico", Raw: map[string]string{}}
attachPlanTodoFallback(&ctx, "- one\n- two")
if len(ctx.Raw) != 0 {
t.Fatalf("raw = %#v, want unchanged", ctx.Raw)
}
}

View file

@ -385,11 +385,7 @@ func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID s
return "", fmt.Errorf("no agent available for session %q", sessionKey) return "", fmt.Errorf("no agent available for session %q", sessionKey)
} }
if tool, ok := agent.Tools.Get("message"); ok { resetSentTrackingTools(agent, sessionKey)
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
resetter.ResetSentInRound(sessionKey)
}
}
var scope *session.SessionScope var scope *session.SessionScope
if metaStore, ok := agent.Sessions.(session.MetadataAwareSessionStore); ok { if metaStore, ok := agent.Sessions.(session.MetadataAwareSessionStore); ok {

View file

@ -20,6 +20,12 @@ type MessageEditor interface {
EditMessage(ctx context.Context, chatID string, messageID string, content string) error EditMessage(ctx context.Context, chatID string, messageID string, content string) error
} }
// StructuredMessageEditor extends MessageEditor for channels that can update
// an existing message while preserving a structured payload.
type StructuredMessageEditor interface {
EditStructuredMessage(ctx context.Context, chatID string, messageID string, content string, structured any) error
}
// MessageDeleter — channels that can delete a message by ID. // MessageDeleter — channels that can delete a message by ID.
type MessageDeleter interface { type MessageDeleter interface {
DeleteMessage(ctx context.Context, chatID string, messageID string) error DeleteMessage(ctx context.Context, chatID string, messageID string) error

View file

@ -8,6 +8,7 @@ package channels
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math" "math"
@ -116,6 +117,19 @@ func outboundMediaChatID(msg bus.OutboundMediaMessage) string {
return msg.ChatID return msg.ChatID
} }
func outboundStructuredPayload(msg bus.OutboundMessage) any {
rawStructured := msg.Context.Raw["structured_data"]
if rawStructured == "" {
return nil
}
var structured any
if err := json.Unmarshal([]byte(rawStructured), &structured); err != nil {
return nil
}
return structured
}
// RecordPlaceholder registers a placeholder message for later editing. // RecordPlaceholder registers a placeholder message for later editing.
// Implements PlaceholderRecorder. // Implements PlaceholderRecorder.
func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
@ -214,6 +228,13 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
// 4. Try editing placeholder // 4. Try editing placeholder
if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if structured := outboundStructuredPayload(msg); structured != nil {
if editor, ok := ch.(StructuredMessageEditor); ok {
if err := editor.EditStructuredMessage(ctx, chatID, entry.id, msg.Content, structured); err == nil {
return []string{entry.id}, true
}
}
}
if editor, ok := ch.(MessageEditor); ok { if editor, ok := ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, chatID, entry.id, msg.Content); err == nil { if err := editor.EditMessage(ctx, chatID, entry.id, msg.Content); err == nil {
return []string{entry.id}, true return []string{entry.id}, true

View file

@ -66,6 +66,32 @@ type mockMediaChannel struct {
sentMediaMessages []bus.OutboundMediaMessage sentMediaMessages []bus.OutboundMediaMessage
} }
type mockStructuredEditorChannel struct {
mockChannel
structuredEditCalls int
lastStructuredEdit struct {
chatID string
messageID string
content string
structured any
}
}
func (m *mockStructuredEditorChannel) EditStructuredMessage(
_ context.Context,
chatID string,
messageID string,
content string,
structured any,
) error {
m.structuredEditCalls++
m.lastStructuredEdit.chatID = chatID
m.lastStructuredEdit.messageID = messageID
m.lastStructuredEdit.content = content
m.lastStructuredEdit.structured = structured
return nil
}
func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
m.sentMediaMessages = append(m.sentMediaMessages, msg) m.sentMediaMessages = append(m.sentMediaMessages, msg)
if m.sendMediaFn != nil { if m.sendMediaFn != nil {
@ -766,6 +792,44 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
} }
} }
func TestPreSend_PlaceholderStructuredEditSuccess(t *testing.T) {
m := newTestManager()
ch := &mockStructuredEditorChannel{}
m.RecordPlaceholder("test", "123", "456")
msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "progress update"})
if msg.Context.Raw == nil {
msg.Context.Raw = make(map[string]string, 1)
}
msg.Context.Raw["structured_data"] = `{"type":"progress","kind":"agent/tool-exec","title":"read_file","status":"running"}`
_, edited := m.preSend(context.Background(), "test", msg, ch)
if !edited {
t.Fatal("expected preSend to return true for structured placeholder edit")
}
if ch.structuredEditCalls != 1 {
t.Fatalf("expected EditStructuredMessage to be called once, got %d", ch.structuredEditCalls)
}
if ch.editedMessages != 0 {
t.Fatal("expected plain EditMessage to NOT be called when structured editor exists")
}
if len(ch.sentMessages) != 0 {
t.Fatal("expected Send to NOT be called when placeholder structured edit succeeds")
}
structured, ok := ch.lastStructuredEdit.structured.(map[string]any)
if !ok {
t.Fatalf("structured payload = %#v, want map[string]any", ch.lastStructuredEdit.structured)
}
if structured["type"] != "progress" || structured["status"] != "running" {
t.Fatalf("structured payload = %#v, want progress/running", structured)
}
if ch.lastStructuredEdit.messageID != "456" {
t.Fatalf("messageID = %q, want 456", ch.lastStructuredEdit.messageID)
}
}
func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
m := newTestManager() m := newTestManager()

View file

@ -262,12 +262,23 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
} }
isThought := outboundMessageIsThought(msg) isThought := outboundMessageIsThought(msg)
payload := map[string]any{ outMsg := newMessage(TypeMessageCreate, map[string]any{
PayloadKeyContent: msg.Content, PayloadKeyContent: msg.Content,
PayloadKeyThought: isThought, PayloadKeyThought: isThought,
})
if rawStructured := strings.TrimSpace(msg.Context.Raw["structured_data"]); rawStructured != "" {
var structured any
if err := json.Unmarshal([]byte(rawStructured), &structured); err == nil {
outMsg.Payload[PayloadKeyStructured] = structured
}
} }
setContextUsagePayload(payload, msg.ContextUsage) logger.InfoCF("pico", "Sending websocket message",
outMsg := newMessage(TypeMessageCreate, payload) map[string]any{
"chat_id": msg.ChatID,
"message_type": outMsg.Type,
"has_thought": isThought,
"has_structured": outMsg.Payload[PayloadKeyStructured] != nil,
})
return nil, c.broadcastToSession(msg.ChatID, outMsg) return nil, c.broadcastToSession(msg.ChatID, outMsg)
} }
@ -281,6 +292,21 @@ func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID
return c.broadcastToSession(chatID, outMsg) return c.broadcastToSession(chatID, outMsg)
} }
func (c *PicoChannel) EditStructuredMessage(
ctx context.Context,
chatID string,
messageID string,
content string,
structured any,
) error {
outMsg := newMessage(TypeMessageUpdate, map[string]any{
"message_id": messageID,
"content": content,
"structured": structured,
})
return c.broadcastToSession(chatID, outMsg)
}
// StartTyping implements channels.TypingCapable. // StartTyping implements channels.TypingCapable.
func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
startMsg := newMessage(TypeTypingStart, nil) startMsg := newMessage(TypeTypingStart, nil)
@ -585,6 +611,9 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
"session_id": sessionID, "session_id": sessionID,
"conn_id": pc.id, "conn_id": pc.id,
} }
if mode, _ := msg.Payload[PayloadKeyMode].(string); strings.TrimSpace(mode) != "" {
metadata[PayloadKeyMode] = strings.TrimSpace(mode)
}
logger.DebugCF("pico", "Received message", map[string]any{ logger.DebugCF("pico", "Received message", map[string]any{
"session_id": sessionID, "session_id": sessionID,
@ -718,16 +747,3 @@ func validateInlineImageDataURL(mediaURL string) error {
return nil return nil
} }
// setContextUsagePayload adds context window usage stats to a pico payload.
func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
if u == nil {
return
}
payload["context_usage"] = map[string]any{
"used_tokens": u.UsedTokens,
"total_tokens": u.TotalTokens,
"compress_at_tokens": u.CompressAtTokens,
"used_percent": u.UsedPercent,
}
}

View file

@ -123,6 +123,24 @@ func TestBroadcastToSession_TargetsOnlyRequestedSession(t *testing.T) {
} }
} }
func TestNewMessage_AssignsMessageIDForCreate(t *testing.T) {
msg := newMessage(TypeMessageCreate, map[string]any{"content": "hello"})
messageID, ok := msg.Payload["message_id"].(string)
if !ok || messageID == "" {
t.Fatalf("message_id = %#v, want non-empty string", msg.Payload["message_id"])
}
}
func TestNewMessage_PreservesExistingMessageIDForCreate(t *testing.T) {
msg := newMessage(TypeMessageCreate, map[string]any{
"content": "hello",
"message_id": "custom-id",
})
if msg.Payload["message_id"] != "custom-id" {
t.Fatalf("message_id = %#v, want custom-id", msg.Payload["message_id"])
}
}
func (c *PicoChannel) addConnForTest(pc *picoConn) { func (c *PicoChannel) addConnForTest(pc *picoConn) {
c.connsMu.Lock() c.connsMu.Lock()
defer c.connsMu.Unlock() defer c.connsMu.Unlock()

View file

@ -1,6 +1,10 @@
package pico package pico
import "time" import (
"time"
"github.com/google/uuid"
)
// Protocol message types. // Protocol message types.
const ( const (
@ -18,10 +22,18 @@ const (
TypeError = "error" TypeError = "error"
TypePong = "pong" TypePong = "pong"
PayloadKeyContent = "content" PicoTokenPrefix = "pico-"
PayloadKeyThought = "thought"
PayloadKeyContent = "content"
PayloadKeyThought = "thought"
PayloadKeyStructured = "structured"
PayloadKeyMode = "mode"
MessageKindThought = "thought" MessageKindThought = "thought"
ChatModeAgent = "agent"
ChatModeAsk = "ask"
ChatModePlan = "plan"
) )
// PicoMessage is the wire format for all Pico Protocol messages. // PicoMessage is the wire format for all Pico Protocol messages.
@ -35,6 +47,15 @@ type PicoMessage struct {
// newMessage creates a PicoMessage with the given type and payload. // newMessage creates a PicoMessage with the given type and payload.
func newMessage(msgType string, payload map[string]any) PicoMessage { func newMessage(msgType string, payload map[string]any) PicoMessage {
if msgType == TypeMessageCreate {
if payload == nil {
payload = make(map[string]any, 1)
}
if _, exists := payload["message_id"]; !exists {
payload["message_id"] = uuid.NewString()
}
}
return PicoMessage{ return PicoMessage{
Type: msgType, Type: msgType,
Timestamp: time.Now().UnixMilli(), Timestamp: time.Now().UnixMilli(),

View file

@ -3,28 +3,15 @@ package integrationtools
import ( import (
"context" "context"
"fmt" "fmt"
"sync"
) )
type SendCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string) error
// sentTarget records the channel+chatID that the message tool sent to.
type sentTarget struct {
Channel string
ChatID string
}
type MessageTool struct { type MessageTool struct {
sendCallback SendCallbackWithContext messageDispatchTool
mu sync.Mutex
// sentTargets tracks targets sent to in the current round, keyed by session key
// to support parallel turns for different sessions.
sentTargets map[string][]sentTarget
} }
func NewMessageTool() *MessageTool { func NewMessageTool() *MessageTool {
return &MessageTool{ return &MessageTool{
sentTargets: make(map[string][]sentTarget), messageDispatchTool: newMessageDispatchTool(),
} }
} }
@ -33,16 +20,65 @@ func (t *MessageTool) Name() string {
} }
func (t *MessageTool) Description() string { func (t *MessageTool) Description() string {
return "Send a message to user on a chat channel. Use this when you want to communicate something." return "Send a structured or plain-text message to the user. Use for interactive UI elements (options, cards, forms, progress, todos, alerts) that need immediate rendering. Always include content as plain-text fallback."
} }
func (t *MessageTool) Parameters() map[string]any { func (t *MessageTool) Parameters() map[string]any {
optionItemSchema := map[string]any{
"type": "object",
"properties": map[string]any{
"label": map[string]any{"type": "string"},
"value": map[string]any{"type": "string"},
"description": map[string]any{"type": "string"},
},
"required": []string{"label", "value"},
}
actionItemSchema := map[string]any{
"type": "object",
"properties": map[string]any{
"label": map[string]any{"type": "string"},
"value": map[string]any{"type": "string"},
},
"required": []string{"label"},
}
structuredPartSchema := map[string]any{
"type": "object",
"oneOf": []any{
map[string]any{
"description": "Interactive options list for the user to choose from.",
"properties": map[string]any{
"type": map[string]any{"type": "string", "const": "options"},
"options": map[string]any{"type": "array", "items": optionItemSchema, "minItems": 1},
"mode": map[string]any{"type": "string", "enum": []string{"single", "multiple"}, "default": "single"},
},
"required": []string{"type", "options"},
},
map[string]any{
"description": "Rich card. Built-in semantic kinds: 'form', 'progress', 'todo', 'alert'. Custom kinds are also accepted and passed through to the frontend renderer.",
"properties": map[string]any{
"type": map[string]any{"type": "string", "const": "card"},
"title": map[string]any{"type": "string"},
"kind": map[string]any{"type": "string", "description": "Semantic subtype. Built-in: 'form'|'progress'|'todo'|'alert'. Custom values are forwarded to the frontend as-is."},
"blocks": map[string]any{"type": "array"},
"actions": map[string]any{"type": "array", "items": actionItemSchema},
},
"required": []string{"type"},
},
map[string]any{
"description": "Custom part type. Any object with a 'type' string field is accepted and forwarded to the frontend renderer as-is.",
"properties": map[string]any{
"type": map[string]any{"type": "string"},
},
"required": []string{"type"},
},
},
}
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"content": map[string]any{ "content": map[string]any{
"type": "string", "type": "string",
"description": "The message content to send", "description": "The visible text summary to send. Keep this even when using structured UI payloads so non-structured clients still have a readable fallback.",
}, },
"channel": map[string]any{ "channel": map[string]any{
"type": "string", "type": "string",
@ -56,88 +92,73 @@ func (t *MessageTool) Parameters() map[string]any {
"type": "string", "type": "string",
"description": "Optional: reply target message ID for channels that support threaded replies", "description": "Optional: reply target message ID for channels that support threaded replies",
}, },
"structured": map[string]any{
"description": "Optional structured payload for rich UI rendering. Can be a single part or an array of parts.",
"oneOf": []any{
structuredPartSchema,
map[string]any{
"type": "array",
"items": structuredPartSchema,
"minItems": 1,
},
},
},
}, },
"required": []string{"content"}, "required": []string{"content"},
} }
} }
// ResetSentInRound resets the per-round send tracker for the given session key.
// Called by the agent loop at the start of each inbound message processing round.
func (t *MessageTool) ResetSentInRound(sessionKey string) {
t.mu.Lock()
defer t.mu.Unlock()
// Delete the key entirely to prevent unbounded map growth over time
// with many unique sessions. Truncating the slice keeps the key alive.
delete(t.sentTargets, sessionKey)
}
// HasSentInRound returns true if the message tool sent a message during the current round.
func (t *MessageTool) HasSentInRound(sessionKey string) bool {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.sentTargets[sessionKey]) > 0
}
// HasSentTo returns true if the message tool sent to the specific channel+chatID
// during the current round. Used by PublishResponseIfNeeded to avoid suppressing
// the final response when the message tool only sent to a different conversation.
func (t *MessageTool) HasSentTo(sessionKey, channel, chatID string) bool {
t.mu.Lock()
defer t.mu.Unlock()
for _, st := range t.sentTargets[sessionKey] {
if st.Channel == channel && st.ChatID == chatID {
return true
}
}
return false
}
func (t *MessageTool) SetSendCallback(callback SendCallbackWithContext) {
t.sendCallback = callback
}
func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
content, ok := args["content"].(string) content, ok := args["content"].(string)
if !ok { if !ok {
return &ToolResult{ForLLM: "content is required", IsError: true} return &ToolResult{ForLLM: "content is required", IsError: true}
} }
channel, _ := args["channel"].(string) structured, err := parseMessageStructuredArgs(args)
chatID, _ := args["chat_id"].(string) if err != nil {
replyToMessageID, _ := args["reply_to_message_id"].(string) return &ToolResult{ForLLM: err.Error(), IsError: true}
if channel == "" {
channel = ToolChannel(ctx)
} }
if chatID == "" { return t.executeSend(ctx, args, content, structured)
chatID = ToolChatID(ctx) }
func parseMessageStructuredArgs(args map[string]any) (any, error) {
if _, exists := args["options"]; exists {
return nil, fmt.Errorf("message does not accept top-level options; use structured.type='options'")
} }
if channel == "" || chatID == "" { if rawStructured, ok := args["structured"]; ok && rawStructured != nil {
return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true} return normalizeStructuredPayload(rawStructured)
} }
if t.sendCallback == nil { return nil, nil
return &ToolResult{ForLLM: "Message sending not configured", IsError: true} }
}
if err := t.sendCallback(ctx, channel, chatID, content, replyToMessageID); err != nil { func normalizeStructuredPayload(rawStructured any) (any, error) {
return &ToolResult{ switch structured := rawStructured.(type) {
ForLLM: fmt.Sprintf("sending message: %v", err), case map[string]any:
IsError: true, return normalizeStructuredEntry(structured, "structured")
Err: err, case []any:
if len(structured) == 0 {
return nil, fmt.Errorf("structured must not be empty")
} }
} result := make([]any, 0, len(structured))
for index, item := range structured {
sessionKey := ToolSessionKey(ctx) entry, ok := item.(map[string]any)
t.mu.Lock() if !ok {
t.sentTargets[sessionKey] = append(t.sentTargets[sessionKey], sentTarget{Channel: channel, ChatID: chatID}) return nil, fmt.Errorf("structured[%d] must be an object", index)
t.mu.Unlock() }
normalized, err := normalizeStructuredEntry(entry, fmt.Sprintf("structured[%d]", index))
// Silent: user already received the message directly if err != nil {
return &ToolResult{ return nil, err
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), }
Silent: true, result = append(result, normalized)
}
return result, nil
default:
return nil, fmt.Errorf("structured must be an object or array")
} }
} }
// StructuredPart is implemented by every canonical and alias structured part type.
// Parse validates and ingests raw LLM input; ToMap serializes back to the wire format.
// This mirrors VS Code's ChatResponsePart design: each kind owns its own schema.

View file

@ -0,0 +1,323 @@
package integrationtools
import "fmt"
// CardBlock is implemented by every card block type.
// Parse validates and ingests raw LLM input; ToMap serializes back to the wire format.
type CardBlock interface {
Parse(entry map[string]any, fieldPath string) error
ToMap() map[string]any
}
type cardBlockFactory func() CardBlock
// cardBlockRegistry maps each known block type to its factory.
// "markdown" is an alias for "text". Custom block types are passed through by the dispatcher.
var cardBlockRegistry = map[string]cardBlockFactory{
"text": func() CardBlock { return &CardTextBlock{} },
"markdown": func() CardBlock { return &CardTextBlock{} },
"fields": func() CardBlock { return &CardFieldsBlock{} },
"badge": func() CardBlock { return &CardBadgeBlock{} },
"actions": func() CardBlock { return &CardActionsBlock{} },
"list": func() CardBlock { return &CardListBlock{} },
"table": func() CardBlock { return &CardTableBlock{} },
"image": func() CardBlock { return &CardImageBlock{} },
"divider": func() CardBlock { return &CardDividerBlock{} },
"json": func() CardBlock { return &CardJSONBlock{} },
}
func normalizeCardBlock(entry map[string]any, fieldPath string) (map[string]any, error) {
blockType, _ := entry["type"].(string)
if blockType == "" {
return nil, fmt.Errorf("%s.type is required when structured.type='card'", fieldPath)
}
if factory, ok := cardBlockRegistry[blockType]; ok {
block := factory()
if err := block.Parse(cloneStructuredEntry(entry), fieldPath); err != nil {
return nil, err
}
return block.ToMap(), nil
}
// Custom block types are passed through as long as they declare a type.
return cloneStructuredEntry(entry), nil
}
// CardTextBlock handles "text" and "markdown" block types.
type CardTextBlock struct{ raw map[string]any }
func (b *CardTextBlock) Parse(entry map[string]any, fieldPath string) error {
blockType, _ := entry["type"].(string)
text, _ := entry["text"].(string)
if text == "" {
return fmt.Errorf("%s.text is required for card block type '%s'", fieldPath, blockType)
}
b.raw = entry
return nil
}
func (b *CardTextBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
// CardFieldsBlock handles the "fields" block type.
type CardFieldsBlock struct{ raw map[string]any }
func (b *CardFieldsBlock) Parse(entry map[string]any, fieldPath string) error {
fields, err := normalizeCardFieldItems(entry["fields"], fieldPath)
if err != nil {
return err
}
entry["fields"] = fields
b.raw = entry
return nil
}
func (b *CardFieldsBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
// CardBadgeBlock handles the "badge" block type.
type CardBadgeBlock struct{ raw map[string]any }
func (b *CardBadgeBlock) Parse(entry map[string]any, fieldPath string) error {
label, _ := entry["label"].(string)
if label == "" {
return fmt.Errorf("%s.label is required for card block type 'badge'", fieldPath)
}
b.raw = entry
return nil
}
func (b *CardBadgeBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
// CardActionsBlock handles the "actions" block type.
type CardActionsBlock struct{ raw map[string]any }
func (b *CardActionsBlock) Parse(entry map[string]any, fieldPath string) error {
actions, _, err := normalizeActionItems(entry["actions"], fieldPath+".actions", "card")
if err != nil {
return err
}
entry["actions"] = actions
b.raw = entry
return nil
}
func (b *CardActionsBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
// CardListBlock handles the "list" block type.
type CardListBlock struct{ raw map[string]any }
func (b *CardListBlock) Parse(entry map[string]any, fieldPath string) error {
items, err := normalizeCardListItems(entry["items"], fieldPath)
if err != nil {
return err
}
entry["items"] = items
b.raw = entry
return nil
}
func (b *CardListBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
// CardTableBlock handles the "table" block type.
type CardTableBlock struct{ raw map[string]any }
func (b *CardTableBlock) Parse(entry map[string]any, fieldPath string) error {
rows, err := normalizeTableRows(entry["rows"], fieldPath)
if err != nil {
return err
}
entry["rows"] = rows
b.raw = entry
return nil
}
func (b *CardTableBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
// CardImageBlock handles the "image" block type.
type CardImageBlock struct{ raw map[string]any }
func (b *CardImageBlock) Parse(entry map[string]any, fieldPath string) error {
url, _ := entry["url"].(string)
if url == "" {
return fmt.Errorf("%s.url is required for card block type 'image'", fieldPath)
}
b.raw = entry
return nil
}
func (b *CardImageBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
// CardDividerBlock handles the "divider" block type (no required fields).
type CardDividerBlock struct{ raw map[string]any }
func (b *CardDividerBlock) Parse(entry map[string]any, fieldPath string) error {
b.raw = entry
return nil
}
func (b *CardDividerBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
// CardJSONBlock handles the "json" block type.
type CardJSONBlock struct{ raw map[string]any }
func (b *CardJSONBlock) Parse(entry map[string]any, fieldPath string) error {
if _, ok := entry["data"]; !ok {
return fmt.Errorf("%s.data is required for card block type 'json'", fieldPath)
}
b.raw = entry
return nil
}
func (b *CardJSONBlock) ToMap() map[string]any { return cloneStructuredEntry(b.raw) }
func normalizeCardFieldItems(raw any, fieldPath string) ([]any, error) {
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("%s.fields must be an array for card block type 'fields'", fieldPath)
}
if len(items) == 0 {
return nil, fmt.Errorf("%s.fields must be a non-empty array for card block type 'fields'", fieldPath)
}
normalized := make([]any, 0, len(items))
for index, item := range items {
entry, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s.fields[%d] must be an object for card block type 'fields'", fieldPath, index)
}
label, _ := entry["label"].(string)
value, _ := entry["value"].(string)
if label == "" || value == "" {
return nil, fmt.Errorf("%s.fields[%d].label and %s.fields[%d].value are required for card block type 'fields'", fieldPath, index, fieldPath, index)
}
normalized = append(normalized, cloneStructuredEntry(entry))
}
return normalized, nil
}
func normalizeCardListItems(raw any, fieldPath string) ([]any, error) {
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("%s.items must be an array for card block type 'list'", fieldPath)
}
if len(items) == 0 {
return nil, fmt.Errorf("%s.items must be a non-empty array for card block type 'list'", fieldPath)
}
normalized := make([]any, 0, len(items))
for index, item := range items {
switch typed := item.(type) {
case string:
if typed == "" {
return nil, fmt.Errorf("%s.items[%d] must not be empty for card block type 'list'", fieldPath, index)
}
normalized = append(normalized, typed)
case map[string]any:
text, _ := typed["text"].(string)
if text == "" {
return nil, fmt.Errorf("%s.items[%d].text is required for card block type 'list'", fieldPath, index)
}
normalized = append(normalized, cloneStructuredEntry(typed))
default:
return nil, fmt.Errorf("%s.items[%d] must be a string or object for card block type 'list'", fieldPath, index)
}
}
return normalized, nil
}
func normalizeTableRows(raw any, fieldPath string) ([]any, error) {
rows, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("%s.rows must be an array for card block type 'table'", fieldPath)
}
if len(rows) == 0 {
return nil, fmt.Errorf("%s.rows must be a non-empty array for card block type 'table'", fieldPath)
}
return rows, nil
}
func normalizeFormFields(raw any, fieldPath string) ([]any, error) {
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("%s.fields must be an array when %s.type='form'", fieldPath, fieldPath)
}
if len(items) == 0 {
return nil, fmt.Errorf("%s.fields must be a non-empty array when %s.type='form'", fieldPath, fieldPath)
}
normalized := make([]any, 0, len(items))
for index, item := range items {
entry, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s.fields[%d] must be an object when %s.type='form'", fieldPath, index, fieldPath)
}
field := cloneStructuredEntry(entry)
name, _ := field["name"].(string)
label, _ := field["label"].(string)
if name == "" || label == "" {
return nil, fmt.Errorf("%s.fields[%d].name and %s.fields[%d].label are required when %s.type='form'", fieldPath, index, fieldPath, index, fieldPath)
}
normalized = append(normalized, field)
}
return normalized, nil
}
func normalizeProgressSteps(raw any, fieldPath string) ([]any, bool, error) {
if raw == nil {
return nil, false, nil
}
items, ok := raw.([]any)
if !ok {
return nil, false, fmt.Errorf("%s.steps must be an array when %s.type='progress'", fieldPath, fieldPath)
}
if len(items) == 0 {
return nil, false, fmt.Errorf("%s.steps must be a non-empty array when %s.type='progress'", fieldPath, fieldPath)
}
normalized := make([]any, 0, len(items))
for index, item := range items {
entry, ok := item.(map[string]any)
if !ok {
return nil, false, fmt.Errorf("%s.steps[%d] must be an object when %s.type='progress'", fieldPath, index, fieldPath)
}
step := cloneStructuredEntry(entry)
assignFirstStringAlias(step, "detail", "detail", "description", "message", "content")
label, _ := step["label"].(string)
if label == "" {
return nil, false, fmt.Errorf("%s.steps[%d].label is required when %s.type='progress'", fieldPath, index, fieldPath)
}
normalized = append(normalized, step)
}
return normalized, true, nil
}
func normalizeTodoItems(raw any, fieldPath string) ([]any, error) {
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("%s.items must be an array when %s.type='todo'", fieldPath, fieldPath)
}
if len(items) == 0 {
return nil, fmt.Errorf("%s.items must be a non-empty array when %s.type='todo'", fieldPath, fieldPath)
}
normalized := make([]any, 0, len(items))
for index, item := range items {
entry, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s.items[%d] must be an object when %s.type='todo'", fieldPath, index, fieldPath)
}
todoItem := cloneStructuredEntry(entry)
assignFirstStringAlias(todoItem, "title", "title", "label", "text", "step")
assignFirstStringAlias(todoItem, "detail", "detail", "description", "message")
title, _ := todoItem["title"].(string)
if title == "" {
return nil, fmt.Errorf("%s.items[%d].title is required when %s.type='todo'", fieldPath, index, fieldPath)
}
status, _ := todoItem["status"].(string)
if status == "" {
if done, ok := todoItem["done"].(bool); ok {
if done {
todoItem["status"] = "completed"
} else {
todoItem["status"] = "not-started"
}
} else {
todoItem["status"] = "not-started"
}
} else if status != "not-started" && status != "in-progress" && status != "completed" {
return nil, fmt.Errorf("%s.items[%d].status must be one of not-started, in-progress, completed when %s.type='todo'", fieldPath, index, fieldPath)
}
normalized = append(normalized, todoItem)
}
return normalized, nil
}

View file

@ -0,0 +1,123 @@
package integrationtools
import (
"context"
"fmt"
"sync"
)
type SendCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string) error
// MessageOption is kept for external callers that reference it directly.
// New code should use OptionItem in message.go instead.
type MessageOption = OptionItem
type SendStructuredCallbackWithContext func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error
// sentTarget records the channel+chatID that a message-like tool sent to.
type sentTarget struct {
Channel string
ChatID string
}
type messageDispatchTool struct {
sendCallback SendStructuredCallbackWithContext
mu sync.Mutex
// sentTargets tracks targets sent to in the current round, keyed by session key
// to support parallel turns for different sessions.
sentTargets map[string][]sentTarget
}
func newMessageDispatchTool() messageDispatchTool {
return messageDispatchTool{
sentTargets: make(map[string][]sentTarget),
}
}
func (t *messageDispatchTool) SetSendCallback(callback SendCallbackWithContext) {
if callback == nil {
t.sendCallback = nil
return
}
t.sendCallback = func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
return callback(ctx, channel, chatID, content, replyToMessageID)
}
}
func (t *messageDispatchTool) SetStructuredSendCallback(callback SendStructuredCallbackWithContext) {
t.sendCallback = callback
}
// ResetSentInRound resets the per-round send tracker for the given session key.
// Called by the agent loop at the start of each inbound message processing round.
func (t *messageDispatchTool) ResetSentInRound(sessionKey string) {
t.mu.Lock()
defer t.mu.Unlock()
// Delete the key entirely to prevent unbounded map growth over time
// with many unique sessions. Truncating the slice keeps the key alive.
delete(t.sentTargets, sessionKey)
}
// HasSentInRound returns true if the tool sent a message during the current round.
func (t *messageDispatchTool) HasSentInRound(sessionKey string) bool {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.sentTargets[sessionKey]) > 0
}
// HasSentTo returns true if the tool sent to the specific channel+chatID during the current round.
func (t *messageDispatchTool) HasSentTo(sessionKey, channel, chatID string) bool {
t.mu.Lock()
defer t.mu.Unlock()
for _, st := range t.sentTargets[sessionKey] {
if st.Channel == channel && st.ChatID == chatID {
return true
}
}
return false
}
func (t *messageDispatchTool) executeSend(
ctx context.Context,
args map[string]any,
content string,
structured any,
) *ToolResult {
channel, _ := args["channel"].(string)
chatID, _ := args["chat_id"].(string)
replyToMessageID, _ := args["reply_to_message_id"].(string)
if channel == "" {
channel = ToolChannel(ctx)
}
if chatID == "" {
chatID = ToolChatID(ctx)
}
if channel == "" || chatID == "" {
return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
}
if t.sendCallback == nil {
return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
}
if err := t.sendCallback(ctx, channel, chatID, content, replyToMessageID, structured); err != nil {
return &ToolResult{
ForLLM: fmt.Sprintf("sending message: %v", err),
IsError: true,
Err: err,
}
}
sessionKey := ToolSessionKey(ctx)
t.mu.Lock()
t.sentTargets[sessionKey] = append(t.sentTargets[sessionKey], sentTarget{Channel: channel, ChatID: chatID})
t.mu.Unlock()
return &ToolResult{
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
Silent: true,
}
}

View file

@ -0,0 +1,380 @@
package integrationtools
import "fmt"
// StructuredPart is implemented by every canonical and alias structured part type.
// Parse validates and ingests raw LLM input; ToMap serializes back to the wire format.
// This mirrors VS Code's ChatResponsePart design: each kind owns its own schema.
type StructuredPart interface {
Parse(entry map[string]any, fieldPath string) error
ToMap() map[string]any
}
// structuredPartFactory creates a fresh zero-value StructuredPart for a given type name.
// Using a factory (rather than storing instances) keeps each normalisation call isolated.
type structuredPartFactory func() StructuredPart
// structuredPartRegistry maps each accepted type name to its factory.
// Canonical types (options, card) produce their own parts.
// Alias input types (form, progress, todo, alert) produce alias parts that
// canonicalise to type:card + kind on ToMap.
var structuredPartRegistry = map[string]structuredPartFactory{
// Canonical types
"options": func() StructuredPart { return &StructuredOptionsPart{} },
"card": func() StructuredPart { return &StructuredCardPart{} },
// Alias input types — accepted for backward compatibility, normalised to card+kind
"form": func() StructuredPart { return &StructuredFormPart{} },
"progress": func() StructuredPart { return &StructuredProgressPart{} },
"todo": func() StructuredPart { return &StructuredTodoPart{} },
"alert": func() StructuredPart { return &StructuredAlertPart{} },
}
func normalizeStructuredEntry(entry map[string]any, fieldPath string) (map[string]any, error) {
msgType, _ := entry["type"].(string)
if msgType == "" {
return nil, fmt.Errorf("%s.type is required", fieldPath)
}
if factory, ok := structuredPartRegistry[msgType]; ok {
part := factory()
if err := part.Parse(cloneStructuredEntry(entry), fieldPath); err != nil {
return nil, err
}
return part.ToMap(), nil
}
// Unknown types are passed through to the frontend for custom rendering.
// The frontend will render them via a registered custom renderer or fall back
// to an "unknown" panel that displays the raw JSON.
return cloneStructuredEntry(entry), nil
}
// OptionItem is the canonical wire representation of a single selectable option.
// Mirrors VS Code ChatResponseQuestionCarouselPart's item shape — each item owns
// its label and a stable value that the reply handler receives.
type OptionItem struct {
Label string
Value string
Description string // optional
}
func (o OptionItem) toMap() map[string]any {
m := map[string]any{
"label": o.Label,
"value": o.Value,
}
if o.Description != "" {
m["description"] = o.Description
}
return m
}
// StructuredOptionsPart is the canonical type for an options part.
// Mirrors VS Code ChatResponseMarkdownPart / ChatResponseConfirmationPart — the struct
// is the schema; Parse is the only place input validation lives.
type StructuredOptionsPart struct {
Options []OptionItem
Mode string // "single" | "multiple"; defaults to "single"
AllowCustom bool
CustomPlaceholder string
SubmitLabel string
}
func (p *StructuredOptionsPart) Parse(entry map[string]any, fieldPath string) error {
rawOptions, ok := entry["options"]
if !ok || rawOptions == nil {
return fmt.Errorf("%s.options must be an array when %s.type='options'", fieldPath, fieldPath)
}
items, ok := rawOptions.([]any)
if !ok {
return fmt.Errorf("%s.options must be an array when %s.type='options'", fieldPath, fieldPath)
}
if len(items) == 0 {
return fmt.Errorf("%s.options must not be empty", fieldPath)
}
options := make([]OptionItem, 0, len(items))
for i, item := range items {
raw, ok := item.(map[string]any)
if !ok {
return fmt.Errorf("%s.options[%d] must be an object", fieldPath, i)
}
label, _ := raw["label"].(string)
value, _ := raw["value"].(string)
if label == "" || value == "" {
return fmt.Errorf("%s.options[%d].label and .value are required", fieldPath, i)
}
description, _ := raw["description"].(string)
options = append(options, OptionItem{Label: label, Value: value, Description: description})
}
mode, _ := entry["mode"].(string)
if mode == "" {
mode = "single"
}
p.Options = options
p.Mode = mode
p.AllowCustom, _ = entry["allowCustom"].(bool)
p.CustomPlaceholder, _ = entry["customPlaceholder"].(string)
p.SubmitLabel, _ = entry["submitLabel"].(string)
return nil
}
// ToMap serializes the canonical part back to a wire map sent to the frontend.
func (p *StructuredOptionsPart) ToMap() map[string]any {
items := make([]any, len(p.Options))
for i, opt := range p.Options {
items[i] = opt.toMap()
}
m := map[string]any{
"type": "options",
"options": items,
"mode": p.Mode,
}
if p.AllowCustom {
m["allowCustom"] = true
}
if p.CustomPlaceholder != "" {
m["customPlaceholder"] = p.CustomPlaceholder
}
if p.SubmitLabel != "" {
m["submitLabel"] = p.SubmitLabel
}
return m
}
// StructuredCardPart is the canonical type for a rich card.
type StructuredCardPart struct {
raw map[string]any // retains all fields after validation
}
func (p *StructuredCardPart) Parse(entry map[string]any, fieldPath string) error {
title, _ := entry["title"].(string)
kind, _ := entry["kind"].(string)
blocks, hasBlocks, err := normalizeCardBlocks(entry["blocks"], fieldPath)
if err != nil {
return err
}
actions, hasActions, err := normalizeActionItems(entry["actions"], fieldPath+".actions", "card")
if err != nil {
return err
}
if title == "" && kind == "" {
return fmt.Errorf("%s.title is required when %s.type='card' unless %s.kind identifies a custom card", fieldPath, fieldPath, fieldPath)
}
if !hasBlocks && !hasActions {
return fmt.Errorf("%s.blocks or %s.actions must be a non-empty array when %s.type='card'", fieldPath, fieldPath, fieldPath)
}
if hasBlocks {
entry["blocks"] = blocks
}
if hasActions {
entry["actions"] = actions
}
p.raw = entry
return nil
}
func (p *StructuredCardPart) ToMap() map[string]any {
return cloneStructuredEntry(p.raw)
}
// StructuredFormPart is an alias input type that normalises to type:card + kind:form.
type StructuredFormPart struct {
raw map[string]any
}
func (p *StructuredFormPart) Parse(entry map[string]any, fieldPath string) error {
assignFirstStringAlias(entry, "content", "content", "description", "message")
title, _ := entry["title"].(string)
content, _ := entry["content"].(string)
if title == "" && content == "" {
return fmt.Errorf("%s.title or %s.content is required when %s.type='form'", fieldPath, fieldPath, fieldPath)
}
fields, err := normalizeFormFields(entry["fields"], fieldPath)
if err != nil {
return err
}
entry["fields"] = fields
actions, hasActions, err := normalizeActionItems(entry["actions"], fieldPath+".actions", "form")
if err != nil {
return err
}
if hasActions {
entry["actions"] = actions
}
p.raw = canonicalizeStructuredAliasEntry(entry, "form")
return nil
}
func (p *StructuredFormPart) ToMap() map[string]any { return cloneStructuredEntry(p.raw) }
// StructuredProgressPart is an alias input type that normalises to type:card + kind:progress.
type StructuredProgressPart struct {
raw map[string]any
}
func (p *StructuredProgressPart) Parse(entry map[string]any, fieldPath string) error {
assignFirstStringAlias(entry, "content", "content", "description", "message", "detail")
status, _ := entry["status"].(string)
if status == "" {
return fmt.Errorf("%s.status is required when %s.type='progress'", fieldPath, fieldPath)
}
title, _ := entry["title"].(string)
content, _ := entry["content"].(string)
steps, hasSteps, err := normalizeProgressSteps(entry["steps"], fieldPath)
if err != nil {
return err
}
if title == "" && content == "" && !hasSteps {
return fmt.Errorf("%s.title, %s.content, or %s.steps must be provided when %s.type='progress'", fieldPath, fieldPath, fieldPath, fieldPath)
}
if hasSteps {
entry["steps"] = steps
}
p.raw = canonicalizeStructuredAliasEntry(entry, "progress")
return nil
}
func (p *StructuredProgressPart) ToMap() map[string]any { return cloneStructuredEntry(p.raw) }
// StructuredTodoPart is an alias input type that normalises to type:card + kind:todo.
type StructuredTodoPart struct {
raw map[string]any
}
func (p *StructuredTodoPart) Parse(entry map[string]any, fieldPath string) error {
assignFirstStringAlias(entry, "content", "content", "description", "message")
items, err := normalizeTodoItems(entry["items"], fieldPath)
if err != nil {
return err
}
entry["items"] = items
p.raw = canonicalizeStructuredAliasEntry(entry, "todo")
return nil
}
func (p *StructuredTodoPart) ToMap() map[string]any { return cloneStructuredEntry(p.raw) }
// StructuredAlertPart is an alias input type that normalises to type:card + kind:alert.
type StructuredAlertPart struct {
raw map[string]any
}
func (p *StructuredAlertPart) Parse(entry map[string]any, fieldPath string) error {
assignFirstStringAlias(entry, "level", "level", "severity", "statusLevel")
assignFirstStringAlias(entry, "content", "content", "description", "message", "detail")
level, _ := entry["level"].(string)
if level == "" {
return fmt.Errorf("%s.level is required when %s.type='alert'", fieldPath, fieldPath)
}
content, _ := entry["content"].(string)
if content == "" {
return fmt.Errorf("%s.content is required when %s.type='alert'", fieldPath, fieldPath)
}
actions, hasActions, err := normalizeActionItems(entry["actions"], fieldPath+".actions", "alert")
if err != nil {
return err
}
if hasActions {
entry["actions"] = actions
}
p.raw = canonicalizeStructuredAliasEntry(entry, "alert")
return nil
}
func (p *StructuredAlertPart) ToMap() map[string]any { return cloneStructuredEntry(p.raw) }
func canonicalizeStructuredAliasEntry(entry map[string]any, aliasType string) map[string]any {
canonical := cloneStructuredEntry(entry)
canonical["type"] = "card"
if kind, _ := canonical["kind"].(string); kind == "" {
canonical["kind"] = aliasType
}
return canonical
}
func cloneStructuredEntry(entry map[string]any) map[string]any {
cloned := make(map[string]any, len(entry))
for key, value := range entry {
cloned[key] = value
}
return cloned
}
func assignFirstStringAlias(entry map[string]any, target string, aliases ...string) {
for _, alias := range aliases {
value, ok := entry[alias].(string)
if ok && value != "" {
entry[target] = value
return
}
}
}
func normalizeActionItems(raw any, fieldPath string, parentType string) ([]any, bool, error) {
if raw == nil {
return nil, false, nil
}
items, ok := raw.([]any)
if !ok {
return nil, false, fmt.Errorf("%s must be an array when structured.type='%s'", fieldPath, parentType)
}
if len(items) == 0 {
return nil, false, fmt.Errorf("%s must be a non-empty array when structured.type='%s'", fieldPath, parentType)
}
normalized := make([]any, 0, len(items))
for index, item := range items {
entry, ok := item.(map[string]any)
if !ok {
return nil, false, fmt.Errorf("%s[%d] must be an object when structured.type='%s'", fieldPath, index, parentType)
}
label, _ := entry["label"].(string)
if label == "" {
return nil, false, fmt.Errorf("%s[%d].label is required when structured.type='%s'", fieldPath, index, parentType)
}
normalized = append(normalized, cloneStructuredEntry(entry))
}
return normalized, true, nil
}
func normalizeCardBlocks(raw any, fieldPath string) ([]any, bool, error) {
if raw == nil {
return nil, false, nil
}
items, ok := raw.([]any)
if !ok {
return nil, false, fmt.Errorf("%s.blocks must be an array when %s.type='card'", fieldPath, fieldPath)
}
if len(items) == 0 {
return nil, false, fmt.Errorf("%s.blocks must be a non-empty array when %s.type='card'", fieldPath, fieldPath)
}
normalized := make([]any, 0, len(items))
for index, item := range items {
entry, ok := item.(map[string]any)
if !ok {
return nil, false, fmt.Errorf("%s.blocks[%d] must be an object when %s.type='card'", fieldPath, index, fieldPath)
}
block, err := normalizeCardBlock(entry, fmt.Sprintf("%s.blocks[%d]", fieldPath, index))
if err != nil {
return nil, false, err
}
normalized = append(normalized, block)
}
return normalized, true, nil
}
// CardBlock is implemented by every card block type.
// Parse validates and ingests raw LLM input; ToMap serializes back to the wire format.

View file

@ -3,6 +3,7 @@ package integrationtools
import ( import (
"context" "context"
"errors" "errors"
"strings"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
@ -208,6 +209,15 @@ func TestMessageTool_Description(t *testing.T) {
if desc == "" { if desc == "" {
t.Error("Description should not be empty") t.Error("Description should not be empty")
} }
for _, snippet := range []string{
"card",
"options",
} {
if !strings.Contains(desc, snippet) {
t.Fatalf("Description() missing snippet %q", snippet)
}
}
} }
func TestMessageTool_Parameters(t *testing.T) { func TestMessageTool_Parameters(t *testing.T) {
@ -266,6 +276,58 @@ func TestMessageTool_Parameters(t *testing.T) {
if replyToProp["type"] != "string" { if replyToProp["type"] != "string" {
t.Error("Expected reply_to_message_id type to be 'string'") t.Error("Expected reply_to_message_id type to be 'string'")
} }
structuredProp, ok := props["structured"].(map[string]any)
if !ok {
t.Fatal("Expected 'structured' property")
}
if _, ok := structuredProp["description"].(string); !ok {
t.Fatal("Expected structured description")
}
// structured must use oneOf to enumerate part types as machine-readable JSON Schema
oneOf, ok := structuredProp["oneOf"].([]any)
if !ok || len(oneOf) == 0 {
t.Fatal("Expected structured.oneOf")
}
// find the single-part schema (object with oneOf) and verify canonical types are present
foundTypes := map[string]bool{}
var checkSchema func(v any)
checkSchema = func(v any) {
switch vt := v.(type) {
case map[string]any:
if c, ok := vt["const"].(string); ok {
foundTypes[c] = true
}
if e, ok := vt["enum"].([]string); ok {
for _, s := range e {
foundTypes[s] = true
}
}
// also scan description strings for kind hints (e.g. "'form'|'progress'|...")
if d, ok := vt["description"].(string); ok {
for _, kw := range []string{"form", "progress", "todo", "alert", "options", "card"} {
if strings.Contains(d, kw) {
foundTypes[kw] = true
}
}
}
for _, val := range vt {
checkSchema(val)
}
case []any:
for _, item := range vt {
checkSchema(item)
}
}
}
for _, item := range oneOf {
checkSchema(item)
}
for _, kind := range []string{"options", "card", "form", "progress", "todo", "alert"} {
if !foundTypes[kind] {
t.Errorf("structured oneOf schema missing type/kind %q", kind)
}
}
} }
func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) { func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) {
@ -329,3 +391,396 @@ func TestMessageTool_Execute_PropagatesTurnSessionMetadata(t *testing.T) {
t.Fatalf("ToolSessionScope() = %+v, want chat scope", gotScope) t.Fatalf("ToolSessionScope() = %+v, want chat scope", gotScope)
} }
} }
func TestMessageTool_Execute_RejectsLegacyOptionsArgs(t *testing.T) {
tool := NewMessageTool()
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "Choose one:",
"options": []any{
map[string]any{"label": "A", "value": "alpha"},
},
})
if !result.IsError {
t.Fatal("expected options on message tool to fail")
}
if result.ForLLM != "message does not accept top-level options; use structured.type='options'" {
t.Fatalf("ForLLM = %q, want message does not accept top-level options; use structured.type='options'", result.ForLLM)
}
}
func TestMessageTool_Execute_WithStructuredOptionsPayload(t *testing.T) {
tool := NewMessageTool()
var gotStructured any
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
gotStructured = structured
return nil
})
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "Choose one:",
"structured": map[string]any{
"type": "options",
"mode": "multiple",
"allowCustom": true,
"submitLabel": "Confirm",
"options": []any{
map[string]any{"label": "A", "value": "alpha"},
},
},
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
structuredMap, ok := gotStructured.(map[string]any)
if !ok {
t.Fatal("expected structured payload")
}
if structuredMap["type"] != "options" {
t.Fatalf("structured type = %v, want options", structuredMap["type"])
}
items, ok := structuredMap["options"].([]any)
if !ok || len(items) != 1 {
t.Fatalf("structured options = %+v, want 1 option", structuredMap["options"])
}
first, ok := items[0].(map[string]any)
if !ok || first["label"] != "A" || first["value"] != "alpha" {
t.Fatalf("first option = %+v, want normalized option", items[0])
}
if structuredMap["mode"] != "multiple" {
t.Fatalf("structured mode = %v, want multiple", structuredMap["mode"])
}
if structuredMap["allowCustom"] != true {
t.Fatalf("structured allowCustom = %v, want true", structuredMap["allowCustom"])
}
if structuredMap["submitLabel"] != "Confirm" {
t.Fatalf("structured submitLabel = %v, want Confirm", structuredMap["submitLabel"])
}
}
func TestMessageTool_Execute_InvalidStructuredOptionsPayload(t *testing.T) {
tool := NewMessageTool()
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "Choose one:",
"structured": map[string]any{
"type": "options",
},
})
if !result.IsError {
t.Fatal("expected invalid structured options payload to fail")
}
if result.ForLLM != "structured.options must be an array when structured.type='options'" {
t.Fatalf("ForLLM = %q, want structured.options must be an array when structured.type='options'", result.ForLLM)
}
}
func TestMessageTool_Execute_WithStructuredCardPayload(t *testing.T) {
tool := NewMessageTool()
var gotStructured any
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
gotStructured = structured
return nil
})
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "审批卡片摘要",
"structured": map[string]any{
"type": "card",
"kind": "custom/approval-card",
"title": "待审批",
"blocks": []any{
map[string]any{"type": "text", "text": "张三提交了请假申请"},
},
},
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
structuredMap, ok := gotStructured.(map[string]any)
if !ok {
t.Fatal("expected structured payload")
}
if structuredMap["type"] != "card" {
t.Fatalf("structured type = %v, want card", structuredMap["type"])
}
if structuredMap["kind"] != "custom/approval-card" {
t.Fatalf("structured kind = %v, want custom/approval-card", structuredMap["kind"])
}
}
func TestMessageTool_Execute_WithStructuredListPayload(t *testing.T) {
tool := NewMessageTool()
var gotStructured any
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
gotStructured = structured
return nil
})
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "Mixed structured payload",
"structured": []any{
map[string]any{"type": "progress", "title": "Syncing", "status": "running"},
map[string]any{"type": "alert", "level": "info", "content": "Waiting"},
},
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
items, ok := gotStructured.([]any)
if !ok || len(items) != 2 {
t.Fatalf("structured payload = %#v, want 2-item list", gotStructured)
}
progress, ok := items[0].(map[string]any)
if !ok || progress["type"] != "card" || progress["kind"] != "progress" {
t.Fatalf("first structured item = %#v, want card kind progress", items[0])
}
alert, ok := items[1].(map[string]any)
if !ok || alert["type"] != "card" || alert["kind"] != "alert" {
t.Fatalf("second structured item = %#v, want card kind alert", items[1])
}
}
func TestMessageTool_Execute_NormalizesStructuredAliases(t *testing.T) {
tool := NewMessageTool()
var gotStructured any
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
gotStructured = structured
return nil
})
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "Mixed structured payload",
"structured": []any{
map[string]any{"type": "progress", "message": "Working", "status": "running"},
map[string]any{"type": "todo", "items": []any{map[string]any{"label": "Review", "done": true}}},
map[string]any{"type": "alert", "severity": "info", "message": "Heads up"},
},
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
items, ok := gotStructured.([]any)
if !ok || len(items) != 3 {
t.Fatalf("structured payload = %#v, want 3-item list", gotStructured)
}
progress, ok := items[0].(map[string]any)
if !ok || progress["type"] != "card" || progress["kind"] != "progress" || progress["content"] != "Working" {
t.Fatalf("progress = %#v, want canonical card progress payload", items[0])
}
todo, ok := items[1].(map[string]any)
if !ok || todo["type"] != "card" || todo["kind"] != "todo" {
t.Fatalf("todo = %#v, want canonical card todo payload", items[1])
}
todoItems, ok := todo["items"].([]any)
if !ok || len(todoItems) != 1 {
t.Fatalf("todo items = %#v, want 1 item", todo["items"])
}
firstTodo, ok := todoItems[0].(map[string]any)
if !ok || firstTodo["title"] != "Review" || firstTodo["status"] != "completed" {
t.Fatalf("first todo = %#v, want normalized title/status", todoItems[0])
}
alert, ok := items[2].(map[string]any)
if !ok || alert["type"] != "card" || alert["kind"] != "alert" || alert["level"] != "info" || alert["content"] != "Heads up" {
t.Fatalf("alert = %#v, want canonical card alert payload", items[2])
}
}
func TestMessageTool_Execute_CanonicalizesStructuredFormAlias(t *testing.T) {
tool := NewMessageTool()
var gotStructured any
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
gotStructured = structured
return nil
})
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "填写反馈",
"structured": map[string]any{
"type": "form",
"title": "反馈表",
"content": "请补充你的意见",
"fields": []any{
map[string]any{"name": "feedback", "label": "你的反馈"},
},
},
})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
structuredMap, ok := gotStructured.(map[string]any)
if !ok {
t.Fatal("expected structured payload")
}
if structuredMap["type"] != "card" || structuredMap["kind"] != "form" {
t.Fatalf("structured payload = %#v, want card kind form", structuredMap)
}
fields, ok := structuredMap["fields"].([]any)
if !ok || len(fields) != 1 {
t.Fatalf("structured fields = %#v, want 1 field", structuredMap["fields"])
}
}
func TestMessageTool_Execute_InvalidStructuredCardPayload(t *testing.T) {
tool := NewMessageTool()
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "bad card",
"structured": map[string]any{
"type": "card",
"title": "Card without body",
},
})
if !result.IsError {
t.Fatal("expected invalid structured card payload to fail")
}
if result.ForLLM != "structured.blocks or structured.actions must be a non-empty array when structured.type='card'" {
t.Fatalf("ForLLM = %q, want repairable card error", result.ForLLM)
}
}
func TestMessageTool_Execute_InvalidStructuredFormPayload(t *testing.T) {
tool := NewMessageTool()
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "bad form",
"structured": map[string]any{
"type": "form",
"title": "Missing fields",
},
})
if !result.IsError {
t.Fatal("expected invalid structured form payload to fail")
}
if result.ForLLM != "structured.fields must be an array when structured.type='form'" {
t.Fatalf("ForLLM = %q, want repairable form error", result.ForLLM)
}
}
func TestMessageTool_Execute_InvalidStructuredProgressPayload(t *testing.T) {
tool := NewMessageTool()
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "bad progress",
"structured": map[string]any{
"type": "progress",
"title": "Syncing",
},
})
if !result.IsError {
t.Fatal("expected invalid structured progress payload to fail")
}
if result.ForLLM != "structured.status is required when structured.type='progress'" {
t.Fatalf("ForLLM = %q, want repairable progress error", result.ForLLM)
}
}
func TestMessageTool_Execute_InvalidStructuredTodoPayload(t *testing.T) {
tool := NewMessageTool()
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "bad todo",
"structured": map[string]any{
"type": "todo",
"items": []any{map[string]any{"status": "waiting"}},
},
})
if !result.IsError {
t.Fatal("expected invalid structured todo payload to fail")
}
if result.ForLLM != "structured.items[0].title is required when structured.type='todo'" {
t.Fatalf("ForLLM = %q, want repairable todo error", result.ForLLM)
}
}
func TestMessageTool_Execute_InvalidStructuredAlertPayload(t *testing.T) {
tool := NewMessageTool()
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "bad alert",
"structured": map[string]any{
"type": "alert",
"severity": "info",
},
})
if !result.IsError {
t.Fatal("expected invalid structured alert payload to fail")
}
if result.ForLLM != "structured.content is required when structured.type='alert'" {
t.Fatalf("ForLLM = %q, want repairable alert error", result.ForLLM)
}
}
func TestMessageTool_Execute_InvalidStructuredListPayload(t *testing.T) {
tool := NewMessageTool()
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "bad",
"structured": []any{map[string]any{"title": "missing type"}},
})
if !result.IsError {
t.Fatal("expected invalid structured list payload to fail")
}
if result.ForLLM != "structured[0].type is required" {
t.Fatalf("ForLLM = %q, want structured[0].type is required", result.ForLLM)
}
}
func TestMessageTool_Execute_InvalidStructuredPayload(t *testing.T) {
tool := NewMessageTool()
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "bad",
"structured": map[string]any{},
})
if !result.IsError {
t.Fatal("expected invalid structured payload to fail")
}
if result.ForLLM != "structured.type is required" {
t.Fatalf("ForLLM = %q, want structured.type is required", result.ForLLM)
}
}
func TestMessageTool_Execute_UnknownTypePassesThrough(t *testing.T) {
tool := NewMessageTool()
var gotStructured any
tool.SetStructuredSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string, structured any) error {
gotStructured = structured
return nil
})
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
result := tool.Execute(ctx, map[string]any{
"content": "Custom component",
"structured": map[string]any{
"type": "approval",
"title": "Deploy to prod",
"approver": "alice",
},
})
if result.IsError {
t.Fatalf("expected unknown type to pass through, got error: %s", result.ForLLM)
}
structuredMap, ok := gotStructured.(map[string]any)
if !ok {
t.Fatal("expected structured payload")
}
if structuredMap["type"] != "approval" {
t.Fatalf("structured type = %v, want approval", structuredMap["type"])
}
if structuredMap["approver"] != "alice" {
t.Fatalf("structured approver = %v, want alice", structuredMap["approver"])
}
}

View file

@ -266,3 +266,30 @@ func TestToolResultContentForLLM_AppendsArtifactPaths(t *testing.T) {
t.Fatalf("expected artifact guidance note in ContentForLLM, got %q", content) t.Fatalf("expected artifact guidance note in ContentForLLM, got %q", content)
} }
} }
func TestToolResultShouldPublishDirectly_DefaultsToFalseForPlainUserResult(t *testing.T) {
result := UserResult("user visible message")
if result.ShouldPublishDirectly(true) {
t.Fatal("expected plain UserResult not to publish directly by default")
}
}
func TestToolResultShouldPublishDirectly_RequiresSendResponseForExplicitDirect(t *testing.T) {
result := UserResult("user visible message").WithDirectUserResponse()
if result.ShouldPublishDirectly(false) {
t.Fatal("expected direct user response to respect sendResponse=false")
}
if !result.ShouldPublishDirectly(true) {
t.Fatal("expected direct user response to publish when sendResponse=true")
}
}
func TestToolResultShouldPublishDirectly_ResponseHandledAlwaysPublishes(t *testing.T) {
result := UserResult("handled").WithResponseHandled()
if !result.ShouldPublishDirectly(false) {
t.Fatal("expected handled response to publish even when sendResponse=false")
}
}

View file

@ -59,6 +59,12 @@ type ToolResult struct {
// user's request at the channel/output level, so the agent loop can stop // user's request at the channel/output level, so the agent loop can stop
// without a follow-up assistant response. // without a follow-up assistant response.
ResponseHandled bool `json:"response_handled,omitempty"` ResponseHandled bool `json:"response_handled,omitempty"`
// DirectUserResponse explicitly allows the raw ForUser payload to be sent
// straight to the chat channel before the model produces a final reply.
// Keep this false by default so the primary interaction path is
// tool-result -> model -> assistant reply.
DirectUserResponse bool `json:"direct_user_response,omitempty"`
} }
// ContentForLLM returns the normalized textual content to append to the // ContentForLLM returns the normalized textual content to append to the
@ -93,6 +99,18 @@ func (tr *ToolResult) ContentForLLM() string {
return "" return ""
} }
// ShouldPublishDirectly reports whether the raw ForUser payload should be sent
// straight to the user channel instead of only flowing back to the model.
func (tr *ToolResult) ShouldPublishDirectly(sendResponse bool) bool {
if tr == nil || tr.Silent || tr.ForUser == "" {
return false
}
if tr.ResponseHandled {
return true
}
return sendResponse && tr.DirectUserResponse
}
// NewToolResult creates a basic ToolResult with content for the LLM. // NewToolResult creates a basic ToolResult with content for the LLM.
// Use this when you need a simple result with default behavior. // Use this when you need a simple result with default behavior.
// //
@ -221,3 +239,10 @@ func (tr *ToolResult) WithResponseHandled() *ToolResult {
tr.ResponseHandled = true tr.ResponseHandled = true
return tr return tr
} }
// WithDirectUserResponse marks the result for immediate raw delivery to the
// user channel in addition to feeding the result back to the model.
func (tr *ToolResult) WithDirectUserResponse() *ToolResult {
tr.DirectUserResponse = true
return tr
}

View file

@ -46,9 +46,11 @@ type sessionListItem struct {
} }
type sessionChatMessage struct { type sessionChatMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Media []string `json:"media,omitempty"` Kind string `json:"kind,omitempty"`
Media []string `json:"media,omitempty"`
Structured any `json:"structured,omitempty"`
} }
// legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL // legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL
@ -460,9 +462,6 @@ 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{
@ -473,10 +472,15 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
} }
case "assistant": case "assistant":
// Reasoning-only assistant messages are transient display artifacts and if strings.TrimSpace(msg.ReasoningContent) != "" {
// should not be restored from session history. transcript = append(transcript, sessionChatMessage{
if assistantMessageTransientThought(msg) { Role: "assistant",
continue Content: msg.ReasoningContent,
Kind: "thought",
})
if assistantMessageTransientThought(msg) {
continue
}
} }
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength) toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
@ -489,6 +493,13 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
transcript = append(transcript, visibleToolMessages...) transcript = append(transcript, visibleToolMessages...)
} }
// Tool-call assistant content is usually a transient preamble such as
// "I'll check that now." Keep the reconstructed tool summaries/cards and
// hide the raw assistant content to match the live Copilot-style UI.
if len(msg.ToolCalls) > 0 {
continue
}
// Pico web chat can persist both visible `message` tool output and a // Pico web chat can persist both visible `message` tool output and a
// later plain assistant reply in the same turn. Hide only the fixed // later plain assistant reply in the same turn. Hide only the fixed
// internal summary that marks handled tool delivery. // internal summary that marks handled tool delivery.
@ -504,18 +515,7 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
} }
} }
return filterSessionChatMessages(transcript) return 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 {
@ -542,16 +542,38 @@ func visibleAssistantToolSummaryMessages(
messages := make([]sessionChatMessage, 0, len(toolCalls)) messages := make([]sessionChatMessage, 0, len(toolCalls))
for _, tc := range toolCalls { for _, tc := range toolCalls {
name, argsJSON := toolCallNameAndArguments(tc) name := tc.Name
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 name != "message" {
content := visibleAssistantToolProgressContent(name, argsJSON, tc.Arguments, toolFeedbackMaxArgsLength)
messages = append(messages, sessionChatMessage{
Role: "assistant",
Content: "",
Structured: map[string]any{
"type": "progress",
"kind": "agent/tool-exec",
"title": name,
"status": "completed",
"content": content,
},
})
continue continue
} }
if name == "message" {
if _, ok := parseMessageToolContent(argsJSON); ok { if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
continue if encodedArgs, err := json.Marshal(tc.Arguments); err == nil {
argsJSON = string(encodedArgs)
} }
} }
@ -569,6 +591,138 @@ func visibleAssistantToolSummaryMessages(
return messages return messages
} }
func visibleAssistantToolProgressContent(
toolName string,
argsJSON string,
rawArgs map[string]any,
maxArgsLength int,
) string {
if maxArgsLength <= 0 {
maxArgsLength = defaultToolFeedbackMaxArgsLength()
}
args := map[string]any{}
if strings.TrimSpace(argsJSON) != "" {
_ = json.Unmarshal([]byte(argsJSON), &args)
}
for key, value := range rawArgs {
if _, exists := args[key]; !exists {
args[key] = value
}
}
if summary := summarizeToolArgs(toolName, args); summary != "" {
return summary
}
preview := strings.TrimSpace(argsJSON)
if preview == "" && len(rawArgs) > 0 {
if encoded, err := json.Marshal(rawArgs); err == nil {
preview = string(encoded)
}
}
if preview == "" {
return "Tool executed during this turn."
}
return utils.Truncate(preview, maxArgsLength)
}
func summarizeToolArgs(toolName string, args map[string]any) string {
if len(args) == 0 {
return ""
}
path := firstNonEmptyString(args, "path", "filePath", "file_path", "dirPath", "dir_path", "resourcePath", "workspaceFolder")
query := firstNonEmptyString(args, "query", "command", "goal", "target")
url := firstNonEmptyString(args, "url")
urls := stringSliceSummary(args["urls"])
packages := stringSliceSummary(args["packageList"])
parts := make([]string, 0, 3)
if path != "" {
if toolName == "read_file" {
if start, ok := numericArg(args, "startLine", "start_line"); ok {
if end, ok := numericArg(args, "endLine", "end_line"); ok {
parts = append(parts, path+":"+strconv.Itoa(start)+"-"+strconv.Itoa(end))
} else {
parts = append(parts, path+":"+strconv.Itoa(start))
}
} else {
parts = append(parts, path)
}
} else {
parts = append(parts, path)
}
}
if query != "" && query != path {
parts = append(parts, query)
}
if url != "" {
parts = append(parts, url)
} else if urls != "" {
parts = append(parts, urls)
}
if packages != "" {
parts = append(parts, packages)
}
return strings.Join(parts, " | ")
}
func firstNonEmptyString(args map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := args[key]; ok {
switch typed := value.(type) {
case string:
if trimmed := strings.TrimSpace(typed); trimmed != "" {
return trimmed
}
}
}
}
return ""
}
func numericArg(args map[string]any, keys ...string) (int, bool) {
for _, key := range keys {
value, ok := args[key]
if !ok {
continue
}
switch typed := value.(type) {
case float64:
return int(typed), true
case int:
return typed, true
case int64:
return int(typed), true
case json.Number:
if parsed, err := typed.Int64(); err == nil {
return int(parsed), true
}
}
}
return 0, false
}
func stringSliceSummary(value any) string {
switch typed := value.(type) {
case []string:
return strings.Join(typed, ", ")
case []any:
items := make([]string, 0, len(typed))
for _, item := range typed {
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
items = append(items, strings.TrimSpace(text))
}
}
return strings.Join(items, ", ")
default:
return ""
}
}
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
if len(toolCalls) == 0 { if len(toolCalls) == 0 {
return nil return nil
@ -576,51 +730,144 @@ 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, argsJSON := toolCallNameAndArguments(tc) name := tc.Name
if name != "message" { argsJSON := ""
continue if tc.Function != nil {
if name == "" {
name = tc.Function.Name
}
argsJSON = tc.Function.Arguments
} }
content, ok := parseMessageToolContent(argsJSON)
if !ok { switch name {
continue case "message":
var args map[string]any
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
continue
}
content, _ := args["content"].(string)
structured := visibleStructuredMessageArg(args)
if strings.TrimSpace(content) == "" && structured == nil {
continue
}
messages = append(messages, sessionChatMessage{
Role: "assistant",
Content: content,
Structured: structured,
})
} }
messages = append(messages, sessionChatMessage{
Role: "assistant",
Content: content,
})
} }
return messages return messages
} }
func toolCallNameAndArguments(tc providers.ToolCall) (string, string) { func visibleStructuredMessageArg(args map[string]any) any {
name := tc.Name if rawStructured, ok := args["structured"]; ok && rawStructured != nil {
argsJSON := "" switch structured := rawStructured.(type) {
if tc.Function != nil { case map[string]any:
if name == "" { msgType, _ := structured["type"].(string)
name = tc.Function.Name if strings.TrimSpace(msgType) != "" {
} return structured
argsJSON = tc.Function.Arguments }
} case []any:
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { parts := make([]any, 0, len(structured))
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { for _, item := range structured {
argsJSON = string(encodedArgs) entry, ok := item.(map[string]any)
if !ok {
continue
}
msgType, _ := entry["type"].(string)
if strings.TrimSpace(msgType) == "" {
continue
}
parts = append(parts, entry)
}
if len(parts) > 0 {
return parts
}
} }
} }
return name, argsJSON
}
func parseMessageToolContent(argsJSON string) (string, bool) { var rawOptions []any
var args struct { switch optionsValue := args["options"].(type) {
Content string `json:"content"` case []any:
rawOptions = optionsValue
case map[string]any:
items, ok := optionsValue["options"].([]any)
if !ok || len(items) == 0 {
return nil
}
rawOptions = items
result := map[string]any{
"type": "options",
"options": []map[string]any{},
}
for _, key := range []string{"mode", "selectionMode", "selection_mode", "multiple", "multi", "multiSelect", "multi_select", "allowCustom", "allow_custom", "customPlaceholder", "custom_placeholder", "submitLabel", "submit_label"} {
if value, exists := optionsValue[key]; exists {
result[key] = value
}
}
options := make([]map[string]any, 0, len(rawOptions))
for _, item := range rawOptions {
entry, ok := item.(map[string]any)
if !ok {
continue
}
label, _ := entry["label"].(string)
value, _ := entry["value"].(string)
if strings.TrimSpace(label) == "" || strings.TrimSpace(value) == "" {
continue
}
option := map[string]any{
"label": label,
"value": value,
}
if description, _ := entry["description"].(string); strings.TrimSpace(description) != "" {
option["description"] = description
}
options = append(options, option)
}
if len(options) == 0 {
return nil
}
result["options"] = options
return result
default:
return nil
} }
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { if len(rawOptions) == 0 {
return "", false return nil
} }
if strings.TrimSpace(args.Content) == "" {
return "", false options := make([]map[string]any, 0, len(rawOptions))
for _, item := range rawOptions {
entry, ok := item.(map[string]any)
if !ok {
continue
}
label, _ := entry["label"].(string)
value, _ := entry["value"].(string)
if strings.TrimSpace(label) == "" || strings.TrimSpace(value) == "" {
continue
}
option := map[string]any{
"label": label,
"value": value,
}
if description, _ := entry["description"].(string); strings.TrimSpace(description) != "" {
option["description"] = description
}
options = append(options, option)
}
if len(options) == 0 {
return nil
}
return map[string]any{
"type": "options",
"options": options,
} }
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.

View file

@ -328,21 +328,25 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
var resp struct { var resp struct {
Messages []struct { Messages []struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Structured map[string]any `json:"structured"`
} `json:"messages"` } `json:"messages"`
} }
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) != 2 { if len(resp.Messages) != 3 {
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
} }
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "hello" { if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "hello" {
t.Fatalf("first message = %#v, want user/hello", resp.Messages[0]) t.Fatalf("first message = %#v, want user/hello", resp.Messages[0])
} }
if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "final visible answer" { if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "internal chain of thought" {
t.Fatalf("second message = %#v, want assistant/final visible answer", resp.Messages[1]) t.Fatalf("second message = %#v, want assistant/internal chain of thought", resp.Messages[1])
}
if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final visible answer" {
t.Fatalf("third message = %#v, want assistant/final visible answer", resp.Messages[2])
} }
} }
@ -395,8 +399,9 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSu
var resp struct { var resp struct {
Messages []struct { Messages []struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Structured map[string]any `json:"structured"`
} `json:"messages"` } `json:"messages"`
} }
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
@ -466,8 +471,9 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
var resp struct { var resp struct {
Messages []struct { Messages []struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Structured map[string]any `json:"structured"`
} `json:"messages"` } `json:"messages"`
} }
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
@ -487,6 +493,286 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t *
} }
} }
func TestHandleGetSession_HidesAssistantPreludeWhenToolCallsArePresent(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "detail-tool-prelude-hidden"
for _, msg := range []providers.Message{
{Role: "user", Content: "check workspace"},
{
Role: "assistant",
Content: "我来再次执行这个任务。",
ToolCalls: []providers.ToolCall{
{
ID: "call_1",
Type: "function",
Function: &providers.FunctionCall{
Name: "list_dir",
Arguments: `{}`,
},
},
},
},
{Role: "assistant", Content: "final assistant reply"},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-prelude-hidden", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
Structured map[string]any `json:"structured"`
} `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 resp.Messages[1].Structured["type"] != "progress" {
t.Fatalf("tool summary message = %#v, want structured progress card", resp.Messages[1])
}
if resp.Messages[2].Content != "final assistant reply" {
t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2])
}
for _, msg := range resp.Messages {
if msg.Content == "我来再次执行这个任务。" {
t.Fatalf("unexpected tool-call prelude content in visible transcript: %#v", resp.Messages)
}
}
}
func TestHandleGetSession_ReconstructsVisibleMessageToolOptions(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "detail-message-tool-options"
for _, msg := range []providers.Message{
{Role: "user", Content: "pick"},
{
Role: "assistant",
ToolCalls: []providers.ToolCall{
{
ID: "call_1",
Type: "function",
Function: &providers.FunctionCall{
Name: "message",
Arguments: `{"content":"Choose one:","options":[{"label":"A","value":"alpha"},{"label":"B","value":"beta"}]}`,
},
},
},
},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool-options", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
Structured struct {
Type string `json:"type"`
Options []struct {
Label string `json:"label"`
Value string `json:"value"`
} `json:"options"`
} `json:"structured"`
} `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 !strings.Contains(resp.Messages[1].Content, "`message`") {
t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1])
}
if resp.Messages[2].Structured.Type != "options" || len(resp.Messages[2].Structured.Options) != 2 {
t.Fatalf("structured = %#v, want options payload", resp.Messages[2].Structured)
}
}
func TestHandleGetSession_ReconstructsCustomCardStructuredPayload(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "detail-message-tool-custom-card"
for _, msg := range []providers.Message{
{Role: "user", Content: "show card"},
{
Role: "assistant",
ToolCalls: []providers.ToolCall{
{
ID: "call_1",
Type: "function",
Function: &providers.FunctionCall{
Name: "message",
Arguments: `{"content":"审批摘要","structured":{"type":"card","kind":"custom/approval-card","title":"待审批","blocks":[{"type":"text","text":"张三提交了请假申请"},{"type":"actions","actions":[{"label":"批准","value":"approve"},{"label":"拒绝","value":"reject"}]}]}}`,
},
},
},
},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool-custom-card", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []struct {
Content string `json:"content"`
Structured struct {
Type string `json:"type"`
Kind string `json:"kind"`
Title string `json:"title"`
Blocks []struct {
Type string `json:"type"`
} `json:"blocks"`
} `json:"structured"`
} `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 resp.Messages[2].Structured.Type != "card" {
t.Fatalf("structured type = %q, want card", resp.Messages[2].Structured.Type)
}
if resp.Messages[2].Structured.Kind != "custom/approval-card" {
t.Fatalf("structured kind = %q, want custom/approval-card", resp.Messages[2].Structured.Kind)
}
if len(resp.Messages[2].Structured.Blocks) != 2 {
t.Fatalf("blocks = %#v, want 2 blocks", resp.Messages[2].Structured.Blocks)
}
}
func TestHandleGetSession_ReconstructsStructuredPayloadList(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "detail-message-tool-structured-list"
for _, msg := range []providers.Message{
{Role: "user", Content: "show list"},
{
Role: "assistant",
ToolCalls: []providers.ToolCall{{
ID: "call_1",
Type: "function",
Function: &providers.FunctionCall{
Name: "message",
Arguments: `{"content":"组合消息","structured":[{"type":"progress","title":"同步中","status":"running"},{"type":"alert","level":"info","content":"等待确认"}]}`,
},
}},
},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-message-tool-structured-list", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []struct {
Structured []struct {
Type string `json:"type"`
} `json:"structured"`
} `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[2].Structured) != 2 {
t.Fatalf("structured = %#v, want 2 items", resp.Messages[2].Structured)
}
if resp.Messages[2].Structured[0].Type != "progress" || resp.Messages[2].Structured[1].Type != "alert" {
t.Fatalf("structured types = %#v, want progress/alert", resp.Messages[2].Structured)
}
}
func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) { func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -545,7 +831,7 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) {
} }
} }
func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) { func TestHandleGetSession_HidesAssistantContentWhenToolSummaryExists(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -593,8 +879,91 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T)
var resp struct { var resp struct {
Messages []struct { Messages []struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Structured map[string]any `json:"structured"`
} `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 resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" {
t.Fatalf("first message = %#v, want user/check file", resp.Messages[0])
}
if resp.Messages[1].Structured["type"] != "progress" || resp.Messages[1].Structured["title"] != "read_file" {
t.Fatalf("tool summary message = %#v, want structured read_file progress", resp.Messages[1])
}
if resp.Messages[1].Structured["status"] != "completed" {
t.Fatalf("tool summary status = %#v, want completed", resp.Messages[1].Structured)
}
if got := resp.Messages[1].Structured["content"]; got != "README.md:1-10" {
t.Fatalf("tool summary content = %#v, want README.md:1-10", got)
}
for _, msg := range resp.Messages {
if msg.Content == "model final reply" {
t.Fatalf("unexpected assistant tool-call content in visible transcript: %#v", resp.Messages)
}
}
}
func TestHandleGetSession_ToolSummaryUsesArgumentPreview(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := picoSessionPrefix + "detail-tool-summary-args"
for _, msg := range []providers.Message{
{Role: "user", Content: "inspect workspace"},
{
Role: "assistant",
ToolCalls: []providers.ToolCall{
{
ID: "call_1",
Type: "function",
Function: &providers.FunctionCall{
Name: "list_dir",
Arguments: `{"path":"/tmp/workspace"}`,
},
},
{
ID: "call_2",
Type: "function",
Function: &providers.FunctionCall{
Name: "fetch_webpage",
Arguments: `{"urls":["https://example.com"],"query":"release notes"}`,
},
},
},
},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-args", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []struct {
Structured map[string]any `json:"structured"`
} `json:"messages"` } `json:"messages"`
} }
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
@ -603,14 +972,11 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T)
if len(resp.Messages) != 3 { if len(resp.Messages) != 3 {
t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages))
} }
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" { if got := resp.Messages[1].Structured["content"]; got != "/tmp/workspace" {
t.Fatalf("first message = %#v, want user/check file", resp.Messages[0]) t.Fatalf("list_dir summary content = %#v, want /tmp/workspace", got)
} }
if !strings.Contains(resp.Messages[1].Content, "`read_file`") { if got := resp.Messages[2].Structured["content"]; got != "release notes | https://example.com" {
t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) t.Fatalf("fetch_webpage summary content = %#v, want release notes | https://example.com", got)
}
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 { for _, msg := range resp.Messages {
if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") { if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") {
@ -639,7 +1005,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
t.Fatalf("NewJSONLStore() error = %v", err) t.Fatalf("NewJSONLStore() error = %v", err)
} }
argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` argsJSON := `{"content":"abcdefghijklmnopqrstuvwxyz0123456789"}`
sessionKey := picoSessionPrefix + "detail-tool-summary-max-args" sessionKey := picoSessionPrefix + "detail-tool-summary-max-args"
err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"}) err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"})
if err != nil { if err != nil {
@ -651,7 +1017,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
ID: "call_1", ID: "call_1",
Type: "function", Type: "function",
Function: &providers.FunctionCall{ Function: &providers.FunctionCall{
Name: "read_file", Name: "message",
Arguments: argsJSON, Arguments: argsJSON,
}, },
}}, }},

View file

@ -14,7 +14,9 @@ export interface SessionDetail {
messages: { messages: {
role: "user" | "assistant" role: "user" | "assistant"
content: string content: string
kind?: "thought"
media?: string[] media?: string[]
structured?: Record<string, unknown> | Record<string, unknown>[]
}[] }[]
summary: string summary: string
created: string created: string

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,11 @@
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react" import { IconArrowUp, IconChecklist, IconMessageCircleQuestion, IconPhotoPlus, IconSparkles, IconX } from "@tabler/icons-react"
import type { KeyboardEvent } from "react" import type { KeyboardEvent } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize" import TextareaAutosize from "react-textarea-autosize"
import { ContextUsageRing } from "@/components/chat/context-usage-ring"
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, ContextUsage } from "@/store/chat" import type { ChatAttachment, ChatInteractionMode } from "@/store/chat"
export type ChatInputDisabledReason = export type ChatInputDisabledReason =
| "gatewayUnknown" | "gatewayUnknown"
@ -28,27 +22,27 @@ export type ChatInputDisabledReason =
interface ChatComposerProps { interface ChatComposerProps {
input: string input: string
attachments: ChatAttachment[] attachments: ChatAttachment[]
mode: ChatInteractionMode
onInputChange: (value: string) => void onInputChange: (value: string) => void
onModeChange: (mode: ChatInteractionMode) => void
onAddImages: () => void onAddImages: () => void
onRemoveAttachment: (index: number) => void onRemoveAttachment: (index: number) => void
onSend: () => void onSend: () => void
onContextDetail?: () => void
inputDisabledReason: ChatInputDisabledReason | null inputDisabledReason: ChatInputDisabledReason | null
canSend: boolean canSend: boolean
contextUsage?: ContextUsage
} }
export function ChatComposer({ export function ChatComposer({
input, input,
attachments, attachments,
mode,
onInputChange, onInputChange,
onModeChange,
onAddImages, onAddImages,
onRemoveAttachment, onRemoveAttachment,
onSend, onSend,
onContextDetail,
inputDisabledReason, inputDisabledReason,
canSend, canSend,
contextUsage,
}: ChatComposerProps) { }: ChatComposerProps) {
const { t } = useTranslation() const { t } = useTranslation()
const canInput = inputDisabledReason === null const canInput = inputDisabledReason === null
@ -66,15 +60,52 @@ export function ChatComposer({
} }
} }
const modeOptions: Array<{
value: ChatInteractionMode
label: string
icon: typeof IconSparkles
}> = [
{ value: "agent", label: t("chat.mode.agent"), icon: IconSparkles },
{ value: "ask", label: t("chat.mode.ask"), icon: IconMessageCircleQuestion },
{ value: "plan", label: t("chat.mode.plan"), icon: IconChecklist },
]
return ( return (
<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="shrink-0 px-3 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] md:px-5 md:pb-5">
<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"> <div className="bg-background mx-auto flex max-w-[880px] flex-col gap-3 rounded-xl border border-border/70 px-3 py-3 shadow-sm">
<div className="flex flex-wrap items-center gap-1.5 px-1">
{modeOptions.map((option) => {
const Icon = option.icon
const active = mode === option.value
return (
<Button
key={option.value}
type="button"
variant={active ? "secondary" : "ghost"}
size="sm"
className={cn(
"h-8 gap-2 rounded-md px-3 text-xs",
active && "bg-foreground text-background hover:bg-foreground/90 hover:text-background",
)}
onClick={() => onModeChange(option.value)}
disabled={!canInput}
>
<Icon className="size-3.5" />
<span>{option.label}</span>
</Button>
)
})}
<span className="text-muted-foreground ml-auto hidden text-xs md:inline">
{t(`chat.modeHint.${mode}`)}
</span>
</div>
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-2"> <div className="flex flex-wrap gap-2 px-1">
{attachments.map((attachment, index) => ( {attachments.map((attachment, index) => (
<div <div
key={`${attachment.url}-${index}`} key={`${attachment.url}-${index}`}
className="bg-background relative h-20 w-20 overflow-hidden rounded-xl border" className="bg-muted/30 relative h-20 w-20 overflow-hidden rounded-lg border border-border/70"
> >
<img <img
src={attachment.url} src={attachment.url}
@ -84,7 +115,7 @@ export function ChatComposer({
<button <button
type="button" type="button"
onClick={() => onRemoveAttachment(index)} onClick={() => onRemoveAttachment(index)}
className="bg-background/85 text-foreground absolute top-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm transition hover:bg-white" className="bg-background/90 text-foreground absolute top-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/70 shadow-sm transition hover:bg-accent"
aria-label={t("chat.removeImage")} aria-label={t("chat.removeImage")}
title={t("chat.removeImage")} title={t("chat.removeImage")}
> >
@ -103,20 +134,25 @@ 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-[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", "placeholder:text-muted-foreground/70 max-h-[220px] min-h-[72px] resize-none border-0 bg-transparent px-1 py-1 text-[14px] leading-6 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-1 py-1 text-xs">
{disabledMessage}
</div>
)}
<div className="mt-2 flex items-center justify-between px-1"> <div className="flex items-center justify-between border-t border-border/60 pt-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full" className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-md"
onClick={onAddImages} onClick={onAddImages}
disabled={!canInput} disabled={!canInput}
aria-label={t("chat.attachImage")} aria-label={t("chat.attachImage")}
@ -124,37 +160,23 @@ export function ChatComposer({
> >
<IconPhotoPlus className="size-4" /> <IconPhotoPlus className="size-4" />
</Button> </Button>
<span className="text-muted-foreground hidden text-xs md:inline">
Enter to send, Shift+Enter for newline
</span>
</div> </div>
<div className="flex items-center gap-1.5"> {canInput ? (
{contextUsage && ( <Button
<ContextUsageRing usage={contextUsage} onDetailClick={onContextDetail} /> type="button"
)} size="sm"
{canInput ? ( className="h-8 gap-2 rounded-md bg-foreground px-3 text-background transition-colors hover:bg-foreground/85"
<Tooltip delayDuration={700}> onClick={onSend}
<TooltipTrigger asChild> disabled={!canSend}
<span tabIndex={!canSend ? 0 : undefined}> >
<Button <IconArrowUp className="size-4" />
type="button" <span>{t("chat.send")}</span>
size="icon" </Button>
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95" ) : null}
onClick={onSend}
disabled={!canSend}
aria-label={t("chat.sendMessage")}
>
<IconArrowUp className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent
className="border-border/70 bg-muted text-foreground border text-center whitespace-pre-line shadow-lg shadow-black/10 dark:shadow-black/30"
arrowClassName="bg-muted fill-muted"
>
{t("chat.sendHint")}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
import { IconPlus } from "@tabler/icons-react" import { IconPlus } from "@tabler/icons-react"
import { type ChangeEvent, useEffect, useRef, useState } from "react" import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { toast } from "sonner" import { toast } from "sonner"
@ -19,8 +19,15 @@ import { useChatModels } from "@/hooks/use-chat-models"
import { useGateway } from "@/hooks/use-gateway" import { useGateway } from "@/hooks/use-gateway"
import { usePicoChat } from "@/hooks/use-pico-chat" import { usePicoChat } from "@/hooks/use-pico-chat"
import { useSessionHistory } from "@/hooks/use-session-history" import { useSessionHistory } from "@/hooks/use-session-history"
import type { ConnectionState } from "@/store/chat" import type {
import type { ChatAttachment } from "@/store/chat" ChatAttachment,
ChatInteractionMode,
ChatMessage,
ChatStructuredContent,
ChatStructuredProgress,
ChatStructuredValue,
ConnectionState,
} from "@/store/chat"
import type { GatewayState } from "@/store/gateway" import type { GatewayState } from "@/store/gateway"
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024 const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
@ -33,6 +40,92 @@ const ALLOWED_IMAGE_TYPES = new Set([
"image/bmp", "image/bmp",
]) ])
function flattenStructuredParts(
structured: ChatStructuredValue | undefined,
): ChatStructuredContent[] {
if (!structured) {
return []
}
return Array.isArray(structured) ? structured : [structured]
}
function isToolProgressPart(
part: ChatStructuredContent,
): part is ChatStructuredProgress {
return part.type === "progress" && part.kind === "agent/tool-exec"
}
function isProgressOnlyAssistantMessage(message: ChatMessage): boolean {
if (message.role !== "assistant" || message.kind === "thought") {
return false
}
if (message.content.trim() || (message.attachments?.length ?? 0) > 0) {
return false
}
const parts = flattenStructuredParts(message.structured)
return parts.length > 0 && parts.every(isToolProgressPart)
}
function isMergeableAssistantResultMessage(message: ChatMessage): boolean {
return message.role === "assistant" && message.kind !== "thought"
}
function mergeStructuredValues(
left: ChatStructuredValue | undefined,
right: ChatStructuredValue | undefined,
): ChatStructuredValue | undefined {
const merged = [...flattenStructuredParts(left), ...flattenStructuredParts(right)]
if (merged.length === 0) {
return undefined
}
return merged
}
function aggregateRenderableMessages(messages: ChatMessage[]): ChatMessage[] {
const aggregated: ChatMessage[] = []
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index]
if (!isProgressOnlyAssistantMessage(message)) {
aggregated.push(message)
continue
}
let mergedMessage = message
let cursor = index + 1
while (cursor < messages.length && isProgressOnlyAssistantMessage(messages[cursor])) {
const progressMessage = messages[cursor]
mergedMessage = {
...mergedMessage,
id: `${mergedMessage.id}__${progressMessage.id}`,
structured: mergeStructuredValues(mergedMessage.structured, progressMessage.structured),
timestamp: progressMessage.timestamp,
}
cursor += 1
}
const nextMessage = messages[cursor]
if (nextMessage && isMergeableAssistantResultMessage(nextMessage)) {
mergedMessage = {
...nextMessage,
id: `${mergedMessage.id}__${nextMessage.id}`,
structured: mergeStructuredValues(mergedMessage.structured, nextMessage.structured),
}
index = cursor
} else {
index = cursor - 1
}
aggregated.push(mergedMessage)
}
return aggregated
}
function readFileAsDataUrl(file: File): Promise<string> { function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
@ -109,13 +202,13 @@ export function ChatPage() {
const [hasScrolled, setHasScrolled] = useState(false) const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<ChatAttachment[]>([]) const [attachments, setAttachments] = useState<ChatAttachment[]>([])
const [mode, setMode] = useState<ChatInteractionMode>("agent")
const { const {
messages, messages,
connectionState, connectionState,
isTyping, isTyping,
activeSessionId, activeSessionId,
contextUsage,
sendMessage, sendMessage,
switchSession, switchSession,
newChat, newChat,
@ -154,7 +247,7 @@ export function ChatPage() {
}) })
const syncScrollState = (element: HTMLDivElement) => { const syncScrollState = (element: HTMLDivElement) => {
const { clientHeight, scrollHeight, scrollTop } = element const { scrollTop, scrollHeight, clientHeight } = element
setHasScrolled(scrollTop > 0) setHasScrolled(scrollTop > 0)
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
} }
@ -178,6 +271,7 @@ export function ChatPage() {
sendMessage({ sendMessage({
content: input, content: input,
attachments, attachments,
mode,
}) })
) { ) {
setInput("") setInput("")
@ -185,6 +279,11 @@ export function ChatPage() {
} }
} }
const handleSelectOption = (value: string) => {
if (!canInput) return
sendMessage({ content: value, mode })
}
const handleAddImages = () => { const handleAddImages = () => {
if (!canInput) return if (!canInput) return
fileInputRef.current?.click() fileInputRef.current?.click()
@ -245,13 +344,17 @@ export function ChatPage() {
const canSubmit = const canSubmit =
canInput && (Boolean(input.trim()) || attachments.length > 0) canInput && (Boolean(input.trim()) || attachments.length > 0)
const renderMessages = useMemo(
() => aggregateRenderableMessages(messages),
[messages],
)
return ( return (
<div className="bg-background/95 flex h-full flex-col"> <div className="bg-muted/35 flex h-full min-h-0 flex-col">
<PageHeader <PageHeader
title={t("navigation.chat")} title={t("navigation.chat")}
className={`transition-shadow ${ className={`transition-shadow ${
hasScrolled ? "shadow-xs" : "shadow-none" hasScrolled ? "border-border/60 bg-background/90 shadow-xs backdrop-blur" : "border-transparent bg-transparent shadow-none"
}`} }`}
titleExtra={ titleExtra={
hasAvailableModels && ( hasAvailableModels && (
@ -292,13 +395,15 @@ export function ChatPage() {
/> />
</PageHeader> </PageHeader>
<div <div className="min-h-0 flex-1 px-2 pb-2 md:px-4 md:pb-4">
ref={scrollRef} <div className="bg-background/92 ring-border/60 mx-auto flex h-full w-full max-w-[1380px] min-h-0 flex-col overflow-hidden rounded-2xl border shadow-sm ring-1">
onScroll={handleScroll} <div
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" ref={scrollRef}
> onScroll={handleScroll}
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8"> className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10"
{messages.length === 0 && !isTyping && ( >
<div className="mx-auto flex w-full max-w-[880px] flex-col gap-5 pb-12">
{renderMessages.length === 0 && !isTyping && (
<ChatEmptyState <ChatEmptyState
hasAvailableModels={hasAvailableModels} hasAvailableModels={hasAvailableModels}
defaultModelName={defaultModelName} defaultModelName={defaultModelName}
@ -306,24 +411,47 @@ export function ChatPage() {
/> />
)} )}
{messages.map((msg) => ( {renderMessages.map((msg) => (
<div key={msg.id} className="flex w-full"> <div
key={msg.id}
className="flex w-full"
>
{msg.role === "assistant" ? ( {msg.role === "assistant" ? (
<AssistantMessage <AssistantMessage
content={msg.content} content={msg.content}
isThought={msg.kind === "thought"} isThought={msg.kind === "thought"}
timestamp={msg.timestamp} timestamp={msg.timestamp}
structured={msg.structured}
onSelectOption={handleSelectOption}
/> />
) : ( ) : (
<UserMessage <UserMessage
content={msg.content} content={msg.content}
attachments={msg.attachments} attachments={msg.attachments}
timestamp={msg.timestamp}
/> />
)} )}
</div> </div>
))} ))}
{isTyping && <TypingIndicator />} {isTyping && <TypingIndicator />}
</div>
</div>
<div className="border-border/70 bg-background/96 border-t">
<ChatComposer
input={input}
attachments={attachments}
mode={mode}
onInputChange={setInput}
onModeChange={setMode}
onAddImages={handleAddImages}
onRemoveAttachment={handleRemoveAttachment}
onSend={handleSend}
inputDisabledReason={inputDisabledReason}
canSend={canSubmit}
/>
</div>
</div> </div>
</div> </div>
@ -334,23 +462,6 @@ export function ChatPage() {
className="hidden" className="hidden"
onChange={handleImageSelection} onChange={handleImageSelection}
/> />
<ChatComposer
input={input}
attachments={attachments}
onInputChange={setInput}
onAddImages={handleAddImages}
onRemoveAttachment={handleRemoveAttachment}
onSend={handleSend}
onContextDetail={() => {
if (sendMessage({ content: "/context", attachments: [] })) {
setInput("")
}
}}
inputDisabledReason={inputDisabledReason}
canSend={canSubmit}
contextUsage={contextUsage}
/>
</div> </div>
) )
} }

View file

@ -20,24 +20,35 @@ export function TypingIndicator() {
}, [thinkingSteps.length]) }, [thinkingSteps.length])
return ( return (
<div className="flex w-full flex-col gap-1.5"> <div className="flex w-full max-w-[820px] gap-3">
<div className="bg-card border-border/50 inline-flex w-fit max-w-xs flex-col gap-3 rounded-xl border px-5 py-4"> <div className="bg-muted text-muted-foreground mt-5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/70 text-[11px] font-semibold uppercase">
<div className="flex items-center gap-1.5"> AI
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.3s]" /> </div>
<span className="size-2 animate-bounce rounded-full bg-violet-400/70 [animation-delay:-0.15s]" /> <div className="flex min-w-0 flex-1 flex-col gap-2">
<span className="size-2 animate-bounce rounded-full bg-violet-400/70" /> <div className="text-muted-foreground flex items-center gap-2 text-[11px] uppercase tracking-[0.14em]">
<span>PicoClaw</span>
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] tracking-normal normal-case">
Thinking
</span>
</div> </div>
<div className="bg-card inline-flex w-fit min-w-56 max-w-md flex-col gap-3 rounded-xl border border-border/70 px-4 py-3 shadow-sm">
<div className="flex items-center gap-1.5">
<span className="size-2 animate-bounce rounded-full bg-foreground/55 [animation-delay:-0.3s]" />
<span className="size-2 animate-bounce rounded-full bg-foreground/55 [animation-delay:-0.15s]" />
<span className="size-2 animate-bounce rounded-full bg-foreground/55" />
</div>
<div className="bg-muted relative h-1 w-36 overflow-hidden rounded-full"> <div className="bg-muted relative h-1 w-40 overflow-hidden rounded-full">
<div className="absolute inset-0 animate-[shimmer_2s_infinite] rounded-full bg-gradient-to-r from-violet-500/60 via-violet-400/80 to-violet-500/60 bg-[length:200%_100%]" /> <div className="absolute inset-0 animate-[shimmer_2s_infinite] rounded-full bg-gradient-to-r from-foreground/35 via-foreground/65 to-foreground/35 bg-[length:200%_100%]" />
</div>
<p
key={stepIndex}
className="text-muted-foreground animate-[fadeSlideIn_0.4s_ease-out] text-xs leading-5"
>
{thinkingSteps[stepIndex]}
</p>
</div> </div>
<p
key={stepIndex}
className="text-muted-foreground animate-[fadeSlideIn_0.4s_ease-out] text-xs"
>
{thinkingSteps[stepIndex]}
</p>
</div> </div>
</div> </div>
) )

View file

@ -1,54 +1,57 @@
import { cn } from "@/lib/utils" import { formatMessageTime } from "@/hooks/use-pico-chat"
import type { ChatAttachment } from "@/store/chat" import type { ChatAttachment } from "@/store/chat"
interface UserMessageProps { interface UserMessageProps {
content: string content: string
attachments?: ChatAttachment[] attachments?: ChatAttachment[]
timestamp?: string | number
} }
export function UserMessage({ content, attachments = [] }: UserMessageProps) { export function UserMessage({
content,
attachments = [],
timestamp,
}: 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",
) )
const formattedTimestamp = timestamp ? formatMessageTime(timestamp) : ""
return ( return (
<div className="flex w-full flex-col items-end gap-1.5"> <div className="ml-auto flex w-full max-w-[820px] justify-end gap-3">
<div className="flex min-w-0 max-w-[80%] flex-col items-end gap-2">
<div className="text-muted-foreground flex items-center gap-2 text-[11px] uppercase tracking-[0.14em]">
<span>You</span>
{formattedTimestamp ? <span className="opacity-60">{formattedTimestamp}</span> : null}
</div>
{imageAttachments.length > 0 && ( {imageAttachments.length > 0 && (
<div className="flex max-w-[70%] flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
{imageAttachments.map((attachment, index) => ( {imageAttachments.map((attachment, index) => (
<img <div
key={`${attachment.url}-${index}`} key={`${attachment.url}-${index}`}
src={attachment.url} className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm"
alt={attachment.filename || "Uploaded image"} >
className="max-h-72 max-w-full object-cover" <img
/> src={attachment.url}
alt={attachment.filename || "Uploaded image"}
className="max-h-72 max-w-full object-cover"
/>
</div>
))} ))}
</div> </div>
)} )}
{hasText && ( {hasText && (
<div <div className="w-full rounded-xl border border-blue-200/70 bg-blue-50/70 px-4 py-3 text-[14px] leading-6 wrap-break-word whitespace-pre-wrap text-slate-900 shadow-sm dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-slate-100">
className={cn( {content}
"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 className="bg-muted text-muted-foreground mt-5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/70 text-[11px] font-semibold uppercase">
You
</div>
</div> </div>
) )
} }

View file

@ -14,6 +14,7 @@ import {
import { invalidateSocket, isCurrentSocket } from "@/features/chat/websocket" import { invalidateSocket, isCurrentSocket } from "@/features/chat/websocket"
import i18n from "@/i18n" import i18n from "@/i18n"
import { import {
type ChatInteractionMode,
type ChatAttachment, type ChatAttachment,
getChatState, getChatState,
updateChatStore, updateChatStore,
@ -33,6 +34,8 @@ let connectionGeneration = 0
let reconnectTimer: number | null = null let reconnectTimer: number | null = null
let reconnectAttempts = 0 let reconnectAttempts = 0
let shouldMaintainConnection = false let shouldMaintainConnection = false
let refreshFromHistoryPromise: Promise<void> | null = null
let refreshFromHistoryTimer: number | null = null
function clearReconnectTimer() { function clearReconnectTimer() {
if (reconnectTimer !== null) { if (reconnectTimer !== null) {
@ -41,6 +44,21 @@ function clearReconnectTimer() {
} }
} }
function clearRefreshFromHistoryTimer() {
if (refreshFromHistoryTimer !== null) {
window.clearTimeout(refreshFromHistoryTimer)
refreshFromHistoryTimer = null
}
}
function scheduleRefreshFromHistory(delayMs: number) {
clearRefreshFromHistoryTimer()
refreshFromHistoryTimer = window.setTimeout(() => {
refreshFromHistoryTimer = null
void refreshActiveSessionFromHistory()
}, delayMs)
}
function shouldReconnectFor(generation: number, sessionId: string): boolean { function shouldReconnectFor(generation: number, sessionId: string): boolean {
return ( return (
shouldMaintainConnection && shouldMaintainConnection &&
@ -89,6 +107,7 @@ function disconnectChatInternal({
}) { }) {
connectionGeneration += 1 connectionGeneration += 1
clearReconnectTimer() clearReconnectTimer()
clearRefreshFromHistoryTimer()
if (clearDesiredConnection) { if (clearDesiredConnection) {
shouldMaintainConnection = false shouldMaintainConnection = false
@ -182,7 +201,11 @@ export async function connectChat() {
try { try {
const message = JSON.parse(event.data) as PicoMessage const message = JSON.parse(event.data) as PicoMessage
handlePicoMessage(message, sessionId) const result = handlePicoMessage(message, sessionId)
if (result.shouldRefreshHistory) {
const delayMs = message.type === "typing.stop" ? 250 : 1200
scheduleRefreshFromHistory(delayMs)
}
} catch { } catch {
console.warn("Non-JSON message from pico:", event.data) console.warn("Non-JSON message from pico:", event.data)
} }
@ -318,11 +341,13 @@ export async function hydrateActiveSession() {
interface SendChatMessageInput { interface SendChatMessageInput {
content: string content: string
attachments?: ChatAttachment[] attachments?: ChatAttachment[]
mode?: ChatInteractionMode
} }
export function sendChatMessage({ export function sendChatMessage({
content, content,
attachments = [], attachments = [],
mode = "agent",
}: SendChatMessageInput) { }: SendChatMessageInput) {
if (!wsRef || wsRef.readyState !== WebSocket.OPEN) { if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
console.warn("WebSocket not connected") console.warn("WebSocket not connected")
@ -364,6 +389,7 @@ export function sendChatMessage({
payload: { payload: {
content: normalizedContent, content: normalizedContent,
media: normalizedAttachments.map((attachment) => attachment.url), media: normalizedAttachments.map((attachment) => attachment.url),
mode,
}, },
}), }),
) )
@ -378,6 +404,34 @@ export function sendChatMessage({
} }
} }
async function refreshActiveSessionFromHistory() {
if (refreshFromHistoryPromise) {
return refreshFromHistoryPromise
}
const sessionId = activeSessionIdRef
refreshFromHistoryPromise = loadSessionMessages(sessionId)
.then((historyMessages) => {
const currentState = getChatState()
if (currentState.activeSessionId !== sessionId) {
return
}
updateChatStore({
messages: historyMessages,
hasHydratedActiveSession: true,
})
})
.catch((error) => {
console.error("Failed to refresh active session history:", error)
})
.finally(() => {
refreshFromHistoryPromise = null
})
return refreshFromHistoryPromise
}
export async function switchChatSession(sessionId: string) { export async function switchChatSession(sessionId: string) {
if (sessionId === activeSessionIdRef) { if (sessionId === activeSessionIdRef) {
return return

View file

@ -1,4 +1,5 @@
import { getSessionHistory } from "@/api/sessions" import { getSessionHistory } from "@/api/sessions"
import { parseStructuredContent } from "@/features/chat/structured"
import { normalizeUnixTimestamp } from "@/features/chat/state" import { normalizeUnixTimestamp } from "@/features/chat/state"
import type { ChatAttachment, ChatMessage } from "@/store/chat" import type { ChatAttachment, ChatMessage } from "@/store/chat"
@ -24,8 +25,14 @@ export async function loadSessionMessages(
id: `hist-${index}-${Date.now()}`, id: `hist-${index}-${Date.now()}`,
role: message.role, role: message.role,
content: message.content, content: message.content,
kind: message.role === "assistant" ? "normal" : undefined, kind:
message.role === "assistant"
? message.kind === "thought"
? "thought"
: "normal"
: undefined,
attachments: toChatAttachments(message.media), attachments: toChatAttachments(message.media),
structured: parseStructuredContent(message.structured),
timestamp: fallbackTime, timestamp: fallbackTime,
})) }))
} }
@ -48,10 +55,13 @@ function messageSignature(message: ChatMessage): string {
const attachmentSignature = (message.attachments ?? []) const attachmentSignature = (message.attachments ?? [])
.map((attachment) => `${attachment.type}\u0001${attachment.url}`) .map((attachment) => `${attachment.type}\u0001${attachment.url}`)
.join("\u0002") .join("\u0002")
const structuredSignature = message.structured
? JSON.stringify(message.structured)
: ""
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp( return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(
message.timestamp, message.timestamp,
)}\u0000${message.kind ?? ""}\u0000${attachmentSignature}` )}\u0000${message.kind ?? ""}\u0000${attachmentSignature}\u0000${structuredSignature}`
} }
function comparableTimestamp(timestamp: number | string): number { function comparableTimestamp(timestamp: number | string): number {

View file

@ -1,7 +1,12 @@
import { toast } from "sonner" import { toast } from "sonner"
import {
inferStructuredContentFromText,
parseStructuredContent,
} from "@/features/chat/structured"
import { normalizeUnixTimestamp } from "@/features/chat/state" import { normalizeUnixTimestamp } from "@/features/chat/state"
import { import {
getChatState,
type AssistantMessageKind, type AssistantMessageKind,
type ContextUsage, type ContextUsage,
updateChatStore, updateChatStore,
@ -15,6 +20,10 @@ export interface PicoMessage {
payload?: Record<string, unknown> payload?: Record<string, unknown>
} }
export interface PicoMessageHandleResult {
shouldRefreshHistory: boolean
}
function parseAssistantMessageKind( function parseAssistantMessageKind(
payload: Record<string, unknown>, payload: Record<string, unknown>,
): AssistantMessageKind { ): AssistantMessageKind {
@ -46,9 +55,9 @@ function parseContextUsage(
export function handlePicoMessage( export function handlePicoMessage(
message: PicoMessage, message: PicoMessage,
expectedSessionId: string, expectedSessionId: string,
) { ): PicoMessageHandleResult {
if (message.session_id && message.session_id !== expectedSessionId) { if (message.session_id && message.session_id !== expectedSessionId) {
return return { shouldRefreshHistory: false }
} }
const payload = message.payload || {} const payload = message.payload || {}
@ -59,6 +68,10 @@ export function handlePicoMessage(
const messageId = (payload.message_id as string) || `pico-${Date.now()}` const messageId = (payload.message_id as string) || `pico-${Date.now()}`
const kind = parseAssistantMessageKind(payload) const kind = parseAssistantMessageKind(payload)
const contextUsage = parseContextUsage(payload) const contextUsage = parseContextUsage(payload)
const wasTyping = getChatState().isTyping
const structured =
parseStructuredContent(payload.structured) ??
(kind === "normal" ? inferStructuredContentFromText(content) : undefined)
const timestamp = const timestamp =
message.timestamp !== undefined && message.timestamp !== undefined &&
Number.isFinite(Number(message.timestamp)) Number.isFinite(Number(message.timestamp))
@ -73,20 +86,34 @@ export function handlePicoMessage(
role: "assistant", role: "assistant",
content, content,
kind, kind,
structured,
timestamp, timestamp,
}, },
], ],
isTyping: false, isTyping: false,
...(contextUsage ? { contextUsage } : {}), ...(contextUsage ? { contextUsage } : {}),
})) }))
break
return {
shouldRefreshHistory:
kind === "normal" &&
!structured &&
content.trim().length > 0 &&
wasTyping,
}
} }
case "message.update": { case "message.update": {
const content = (payload.content as string) || "" const hasContent = typeof payload.content === "string"
const content = hasContent ? ((payload.content as string) || "") : ""
const messageId = payload.message_id as string const messageId = payload.message_id as string
const hasKind = hasAssistantKindPayload(payload) const hasKind = hasAssistantKindPayload(payload)
const kind = parseAssistantMessageKind(payload) const kind = parseAssistantMessageKind(payload)
const structured =
parseStructuredContent(payload.structured) ??
(hasContent && kind === "normal"
? inferStructuredContentFromText(content)
: undefined)
if (!messageId) { if (!messageId) {
break break
} }
@ -96,22 +123,23 @@ export function handlePicoMessage(
msg.id === messageId msg.id === messageId
? { ? {
...msg, ...msg,
content, ...(hasContent ? { content } : {}),
...(hasKind ? { kind } : {}), ...(hasKind ? { kind } : {}),
...(structured ? { structured } : {}),
} }
: msg, : msg,
), ),
})) }))
break return { shouldRefreshHistory: false }
} }
case "typing.start": case "typing.start":
updateChatStore({ isTyping: true }) updateChatStore({ isTyping: true })
break return { shouldRefreshHistory: false }
case "typing.stop": case "typing.stop":
updateChatStore({ isTyping: false }) updateChatStore({ isTyping: false })
break return { shouldRefreshHistory: true }
case "error": { case "error": {
const requestId = const requestId =
@ -129,13 +157,16 @@ export function handlePicoMessage(
: prev.messages, : prev.messages,
isTyping: false, isTyping: false,
})) }))
break return { shouldRefreshHistory: false }
} }
case "pong": case "pong":
break return { shouldRefreshHistory: false }
default: default:
console.log("Unknown pico message type:", message.type) console.log("Unknown pico message type:", message.type)
return { shouldRefreshHistory: false }
} }
return { shouldRefreshHistory: false }
} }

View file

@ -0,0 +1,592 @@
import type {
ChatActionItem,
ChatCardBlock,
ChatFieldItem,
ChatFormField,
ChatListItem,
ChatProgressStep,
ChatTodoItem,
ChatStructuredContent,
ChatStructuredValue,
} from "@/store/chat"
const planHeadingRe = /^\s*#{1,6}\s+(.+?)\s*$/
const planCheckboxRe = /^\s*(?:[-*+]|\d+[.)])\s*\[\s*(x|X)?\s*\]\s*(.+?)\s*$/
const planBulletRe = /^\s*[-*+]\s+(.+?)\s*$/
const planNumberRe = /^\s*\d+[.)]\s+(.+?)\s*$/
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined
}
function asBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined
}
function firstString(record: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = asString(record[key])
if (value) {
return value
}
}
return undefined
}
function inferStructuredType(record: Record<string, unknown>): string | undefined {
const explicitType = asString(record.type)
if (explicitType) {
return explicitType
}
if (Array.isArray(record.options)) {
return "options"
}
if (Array.isArray(record.items)) {
return "todo"
}
if (Array.isArray(record.steps) || record.progress !== undefined) {
return "progress"
}
if (firstString(record, ["level", "severity", "statusLevel"])) {
return "alert"
}
if (Array.isArray(record.fields)) {
return "form"
}
if (Array.isArray(record.blocks) || Array.isArray(record.actions)) {
return "card"
}
return undefined
}
function inferOptionsMode(record: Record<string, unknown>): "single" | "multiple" {
const explicitMode = firstString(record, [
"mode",
"selectionMode",
"selection_mode",
])
if (explicitMode === "multiple") {
return "multiple"
}
if (
asBoolean(record.multiple) === true ||
asBoolean(record.multi) === true ||
asBoolean(record.multiSelect) === true ||
asBoolean(record.multi_select) === true
) {
return "multiple"
}
return "single"
}
function parseActionItem(value: unknown): ChatActionItem | null {
const record = asRecord(value)
if (!record) {
return null
}
const label = asString(record.label)
if (!label) {
return null
}
const variant = asString(record.variant)
return {
label,
type: asString(record.type),
action: asString(record.action),
value: asString(record.value),
url: asString(record.url),
variant:
variant === "default" ||
variant === "outline" ||
variant === "secondary" ||
variant === "ghost"
? variant
: undefined,
}
}
function parseFieldItem(value: unknown): ChatFieldItem | null {
const record = asRecord(value)
if (!record) {
return null
}
const label = asString(record.label)
const fieldValue = asString(record.value)
if (!label || !fieldValue) {
return null
}
return { label, value: fieldValue }
}
function parseListItem(value: unknown): ChatListItem | null {
if (typeof value === "string" && value.trim()) {
return { text: value }
}
const record = asRecord(value)
if (!record) {
return null
}
const text = asString(record.text)
if (!text) {
return null
}
return { text, label: asString(record.label) }
}
function parseProgressStep(value: unknown): ChatProgressStep | null {
const record = asRecord(value)
if (!record) {
return null
}
const label = asString(record.label)
if (!label) {
return null
}
return {
label,
status: asString(record.status),
detail: asString(record.detail),
}
}
function parseTodoItem(value: unknown): ChatTodoItem | null {
const record = asRecord(value)
if (!record) {
return null
}
const title = firstString(record, ["title", "label", "text", "step"])
if (!title) {
return null
}
const status = asString(record.status)
return {
id: asString(record.id),
title,
detail: firstString(record, ["detail", "description", "message"]),
status:
status === "not-started" ||
status === "in-progress" ||
status === "completed"
? status
: undefined,
}
}
function parseFormField(value: unknown): ChatFormField | null {
const record = asRecord(value)
if (!record) {
return null
}
const name = asString(record.name)
const label = asString(record.label)
if (!name || !label) {
return null
}
return {
name,
label,
fieldType: asString(record.fieldType) ?? asString(record.type),
value: asString(record.value),
placeholder: asString(record.placeholder),
required: record.required === true,
}
}
function parseCardBlock(value: unknown): ChatCardBlock | null {
const record = asRecord(value)
if (!record) {
return null
}
const type = asString(record.type)
if (!type) {
return null
}
switch (type) {
case "text":
case "markdown": {
const text = asString(record.text)
return text ? { type, text } : null
}
case "fields": {
const items = Array.isArray(record.fields)
? record.fields
.map((item) => parseFieldItem(item))
.filter((item): item is NonNullable<typeof item> => item !== null)
: []
return items.length > 0 ? { type, fields: items } : null
}
case "badge": {
const label = asString(record.label)
return label ? { type, label, status: asString(record.status) } : null
}
case "actions": {
const actions = Array.isArray(record.actions)
? record.actions
.map((item) => parseActionItem(item))
.filter((item): item is NonNullable<typeof item> => item !== null)
: []
return actions.length > 0 ? { type, actions } : null
}
case "list": {
const items = Array.isArray(record.items)
? record.items
.map((item) => parseListItem(item))
.filter((item): item is NonNullable<typeof item> => item !== null)
: []
return items.length > 0 ? { type, items } : null
}
case "table": {
const rows = Array.isArray(record.rows)
? record.rows
.map((row) =>
Array.isArray(row)
? row
.map((cell) => (typeof cell === "string" ? cell : String(cell)))
.filter((cell) => cell.length > 0)
: [],
)
.filter((row) => row.length > 0)
: []
if (rows.length === 0) {
return null
}
const headers = Array.isArray(record.headers)
? record.headers.map((item) => String(item))
: undefined
return { type, headers, rows }
}
case "image": {
const url = asString(record.url)
return url ? { type, url, alt: asString(record.alt) } : null
}
case "divider":
return { type }
case "json":
return { type, data: record.data }
default:
return { type: "unknown", blockType: type, raw: record }
}
}
function parseSingleStructuredContent(
structured: unknown,
): ChatStructuredContent | undefined {
const record = asRecord(structured)
if (!record) {
return undefined
}
const type = inferStructuredType(record)
if (!type) {
return undefined
}
switch (type) {
case "options": {
const options = Array.isArray(record.options)
? record.options
.map((item) => {
const entry = asRecord(item)
if (!entry) {
return null
}
const label = asString(entry.label)
const value = asString(entry.value)
if (!label || !value) {
return null
}
return {
label,
value,
description: asString(entry.description),
}
})
.filter((item): item is NonNullable<typeof item> => item !== null)
: []
return options.length > 0
? {
type,
options,
mode: inferOptionsMode(record),
allowCustom:
asBoolean(record.allowCustom) ??
asBoolean(record.allow_custom) ??
asBoolean(record.customInputEnabled) ??
asBoolean(record.custom_input_enabled) ??
false,
customPlaceholder:
firstString(record, [
"customPlaceholder",
"custom_placeholder",
"inputPlaceholder",
"input_placeholder",
]),
submitLabel:
firstString(record, [
"submitLabel",
"submit_label",
"buttonLabel",
"button_label",
]),
raw: record,
}
: undefined
}
case "card": {
const blocks = Array.isArray(record.blocks)
? record.blocks
.map((item) => parseCardBlock(item))
.filter((item): item is NonNullable<typeof item> => item !== null)
: undefined
const actions = Array.isArray(record.actions)
? record.actions
.map((item) => parseActionItem(item))
.filter((item): item is NonNullable<typeof item> => item !== null)
: undefined
return {
type,
kind: asString(record.kind),
version: asString(record.version),
title: asString(record.title),
blocks,
actions,
raw: record,
}
}
case "form": {
const fields = Array.isArray(record.fields)
? record.fields
.map((item) => parseFormField(item))
.filter((item): item is NonNullable<typeof item> => item !== null)
: undefined
const actions = Array.isArray(record.actions)
? record.actions
.map((item) => parseActionItem(item))
.filter((item): item is NonNullable<typeof item> => item !== null)
: undefined
return {
type,
kind: asString(record.kind),
version: asString(record.version),
title: asString(record.title),
content: firstString(record, ["content", "description", "message"]),
fields,
actions,
raw: record,
}
}
case "progress": {
const steps = Array.isArray(record.steps)
? record.steps
.map((item) => parseProgressStep(item))
.filter((item): item is NonNullable<typeof item> => item !== null)
: undefined
return {
type,
kind: asString(record.kind),
version: asString(record.version),
title: asString(record.title),
content: firstString(record, ["content", "description", "message", "detail"]),
status: asString(record.status),
steps,
raw: record,
}
}
case "todo": {
const items = Array.isArray(record.items)
? record.items
.map((item) => parseTodoItem(item))
.filter((item): item is NonNullable<typeof item> => item !== null)
: undefined
return {
type,
kind: asString(record.kind),
version: asString(record.version),
title: asString(record.title),
content: firstString(record, ["content", "description", "message"]),
items,
raw: record,
}
}
case "alert": {
const actions = Array.isArray(record.actions)
? record.actions
.map((item) => parseActionItem(item))
.filter((item): item is NonNullable<typeof item> => item !== null)
: undefined
return {
type,
kind: asString(record.kind),
version: asString(record.version),
title: asString(record.title),
level: firstString(record, ["level", "severity", "statusLevel"]),
content: firstString(record, ["content", "description", "message", "detail"]),
actions,
raw: record,
}
}
default:
return {
type: "unknown",
kind: asString(record.kind) ?? type,
raw: record,
}
}
}
export function parseStructuredContent(
structured: unknown,
): ChatStructuredValue | undefined {
if (Array.isArray(structured)) {
const parts = structured
.map((item) => parseSingleStructuredContent(item))
.filter((item): item is ChatStructuredContent => item !== undefined)
return parts.length > 0 ? parts : undefined
}
return parseSingleStructuredContent(structured)
}
function cleanPlanText(value: string): string {
return value
.trim()
.replace(/^[:\-\s]+/, "")
.replace(/[*_`#]/g, "")
.trim()
}
function looksLikePlanLine(line: string): boolean {
return (
planCheckboxRe.test(line) ||
planBulletRe.test(line) ||
planNumberRe.test(line)
)
}
function isPlanHeading(text: string): boolean {
return /规划|计划|plan|阶段|任务|step|phase|milestone|测试|验证|优化/i.test(text)
}
function inferPlanStatus(text: string): ChatTodoItem["status"] {
if (/completed|done|已完成/i.test(text)) {
return "completed"
}
if (/in-progress|running|进行中/i.test(text)) {
return "in-progress"
}
return "not-started"
}
export function inferStructuredContentFromText(
content: string,
): ChatStructuredValue | undefined {
const normalized = content
.replace(/\r\n/g, "\n")
.replace(/[\u00A0\u3000]/g, " ")
.trim()
if (!normalized) {
return undefined
}
const lines = normalized.split("\n")
let title: string | undefined
let summary: string | undefined
let hasChecklist = false
const headingItems: ChatTodoItem[] = []
const listItems: ChatTodoItem[] = []
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line || line === "---") {
continue
}
const headingMatch = line.match(planHeadingRe)
if (headingMatch) {
const headingText = cleanPlanText(headingMatch[1] ?? "")
if (!headingText) {
continue
}
if (!title) {
title = headingText
continue
}
if (isPlanHeading(headingText)) {
headingItems.push({
title: headingText,
status: "not-started",
})
}
continue
}
if (!summary && !looksLikePlanLine(line)) {
const candidate = cleanPlanText(line)
if (candidate) {
summary = candidate
}
}
const checkboxMatch = line.match(planCheckboxRe)
if (checkboxMatch) {
hasChecklist = true
listItems.push({
title: cleanPlanText(checkboxMatch[2] ?? ""),
status: checkboxMatch[1]?.toLowerCase() === "x" ? "completed" : "not-started",
})
continue
}
const bulletMatch = line.match(planBulletRe)
if (bulletMatch) {
const itemText = cleanPlanText(bulletMatch[1] ?? "")
if (itemText) {
listItems.push({ title: itemText, status: inferPlanStatus(itemText) })
}
continue
}
const numberMatch = line.match(planNumberRe)
if (numberMatch) {
const itemText = cleanPlanText(numberMatch[1] ?? "")
if (itemText) {
listItems.push({ title: itemText, status: inferPlanStatus(itemText) })
}
}
}
const items = headingItems.length > 0 ? headingItems : listItems
if (items.length < 2) {
return undefined
}
const signalText = `${title ?? ""}\n${normalized}`
if (!hasChecklist && !/规划|计划|plan|阶段|任务|todo|step|phase/i.test(signalText)) {
return undefined
}
if (!items.some((item) => item.status === "completed" || item.status === "in-progress")) {
items[0] = { ...items[0], status: "in-progress" }
}
return {
type: "todo",
title: title ?? "Plan",
content: summary,
items: items.slice(0, 8),
}
}

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...", "placeholder": "Start a new message...\nPress Enter to send, Shift + Enter for a new line",
"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,7 +60,16 @@
"step4": "Almost there..." "step4": "Almost there..."
}, },
"reasoningLabel": "Reasoning", "reasoningLabel": "Reasoning",
"toolLabel": "Tool", "mode": {
"agent": "Agent",
"ask": "Ask",
"plan": "Plan"
},
"modeHint": {
"agent": "Execution-first. Tools and edits are allowed to complete the task.",
"ask": "Answer-first. Prefer explanation and guidance over taking actions.",
"plan": "Planning-first. Produce a concrete plan before any execution."
},
"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",
@ -73,10 +82,6 @@
"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",
"contextTitle": "Context",
"contextDetail": "View Details",
"attachImage": "Add images", "attachImage": "Add images",
"removeImage": "Remove image", "removeImage": "Remove image",
"uploadedImage": "Uploaded image", "uploadedImage": "Uploaded image",
@ -359,15 +364,11 @@
"placeholderText": "Placeholder Text", "placeholderText": "Placeholder Text",
"groupTriggerMentionOnly": "Group Mention Only", "groupTriggerMentionOnly": "Group Mention Only",
"groupTriggerPrefixes": "Group Trigger Prefixes", "groupTriggerPrefixes": "Group Trigger Prefixes",
"groupTriggerPrefixesPlaceholder": "e.g. /, !, ?",
"randomReactionEmoji": "Random Reaction Emoji",
"randomReactionEmojiPlaceholder": "e.g. THUMBSUP, HEART, SMILE",
"isLark": "Lark (International)", "isLark": "Lark (International)",
"allowFrom": "Allow From", "allowFrom": "Allow From",
"allowFromPlaceholder": "e.g. 123456, 789012", "allowFromPlaceholder": "e.g. 123456, 789012",
"allowOrigins": "Allow Origins", "allowOrigins": "Allow Origins",
"allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173", "allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173",
"removeListItem": "Remove {{value}}",
"secretPlaceholder": "Enter secret", "secretPlaceholder": "Enter secret",
"secretHintSet": "A value is already set. Leave blank to keep it unchanged." "secretHintSet": "A value is already set. Leave blank to keep it unchanged."
}, },
@ -395,11 +396,10 @@
"typingEnabled": "Display typing status while the assistant is generating a response.", "typingEnabled": "Display typing status while the assistant is generating a response.",
"placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.", "placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.",
"groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.", "groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.",
"groupTriggerPrefixes": "Custom group-chat trigger prefixes. Add items one by one, or paste multiple values at once.", "groupTriggerPrefixes": "Custom group-chat trigger prefixes, separated by commas.",
"randomReactionEmoji": "PicoClaw adds emoji reactions to user messages to confirm receipt. Example: \"THUMBSUP\", \"HEART\", \"SMILE\". Leave empty to use the default \"Pin\" emoji.",
"isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).", "isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).",
"allowFrom": "Allowed user or group IDs. Add items one by one, or paste multiple values at once.", "allowFrom": "Allowed user or group IDs, separated by commas.",
"allowOrigins": "Allowed origin domains. Add items one by one, or paste multiple values at once.", "allowOrigins": "Allowed origin domains, separated by commas.",
"wsUrl": "WebSocket service URL.", "wsUrl": "WebSocket service URL.",
"reconnectInterval": "Reconnect interval after disconnection (seconds).", "reconnectInterval": "Reconnect interval after disconnection (seconds).",
"bridgeUrl": "Bridge service URL.", "bridgeUrl": "Bridge service URL.",
@ -663,16 +663,10 @@
"autostart_load_error": "Failed to load launch-at-login status.", "autostart_load_error": "Failed to load launch-at-login status.",
"server_port": "Service Port", "server_port": "Service Port",
"server_port_hint": "HTTP port used by PicoClaw Web.", "server_port_hint": "HTTP port used by PicoClaw Web.",
"launcher_section_hint": "Changes in this section take effect after the launcher restarts.", "launcher_token": "Login Token",
"dashboard_password": "Login Password", "launcher_token_section_hint": "Changes in this section take effect after the launcher restarts.",
"dashboard_password_hint": "Set a new login password.", "launcher_token_hint": "Used to sign in on the launcher login page.",
"dashboard_password_placeholder": "At least 8 characters", "launcher_token_placeholder": "Enter login token",
"dashboard_password_confirm": "Confirm New Password",
"dashboard_password_confirm_hint": "Enter the new login password again.",
"dashboard_password_confirm_placeholder": "Repeat password",
"dashboard_password_required": "Enter and confirm the new login password.",
"dashboard_password_mismatch": "The login passwords do not match.",
"dashboard_password_min_length": "Login password must be at least 8 characters.",
"lan_access": "Enable LAN Access", "lan_access": "Enable LAN Access",
"lan_access_hint": "Allow access from other devices on your local network.", "lan_access_hint": "Allow access from other devices on your local network.",
"allowed_cidrs": "Allowed Network CIDRs", "allowed_cidrs": "Allowed Network CIDRs",

View file

@ -38,7 +38,7 @@
"chat": { "chat": {
"welcome": "今天我能为您做些什么?", "welcome": "今天我能为您做些什么?",
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。", "welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
"placeholder": "输入新消息...", "placeholder": "输入新消息...\n按 Enter 发送Shift + Enter 换行",
"disabledPlaceholder": { "disabledPlaceholder": {
"gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。", "gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。",
"gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。", "gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。",
@ -60,7 +60,16 @@
"step4": "马上就好..." "step4": "马上就好..."
}, },
"reasoningLabel": "思考", "reasoningLabel": "思考",
"toolLabel": "工具", "mode": {
"agent": "Agent",
"ask": "Ask",
"plan": "Plan"
},
"modeHint": {
"agent": "执行优先,可调用工具并直接完成任务。",
"ask": "问答优先,偏解释与建议,减少主动执行。",
"plan": "规划优先,先给方案,不直接落地执行。"
},
"history": "历史记录", "history": "历史记录",
"noHistory": "暂无对话历史", "noHistory": "暂无对话历史",
"historyLoadFailed": "加载历史记录失败", "historyLoadFailed": "加载历史记录失败",
@ -73,10 +82,6 @@
"notConnected": "服务未运行,请先启动以进行对话。", "notConnected": "服务未运行,请先启动以进行对话。",
"noModel": "未设置默认模型,请前往模型页面进行配置。" "noModel": "未设置默认模型,请前往模型页面进行配置。"
}, },
"sendMessage": "发送消息",
"sendHint": "按 Enter 发送\nShift + Enter 换行",
"contextTitle": "上下文",
"contextDetail": "查看详情",
"attachImage": "添加图片", "attachImage": "添加图片",
"removeImage": "移除图片", "removeImage": "移除图片",
"uploadedImage": "已上传图片", "uploadedImage": "已上传图片",
@ -359,15 +364,11 @@
"placeholderText": "占位文案", "placeholderText": "占位文案",
"groupTriggerMentionOnly": "群聊仅提及时响应", "groupTriggerMentionOnly": "群聊仅提及时响应",
"groupTriggerPrefixes": "群聊触发前缀", "groupTriggerPrefixes": "群聊触发前缀",
"groupTriggerPrefixesPlaceholder": "例如 /, !, ?",
"randomReactionEmoji": "随机表情回应",
"randomReactionEmojiPlaceholder": "例如 THUMBSUP, HEART, SMILE",
"isLark": "Lark国际版", "isLark": "Lark国际版",
"allowFrom": "允许来源", "allowFrom": "允许来源",
"allowFromPlaceholder": "例如 123456, 789012", "allowFromPlaceholder": "例如 123456, 789012",
"allowOrigins": "允许来源域名", "allowOrigins": "允许来源域名",
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173", "allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
"removeListItem": "删除 {{value}}",
"secretPlaceholder": "输入密钥", "secretPlaceholder": "输入密钥",
"secretHintSet": "配置已保存,留空表示不修改" "secretHintSet": "配置已保存,留空表示不修改"
}, },
@ -395,11 +396,10 @@
"typingEnabled": "在生成回复时显示“正在输入”状态", "typingEnabled": "在生成回复时显示“正在输入”状态",
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息", "placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应", "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
"groupTriggerPrefixes": "群聊触发前缀。可逐项添加,也支持一次粘贴多个值。", "groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔",
"randomReactionEmoji": "PicoClaw 会对用户消息添加表情回复以确认已收到。例如:\"THUMBSUP\", \"HEART\", \"SMILE\"。留空则使用默认的 \"Pin\" 表情。",
"isLark": "使用 Lark 国际版域名open.larksuite.com替代飞书域名open.feishu.cn", "isLark": "使用 Lark 国际版域名open.larksuite.com替代飞书域名open.feishu.cn",
"allowFrom": "允许访问的用户或群组 ID。可逐项添加,也支持一次粘贴多个值。", "allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔",
"allowOrigins": "允许访问的来源域名。可逐项添加,也支持一次粘贴多个值。", "allowOrigins": "允许访问的来源域名,多个值用逗号分隔",
"wsUrl": "WebSocket 服务地址", "wsUrl": "WebSocket 服务地址",
"reconnectInterval": "断线后的重连间隔(秒)", "reconnectInterval": "断线后的重连间隔(秒)",
"bridgeUrl": "桥接服务地址", "bridgeUrl": "桥接服务地址",
@ -663,16 +663,10 @@
"autostart_load_error": "加载开机自启状态失败", "autostart_load_error": "加载开机自启状态失败",
"server_port": "服务端口", "server_port": "服务端口",
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口", "server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
"launcher_section_hint": "此分组中的改动需要在重启 launcher 后生效", "launcher_token": "登录令牌",
"dashboard_password": "登录密码", "launcher_token_section_hint": "此分组中的改动需要在重启 launcher 后生效",
"dashboard_password_hint": "设置新的登录密码", "launcher_token_hint": "用于在 launcher 登录页进行登录",
"dashboard_password_placeholder": "至少 8 个字符", "launcher_token_placeholder": "输入登录令牌",
"dashboard_password_confirm": "确认新密码",
"dashboard_password_confirm_hint": "再次输入新的登录密码",
"dashboard_password_confirm_placeholder": "再次输入密码",
"dashboard_password_required": "请输入并确认新的登录密码",
"dashboard_password_mismatch": "两次输入的登录密码不一致",
"dashboard_password_min_length": "登录密码至少需要 8 个字符",
"lan_access": "启用局域网访问", "lan_access": "启用局域网访问",
"lan_access_hint": "允许局域网中的其他设备访问当前服务", "lan_access_hint": "允许局域网中的其他设备访问当前服务",
"allowed_cidrs": "允许访问网段", "allowed_cidrs": "允许访问网段",

View file

@ -11,6 +11,200 @@ export interface ChatAttachment {
filename?: string filename?: string
} }
export interface ChatOptionItem {
label: string
value: string
description?: string
}
export interface ChatActionItem {
type?: string
action?: string
label: string
value?: string
url?: string
variant?: "default" | "outline" | "secondary" | "ghost"
}
export interface ChatFieldItem {
label: string
value: string
}
export interface ChatListItem {
label?: string
text: string
}
export interface ChatTableBlock {
type: "table"
headers?: string[]
rows: string[][]
}
export interface ChatTextBlock {
type: "text" | "markdown"
text: string
}
export interface ChatFieldsBlock {
type: "fields"
fields: ChatFieldItem[]
}
export interface ChatBadgeBlock {
type: "badge"
label: string
status?: string
}
export interface ChatActionsBlock {
type: "actions"
actions: ChatActionItem[]
}
export interface ChatListBlock {
type: "list"
items: ChatListItem[]
}
export interface ChatImageBlock {
type: "image"
url: string
alt?: string
}
export interface ChatDividerBlock {
type: "divider"
}
export interface ChatJsonBlock {
type: "json"
data: unknown
}
export interface ChatUnknownBlock {
type: "unknown"
blockType: string
raw: Record<string, unknown>
}
export type ChatCardBlock =
| ChatTextBlock
| ChatFieldsBlock
| ChatBadgeBlock
| ChatActionsBlock
| ChatListBlock
| ChatTableBlock
| ChatImageBlock
| ChatDividerBlock
| ChatJsonBlock
| ChatUnknownBlock
export interface ChatStructuredOptions {
type: "options"
options: ChatOptionItem[]
mode?: "single" | "multiple"
allowCustom?: boolean
customPlaceholder?: string
submitLabel?: string
raw?: Record<string, unknown>
}
export interface ChatStructuredCard {
type: "card"
kind?: string
version?: string
title?: string
blocks?: ChatCardBlock[]
actions?: ChatActionItem[]
raw?: Record<string, unknown>
}
export interface ChatFormField {
name: string
label: string
fieldType?: string
value?: string
required?: boolean
placeholder?: string
}
export interface ChatStructuredForm {
type: "form"
kind?: string
version?: string
title?: string
content?: string
fields?: ChatFormField[]
actions?: ChatActionItem[]
raw?: Record<string, unknown>
}
export interface ChatProgressStep {
label: string
status?: string
detail?: string
}
export interface ChatStructuredProgress {
type: "progress"
kind?: string
version?: string
title?: string
content?: string
status?: string
steps?: ChatProgressStep[]
raw?: Record<string, unknown>
}
export interface ChatStructuredAlert {
type: "alert"
kind?: string
version?: string
title?: string
level?: string
content?: string
actions?: ChatActionItem[]
raw?: Record<string, unknown>
}
export type ChatTodoStatus = "not-started" | "in-progress" | "completed"
export interface ChatTodoItem {
id?: string
title: string
status?: ChatTodoStatus
detail?: string
}
export interface ChatStructuredTodo {
type: "todo"
kind?: string
version?: string
title?: string
content?: string
items?: ChatTodoItem[]
raw?: Record<string, unknown>
}
export interface ChatStructuredUnknown {
type: "unknown"
kind?: string
raw: Record<string, unknown>
}
export type ChatStructuredContent =
| ChatStructuredOptions
| ChatStructuredCard
| ChatStructuredForm
| ChatStructuredProgress
| ChatStructuredAlert
| ChatStructuredTodo
| ChatStructuredUnknown
export type ChatStructuredValue = ChatStructuredContent | ChatStructuredContent[]
export type AssistantMessageKind = "normal" | "thought" export type AssistantMessageKind = "normal" | "thought"
export interface ChatMessage { export interface ChatMessage {
@ -20,6 +214,7 @@ export interface ChatMessage {
timestamp: number | string timestamp: number | string
kind?: AssistantMessageKind kind?: AssistantMessageKind
attachments?: ChatAttachment[] attachments?: ChatAttachment[]
structured?: ChatStructuredValue
} }
export interface ContextUsage { export interface ContextUsage {
@ -35,6 +230,8 @@ export type ConnectionState =
| "connected" | "connected"
| "error" | "error"
export type ChatInteractionMode = "agent" | "ask" | "plan"
export interface ChatStoreState { export interface ChatStoreState {
messages: ChatMessage[] messages: ChatMessage[]
connectionState: ConnectionState connectionState: ConnectionState