mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(channel): structured tool-call IM display with edit-in-place
Introduce a new `show_tool_calls_in_im` bot setting plus a full overhaul of how tool calls are surfaced in IM channels: - Add per-bot setting + migration (0072) and expose through settings API / handlers / frontend SDK. - Introduce a `toolCallDroppingStream` wrapper that filters tool_call_* events when the setting is off, keeping the rest of the stream intact. - Add a shared `ToolCallPresentation` model (Header / Body blocks / Footer) with plain and Markdown renderers, and a per-tool formatter registry that produces rich output (e.g. `web_search` link lists, `list` directory previews, `exec` stdout/stderr tails) instead of raw JSON dumps. - High-capability adapters (Telegram, Feishu, Matrix, Slack, Discord) now flush pre-text and then send ONE tool-call message per call, editing it in-place from `running` to `completed` / `failed`; mapping from callID to platform message ID is tracked per stream, with a fallback to a new message if the edit fails. Low-capability adapters (WeCom, QQ, DingTalk) keep posting a single final message, but now benefit from the same rich per-tool formatting. - Suppress the early duplicate `EventToolCallStart` (from `sdk.ToolInputStartPart`) so that the SDK's final `StreamToolCallPart` remains the single source of truth for tool call start, preventing duplicated "running" bubbles in IM. - Stop auto-populating `InputSummary` / `ResultSummary` after a per-tool formatter runs, which previously leaked the raw JSON result as a fallback footer underneath the formatted body. Add regression tests for the formatters, the Markdown renderer, the edit-in-place flow on Telegram/Matrix, and the JSON-leak guard on `list`.
This commit is contained in:
@@ -22,18 +22,19 @@ const (
|
||||
)
|
||||
|
||||
type slackOutboundStream struct {
|
||||
adapter *SlackAdapter
|
||||
cfg channel.ChannelConfig
|
||||
target string
|
||||
reply *channel.ReplyRef
|
||||
api *slackapi.Client
|
||||
closed atomic.Bool
|
||||
mu sync.Mutex
|
||||
msgTS string // Slack message timestamp (used as message ID)
|
||||
buffer strings.Builder
|
||||
lastSent string
|
||||
lastUpdate time.Time
|
||||
nextUpdate time.Time
|
||||
adapter *SlackAdapter
|
||||
cfg channel.ChannelConfig
|
||||
target string
|
||||
reply *channel.ReplyRef
|
||||
api *slackapi.Client
|
||||
closed atomic.Bool
|
||||
mu sync.Mutex
|
||||
msgTS string // Slack message timestamp (used as message ID)
|
||||
buffer strings.Builder
|
||||
lastSent string
|
||||
lastUpdate time.Time
|
||||
nextUpdate time.Time
|
||||
toolMessages map[string]string
|
||||
}
|
||||
|
||||
var _ channel.PreparedOutboundStream = (*slackOutboundStream)(nil)
|
||||
@@ -122,11 +123,26 @@ func (s *slackOutboundStream) Push(ctx context.Context, event channel.PreparedSt
|
||||
}
|
||||
return nil
|
||||
|
||||
case channel.StreamEventToolCallStart:
|
||||
s.mu.Lock()
|
||||
bufText := strings.TrimSpace(s.buffer.String())
|
||||
s.mu.Unlock()
|
||||
if bufText != "" {
|
||||
if err := s.finalizeMessage(ctx, bufText); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := s.clearPlaceholder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
s.resetStreamState()
|
||||
return s.sendToolCallMessage(ctx, event.ToolCall, channel.BuildToolCallStart(event.ToolCall))
|
||||
case channel.StreamEventToolCallEnd:
|
||||
return s.sendToolCallMessage(ctx, event.ToolCall, channel.BuildToolCallEnd(event.ToolCall))
|
||||
|
||||
case channel.StreamEventAgentStart, channel.StreamEventAgentEnd,
|
||||
channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd,
|
||||
channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted,
|
||||
channel.StreamEventProcessingFailed,
|
||||
channel.StreamEventToolCallStart, channel.StreamEventToolCallEnd,
|
||||
channel.StreamEventReaction, channel.StreamEventSpeech:
|
||||
return nil
|
||||
|
||||
@@ -310,6 +326,80 @@ func (s *slackOutboundStream) sendAttachment(ctx context.Context, att channel.Pr
|
||||
return s.adapter.uploadPreparedAttachment(ctx, s.api, s.target, threadTS, att)
|
||||
}
|
||||
|
||||
// sendToolCallMessage posts a message for tool_call_start and updates the same
|
||||
// message on tool_call_end via chat.update so the running → completed/failed
|
||||
// transition shares one visible post. If the edit fails (or no prior message
|
||||
// is tracked), it falls back to posting a new message.
|
||||
func (s *slackOutboundStream) sendToolCallMessage(
|
||||
ctx context.Context,
|
||||
tc *channel.StreamToolCall,
|
||||
p channel.ToolCallPresentation,
|
||||
) error {
|
||||
text := truncateSlackText(strings.TrimSpace(channel.RenderToolCallMessageMarkdown(p)))
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
callID := ""
|
||||
if tc != nil {
|
||||
callID = strings.TrimSpace(tc.CallID)
|
||||
}
|
||||
if p.Status != channel.ToolCallStatusRunning && callID != "" {
|
||||
if ts, ok := s.lookupToolCallMessage(callID); ok {
|
||||
if err := s.updateMessageTextWithRetry(ctx, ts, text); err == nil {
|
||||
s.forgetToolCallMessage(callID)
|
||||
return nil
|
||||
}
|
||||
s.forgetToolCallMessage(callID)
|
||||
}
|
||||
}
|
||||
ts, err := s.postMessageWithRetry(ctx, text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Status == channel.ToolCallStatusRunning && callID != "" && ts != "" {
|
||||
s.storeToolCallMessage(callID, ts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) lookupToolCallMessage(callID string) (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.toolMessages == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := s.toolMessages[callID]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) storeToolCallMessage(callID, ts string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.toolMessages == nil {
|
||||
s.toolMessages = make(map[string]string)
|
||||
}
|
||||
s.toolMessages[callID] = ts
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) forgetToolCallMessage(callID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.toolMessages == nil {
|
||||
return
|
||||
}
|
||||
delete(s.toolMessages, callID)
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) resetStreamState() {
|
||||
s.mu.Lock()
|
||||
s.msgTS = ""
|
||||
s.buffer.Reset()
|
||||
s.lastSent = ""
|
||||
s.lastUpdate = time.Time{}
|
||||
s.nextUpdate = time.Time{}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *slackOutboundStream) postMessageWithRetry(ctx context.Context, text string) (string, error) {
|
||||
opts := []slackapi.MsgOption{
|
||||
slackapi.MsgOptionText(text, false),
|
||||
|
||||
Reference in New Issue
Block a user