mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-21 13:52:09 +08:00
Sessions today are a flat list ordered by updated_at. Two gaps showed up in practice: - Users cannot find specific chats as the list grows beyond a screen. - IM-created sessions (WeCom/Feishu/Slack/...) are indistinguishable: every title was "IM-<platform>" or "IM-<platform>-<username>" and the list API hid the underlying im_channel_sessions mapping, so admins had no way to tell which Feishu group a session came from. Backend - Migration 000039 adds sessions.user_id (owner), is_pinned, pinned_at plus a composite index for the list query. Existing rows keep user_id NULL and stay visible at the tenant level for backward compatibility. - CreateSession now writes the caller's user_id from auth context. - GET /sessions accepts keyword / source / agent_id. When any filter is set, the response switches to enriched items that LEFT JOIN im_channel_sessions and expose im_platform / im_chat_id / im_thread_id / im_user_id / im_agent_id / im_channel_id. No filters => legacy shape, existing clients unaffected. - Ordering: is_pinned DESC, pinned_at DESC NULLS LAST, updated_at DESC. - POST/DELETE /sessions/:id/pin for user-scoped pin/unpin. - IM session titles: "[platform] <user|chat|thread>" with short ID suffixes so group/DM/thread sessions are visually distinct without needing a round-trip to fetch a display name from the IM adapter. Frontend - Search input debounced at 300ms drives the keyword filter. - Pinned chats render in a dedicated group above the time-based groups, with a pin icon and a pin/unpin entry in the per-chat dropdown. - IM chats get a short [platform] badge in the list. - Pin toggle is optimistic and guards against double-clicks. - zh/en/ko/ru i18n keys added for the new strings.
2765 lines
99 KiB
Go
2765 lines
99 KiB
Go
package im
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/textproto"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
filesvc "github.com/Tencent/WeKnora/internal/application/service/file"
|
|
"github.com/Tencent/WeKnora/internal/config"
|
|
"github.com/Tencent/WeKnora/internal/event"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/models/chat"
|
|
"github.com/Tencent/WeKnora/internal/tracing"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/google/uuid"
|
|
"github.com/redis/go-redis/v9"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/codes"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
// dedupTTL is how long processed message IDs are retained.
|
|
dedupTTL = 5 * time.Minute
|
|
// dedupCleanupInterval is how often the dedup map is cleaned.
|
|
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
|
|
)
|
|
|
|
// imCitationTagRe matches inline citation tags produced by the agent pipeline.
|
|
// These tags are rendered as interactive UI in the web frontend but are meaningless
|
|
// in IM platforms, so they must be stripped before sending.
|
|
var imCitationTagRe = regexp.MustCompile(`<(?:kb|web)\b[^>]*/?>`)
|
|
|
|
// stripIMCitationTags removes <kb .../> and <web .../> inline citation tags from s.
|
|
func stripIMCitationTags(s string) string {
|
|
return imCitationTagRe.ReplaceAllString(s, "")
|
|
}
|
|
|
|
// imageXMLBlockRe matches <image ...>...</image> blocks produced by
|
|
// EnrichContentWithImageInfo in the RAG context pipeline. These blocks contain
|
|
// metadata for the LLM and must be stripped before sending to IM platforms.
|
|
var imageXMLBlockRe = regexp.MustCompile(`(?s)<image\b[^>]*>.*?</image>`)
|
|
|
|
// imageOriginalRe extracts the original markdown image syntax from <image_original> tags.
|
|
var imageOriginalRe = regexp.MustCompile(`<image_original>(.*?)</image_original>`)
|
|
|
|
// stripImageXMLTags collapses <image> blocks back to plain markdown.
|
|
// Extracts the original  from <image_original> when present,
|
|
// otherwise drops the block entirely.
|
|
func stripImageXMLTags(s string) string {
|
|
return imageXMLBlockRe.ReplaceAllStringFunc(s, func(block string) string {
|
|
if m := imageOriginalRe.FindStringSubmatch(block); len(m) > 1 {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
})
|
|
}
|
|
|
|
// storageSchemeRe matches provider:// URLs used by file storage backends.
|
|
var storageSchemeRe = regexp.MustCompile(`\b(local|minio|s3|cos|tos|oss)://[^\s)\]>"]+`)
|
|
|
|
// rewriteStorageURLs replaces all provider:// URLs in content with HTTP URLs
|
|
// obtained from fileService.GetFileURL. URLs that are already HTTP or cannot
|
|
// be resolved are left unchanged.
|
|
func rewriteStorageURLs(ctx context.Context, content string, fileSvc interfaces.FileService) string {
|
|
if fileSvc == nil {
|
|
return content
|
|
}
|
|
return storageSchemeRe.ReplaceAllStringFunc(content, func(match string) string {
|
|
httpURL, err := fileSvc.GetFileURL(ctx, match)
|
|
if err != nil || httpURL == match {
|
|
return match
|
|
}
|
|
return httpURL
|
|
})
|
|
}
|
|
|
|
// ── Streaming holdback helpers ──
|
|
// During streaming, content is flushed in 300ms batches. A provider:// URL or
|
|
// an XML tag may be split across two batches. These helpers detect incomplete
|
|
// patterns at the end of a chunk so the caller can hold them back until the
|
|
// next flush completes them.
|
|
|
|
// incompleteURLSuffixRe matches a provider:// URL that reaches the end of the
|
|
// string — it may continue in the next chunk.
|
|
var incompleteURLSuffixRe = regexp.MustCompile(
|
|
`\b(?:local|minio|s3|cos|tos|oss)://[^\s)\]>"]*$`,
|
|
)
|
|
|
|
// findIncompleteStorageURL returns the byte offset of a potentially truncated
|
|
// provider:// URL at the tail of s, or -1 if none.
|
|
func findIncompleteStorageURL(s string) int {
|
|
loc := incompleteURLSuffixRe.FindStringIndex(s)
|
|
if loc == nil {
|
|
return -1
|
|
}
|
|
return loc[0]
|
|
}
|
|
|
|
// incompleteXMLTagRe matches the opening of an <image…>, <kb…>, or <web…> tag
|
|
// that reaches the end of the string without a closing '>'.
|
|
var incompleteXMLTagRe = regexp.MustCompile(
|
|
`<(?:image|image_original|image_caption|image_ocr|kb|web)[^>]*$`,
|
|
)
|
|
|
|
// findIncompleteXMLTag returns the byte offset of a potentially truncated XML
|
|
// tag at the tail of s, or -1 if none.
|
|
func findIncompleteXMLTag(s string) int {
|
|
loc := incompleteXMLTagRe.FindStringIndex(s)
|
|
if loc == nil {
|
|
return -1
|
|
}
|
|
return loc[0]
|
|
}
|
|
|
|
// holdbackCutoff returns the earliest incomplete-pattern offset at the tail of
|
|
// chunk, or len(chunk) if the chunk is safe to flush entirely.
|
|
func holdbackCutoff(chunk string) int {
|
|
cutoff := len(chunk)
|
|
if idx := findIncompleteStorageURL(chunk); idx >= 0 && idx < cutoff {
|
|
cutoff = idx
|
|
}
|
|
if idx := findIncompleteXMLTag(chunk); idx >= 0 && idx < cutoff {
|
|
cutoff = idx
|
|
}
|
|
return cutoff
|
|
}
|
|
|
|
// cleanIMContent applies all IM-specific content transformations:
|
|
// 1. Collapse <image> XML blocks back to plain markdown
|
|
// 2. Strip <kb/> and <web/> citation tags
|
|
// 3. Rewrite provider:// URLs to HTTP URLs (if fileSvc is available)
|
|
func cleanIMContent(ctx context.Context, content string, fileSvc interfaces.FileService) string {
|
|
content = stripImageXMLTags(content)
|
|
content = stripIMCitationTags(content)
|
|
content = rewriteStorageURLs(ctx, content, fileSvc)
|
|
return content
|
|
}
|
|
|
|
// buildTenantFileService creates a FileService for the given tenant's storage config.
|
|
// Returns nil if the tenant has no storage config or if creation fails.
|
|
func buildTenantFileService(tenant *types.Tenant) interfaces.FileService {
|
|
if tenant == nil {
|
|
return nil
|
|
}
|
|
baseDir := os.Getenv("LOCAL_STORAGE_BASE_DIR")
|
|
if baseDir == "" {
|
|
baseDir = "/data/files"
|
|
}
|
|
fileSvc, _, err := filesvc.NewFileServiceFromStorageConfig("", tenant.StorageEngineConfig, baseDir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return fileSvc
|
|
}
|
|
|
|
const (
|
|
// wsLeaderTTL is the TTL for the Redis key used for WebSocket leader election.
|
|
wsLeaderTTL = 15 * time.Second
|
|
// wsLeaderRenewInterval is how often the leader renews its lock.
|
|
wsLeaderRenewInterval = 5 * time.Second
|
|
// wsLeaderRetryInterval is how often non-leader instances try to acquire the lock.
|
|
wsLeaderRetryInterval = 10 * time.Second
|
|
// stopMarkerTTL is the TTL for cross-instance /stop markers in Redis.
|
|
stopMarkerTTL = 30 * time.Second
|
|
// stopPollInterval is how often in-flight workers check for remote /stop signals.
|
|
stopPollInterval = 500 * time.Millisecond
|
|
)
|
|
|
|
// ── Redis key prefixes ──────────────────────────────────────────────────────
|
|
// All IM-related Redis keys are defined here for discoverability and to avoid
|
|
// scattered string literals across multiple files.
|
|
const (
|
|
RedisKeyLeader = "im:ws:leader:" // + channelID — WebSocket leader election
|
|
RedisKeyDedup = "im:dedup:" // + messageID — message deduplication
|
|
RedisKeyStop = "im:stop:" // + userKey — cross-instance /stop marker (pre-execution)
|
|
RedisKeyInflight = "im:inflight:" // + userKey — maps userKey → sessionID:messageID for cross-instance /stop
|
|
RedisKeyQueueUser = "im:queue:user:" // + userKey — global per-user queue counter
|
|
RedisKeyRateLimit = "im:ratelimit:" // + key — sliding-window rate limiting
|
|
RedisKeyGlobalGate = "im:global:active" // global concurrent worker counter
|
|
)
|
|
|
|
// channelState holds runtime state for a running IM channel.
|
|
type channelState struct {
|
|
Channel *IMChannel
|
|
Adapter Adapter
|
|
Cancel context.CancelFunc // for stopping websocket goroutines
|
|
leaderCancel context.CancelFunc // stops the leader renewal goroutine (nil if not leader)
|
|
}
|
|
|
|
// AdapterFactory creates an Adapter from an IMChannel configuration.
|
|
// The second return value is an optional cleanup function (e.g., for stopping websocket connections).
|
|
type AdapterFactory func(ctx context.Context, channel *IMChannel, msgHandler func(ctx context.Context, msg *IncomingMessage) error) (Adapter, context.CancelFunc, error)
|
|
|
|
// inflightEntry tracks a running QA request, keyed by userKey in the inflight map.
|
|
type inflightEntry struct {
|
|
cancel context.CancelFunc
|
|
sessionID string // set after assistant message is created
|
|
assistantMessageID string // set after assistant message is created
|
|
}
|
|
|
|
// Service orchestrates IM message handling:
|
|
// 1. Receives a unified IncomingMessage from an Adapter
|
|
// 2. Resolves or creates a WeKnora session for the IM channel
|
|
// 3. Dispatches slash-commands (/help, /kb, /clear, etc.) without entering QA
|
|
// 4. Calls the WeKnora QA pipeline for normal messages
|
|
// 5. Collects the streaming answer and sends it back via the Adapter
|
|
type Service struct {
|
|
db *gorm.DB
|
|
sessionService interfaces.SessionService
|
|
messageService interfaces.MessageService
|
|
tenantService interfaces.TenantService
|
|
agentService interfaces.CustomAgentService
|
|
|
|
// knowledgeService is used for saving IM file messages to knowledge bases.
|
|
knowledgeService interfaces.KnowledgeService
|
|
|
|
// kbService is used by slash-commands (/info) to list and inspect knowledge bases.
|
|
kbService interfaces.KnowledgeBaseService
|
|
|
|
// modelService is used to obtain the chat model for generating smart notification replies.
|
|
modelService interfaces.ModelService
|
|
|
|
// streamManager writes/reads QA events for distributed stop detection,
|
|
// consistent with the web StopSession mechanism. May be nil in Lite mode
|
|
// (but NewStreamManager always returns at least a memory implementation).
|
|
streamManager interfaces.StreamManager
|
|
|
|
// cmdRegistry holds all registered slash-commands.
|
|
cmdRegistry *CommandRegistry
|
|
|
|
// channels maps channel ID -> running channel state
|
|
channels map[string]*channelState
|
|
mu sync.RWMutex
|
|
|
|
// adapterFactories maps platform name -> factory function
|
|
adapterFactories map[string]AdapterFactory
|
|
|
|
// processedMsgs tracks recently processed message IDs to prevent duplicate handling.
|
|
processedMsgs sync.Map
|
|
|
|
// rateLimiter enforces per-user sliding window rate limiting.
|
|
// Uses Redis ZSET when available, falls back to local sliding window.
|
|
rateLimiter *distributedLimiter
|
|
|
|
// inflight tracks in-progress QA requests, keyed by userKey
|
|
// ("channelID:userID:chatID"). Allows /stop to abort a running request
|
|
// on this instance and look up (sessionID, messageID) for StreamManager.
|
|
inflight sync.Map // userKey -> *inflightEntry
|
|
|
|
// qaQueue manages bounded queuing and worker-pool execution of QA requests,
|
|
// providing backpressure to protect downstream LLM resources.
|
|
qaQueue *qaQueue
|
|
|
|
// redis is the optional Redis client for distributed state (dedup, rate
|
|
// limiting, leader election, cross-instance /stop). When nil the service
|
|
// falls back to local in-memory state (single-instance / Lite mode).
|
|
redis *redis.Client
|
|
|
|
// instanceID uniquely identifies this service instance for leader election.
|
|
instanceID string
|
|
|
|
stopCh chan struct{}
|
|
}
|
|
|
|
// makeUserKey builds the canonical key used to identify a user's request
|
|
// across the queue, inflight map, and /stop command.
|
|
// threadID should only be non-empty when channel.SessionMode == "thread";
|
|
// callers must guard this to avoid leaking thread scope into user-mode keys.
|
|
func makeUserKey(channelID, userID, chatID, threadID string) string {
|
|
if threadID != "" {
|
|
return fmt.Sprintf("%s:%s:%s:%s", channelID, userID, chatID, threadID)
|
|
}
|
|
return fmt.Sprintf("%s:%s:%s", channelID, userID, chatID)
|
|
}
|
|
|
|
// nonTextTypeLabel maps a message type to a Chinese label for LLM instructions.
|
|
var nonTextTypeLabel = map[string]string{
|
|
"image": "图片",
|
|
"file": "文件",
|
|
"video": "视频",
|
|
"voice": "语音",
|
|
}
|
|
|
|
// formatQuotedContext formats a QuotedMessage into a labeled string for LLM context.
|
|
// Returns empty string if quote is nil.
|
|
// For non-text quotes, generates an instruction telling the LLM to acknowledge
|
|
// the unprocessable content instead of a placeholder that causes hallucination.
|
|
func formatQuotedContext(quote *QuotedMessage) string {
|
|
if quote == nil {
|
|
return ""
|
|
}
|
|
// Non-text quote: generate instruction, not content placeholder.
|
|
if quote.NonTextType != "" {
|
|
label := nonTextTypeLabel[quote.NonTextType]
|
|
if label == "" {
|
|
label = "该类型的"
|
|
}
|
|
return "用户引用了一条" + label + "消息,但你无法查看该内容。请直接告知用户你目前无法处理" + label + "消息,建议用户用文字描述问题。不要猜测该消息的内容。"
|
|
}
|
|
if quote.Content == "" {
|
|
return ""
|
|
}
|
|
content := quote.Content
|
|
runes := []rune(content)
|
|
if len(runes) > maxQuoteContentLength {
|
|
content = string(runes[:maxQuoteContentLength]) + "..."
|
|
}
|
|
// Prevent quoted content from escaping the XML tag boundary.
|
|
content = strings.ReplaceAll(content, "</quoted_message>", "")
|
|
label := "以下是用户引用的一条历史消息,仅作为上下文参考:"
|
|
if quote.IsBotMessage {
|
|
label = "以下是用户引用的你(机器人)之前的回复,仅作为上下文参考:"
|
|
}
|
|
return label + "\n<quoted_message>\n" + content + "\n</quoted_message>"
|
|
}
|
|
|
|
func buildIMQARequest(
|
|
session *types.Session,
|
|
query string,
|
|
assistantMessageID string,
|
|
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,
|
|
// so we derive it from the agent config (the single source of truth).
|
|
webSearchEnabled := customAgent != nil && customAgent.Config.WebSearchEnabled
|
|
quotedContext := formatQuotedContext(quote)
|
|
return &types.QARequest{
|
|
Session: session,
|
|
Query: query,
|
|
AssistantMessageID: assistantMessageID,
|
|
CustomAgent: customAgent,
|
|
KnowledgeBaseIDs: kbIDs,
|
|
UserMessageID: userMessageID,
|
|
WebSearchEnabled: webSearchEnabled,
|
|
QuotedContext: quotedContext,
|
|
}
|
|
}
|
|
|
|
// resolveIMConfig extracts IM tuning parameters from the application config,
|
|
// falling back to built-in defaults for any zero/nil values.
|
|
func resolveIMConfig(appCfg *config.Config) (workers, maxQueue, maxPerUser, globalMaxWorkers int, rlWindow time.Duration, rlMax int) {
|
|
workers = defaultWorkers
|
|
maxQueue = defaultMaxQueueSize
|
|
maxPerUser = defaultMaxPerUser
|
|
rlWindow = rateLimitWindow
|
|
rlMax = rateLimitMaxRequests
|
|
|
|
if appCfg == nil || appCfg.IM == nil {
|
|
return
|
|
}
|
|
im := appCfg.IM
|
|
if im.Workers > 0 {
|
|
workers = im.Workers
|
|
}
|
|
if im.MaxQueueSize > 0 {
|
|
maxQueue = im.MaxQueueSize
|
|
}
|
|
if im.MaxPerUser > 0 {
|
|
maxPerUser = im.MaxPerUser
|
|
}
|
|
if im.GlobalMaxWorkers > 0 {
|
|
globalMaxWorkers = im.GlobalMaxWorkers
|
|
}
|
|
if im.RateLimitWindow > 0 {
|
|
rlWindow = im.RateLimitWindow
|
|
}
|
|
if im.RateLimitMax > 0 {
|
|
rlMax = im.RateLimitMax
|
|
}
|
|
return
|
|
}
|
|
|
|
// NewService creates a new IM service.
|
|
// redisClient may be nil — in that case the service falls back to local
|
|
// in-memory state (Lite / single-instance mode).
|
|
// cfg may be nil — in that case built-in defaults are used.
|
|
func NewService(
|
|
db *gorm.DB,
|
|
sessionService interfaces.SessionService,
|
|
messageService interfaces.MessageService,
|
|
tenantService interfaces.TenantService,
|
|
agentService interfaces.CustomAgentService,
|
|
knowledgeService interfaces.KnowledgeService,
|
|
kbService interfaces.KnowledgeBaseService,
|
|
modelService interfaces.ModelService,
|
|
streamManager interfaces.StreamManager,
|
|
redisClient *redis.Client,
|
|
appCfg *config.Config,
|
|
) *Service {
|
|
// Resolve IM configuration with defaults.
|
|
workers, maxQueue, maxPerUser, globalMaxWorkers, rlWindow, rlMax := resolveIMConfig(appCfg)
|
|
|
|
// Build command registry.
|
|
registry := NewCommandRegistry()
|
|
registry.Register(newHelpCommand(registry))
|
|
registry.Register(newInfoCommand(kbService))
|
|
registry.Register(newSearchCommand(sessionService, kbService))
|
|
registry.Register(newStopCommand())
|
|
registry.Register(newClearCommand())
|
|
|
|
instanceID := uuid.New().String()
|
|
s := &Service{
|
|
db: db,
|
|
sessionService: sessionService,
|
|
messageService: messageService,
|
|
tenantService: tenantService,
|
|
agentService: agentService,
|
|
knowledgeService: knowledgeService,
|
|
kbService: kbService,
|
|
modelService: modelService,
|
|
streamManager: streamManager,
|
|
cmdRegistry: registry,
|
|
channels: make(map[string]*channelState),
|
|
adapterFactories: make(map[string]AdapterFactory),
|
|
rateLimiter: newDistributedLimiter(redisClient, rlWindow, rlMax, instanceID),
|
|
redis: redisClient,
|
|
instanceID: instanceID,
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
// Initialize the QA worker pool and bounded queue.
|
|
s.qaQueue = newQAQueue(workers, maxQueue, maxPerUser, globalMaxWorkers, s.executeQARequest, redisClient)
|
|
s.qaQueue.Start(s.stopCh)
|
|
|
|
// Start periodic cleanup loops.
|
|
// Dedup cleanup is only needed in single-instance mode (local sync.Map);
|
|
// when Redis handles dedup, the TTL on Redis keys handles expiry automatically.
|
|
if redisClient == nil {
|
|
go s.dedupCleanupLoop()
|
|
}
|
|
go s.rateLimiter.cleanupLoop(s.stopCh)
|
|
|
|
if redisClient != nil {
|
|
globalInfo := "unlimited"
|
|
if globalMaxWorkers > 0 {
|
|
globalInfo = fmt.Sprintf("%d", globalMaxWorkers)
|
|
}
|
|
logger.Infof(context.Background(), "[IM] Multi-instance mode enabled (instance=%s, workers=%d, queue=%d, global_max=%s)",
|
|
s.instanceID[:8], workers, maxQueue, globalInfo)
|
|
} else {
|
|
logger.Infof(context.Background(), "[IM] Single-instance mode (no Redis, workers=%d, queue=%d)",
|
|
workers, maxQueue)
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// RegisterAdapterFactory registers a factory for creating adapters for a given platform.
|
|
func (s *Service) RegisterAdapterFactory(platform string, factory AdapterFactory) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.adapterFactories[platform] = factory
|
|
}
|
|
|
|
// Stop gracefully shuts down the service, stopping all channels and background goroutines.
|
|
func (s *Service) Stop() {
|
|
close(s.stopCh)
|
|
s.qaQueue.Stop()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for id, cs := range s.channels {
|
|
s.stopChannelLocked(id, cs)
|
|
}
|
|
}
|
|
|
|
// dedupCleanupLoop periodically cleans up expired entries from the dedup map.
|
|
func (s *Service) dedupCleanupLoop() {
|
|
ticker := time.NewTicker(dedupCleanupInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
cutoff := time.Now().Add(-dedupTTL)
|
|
s.processedMsgs.Range(func(key, value interface{}) bool {
|
|
if t, ok := value.(time.Time); ok && t.Before(cutoff) {
|
|
s.processedMsgs.Delete(key)
|
|
}
|
|
return true
|
|
})
|
|
case <-s.stopCh:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// LoadAndStartChannels loads all enabled channels from the database and starts them.
|
|
func (s *Service) LoadAndStartChannels() error {
|
|
ctx := context.Background()
|
|
var channels []IMChannel
|
|
if err := s.db.Where("enabled = ? AND deleted_at IS NULL", true).Find(&channels).Error; err != nil {
|
|
return fmt.Errorf("load im channels: %w", err)
|
|
}
|
|
|
|
for i := range channels {
|
|
ch := channels[i]
|
|
if err := s.StartChannel(&ch); err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to start channel %s (%s/%s): %v", ch.ID, ch.Platform, ch.Name, err)
|
|
} else {
|
|
logger.Infof(ctx, "[IM] Started channel: id=%s platform=%s name=%s mode=%s agent=%s",
|
|
ch.ID, ch.Platform, ch.Name, ch.Mode, ch.AgentID)
|
|
}
|
|
}
|
|
|
|
logger.Infof(ctx, "[IM] Loaded %d enabled channels", len(channels))
|
|
return nil
|
|
}
|
|
|
|
// StartChannel creates and registers an adapter for the given channel.
|
|
// For WebSocket channels with Redis available, only one instance acquires
|
|
// the leader lock and opens the connection; other instances periodically
|
|
// retry so they can take over if the leader dies.
|
|
func (s *Service) StartChannel(channel *IMChannel) error {
|
|
_, span := tracing.ContextWithSpan(context.Background(), "im.StartChannel")
|
|
defer span.End()
|
|
span.SetAttributes(
|
|
attribute.String("im.channel_id", channel.ID),
|
|
attribute.String("im.platform", channel.Platform),
|
|
attribute.String("im.mode", channel.Mode),
|
|
)
|
|
|
|
s.mu.Lock()
|
|
factory, ok := s.adapterFactories[channel.Platform]
|
|
if !ok {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("no adapter factory for platform: %s", channel.Platform)
|
|
}
|
|
// Stop existing channel if running
|
|
if existing, ok := s.channels[channel.ID]; ok {
|
|
s.stopChannelLocked(channel.ID, existing)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
// For WebSocket / long-poll channels, try leader election to avoid
|
|
// duplicate connections. Only one instance should actively poll or
|
|
// maintain a persistent connection for each channel.
|
|
if (channel.Mode == "websocket" || channel.Mode == "longpoll") && s.redis != nil {
|
|
acquired := s.tryAcquireWSLeader(channel.ID)
|
|
if !acquired {
|
|
logger.Infof(context.Background(),
|
|
"[IM] Channel %s %s owned by another instance, will retry", channel.ID, channel.Mode)
|
|
go s.wsLeaderRetryLoop(channel)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return s.startChannelInternal(channel, factory)
|
|
}
|
|
|
|
// startChannelInternal does the actual adapter creation and registration.
|
|
func (s *Service) startChannelInternal(channel *IMChannel, factory AdapterFactory) error {
|
|
// Build the message handler that delegates to HandleMessage with this channel's config
|
|
msgHandler := func(msgCtx context.Context, msg *IncomingMessage) error {
|
|
return s.HandleMessage(msgCtx, msg, channel.ID)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
adapter, cancelFn, err := factory(ctx, channel, msgHandler)
|
|
if err != nil {
|
|
s.releaseWSLeader(channel.ID) // release lock on failure
|
|
return fmt.Errorf("create adapter: %w", err)
|
|
}
|
|
|
|
// Start leader renewal goroutine for WebSocket / long-poll channels.
|
|
var leaderCancel context.CancelFunc
|
|
if (channel.Mode == "websocket" || channel.Mode == "longpoll") && s.redis != nil {
|
|
leaderCtx, lCancel := context.WithCancel(context.Background())
|
|
leaderCancel = lCancel
|
|
go s.wsLeaderRenewLoop(leaderCtx, channel.ID)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.channels[channel.ID] = &channelState{
|
|
Channel: channel,
|
|
Adapter: adapter,
|
|
Cancel: cancelFn,
|
|
leaderCancel: leaderCancel,
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// StopChannel stops and removes a running channel.
|
|
func (s *Service) StopChannel(channelID string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if cs, ok := s.channels[channelID]; ok {
|
|
s.stopChannelLocked(channelID, cs)
|
|
}
|
|
}
|
|
|
|
// stopChannelLocked stops a channel and removes it from the map.
|
|
// Caller must hold s.mu.
|
|
func (s *Service) stopChannelLocked(channelID string, cs *channelState) {
|
|
if cs.leaderCancel != nil {
|
|
cs.leaderCancel()
|
|
}
|
|
if cs.Cancel != nil {
|
|
cs.Cancel()
|
|
}
|
|
delete(s.channels, channelID)
|
|
// For long-poll channels, do NOT release the leader lock immediately.
|
|
// Let it expire naturally via TTL so the old poll goroutine has time to
|
|
// fully drain before another instance takes over. This prevents a brief
|
|
// dual-writer window where both old and new instances process messages.
|
|
// For websocket channels, the connection closes synchronously, so
|
|
// immediate release is safe.
|
|
if cs.Channel != nil && cs.Channel.Mode == "longpoll" {
|
|
logger.Infof(context.Background(), "[IM] Stopped longpoll channel: id=%s (leader lock will expire via TTL)", channelID)
|
|
} else {
|
|
s.releaseWSLeader(channelID)
|
|
logger.Infof(context.Background(), "[IM] Stopped channel: id=%s", channelID)
|
|
}
|
|
}
|
|
|
|
// ── WebSocket leader election ───────────────────────────────────────────────
|
|
|
|
// tryAcquireWSLeader attempts to acquire the Redis lock for a WebSocket channel.
|
|
// Returns true if this instance is now the leader.
|
|
func (s *Service) tryAcquireWSLeader(channelID string) bool {
|
|
if s.redis == nil {
|
|
return true // single-instance mode: always leader
|
|
}
|
|
key := RedisKeyLeader + channelID
|
|
ok, err := s.redis.SetNX(context.Background(), key, s.instanceID, wsLeaderTTL).Result()
|
|
if err != nil {
|
|
logger.Warnf(context.Background(), "[IM] Redis leader election failed for %s: %v, assuming leader", channelID, err)
|
|
return true // Redis error: proceed anyway to avoid channel getting stuck
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// releaseWSLeader releases the Redis leader lock for a WebSocket channel,
|
|
// but only if this instance owns it.
|
|
func (s *Service) releaseWSLeader(channelID string) {
|
|
if s.redis == nil {
|
|
return
|
|
}
|
|
key := RedisKeyLeader + channelID
|
|
// Only delete if we own it (compare-and-delete via Lua).
|
|
script := redis.NewScript(`
|
|
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
return redis.call('DEL', KEYS[1])
|
|
end
|
|
return 0
|
|
`)
|
|
script.Run(context.Background(), s.redis, []string{key}, s.instanceID)
|
|
}
|
|
|
|
// wsLeaderRenewLoop periodically refreshes the leader lock TTL.
|
|
// Stops when ctx is cancelled (channel stopped) or if the lock is lost.
|
|
func (s *Service) wsLeaderRenewLoop(ctx context.Context, channelID string) {
|
|
key := RedisKeyLeader + channelID
|
|
ticker := time.NewTicker(wsLeaderRenewInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
// Only renew if we still own the lock.
|
|
script := redis.NewScript(`
|
|
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
return 1
|
|
end
|
|
return 0
|
|
`)
|
|
result, err := script.Run(ctx, s.redis, []string{key}, s.instanceID, wsLeaderTTL.Milliseconds()).Int64()
|
|
if err != nil || result == 0 {
|
|
logger.Warnf(context.Background(),
|
|
"[IM] Lost leadership for channel %s, stopping adapter", channelID)
|
|
s.StopChannel(channelID)
|
|
return
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// wsLeaderRetryLoop periodically tries to acquire the WebSocket leader lock.
|
|
// When it succeeds, it starts the channel adapter.
|
|
func (s *Service) wsLeaderRetryLoop(channel *IMChannel) {
|
|
ticker := time.NewTicker(wsLeaderRetryInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
// Check if channel is already running (another goroutine may have started it).
|
|
if _, _, ok := s.GetChannelAdapter(channel.ID); ok {
|
|
return
|
|
}
|
|
if s.tryAcquireWSLeader(channel.ID) {
|
|
logger.Infof(context.Background(),
|
|
"[IM] Acquired leadership for channel %s, starting adapter", channel.ID)
|
|
s.mu.RLock()
|
|
factory, ok := s.adapterFactories[channel.Platform]
|
|
s.mu.RUnlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := s.startChannelInternal(channel, factory); err != nil {
|
|
logger.Warnf(context.Background(),
|
|
"[IM] Failed to start channel %s after acquiring leadership: %v", channel.ID, err)
|
|
}
|
|
return
|
|
}
|
|
case <-s.stopCh:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Cross-instance /stop via StreamManager ───────────────────────────────────
|
|
//
|
|
// The mechanism mirrors the web StopSession flow:
|
|
// 1. /stop writes a stop StreamEvent to StreamManager (keyed by sessionID + messageID)
|
|
// 2. A per-request watcher polls StreamManager and cancels the context on detection
|
|
//
|
|
// A Redis marker (im:stop:{userKey}) is kept as a lightweight pre-execution
|
|
// check for requests that haven't created an assistant message yet.
|
|
|
|
// checkAndClearStopMarker checks if a pre-execution /stop marker exists for
|
|
// the given userKey. If found, it deletes the marker and returns true.
|
|
func (s *Service) checkAndClearStopMarker(ctx context.Context, userKey string) bool {
|
|
if s.redis == nil {
|
|
return false
|
|
}
|
|
stopKey := RedisKeyStop + userKey
|
|
deleted, err := s.redis.Del(ctx, stopKey).Result()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return deleted > 0
|
|
}
|
|
|
|
// storeInflightMapping writes the (sessionID, assistantMessageID) to Redis so
|
|
// that /stop on any instance can look it up and write to StreamManager.
|
|
func (s *Service) storeInflightMapping(ctx context.Context, userKey, sessionID, messageID string) {
|
|
if s.redis == nil {
|
|
return
|
|
}
|
|
val := sessionID + ":" + messageID
|
|
if err := s.redis.Set(ctx, RedisKeyInflight+userKey, val, 10*time.Minute).Err(); err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to store inflight mapping: %v", err)
|
|
}
|
|
}
|
|
|
|
// clearInflightMapping removes the inflight mapping from Redis.
|
|
func (s *Service) clearInflightMapping(ctx context.Context, userKey string) {
|
|
if s.redis == nil {
|
|
return
|
|
}
|
|
s.redis.Del(ctx, RedisKeyInflight+userKey)
|
|
}
|
|
|
|
// loadInflightMapping retrieves (sessionID, messageID) from Redis.
|
|
func (s *Service) loadInflightMapping(ctx context.Context, userKey string) (sessionID, messageID string, ok bool) {
|
|
if s.redis == nil {
|
|
return "", "", false
|
|
}
|
|
val, err := s.redis.Get(ctx, RedisKeyInflight+userKey).Result()
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
parts := strings.SplitN(val, ":", 2)
|
|
if len(parts) != 2 {
|
|
return "", "", false
|
|
}
|
|
return parts[0], parts[1], true
|
|
}
|
|
|
|
// writeStopEvent writes a stop event to StreamManager, matching the web
|
|
// StopSession pattern. The QA watcher goroutine detects it and cancels.
|
|
func (s *Service) writeStopEvent(ctx context.Context, sessionID, messageID string) {
|
|
stopEvt := interfaces.StreamEvent{
|
|
ID: fmt.Sprintf("stop-%d", time.Now().UnixNano()),
|
|
Type: types.ResponseType(event.EventStop),
|
|
Content: "",
|
|
Done: true,
|
|
Timestamp: time.Now(),
|
|
Data: map[string]interface{}{
|
|
"session_id": sessionID,
|
|
"message_id": messageID,
|
|
"reason": "user_requested",
|
|
"source": "im",
|
|
},
|
|
}
|
|
if err := s.streamManager.AppendEvent(ctx, sessionID, messageID, stopEvt); err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to write stop event to StreamManager: %v", err)
|
|
}
|
|
}
|
|
|
|
// watchStreamManagerStop polls StreamManager for stop events and cancels the
|
|
// QA context when one is detected. This is the IM equivalent of the web SSE
|
|
// handler's stop detection loop. Exits when ctx is done.
|
|
func (s *Service) watchStreamManagerStop(ctx context.Context, sessionID, messageID string, cancel context.CancelFunc) {
|
|
ticker := time.NewTicker(stopPollInterval)
|
|
defer ticker.Stop()
|
|
|
|
offset := 0
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
events, newOffset, err := s.streamManager.GetEvents(ctx, sessionID, messageID, offset)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, evt := range events {
|
|
if evt.Type == types.ResponseType(event.EventStop) {
|
|
logger.Infof(ctx, "[IM] Stop event from StreamManager, cancelling: session=%s message=%s",
|
|
sessionID, messageID)
|
|
cancel()
|
|
return
|
|
}
|
|
}
|
|
offset = newOffset
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetChannelAdapter returns the adapter and channel config for a given channel ID.
|
|
func (s *Service) GetChannelAdapter(channelID string) (Adapter, *IMChannel, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
cs, ok := s.channels[channelID]
|
|
if !ok {
|
|
return nil, nil, false
|
|
}
|
|
return cs.Adapter, cs.Channel, true
|
|
}
|
|
|
|
// GetChannelByID loads a channel from the database.
|
|
func (s *Service) GetChannelByID(channelID string) (*IMChannel, error) {
|
|
var ch IMChannel
|
|
if err := s.db.Where("id = ? AND deleted_at IS NULL", channelID).First(&ch).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &ch, nil
|
|
}
|
|
|
|
// GetChannelByIDAndTenant loads a channel from the database, scoped to a specific tenant.
|
|
func (s *Service) GetChannelByIDAndTenant(channelID string, tenantID uint64) (*IMChannel, error) {
|
|
var ch IMChannel
|
|
if err := s.db.Where("id = ? AND tenant_id = ? AND deleted_at IS NULL", channelID, tenantID).First(&ch).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &ch, nil
|
|
}
|
|
|
|
// isDuplicate checks if a message has already been processed.
|
|
//
|
|
// Multi-instance mode (Redis available): uses Redis SetNX for cross-instance
|
|
// deduplication. If Redis fails, returns true (fail-closed) to prevent
|
|
// duplicate processing across instances — a dropped message can be retried
|
|
// by the user, but a duplicate LLM response wastes resources and confuses.
|
|
//
|
|
// Single-instance mode (no Redis): uses a local sync.Map, which is sufficient
|
|
// when only one instance receives messages.
|
|
func (s *Service) isDuplicate(ctx context.Context, messageID string) bool {
|
|
if s.redis != nil {
|
|
key := RedisKeyDedup + messageID
|
|
ok, err := s.redis.SetNX(ctx, key, "1", dedupTTL).Result()
|
|
if err == nil {
|
|
return !ok // SetNX returns true when key was newly set (not a duplicate)
|
|
}
|
|
// Redis is configured but failed — fail-closed to avoid cross-instance
|
|
// duplicate processing. The user can simply resend the message.
|
|
logger.Errorf(ctx, "[IM] Redis dedup failed (fail-closed, message dropped): %v", err)
|
|
return true
|
|
}
|
|
// Single-instance mode: local dedup is sufficient.
|
|
_, loaded := s.processedMsgs.LoadOrStore(messageID, time.Now())
|
|
return loaded
|
|
}
|
|
|
|
// HandleMessage processes an incoming IM message end-to-end using channel config.
|
|
func (s *Service) HandleMessage(ctx context.Context, msg *IncomingMessage, channelID string) error {
|
|
ctx, span := tracing.ContextWithSpan(ctx, "im.HandleMessage")
|
|
defer span.End()
|
|
span.SetAttributes(
|
|
attribute.String("im.channel_id", channelID),
|
|
attribute.String("im.platform", string(msg.Platform)),
|
|
attribute.String("im.user_id", msg.UserID),
|
|
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)
|
|
if msg.MessageID != "" {
|
|
if s.isDuplicate(ctx, msg.MessageID) {
|
|
logger.Infof(ctx, "[IM] Skipping duplicate message: %s", msg.MessageID)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Reject overly long messages to protect the QA pipeline
|
|
contentRunes := []rune(msg.Content)
|
|
if len(contentRunes) > maxContentLength {
|
|
logger.Warnf(ctx, "[IM] Message too long (%d runes), truncating to %d", len(contentRunes), maxContentLength)
|
|
msg.Content = string(contentRunes[:maxContentLength])
|
|
}
|
|
|
|
// Get channel config (moved before rate limit so we can reply to the user)
|
|
adapter, channel, ok := s.GetChannelAdapter(channelID)
|
|
if !ok {
|
|
// Try loading from DB (channel might have been created after service start)
|
|
ch, err := s.GetChannelByID(channelID)
|
|
if err != nil {
|
|
return fmt.Errorf("channel not found: %s", channelID)
|
|
}
|
|
// Start it dynamically
|
|
if err := s.StartChannel(ch); err != nil {
|
|
return fmt.Errorf("start channel %s: %w", channelID, err)
|
|
}
|
|
adapter, channel, ok = s.GetChannelAdapter(channelID)
|
|
if !ok {
|
|
return fmt.Errorf("channel adapter not available after start: %s", channelID)
|
|
}
|
|
}
|
|
|
|
span.SetAttributes(attribute.String("im.session_mode", channel.SessionMode))
|
|
|
|
// Resolve threadID for key building — only include in thread mode to avoid
|
|
// leaking thread scope into user-mode rate limit / inflight keys.
|
|
threadID := ""
|
|
if channel.SessionMode == string(SessionModeThread) {
|
|
threadID = msg.ThreadID
|
|
}
|
|
|
|
// Rate limit: enforce per-user sliding window to prevent abuse.
|
|
// Slash-commands (/stop, /clear, etc.) bypass rate limiting so the user
|
|
// always retains control over the bot even under heavy messaging.
|
|
isCommand := s.cmdRegistry.IsRegistered(msg.Content)
|
|
if !isCommand {
|
|
rateLimitKey := makeUserKey(channelID, msg.UserID, msg.ChatID, threadID)
|
|
if !s.rateLimiter.Allow(rateLimitKey) {
|
|
logger.Warnf(ctx, "[IM] Rate limited: channel=%s user=%s chat=%s", channelID, msg.UserID, msg.ChatID)
|
|
_ = adapter.SendReply(ctx, msg, &ReplyMessage{
|
|
Content: "您的消息发送过于频繁,请稍后再试。",
|
|
IsFinal: true,
|
|
})
|
|
return nil
|
|
}
|
|
}
|
|
|
|
tenantID := channel.TenantID
|
|
agentID := channel.AgentID
|
|
|
|
logger.Infof(ctx, "[IM] HandleMessage: channel=%s platform=%s user=%s chat=%s msgtype=%s content_len=%d",
|
|
channelID, msg.Platform, msg.UserID, msg.ChatID, msg.MessageType, len(msg.Content))
|
|
logger.Debugf(ctx, "[IM] HandleMessage detail: msgid=%s filekey=%s filename=%s",
|
|
msg.MessageID, msg.FileKey, msg.FileName)
|
|
|
|
// ── File/Image message shortcut ──
|
|
// If the message is a file or image and the channel has a knowledge_base_id configured,
|
|
// handle it separately without entering the QA pipeline.
|
|
if (msg.MessageType == MessageTypeFile || msg.MessageType == MessageTypeImage) && channel.KnowledgeBaseID != "" {
|
|
return s.handleFileMessage(ctx, msg, adapter, channel)
|
|
}
|
|
|
|
// ── Non-text message without text content ──
|
|
// If the message is an image/file/video but has no text content, the QA pipeline
|
|
// cannot do anything useful (no vision support in IM yet). Sending an empty query
|
|
// to KB retrieval would return irrelevant results and cause hallucination.
|
|
if msg.Content == "" && (msg.MessageType == MessageTypeImage || msg.MessageType == MessageTypeFile) {
|
|
logger.Infof(ctx, "[IM] Skipping QA for non-text message without content: type=%s", msg.MessageType)
|
|
if err := adapter.SendReply(ctx, msg, &ReplyMessage{
|
|
Content: "当前渠道未配置文件知识库,无法处理图片/文件消息。请在渠道设置中配置文件知识库后再发送,或直接用文字描述您的问题。",
|
|
IsFinal: true,
|
|
}); err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to send non-text hint reply: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 1. Get tenant
|
|
tenant, err := s.tenantService.GetTenantByID(ctx, tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("get tenant: %w", err)
|
|
}
|
|
sessionCtx := context.WithValue(ctx, types.TenantIDContextKey, tenantID)
|
|
sessionCtx = context.WithValue(sessionCtx, types.TenantInfoContextKey, tenant)
|
|
|
|
// 2. Resolve or create a WeKnora session
|
|
channelSession, err := s.resolveSession(sessionCtx, msg, tenantID, agentID, channelID, channel.SessionMode)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve session: %w", err)
|
|
}
|
|
|
|
// 3. Resolve custom agent (optional)
|
|
var customAgent *types.CustomAgent
|
|
if agentID != "" {
|
|
agent, err := s.agentService.GetAgentByID(sessionCtx, agentID)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to get agent %s: %v, using default", agentID, err)
|
|
} else {
|
|
customAgent = agent
|
|
}
|
|
}
|
|
|
|
// ── Slash-command dispatch ──
|
|
// Commands are handled before the QA pipeline so they respond instantly.
|
|
if cmd, args, ok := s.cmdRegistry.Parse(msg.Content); ok {
|
|
return s.handleCommand(sessionCtx, cmd, args, msg, adapter, channel, channelSession, customAgent)
|
|
}
|
|
// Unrecognised slash-word: show help hint instead of sending to QA.
|
|
if LooksLikeCommand(msg.Content) {
|
|
_ = adapter.SendReply(ctx, msg, &ReplyMessage{
|
|
Content: "未知指令,发送 `/help` 查看所有可用指令。",
|
|
IsFinal: true,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// 4. Get the WeKnora session
|
|
session, err := s.sessionService.GetSession(sessionCtx, channelSession.SessionID)
|
|
if err != nil {
|
|
// The underlying session may have been deleted from the UI while the
|
|
// ChannelSession mapping still exists (GORM soft-delete does not trigger
|
|
// SQL ON DELETE CASCADE). Recover by soft-deleting the stale mapping and
|
|
// re-creating a fresh session so the IM bot doesn't become permanently
|
|
// unresponsive. (fixes #1046)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
logger.Warnf(ctx, "[IM] Session %s not found (deleted?), recycling stale channel session %s",
|
|
channelSession.SessionID, channelSession.ID)
|
|
if delErr := s.db.Delete(&ChannelSession{}, "id = ?", channelSession.ID).Error; delErr != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to delete stale channel session %s: %v", channelSession.ID, delErr)
|
|
}
|
|
channelSession, err = s.resolveSession(sessionCtx, msg, tenantID, agentID, channelID, channel.SessionMode)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve session (retry): %w", err)
|
|
}
|
|
session, err = s.sessionService.GetSession(sessionCtx, channelSession.SessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("get session (retry): %w", err)
|
|
}
|
|
} else {
|
|
return fmt.Errorf("get session: %w", err)
|
|
}
|
|
}
|
|
|
|
// 5. Enqueue the QA request into the bounded worker pool.
|
|
// The worker pool controls LLM concurrency and provides backpressure.
|
|
qaCtx, qaCancel := context.WithCancel(sessionCtx)
|
|
userKey := makeUserKey(channelID, msg.UserID, msg.ChatID, threadID)
|
|
|
|
req := &qaRequest{
|
|
ctx: qaCtx,
|
|
cancel: qaCancel,
|
|
msg: msg,
|
|
session: session,
|
|
agent: customAgent,
|
|
adapter: adapter,
|
|
channel: channel,
|
|
channelID: channelID,
|
|
fileSvc: buildTenantFileService(tenant),
|
|
userKey: userKey,
|
|
}
|
|
|
|
pos, enqueueErr := s.qaQueue.Enqueue(req)
|
|
if enqueueErr != nil {
|
|
qaCancel()
|
|
span.AddEvent("queue rejected", trace.WithAttributes(attribute.String("reason", enqueueErr.Error())))
|
|
logger.Warnf(ctx, "[IM] Queue rejected: user=%s reason=%v", msg.UserID, enqueueErr)
|
|
_ = adapter.SendReply(ctx, msg, &ReplyMessage{
|
|
Content: "当前排队人数较多,请稍后再试。",
|
|
IsFinal: true,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
if pos > 0 {
|
|
logger.Infof(ctx, "[IM] Enqueued: user=%s pos=%d depth=%d", msg.UserID, pos, s.qaQueue.Metrics().Depth)
|
|
// In multi-instance mode the local queue position does not reflect global
|
|
// depth, so use a generic "queued" hint instead of an exact number.
|
|
queueMsg := fmt.Sprintf("收到,前面还有 %d 条消息在处理,请稍候 ⏳", pos)
|
|
if s.redis != nil {
|
|
queueMsg = "收到,当前排队中,请稍候 ⏳"
|
|
}
|
|
_ = adapter.SendReply(ctx, msg, &ReplyMessage{
|
|
Content: queueMsg,
|
|
IsFinal: true,
|
|
})
|
|
} else {
|
|
logger.Infof(ctx, "[IM] Enqueued: user=%s pos=0 (immediate)", msg.UserID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeQARequest is the worker handler that runs the QA pipeline for a queued request.
|
|
// It is called by qaQueue workers and must not block indefinitely.
|
|
func (s *Service) executeQARequest(req *qaRequest) {
|
|
ctx, span := tracing.ContextWithSpan(req.ctx, "im.ExecuteQA")
|
|
defer span.End()
|
|
span.SetAttributes(
|
|
attribute.String("im.channel_id", req.channelID),
|
|
attribute.String("im.user_key", req.userKey),
|
|
attribute.String("im.user_id", req.msg.UserID),
|
|
)
|
|
defer req.cancel()
|
|
|
|
// Track in-flight request so /stop can cancel it.
|
|
entry := &inflightEntry{cancel: req.cancel}
|
|
s.inflight.Store(req.userKey, entry)
|
|
defer s.inflight.Delete(req.userKey)
|
|
|
|
// Check if a pre-execution /stop was issued while this request was queued.
|
|
if s.checkAndClearStopMarker(ctx, req.userKey) {
|
|
span.AddEvent("cancelled by remote /stop before execution")
|
|
logger.Infof(ctx, "[IM] Request cancelled by remote /stop before execution: %s", req.userKey)
|
|
return
|
|
}
|
|
|
|
// NOTE: StreamManager-based stop detection is started inside handleMessageStream /
|
|
// runQA after the assistant message is created (that's when we have the
|
|
// sessionID + messageID needed to poll StreamManager).
|
|
|
|
// kbIDs is left empty so the QA pipeline resolves them from the agent config.
|
|
var kbIDs []string
|
|
|
|
// Determine output mode from channel config.
|
|
streamDisabled := req.channel.OutputMode == "full"
|
|
|
|
// If the adapter supports streaming and output is not "full", use streaming.
|
|
if !streamDisabled {
|
|
if streamer, ok := req.adapter.(StreamSender); ok {
|
|
if err := s.handleMessageStream(ctx, req.msg, req.session, req.agent, kbIDs, streamer, req.adapter, req.userKey, req.fileSvc); err != nil {
|
|
span.SetStatus(codes.Error, err.Error())
|
|
logger.Errorf(ctx, "[IM] Stream QA failed: %v", err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Non-streaming fallback: collect full answer then send.
|
|
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)
|
|
answer = "抱歉,处理您的问题时出现了异常,请稍后再试。"
|
|
}
|
|
|
|
reply := &ReplyMessage{
|
|
Content: cleanIMContent(ctx, answer, req.fileSvc),
|
|
IsFinal: true,
|
|
}
|
|
if err := req.adapter.SendReply(ctx, req.msg, reply); err != nil {
|
|
logger.Errorf(ctx, "[IM] Send reply failed: %v", err)
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "[IM] Reply sent: channel=%s platform=%s user=%s answer_len=%d",
|
|
req.channelID, req.msg.Platform, req.msg.UserID, len(answer))
|
|
}
|
|
|
|
// handleCommand executes a slash-command and sends the result back to the user.
|
|
// It also handles side effects (ActionClear, ActionStop).
|
|
func (s *Service) handleCommand(
|
|
ctx context.Context,
|
|
cmd Command,
|
|
args []string,
|
|
msg *IncomingMessage,
|
|
adapter Adapter,
|
|
channel *IMChannel,
|
|
channelSession *ChannelSession,
|
|
customAgent *types.CustomAgent,
|
|
) error {
|
|
ctx, span := tracing.ContextWithSpan(ctx, "im.HandleCommand")
|
|
defer span.End()
|
|
span.SetAttributes(
|
|
attribute.String("im.command", cmd.Name()),
|
|
attribute.String("im.channel_id", channel.ID),
|
|
attribute.String("im.user_id", msg.UserID),
|
|
)
|
|
|
|
agentName := ""
|
|
if customAgent != nil {
|
|
agentName = customAgent.Name
|
|
}
|
|
|
|
cmdCtx := &CommandContext{
|
|
Incoming: msg,
|
|
Session: channelSession,
|
|
TenantID: channel.TenantID,
|
|
AgentName: agentName,
|
|
CustomAgent: customAgent,
|
|
ChannelOutputMode: channel.OutputMode,
|
|
}
|
|
|
|
result, err := cmd.Execute(ctx, cmdCtx, args)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "[IM] Command /%s error: %v", cmd.Name(), err)
|
|
_ = adapter.SendReply(ctx, msg, &ReplyMessage{
|
|
Content: "抱歉,执行指令时出现了异常,请稍后再试。",
|
|
IsFinal: true,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// Handle service-level side effects.
|
|
switch result.Action {
|
|
case ActionClear:
|
|
// Soft-delete the current ChannelSession and clear the LLM context
|
|
// so the next message creates a completely fresh conversation.
|
|
if err := s.db.Model(&ChannelSession{}).
|
|
Where("id = ?", channelSession.ID).
|
|
Update("deleted_at", time.Now()).Error; err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to soft-delete channel session: %v", err)
|
|
}
|
|
if err := s.sessionService.ClearContext(ctx, channelSession.SessionID); err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to clear session context: %v", err)
|
|
}
|
|
case ActionStop:
|
|
stopThreadID := ""
|
|
if channel.SessionMode == string(SessionModeThread) {
|
|
stopThreadID = msg.ThreadID
|
|
}
|
|
inflightKey := makeUserKey(channel.ID, msg.UserID, msg.ChatID, stopThreadID)
|
|
|
|
// 1. Try local cancel: remove from queue or cancel in-flight.
|
|
var localSessionID, localMessageID string
|
|
localStopped := s.qaQueue.Remove(inflightKey)
|
|
if localStopped {
|
|
logger.Infof(ctx, "[IM] Cancelled queued QA: key=%s", inflightKey)
|
|
} else if raw, loaded := s.inflight.LoadAndDelete(inflightKey); loaded {
|
|
e := raw.(*inflightEntry)
|
|
e.cancel()
|
|
localStopped = true
|
|
localSessionID = e.sessionID
|
|
localMessageID = e.assistantMessageID
|
|
logger.Infof(ctx, "[IM] Cancelled in-flight QA: key=%s", inflightKey)
|
|
}
|
|
|
|
// 2. Write stop event to StreamManager (same as web StopSession).
|
|
// For local stop with known IDs, write directly.
|
|
// For cross-instance, look up Redis inflight mapping to get IDs.
|
|
sessionID, messageID := localSessionID, localMessageID
|
|
if sessionID == "" || messageID == "" {
|
|
// Try cross-instance lookup.
|
|
sessionID, messageID, _ = s.loadInflightMapping(ctx, inflightKey)
|
|
}
|
|
if sessionID != "" && messageID != "" {
|
|
s.writeStopEvent(ctx, sessionID, messageID)
|
|
logger.Infof(ctx, "[IM] Wrote stop event to StreamManager: session=%s message=%s", sessionID, messageID)
|
|
}
|
|
|
|
// 3. Set Redis marker as fallback for requests not yet executing
|
|
// (no assistant message yet → no StreamManager entry to poll).
|
|
if s.redis != nil {
|
|
s.redis.Set(ctx, RedisKeyStop+inflightKey, "1", stopMarkerTTL)
|
|
}
|
|
|
|
if !localStopped && sessionID == "" {
|
|
logger.Infof(ctx, "[IM] Set cross-instance stop marker (no inflight found): key=%s", inflightKey)
|
|
}
|
|
}
|
|
|
|
// Send the command reply, respecting the configured output mode.
|
|
sent := false
|
|
if channel.OutputMode != "full" {
|
|
if streamer, ok := adapter.(StreamSender); ok {
|
|
if err := s.sendStreamReply(ctx, msg, streamer, result.Content); err != nil {
|
|
logger.Warnf(ctx, "[IM] Stream reply for command /%s failed, falling back: %v", cmd.Name(), err)
|
|
} else {
|
|
sent = true
|
|
}
|
|
}
|
|
}
|
|
if !sent {
|
|
_ = adapter.SendReply(ctx, msg, &ReplyMessage{
|
|
Content: result.Content,
|
|
IsFinal: true,
|
|
})
|
|
}
|
|
|
|
logger.Infof(ctx, "[IM] Command /%s executed: channel=%s user=%s action=%d",
|
|
cmd.Name(), channel.ID, msg.UserID, result.Action)
|
|
return nil
|
|
}
|
|
|
|
// sendStreamReply sends a complete content string via the streaming interface
|
|
// (StartStream → SendStreamChunk → EndStream). This is used for command replies
|
|
// when the output mode is set to "stream", so they visually match QA responses.
|
|
func (s *Service) sendStreamReply(ctx context.Context, msg *IncomingMessage, streamer StreamSender, content string) error {
|
|
streamID, err := streamer.StartStream(ctx, msg)
|
|
if err != nil {
|
|
return fmt.Errorf("start stream: %w", err)
|
|
}
|
|
if err := streamer.SendStreamChunk(ctx, msg, streamID, content); err != nil {
|
|
return fmt.Errorf("send stream chunk: %w", err)
|
|
}
|
|
if err := streamer.EndStream(ctx, msg, streamID); err != nil {
|
|
return fmt.Errorf("end stream: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveSession dispatches to the appropriate session resolution strategy
|
|
// based on the channel's session mode.
|
|
func (s *Service) resolveSession(ctx context.Context, msg *IncomingMessage, tenantID uint64, agentID string, imChannelID string, sessionMode string) (*ChannelSession, error) {
|
|
switch SessionMode(sessionMode) {
|
|
case SessionModeThread:
|
|
return s.resolveThreadSession(ctx, msg, tenantID, agentID, imChannelID)
|
|
default: // SessionModeUser
|
|
return s.resolveUserSession(ctx, msg, tenantID, agentID, imChannelID)
|
|
}
|
|
}
|
|
|
|
// buildUserSessionTitle produces a human-distinguishable title for a user-mode
|
|
// IM session. Platform adapters only surface ChatID, not a readable chat name,
|
|
// so we fall back to short ID suffixes to keep group/DM sessions visually distinct.
|
|
func buildUserSessionTitle(msg *IncomingMessage) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "[%s] ", msg.Platform)
|
|
if msg.UserName != "" {
|
|
b.WriteString(msg.UserName)
|
|
} else if msg.UserID != "" {
|
|
b.WriteString("user ")
|
|
b.WriteString(shortID(msg.UserID))
|
|
} else {
|
|
b.WriteString("user")
|
|
}
|
|
if msg.ChatType == ChatTypeGroup && msg.ChatID != "" {
|
|
fmt.Fprintf(&b, " · group %s", shortID(msg.ChatID))
|
|
} else if msg.ChatType == ChatTypeDirect {
|
|
b.WriteString(" · dm")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// buildThreadSessionTitle produces a title for a thread-mode IM session.
|
|
// In thread mode different users can share one session, so the user name is
|
|
// omitted and chat/thread IDs carry the distinguishing information.
|
|
func buildThreadSessionTitle(msg *IncomingMessage) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "[%s] ", msg.Platform)
|
|
if msg.ChatID != "" {
|
|
fmt.Fprintf(&b, "chat %s · ", shortID(msg.ChatID))
|
|
}
|
|
b.WriteString("thread ")
|
|
b.WriteString(shortID(msg.ThreadID))
|
|
return b.String()
|
|
}
|
|
|
|
// shortID returns the last 8 characters of id, or id itself when shorter.
|
|
// Used to keep long platform IDs readable inside titles without losing uniqueness.
|
|
func shortID(id string) string {
|
|
if len(id) > 8 {
|
|
return id[len(id)-8:]
|
|
}
|
|
return id
|
|
}
|
|
|
|
// resolveUserSession finds or creates a ChannelSession keyed by (platform, user_id, chat_id, tenant_id, agent_id).
|
|
// This is the original session resolution strategy.
|
|
func (s *Service) resolveUserSession(ctx context.Context, msg *IncomingMessage, tenantID uint64, agentID string, imChannelID string) (*ChannelSession, error) {
|
|
var cs ChannelSession
|
|
result := s.db.Where("platform = ? AND user_id = ? AND chat_id = ? AND tenant_id = ? AND agent_id = ? AND deleted_at IS NULL",
|
|
string(msg.Platform), msg.UserID, msg.ChatID, tenantID, agentID).
|
|
First(&cs)
|
|
|
|
if result.Error == nil {
|
|
return &cs, nil
|
|
}
|
|
|
|
if result.Error != gorm.ErrRecordNotFound {
|
|
return nil, fmt.Errorf("query channel session: %w", result.Error)
|
|
}
|
|
|
|
// Create a new WeKnora session
|
|
title := buildUserSessionTitle(msg)
|
|
|
|
newSession := &types.Session{
|
|
TenantID: tenantID,
|
|
Title: title,
|
|
Description: fmt.Sprintf("Auto-created from %s IM integration", msg.Platform),
|
|
}
|
|
|
|
createdSession, err := s.sessionService.CreateSession(ctx, newSession)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create session: %w", err)
|
|
}
|
|
|
|
// Create the channel-session mapping; use a unique constraint fallback
|
|
// to handle concurrent creation attempts for the same channel.
|
|
cs = ChannelSession{
|
|
Platform: string(msg.Platform),
|
|
UserID: msg.UserID,
|
|
ChatID: msg.ChatID,
|
|
SessionID: createdSession.ID,
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
IMChannelID: imChannelID,
|
|
}
|
|
if err := s.db.Create(&cs).Error; err != nil {
|
|
if delErr := s.db.Where("id = ?", createdSession.ID).Delete(createdSession).Error; delErr != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to clean up orphaned session %s: %v", createdSession.ID, delErr)
|
|
}
|
|
var existing ChannelSession
|
|
if findErr := s.db.Where("platform = ? AND user_id = ? AND chat_id = ? AND tenant_id = ? AND agent_id = ? AND deleted_at IS NULL",
|
|
string(msg.Platform), msg.UserID, msg.ChatID, tenantID, agentID).
|
|
First(&existing).Error; findErr != nil {
|
|
return nil, fmt.Errorf("create channel session: %w (lookup fallback: %v)", err, findErr)
|
|
}
|
|
return &existing, nil
|
|
}
|
|
|
|
logger.Infof(ctx, "[IM] Created new session mapping: channel=%s/%s/%s -> session=%s",
|
|
msg.Platform, msg.UserID, msg.ChatID, createdSession.ID)
|
|
|
|
return &cs, nil
|
|
}
|
|
|
|
// resolveThreadSession finds or creates a ChannelSession keyed by (platform, chat_id, thread_id, tenant_id, agent_id).
|
|
// In thread mode, each message thread gets its own session. Multiple users in the
|
|
// same thread share the same session. Top-level messages use their own ID as
|
|
// ThreadID, creating a new session per top-level message.
|
|
func (s *Service) resolveThreadSession(ctx context.Context, msg *IncomingMessage, tenantID uint64, agentID string, imChannelID string) (*ChannelSession, error) {
|
|
threadID := msg.ThreadID
|
|
if threadID == "" {
|
|
// Defense-in-depth: frontend blocks thread mode for unsupported platforms,
|
|
// but if ThreadID is somehow empty, fall back to user-mode resolution
|
|
// to avoid creating a shared session for all empty-thread messages.
|
|
logger.Warnf(ctx, "[IM] Thread mode but ThreadID is empty (platform=%s chat=%s), falling back to user session", msg.Platform, msg.ChatID)
|
|
return s.resolveUserSession(ctx, msg, tenantID, agentID, imChannelID)
|
|
}
|
|
|
|
var cs ChannelSession
|
|
result := s.db.Where(
|
|
"platform = ? AND chat_id = ? AND thread_id = ? AND tenant_id = ? AND agent_id = ? AND deleted_at IS NULL",
|
|
string(msg.Platform), msg.ChatID, threadID, tenantID, agentID,
|
|
).First(&cs)
|
|
|
|
if result.Error == nil {
|
|
return &cs, nil
|
|
}
|
|
|
|
if result.Error != gorm.ErrRecordNotFound {
|
|
return nil, fmt.Errorf("query thread session: %w", result.Error)
|
|
}
|
|
|
|
// Build a session title including chat + thread suffix for traceability.
|
|
title := buildThreadSessionTitle(msg)
|
|
|
|
newSession := &types.Session{
|
|
TenantID: tenantID,
|
|
Title: title,
|
|
Description: fmt.Sprintf("Thread-based session from %s IM", msg.Platform),
|
|
}
|
|
|
|
createdSession, err := s.sessionService.CreateSession(ctx, newSession)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create session: %w", err)
|
|
}
|
|
|
|
cs = ChannelSession{
|
|
Platform: string(msg.Platform),
|
|
UserID: msg.UserID, // record the first creator
|
|
ChatID: msg.ChatID,
|
|
ThreadID: threadID,
|
|
SessionID: createdSession.ID,
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
IMChannelID: imChannelID,
|
|
}
|
|
|
|
if err := s.db.Create(&cs).Error; err != nil {
|
|
// Unique constraint fallback for concurrent creation.
|
|
if delErr := s.db.Where("id = ?", createdSession.ID).Delete(createdSession).Error; delErr != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to clean up orphaned session %s: %v", createdSession.ID, delErr)
|
|
}
|
|
var existing ChannelSession
|
|
if findErr := s.db.Where(
|
|
"platform = ? AND chat_id = ? AND thread_id = ? AND tenant_id = ? AND agent_id = ? AND deleted_at IS NULL",
|
|
string(msg.Platform), msg.ChatID, threadID, tenantID, agentID,
|
|
).First(&existing).Error; findErr != nil {
|
|
return nil, fmt.Errorf("create thread session: %w (lookup fallback: %v)", err, findErr)
|
|
}
|
|
return &existing, nil
|
|
}
|
|
|
|
logger.Infof(ctx, "[IM] Created new thread session: platform=%s thread=%s chat=%s -> session=%s",
|
|
msg.Platform, threadID, msg.ChatID, createdSession.ID)
|
|
return &cs, nil
|
|
}
|
|
|
|
// ── Agent tool call progress formatting ──────────────────────────────
|
|
// These helpers format tool-call / tool-result events as Markdown text
|
|
// that is injected into the streaming reply so IM users can see the
|
|
// agent's reasoning process in real-time.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
// toolDisplayNames maps internal tool function names to user-friendly labels.
|
|
var toolDisplayNames = map[string]string{
|
|
"thinking": "深度思考",
|
|
"todo_write": "制定计划",
|
|
"knowledge_search": "知识库检索",
|
|
"grep_chunks": "关键词搜索",
|
|
"list_knowledge_chunks": "查看文档分块",
|
|
"query_knowledge_graph": "查询知识图谱",
|
|
"get_document_info": "获取文档信息",
|
|
"database_query": "查询数据库",
|
|
"data_analysis": "数据分析",
|
|
"data_schema": "查看数据元信息",
|
|
"web_search": "网络搜索",
|
|
"web_fetch": "网页阅读",
|
|
"read_skill": "读取技能",
|
|
"execute_skill_script": "执行技能脚本",
|
|
"final_answer": "生成回答",
|
|
}
|
|
|
|
// internalToolNames lists tools whose execution should NOT be displayed in IM
|
|
// messages because they are internal reasoning aids (thinking, planning) rather
|
|
// than user-facing actions.
|
|
var internalToolNames = map[string]bool{
|
|
"thinking": true,
|
|
"todo_write": true,
|
|
}
|
|
|
|
// friendlyToolName returns a human-readable name for a tool.
|
|
func friendlyToolName(toolName string) string {
|
|
if display, ok := toolDisplayNames[toolName]; ok {
|
|
return display
|
|
}
|
|
return toolName
|
|
}
|
|
|
|
// isToolVisibleToUser returns true if the tool's execution progress should be
|
|
// displayed to the IM user. Internal reasoning tools (thinking, planning) and
|
|
// the final_answer pseudo-tool are hidden.
|
|
func isToolVisibleToUser(toolName string) bool {
|
|
if toolName == "final_answer" {
|
|
return false
|
|
}
|
|
return !internalToolNames[toolName]
|
|
}
|
|
|
|
// formatToolCallStart returns a plain-text line for a tool invocation (inside <think> block).
|
|
func formatToolCallStart(toolName string) string {
|
|
return fmt.Sprintf("⏳ %s\n", friendlyToolName(toolName))
|
|
}
|
|
|
|
// formatToolCallResult returns a plain-text line for a tool result (inside <think> block).
|
|
func formatToolCallResult(toolName string, success bool, output string) string {
|
|
friendly := friendlyToolName(toolName)
|
|
if success {
|
|
if summary := briefToolSummary(output); summary != "" {
|
|
return fmt.Sprintf("✅ %s · %s\n", friendly, summary)
|
|
}
|
|
return fmt.Sprintf("✅ %s\n", friendly)
|
|
}
|
|
return fmt.Sprintf("⚠️ %s 失败\n", friendly)
|
|
}
|
|
|
|
// briefToolSummary extracts a short human-readable summary from tool output.
|
|
// Returns empty string if no suitable summary can be extracted.
|
|
func briefToolSummary(output string) string {
|
|
const maxRunes = 40
|
|
if output == "" {
|
|
return ""
|
|
}
|
|
output = strings.TrimSpace(output)
|
|
if output == "" {
|
|
return ""
|
|
}
|
|
// Skip structured data (JSON, XML, etc.)
|
|
if output[0] == '{' || output[0] == '[' || output[0] == '<' {
|
|
return ""
|
|
}
|
|
// Take first non-empty line
|
|
if idx := strings.IndexByte(output, '\n'); idx >= 0 {
|
|
output = strings.TrimSpace(output[:idx])
|
|
}
|
|
if output == "" {
|
|
return ""
|
|
}
|
|
runes := []rune(output)
|
|
if len(runes) > maxRunes {
|
|
return string(runes[:maxRunes]) + "..."
|
|
}
|
|
return output
|
|
}
|
|
|
|
// handleMessageStream runs the QA pipeline and streams answer chunks to the IM platform
|
|
// in real-time via the StreamSender interface. Chunks are batched at streamFlushInterval
|
|
// to avoid API rate-limiting.
|
|
func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, session *types.Session, customAgent *types.CustomAgent, kbIDs []string, streamer StreamSender, adapter Adapter, userKey string, fileSvc interfaces.FileService) error {
|
|
// Start the stream on the IM platform (e.g., create Feishu streaming card)
|
|
streamID, err := streamer.StartStream(ctx, msg)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[IM] StartStream failed, falling back to non-streaming: %v", err)
|
|
return s.fallbackNonStream(ctx, msg, session, customAgent, kbIDs, adapter, userKey, fileSvc)
|
|
}
|
|
|
|
// Prepare the QA pipeline
|
|
// No total deadline: each agent round has its own LLMCallTimeout (default 120s).
|
|
// A hard pipeline deadline would kill multi-round agent reasoning prematurely.
|
|
qaCtx, qaCancel := context.WithCancel(ctx)
|
|
defer qaCancel()
|
|
|
|
eventBus := event.NewEventBus()
|
|
|
|
var (
|
|
bufMu sync.Mutex
|
|
buf strings.Builder // buffered content awaiting flush
|
|
answerBuilder strings.Builder // full answer for DB persistence (includes <think>)
|
|
qaErr error
|
|
done = make(chan struct{})
|
|
closeOnce sync.Once
|
|
thinkBlockOpen bool // whether we've opened a <think> block (agent pipeline)
|
|
answerStarted bool // whether the final answer stream has begun
|
|
|
|
// seenToolCalls deduplicates EventAgentToolCall events.
|
|
// The engine emits tool calls twice: once during streaming (pending)
|
|
// and once at execution time. We only show the first occurrence.
|
|
seenToolCalls = make(map[string]bool)
|
|
|
|
// lastCharNewline tracks whether the most recently written character
|
|
// (across flush boundaries) was '\n'. This lets ensureNewlineBefore
|
|
// work correctly even after buf has been Reset by a flush.
|
|
lastCharNewline = true
|
|
streamedAny bool // whether any user-visible content was written to buf
|
|
)
|
|
closeDone := func() { closeOnce.Do(func() { close(done) }) }
|
|
|
|
// bufWrite appends s to buf and updates lastCharNewline. Must hold bufMu.
|
|
bufWrite := func(s string) {
|
|
if s == "" {
|
|
return
|
|
}
|
|
buf.WriteString(s)
|
|
lastCharNewline = s[len(s)-1] == '\n'
|
|
}
|
|
|
|
// ensureNewlineBefore guarantees a '\n' exists before the next write,
|
|
// even if the previous content was already flushed. Must hold bufMu.
|
|
ensureNewlineBefore := func() {
|
|
if !lastCharNewline {
|
|
buf.WriteByte('\n')
|
|
lastCharNewline = true
|
|
}
|
|
}
|
|
|
|
// ensureThinkOpen opens a <think> block if not already open.
|
|
// Used for agent pipeline to wrap thinking + tool calls. Must hold bufMu.
|
|
ensureThinkOpen := func() {
|
|
if !thinkBlockOpen {
|
|
thinkBlockOpen = true
|
|
bufWrite("<think>\n")
|
|
}
|
|
}
|
|
|
|
// Subscribe to answer chunks.
|
|
// Non-agent pipeline: content may contain <think>...</think> from the model — pass through as-is.
|
|
// Agent pipeline: we've already opened a <think> block via EventAgentThought/ToolCall,
|
|
// so we close it before streaming the answer.
|
|
eventBus.On(event.EventAgentFinalAnswer, func(_ context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentFinalAnswerData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
bufMu.Lock()
|
|
answerBuilder.WriteString(data.Content)
|
|
|
|
if thinkBlockOpen && !answerStarted {
|
|
answerStarted = true
|
|
bufWrite("\n</think>\n\n")
|
|
}
|
|
|
|
bufWrite(data.Content)
|
|
streamedAny = true
|
|
bufMu.Unlock()
|
|
|
|
if data.Done {
|
|
closeDone()
|
|
}
|
|
return nil
|
|
})
|
|
|
|
eventBus.On(event.EventError, func(_ context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.ErrorData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
logger.Errorf(ctx, "[IM] QA stream error: %s", data.Error)
|
|
bufMu.Lock()
|
|
qaErr = fmt.Errorf("QA pipeline error: %s", data.Error)
|
|
bufMu.Unlock()
|
|
closeDone()
|
|
return nil
|
|
})
|
|
|
|
// Subscribe to agent thought events — stream thinking content into <think> block
|
|
eventBus.On(event.EventAgentThought, func(_ context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentThoughtData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
bufMu.Lock()
|
|
ensureThinkOpen()
|
|
bufWrite(data.Content)
|
|
bufMu.Unlock()
|
|
return nil
|
|
})
|
|
|
|
// Subscribe to agent tool call events — write status line into <think> block.
|
|
// The engine may emit this event twice per tool call (once during streaming,
|
|
// once at execution), so we deduplicate by ToolCallID.
|
|
eventBus.On(event.EventAgentToolCall, func(_ context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentToolCallData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if !isToolVisibleToUser(data.ToolName) {
|
|
return nil
|
|
}
|
|
bufMu.Lock()
|
|
if seenToolCalls[data.ToolCallID] {
|
|
bufMu.Unlock()
|
|
return nil
|
|
}
|
|
seenToolCalls[data.ToolCallID] = true
|
|
ensureThinkOpen()
|
|
ensureNewlineBefore()
|
|
bufWrite(formatToolCallStart(data.ToolName))
|
|
bufMu.Unlock()
|
|
logger.Debugf(ctx, "[IM] Tool call streamed to IM: tool=%s id=%s", data.ToolName, data.ToolCallID)
|
|
return nil
|
|
})
|
|
|
|
// Subscribe to agent tool result events — write result line into <think> block
|
|
eventBus.On(event.EventAgentToolResult, func(_ context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentToolResultData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if !isToolVisibleToUser(data.ToolName) {
|
|
return nil
|
|
}
|
|
bufMu.Lock()
|
|
ensureNewlineBefore()
|
|
bufWrite(formatToolCallResult(data.ToolName, data.Success, data.Output))
|
|
bufMu.Unlock()
|
|
logger.Debugf(ctx, "[IM] Tool result streamed to IM: tool=%s success=%v duration=%dms",
|
|
data.ToolName, data.Success, data.Duration)
|
|
return nil
|
|
})
|
|
|
|
// Determine whether to use agent mode
|
|
useAgent := customAgent != nil && customAgent.IsAgentMode()
|
|
requestID := uuid.New().String()
|
|
|
|
// Create user message
|
|
userMsg, err := s.messageService.CreateMessage(qaCtx, &types.Message{
|
|
SessionID: session.ID, Role: "user", Content: msg.Content,
|
|
RequestID: requestID, CreatedAt: time.Now(), IsCompleted: true,
|
|
Channel: "im",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create user message: %w", err)
|
|
}
|
|
|
|
// Create placeholder assistant message
|
|
assistantMsg, err := s.messageService.CreateMessage(qaCtx, &types.Message{
|
|
SessionID: session.ID, Role: "assistant",
|
|
RequestID: requestID, CreatedAt: time.Now(), IsCompleted: false,
|
|
Channel: "im",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create assistant message: %w", err)
|
|
}
|
|
|
|
// Register inflight mapping so cross-instance /stop can find this request
|
|
// and write a stop event to StreamManager.
|
|
if raw, ok := s.inflight.Load(userKey); ok {
|
|
e := raw.(*inflightEntry)
|
|
e.sessionID = session.ID
|
|
e.assistantMessageID = assistantMsg.ID
|
|
}
|
|
s.storeInflightMapping(qaCtx, userKey, session.ID, assistantMsg.ID)
|
|
defer s.clearInflightMapping(ctx, userKey)
|
|
|
|
// Start StreamManager stop watcher — mirrors web's handleAgentEventsForSSE
|
|
// stop detection. Cancels qaCtx if a stop event is written by any instance.
|
|
go s.watchStreamManagerStop(qaCtx, session.ID, assistantMsg.ID, qaCancel)
|
|
|
|
// Run QA async
|
|
go func() {
|
|
var err error
|
|
req := buildIMQARequest(session, msg.Content, assistantMsg.ID, userMsg.ID, customAgent, kbIDs, msg.Quote)
|
|
if req.QuotedContext != "" {
|
|
logger.Debugf(qaCtx, "[IM] QuotedContext set: length=%d", len(req.QuotedContext))
|
|
}
|
|
if useAgent {
|
|
err = s.sessionService.AgentQA(qaCtx, req, eventBus)
|
|
} else {
|
|
err = s.sessionService.KnowledgeQA(qaCtx, req, eventBus)
|
|
}
|
|
if err != nil {
|
|
logger.Errorf(ctx, "[IM] QA stream execution error: %v", err)
|
|
bufMu.Lock()
|
|
qaErr = fmt.Errorf("QA execution error: %w", err)
|
|
bufMu.Unlock()
|
|
closeDone()
|
|
}
|
|
}()
|
|
|
|
// Flush loop: periodically send buffered content to the IM platform.
|
|
// A holdback mechanism prevents flushing incomplete provider:// URLs or
|
|
// XML tags that straddle a chunk boundary (see holdbackCutoff).
|
|
ticker := time.NewTicker(streamFlushInterval)
|
|
defer ticker.Stop()
|
|
|
|
var holdback string // text held back from the previous flush
|
|
|
|
flush := func(final bool) {
|
|
bufMu.Lock()
|
|
chunk := holdback + buf.String()
|
|
buf.Reset()
|
|
bufMu.Unlock()
|
|
holdback = ""
|
|
|
|
if chunk == "" {
|
|
return
|
|
}
|
|
|
|
// On non-final flushes, check for incomplete patterns at the tail.
|
|
if !final {
|
|
if cut := holdbackCutoff(chunk); cut < len(chunk) {
|
|
holdback = chunk[cut:]
|
|
chunk = chunk[:cut]
|
|
}
|
|
}
|
|
|
|
if chunk != "" {
|
|
if err := streamer.SendStreamChunk(ctx, msg, streamID, cleanIMContent(ctx, chunk, fileSvc)); err != nil {
|
|
logger.Warnf(ctx, "[IM] SendStreamChunk failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
loop:
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
flush(false)
|
|
case <-done:
|
|
break loop
|
|
case <-qaCtx.Done():
|
|
break loop
|
|
}
|
|
}
|
|
|
|
// Final flush of any remaining content (including holdback).
|
|
flush(true)
|
|
|
|
// If no user-visible content was streamed (e.g., the entire response was
|
|
// in <think> blocks, or the QA pipeline errored), send a fallback message
|
|
// as the last chunk so the Feishu card doesn't end up empty.
|
|
bufMu.Lock()
|
|
answer := answerBuilder.String()
|
|
finalErr := qaErr
|
|
noVisibleContent := !streamedAny
|
|
bufMu.Unlock()
|
|
|
|
if noVisibleContent {
|
|
fallback := "抱歉,我暂时无法回答这个问题。"
|
|
if finalErr != nil {
|
|
fallback = "抱歉,处理您的问题时出现了异常,请稍后再试。"
|
|
}
|
|
if err := streamer.SendStreamChunk(ctx, msg, streamID, fallback); err != nil {
|
|
logger.Warnf(ctx, "[IM] SendStreamChunk fallback failed: %v", err)
|
|
}
|
|
if answer == "" {
|
|
answer = fallback
|
|
}
|
|
}
|
|
|
|
// End the stream
|
|
if err := streamer.EndStream(ctx, msg, streamID); err != nil {
|
|
logger.Warnf(ctx, "[IM] EndStream failed: %v", err)
|
|
}
|
|
|
|
if answer == "" {
|
|
answer = "抱歉,我暂时无法回答这个问题。"
|
|
}
|
|
|
|
assistantMsg.Content = answer
|
|
assistantMsg.IsCompleted = true
|
|
if err := s.messageService.UpdateMessage(ctx, assistantMsg); err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to update assistant message: %v", err)
|
|
}
|
|
|
|
logger.Infof(ctx, "[IM] Stream reply sent: platform=%s user=%s answer_len=%d", msg.Platform, msg.UserID, len(answer))
|
|
return nil
|
|
}
|
|
|
|
// 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, fileSvc interfaces.FileService) error {
|
|
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 = "抱歉,处理您的问题时出现了异常,请稍后再试。"
|
|
}
|
|
|
|
return adapter.SendReply(ctx, msg, &ReplyMessage{Content: cleanIMContent(ctx, answer, fileSvc), IsFinal: true})
|
|
}
|
|
|
|
// 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, quote *QuotedMessage) (string, error) {
|
|
// Cancellable context (no hard deadline): each agent round has its own
|
|
// LLMCallTimeout. The context can still be cancelled by /stop.
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
eventBus := event.NewEventBus()
|
|
|
|
// Thread-safe answer collection
|
|
var answerMu sync.Mutex
|
|
var answerBuilder strings.Builder
|
|
var qaErr error
|
|
done := make(chan struct{})
|
|
var closeOnce sync.Once
|
|
closeDone := func() { closeOnce.Do(func() { close(done) }) }
|
|
|
|
eventBus.On(event.EventAgentFinalAnswer, func(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentFinalAnswerData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
answerMu.Lock()
|
|
answerBuilder.WriteString(data.Content)
|
|
answerMu.Unlock()
|
|
if data.Done {
|
|
closeDone()
|
|
}
|
|
return nil
|
|
})
|
|
|
|
eventBus.On(event.EventError, func(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.ErrorData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
logger.Errorf(ctx, "[IM] QA error: %s", data.Error)
|
|
answerMu.Lock()
|
|
qaErr = fmt.Errorf("QA pipeline error: %s", data.Error)
|
|
answerMu.Unlock()
|
|
closeDone()
|
|
return nil
|
|
})
|
|
|
|
// Determine whether to use agent mode
|
|
useAgent := customAgent != nil && customAgent.IsAgentMode()
|
|
|
|
// Generate a shared RequestID to pair user and assistant messages for history
|
|
requestID := uuid.New().String()
|
|
|
|
// Create user message so it appears in conversation history
|
|
userMsg, err := s.messageService.CreateMessage(ctx, &types.Message{
|
|
SessionID: session.ID,
|
|
Role: "user",
|
|
Content: query,
|
|
RequestID: requestID,
|
|
CreatedAt: time.Now(),
|
|
IsCompleted: true,
|
|
Channel: "im",
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create user message: %w", err)
|
|
}
|
|
|
|
// Create a placeholder assistant message
|
|
assistantMsg, err := s.messageService.CreateMessage(ctx, &types.Message{
|
|
SessionID: session.ID,
|
|
Role: "assistant",
|
|
RequestID: requestID,
|
|
CreatedAt: time.Now(),
|
|
IsCompleted: false,
|
|
Channel: "im",
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create assistant message: %w", err)
|
|
}
|
|
|
|
// Register inflight mapping for cross-instance /stop via StreamManager.
|
|
if raw, ok := s.inflight.Load(userKey); ok {
|
|
e := raw.(*inflightEntry)
|
|
e.sessionID = session.ID
|
|
e.assistantMessageID = assistantMsg.ID
|
|
}
|
|
s.storeInflightMapping(ctx, userKey, session.ID, assistantMsg.ID)
|
|
defer s.clearInflightMapping(ctx, userKey)
|
|
|
|
// Start StreamManager stop watcher.
|
|
go s.watchStreamManagerStop(ctx, session.ID, assistantMsg.ID, cancel)
|
|
|
|
// Run QA async
|
|
go func() {
|
|
var err error
|
|
req := buildIMQARequest(session, query, assistantMsg.ID, userMsg.ID, customAgent, kbIDs, quote)
|
|
if req.QuotedContext != "" {
|
|
logger.Debugf(ctx, "[IM] QuotedContext set: length=%d", len(req.QuotedContext))
|
|
}
|
|
if useAgent {
|
|
err = s.sessionService.AgentQA(ctx, req, eventBus)
|
|
} else {
|
|
err = s.sessionService.KnowledgeQA(ctx, req, eventBus)
|
|
}
|
|
if err != nil {
|
|
logger.Errorf(ctx, "[IM] QA execution error: %v", err)
|
|
answerMu.Lock()
|
|
qaErr = fmt.Errorf("QA execution error: %w", err)
|
|
answerMu.Unlock()
|
|
closeDone()
|
|
}
|
|
}()
|
|
|
|
// Wait for completion or cancellation (e.g., /stop)
|
|
select {
|
|
case <-done:
|
|
case <-ctx.Done():
|
|
// Mark assistant message as completed to avoid dangling incomplete records
|
|
assistantMsg.Content = "抱歉,回答已被取消。"
|
|
assistantMsg.IsCompleted = true
|
|
// Use a fresh context since the original is cancelled
|
|
if updateErr := s.messageService.UpdateMessage(context.WithoutCancel(ctx), assistantMsg); updateErr != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to update cancelled assistant message: %v", updateErr)
|
|
}
|
|
return "", fmt.Errorf("QA cancelled: %w", ctx.Err())
|
|
}
|
|
|
|
answerMu.Lock()
|
|
answer := answerBuilder.String()
|
|
qaError := qaErr
|
|
answerMu.Unlock()
|
|
|
|
if answer == "" && qaError != nil {
|
|
return "", qaError
|
|
}
|
|
if answer == "" {
|
|
answer = "抱歉,我暂时无法回答这个问题。"
|
|
}
|
|
|
|
// Update assistant message with the full answer (including citation tags for web rendering).
|
|
assistantMsg.Content = answer
|
|
assistantMsg.IsCompleted = true
|
|
if err := s.messageService.UpdateMessage(ctx, assistantMsg); err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to update assistant message: %v", err)
|
|
}
|
|
|
|
// Return raw answer — callers apply cleanIMContent with the appropriate FileService.
|
|
return answer, nil
|
|
}
|
|
|
|
// ── CRUD operations for IM channels ──
|
|
|
|
// ListChannelsByAgent returns all channels for a given agent within a tenant.
|
|
func (s *Service) ListChannelsByAgent(agentID string, tenantID uint64) ([]IMChannel, error) {
|
|
var channels []IMChannel
|
|
if err := s.db.Where("agent_id = ? AND tenant_id = ? AND deleted_at IS NULL", agentID, tenantID).
|
|
Order("created_at DESC").Find(&channels).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return channels, nil
|
|
}
|
|
|
|
// ChannelWithAgent augments an IMChannel summary with its owning agent's display name.
|
|
// Credentials are intentionally omitted so this type is safe to return from a
|
|
// tenant-scoped list endpoint; callers that need credentials must use the
|
|
// per-agent endpoint which enforces the same tenant scope anyway.
|
|
type ChannelWithAgent struct {
|
|
ID string `json:"id"`
|
|
TenantID uint64 `json:"tenant_id"`
|
|
AgentID string `json:"agent_id"`
|
|
AgentName string `json:"agent_name"`
|
|
Platform string `json:"platform"`
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
Mode string `json:"mode"`
|
|
OutputMode string `json:"output_mode"`
|
|
SessionMode string `json:"session_mode"`
|
|
BotIdentity string `json:"bot_identity"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// ListChannelsByTenant returns all non-deleted IM channels in the given tenant,
|
|
// joined with custom_agents.name. Built-in agent IDs (whose rows may not exist
|
|
// in custom_agents) produce an empty AgentName — the frontend can substitute a
|
|
// localized "builtin agent" label in that case.
|
|
func (s *Service) ListChannelsByTenant(tenantID uint64) ([]ChannelWithAgent, error) {
|
|
var rows []ChannelWithAgent
|
|
err := s.db.Table("im_channels AS c").
|
|
Select(`c.id, c.tenant_id, c.agent_id,
|
|
COALESCE(a.name, '') AS agent_name,
|
|
c.platform, c.name, c.enabled, c.mode, c.output_mode,
|
|
c.session_mode, c.bot_identity, c.created_at, c.updated_at`).
|
|
Joins(`LEFT JOIN custom_agents AS a
|
|
ON a.id = c.agent_id AND a.tenant_id = c.tenant_id`).
|
|
Where("c.tenant_id = ? AND c.deleted_at IS NULL", tenantID).
|
|
Order("c.created_at DESC").
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// CreateChannel creates a new IM channel and optionally starts it.
|
|
// Returns a duplicate_bot error if the bot identity is already used by another channel.
|
|
func (s *Service) CreateChannel(channel *IMChannel) error {
|
|
if err := s.checkDuplicateBot(channel, ""); err != nil {
|
|
return err
|
|
}
|
|
if err := s.db.Create(channel).Error; err != nil {
|
|
return err
|
|
}
|
|
if channel.Enabled {
|
|
if err := s.StartChannel(channel); err != nil {
|
|
logger.Warnf(context.Background(), "[IM] Created channel %s but failed to start: %v", channel.ID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateChannel updates a channel and restarts it if needed.
|
|
// Returns a duplicate_bot error if the bot identity is already used by another channel.
|
|
func (s *Service) UpdateChannel(channel *IMChannel) error {
|
|
if err := s.checkDuplicateBot(channel, channel.ID); err != nil {
|
|
return err
|
|
}
|
|
if err := s.db.Save(channel).Error; err != nil {
|
|
return err
|
|
}
|
|
// Restart channel: stop old, start new if enabled
|
|
s.StopChannel(channel.ID)
|
|
if channel.Enabled {
|
|
if err := s.StartChannel(channel); err != nil {
|
|
logger.Warnf(context.Background(), "[IM] Updated channel %s but failed to restart: %v", channel.ID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteChannel soft-deletes a channel and stops it. Only deletes if the channel belongs to the given tenant.
|
|
func (s *Service) DeleteChannel(channelID string, tenantID uint64) error {
|
|
s.StopChannel(channelID)
|
|
result := s.db.Where("id = ? AND tenant_id = ?", channelID, tenantID).Delete(&IMChannel{})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return fmt.Errorf("channel not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ToggleChannel enables or disables a channel. Only toggles if the channel belongs to the given tenant.
|
|
func (s *Service) ToggleChannel(channelID string, tenantID uint64) (*IMChannel, error) {
|
|
var ch IMChannel
|
|
if err := s.db.Where("id = ? AND tenant_id = ? AND deleted_at IS NULL", channelID, tenantID).First(&ch).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
ch.Enabled = !ch.Enabled
|
|
if err := s.db.Save(&ch).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if ch.Enabled {
|
|
if err := s.StartChannel(&ch); err != nil {
|
|
logger.Warnf(context.Background(), "[IM] Failed to start channel %s after enable: %v", ch.ID, err)
|
|
}
|
|
} else {
|
|
s.StopChannel(channelID)
|
|
}
|
|
return &ch, nil
|
|
}
|
|
|
|
// checkDuplicateBot queries the bot_identity index to see if another active channel
|
|
// already uses the same bot. This is an O(1) index lookup, not a full table scan.
|
|
// The DB unique index on bot_identity serves as an additional safety net.
|
|
// excludeID is the channel's own ID (for updates); pass "" for new channels.
|
|
func (s *Service) checkDuplicateBot(channel *IMChannel, excludeID string) error {
|
|
// Compute bot_identity the same way the BeforeSave hook will
|
|
botKey := channel.computeBotIdentity()
|
|
if botKey == "" {
|
|
return nil
|
|
}
|
|
|
|
var existing IMChannel
|
|
query := s.db.Where("bot_identity = ? AND deleted_at IS NULL", botKey)
|
|
if excludeID != "" {
|
|
query = query.Where("id != ?", excludeID)
|
|
}
|
|
if err := query.First(&existing).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil // no conflict
|
|
}
|
|
return fmt.Errorf("check duplicate bot: %w", err)
|
|
}
|
|
return fmt.Errorf("duplicate_bot: this bot is already bound to channel %q (%s); each bot can only be connected to one channel", existing.Name, existing.ID)
|
|
}
|
|
|
|
// ── File message handling ──────────────────────────────────────────────
|
|
// These methods handle file messages received via IM platforms.
|
|
// Files are downloaded from the IM platform, validated, and saved to the
|
|
// configured knowledge base asynchronously. The user receives a notification
|
|
// at the start and end of processing.
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
// supportedKBFileExts is the set of file extensions that can be saved to a knowledge base.
|
|
var supportedKBFileExts = map[string]bool{
|
|
"pdf": true, "txt": true, "docx": true, "doc": true,
|
|
"md": true, "markdown": true,
|
|
"png": true, "jpg": true, "jpeg": true, "gif": true,
|
|
"csv": true, "xlsx": true, "xls": true,
|
|
"pptx": true, "ppt": true,
|
|
}
|
|
|
|
// handleFileMessage processes a file message by downloading it from the IM platform
|
|
// and saving it to the channel's configured knowledge base. Sends start/end
|
|
// notifications to the user via the adapter.
|
|
func (s *Service) handleFileMessage(ctx context.Context, msg *IncomingMessage, adapter Adapter, channel *IMChannel) error {
|
|
// Check if the adapter supports file downloading
|
|
downloader, ok := adapter.(FileDownloader)
|
|
if !ok {
|
|
logger.Infof(ctx, "[IM] Adapter for platform %s does not support file download, ignoring file message", msg.Platform)
|
|
return s.sendSmartReply(ctx, adapter, msg, channel,
|
|
"用户尝试发送文件,但当前平台暂不支持文件消息处理。",
|
|
"❌ 当前平台暂不支持文件消息处理。")
|
|
}
|
|
|
|
// For image messages, ensure a proper file extension is present.
|
|
// IM platforms may only provide a hash/key as filename without extension.
|
|
if msg.MessageType == MessageTypeImage && fileExtension(msg.FileName) == "" {
|
|
msg.FileName = msg.FileName + ".png"
|
|
}
|
|
|
|
// Validate file extension (pre-download).
|
|
// Some platforms (e.g. WeCom aibot) do not provide original filenames in the
|
|
// callback JSON — only a hash ID. For such cases we defer extension validation
|
|
// to after the file is downloaded, where the real name may be obtained from
|
|
// HTTP Content-Disposition or Content-Type headers.
|
|
ext := fileExtension(msg.FileName)
|
|
if ext != "" && !supportedKBFileExts[ext] {
|
|
logger.Infof(ctx, "[IM] Unsupported file type: %s (file=%s)", ext, msg.FileName)
|
|
return s.sendSmartReply(ctx, adapter, msg, channel,
|
|
fmt.Sprintf("用户上传了一个不支持的文件类型「%s」。目前支持的类型包括:PDF、Word、TXT、Markdown、Excel、CSV、PPT、图片。", ext),
|
|
fmt.Sprintf("❌ 不支持的文件类型「%s」。\n\n支持的类型:PDF、Word、TXT、Markdown、Excel、CSV、PPT、图片。", ext))
|
|
}
|
|
|
|
// Process asynchronously to avoid blocking the message handler
|
|
go s.processFileToKnowledgeBase(context.WithoutCancel(ctx), msg, downloader, adapter, channel)
|
|
|
|
return nil
|
|
}
|
|
|
|
// processFileToKnowledgeBase is the async worker that downloads a file from the
|
|
// IM platform and creates a knowledge entry in the configured knowledge base.
|
|
func (s *Service) processFileToKnowledgeBase(ctx context.Context, msg *IncomingMessage, downloader FileDownloader, adapter Adapter, channel *IMChannel) {
|
|
kbID := channel.KnowledgeBaseID
|
|
tenantID := channel.TenantID
|
|
|
|
// Build context with tenant info for the knowledge service
|
|
tenant, err := s.tenantService.GetTenantByID(ctx, tenantID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "[IM] Failed to get tenant %d for file processing: %v", tenantID, err)
|
|
s.sendFileResult(ctx, adapter, msg, msg.FileName, false, "获取租户信息失败", channel)
|
|
return
|
|
}
|
|
kbCtx := context.WithValue(ctx, types.TenantIDContextKey, tenantID)
|
|
kbCtx = context.WithValue(kbCtx, types.TenantInfoContextKey, tenant)
|
|
|
|
// Download file from IM platform
|
|
reader, fileName, err := downloader.DownloadFile(ctx, msg)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "[IM] Failed to download file from %s: %v", msg.Platform, err)
|
|
s.sendFileResult(ctx, adapter, msg, msg.FileName, false, "下载文件失败", channel)
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
|
|
logger.Debugf(ctx, "[IM] Downloaded file: original_name=%s resolved_name=%s", msg.FileName, fileName)
|
|
|
|
// Post-download extension validation: if the pre-download name had no extension
|
|
// (e.g. WeCom file messages only provide a hash), check the resolved name now.
|
|
ext := fileExtension(fileName)
|
|
if !supportedKBFileExts[ext] {
|
|
logger.Infof(ctx, "[IM] Unsupported file type after download: %s (file=%s)", ext, fileName)
|
|
s.sendFileResult(ctx, adapter, msg, fileName, false,
|
|
fmt.Sprintf("不支持的文件类型「%s」。支持:PDF、Word、TXT、Markdown、Excel、CSV、PPT、图片", ext), channel)
|
|
return
|
|
}
|
|
|
|
// Read file content into memory for multipart upload
|
|
content, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "[IM] Failed to read file content: %v", err)
|
|
s.sendFileResult(ctx, adapter, msg, fileName, false, "读取文件内容失败", channel)
|
|
return
|
|
}
|
|
|
|
// Create a multipart.FileHeader compatible wrapper
|
|
fh := newInMemoryFileHeader(fileName, content)
|
|
|
|
// Create knowledge entry via the knowledge service
|
|
knowledge, err := s.knowledgeService.CreateKnowledgeFromFile(kbCtx, kbID, fh, nil, nil, "", "", imPlatformToChannel(channel.Platform))
|
|
if err != nil {
|
|
errMsg := err.Error()
|
|
// Check for duplicate file
|
|
if strings.Contains(errMsg, "duplicate") || strings.Contains(errMsg, "already exists") {
|
|
logger.Infof(ctx, "[IM] File already exists in knowledge base: %s", fileName)
|
|
s.sendFileResult(ctx, adapter, msg, fileName, false, "文件已存在于知识库中", channel)
|
|
return
|
|
}
|
|
logger.Errorf(ctx, "[IM] Failed to create knowledge from file: %v", err)
|
|
s.sendFileResult(ctx, adapter, msg, fileName, false, "保存到知识库失败", channel)
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "[IM] File saved to knowledge base: kb=%s knowledge=%s file=%s", kbID, knowledge.ID, fileName)
|
|
s.sendFileResult(ctx, adapter, msg, fileName, true, "", channel)
|
|
|
|
// Start a background watcher to send the document summary once Asynq
|
|
// finishes parsing + summary generation. This is intentionally decoupled
|
|
// from the Asynq task pipeline to avoid modifying any existing logic.
|
|
go s.watchAndSendSummary(ctx, kbCtx, adapter, msg, knowledge.ID, fileName, channel)
|
|
}
|
|
|
|
// sendFileResult sends a notification about the file processing result.
|
|
// It uses sendSmartReply to generate a friendly, streaming reply via the channel's LLM.
|
|
// Falls back to a static template if the LLM is unavailable.
|
|
func (s *Service) sendFileResult(ctx context.Context, adapter Adapter, msg *IncomingMessage, fileName string, success bool, errDetail string, channel *IMChannel) {
|
|
typeName := fileTypeName(fileName)
|
|
|
|
var fallback string
|
|
if success {
|
|
fallback = fmt.Sprintf("✅ %s已保存到知识库,正在解析中,完成后会通知你~", typeName)
|
|
} else {
|
|
fallback = fmt.Sprintf("❌ %s处理失败:%s", typeName, errDetail)
|
|
}
|
|
|
|
var situation string
|
|
if success {
|
|
situation = fmt.Sprintf("用户上传的%s已成功保存到知识库,但还需要后台解析文档内容(这需要一些时间)。请告知用户文件已收到,正在解析处理中,解析完成后会自动推送结果。", typeName)
|
|
} else {
|
|
situation = fmt.Sprintf("用户上传的%s处理失败,原因:%s。", typeName, errDetail)
|
|
}
|
|
|
|
if err := s.sendSmartReply(ctx, adapter, msg, channel, situation, fallback); err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to send file result notification: %v", err)
|
|
}
|
|
}
|
|
|
|
// smartReplySystemPrompt is the system prompt used for generating smart notification replies.
|
|
const smartReplySystemPrompt = "你是一个专业的 IM 机器人助手。请根据以下事件情况,生成一条简洁、清晰的通知消息。" +
|
|
"要求:1) 可适当使用 emoji 但不要过多;2) 语气专业平等,像同事之间对话,不要谄媚讨好,不要用「啦」「哦」「呢」「哟」等撒娇语气词;" +
|
|
"3) 直接输出消息内容,不要加任何额外解释;" +
|
|
"4) 如果事件中包含摘要或详细内容,请用 Markdown 格式结构化展示(使用标题、列表、加粗等),完整呈现,不要删减或概括;如果是简单通知,则控制在 2-3 句话以内。"
|
|
|
|
// sendSmartReply generates a notification message using the channel's LLM and sends it
|
|
// to the user. If the adapter supports streaming (StreamSender), it streams the reply
|
|
// in real-time for a better user experience. Otherwise, it falls back to non-streaming.
|
|
// If the LLM is unavailable or fails, it sends the provided fallback text.
|
|
func (s *Service) sendSmartReply(ctx context.Context, adapter Adapter, msg *IncomingMessage, channel *IMChannel, situation string, fallback string) error {
|
|
chatModel := s.getChatModelForChannel(ctx, channel)
|
|
if chatModel == nil {
|
|
return adapter.SendReply(ctx, msg, &ReplyMessage{Content: fallback, IsFinal: true})
|
|
}
|
|
|
|
// If the adapter supports streaming, use stream mode
|
|
if streamer, ok := adapter.(StreamSender); ok {
|
|
if err := s.streamSmartReply(ctx, chatModel, streamer, msg, situation); err == nil {
|
|
return nil
|
|
}
|
|
// Stream failed — fall through to non-streaming
|
|
logger.Warnf(ctx, "[IM] Stream smart reply failed, falling back to non-streaming")
|
|
}
|
|
|
|
// Non-streaming fallback
|
|
content := s.generateSmartReply(ctx, chatModel, situation, fallback)
|
|
return adapter.SendReply(ctx, msg, &ReplyMessage{Content: content, IsFinal: true})
|
|
}
|
|
|
|
// streamSmartReply uses ChatStream to generate and stream a notification reply in real-time.
|
|
func (s *Service) streamSmartReply(ctx context.Context, chatModel chat.Chat, streamer StreamSender, msg *IncomingMessage, situation string) error {
|
|
messages := []chat.Message{
|
|
{Role: "system", Content: smartReplySystemPrompt},
|
|
{Role: "user", Content: situation},
|
|
}
|
|
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
streamCh, err := chatModel.ChatStream(timeoutCtx, messages, &chat.ChatOptions{
|
|
Temperature: 0.7,
|
|
MaxTokens: 800,
|
|
})
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[IM] ChatStream failed for smart reply: %v", err)
|
|
return err
|
|
}
|
|
|
|
// Start the stream on the IM platform
|
|
streamID, err := streamer.StartStream(ctx, msg)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[IM] StartStream failed for smart reply: %v", err)
|
|
return err
|
|
}
|
|
|
|
// Flush loop with batching (same pattern as handleMessageStream)
|
|
var (
|
|
bufMu sync.Mutex
|
|
buf strings.Builder
|
|
done = make(chan struct{})
|
|
)
|
|
|
|
go func() {
|
|
defer close(done)
|
|
for resp := range streamCh {
|
|
if resp.Content != "" {
|
|
bufMu.Lock()
|
|
buf.WriteString(resp.Content)
|
|
bufMu.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
|
|
ticker := time.NewTicker(streamFlushInterval)
|
|
defer ticker.Stop()
|
|
|
|
flush := func() {
|
|
bufMu.Lock()
|
|
chunk := buf.String()
|
|
buf.Reset()
|
|
bufMu.Unlock()
|
|
|
|
if chunk != "" {
|
|
if err := streamer.SendStreamChunk(ctx, msg, streamID, chunk); err != nil {
|
|
logger.Warnf(ctx, "[IM] SendStreamChunk failed for smart reply: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
loop:
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
flush()
|
|
case <-done:
|
|
break loop
|
|
case <-timeoutCtx.Done():
|
|
break loop
|
|
}
|
|
}
|
|
|
|
// Final flush
|
|
flush()
|
|
|
|
// End the stream
|
|
if err := streamer.EndStream(ctx, msg, streamID); err != nil {
|
|
logger.Warnf(ctx, "[IM] EndStream failed for smart reply: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// generateSmartReply uses the channel's agent LLM to produce a natural-language
|
|
// notification message for the given situation (non-streaming).
|
|
// If the call fails, it returns the provided fallback text.
|
|
func (s *Service) generateSmartReply(ctx context.Context, chatModel chat.Chat, situation string, fallback string) string {
|
|
messages := []chat.Message{
|
|
{Role: "system", Content: smartReplySystemPrompt},
|
|
{Role: "user", Content: situation},
|
|
}
|
|
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
resp, err := chatModel.Chat(timeoutCtx, messages, &chat.ChatOptions{
|
|
Temperature: 0.7,
|
|
MaxTokens: 800,
|
|
})
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[IM] Smart reply generation failed, using fallback: %v", err)
|
|
return fallback
|
|
}
|
|
|
|
reply := strings.TrimSpace(resp.Content)
|
|
if reply == "" {
|
|
return fallback
|
|
}
|
|
return reply
|
|
}
|
|
|
|
// getChatModelForChannel resolves the chat.Chat instance configured on the
|
|
// channel's agent. Returns nil if the model cannot be resolved.
|
|
func (s *Service) getChatModelForChannel(ctx context.Context, channel *IMChannel) chat.Chat {
|
|
if channel == nil || channel.AgentID == "" {
|
|
return nil
|
|
}
|
|
|
|
// Ensure the context carries tenant ID — some call sites (e.g. handleFileMessage)
|
|
// may invoke this before the tenant has been injected into ctx.
|
|
if _, ok := types.TenantIDFromContext(ctx); !ok && channel.TenantID != 0 {
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, channel.TenantID)
|
|
}
|
|
|
|
agent, err := s.agentService.GetAgentByID(ctx, channel.AgentID)
|
|
if err != nil || agent == nil {
|
|
logger.Debugf(ctx, "[IM] Cannot get agent %s for smart reply: %v", channel.AgentID, err)
|
|
return nil
|
|
}
|
|
|
|
modelID := agent.Config.ModelID
|
|
if modelID == "" {
|
|
return nil
|
|
}
|
|
|
|
chatModel, err := s.modelService.GetChatModel(ctx, modelID)
|
|
if err != nil {
|
|
logger.Debugf(ctx, "[IM] Cannot get chat model %s for smart reply: %v", modelID, err)
|
|
return nil
|
|
}
|
|
return chatModel
|
|
}
|
|
|
|
// watchAndSendSummary polls the knowledge record until document parsing (and
|
|
// optionally summary generation) completes, then sends the result back to the
|
|
// IM user. This runs as a fire-and-forget goroutine, completely decoupled from
|
|
// the Asynq worker pipeline.
|
|
func (s *Service) watchAndSendSummary(
|
|
ctx context.Context,
|
|
kbCtx context.Context,
|
|
adapter Adapter,
|
|
msg *IncomingMessage,
|
|
knowledgeID string,
|
|
fileName string,
|
|
channel *IMChannel,
|
|
) {
|
|
const (
|
|
pollInterval = 5 * time.Second
|
|
maxWait = 10 * time.Minute // give up after 10 minutes
|
|
)
|
|
|
|
deadline := time.Now().Add(maxWait)
|
|
ticker := time.NewTicker(pollInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if time.Now().After(deadline) {
|
|
logger.Infof(ctx, "[IM] Summary watcher timed out for knowledge %s", knowledgeID)
|
|
return
|
|
}
|
|
|
|
knowledge, err := s.knowledgeService.GetKnowledgeByID(kbCtx, knowledgeID)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[IM] Summary watcher: failed to get knowledge %s: %v", knowledgeID, err)
|
|
return
|
|
}
|
|
|
|
typeName := fileTypeName(fileName)
|
|
|
|
switch knowledge.ParseStatus {
|
|
case types.ParseStatusFailed:
|
|
// Parsing failed — notify user and stop watching
|
|
errMsg := knowledge.ErrorMessage
|
|
if errMsg == "" {
|
|
errMsg = "文档解析失败"
|
|
}
|
|
_ = s.sendSmartReply(ctx, adapter, msg, channel,
|
|
fmt.Sprintf("用户之前上传的%s解析失败了,错误原因:%s。请安慰用户并建议重试。", typeName, errMsg),
|
|
fmt.Sprintf("⚠️ %s解析失败:%s", typeName, errMsg))
|
|
return
|
|
|
|
case types.ParseStatusCompleted:
|
|
// Parsing done. If summary generation is in progress, wait for it.
|
|
switch knowledge.SummaryStatus {
|
|
case types.SummaryStatusNone, "":
|
|
// No summary task configured. For image files the VLM caption
|
|
// is stored in Description by finalizeImageKnowledge, so we
|
|
// still show it if present.
|
|
if knowledge.Description != "" && knowledge.Description != fileName {
|
|
_ = s.sendSmartReply(ctx, adapter, msg, channel,
|
|
fmt.Sprintf("用户之前上传的%s已解析完成。以下是文件的完整摘要内容:\n%s\n\n请生成一条通知消息,包含:1) 告知文件已解析完成;2) 用 Markdown 格式(标题、列表、加粗等)结构化展示上述摘要内容,不要删减或概括;3) 提示用户可以针对该文件提问。", typeName, knowledge.Description),
|
|
fmt.Sprintf("📄 %s已解析完成。\n\n**摘要:**\n\n%s\n\n---\n可以针对该文件进行提问。", typeName, knowledge.Description))
|
|
} else {
|
|
_ = s.sendSmartReply(ctx, adapter, msg, channel,
|
|
fmt.Sprintf("用户之前上传的%s已解析完成,现在可以开始针对该文件进行提问了。", typeName),
|
|
fmt.Sprintf("📄 %s已解析完成,可以开始提问了!", typeName))
|
|
}
|
|
return
|
|
|
|
case types.SummaryStatusCompleted:
|
|
// Summary is ready — send it
|
|
s.sendSummaryNotification(ctx, adapter, msg, knowledge, fileName, channel)
|
|
return
|
|
|
|
case types.SummaryStatusFailed:
|
|
_ = s.sendSmartReply(ctx, adapter, msg, channel,
|
|
fmt.Sprintf("用户之前上传的%s已解析完成,但摘要生成失败了。不过文件已可用于提问。", typeName),
|
|
fmt.Sprintf("📄 %s已解析完成,可以开始提问了!(摘要生成失败)", typeName))
|
|
return
|
|
|
|
default:
|
|
// Still generating summary — keep polling
|
|
}
|
|
|
|
default:
|
|
// Still parsing — keep polling
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendSummaryNotification retrieves the summary chunk for a knowledge entry
|
|
// and sends it as a message to the IM user.
|
|
func (s *Service) sendSummaryNotification(
|
|
ctx context.Context,
|
|
adapter Adapter,
|
|
msg *IncomingMessage,
|
|
knowledge *types.Knowledge,
|
|
fileName string,
|
|
channel *IMChannel,
|
|
) {
|
|
// The summary is stored in the knowledge's Description field or as a
|
|
// ChunkTypeSummary chunk. We use Description first (populated by the
|
|
// summary generation task), falling back to a generic notice.
|
|
summary := knowledge.Description
|
|
if summary == "" {
|
|
summary = knowledge.Title
|
|
}
|
|
|
|
typeName := fileTypeName(fileName)
|
|
var situation, fallback string
|
|
if summary != "" && summary != fileName {
|
|
situation = fmt.Sprintf("用户之前上传的%s已解析完成。以下是文件的完整摘要内容:\n%s\n\n请生成一条通知消息,包含:1) 告知文件已解析完成;2) 用 Markdown 格式(标题、列表、加粗等)结构化展示上述摘要内容,不要删减或概括;3) 提示用户可以针对该文件提问。", typeName, summary)
|
|
fallback = fmt.Sprintf("📄 %s已解析完成。\n\n**摘要:**\n\n%s\n\n---\n可以针对该文件进行提问。", typeName, summary)
|
|
} else {
|
|
situation = fmt.Sprintf("用户之前上传的%s已解析完成,现在可以开始针对该文件进行提问了。", typeName)
|
|
fallback = fmt.Sprintf("📄 %s已解析完成,可以开始提问了!", typeName)
|
|
}
|
|
|
|
if err := s.sendSmartReply(ctx, adapter, msg, channel, situation, fallback); err != nil {
|
|
logger.Warnf(ctx, "[IM] Failed to send summary notification: %v", err)
|
|
}
|
|
}
|
|
|
|
// fileExtension extracts the lowercase file extension from a filename.
|
|
func fileExtension(filename string) string {
|
|
parts := strings.Split(filename, ".")
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(parts[len(parts)-1])
|
|
}
|
|
|
|
// imPlatformToChannel maps an IM platform identifier to a Knowledge.Channel constant.
|
|
func imPlatformToChannel(platform string) string {
|
|
switch strings.ToLower(platform) {
|
|
case "wechat":
|
|
return types.ChannelWechat
|
|
case "wecom", "wxwork":
|
|
return types.ChannelWecom
|
|
case "feishu", "lark":
|
|
return types.ChannelFeishu
|
|
case "dingtalk":
|
|
return types.ChannelDingtalk
|
|
case "slack":
|
|
return types.ChannelSlack
|
|
default:
|
|
return types.ChannelIM
|
|
}
|
|
}
|
|
|
|
// fileTypeName returns a human-readable file type name based on the file extension.
|
|
func fileTypeName(filename string) string {
|
|
switch fileExtension(filename) {
|
|
case "pdf":
|
|
return "PDF 文档"
|
|
case "doc", "docx":
|
|
return "Word 文档"
|
|
case "txt":
|
|
return "文本文件"
|
|
case "md", "markdown":
|
|
return "Markdown 文档"
|
|
case "png", "jpg", "jpeg", "gif":
|
|
return "图片"
|
|
case "csv":
|
|
return "CSV 表格"
|
|
case "xls", "xlsx":
|
|
return "Excel 表格"
|
|
case "ppt", "pptx":
|
|
return "PPT 演示文稿"
|
|
default:
|
|
return "文件"
|
|
}
|
|
}
|
|
|
|
// newInMemoryFileHeader wraps in-memory file content as a *multipart.FileHeader
|
|
// so it can be passed to CreateKnowledgeFromFile which expects a multipart upload.
|
|
func newInMemoryFileHeader(filename string, data []byte) *multipart.FileHeader {
|
|
body := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(body)
|
|
|
|
h := make(textproto.MIMEHeader)
|
|
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
|
|
h.Set("Content-Type", "application/octet-stream")
|
|
|
|
part, err := writer.CreatePart(h)
|
|
if err != nil {
|
|
// Fallback: return a minimal FileHeader
|
|
return &multipart.FileHeader{Filename: filename, Size: int64(len(data))}
|
|
}
|
|
_, _ = part.Write(data)
|
|
_ = writer.Close()
|
|
|
|
// Parse the multipart body to extract the FileHeader
|
|
reader := multipart.NewReader(body, writer.Boundary())
|
|
form, err := reader.ReadForm(int64(len(data)) + 1024)
|
|
if err != nil || form == nil {
|
|
return &multipart.FileHeader{Filename: filename, Size: int64(len(data))}
|
|
}
|
|
files := form.File["file"]
|
|
if len(files) == 0 {
|
|
return &multipart.FileHeader{Filename: filename, Size: int64(len(data))}
|
|
}
|
|
return files[0]
|
|
}
|