feat(knowledge): add channel support to knowledge entries and related components

- Introduced a new `channel` field in the Knowledge struct and associated request types to track the source channel (e.g., "web", "api", "browser_extension").
- Updated various frontend components to display channel information and enhance user experience with channel labels.
- Enhanced localization files to support channel labels in English and Chinese.
- Modified backend services and database migrations to accommodate the new channel feature, ensuring consistent tracking across knowledge entries.
- Refactored related functions to integrate channel handling, improving overall knowledge management and context.
This commit is contained in:
wizardchen
2026-03-24 19:50:13 +08:00
committed by lyingbug
parent aedaea274d
commit 5245475e0f
23 changed files with 202 additions and 29 deletions

View File

@@ -29,6 +29,7 @@ type Knowledge struct {
Title string `json:"title"`
Description string `json:"description"`
Source string `json:"source"`
Channel string `json:"channel"`
ParseStatus string `json:"parse_status"`
SummaryStatus string `json:"summary_status"`
EnableStatus string `json:"enable_status"`
@@ -204,6 +205,8 @@ type CreateKnowledgeFromURLRequest struct {
Title string `json:"title,omitempty"`
// TagID is the optional tag ID to associate with the knowledge entry
TagID string `json:"tag_id,omitempty"`
// Channel identifies the ingestion channel (e.g. "web", "browser_extension", "api")
Channel string `json:"channel,omitempty"`
}
// CreateKnowledgeFromURL creates a knowledge entry from a URL.
@@ -435,9 +438,10 @@ func (c *Client) UpdateImageInfo(ctx context.Context,
// CreateManualKnowledgeRequest contains the parameters for creating a manual Markdown knowledge entry.
type CreateManualKnowledgeRequest struct {
Title string `json:"title"`
Content string `json:"content"`
Title string `json:"title"`
Content string `json:"content"`
TagID string `json:"tag_id,omitempty"`
Channel string `json:"channel,omitempty"`
}
// UpdateManualKnowledgeRequest contains the parameters for updating a manual Markdown knowledge entry.

View File

@@ -153,7 +153,8 @@ type SearchResult struct {
ImageInfo string `json:"image_info"`
Metadata map[string]string `json:"metadata"`
KnowledgeFilename string `json:"knowledge_filename"`
KnowledgeSource string `json:"knowledge_source"`
KnowledgeSource string `json:"knowledge_source"`
KnowledgeChannel string `json:"knowledge_channel"`
// MatchedContent is the actual content that was matched in vector search
// For FAQ: this is the matched question text (standard or similar question)
MatchedContent string `json:"matched_content,omitempty"`

View File

@@ -400,6 +400,23 @@ const getDisplayTitle = () => {
return props.details.title;
};
const channelLabelMap: Record<string, string> = {
web: 'knowledgeBase.channelWeb',
api: 'knowledgeBase.channelApi',
browser_extension: 'knowledgeBase.channelBrowserExtension',
wechat: 'knowledgeBase.channelWechat',
wecom: 'knowledgeBase.channelWecom',
feishu: 'knowledgeBase.channelFeishu',
dingtalk: 'knowledgeBase.channelDingtalk',
slack: 'knowledgeBase.channelSlack',
im: 'knowledgeBase.channelIm',
};
const getChannelLabel = (channel: string) => {
const key = channelLabelMap[channel];
return key ? t(key) : t('knowledgeBase.channelUnknown');
};
// 获取类型标签
const getTypeLabel = () => {
switch (props.details.type) {
@@ -714,6 +731,9 @@ const handleDetailsScroll = () => {
</div>
<div class="meta-row">
<span class="time"> {{ getTimeLabel() }}{{ details.time }} </span>
<t-tag v-if="details.channel && details.channel !== 'web'" size="small" variant="light" theme="warning" class="channel-tag">
{{ getChannelLabel(details.channel) }}
</t-tag>
<div class="view-mode-buttons">
<t-button
v-if="canPreview()"
@@ -1057,6 +1077,10 @@ const handleDetailsScroll = () => {
flex-wrap: wrap;
}
.channel-tag {
flex-shrink: 0;
}
.chunk-count {
color: var(--td-brand-color);
font-size: 12px;

View File

@@ -28,6 +28,7 @@ export default function (knowledgeBaseId?: string) {
total: 0,
type: "",
source: "",
channel: "",
file_type: "",
chunkLoading: false,
chunkLoadError: "",
@@ -151,6 +152,7 @@ export default function (knowledgeBaseId?: string) {
id: "",
type: "",
source: "",
channel: "",
file_type: "",
chunkLoadError: "",
});
@@ -164,6 +166,7 @@ export default function (knowledgeBaseId?: string) {
id: data.id,
type: data.type || 'file',
source: data.source || '',
channel: data.channel || '',
file_type: data.file_type || ''
});
}

View File

@@ -129,6 +129,17 @@ export default {
typeURL: 'URL',
typeManual: 'Manual',
typeFile: 'File',
channelLabel: 'Source Channel',
channelWeb: 'Web',
channelApi: 'API',
channelBrowserExtension: 'Browser Extension',
channelWechat: 'WeChat',
channelWecom: 'WeCom',
channelFeishu: 'Feishu',
channelDingtalk: 'DingTalk',
channelSlack: 'Slack',
channelIm: 'IM Channel',
channelUnknown: 'Unknown',
urlSource: 'Source URL',
documentTitle: 'Document Title',
webContent: 'Web Content',

View File

@@ -128,6 +128,17 @@ export default {
typeURL: "网页",
typeManual: "手动创建",
typeFile: "文件",
channelLabel: "来源渠道",
channelWeb: "网页端",
channelApi: "API",
channelBrowserExtension: "浏览器插件",
channelWechat: "微信",
channelWecom: "企业微信",
channelFeishu: "飞书",
channelDingtalk: "钉钉",
channelSlack: "Slack",
channelIm: "IM 渠道",
channelUnknown: "未知",
urlSource: "来源网址",
documentTitle: "文档标题",
webContent: "网页内容",

View File

@@ -100,6 +100,7 @@ export interface DocumentInfoDocument {
description?: string;
type?: string;
source?: string;
channel?: string;
file_name?: string;
file_type?: string;
file_size?: number;

View File

@@ -32,6 +32,10 @@
<span class="field-label">{{ $t('chat.documentSourceLabel') }}</span>
<span class="field-value">{{ formatSource(doc) }}</span>
</div>
<div class="info-field" v-if="doc.channel && doc.channel !== 'web'">
<span class="field-label">{{ $t('knowledgeBase.channelLabel') }}</span>
<span class="field-value">{{ getChannelLabel(doc.channel) }}</span>
</div>
<div class="info-field" v-if="doc.file_name || doc.file_type || doc.file_size">
<span class="field-label">{{ $t('chat.documentFileLabel') }}</span>
<span class="field-value">
@@ -84,6 +88,23 @@ const totalChunkCount = computed(() =>
documents.value.reduce((sum, doc) => sum + (doc.chunk_count || 0), 0),
);
const channelLabelMap: Record<string, string> = {
web: 'knowledgeBase.channelWeb',
api: 'knowledgeBase.channelApi',
browser_extension: 'knowledgeBase.channelBrowserExtension',
wechat: 'knowledgeBase.channelWechat',
wecom: 'knowledgeBase.channelWecom',
feishu: 'knowledgeBase.channelFeishu',
dingtalk: 'knowledgeBase.channelDingtalk',
slack: 'knowledgeBase.channelSlack',
im: 'knowledgeBase.channelIm',
};
const getChannelLabel = (channel: string) => {
const key = channelLabelMap[channel];
return key ? t(key) : t('knowledgeBase.channelUnknown');
};
const formatSource = (doc: DocumentInfoDocument) => {
if (doc.type && doc.source) {
return `${doc.type} · ${doc.source}`;

View File

@@ -280,6 +280,23 @@ const formatFileSize = (bytes?: number | string) => {
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
const channelLabelMap: Record<string, string> = {
web: 'knowledgeBase.channelWeb',
api: 'knowledgeBase.channelApi',
browser_extension: 'knowledgeBase.channelBrowserExtension',
wechat: 'knowledgeBase.channelWechat',
wecom: 'knowledgeBase.channelWecom',
feishu: 'knowledgeBase.channelFeishu',
dingtalk: 'knowledgeBase.channelDingtalk',
slack: 'knowledgeBase.channelSlack',
im: 'knowledgeBase.channelIm',
};
const getChannelLabel = (channel: string) => {
const key = channelLabelMap[channel];
return key ? t(key) : t('knowledgeBase.channelUnknown');
};
// 获取知识条目的显示类型
const getKnowledgeType = (item: any) => {
if (item.type === 'url') {
@@ -1923,6 +1940,7 @@ async function createNewSession(value: string): Promise<void> {
</template>
<div class="card-popover-meta">
<span class="card-popover-time">{{ t('knowledgeBase.updatedAt') }}{{ formatDocTime(hoveredCardItem.updated_at) }}</span>
<span v-if="(hoveredCardItem as any).channel && (hoveredCardItem as any).channel !== 'web'" class="card-popover-channel">{{ getChannelLabel((hoveredCardItem as any).channel) }}</span>
<span v-if="getTagName(hoveredCardItem.tag_id)" class="card-popover-tag">{{ getTagName(hoveredCardItem.tag_id) }}</span>
<span class="card-popover-type">{{ getKnowledgeType(hoveredCardItem) }}</span>
</div>
@@ -3383,6 +3401,13 @@ async function createNewSession(value: string): Promise<void> {
color: var(--td-text-color-secondary);
}
.card-popover-channel {
padding: 1px 6px;
background: var(--td-warning-color-light);
color: var(--td-warning-color);
border-radius: 4px;
}
.card-popover-tag {
padding: 1px 6px;
background: var(--td-success-color-light);

View File

@@ -601,6 +601,7 @@ func (p *PluginSearch) tryDirectChunkLoading(ctx context.Context, tenantID uint6
res.KnowledgeTitle = k.Title
res.KnowledgeFilename = k.FileName
res.KnowledgeSource = k.Source
res.KnowledgeChannel = k.Channel
res.Metadata = k.GetMetadata()
}

View File

@@ -221,6 +221,7 @@ func chunk2SearchResult(chunk *types.Chunk, knowledge *types.Knowledge) *types.S
ImageInfo: chunk.ImageInfo,
KnowledgeFilename: knowledge.FileName,
KnowledgeSource: knowledge.Source,
KnowledgeChannel: knowledge.Channel,
ChunkMetadata: chunk.Metadata,
KnowledgeBaseID: knowledge.KnowledgeBaseID,
}

View File

@@ -353,7 +353,7 @@ func (e *EvaluationService) EvalDataset(ctx context.Context, detail *types.Evalu
logger.Infof(ctx, "Creating knowledge from %d passages", len(passages))
// Create knowledge base from passages
knowledge, err := e.knowledgeService.CreateKnowledgeFromPassage(ctx, knowledgeBaseID, passages)
knowledge, err := e.knowledgeService.CreateKnowledgeFromPassage(ctx, knowledgeBaseID, passages, "")
if err != nil {
logger.Errorf(ctx, "Failed to create knowledge from passages: %v", err)
return err

View File

@@ -178,9 +178,16 @@ func checkStorageEngineConfigured(ctx context.Context, kb *types.KnowledgeBase)
return nil
}
func defaultChannel(ch string) string {
if ch == "" {
return types.ChannelWeb
}
return ch
}
// CreateKnowledgeFromFile creates a knowledge entry from an uploaded file
func (s *knowledgeService) CreateKnowledgeFromFile(ctx context.Context,
kbID string, file *multipart.FileHeader, metadata map[string]string, enableMultimodel *bool, customFileName string, tagID string,
kbID string, file *multipart.FileHeader, metadata map[string]string, enableMultimodel *bool, customFileName string, tagID string, channel string,
) (*types.Knowledge, error) {
logger.Info(ctx, "Start creating knowledge from file")
@@ -322,6 +329,7 @@ func (s *knowledgeService) CreateKnowledgeFromFile(ctx context.Context,
KnowledgeBaseID: kbID,
TagID: tagID, // 设置分类ID用于知识分类管理
Type: "file",
Channel: defaultChannel(channel),
Title: safeFilename,
FileName: safeFilename,
FileType: getFileType(safeFilename),
@@ -434,14 +442,14 @@ func isFileURL(rawURL, fileName, fileType string) bool {
}
func (s *knowledgeService) CreateKnowledgeFromURL(ctx context.Context,
kbID string, rawURL string, fileName string, fileType string, enableMultimodel *bool, title string, tagID string,
kbID string, rawURL string, fileName string, fileType string, enableMultimodel *bool, title string, tagID string, channel string,
) (*types.Knowledge, error) {
logger.Info(ctx, "Start creating knowledge from URL")
logger.Infof(ctx, "Knowledge base ID: %s, URL: %s", kbID, rawURL)
// Route to file_url logic when the URL points to a downloadable file
if isFileURL(rawURL, fileName, fileType) {
return s.createKnowledgeFromFileURL(ctx, kbID, rawURL, fileName, fileType, enableMultimodel, title, tagID)
return s.createKnowledgeFromFileURL(ctx, kbID, rawURL, fileName, fileType, enableMultimodel, title, tagID, channel)
}
url := rawURL
@@ -510,6 +518,7 @@ func (s *knowledgeService) CreateKnowledgeFromURL(ctx context.Context,
TenantID: tenantID,
KnowledgeBaseID: kbID,
Type: "url",
Channel: defaultChannel(channel),
Title: title,
Source: url,
FileHash: fileHash,
@@ -628,6 +637,7 @@ func (s *knowledgeService) createKnowledgeFromFileURL(
enableMultimodel *bool,
title string,
tagID string,
channel string,
) (*types.Knowledge, error) {
logger.Info(ctx, "Start creating knowledge from file URL")
logger.Infof(ctx, "Knowledge base ID: %s, file URL: %s", kbID, fileURL)
@@ -720,6 +730,7 @@ func (s *knowledgeService) createKnowledgeFromFileURL(
TenantID: tenantID,
KnowledgeBaseID: kbID,
Type: "file_url",
Channel: defaultChannel(channel),
Title: title,
FileName: displayName,
FileType: fileType,
@@ -790,21 +801,21 @@ func (s *knowledgeService) createKnowledgeFromFileURL(
// CreateKnowledgeFromPassage creates a knowledge entry from text passages
func (s *knowledgeService) CreateKnowledgeFromPassage(ctx context.Context,
kbID string, passage []string,
kbID string, passage []string, channel string,
) (*types.Knowledge, error) {
return s.createKnowledgeFromPassageInternal(ctx, kbID, passage, false)
return s.createKnowledgeFromPassageInternal(ctx, kbID, passage, false, channel)
}
// CreateKnowledgeFromPassageSync creates a knowledge entry from text passages and waits for indexing to complete.
func (s *knowledgeService) CreateKnowledgeFromPassageSync(ctx context.Context,
kbID string, passage []string,
kbID string, passage []string, channel string,
) (*types.Knowledge, error) {
return s.createKnowledgeFromPassageInternal(ctx, kbID, passage, true)
return s.createKnowledgeFromPassageInternal(ctx, kbID, passage, true, channel)
}
// CreateKnowledgeFromManual creates or saves manual Markdown knowledge content.
func (s *knowledgeService) CreateKnowledgeFromManual(ctx context.Context,
kbID string, payload *types.ManualKnowledgePayload,
kbID string, payload *types.ManualKnowledgePayload, channel string,
) (*types.Knowledge, error) {
logger.Info(ctx, "Start creating manual knowledge entry")
@@ -857,6 +868,7 @@ func (s *knowledgeService) CreateKnowledgeFromManual(ctx context.Context,
TenantID: tenantID,
KnowledgeBaseID: kbID,
Type: types.KnowledgeTypeManual,
Channel: defaultChannel(channel),
Title: title,
Description: "",
Source: types.KnowledgeTypeManual,
@@ -901,7 +913,7 @@ func (s *knowledgeService) CreateKnowledgeFromManual(ctx context.Context,
// createKnowledgeFromPassageInternal consolidates the common logic for creating knowledge from passages.
// When syncMode is true, chunk processing is performed synchronously; otherwise, it's processed asynchronously.
func (s *knowledgeService) createKnowledgeFromPassageInternal(ctx context.Context,
kbID string, passage []string, syncMode bool,
kbID string, passage []string, syncMode bool, channel string,
) (*types.Knowledge, error) {
if syncMode {
logger.Info(ctx, "Start creating knowledge from passage (sync)")
@@ -940,6 +952,7 @@ func (s *knowledgeService) createKnowledgeFromPassageInternal(ctx context.Contex
TenantID: ctx.Value(types.TenantIDContextKey).(uint64),
KnowledgeBaseID: kbID,
Type: "passage",
Channel: defaultChannel(channel),
ParseStatus: "pending",
EnableStatus: "disabled",
CreatedAt: time.Now(),
@@ -1271,6 +1284,7 @@ func (s *knowledgeService) cloneKnowledge(
TenantID: targetKB.TenantID,
KnowledgeBaseID: targetKB.ID,
Type: src.Type,
Channel: src.Channel,
Title: src.Title,
Description: src.Description,
Source: src.Source,
@@ -6038,6 +6052,7 @@ func (s *knowledgeService) ensureFAQKnowledge(
TenantID: tenantID,
KnowledgeBaseID: kb.ID,
Type: types.KnowledgeTypeFAQ,
Channel: types.ChannelWeb,
Title: fmt.Sprintf("%s - FAQ", kb.Name),
Description: "FAQ 条目容器",
Source: types.KnowledgeTypeFAQ,
@@ -8610,6 +8625,7 @@ func (s *knowledgeService) getOrCreateFAQKnowledge(ctx context.Context, kb *type
TenantID: kb.TenantID,
KnowledgeBaseID: kb.ID,
Type: types.KnowledgeTypeFAQ,
Channel: types.ChannelWeb,
Title: "FAQ",
ParseStatus: "completed",
EnableStatus: "enabled",
@@ -8621,6 +8637,7 @@ func (s *knowledgeService) getOrCreateFAQKnowledge(ctx context.Context, kb *type
knowledge.Title = srcKnowledge.Title
knowledge.Description = srcKnowledge.Description
knowledge.Source = srcKnowledge.Source
knowledge.Channel = srcKnowledge.Channel
knowledge.Metadata = srcKnowledge.Metadata
}

View File

@@ -272,6 +272,7 @@ func (s *knowledgeBaseService) buildSearchResult(chunk *types.Chunk,
ImageInfo: chunk.ImageInfo,
KnowledgeFilename: knowledge.FileName,
KnowledgeSource: knowledge.Source,
KnowledgeChannel: knowledge.Channel,
ChunkMetadata: chunk.Metadata,
MatchedContent: matchedContent,
KnowledgeBaseID: knowledge.KnowledgeBaseID,

View File

@@ -369,7 +369,7 @@ func (s *messageService) IndexMessageToKB(ctx context.Context, userQuery string,
passages = append(passages, passage)
// Use async (non-sync) passage creation so it doesn't block the response
knowledge, err := s.knowService.CreateKnowledgeFromPassage(ctx, cfg.KnowledgeBaseID, passages)
knowledge, err := s.knowService.CreateKnowledgeFromPassage(ctx, cfg.KnowledgeBaseID, passages, "")
if err != nil {
logger.Warnf(ctx, "Failed to index message to chat history KB: %v", err)
return

View File

@@ -89,7 +89,7 @@ func (s *WebSearchService) CompressWithRAG(
if body != "" {
contentLines = append(contentLines, body)
}
knowledge, err := knowSvc.CreateKnowledgeFromPassageSync(ctx, createdKB.ID, contentLines)
knowledge, err := knowSvc.CreateKnowledgeFromPassageSync(ctx, createdKB.ID, contentLines, "")
if err != nil {
logger.Warnf(ctx, "failed to ingest passage into temp KB: %v", err)
continue

View File

@@ -280,8 +280,10 @@ func (h *KnowledgeHandler) CreateKnowledgeFromFile(c *gin.Context) {
tagID = ""
}
channel := c.PostForm("channel")
// Create knowledge entry from the file
knowledge, err := h.kgService.CreateKnowledgeFromFile(ctx, kbID, file, metadata, enableMultimodel, customFileName, tagID)
knowledge, err := h.kgService.CreateKnowledgeFromFile(ctx, kbID, file, metadata, enableMultimodel, customFileName, tagID, channel)
// Check for duplicate knowledge error
if err != nil {
if h.handleDuplicateKnowledgeError(c, err, knowledge, "file") {
@@ -348,6 +350,7 @@ func (h *KnowledgeHandler) CreateKnowledgeFromURL(c *gin.Context) {
EnableMultimodel *bool `json:"enable_multimodel"`
Title string `json:"title"`
TagID string `json:"tag_id"`
Channel string `json:"channel"`
}
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse URL request", err)
@@ -375,7 +378,7 @@ func (h *KnowledgeHandler) CreateKnowledgeFromURL(c *gin.Context) {
)
// Create knowledge entry from the URL
knowledge, err := h.kgService.CreateKnowledgeFromURL(ctx, kbID, req.URL, req.FileName, req.FileType, req.EnableMultimodel, req.Title, req.TagID)
knowledge, err := h.kgService.CreateKnowledgeFromURL(ctx, kbID, req.URL, req.FileName, req.FileType, req.EnableMultimodel, req.Title, req.TagID, req.Channel)
// Check for duplicate knowledge error
if err != nil {
if h.handleDuplicateKnowledgeError(c, err, knowledge, "url") {
@@ -440,7 +443,7 @@ func (h *KnowledgeHandler) CreateManualKnowledge(c *gin.Context) {
return
}
knowledge, err := h.kgService.CreateKnowledgeFromManual(ctx, kbID, &req)
knowledge, err := h.kgService.CreateKnowledgeFromManual(ctx, kbID, &req, req.Channel)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)

View File

@@ -1919,7 +1919,7 @@ func (s *Service) processFileToKnowledgeBase(ctx context.Context, msg *IncomingM
fh := newInMemoryFileHeader(fileName, content)
// Create knowledge entry via the knowledge service
knowledge, err := s.knowledgeService.CreateKnowledgeFromFile(kbCtx, kbID, fh, nil, nil, "", "")
knowledge, err := s.knowledgeService.CreateKnowledgeFromFile(kbCtx, kbID, fh, nil, nil, "", "", imPlatformToChannel(channel.Platform))
if err != nil {
errMsg := err.Error()
// Check for duplicate file
@@ -2269,6 +2269,24 @@ func fileExtension(filename string) string {
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
}
}
// 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 {

View File

@@ -12,7 +12,7 @@ import (
// KnowledgeService defines the interface for knowledge services.
type KnowledgeService interface {
// CreateKnowledgeFromFile creates knowledge from a file.
// tagID is optional - when provided, the file will be assigned to the specified tag/category.
// channel identifies the ingestion channel (e.g. "web", "api", "wechat"); empty defaults to "web".
CreateKnowledgeFromFile(
ctx context.Context,
kbID string,
@@ -21,11 +21,12 @@ type KnowledgeService interface {
enableMultimodel *bool,
customFileName string,
tagID string,
channel string,
) (*types.Knowledge, error)
// CreateKnowledgeFromURL creates knowledge from a URL.
// When fileName or fileType is provided (or the URL path has a known file extension),
// the URL is treated as a direct file download instead of a web page crawl.
// tagID is optional - when provided, the knowledge will be assigned to the specified tag/category.
// channel identifies the ingestion channel; empty defaults to "web".
CreateKnowledgeFromURL(
ctx context.Context,
kbID string,
@@ -35,16 +36,20 @@ type KnowledgeService interface {
enableMultimodel *bool,
title string,
tagID string,
channel string,
) (*types.Knowledge, error)
// CreateKnowledgeFromPassage creates knowledge from text passages.
CreateKnowledgeFromPassage(ctx context.Context, kbID string, passage []string) (*types.Knowledge, error)
// channel identifies the ingestion channel; empty defaults to "web".
CreateKnowledgeFromPassage(ctx context.Context, kbID string, passage []string, channel string) (*types.Knowledge, error)
// CreateKnowledgeFromPassageSync creates knowledge from text passages and waits until chunks are indexed.
CreateKnowledgeFromPassageSync(ctx context.Context, kbID string, passage []string) (*types.Knowledge, error)
CreateKnowledgeFromPassageSync(ctx context.Context, kbID string, passage []string, channel string) (*types.Knowledge, error)
// CreateKnowledgeFromManual creates or saves manual Markdown knowledge content.
// channel identifies the ingestion channel; empty defaults to "web".
CreateKnowledgeFromManual(
ctx context.Context,
kbID string,
payload *types.ManualKnowledgePayload,
channel string,
) (*types.Knowledge, error)
// GetKnowledgeByID retrieves knowledge by ID (uses tenant from context).
GetKnowledgeByID(ctx context.Context, id string) (*types.Knowledge, error)

View File

@@ -16,6 +16,20 @@ const (
KnowledgeTypeFAQ = "faq"
)
// Channel constants identify through which channel a knowledge entry was ingested.
// Aligned with Message.Channel values ("web", "api", "im") but allows finer granularity.
const (
ChannelWeb = "web" // Web UI (default)
ChannelAPI = "api" // External API call
ChannelBrowserExtension = "browser_extension" // Browser extension / plugin
ChannelWechat = "wechat" // WeChat
ChannelWecom = "wecom" // WeCom (企业微信)
ChannelFeishu = "feishu" // Feishu / Lark
ChannelDingtalk = "dingtalk" // DingTalk
ChannelSlack = "slack" // Slack
ChannelIM = "im" // Generic IM channel
)
// Knowledge parse status constants
const (
// ParseStatusPending indicates the knowledge is waiting to be processed
@@ -69,8 +83,10 @@ type Knowledge struct {
Title string `json:"title"`
// Description of the knowledge
Description string `json:"description"`
// Source of the knowledge
// Source of the knowledge (e.g. URL address for url type, "manual" for manual type)
Source string `json:"source"`
// Channel indicates through which channel the knowledge was ingested (web, api, browser_extension, wechat, etc.)
Channel string `json:"channel" gorm:"type:varchar(50);default:'web'"`
// Parse status of the knowledge
ParseStatus string `json:"parse_status"`
// Summary status for async summary generation
@@ -148,6 +164,7 @@ type ManualKnowledgePayload struct {
Content string `json:"content"`
Status string `json:"status"`
TagID string `json:"tag_id"`
Channel string `json:"channel"`
}
// KnowledgeSearchScope defines a (tenant_id, knowledge_base_id) scope for knowledge search (e.g. own KBs + shared KBs).
@@ -271,6 +288,9 @@ func (k *Knowledge) EnsureManualDefaults() {
if k.Source == "" {
k.Source = KnowledgeTypeManual
}
if k.Channel == "" {
k.Channel = ChannelWeb
}
}
// IsDraft returns whether the payload should be saved as draft.

View File

@@ -120,6 +120,9 @@ type SearchResult struct {
// Used to indicate the source of the knowledge, such as "url"
KnowledgeSource string `json:"knowledge_source"`
// KnowledgeChannel indicates through which channel the knowledge was ingested (web, api, wechat, etc.)
KnowledgeChannel string `json:"knowledge_channel"`
// ChunkMetadata stores chunk-level metadata (e.g., generated questions)
ChunkMetadata JSON `json:"chunk_metadata,omitempty"`

View File

@@ -1,3 +1,4 @@
-- Migration: 000025_message_channel (rollback)
-- Description: Remove channel column from messages
-- Description: Remove channel column from messages and knowledge
ALTER TABLE messages DROP COLUMN IF EXISTS channel;
ALTER TABLE knowledges DROP COLUMN IF EXISTS channel;

View File

@@ -1,9 +1,11 @@
-- 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 $$;
-- Description: Add channel column to messages and knowledge to track the source channel (web, api, im, etc.)
DO $$ BEGIN RAISE NOTICE '[Migration 000025] Adding channel column to messages and knowledge'; 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 $$;
ALTER TABLE knowledges ADD COLUMN IF NOT EXISTS channel VARCHAR(50) NOT NULL DEFAULT 'web';
COMMENT ON COLUMN knowledges.channel IS 'Source channel of the knowledge: web, api, browser_extension, wechat, etc.';
DO $$ BEGIN RAISE NOTICE '[Migration 000025] channel column added to messages and knowledge successfully'; END $$;