diff --git a/internal/im/service.go b/internal/im/service.go index 92049205..c4b8f595 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -35,6 +35,8 @@ const ( dedupCleanupInterval = 1 * time.Minute // maxContentLength is the maximum allowed message content length. maxContentLength = 4096 + // maxQuoteContentLength is the max runes to include from a quoted message. + maxQuoteContentLength = 500 // streamFlushInterval is how often buffered stream content is flushed to the IM platform. // This prevents API rate-limiting while keeping perceived latency low. streamFlushInterval = 300 * time.Millisecond @@ -160,6 +162,24 @@ func makeUserKey(channelID, userID, chatID, threadID string) string { return fmt.Sprintf("%s:%s:%s", channelID, userID, chatID) } +// formatQuotedContext formats a QuotedMessage into a labeled string for LLM context. +// Returns empty string if quote is nil or has no content. +func formatQuotedContext(quote *QuotedMessage) string { + if quote == nil || quote.Content == "" { + return "" + } + content := quote.Content + runes := []rune(content) + if len(runes) > maxQuoteContentLength { + content = string(runes[:maxQuoteContentLength]) + "..." + } + label := "[被引用的消息]" + if quote.IsBotMessage { + label = "[引用的机器人回复]" + } + return label + "\n" + content +} + func buildIMQARequest( session *types.Session, query string, @@ -167,6 +187,7 @@ func buildIMQARequest( userMessageID string, customAgent *types.CustomAgent, kbIDs []string, + quote *QuotedMessage, ) *types.QARequest { // WebSearchEnabled: the web handler passes this per-request from the // frontend toggle; for IM channels the user has no per-message toggle, @@ -180,6 +201,7 @@ func buildIMQARequest( KnowledgeBaseIDs: kbIDs, UserMessageID: userMessageID, WebSearchEnabled: webSearchEnabled, + QuotedContext: formatQuotedContext(quote), } } @@ -723,6 +745,7 @@ func (s *Service) HandleMessage(ctx context.Context, msg *IncomingMessage, chann attribute.String("im.chat_id", msg.ChatID), attribute.String("im.thread_id", msg.ThreadID), attribute.String("im.message_type", string(msg.MessageType)), + attribute.Bool("im.has_quote", msg.Quote != nil), ) // Dedup: skip if this message was already processed (IM platforms may retry) @@ -937,7 +960,7 @@ func (s *Service) executeQARequest(req *qaRequest) { } // Non-streaming fallback: collect full answer then send. - answer, err := s.runQA(ctx, req.session, req.msg.Content, req.agent, kbIDs, req.userKey) + answer, err := s.runQA(ctx, req.session, req.msg.Content, req.agent, kbIDs, req.userKey, req.msg.Quote) if err != nil { span.SetStatus(codes.Error, err.Error()) logger.Errorf(ctx, "[IM] QA failed: %v, sending fallback reply", err) @@ -1559,7 +1582,7 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, // Run QA async go func() { var err error - req := buildIMQARequest(session, msg.Content, assistantMsg.ID, userMsg.ID, customAgent, kbIDs) + req := buildIMQARequest(session, msg.Content, assistantMsg.ID, userMsg.ID, customAgent, kbIDs, msg.Quote) if useAgent { err = s.sessionService.AgentQA(qaCtx, req, eventBus) } else { @@ -1649,7 +1672,7 @@ loop: // fallbackNonStream is used when streaming initialization fails. func (s *Service) fallbackNonStream(ctx context.Context, msg *IncomingMessage, session *types.Session, customAgent *types.CustomAgent, kbIDs []string, adapter Adapter, userKey string) error { - answer, err := s.runQA(ctx, session, msg.Content, customAgent, kbIDs, userKey) + answer, err := s.runQA(ctx, session, msg.Content, customAgent, kbIDs, userKey, msg.Quote) if err != nil { logger.Errorf(ctx, "[IM] QA fallback failed: %v", err) answer = "抱歉,处理您的问题时出现了异常,请稍后再试。" @@ -1659,7 +1682,7 @@ func (s *Service) fallbackNonStream(ctx context.Context, msg *IncomingMessage, s } // runQA executes the WeKnora QA pipeline and returns the full answer text. -func (s *Service) runQA(ctx context.Context, session *types.Session, query string, customAgent *types.CustomAgent, kbIDs []string, userKey string) (string, error) { +func (s *Service) runQA(ctx context.Context, session *types.Session, query string, customAgent *types.CustomAgent, kbIDs []string, userKey string, quote *QuotedMessage) (string, error) { // Add timeout to prevent indefinite blocking ctx, cancel := context.WithTimeout(ctx, qaTimeout) defer cancel() @@ -1749,7 +1772,7 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin // Run QA async go func() { var err error - req := buildIMQARequest(session, query, assistantMsg.ID, userMsg.ID, customAgent, kbIDs) + req := buildIMQARequest(session, query, assistantMsg.ID, userMsg.ID, customAgent, kbIDs, quote) if useAgent { err = s.sessionService.AgentQA(ctx, req, eventBus) } else { diff --git a/internal/im/service_quote_test.go b/internal/im/service_quote_test.go new file mode 100644 index 00000000..22938650 --- /dev/null +++ b/internal/im/service_quote_test.go @@ -0,0 +1,54 @@ +package im + +import ( + "testing" +) + +func TestFormatQuotedContext(t *testing.T) { + tests := []struct { + name string + quote *QuotedMessage + want string + }{ + { + name: "nil quote", + quote: nil, + want: "", + }, + { + name: "empty content", + quote: &QuotedMessage{Content: ""}, + want: "", + }, + { + name: "bot message", + quote: &QuotedMessage{Content: "bot reply text", IsBotMessage: true}, + want: "[引用的机器人回复]\nbot reply text", + }, + { + name: "user message", + quote: &QuotedMessage{Content: "user message text", IsBotMessage: false}, + want: "[被引用的消息]\nuser message text", + }, + { + name: "truncation at 500 runes", + quote: &QuotedMessage{ + Content: string(make([]rune, 600)), + IsBotMessage: false, + }, + want: "[被引用的消息]\n" + string(make([]rune, 500)) + "...", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatQuotedContext(tt.quote) + if got != tt.want { + t.Errorf("formatQuotedContext() length = %d, want length %d", len(got), len(tt.want)) + if len(got) < 200 && len(tt.want) < 200 { + t.Errorf("got = %q, want %q", got, tt.want) + } + } + }) + } +} diff --git a/internal/types/chat_manage.go b/internal/types/chat_manage.go index 6c21d18f..10dca6cf 100644 --- a/internal/types/chat_manage.go +++ b/internal/types/chat_manage.go @@ -99,6 +99,7 @@ type PipelineState struct { RenderedContexts string `json:"-"` ChatResponse *ChatResponse `json:"-"` ImageDescription string `json:"-"` + QuotedContext string `json:"-"` // Quoted message text, injected at LLM prompt stage SystemPromptOverride string `json:"-"` } @@ -195,6 +196,7 @@ func (c *ChatManage) Clone() *ChatManage { RewriteQuery: c.RewriteQuery, Intent: c.Intent, ImageDescription: c.ImageDescription, + QuotedContext: c.QuotedContext, SystemPromptOverride: c.SystemPromptOverride, RenderedContexts: c.RenderedContexts, }, diff --git a/internal/types/qa_request.go b/internal/types/qa_request.go index 56cab9ac..e9639e8d 100644 --- a/internal/types/qa_request.go +++ b/internal/types/qa_request.go @@ -16,4 +16,5 @@ type QARequest struct { UserMessageID string // Created user message ID WebSearchEnabled bool // Whether web search is enabled for this request EnableMemory bool // Whether memory feature is enabled + QuotedContext string // Quoted message content from IM quote-reply (appended at LLM prompt stage, not used for retrieval) }