mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
473d559042
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`.
190 lines
5.0 KiB
Go
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
|
|
}
|