diff --git a/client/agent.go b/client/agent.go index f3773ac2..18d2530b 100644 --- a/client/agent.go +++ b/client/agent.go @@ -33,6 +33,7 @@ type AgentQARequest struct { DisableTitle bool `json:"disable_title,omitempty"` // Whether to disable auto title generation MCPServiceIDs []string `json:"mcp_service_ids,omitempty"` // Optional MCP service allow list (deprecated) Images []ImageAttachment `json:"images,omitempty"` // Attached images for multimodal chat + Channel string `json:"channel,omitempty"` // Source channel: "web", "api", "im", etc. } // AgentResponseType defines the type of agent response diff --git a/client/message.go b/client/message.go index 95a51d8b..9eeda4fd 100644 --- a/client/message.go +++ b/client/message.go @@ -49,6 +49,7 @@ type Message struct { KnowledgeReferences []*SearchResult `json:"knowledge_references"` AgentSteps []AgentStep `json:"agent_steps,omitempty"` // Agent execution steps (only for assistant messages) IsCompleted bool `json:"is_completed"` + Channel string `json:"channel,omitempty"` // Source channel: "web", "api", "im", etc. CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/client/session.go b/client/session.go index 831fc4c6..fa29a3d4 100644 --- a/client/session.go +++ b/client/session.go @@ -218,6 +218,7 @@ type KnowledgeQARequest struct { SummaryModelID string `json:"summary_model_id"` // Optional summary model ID (overrides session default) DisableTitle bool `json:"disable_title"` // Whether to disable auto title generation Images []ImageAttachment `json:"images,omitempty"` // Attached images for multimodal chat + Channel string `json:"channel,omitempty"` // Source channel: "web", "api", "im", etc. } // LLMToolCall represents a function/tool call from the LLM diff --git a/frontend/src/api/chat/index.ts b/frontend/src/api/chat/index.ts index 8b59875d..2b98c382 100644 --- a/frontend/src/api/chat/index.ts +++ b/frontend/src/api/chat/index.ts @@ -15,7 +15,7 @@ export async function generateSessionsTitle(session_id: string, data: any) { } export async function knowledgeChat(data: { session_id: string; query: string; }) { - return postChat(`/api/v1/knowledge-chat/${data.session_id}`, { query: data.query }); + return postChat(`/api/v1/knowledge-chat/${data.session_id}`, { query: data.query, channel: "web" }); } // Agent chat with streaming support @@ -28,7 +28,8 @@ export async function agentChat(data: { return postChat(`/api/v1/agent-chat/${data.session_id}`, { query: data.query, knowledge_base_ids: data.knowledge_base_ids, - agent_enabled: data.agent_enabled + agent_enabled: data.agent_enabled, + channel: "web" }); } diff --git a/frontend/src/api/chat/streame.ts b/frontend/src/api/chat/streame.ts index f4f64439..ec789d92 100644 --- a/frontend/src/api/chat/streame.ts +++ b/frontend/src/api/chat/streame.ts @@ -118,6 +118,7 @@ export function useStream() { if (params.images !== undefined && params.images.length > 0) { postBody.images = params.images; } + postBody.channel = "web"; await fetchEventSource(url, { method: params.method, diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 51fd9afc..d033f359 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -1718,6 +1718,9 @@ export default { referencesDocAndWebCount: 'Referenced {docCount} document(s) and {webCount} web page(s)', referenceChunkCount: '{count} chunk(s)', fallbackHint: 'No relevant content found in knowledge base. Above is a direct response from the model.', + channelWeb: 'Web', + channelApi: 'API', + channelIm: 'IM', chunkLabel: 'Chunk {index}:', navigateToDocument: 'View document details', referenceIconAlt: 'Reference materials icon', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index d1ba39ca..232cef4d 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -322,6 +322,9 @@ export default { referencesDocAndWebCount: "{docCount}개 문서와 {webCount}개 웹페이지 참조", referenceChunkCount: "{count}개 청크", fallbackHint: "지식 베이스에서 관련 내용을 찾지 못했습니다. 위는 모델의 직접 응답입니다.", + channelWeb: "웹", + channelApi: "API", + channelIm: "IM", chunkLabel: "청크 {index}:", navigateToDocument: "문서 상세 보기", referenceIconAlt: "참조 내용 아이콘", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 4be4f54e..9b70b050 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -1887,6 +1887,9 @@ export default { referencesDocAndWebCount: 'Использовано {docCount} документ(ов) и {webCount} веб-страниц(ы)', referenceChunkCount: '{count} фрагмент(ов)', fallbackHint: 'В базе знаний не найдено релевантного содержимого. Выше представлен прямой ответ модели.', + channelWeb: 'Веб', + channelApi: 'API', + channelIm: 'IM', chunkLabel: 'Фрагмент {index}:', navigateToDocument: 'Просмотр документа', referenceIconAlt: 'Иконка ссылок на материалы', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index c9904f7d..7f064433 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -319,6 +319,9 @@ export default { referencesDocAndWebCount: "引用了{docCount}篇文档和{webCount}条网页", referenceChunkCount: "{count}个片段", fallbackHint: "未从知识库中检索到相关内容,以上为模型直接回答", + channelWeb: "网页", + channelApi: "API", + channelIm: "IM", chunkLabel: "片段{index}:", navigateToDocument: "查看文档详情", referenceIconAlt: "参考内容图标", diff --git a/frontend/src/views/chat/components/usermsg.vue b/frontend/src/views/chat/components/usermsg.vue index 85145190..8f406e2c 100644 --- a/frontend/src/views/chat/components/usermsg.vue +++ b/frontend/src/views/chat/components/usermsg.vue @@ -37,6 +37,9 @@ import { defineProps, computed, ref, watch, onMounted, nextTick } from "vue"; import { hydrateProtectedFileImages } from '@/utils/security'; import picturePreview from '@/components/picture-preview.vue'; +import { useI18n } from 'vue-i18n'; + +const { t } = useI18n(); const props = defineProps({ content: { @@ -52,9 +55,28 @@ const props = defineProps({ type: Array, required: false, default: () => [] + }, + channel: { + type: String, + required: false, + default: '' } }); +const channelLabelMap = { + web: () => t('chat.channelWeb'), + api: () => t('chat.channelApi'), + im: () => t('chat.channelIm'), +}; + +const channelLabel = computed(() => { + if (!props.channel) return ''; + const label = channelLabelMap[props.channel]; + return typeof label === 'function' ? label() : (label || props.channel); +}); + +const channelClass = computed(() => props.channel ? `channel-${props.channel}` : ''); + const containerRef = ref(null); const hasImages = computed(() => props.images && props.images.length > 0); @@ -190,6 +212,37 @@ const closePreImg = () => { } } +.channel_tag { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 500; + line-height: 18px; + background: var(--td-bg-color-secondarycontainer); + color: var(--td-text-color-placeholder); + border: 1px solid var(--td-border-level-2-color, #e7e7e7); + + &.channel-web { + color: var(--td-brand-color); + background: var(--td-brand-color-light); + border-color: var(--td-brand-color-2, rgba(0, 82, 217, 0.1)); + } + + &.channel-api { + color: var(--td-success-color); + background: var(--td-success-color-1, rgba(0, 168, 112, 0.06)); + border-color: var(--td-success-color-2, rgba(0, 168, 112, 0.15)); + } + + &.channel-im { + color: var(--td-warning-color); + background: var(--td-warning-color-1, rgba(237, 123, 0, 0.06)); + border-color: var(--td-warning-color-2, rgba(237, 123, 0, 0.15)); + } +} + html[theme-mode="dark"] { .user_msg { background: var(--td-brand-color-3); diff --git a/frontend/src/views/chat/index.vue b/frontend/src/views/chat/index.vue index d7690859..2e648cbe 100644 --- a/frontend/src/views/chat/index.vue +++ b/frontend/src/views/chat/index.vue @@ -355,7 +355,7 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = [] } // 将@提及的知识库和文件信息存入用户消息 - messagesList.push({ content: value, role: 'user', mentioned_items: mentionedItems, images: userImages }); + messagesList.push({ content: value, role: 'user', mentioned_items: mentionedItems, images: userImages, channel: 'web' }); scrollToBottom(); // Get agent mode status from settings store diff --git a/internal/handler/session/helpers.go b/internal/handler/session/helpers.go index 32dcede2..d6acd02b 100644 --- a/internal/handler/session/helpers.go +++ b/internal/handler/session/helpers.go @@ -165,7 +165,7 @@ func createAgentQueryEvent(sessionID, assistantMessageID string) interfaces.Stre } // createUserMessage creates a user message and returns the created message. -func (h *Handler) createUserMessage(ctx context.Context, sessionID, query, requestID string, mentionedItems types.MentionedItems, images types.MessageImages) (*types.Message, error) { +func (h *Handler) createUserMessage(ctx context.Context, sessionID, query, requestID string, mentionedItems types.MentionedItems, images types.MessageImages, channel string) (*types.Message, error) { return h.messageService.CreateMessage(ctx, &types.Message{ SessionID: sessionID, Role: "user", @@ -175,6 +175,7 @@ func (h *Handler) createUserMessage(ctx context.Context, sessionID, query, reque IsCompleted: true, MentionedItems: mentionedItems, Images: images, + Channel: channel, }) } diff --git a/internal/handler/session/qa.go b/internal/handler/session/qa.go index 0e5372ca..bcce1c79 100644 --- a/internal/handler/session/qa.go +++ b/internal/handler/session/qa.go @@ -36,6 +36,7 @@ type qaRequestContext struct { effectiveTenantID uint64 // when using shared agent, tenant ID for model/KB/MCP resolution; 0 = use context tenant images []ImageAttachment // Uploaded images with analysis text userMessageID string // Created user message ID (populated after createUserMessage) + channel string // Source channel: "web", "api", "im", etc. } // buildQARequest converts the qaRequestContext into a types.QARequest for service invocation. @@ -148,6 +149,7 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo Role: "assistant", RequestID: c.GetString(types.RequestIDContextKey.String()), IsCompleted: false, + Channel: request.Channel, }, knowledgeBaseIDs: secutils.SanitizeForLogArray(kbIDs), knowledgeIDs: secutils.SanitizeForLogArray(knowledgeIDs), @@ -157,6 +159,7 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo mentionedItems: convertMentionedItems(request.MentionedItems), effectiveTenantID: effectiveTenantID, images: request.Images, + channel: request.Channel, } return reqCtx, &request, nil @@ -475,7 +478,7 @@ func (h *Handler) executeQA(reqCtx *qaRequestContext, mode qaMode, generateTitle } // Create user message - userMsg, err := h.createUserMessage(ctx, sessionID, reqCtx.query, reqCtx.requestID, reqCtx.mentionedItems, convertImageAttachments(reqCtx.images)) + userMsg, err := h.createUserMessage(ctx, sessionID, reqCtx.query, reqCtx.requestID, reqCtx.mentionedItems, convertImageAttachments(reqCtx.images), reqCtx.channel) if err != nil { reqCtx.c.Error(errors.NewInternalServerError(err.Error())) return diff --git a/internal/handler/session/types.go b/internal/handler/session/types.go index 235577ca..75f080bf 100644 --- a/internal/handler/session/types.go +++ b/internal/handler/session/types.go @@ -49,6 +49,7 @@ type CreateKnowledgeQARequest struct { DisableTitle bool `json:"disable_title"` // Whether to disable auto title generation EnableMemory bool `json:"enable_memory"` // Whether memory feature is enabled for this request Images []ImageAttachment `json:"images"` // Attached images for multimodal chat + Channel string `json:"channel"` // Source channel: "web", "api", "im", etc. } // SearchKnowledgeRequest defines the request structure for searching knowledge without LLM summarization diff --git a/internal/im/service.go b/internal/im/service.go index ac2c7b2e..6614d6e7 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -1424,6 +1424,7 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, 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) @@ -1433,6 +1434,7 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, 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) @@ -1611,6 +1613,7 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin RequestID: requestID, CreatedAt: time.Now(), IsCompleted: true, + Channel: "im", }) if err != nil { return "", fmt.Errorf("create user message: %w", err) @@ -1623,6 +1626,7 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin RequestID: requestID, CreatedAt: time.Now(), IsCompleted: false, + Channel: "im", }) if err != nil { return "", fmt.Errorf("create assistant message: %w", err) diff --git a/internal/types/message.go b/internal/types/message.go index 6624bbc2..ba452497 100644 --- a/internal/types/message.go +++ b/internal/types/message.go @@ -125,6 +125,8 @@ type Message struct { IsFallback bool `json:"is_fallback,omitempty"` // Agent total execution duration in milliseconds (from query start to answer start) AgentDurationMs int64 `json:"agent_duration_ms,omitempty" gorm:"column:agent_duration_ms;default:0"` + // Channel indicates the source channel of this message (e.g., "web", "api", "im") + Channel string `json:"channel,omitempty" gorm:"type:varchar(50);default:''"` // KnowledgeID links this message to a Knowledge entry in the chat history knowledge base // Used for vector search indexing: when set, the message content has been indexed as a Knowledge passage KnowledgeID string `json:"knowledge_id,omitempty" gorm:"type:varchar(36);index"` diff --git a/migrations/sqlite/000000_init.up.sql b/migrations/sqlite/000000_init.up.sql index 6b6f3312..4ce295bb 100644 --- a/migrations/sqlite/000000_init.up.sql +++ b/migrations/sqlite/000000_init.up.sql @@ -143,6 +143,7 @@ CREATE TABLE IF NOT EXISTS messages ( mentioned_items TEXT DEFAULT '[]', is_completed BOOLEAN NOT NULL DEFAULT 0, is_fallback BOOLEAN NOT NULL DEFAULT 0, + channel VARCHAR(50) NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME diff --git a/migrations/versioned/000025_message_channel.down.sql b/migrations/versioned/000025_message_channel.down.sql new file mode 100644 index 00000000..8c21a777 --- /dev/null +++ b/migrations/versioned/000025_message_channel.down.sql @@ -0,0 +1,3 @@ +-- Migration: 000025_message_channel (rollback) +-- Description: Remove channel column from messages +ALTER TABLE messages DROP COLUMN IF EXISTS channel; diff --git a/migrations/versioned/000025_message_channel.up.sql b/migrations/versioned/000025_message_channel.up.sql new file mode 100644 index 00000000..e3a8d62f --- /dev/null +++ b/migrations/versioned/000025_message_channel.up.sql @@ -0,0 +1,9 @@ +-- Migration: 000025_message_channel +-- Description: Add channel column to messages to track the source channel (web, api, im, etc.) +DO $$ BEGIN RAISE NOTICE '[Migration 000025] Adding channel column to messages'; END $$; + +ALTER TABLE messages ADD COLUMN IF NOT EXISTS channel VARCHAR(50) NOT NULL DEFAULT ''; + +COMMENT ON COLUMN messages.channel IS 'Source channel of the message: web, api, im, etc.'; + +DO $$ BEGIN RAISE NOTICE '[Migration 000025] channel column added to messages successfully'; END $$;