Files
Memoh/internal/channel/adapters/qq/stream.go
T
Acbox 473d559042 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`.
2026-04-23 20:49:44 +08:00

190 lines
5.0 KiB
Go

package qq
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"github.com/memohai/memoh/internal/channel"
)
type qqOutboundStream struct {
target string
reply *channel.ReplyRef
send func(context.Context, channel.PreparedOutboundMessage) error
closed atomic.Bool
mu sync.Mutex
buffer strings.Builder
attachments []channel.PreparedAttachment
sentText bool
}
func (a *QQAdapter) OpenStream(_ context.Context, cfg channel.ChannelConfig, target string, opts channel.StreamOptions) (channel.PreparedOutboundStream, error) {
parsed, err := parseConfig(cfg.Credentials)
if err != nil {
return nil, fmt.Errorf("qq open stream: %w", err)
}
channel.SetIMErrorSecrets("qq:"+parsed.AppID, parsed.AppSecret)
return &qqOutboundStream{
target: target,
reply: opts.Reply,
send: func(ctx context.Context, msg channel.PreparedOutboundMessage) error {
if msg.Target == "" {
msg.Target = target
}
if msg.Message.Message.Reply == nil && opts.Reply != nil {
msg.Message.Message.Reply = opts.Reply
}
return a.Send(ctx, cfg, msg)
},
}, nil
}
func (s *qqOutboundStream) Push(ctx context.Context, event channel.PreparedStreamEvent) error {
if s == nil || s.send == nil {
return errors.New("qq stream not configured")
}
if s.closed.Load() {
return errors.New("qq stream is closed")
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
switch event.Type {
case channel.StreamEventStatus,
channel.StreamEventPhaseStart,
channel.StreamEventPhaseEnd,
channel.StreamEventToolCallStart,
channel.StreamEventAgentStart,
channel.StreamEventAgentEnd,
channel.StreamEventProcessingStarted,
channel.StreamEventProcessingCompleted,
channel.StreamEventProcessingFailed:
return nil
case channel.StreamEventToolCallEnd:
text := strings.TrimSpace(channel.RenderToolCallMessage(channel.BuildToolCallEnd(event.ToolCall)))
if text == "" {
return nil
}
return s.send(ctx, channel.PreparedOutboundMessage{
Target: s.target,
Message: channel.PreparedMessage{
Message: channel.Message{Format: channel.MessageFormatPlain, Text: text, Reply: s.reply},
},
})
case channel.StreamEventDelta:
if event.Phase == channel.StreamPhaseReasoning || event.Delta == "" {
return nil
}
s.mu.Lock()
s.buffer.WriteString(event.Delta)
s.mu.Unlock()
return nil
case channel.StreamEventAttachment:
if len(event.Attachments) == 0 {
return nil
}
s.mu.Lock()
s.attachments = append(s.attachments, event.Attachments...)
s.mu.Unlock()
return nil
case channel.StreamEventError:
errText := channel.RedactIMErrorText(strings.TrimSpace(event.Error))
if errText == "" {
return nil
}
return s.flush(ctx, channel.PreparedMessage{
Message: channel.Message{
Text: "Error: " + errText,
},
})
case channel.StreamEventFinal:
if event.Final == nil {
return errors.New("qq stream final payload is required")
}
return s.flush(ctx, event.Final.Message)
default:
return nil
}
}
func (s *qqOutboundStream) Close(ctx context.Context) error {
if s == nil {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
s.closed.Store(true)
return nil
}
func (s *qqOutboundStream) flush(ctx context.Context, msg channel.PreparedMessage) error {
s.mu.Lock()
bufferedText := strings.TrimSpace(s.buffer.String())
bufferedAttachments := append([]channel.PreparedAttachment(nil), s.attachments...)
alreadySentText := s.sentText
s.buffer.Reset()
s.attachments = nil
s.mu.Unlock()
logicalMsg := msg.LogicalMessage()
if bufferedText != "" {
logicalMsg.Text = bufferedText
logicalMsg.Parts = nil
if logicalMsg.Format == "" {
logicalMsg.Format = channel.MessageFormatPlain
}
} else if alreadySentText && len(bufferedAttachments) == 0 && len(msg.Attachments) == 0 && strings.TrimSpace(logicalMsg.PlainText()) != "" {
return nil
}
preparedAttachments := append([]channel.PreparedAttachment(nil), bufferedAttachments...)
if len(bufferedAttachments) > 0 {
logicalMsg.Attachments = append(preparedAttachmentLogicals(bufferedAttachments), logicalMsg.Attachments...)
preparedAttachments = append(preparedAttachments, msg.Attachments...)
} else {
preparedAttachments = append(preparedAttachments, msg.Attachments...)
}
if logicalMsg.Reply == nil && s.reply != nil {
logicalMsg.Reply = s.reply
}
if logicalMsg.IsEmpty() && len(preparedAttachments) == 0 {
return nil
}
if err := s.send(ctx, channel.PreparedOutboundMessage{
Target: s.target,
Message: channel.PreparedMessage{
Message: logicalMsg,
Attachments: preparedAttachments,
},
}); err != nil {
return err
}
if strings.TrimSpace(logicalMsg.PlainText()) != "" {
s.mu.Lock()
s.sentText = true
s.mu.Unlock()
}
return nil
}
func preparedAttachmentLogicals(attachments []channel.PreparedAttachment) []channel.Attachment {
if len(attachments) == 0 {
return nil
}
logical := make([]channel.Attachment, 0, len(attachments))
for _, att := range attachments {
logical = append(logical, att.Logical)
}
return logical
}