From f8598f50bd84dde76dd80587106f7710e36116a6 Mon Sep 17 00:00:00 2001 From: shikihane Date: Tue, 10 Mar 2026 12:30:50 +0800 Subject: [PATCH] feat(feishu): add tool progress feedback with verbose toggle - Send "Thinking..." placeholder card on message receive - Report each tool call and result as progress updates on the card - Accumulate progress lines in a single evolving card (EditMessage) - Add IsProgress flag to OutboundMessage for progress vs final distinction - Add Bus() accessor to BaseChannel for progress support in channel code - Add FeaturesConfig with Verbose flag to gate progress updates - When verbose=false (default), discard progress silently (old behavior) Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 93 ++++++++++++++++++++++++++++++++ pkg/bus/types.go | 7 +-- pkg/channels/base.go | 3 ++ pkg/channels/feishu/feishu_32.go | 2 +- pkg/channels/feishu/feishu_64.go | 68 +++++++++++++++++++++-- pkg/channels/feishu/init.go | 2 +- pkg/config/config.go | 6 +++ pkg/config/defaults.go | 3 ++ 8 files changed, 176 insertions(+), 8 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 235d42fcc..f8d159978 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1199,6 +1199,16 @@ func (al *AgentLoop) runLLMIteration( "iteration": iteration, }) + // Send progress update to user + if !constants.IsInternalChannel(opts.Channel) { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: formatToolProgress(tc.Name, tc.Arguments), + IsProgress: true, + }) + } + // Create async callback for tools that implement AsyncExecutor. // When the background work completes, this publishes the result // as an inbound system message so processSystemMessage routes it @@ -1271,6 +1281,18 @@ func (al *AgentLoop) runLLMIteration( }) } + // Report tool result to user via progress update + if !constants.IsInternalChannel(opts.Channel) { + if resultMsg := formatToolResult(r.tc.Name, r.tc.Arguments, r.result); resultMsg != "" { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: resultMsg, + IsProgress: true, + }) + } + } + // If tool returned media refs, publish them as outbound media if len(r.result.Media) > 0 { parts := make([]bus.MediaPart, 0, len(r.result.Media)) @@ -1872,3 +1894,74 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { } return &routing.RoutePeer{Kind: parentKind, ID: parentID} } + +// formatToolProgress formats a human-readable progress string for a tool call. +func formatToolProgress(toolName string, args map[string]any) string { + argStr := func(key string) string { + if v, ok := args[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" + } + + switch toolName { + case "write_file": + if path := argStr("path"); path != "" { + return fmt.Sprintf("Writing %s", path) + } + case "read_file": + if path := argStr("path"); path != "" { + return fmt.Sprintf("Reading %s", path) + } + case "edit_file": + if path := argStr("path"); path != "" { + return fmt.Sprintf("Editing %s", path) + } + case "exec": + if cmd := argStr("command"); cmd != "" { + return fmt.Sprintf("Executing %s", utils.Truncate(cmd, 80)) + } + case "web_search": + if query := argStr("query"); query != "" { + return fmt.Sprintf("Searching %s", utils.Truncate(query, 60)) + } + case "web_fetch": + if u := argStr("url"); u != "" { + return fmt.Sprintf("Fetching %s", utils.Truncate(u, 80)) + } + case "spawn": + if label := argStr("label"); label != "" { + return fmt.Sprintf("Subtask %s", label) + } + case "message": + return "Sending message" + case "cron": + if action := argStr("action"); action != "" { + return fmt.Sprintf("Cron %s", action) + } + } + + return toolName +} + +// formatToolResult formats a result summary for a completed tool call. +// Returns empty string if no result message is needed. +func formatToolResult(toolName string, args map[string]any, result *tools.ToolResult) string { + desc := formatToolProgress(toolName, args) + + // Error: always report + if result.Err != nil { + errPreview := utils.Truncate(result.Err.Error(), 100) + return fmt.Sprintf("[%s] Failed: %s", desc, errPreview) + } + + // exec: show output summary + if toolName == "exec" && result.ForLLM != "" { + output := utils.Truncate(result.ForLLM, 120) + return fmt.Sprintf("[%s] Done:\n%s", desc, output) + } + + return "" +} diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 7ad8f0417..8b5a63304 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -30,9 +30,10 @@ type InboundMessage struct { } type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + IsProgress bool `json:"is_progress,omitempty"` } // MediaPart describes a single media attachment to send. diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 063a66523..5e4b98f79 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -311,6 +311,9 @@ func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s } // GetMediaStore returns the injected MediaStore (may be nil). func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore } +// Bus returns the underlying MessageBus. +func (c *BaseChannel) Bus() *bus.MessageBus { return c.bus } + // SetPlaceholderRecorder injects a PlaceholderRecorder into the channel. func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) { c.placeholderRecorder = r diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index f5e3aa224..a8a007726 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -19,7 +19,7 @@ type FeishuChannel struct { var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures") // NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported -func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { +func NewFeishuChannel(cfg config.FeishuConfig, _ config.FeaturesConfig, bus *bus.MessageBus) (*FeishuChannel, error) { return nil, errors.New( "feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config", ) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5dbbcf0af..0a12ba2f0 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -11,8 +11,10 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "sync/atomic" + "time" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -32,16 +34,26 @@ import ( type FeishuChannel struct { *channels.BaseChannel config config.FeishuConfig + features config.FeaturesConfig client *lark.Client wsClient *larkws.Client botOpenID atomic.Value // stores string; populated lazily for @mention detection - mu sync.Mutex - cancel context.CancelFunc + mu sync.Mutex + cancel context.CancelFunc + placeholders sync.Map // chatID -> *progressState } -func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { +// progressState tracks the placeholder card and accumulated progress lines for a chat. +type progressState struct { + messageID string + lines []string +} + +func NewFeishuChannel( + cfg config.FeishuConfig, features config.FeaturesConfig, bus *bus.MessageBus, +) (*FeishuChannel, error) { base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom, channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), @@ -50,6 +62,7 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan ch := &FeishuChannel{ BaseChannel: base, config: cfg, + features: features, client: lark.NewClient(cfg.AppID, cfg.AppSecret), } ch.SetOwner(ch) @@ -121,6 +134,31 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } + // Progress update: append to the existing placeholder card instead of sending a new message. + if msg.IsProgress { + if !c.features.Verbose { + return nil + } + val, ok := c.placeholders.Load(msg.ChatID) + if !ok { + // Placeholder already consumed (e.g. by final response); silently discard. + return nil + } + state := val.(*progressState) + state.lines = append(state.lines, "─ "+msg.Content) + combined := strings.Join(state.lines, "\n") + if err := c.EditMessage(ctx, msg.ChatID, state.messageID, combined); err != nil { + logger.DebugCF("feishu", "Failed to update progress card", map[string]any{ + "chat_id": msg.ChatID, + "error": err.Error(), + }) + } + return nil + } + + // Final response: consume placeholder (stop progress updates), then send as new message. + c.placeholders.Delete(msg.ChatID) + // Build interactive card with markdown content cardContent, err := buildMarkdownCard(msg.Content) if err != nil { @@ -424,6 +462,30 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. "preview": utils.Truncate(content, 80), }) + // Send thinking placeholder as interactive card before dispatching to agent. + // Using a card (not text) so that subsequent Message.Patch calls can update it. + placeholderCtx, placeholderCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer placeholderCancel() + + initialText := "Thinking..." + cardContent, _ := buildMarkdownCard(initialText) + placeholderReq := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(larkim.MsgTypeInteractive). + Content(cardContent). + Build()). + Build() + + placeholderResp, placeholderErr := c.client.Im.V1.Message.Create(placeholderCtx, placeholderReq) + if placeholderErr == nil && placeholderResp.Success() && placeholderResp.Data.MessageId != nil { + c.placeholders.Store(chatID, &progressState{ + messageID: *placeholderResp.Data.MessageId, + lines: []string{initialText}, + }) + } + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo) return nil } diff --git a/pkg/channels/feishu/init.go b/pkg/channels/feishu/init.go index 7e5a62dae..6ae493038 100644 --- a/pkg/channels/feishu/init.go +++ b/pkg/channels/feishu/init.go @@ -8,6 +8,6 @@ import ( func init() { channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewFeishuChannel(cfg.Channels.Feishu, b) + return NewFeishuChannel(cfg.Channels.Feishu, cfg.Features, b) }) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 13d5a7306..0f86afdf1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -59,10 +59,16 @@ type Config struct { Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` + Features FeaturesConfig `json:"features"` // BuildInfo contains build-time version information BuildInfo BuildInfo `json:"build_info,omitempty"` } +// FeaturesConfig holds feature flags that control optional behavior across the system. +type FeaturesConfig struct { + Verbose bool `json:"verbose" env:"PICOCLAW_FEATURES_VERBOSE"` +} + // BuildInfo contains build-time version information type BuildInfo struct { Version string `json:"version"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 5bb3bd1d6..55e71b791 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -510,6 +510,9 @@ func DefaultConfig() *Config { Enabled: false, MonitorUSB: true, }, + Features: FeaturesConfig{ + Verbose: false, + }, BuildInfo: BuildInfo{ Version: Version, GitCommit: GitCommit,