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.
This commit is contained in:
Acbox
2026-03-31 15:11:57 +08:00
parent 72316d4040
commit f1dd30a388
6 changed files with 36 additions and 7 deletions
@@ -1,6 +1,8 @@
import type { Message } from './useChat.types'
const yamlHeaderRe = /^---\n[\s\S]*?\n---\n?/
const agentTagsRe = /<(attachments|reactions|speech)>[\s\S]*?<\/\1>/g
const collapsedNewlinesRe = /\n{3,}/g
export function extractToolCalls(
message: Message,
@@ -92,6 +94,8 @@ export function extractMessageText(message: Message): string {
if (message.role === 'user') {
text = stripYAMLHeader(text)
} else {
text = stripAgentTags(text)
}
return text
}
@@ -100,6 +104,10 @@ export function stripYAMLHeader(text: string): string {
return text.replace(yamlHeaderRe, '').trim()
}
export function stripAgentTags(text: string): string {
return text.replace(agentTagsRe, '').replace(collapsedNewlinesRe, '\n\n').trim()
}
export function extractTextFromContent(content: unknown): string {
if (typeof content === 'string') return content.trim()
+2
View File
@@ -278,6 +278,7 @@ export const useChatStore = defineStore('chat', () => {
pendingAssistant.blocks.push(block)
if (tc.id) pendingToolCallMap.set(tc.id, block)
}
pendingAssistant.blocks.push(...buildAssetBlocks(raw))
continue
}
@@ -286,6 +287,7 @@ export const useChatStore = defineStore('chat', () => {
pendingAssistant.blocks.push({ type: 'thinking', content: r, done: true })
}
pendingAssistant.blocks.push({ type: 'text', content: text })
pendingAssistant.blocks.push(...buildAssetBlocks(raw))
flushPending()
continue
}
+7
View File
@@ -107,6 +107,13 @@ func SpeechResolver() TagResolver {
}
}
// StripAgentTags removes all default agent tag blocks (<attachments>, <reactions>, <speech>)
// from text, returning only the visible content.
func StripAgentTags(text string) string {
cleaned, _ := ExtractTagsFromText(text, DefaultTagResolvers())
return cleaned
}
// ExtractTagsFromText extracts and removes all tag blocks from a complete string.
func ExtractTagsFromText(text string, resolvers []TagResolver) (string, []TagEvent) {
var events []TagEvent
@@ -3,6 +3,7 @@ package flow
import (
"strings"
"github.com/memohai/memoh/internal/agent"
"github.com/memohai/memoh/internal/conversation"
)
@@ -28,6 +29,7 @@ func ExtractAssistantOutputs(messages []conversation.ModelMessage) []conversatio
if content == "" && len(parts) == 0 {
continue
}
content = agent.StripAgentTags(content)
outputs = append(outputs, conversation.AssistantOutput{Content: content, Parts: parts})
}
return outputs
+14 -5
View File
@@ -200,14 +200,23 @@ func (r *Resolver) resolvePersistSenderIDs(ctx context.Context, req conversation
}
// LinkOutboundAssets links bot-generated assets to the latest assistant
// message for the given bot. Used by the WebSocket path where attachment
// ingestion happens after message persistence.
func (r *Resolver) LinkOutboundAssets(ctx context.Context, botID string, assets []messagepkg.AssetRef) {
// message. When sessionID is provided, the search is scoped to that session;
// otherwise it falls back to a bot-wide search.
// Used by the WebSocket path where attachment ingestion happens after message
// persistence.
func (r *Resolver) LinkOutboundAssets(ctx context.Context, botID, sessionID string, assets []messagepkg.AssetRef) {
if r.messageService == nil || len(assets) == 0 || strings.TrimSpace(botID) == "" {
return
}
// ListLatest returns messages in DESC order (newest first).
msgs, err := r.messageService.ListLatest(ctx, botID, 5)
var (
msgs []messagepkg.Message
err error
)
if strings.TrimSpace(sessionID) != "" {
msgs, err = r.messageService.ListLatestBySession(ctx, sessionID, 5)
} else {
msgs, err = r.messageService.ListLatest(ctx, botID, 5)
}
if err != nil {
r.logger.Warn("LinkOutboundAssets: list latest failed", slog.Any("error", err))
return
+3 -2
View File
@@ -415,6 +415,7 @@ func (h *LocalChannelHandler) HandleWebSocket(c echo.Context) error {
activeCancel = streamCancel
eventCh := make(chan flow.WSStreamEvent, 64)
sessionID := strings.TrimSpace(msg.SessionID)
var (
outboundAssetMu sync.Mutex
outboundAssetRefs []messagepkg.AssetRef
@@ -426,7 +427,7 @@ func (h *LocalChannelHandler) HandleWebSocket(c echo.Context) error {
req := conversation.ChatRequest{
BotID: botID,
ChatID: botID,
SessionID: strings.TrimSpace(msg.SessionID),
SessionID: sessionID,
Token: bearerToken,
UserID: channelIdentityID,
SourceChannelIdentityID: channelIdentityID,
@@ -462,7 +463,7 @@ func (h *LocalChannelHandler) HandleWebSocket(c echo.Context) error {
refs := outboundAssetRefs
outboundAssetMu.Unlock()
if len(refs) > 0 {
h.resolver.LinkOutboundAssets(context.WithoutCancel(ctx), botID, refs)
h.resolver.LinkOutboundAssets(context.WithoutCancel(ctx), botID, sessionID, refs)
}
}()