Files
Memoh/internal/conversation/flow/assistant_output.go
T
Acbox f1dd30a388 fix: strip agent tags from IM/WebUI output and fix attachment display after refresh
Three independent bugs fixed:

1. IM channels were sending raw <attachments>/<reactions>/<speech> tag blocks
   alongside file attachments. Now ExtractAssistantOutputs strips these tags
   before building the outbound channel message.

2. WebUI rendered these tags as markdown after page refresh. Now
   extractMessageText strips agent tags for non-user messages.

3. WebUI lost attachment blocks after refresh because convertMessagesToChats
   did not call buildAssetBlocks when merging assistant messages into a
   pending tool-call group. Also made LinkOutboundAssets session-aware so
   assets are linked to the correct assistant message.
2026-03-31 15:11:57 +08:00

102 lines
2.3 KiB
Go

package flow
import (
"strings"
"github.com/memohai/memoh/internal/agent"
"github.com/memohai/memoh/internal/conversation"
)
// ExtractAssistantOutputs collects assistant-role outputs from a slice of ModelMessages.
func ExtractAssistantOutputs(messages []conversation.ModelMessage) []conversation.AssistantOutput {
if len(messages) == 0 {
return nil
}
outputs := make([]conversation.AssistantOutput, 0, len(messages))
for _, msg := range messages {
if msg.Role != "assistant" {
continue
}
if hasToolCallContent(msg) {
continue
}
rawParts := msg.ContentParts()
parts := filterVisibleContentParts(rawParts)
content := visibleContentText(parts)
if len(rawParts) == 0 {
content = strings.TrimSpace(msg.TextContent())
}
if content == "" && len(parts) == 0 {
continue
}
content = agent.StripAgentTags(content)
outputs = append(outputs, conversation.AssistantOutput{Content: content, Parts: parts})
}
return outputs
}
func hasToolCallContent(msg conversation.ModelMessage) bool {
if len(msg.ToolCalls) > 0 {
return true
}
for _, p := range msg.ContentParts() {
if p.Type == "tool-call" {
return true
}
}
return false
}
func filterVisibleContentParts(parts []conversation.ContentPart) []conversation.ContentPart {
if len(parts) == 0 {
return nil
}
filtered := make([]conversation.ContentPart, 0, len(parts))
for _, p := range parts {
if isVisibleContentPart(p) {
filtered = append(filtered, p)
}
}
return filtered
}
func isVisibleContentPart(part conversation.ContentPart) bool {
if !part.HasValue() {
return false
}
switch strings.ToLower(strings.TrimSpace(part.Type)) {
case "reasoning", "tool-call", "tool-result":
return false
default:
return true
}
}
func visibleContentText(parts []conversation.ContentPart) string {
if len(parts) == 0 {
return ""
}
texts := make([]string, 0, len(parts))
for _, part := range parts {
text := strings.TrimSpace(visibleContentPartText(part))
if text == "" {
continue
}
texts = append(texts, text)
}
return strings.TrimSpace(strings.Join(texts, "\n"))
}
func visibleContentPartText(part conversation.ContentPart) string {
if strings.TrimSpace(part.Text) != "" {
return part.Text
}
if strings.TrimSpace(part.URL) != "" {
return part.URL
}
if strings.TrimSpace(part.Emoji) != "" {
return part.Emoji
}
return ""
}