mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
f1dd30a388
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.
234 lines
7.3 KiB
Go
234 lines
7.3 KiB
Go
package flow
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/memohai/memoh/internal/conversation"
|
|
messagepkg "github.com/memohai/memoh/internal/message"
|
|
)
|
|
|
|
func (r *Resolver) storeRound(ctx context.Context, req conversation.ChatRequest, messages []conversation.ModelMessage, modelID string) error {
|
|
fullRound := make([]conversation.ModelMessage, 0, len(messages))
|
|
|
|
// When the user message was already persisted by a channel adapter, skip
|
|
// the duplicate from the round. Otherwise keep it so that user + assistant
|
|
// messages are written atomically (deferred persistence).
|
|
skipUserQuery := req.UserMessagePersisted
|
|
for _, m := range messages {
|
|
if skipUserQuery && m.Role == "user" && strings.TrimSpace(m.TextContent()) == strings.TrimSpace(req.Query) {
|
|
skipUserQuery = false // only skip the first matching user message
|
|
continue
|
|
}
|
|
fullRound = append(fullRound, m)
|
|
}
|
|
if len(fullRound) == 0 {
|
|
return nil
|
|
}
|
|
|
|
r.storeMessages(ctx, req, fullRound, modelID)
|
|
go r.storeMemory(context.WithoutCancel(ctx), req, fullRound)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Resolver) storeMessages(ctx context.Context, req conversation.ChatRequest, messages []conversation.ModelMessage, modelID string) {
|
|
if r.messageService == nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.BotID) == "" {
|
|
return
|
|
}
|
|
meta := buildRouteMetadata(req)
|
|
senderChannelIdentityID, senderUserID := r.resolvePersistSenderIDs(ctx, req)
|
|
|
|
// Determine the last assistant message index for outbound asset attachment.
|
|
lastAssistantIdx := -1
|
|
if req.OutboundAssetCollector != nil {
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
if messages[i].Role == "assistant" {
|
|
lastAssistantIdx = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
var outboundAssets []messagepkg.AssetRef
|
|
if lastAssistantIdx >= 0 {
|
|
outboundAssets = outboundAssetRefsToMessageRefs(req.OutboundAssetCollector())
|
|
}
|
|
|
|
for i, msg := range messages {
|
|
msg = normalizeUserMessageContent(msg)
|
|
content, err := json.Marshal(msg)
|
|
if err != nil {
|
|
r.logger.Warn("storeMessages: marshal failed", slog.Any("error", err))
|
|
continue
|
|
}
|
|
messageSenderChannelIdentityID := ""
|
|
messageSenderUserID := ""
|
|
externalMessageID := ""
|
|
sourceReplyToMessageID := ""
|
|
assets := []messagepkg.AssetRef(nil)
|
|
if msg.Role == "user" {
|
|
messageSenderChannelIdentityID = senderChannelIdentityID
|
|
messageSenderUserID = senderUserID
|
|
externalMessageID = req.ExternalMessageID
|
|
if strings.TrimSpace(msg.TextContent()) == strings.TrimSpace(req.Query) {
|
|
assets = chatAttachmentsToAssetRefs(req.Attachments)
|
|
}
|
|
} else if strings.TrimSpace(req.ExternalMessageID) != "" {
|
|
sourceReplyToMessageID = req.ExternalMessageID
|
|
}
|
|
if i == lastAssistantIdx && len(outboundAssets) > 0 {
|
|
assets = append(assets, outboundAssets...)
|
|
}
|
|
if _, err := r.messageService.Persist(ctx, messagepkg.PersistInput{
|
|
BotID: req.BotID,
|
|
SessionID: req.SessionID,
|
|
SenderChannelIdentityID: messageSenderChannelIdentityID,
|
|
SenderUserID: messageSenderUserID,
|
|
ExternalMessageID: externalMessageID,
|
|
SourceReplyToMessageID: sourceReplyToMessageID,
|
|
Role: msg.Role,
|
|
Content: content,
|
|
Metadata: meta,
|
|
Usage: msg.Usage,
|
|
Assets: assets,
|
|
ModelID: modelID,
|
|
}); err != nil {
|
|
r.logger.Warn("persist message failed", slog.Any("error", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// outboundAssetRefsToMessageRefs converts outbound asset refs from the streaming
|
|
// collector into message-level asset refs for persistence.
|
|
func outboundAssetRefsToMessageRefs(refs []conversation.OutboundAssetRef) []messagepkg.AssetRef {
|
|
if len(refs) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]messagepkg.AssetRef, 0, len(refs))
|
|
for _, ref := range refs {
|
|
contentHash := strings.TrimSpace(ref.ContentHash)
|
|
if contentHash == "" {
|
|
continue
|
|
}
|
|
role := ref.Role
|
|
if strings.TrimSpace(role) == "" {
|
|
role = "attachment"
|
|
}
|
|
result = append(result, messagepkg.AssetRef{
|
|
ContentHash: contentHash,
|
|
Role: role,
|
|
Ordinal: ref.Ordinal,
|
|
Mime: ref.Mime,
|
|
SizeBytes: ref.SizeBytes,
|
|
StorageKey: ref.StorageKey,
|
|
Name: ref.Name,
|
|
Metadata: ref.Metadata,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
// chatAttachmentsToAssetRefs converts ChatAttachment slice to message AssetRef slice.
|
|
// Only attachments that carry a content_hash are included.
|
|
func chatAttachmentsToAssetRefs(attachments []conversation.ChatAttachment) []messagepkg.AssetRef {
|
|
if len(attachments) == 0 {
|
|
return nil
|
|
}
|
|
refs := make([]messagepkg.AssetRef, 0, len(attachments))
|
|
for i, att := range attachments {
|
|
contentHash := strings.TrimSpace(att.ContentHash)
|
|
if contentHash == "" {
|
|
continue
|
|
}
|
|
ref := messagepkg.AssetRef{
|
|
ContentHash: contentHash,
|
|
Role: "attachment",
|
|
Ordinal: i,
|
|
Mime: strings.TrimSpace(att.Mime),
|
|
SizeBytes: att.Size,
|
|
Name: strings.TrimSpace(att.Name),
|
|
Metadata: att.Metadata,
|
|
}
|
|
if att.Metadata != nil {
|
|
if sk, ok := att.Metadata["storage_key"].(string); ok {
|
|
ref.StorageKey = sk
|
|
}
|
|
}
|
|
refs = append(refs, ref)
|
|
}
|
|
return refs
|
|
}
|
|
|
|
func buildRouteMetadata(req conversation.ChatRequest) map[string]any {
|
|
if strings.TrimSpace(req.RouteID) == "" && strings.TrimSpace(req.CurrentChannel) == "" {
|
|
return nil
|
|
}
|
|
meta := map[string]any{}
|
|
if strings.TrimSpace(req.RouteID) != "" {
|
|
meta["route_id"] = req.RouteID
|
|
}
|
|
if strings.TrimSpace(req.CurrentChannel) != "" {
|
|
meta["platform"] = req.CurrentChannel
|
|
}
|
|
return meta
|
|
}
|
|
|
|
func (r *Resolver) resolvePersistSenderIDs(ctx context.Context, req conversation.ChatRequest) (string, string) {
|
|
channelIdentityID := strings.TrimSpace(req.SourceChannelIdentityID)
|
|
userID := strings.TrimSpace(req.UserID)
|
|
|
|
senderChannelIdentityID := ""
|
|
if r.isExistingChannelIdentityID(ctx, channelIdentityID) {
|
|
senderChannelIdentityID = channelIdentityID
|
|
}
|
|
|
|
senderUserID := ""
|
|
if r.isExistingUserID(ctx, userID) {
|
|
senderUserID = userID
|
|
}
|
|
if senderUserID == "" && senderChannelIdentityID != "" {
|
|
if linked := r.linkedUserIDFromChannelIdentity(ctx, senderChannelIdentityID); linked != "" {
|
|
senderUserID = linked
|
|
}
|
|
}
|
|
return senderChannelIdentityID, senderUserID
|
|
}
|
|
|
|
// LinkOutboundAssets links bot-generated assets to the latest assistant
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
for _, msg := range msgs {
|
|
if msg.Role == "assistant" {
|
|
if linkErr := r.messageService.LinkAssets(ctx, msg.ID, assets); linkErr != nil {
|
|
r.logger.Warn("LinkOutboundAssets: link failed", slog.Any("error", linkErr))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
r.logger.Warn("LinkOutboundAssets: no assistant message found", slog.String("bot_id", botID))
|
|
}
|