diff --git a/.env.example b/.env.example index 9f03b93e5a..83cef1a971 100644 --- a/.env.example +++ b/.env.example @@ -90,6 +90,16 @@ DOCREADER_TRANSPORT=grpc # APP_EXTERNAL_URL= # 前端外部 origin,用于邀请链接等绝对 URL(留空走 host-relative)。 # FRONTEND_BASE_URL= +# API 响应里文件引用的默认形式:handle(默认)/ public。 +# handle — 返回内部 resource://,客户端需再调用带鉴权的 /files 代理取图。 +# public — 直接返回可加载的 http(s) 链接,第三方 App 拿到即可渲染,无需二次请求。 +# 单次请求可用 ?resource_urls=handle|public 覆盖本变量,覆盖范围见 README「API 参考」。 +# public 依赖上面的 APP_EXTERNAL_URL(或存储后端本身公网可达)才能生成外链; +# 无法生成时该引用保持 resource:// 原样,客户端仍可回退到 /files。 +# 注意:public 会为每个被引用文件签发限时(WeKnora 2 小时 / MinIO 24 小时)的匿名可读链接。 +# 匿名的 embed 渠道与限定知识库的 API Key 不受本变量影响,始终返回 handle。 +# 建议同时配置 SYSTEM_AES_KEY,以便复用 grant 行、稳定直链 URL 并降低读接口的写入压力。 +# RESOURCE_URL_MODE=handle # 对外暴露的 MCP Server 端口(默认 8082)。 # MCP_PORT=8082 @@ -200,11 +210,16 @@ LOCAL_STORAGE_BASE_DIR=/data/files # TOS_PATH_PREFIX=your_tos_path_prefix # TOS_TEMP_BUCKET_NAME=your_tos_temp_bucket_name # TOS_TEMP_REGION=your_tos_temp_region +# 如果使用AWS S3作为文件存储,需要配置以下参数 +# AWS S3的访问端点(可选;留空使用 Region 对应的 AWS 标准端点,例如 https://s3.amazonaws.com) # ----- AWS S3(STORAGE_TYPE=s3)----- # S3_ENDPOINT=https://s3.amazonaws.com # S3_REGION=us-east-1 -# S3_ACCESS_KEY=your_s3_access_key +# AWS S3访问密钥 Access Key(可选;AK/SK 同时留空时使用 AWS SDK 默认凭证链, +# 支持 EC2/ECS/EKS IAM Role、IRSA/Web Identity、环境变量和共享配置文件) +# AWS S3访问密钥 Secret Key(必须与 Access Key 同时填写或同时留空) # S3_SECRET_KEY=your_s3_secret_key +# S3_ACCESS_KEY=your_s3_access_key # S3_BUCKET_NAME=your_s3_bucket_name # S3_PATH_PREFIX=weknora/ # S3_USE_SSL=true @@ -479,6 +494,20 @@ OLLAMA_BASE_URL=http://host.docker.internal:11434 # WEKNORA_CHAT_ATTACHMENT_OCR_CONCURRENCY=8 # WEKNORA_CHAT_ATTACHMENT_OCR_MAX_PAGES=8 +# ========== E9. 飞书云文档解析模式 ========== +# 控制飞书云盘 / 飞书知识库同步新版云文档(docx)时的解析路径: +# export(默认,留空即可):走异步导出 API 下载 .docx 二进制,交给 docreader +# 解析。图片 inline 进父文档(parent_chunk_id 同 knowledge 关联),检索 / +# wiki / 智能体三个场景都能关联图片内容。代价:同步变慢;飞书 docx 内的 +# file block 附件不会随导出下载(会丢,需单独同步);若云文档中存在电子表格、 +# 多维表格等,导出后会变成内嵌在 docx 中的表格,weknora 会无法解析这些表格。 +# blocks:走 blocks API 转 Markdown。快,保留 docx 内附件;但图片渲染成空 +# `![图片]()` 占位符、并作为独立知识条目入库,与父文档无内容关联(检索 / +# wiki / 智能体均无法把图片关联回文档);电子表格会解析成 markdown 表格, +# 存在丢失合并单元格等样式风险。 +# 不需要图片关联、追求速度、要保留附件或需保留电子表格样式时设为 blocks。 +# 此方案为临时方案,若后续有更好的解析方案,会移除此环境变量并替换。 +# FEISHU_DOCX_PARSE_MODE=export # ##################################################################### # F. 认证与空间隔离 diff --git a/.gitattributes b/.gitattributes index 671cc2429c..9c0d7762fd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,13 @@ # acceptance/contract envelope tests. *.go text eol=lf cli/acceptance/testdata/**/*.json text eol=lf + +# Trellis: append-only developer journals should merge cleanly across +# parallel sessions/worktrees — each session only appends a new block, so +# there is nothing to actually conflict on. +# +# Do NOT add a rule for workspace/*/index.md here — it is fully regenerated +# every session, so a real conflict there is expected and safe to resolve by +# picking either side (task state lives in task.json, not index.md). See +# .trellis/spec/cli/backend/directory-structure.md for details. +.trellis/workspace/*/journal-*.md merge=union diff --git a/cli/go.mod b/cli/go.mod index e0327f4852..7c42bc8c46 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -6,9 +6,9 @@ require ( github.com/Tencent/WeKnora/client v0.0.0-00010101000000-000000000000 github.com/charmbracelet/huh v1.0.0 github.com/itchyny/gojq v0.12.19 - github.com/mattn/go-isatty v0.0.22 - github.com/mattn/go-runewidth v0.0.24 - github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/mattn/go-isatty v0.0.24 + github.com/mattn/go-runewidth v0.0.27 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 @@ -51,9 +51,10 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.15.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.15.0 // indirect ) // In-repo SDK module: cli pulls the local client/ until both modules ship diff --git a/cli/go.sum b/cli/go.sum index 3420ef01c5..4e963874fb 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -67,16 +67,16 @@ github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo79 github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= -github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -112,13 +112,15 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/client/agent.go b/client/agent.go index 0f560cdea8..ccc9ee8407 100644 --- a/client/agent.go +++ b/client/agent.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" ) @@ -77,8 +78,10 @@ func (c *Client) AgentQAStream(ctx context.Context, sessionID string, query stri } // AgentQAStreamWithRequest performs agent-based Q&A with SSE streaming using the full request payload. +// Pass ResourceURLOptions to receive public HTTP(S) file URLs in the stream. func (c *Client) AgentQAStreamWithRequest(ctx context.Context, sessionID string, request *AgentQARequest, callback AgentEventCallback, + opts ...ResourceURLOptions, ) error { if request == nil { return fmt.Errorf("agent QA request cannot be nil") @@ -88,7 +91,11 @@ func (c *Client) AgentQAStreamWithRequest(ctx context.Context, } path := fmt.Sprintf("/api/v1/agent-chat/%s", sessionID) - resp, err := c.doRequestStream(ctx, http.MethodPost, path, request, nil) + queryParams := url.Values{} + if len(opts) > 0 { + applyResourceURLQuery(queryParams, &opts[0]) + } + resp, err := c.doRequestStream(ctx, http.MethodPost, path, request, queryParams) if err != nil { return fmt.Errorf("request failed: %w", err) } diff --git a/client/knowledge.go b/client/knowledge.go index 8bfbe299a5..1c6efe16d8 100644 --- a/client/knowledge.go +++ b/client/knowledge.go @@ -36,6 +36,7 @@ type Knowledge struct { EnableStatus string `json:"enable_status"` EmbeddingModelID string `json:"embedding_model_id"` FileName string `json:"file_name"` + FolderPath string `json:"folder_path"` FileType string `json:"file_type"` FileSize int64 `json:"file_size"` FileHash string `json:"file_hash"` @@ -86,14 +87,14 @@ var ErrDuplicateURL = errors.New("URL already exists") // KnowledgeProcessOverrides stores per-upload parse config overrides sent as process_config. // When nil, the server uses the knowledge base defaults only. type KnowledgeProcessOverrides struct { - ParserEngineRules []ParserEngineRule `json:"parser_engine_rules,omitempty"` - ChunkingConfig *ChunkingConfig `json:"chunking_config,omitempty"` - EnableMultimodel *bool `json:"enable_multimodel,omitempty"` - VLMConfig *VLMConfig `json:"vlm_config,omitempty"` - ASRConfig *ASRConfig `json:"asr_config,omitempty"` - QuestionGenerationConfig *QuestionGenerationConfig `json:"question_generation_config,omitempty"` - GraphEnabled *bool `json:"graph_enabled,omitempty"` - ExtractConfig *ExtractConfig `json:"extract_config,omitempty"` + ParserEngineRules []ParserEngineRule `json:"parser_engine_rules,omitempty"` + ChunkingConfig *ChunkingConfig `json:"chunking_config,omitempty"` + EnableMultimodel *bool `json:"enable_multimodel,omitempty"` + VLMConfig *VLMConfig `json:"vlm_config,omitempty"` + ASRConfig *ASRConfig `json:"asr_config,omitempty"` + QuestionGenerationConfig *QuestionGenerationConfig `json:"question_generation_config,omitempty"` + GraphEnabled *bool `json:"graph_enabled,omitempty"` + ExtractConfig *ExtractConfig `json:"extract_config,omitempty"` } // CreateKnowledgeFromFile creates a knowledge entry from a local file path @@ -320,7 +321,9 @@ func (c *Client) ListKnowledge(ctx context.Context, // KnowledgeListFilter mirrors the server-side filters accepted by GET // /api/v1/knowledge-bases/{id}/knowledge. Empty / zero fields are omitted from -// the request. +// the request. FolderPath, when non-nil, selects a folder scope; the empty +// string means the knowledge base root. Omit FolderPath entirely for a flat +// listing across every folder. type KnowledgeListFilter struct { TagID string Keyword string @@ -329,8 +332,10 @@ type KnowledgeListFilter struct { Source string // StartTime / EndTime filter on knowledge updated_at. Zero values are skipped. // They are serialized in RFC3339 format. - StartTime time.Time - EndTime time.Time + StartTime time.Time + EndTime time.Time + FolderPath *string + FolderRecursive bool } // ListKnowledgeWithFilter lists knowledge entries with the full filter surface. @@ -366,6 +371,12 @@ func (c *Client) ListKnowledgeWithFilter(ctx context.Context, if !filter.EndTime.IsZero() { queryParams.Add("end_time", filter.EndTime.Format(time.RFC3339)) } + if filter.FolderPath != nil { + queryParams.Add("folder_path", *filter.FolderPath) + if filter.FolderRecursive { + queryParams.Add("folder_recursive", "true") + } + } resp, err := c.doRequest(ctx, http.MethodGet, path, nil, queryParams) if err != nil { @@ -380,6 +391,105 @@ func (c *Client) ListKnowledgeWithFilter(ctx context.Context, return response.Data, response.Total, nil } +// KnowledgeFolderNode is one node of the knowledge base folder tree. +type KnowledgeFolderNode struct { + Path string `json:"path"` + Name string `json:"name"` + DocumentCount int64 `json:"document_count"` + TotalCount int64 `json:"total_count"` + Children []*KnowledgeFolderNode `json:"children,omitempty"` +} + +// KnowledgeFolderTree is returned by ListKnowledgeFolders. +type KnowledgeFolderTree struct { + RootDocumentCount int64 `json:"root_document_count"` + TotalDocumentCount int64 `json:"total_document_count"` + Folders []*KnowledgeFolderNode `json:"folders"` +} + +// ListKnowledgeFolders returns the folder hierarchy of a knowledge base. +func (c *Client) ListKnowledgeFolders(ctx context.Context, knowledgeBaseID string) (*KnowledgeFolderTree, error) { + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/knowledge/folders", knowledgeBaseID) + resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil) + if err != nil { + return nil, err + } + + var response struct { + Success bool `json:"success"` + Data *KnowledgeFolderTree `json:"data"` + } + if err := parseResponse(resp, &response); err != nil { + return nil, err + } + return response.Data, nil +} + +// MoveKnowledgeToFolderRequest re-files knowledge entries under a folder path. +type MoveKnowledgeToFolderRequest struct { + KBID string `json:"kb_id"` + IDs []string `json:"knowledge_ids"` + FolderPath string `json:"folder_path"` +} + +// MoveKnowledgeToFolderResponse is the payload of POST /knowledge/folder. +type MoveKnowledgeToFolderResponse struct { + MovedCount int64 `json:"moved_count"` + FolderPath string `json:"folder_path"` +} + +// MoveKnowledgeToFolder updates folder_path for the given knowledge entries. +// An empty FolderPath moves documents back to the knowledge base root. +func (c *Client) MoveKnowledgeToFolder(ctx context.Context, req *MoveKnowledgeToFolderRequest) (*MoveKnowledgeToFolderResponse, error) { + resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/knowledge/folder", req, nil) + if err != nil { + return nil, err + } + + var response struct { + Success bool `json:"success"` + Data *MoveKnowledgeToFolderResponse `json:"data"` + } + if err := parseResponse(resp, &response); err != nil { + return nil, err + } + return response.Data, nil +} + +// RenameKnowledgeFolderRequest moves a folder and its subtree to a new path. +type RenameKnowledgeFolderRequest struct { + From string `json:"from"` + To string `json:"to"` +} + +// RenameKnowledgeFolderResponse is the payload of PUT .../knowledge/folders. +type RenameKnowledgeFolderResponse struct { + MovedCount int64 `json:"moved_count"` + FolderPath string `json:"folder_path"` +} + +// RenameKnowledgeFolder rewrites folder_path for a folder and every descendant. +func (c *Client) RenameKnowledgeFolder( + ctx context.Context, + knowledgeBaseID string, + req *RenameKnowledgeFolderRequest, +) (*RenameKnowledgeFolderResponse, error) { + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/knowledge/folders", knowledgeBaseID) + resp, err := c.doRequest(ctx, http.MethodPut, path, req, nil) + if err != nil { + return nil, err + } + + var response struct { + Success bool `json:"success"` + Data *RenameKnowledgeFolderResponse `json:"data"` + } + if err := parseResponse(resp, &response); err != nil { + return nil, err + } + return response.Data, nil +} + // DeleteKnowledge enqueues an asynchronous delete for the given knowledge entry. // The server returns 200 once the task has been submitted; the actual deletion is // performed by a background worker (same pipeline as BatchDeleteKnowledge). @@ -527,7 +637,7 @@ func (c *Client) ReparseKnowledge(ctx context.Context, knowledgeID string) (*Kno // - pending — task has not started // - processing — DocReader / chunking / embedding stage // - finalizing — primary parse done, enrichment subtasks (summary, -// question generation, graph extract) still running +// question generation, graph extract) still running // // Returns an error when the knowledge is in a terminal state // (completed, failed) or already being deleted. diff --git a/client/message.go b/client/message.go index b5c59e3c6b..a00ca702ba 100644 --- a/client/message.go +++ b/client/message.go @@ -14,10 +14,10 @@ import ( // ToolResult represents the result of a tool execution type ToolResult struct { - Success bool `json:"success"` // Whether the tool executed successfully - Output string `json:"output"` // Human-readable output - Data map[string]interface{} `json:"data,omitempty"` // Structured data for programmatic use - Error string `json:"error,omitempty"` // Error message if execution failed + Success bool `json:"success"` // Whether the tool executed successfully + Output string `json:"output"` // Human-readable output + Data map[string]interface{} `json:"data,omitempty"` // Structured data for programmatic use + Error string `json:"error,omitempty"` // Error message if execution failed Images []string `json:"images,omitempty"` // Base64 data URIs from tool (e.g. MCP image content) } @@ -60,12 +60,15 @@ type MessageListResponse struct { Data []Message `json:"data"` } -// LoadMessages loads session messages, supports pagination and time filtering +// LoadMessages loads session messages, supports pagination and time filtering. +// Pass ResourceURLOptions to request public HTTP(S) file URLs instead of +// resource:// handles. func (c *Client) LoadMessages( ctx context.Context, sessionID string, limit int, beforeTime *time.Time, + opts ...ResourceURLOptions, ) ([]Message, error) { path := fmt.Sprintf("/api/v1/messages/%s/load", sessionID) @@ -75,6 +78,9 @@ func (c *Client) LoadMessages( if beforeTime != nil { queryParams.Add("before_time", beforeTime.Format(time.RFC3339Nano)) } + if len(opts) > 0 { + applyResourceURLQuery(queryParams, &opts[0]) + } resp, err := c.doRequest(ctx, http.MethodGet, path, nil, queryParams) if err != nil { @@ -139,14 +145,14 @@ type SearchMessagesRequest struct { // MessageSearchGroupItem represents a grouped search result item type MessageSearchGroupItem struct { - RequestID string `json:"request_id"` - SessionID string `json:"session_id"` - SessionTitle string `json:"session_title"` - QueryContent string `json:"query_content"` - AnswerContent string `json:"answer_content"` - Score float64 `json:"score"` - MatchType string `json:"match_type"` - CreatedAt time.Time `json:"created_at"` + RequestID string `json:"request_id"` + SessionID string `json:"session_id"` + SessionTitle string `json:"session_title"` + QueryContent string `json:"query_content"` + AnswerContent string `json:"answer_content"` + Score float64 `json:"score"` + MatchType string `json:"match_type"` + CreatedAt time.Time `json:"created_at"` } // MessageSearchResult represents the result of a message search diff --git a/client/resource_urls.go b/client/resource_urls.go new file mode 100644 index 0000000000..442fae4881 --- /dev/null +++ b/client/resource_urls.go @@ -0,0 +1,30 @@ +package client + +import "net/url" + +// ResourceURLMode selects how file references are returned in API responses. +// Empty leaves the choice to the server (handle by default, or +// RESOURCE_URL_MODE when set). +type ResourceURLMode string + +const ( + // ResourceURLModeHandle returns internal resource:// handles; clients fetch + // bytes through the authenticated /files proxy. + ResourceURLModeHandle ResourceURLMode = "handle" + // ResourceURLModePublic returns time-limited HTTP(S) URLs loadable without + // WeKnora credentials. + ResourceURLModePublic ResourceURLMode = "public" +) + +// ResourceURLOptions carries the optional resource_urls query parameter shared +// by chat, message-history, and knowledge-search endpoints. +type ResourceURLOptions struct { + ResourceURLs ResourceURLMode +} + +func applyResourceURLQuery(q url.Values, opts *ResourceURLOptions) { + if opts == nil || opts.ResourceURLs == "" || opts.ResourceURLs == ResourceURLModeHandle { + return + } + q.Set("resource_urls", string(opts.ResourceURLs)) +} diff --git a/client/resource_urls_test.go b/client/resource_urls_test.go new file mode 100644 index 0000000000..f081bd7568 --- /dev/null +++ b/client/resource_urls_test.go @@ -0,0 +1,26 @@ +package client + +import ( + "net/url" + "testing" +) + +func TestApplyResourceURLQuery(t *testing.T) { + q := url.Values{} + applyResourceURLQuery(q, nil) + if len(q) != 0 { + t.Fatalf("nil opts: got %v, want empty", q) + } + + q = url.Values{} + applyResourceURLQuery(q, &ResourceURLOptions{ResourceURLs: ResourceURLModeHandle}) + if len(q) != 0 { + t.Fatalf("handle mode: got %v, want empty", q) + } + + q = url.Values{} + applyResourceURLQuery(q, &ResourceURLOptions{ResourceURLs: ResourceURLModePublic}) + if got := q.Get("resource_urls"); got != "public" { + t.Fatalf("public mode: got %q, want public", got) + } +} diff --git a/client/session.go b/client/session.go index 37ff2ecce1..31863cea6a 100644 --- a/client/session.go +++ b/client/session.go @@ -262,17 +262,24 @@ type StreamResponse struct { Data map[string]interface{} `json:"data,omitempty"` // Additional metadata for enhanced display } -// KnowledgeQAStream knowledge Q&A streaming API +// KnowledgeQAStream knowledge Q&A streaming API. +// Pass ResourceURLOptions to receive public HTTP(S) file URLs in the stream. func (c *Client) KnowledgeQAStream( ctx context.Context, sessionID string, request *KnowledgeQARequest, callback func(*StreamResponse) error, + opts ...ResourceURLOptions, ) error { path := fmt.Sprintf("/api/v1/knowledge-chat/%s", sessionID) debugLogger.Debug("knowledge_qa_stream_start", "session_id", sessionID, "query", request.Query) - resp, err := c.doRequestStream(ctx, http.MethodPost, path, request, nil) + queryParams := url.Values{} + if len(opts) > 0 { + applyResourceURLQuery(queryParams, &opts[0]) + } + + resp, err := c.doRequestStream(ctx, http.MethodPost, path, request, queryParams) if err != nil { debugLogger.Debug("request_failed", "error", err) return err @@ -350,17 +357,22 @@ func (c *Client) KnowledgeQAStream( return nil } -// ContinueStream continues to receive an active stream for a session +// ContinueStream continues to receive an active stream for a session. +// Pass ResourceURLOptions to receive public HTTP(S) file URLs in the stream. func (c *Client) ContinueStream( ctx context.Context, sessionID string, messageID string, callback func(*StreamResponse) error, + opts ...ResourceURLOptions, ) error { path := fmt.Sprintf("/api/v1/sessions/continue-stream/%s", sessionID) queryParams := url.Values{} queryParams.Add("message_id", messageID) + if len(opts) > 0 { + applyResourceURLQuery(queryParams, &opts[0]) + } resp, err := c.doRequestStream(ctx, http.MethodGet, path, nil, queryParams) if err != nil { @@ -463,15 +475,25 @@ type SearchKnowledgeResponse struct { Data []*SearchResult `json:"data"` } -// SearchKnowledge performs knowledge base search without LLM summarization -func (c *Client) SearchKnowledge(ctx context.Context, request *SearchKnowledgeRequest) ([]*SearchResult, error) { +// SearchKnowledge performs knowledge base search without LLM summarization. +// Pass ResourceURLOptions to receive public HTTP(S) file URLs in results. +func (c *Client) SearchKnowledge( + ctx context.Context, + request *SearchKnowledgeRequest, + opts ...ResourceURLOptions, +) ([]*SearchResult, error) { debugLogger.Debug("search_knowledge_start", "knowledge_base_ids", request.KnowledgeBaseIDs, "knowledge_ids", request.KnowledgeIDs, "query", request.Query, ) - resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/knowledge-search", request, nil) + queryParams := url.Values{} + if len(opts) > 0 { + applyResourceURLQuery(queryParams, &opts[0]) + } + + resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/knowledge-search", request, queryParams) if err != nil { debugLogger.Debug("request_failed", "error", err) return nil, err diff --git a/docker-compose.yml b/docker-compose.yml index 6bdda41eb2..5155038267 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -352,6 +352,8 @@ services: - OIDC_AUTH_SCOPES=${OIDC_AUTH_SCOPES:-} - OIDC_USER_INFO_MAPPING_USER_NAME=${OIDC_USER_INFO_MAPPING_USER_NAME:-} - OIDC_USER_INFO_MAPPING_EMAIL=${OIDC_USER_INFO_MAPPING_EMAIL:-} + # 飞书云文档解析模式 export:导出为docx,blocks:根据飞书云文档块解析为markdown。默认为:export + - FEISHU_DOCX_PARSE_MODE=${FEISHU_DOCX_PARSE_MODE:-export} depends_on: redis: condition: service_started diff --git a/docs/api/README.md b/docs/api/README.md index 11ed509e31..a705f5005d 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -7,6 +7,7 @@ - [基础信息](#基础信息) - [认证机制](#认证机制) - [错误处理](#错误处理) +- [文件与图片引用(`resource://` 与直链)](#文件与图片引用resource-与直链) - [API 概览](#api-概览) ## 概述 @@ -62,6 +63,49 @@ X-Request-ID: unique_request_id } ``` +## 文件与图片引用(`resource://` 与直链) + +响应里的图片、图表、附件默认以内部引用 `resource://` 返回,例如问答答案中的 +`![示意图](resource://xifDo7NTSL300Lp1goVutw)`。这类引用不能被浏览器直接加载,客户端需要再 +调用带鉴权的 `GET /files?file_path=<引用>` 代理去取字节流。 + +如果你在把 WeKnora 集成进自己的 App,可以让服务端直接返回**可加载的 http(s) 直链**,省掉这一次 +额外请求: + +| 方式 | 用法 | 生效范围 | +|------|------|----------| +| 单次请求 | 在 URL 上加 `?resource_urls=public` | 仅该次请求 | +| 整个部署 | 环境变量 `RESOURCE_URL_MODE=public` | 所有未显式传参的请求 | + +`resource_urls` 取值为 `handle`(默认,保持内部引用)或 `public`(返回直链);传其它值返回 +`400`。单次请求的参数优先于环境变量,因此把部署默认设成 `public` 后,仍可用 +`?resource_urls=handle` 单独退回。 + +支持该参数的接口: + +- `POST /api/v1/knowledge-chat/{session_id}`(SSE) +- `POST /api/v1/agent-chat/{session_id}`(SSE) +- `GET /api/v1/sessions/continue-stream/{session_id}`(SSE) +- `GET /api/v1/messages/{session_id}/load` +- `POST /api/v1/knowledge-search` + +改写覆盖答案正文、`knowledge_references`(含 `image_info`)、Agent 执行步骤与工具结果,以及消息 +上的图片附件。流式回答里跨两个 chunk 被截断的引用会先缓冲再改写,客户端拿到的始终是完整链接。 + +### 注意事项 + +- **需要外链能力。** 直链由存储后端预签名,或由 `APP_EXTERNAL_URL` + `/r/` 提供。二者都不 + 可用时(例如 local 存储且未设 `APP_EXTERNAL_URL`),该引用**保持 `resource://` 原样**,客户端 + 仍可回退到 `/files` 代理。详见 `.env.example` 中的 `APP_EXTERNAL_URL` 说明。 +- **直链是限时匿名可读的**(WeKnora 签发的 grant 2 小时,MinIO 预签名 24 小时)。任何拿到链接的 + 人在过期前都能读取该文件,请勿写入日志或转发给不应看到该文件的一方。 +- **嵌入式(embed)渠道不支持该参数。** 其访客是匿名的,`/api/v1/embed/...` 下的接口会强制使用 + `handle`(即使传了 `?resource_urls=public`、或部署默认是 `public`),图片仍走渠道维度的鉴权代理。 +- **限定知识库的 API Key 不能使用 `public`**,返回 `403`。这类 Key 本身也被拒绝访问 `/files` + 代理,若能拿到匿名直链等于绕过同一道限制。改用 `handle` 即可正常调用。 +- **同一文件的直链会在有效期内复用**:重复请求不会反复签发凭证,也不会每次都拿到不同的 URL,客户端 + 和 CDN 的缓存因此可以命中。凭证被吊销或过期后链接立即失效。 + ## API 概览 WeKnora API 按功能分为以下几类: diff --git a/docs/api/chat.md b/docs/api/chat.md index eb91ecf83c..b84b4c1c7c 100644 --- a/docs/api/chat.md +++ b/docs/api/chat.md @@ -15,6 +15,14 @@ 基于知识库的 RAG 问答,支持 SSE 流式响应。 +**查询参数**: + +| 参数 | 取值 | 说明 | +|------|------|------| +| `resource_urls` | `handle`(默认)/ `public` | `public` 让答案与引用里的图片直接返回可加载的 http(s) 链接,省去逐个调用 `/files` 代理。详见[文件与图片引用](./README.md#文件与图片引用resource-与直链) | + +同样适用于下面的 `/agent-chat/:session_id`、`/knowledge-search` 与 `/sessions/continue-stream/:session_id`。 + **请求参数**: | 参数 | 类型 | 必填 | 说明 | diff --git a/docs/api/knowledge-search.md b/docs/api/knowledge-search.md index 13b6cb5baf..96a3b9f244 100644 --- a/docs/api/knowledge-search.md +++ b/docs/api/knowledge-search.md @@ -21,6 +21,10 @@ > 必须指定 `knowledge_base_id` 或 `knowledge_base_ids` 中的至少一个。 +**查询参数**: + +- `resource_urls`: `handle`(默认)或 `public`。`public` 把检索结果 `content` / `image_info` 里的 `resource://` 引用换成可加载的 http(s) 链接,详见[文件与图片引用](./README.md#文件与图片引用resource-与直链) + **请求**: ```curl diff --git a/docs/api/knowledge.md b/docs/api/knowledge.md index d4c632e34f..94dc51c737 100644 --- a/docs/api/knowledge.md +++ b/docs/api/knowledge.md @@ -10,6 +10,8 @@ | POST | `/knowledge-bases/:id/knowledge/url` | 从 URL 创建知识(网页抓取或文件下载) | | POST | `/knowledge-bases/:id/knowledge/manual` | 创建手工 Markdown 知识 | | GET | `/knowledge-bases/:id/knowledge` | 列出知识库下的知识(支持分页/筛选) | +| GET | `/knowledge-bases/:id/knowledge/folders` | 获取知识库文件夹目录树 | +| PUT | `/knowledge-bases/:id/knowledge/folders` | 重命名或移动文件夹(含子目录) | | DELETE | `/knowledge-bases/:id/knowledge` | 清空知识库下的所有知识(异步任务) | | GET | `/knowledge/batch` | 按 ID 列表批量获取知识 | | GET | `/knowledge/:id` | 获取知识详情 | @@ -25,6 +27,7 @@ | GET | `/knowledge/search` | 跨知识库搜索/过滤知识 | | POST | `/knowledge/batch-reparse` | 同一知识库内批量重新解析知识(异步任务) | | POST | `/knowledge/batch-delete` | 同一知识库内批量删除知识(异步任务) | +| POST | `/knowledge/folder` | 批量移动知识到指定文件夹(仅改归类) | | POST | `/knowledge/move` | 迁移知识到另一知识库(异步任务) | | GET | `/knowledge/move/progress/:task_id` | 查询知识迁移任务进度 | @@ -276,6 +279,10 @@ curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/knowle | `source` | string | - | 按来源/渠道过滤:`web` / `api` / `browser_extension` / `feishu` / `notion` / `yuque` / `wechat` 等; 特殊值 `manual` / `url` 命中 `type` 列 | | `start_time` | string | - | 更新时间起点,接受 RFC3339 (`2024-05-01T00:00:00+08:00`) 或 `YYYY-MM-DD HH:MM:SS` / `YYYY-MM-DD` | | `end_time` | string | - | 更新时间终点,格式同 `start_time` | +| `folder_path` | string | - | 按文件夹路径筛选;**仅当传入该参数时启用文件夹维度**。空字符串表示知识库根目录(不含子文件夹中的文档);不传则列出全部文件夹下的文档(扁平视图) | +| `folder_recursive` | bool | false | 为 `true` 时同时返回 `folder_path` 子目录内的文档;仅在传入 `folder_path` 时生效 | + +> **文件夹筛选语义**:`folder_path` 是否出现在 query 中决定列表模式,不能仅凭空字符串区分「根目录」与「不按文件夹过滤」。集成方若需要浏览某一文件夹,应显式传 `folder_path`(根目录传 `folder_path=`);若需要全库扁平列表,则省略该参数。 **请求**: @@ -304,6 +311,7 @@ curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/knowle "enable_status": "disabled", "embedding_model_id": "dff7bc94-7885-4dd1-bfd5-bd96e4df2fc3", "file_name": "", + "folder_path": "", "file_type": "", "file_size": 0, "file_hash": "", @@ -324,6 +332,88 @@ curl --location 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/knowle } ``` +## GET `/knowledge-bases/:id/knowledge/folders` - 获取文件夹目录树 + +返回由 `folder_path` 聚合而成的目录树,包含每个文件夹的直接文档数与含子目录的总数。只读,权限与列出知识相同(Viewer+ 且对 KB 有 read 权限)。 + +**响应**: + +```json +{ + "success": true, + "data": { + "root_document_count": 2, + "total_document_count": 10, + "folders": [ + { + "path": "docs", + "name": "docs", + "document_count": 0, + "total_count": 4, + "children": [ + { + "path": "docs/spec", + "name": "spec", + "document_count": 3, + "total_count": 4, + "children": [] + } + ] + } + ] + } +} +``` + +## PUT `/knowledge-bases/:id/knowledge/folders` - 重命名或移动文件夹 + +把一个文件夹及其所有子目录改到新路径。目标路径已存在时两个文件夹合并;不能移动到自身子目录下。需要 KB **创建者**或 Admin+,且对 KB 有 write 权限。 + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +| ------ | ------ | ---- | ---------------------------- | +| `from` | string | 是 | 源文件夹路径(不能为空) | +| `to` | string | 是 | 目标文件夹路径(不能为空) | + +**响应**: + +```json +{ + "success": true, + "data": { + "moved_count": 3, + "folder_path": "handbook" + } +} +``` + +`moved_count` 为 0 表示源文件夹不存在或已是 no-op。 + +## POST `/knowledge/folder` - 批量移动知识到文件夹 + +批量修改知识条目的 `folder_path`,仅调整归类,**不会**重新解析、分块或向量化。目标路径不存在时会自动创建;空路径表示移回知识库根目录。需要 editor/admin 且为 KB 创建者或 Admin+。 + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +| --------------- | -------- | ---- | ----------------------------------------- | +| `kb_id` | string | 是 | 知识库 ID | +| `knowledge_ids` | string[] | 是 | 知识 ID 列表(最多 200 条) | +| `folder_path` | string | 否 | 目标文件夹路径;省略或空字符串表示根目录 | + +**响应**: + +```json +{ + "success": true, + "data": { + "moved_count": 2, + "folder_path": "archive/2026" + } +} +``` + ## DELETE `/knowledge-bases/:id/knowledge` - 清空知识库下的所有知识 异步提交"清空任务",删除该知识库下的全部知识条目;知识库本身保留。**仅 KB 所有者(admin 且空间匹配)可操作**。 diff --git a/docs/api/message.md b/docs/api/message.md index 3e317134fd..00428ad9e8 100644 --- a/docs/api/message.md +++ b/docs/api/message.md @@ -15,6 +15,7 @@ - `before_time`: 上一次拉取的最早一条消息的 created_at 字段,为空拉取最近的消息 - `limit`: 每页条数(默认 20) +- `resource_urls`: `handle`(默认)或 `public`。`public` 把历史消息里的 `resource://` 图片引用换成可加载的 http(s) 链接,详见[文件与图片引用](./README.md#文件与图片引用resource-与直链) **请求**: diff --git a/docs/docs.go b/docs/docs.go index 15643bd3b8..9f87c24c8f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -19,6 +19,73 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/agent-chat/{session_id}": { + "post": { + "security": [ + { + "Bearer": [] + }, + { + "ApiKeyAuth": [] + } + ], + "description": "基于Agent的智能问答,支持多轮对话和SSE流式响应", + "consumes": [ + "application/json" + ], + "produces": [ + "text/event-stream" + ], + "tags": [ + "问答" + ], + "summary": "Agent问答", + "parameters": [ + { + "type": "string", + "description": "会话ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "问答请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handler_session.CreateKnowledgeQARequest" + } + }, + { + "enum": [ + "handle", + "public" + ], + "type": "string", + "default": "handle", + "description": "文件引用形式,public 返回可加载直链", + "name": "resource_urls", + "in": "query" + } + ], + "responses": { + "200": { + "description": "问答结果(SSE流)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "请求参数错误", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + } + } + } + }, "/agent/mcp-oauth-resolutions/{pending_id}": { "post": { "security": [ @@ -5002,6 +5069,18 @@ const docTemplate = `{ "description": "更新时间终点,RFC3339 格式", "name": "end_time", "in": "query" + }, + { + "type": "string", + "description": "文件夹路径筛选,空字符串表示知识库根目录;不传该参数则不按文件夹过滤", + "name": "folder_path", + "in": "query" + }, + { + "type": "boolean", + "description": "为 true 时同时返回子文件夹内的文档", + "name": "folder_recursive", + "in": "query" } ], "responses": { @@ -5163,6 +5242,113 @@ const docTemplate = `{ } } }, + "/knowledge-bases/{id}/knowledge/folders": { + "get": { + "security": [ + { + "Bearer": [] + }, + { + "ApiKeyAuth": [] + } + ], + "description": "返回知识库内由文件夹上传形成的目录树,包含每个文件夹的直接文档数与含子目录的总数", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "知识管理" + ], + "summary": "获取知识库文件夹目录树", + "parameters": [ + { + "type": "string", + "description": "知识库ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "目录树", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "请求参数错误", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + }, + { + "ApiKeyAuth": [] + } + ], + "description": "把一个文件夹及其所有子目录改到新路径。目标路径已存在时两个文件夹合并;不能移动到自身子目录下", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "知识管理" + ], + "summary": "重命名或移动文件夹", + "parameters": [ + { + "type": "string", + "description": "知识库ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "重命名请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handler.RenameKnowledgeFolderRequest" + } + } + ], + "responses": { + "200": { + "description": "重命名成功", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "请求参数错误", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + }, + "403": { + "description": "权限不足", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + } + } + } + }, "/knowledge-bases/{id}/knowledge/manual": { "post": { "security": [ @@ -5855,6 +6041,73 @@ const docTemplate = `{ } } }, + "/knowledge-chat/{session_id}": { + "post": { + "security": [ + { + "Bearer": [] + }, + { + "ApiKeyAuth": [] + } + ], + "description": "基于知识库的问答(使用LLM总结),支持SSE流式响应", + "consumes": [ + "application/json" + ], + "produces": [ + "text/event-stream" + ], + "tags": [ + "问答" + ], + "summary": "知识问答", + "parameters": [ + { + "type": "string", + "description": "会话ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "问答请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handler_session.CreateKnowledgeQARequest" + } + }, + { + "enum": [ + "handle", + "public" + ], + "type": "string", + "default": "handle", + "description": "文件引用形式,public 返回可加载直链", + "name": "resource_urls", + "in": "query" + } + ], + "responses": { + "200": { + "description": "问答结果(SSE流)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "请求参数错误", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + } + } + } + }, "/knowledge/batch": { "get": { "security": [ @@ -6028,6 +6281,61 @@ const docTemplate = `{ } } }, + "/knowledge/folder": { + "post": { + "security": [ + { + "Bearer": [] + }, + { + "ApiKeyAuth": [] + } + ], + "description": "批量修改知识条目所属文件夹。文件夹由路径推导而来,因此目标路径不存在时会自动创建;空路径表示知识库顶层。仅调整归类,不会重新解析文档", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "知识管理" + ], + "summary": "移动知识到文件夹", + "parameters": [ + { + "description": "移动请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handler.MoveKnowledgeToFolderRequest" + } + } + ], + "responses": { + "200": { + "description": "移动成功", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "请求参数错误", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + }, + "403": { + "description": "权限不足", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + } + } + } + }, "/knowledge/image/{id}/{chunk_id}": { "put": { "security": [ @@ -8805,6 +9113,17 @@ const docTemplate = `{ "description": "在此时间之前的消息(RFC3339Nano格式)", "name": "before_time", "in": "query" + }, + { + "enum": [ + "handle", + "public" + ], + "type": "string", + "default": "handle", + "description": "文件引用形式,public 返回可加载直链", + "name": "resource_urls", + "in": "query" } ], "responses": { @@ -10441,6 +10760,17 @@ const docTemplate = `{ "name": "message_id", "in": "query", "required": true + }, + { + "enum": [ + "handle", + "public" + ], + "type": "string", + "default": "handle", + "description": "文件引用形式,public 返回可加载直链", + "name": "resource_urls", + "in": "query" } ], "responses": { @@ -10490,6 +10820,17 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/internal_handler_session.SearchKnowledgeRequest" } + }, + { + "enum": [ + "handle", + "public" + ], + "type": "string", + "default": "handle", + "description": "文件引用形式,public 返回可加载直链", + "name": "resource_urls", + "in": "query" } ], "responses": { @@ -10752,118 +11093,6 @@ const docTemplate = `{ } } }, - "/sessions/{session_id}/agent-qa": { - "post": { - "security": [ - { - "Bearer": [] - }, - { - "ApiKeyAuth": [] - } - ], - "description": "基于Agent的智能问答,支持多轮对话和SSE流式响应", - "consumes": [ - "application/json" - ], - "produces": [ - "text/event-stream" - ], - "tags": [ - "问答" - ], - "summary": "Agent问答", - "parameters": [ - { - "type": "string", - "description": "会话ID", - "name": "session_id", - "in": "path", - "required": true - }, - { - "description": "问答请求", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_handler_session.CreateKnowledgeQARequest" - } - } - ], - "responses": { - "200": { - "description": "问答结果(SSE流)", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "请求参数错误", - "schema": { - "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" - } - } - } - } - }, - "/sessions/{session_id}/knowledge-qa": { - "post": { - "security": [ - { - "Bearer": [] - }, - { - "ApiKeyAuth": [] - } - ], - "description": "基于知识库的问答(使用LLM总结),支持SSE流式响应", - "consumes": [ - "application/json" - ], - "produces": [ - "text/event-stream" - ], - "tags": [ - "问答" - ], - "summary": "知识问答", - "parameters": [ - { - "type": "string", - "description": "会话ID", - "name": "session_id", - "in": "path", - "required": true - }, - { - "description": "问答请求", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_handler_session.CreateKnowledgeQARequest" - } - } - ], - "responses": { - "200": { - "description": "问答结果(SSE流)", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "请求参数错误", - "schema": { - "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" - } - } - } - } - }, "/sessions/{session_id}/messages/{message_id}/suggestions": { "get": { "security": [ @@ -16369,6 +16598,13 @@ const docTemplate = `{ "description": "Creation time of the knowledge", "type": "string" }, + "custom_metadata": { + "description": "CustomMetadata is user-authored descriptive metadata. It is deliberately\nseparate from Metadata, which contains internal ingestion state and IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, "deleted_at": { "description": "Deletion time of the knowledge", "allOf": [ @@ -16413,6 +16649,10 @@ const docTemplate = `{ "description": "File type of the knowledge", "type": "string" }, + "folder_path": { + "description": "FolderPath is the canonical relative directory this entry belongs to\ninside the knowledge base, e.g. \"docs/spec\" for a folder upload of\n\"docs/spec/design.md\". Empty means the knowledge base root. It is a\ndisplay/navigation concern only: it never affects where the file is\nphysically stored (see FilePath).", + "type": "string" + }, "id": { "description": "Unique identifier of the knowledge", "type": "string" @@ -17926,6 +18166,7 @@ const docTemplate = `{ "type": "boolean" }, "mineru_enable_ocr": { + "description": "MinerUEnableOCR is retained for compatibility with configurations saved\nbefore parse_method supported auto/ocr/txt.", "type": "boolean" }, "mineru_enable_table": { @@ -17942,6 +18183,9 @@ const docTemplate = `{ "description": "MinerU 自建解析参数", "type": "string" }, + "mineru_parse_method": { + "type": "string" + }, "mineru_vlm_server_url": { "description": "vLLM 服务器地址 (vlm-http-client / hybrid-http-client)", "type": "string" @@ -18001,6 +18245,10 @@ const docTemplate = `{ "items": { "type": "string" } + }, + "xlsx_first_row_as_header": { + "description": "XLSXFirstRowAsHeader restores row-1 column context for flat XLSX tables.\nnil preserves the parser default; an explicit false disables the mode.", + "type": "boolean" } } }, @@ -18617,6 +18865,10 @@ const docTemplate = `{ "description": "KnowledgeChannel indicates through which channel the knowledge was ingested (web, api, wechat, etc.)", "type": "string" }, + "knowledge_custom_metadata": { + "description": "KnowledgeCustomMetadata is user-authored context safe to expose to models.", + "type": "string" + }, "knowledge_description": { "description": "KnowledgeDescription is the description of the knowledge document", "type": "string" @@ -21169,6 +21421,28 @@ const docTemplate = `{ } } }, + "internal_handler.MoveKnowledgeToFolderRequest": { + "type": "object", + "required": [ + "kb_id", + "knowledge_ids" + ], + "properties": { + "folder_path": { + "description": "FolderPath is the destination folder; the empty string is the knowledge\nbase top level. It is deliberately not ` + "`" + `binding:\"required\"` + "`" + ` so documents\ncan be moved back out of every folder.", + "type": "string" + }, + "kb_id": { + "type": "string" + }, + "knowledge_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "internal_handler.PreviewChunkResult": { "type": "object", "properties": { @@ -21198,18 +21472,27 @@ const docTemplate = `{ "internal_handler.PreviewChunkingPayload": { "type": "object", "properties": { + "child_chunk_size": { + "type": "integer" + }, "chunk_overlap": { "type": "integer" }, "chunk_size": { "type": "integer" }, + "enable_parent_child": { + "type": "boolean" + }, "languages": { "type": "array", "items": { "type": "string" } }, + "parent_chunk_size": { + "type": "integer" + }, "separators": { "type": "array", "items": { @@ -21355,6 +21638,21 @@ const docTemplate = `{ } } }, + "internal_handler.RenameKnowledgeFolderRequest": { + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + } + }, "internal_handler.ResetUserPasswordRequest": { "type": "object", "required": [ @@ -21659,29 +21957,14 @@ const docTemplate = `{ "internal_handler.UpdateChunkRequest": { "type": "object", "properties": { - "chunk_index": { - "type": "integer" - }, "content": { "type": "string" }, - "embedding": { - "type": "array", - "items": { - "type": "number" - } - }, - "end_at": { + "expected_revision": { "type": "integer" }, - "image_info": { - "type": "string" - }, "is_enabled": { "type": "boolean" - }, - "start_at": { - "type": "integer" } } }, @@ -22076,6 +22359,10 @@ const docTemplate = `{ "description": "Selected custom agent ID (backend resolves shared agent and its workspace from share relation)", "type": "string" }, + "agent_source_tenant_id": { + "description": "Optional disambiguator; backend still verifies the share relation", + "type": "integer" + }, "attachment_ids": { "description": "Pre-uploaded session-scoped document IDs", "type": "array", diff --git a/docs/swagger.json b/docs/swagger.json index faadf1ad15..e1eb820d5f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -12,6 +12,73 @@ }, "basePath": "/api/v1", "paths": { + "/agent-chat/{session_id}": { + "post": { + "security": [ + { + "Bearer": [] + }, + { + "ApiKeyAuth": [] + } + ], + "description": "基于Agent的智能问答,支持多轮对话和SSE流式响应", + "consumes": [ + "application/json" + ], + "produces": [ + "text/event-stream" + ], + "tags": [ + "问答" + ], + "summary": "Agent问答", + "parameters": [ + { + "type": "string", + "description": "会话ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "问答请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handler_session.CreateKnowledgeQARequest" + } + }, + { + "enum": [ + "handle", + "public" + ], + "type": "string", + "default": "handle", + "description": "文件引用形式,public 返回可加载直链", + "name": "resource_urls", + "in": "query" + } + ], + "responses": { + "200": { + "description": "问答结果(SSE流)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "请求参数错误", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + } + } + } + }, "/agent/mcp-oauth-resolutions/{pending_id}": { "post": { "security": [ @@ -4995,6 +5062,18 @@ "description": "更新时间终点,RFC3339 格式", "name": "end_time", "in": "query" + }, + { + "type": "string", + "description": "文件夹路径筛选,空字符串表示知识库根目录;不传该参数则不按文件夹过滤", + "name": "folder_path", + "in": "query" + }, + { + "type": "boolean", + "description": "为 true 时同时返回子文件夹内的文档", + "name": "folder_recursive", + "in": "query" } ], "responses": { @@ -5156,6 +5235,113 @@ } } }, + "/knowledge-bases/{id}/knowledge/folders": { + "get": { + "security": [ + { + "Bearer": [] + }, + { + "ApiKeyAuth": [] + } + ], + "description": "返回知识库内由文件夹上传形成的目录树,包含每个文件夹的直接文档数与含子目录的总数", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "知识管理" + ], + "summary": "获取知识库文件夹目录树", + "parameters": [ + { + "type": "string", + "description": "知识库ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "目录树", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "请求参数错误", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + }, + { + "ApiKeyAuth": [] + } + ], + "description": "把一个文件夹及其所有子目录改到新路径。目标路径已存在时两个文件夹合并;不能移动到自身子目录下", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "知识管理" + ], + "summary": "重命名或移动文件夹", + "parameters": [ + { + "type": "string", + "description": "知识库ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "重命名请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handler.RenameKnowledgeFolderRequest" + } + } + ], + "responses": { + "200": { + "description": "重命名成功", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "请求参数错误", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + }, + "403": { + "description": "权限不足", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + } + } + } + }, "/knowledge-bases/{id}/knowledge/manual": { "post": { "security": [ @@ -5848,6 +6034,73 @@ } } }, + "/knowledge-chat/{session_id}": { + "post": { + "security": [ + { + "Bearer": [] + }, + { + "ApiKeyAuth": [] + } + ], + "description": "基于知识库的问答(使用LLM总结),支持SSE流式响应", + "consumes": [ + "application/json" + ], + "produces": [ + "text/event-stream" + ], + "tags": [ + "问答" + ], + "summary": "知识问答", + "parameters": [ + { + "type": "string", + "description": "会话ID", + "name": "session_id", + "in": "path", + "required": true + }, + { + "description": "问答请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handler_session.CreateKnowledgeQARequest" + } + }, + { + "enum": [ + "handle", + "public" + ], + "type": "string", + "default": "handle", + "description": "文件引用形式,public 返回可加载直链", + "name": "resource_urls", + "in": "query" + } + ], + "responses": { + "200": { + "description": "问答结果(SSE流)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "请求参数错误", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + } + } + } + }, "/knowledge/batch": { "get": { "security": [ @@ -6021,6 +6274,61 @@ } } }, + "/knowledge/folder": { + "post": { + "security": [ + { + "Bearer": [] + }, + { + "ApiKeyAuth": [] + } + ], + "description": "批量修改知识条目所属文件夹。文件夹由路径推导而来,因此目标路径不存在时会自动创建;空路径表示知识库顶层。仅调整归类,不会重新解析文档", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "知识管理" + ], + "summary": "移动知识到文件夹", + "parameters": [ + { + "description": "移动请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handler.MoveKnowledgeToFolderRequest" + } + } + ], + "responses": { + "200": { + "description": "移动成功", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "请求参数错误", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + }, + "403": { + "description": "权限不足", + "schema": { + "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" + } + } + } + } + }, "/knowledge/image/{id}/{chunk_id}": { "put": { "security": [ @@ -8798,6 +9106,17 @@ "description": "在此时间之前的消息(RFC3339Nano格式)", "name": "before_time", "in": "query" + }, + { + "enum": [ + "handle", + "public" + ], + "type": "string", + "default": "handle", + "description": "文件引用形式,public 返回可加载直链", + "name": "resource_urls", + "in": "query" } ], "responses": { @@ -10434,6 +10753,17 @@ "name": "message_id", "in": "query", "required": true + }, + { + "enum": [ + "handle", + "public" + ], + "type": "string", + "default": "handle", + "description": "文件引用形式,public 返回可加载直链", + "name": "resource_urls", + "in": "query" } ], "responses": { @@ -10483,6 +10813,17 @@ "schema": { "$ref": "#/definitions/internal_handler_session.SearchKnowledgeRequest" } + }, + { + "enum": [ + "handle", + "public" + ], + "type": "string", + "default": "handle", + "description": "文件引用形式,public 返回可加载直链", + "name": "resource_urls", + "in": "query" } ], "responses": { @@ -10745,118 +11086,6 @@ } } }, - "/sessions/{session_id}/agent-qa": { - "post": { - "security": [ - { - "Bearer": [] - }, - { - "ApiKeyAuth": [] - } - ], - "description": "基于Agent的智能问答,支持多轮对话和SSE流式响应", - "consumes": [ - "application/json" - ], - "produces": [ - "text/event-stream" - ], - "tags": [ - "问答" - ], - "summary": "Agent问答", - "parameters": [ - { - "type": "string", - "description": "会话ID", - "name": "session_id", - "in": "path", - "required": true - }, - { - "description": "问答请求", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_handler_session.CreateKnowledgeQARequest" - } - } - ], - "responses": { - "200": { - "description": "问答结果(SSE流)", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "请求参数错误", - "schema": { - "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" - } - } - } - } - }, - "/sessions/{session_id}/knowledge-qa": { - "post": { - "security": [ - { - "Bearer": [] - }, - { - "ApiKeyAuth": [] - } - ], - "description": "基于知识库的问答(使用LLM总结),支持SSE流式响应", - "consumes": [ - "application/json" - ], - "produces": [ - "text/event-stream" - ], - "tags": [ - "问答" - ], - "summary": "知识问答", - "parameters": [ - { - "type": "string", - "description": "会话ID", - "name": "session_id", - "in": "path", - "required": true - }, - { - "description": "问答请求", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_handler_session.CreateKnowledgeQARequest" - } - } - ], - "responses": { - "200": { - "description": "问答结果(SSE流)", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "请求参数错误", - "schema": { - "$ref": "#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError" - } - } - } - } - }, "/sessions/{session_id}/messages/{message_id}/suggestions": { "get": { "security": [ @@ -16362,6 +16591,13 @@ "description": "Creation time of the knowledge", "type": "string" }, + "custom_metadata": { + "description": "CustomMetadata is user-authored descriptive metadata. It is deliberately\nseparate from Metadata, which contains internal ingestion state and IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, "deleted_at": { "description": "Deletion time of the knowledge", "allOf": [ @@ -16406,6 +16642,10 @@ "description": "File type of the knowledge", "type": "string" }, + "folder_path": { + "description": "FolderPath is the canonical relative directory this entry belongs to\ninside the knowledge base, e.g. \"docs/spec\" for a folder upload of\n\"docs/spec/design.md\". Empty means the knowledge base root. It is a\ndisplay/navigation concern only: it never affects where the file is\nphysically stored (see FilePath).", + "type": "string" + }, "id": { "description": "Unique identifier of the knowledge", "type": "string" @@ -17919,6 +18159,7 @@ "type": "boolean" }, "mineru_enable_ocr": { + "description": "MinerUEnableOCR is retained for compatibility with configurations saved\nbefore parse_method supported auto/ocr/txt.", "type": "boolean" }, "mineru_enable_table": { @@ -17935,6 +18176,9 @@ "description": "MinerU 自建解析参数", "type": "string" }, + "mineru_parse_method": { + "type": "string" + }, "mineru_vlm_server_url": { "description": "vLLM 服务器地址 (vlm-http-client / hybrid-http-client)", "type": "string" @@ -17994,6 +18238,10 @@ "items": { "type": "string" } + }, + "xlsx_first_row_as_header": { + "description": "XLSXFirstRowAsHeader restores row-1 column context for flat XLSX tables.\nnil preserves the parser default; an explicit false disables the mode.", + "type": "boolean" } } }, @@ -18610,6 +18858,10 @@ "description": "KnowledgeChannel indicates through which channel the knowledge was ingested (web, api, wechat, etc.)", "type": "string" }, + "knowledge_custom_metadata": { + "description": "KnowledgeCustomMetadata is user-authored context safe to expose to models.", + "type": "string" + }, "knowledge_description": { "description": "KnowledgeDescription is the description of the knowledge document", "type": "string" @@ -21162,6 +21414,28 @@ } } }, + "internal_handler.MoveKnowledgeToFolderRequest": { + "type": "object", + "required": [ + "kb_id", + "knowledge_ids" + ], + "properties": { + "folder_path": { + "description": "FolderPath is the destination folder; the empty string is the knowledge\nbase top level. It is deliberately not `binding:\"required\"` so documents\ncan be moved back out of every folder.", + "type": "string" + }, + "kb_id": { + "type": "string" + }, + "knowledge_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "internal_handler.PreviewChunkResult": { "type": "object", "properties": { @@ -21191,18 +21465,27 @@ "internal_handler.PreviewChunkingPayload": { "type": "object", "properties": { + "child_chunk_size": { + "type": "integer" + }, "chunk_overlap": { "type": "integer" }, "chunk_size": { "type": "integer" }, + "enable_parent_child": { + "type": "boolean" + }, "languages": { "type": "array", "items": { "type": "string" } }, + "parent_chunk_size": { + "type": "integer" + }, "separators": { "type": "array", "items": { @@ -21348,6 +21631,21 @@ } } }, + "internal_handler.RenameKnowledgeFolderRequest": { + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + } + }, "internal_handler.ResetUserPasswordRequest": { "type": "object", "required": [ @@ -21652,29 +21950,14 @@ "internal_handler.UpdateChunkRequest": { "type": "object", "properties": { - "chunk_index": { - "type": "integer" - }, "content": { "type": "string" }, - "embedding": { - "type": "array", - "items": { - "type": "number" - } - }, - "end_at": { + "expected_revision": { "type": "integer" }, - "image_info": { - "type": "string" - }, "is_enabled": { "type": "boolean" - }, - "start_at": { - "type": "integer" } } }, @@ -22069,6 +22352,10 @@ "description": "Selected custom agent ID (backend resolves shared agent and its workspace from share relation)", "type": "string" }, + "agent_source_tenant_id": { + "description": "Optional disambiguator; backend still verifies the share relation", + "type": "integer" + }, "attachment_ids": { "description": "Pre-uploaded session-scoped document IDs", "type": "array", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7c37ab153b..72a2a86fd6 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1391,6 +1391,13 @@ definitions: created_at: description: Creation time of the knowledge type: string + custom_metadata: + description: |- + CustomMetadata is user-authored descriptive metadata. It is deliberately + separate from Metadata, which contains internal ingestion state and IDs. + items: + type: integer + type: array deleted_at: allOf: - $ref: '#/definitions/gorm.DeletedAt' @@ -1422,6 +1429,14 @@ definitions: file_type: description: File type of the knowledge type: string + folder_path: + description: |- + FolderPath is the canonical relative directory this entry belongs to + inside the knowledge base, e.g. "docs/spec" for a folder upload of + "docs/spec/design.md". Empty means the knowledge base root. It is a + display/navigation concern only: it never affects where the file is + physically stored (see FilePath). + type: string id: description: Unique identifier of the knowledge type: string @@ -2574,6 +2589,9 @@ definitions: mineru_enable_formula: type: boolean mineru_enable_ocr: + description: |- + MinerUEnableOCR is retained for compatibility with configurations saved + before parse_method supported auto/ocr/txt. type: boolean mineru_enable_table: type: boolean @@ -2585,6 +2603,8 @@ definitions: mineru_model: description: MinerU 自建解析参数 type: string + mineru_parse_method: + type: string mineru_vlm_server_url: description: vLLM 服务器地址 (vlm-http-client / hybrid-http-client) type: string @@ -2629,6 +2649,11 @@ definitions: items: type: string type: array + xlsx_first_row_as_header: + description: |- + XLSXFirstRowAsHeader restores row-1 column context for flat XLSX tables. + nil preserves the parser default; an explicit false disables the mode. + type: boolean type: object github_com_Tencent_WeKnora_internal_types.QuestionGenerationConfig: properties: @@ -3086,6 +3111,10 @@ definitions: description: KnowledgeChannel indicates through which channel the knowledge was ingested (web, api, wechat, etc.) type: string + knowledge_custom_metadata: + description: KnowledgeCustomMetadata is user-authored context safe to expose + to models. + type: string knowledge_description: description: KnowledgeDescription is the description of the knowledge document type: string @@ -4979,6 +5008,24 @@ definitions: task_id: type: string type: object + internal_handler.MoveKnowledgeToFolderRequest: + properties: + folder_path: + description: |- + FolderPath is the destination folder; the empty string is the knowledge + base top level. It is deliberately not `binding:"required"` so documents + can be moved back out of every folder. + type: string + kb_id: + type: string + knowledge_ids: + items: + type: string + type: array + required: + - kb_id + - knowledge_ids + type: object internal_handler.PreviewChunkResult: properties: content: @@ -4998,14 +5045,20 @@ definitions: type: object internal_handler.PreviewChunkingPayload: properties: + child_chunk_size: + type: integer chunk_overlap: type: integer chunk_size: type: integer + enable_parent_child: + type: boolean languages: items: type: string type: array + parent_chunk_size: + type: integer separators: items: type: string @@ -5112,6 +5165,16 @@ definitions: required: - modelName type: object + internal_handler.RenameKnowledgeFolderRequest: + properties: + from: + type: string + to: + type: string + required: + - from + - to + type: object internal_handler.ResetUserPasswordRequest: properties: email: @@ -5318,22 +5381,12 @@ definitions: type: object internal_handler.UpdateChunkRequest: properties: - chunk_index: - type: integer content: type: string - embedding: - items: - type: number - type: array - end_at: + expected_revision: type: integer - image_info: - type: string is_enabled: type: boolean - start_at: - type: integer type: object internal_handler.UpdateKnowledgeBaseRequest: properties: @@ -5601,6 +5654,9 @@ definitions: description: Selected custom agent ID (backend resolves shared agent and its workspace from share relation) type: string + agent_source_tenant_id: + description: Optional disambiguator; backend still verifies the share relation + type: integer attachment_ids: description: Pre-uploaded session-scoped document IDs items: @@ -5779,6 +5835,49 @@ info: title: WeKnora API version: "1.0" paths: + /agent-chat/{session_id}: + post: + consumes: + - application/json + description: 基于Agent的智能问答,支持多轮对话和SSE流式响应 + parameters: + - description: 会话ID + in: path + name: session_id + required: true + type: string + - description: 问答请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handler_session.CreateKnowledgeQARequest' + - default: handle + description: 文件引用形式,public 返回可加载直链 + enum: + - handle + - public + in: query + name: resource_urls + type: string + produces: + - text/event-stream + responses: + "200": + description: 问答结果(SSE流) + schema: + additionalProperties: true + type: object + "400": + description: 请求参数错误 + schema: + $ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError' + security: + - Bearer: [] + - ApiKeyAuth: [] + summary: Agent问答 + tags: + - 问答 /agent/mcp-oauth-resolutions/{pending_id}: post: consumes: @@ -8927,6 +9026,14 @@ paths: in: query name: end_time type: string + - description: 文件夹路径筛选,空字符串表示知识库根目录;不传该参数则不按文件夹过滤 + in: query + name: folder_path + type: string + - description: 为 true 时同时返回子文件夹内的文档 + in: query + name: folder_recursive + type: boolean produces: - application/json responses: @@ -9004,6 +9111,73 @@ paths: summary: 从文件创建知识 tags: - 知识管理 + /knowledge-bases/{id}/knowledge/folders: + get: + consumes: + - application/json + description: 返回知识库内由文件夹上传形成的目录树,包含每个文件夹的直接文档数与含子目录的总数 + parameters: + - description: 知识库ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 目录树 + schema: + additionalProperties: true + type: object + "400": + description: 请求参数错误 + schema: + $ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError' + security: + - Bearer: [] + - ApiKeyAuth: [] + summary: 获取知识库文件夹目录树 + tags: + - 知识管理 + put: + consumes: + - application/json + description: 把一个文件夹及其所有子目录改到新路径。目标路径已存在时两个文件夹合并;不能移动到自身子目录下 + parameters: + - description: 知识库ID + in: path + name: id + required: true + type: string + - description: 重命名请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handler.RenameKnowledgeFolderRequest' + produces: + - application/json + responses: + "200": + description: 重命名成功 + schema: + additionalProperties: true + type: object + "400": + description: 请求参数错误 + schema: + $ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError' + "403": + description: 权限不足 + schema: + $ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError' + security: + - Bearer: [] + - ApiKeyAuth: [] + summary: 重命名或移动文件夹 + tags: + - 知识管理 /knowledge-bases/{id}/knowledge/manual: post: consumes: @@ -9502,6 +9676,49 @@ paths: summary: 获取知识库复制进度 tags: - 知识库 + /knowledge-chat/{session_id}: + post: + consumes: + - application/json + description: 基于知识库的问答(使用LLM总结),支持SSE流式响应 + parameters: + - description: 会话ID + in: path + name: session_id + required: true + type: string + - description: 问答请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handler_session.CreateKnowledgeQARequest' + - default: handle + description: 文件引用形式,public 返回可加载直链 + enum: + - handle + - public + in: query + name: resource_urls + type: string + produces: + - text/event-stream + responses: + "200": + description: 问答结果(SSE流) + schema: + additionalProperties: true + type: object + "400": + description: 请求参数错误 + schema: + $ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError' + security: + - Bearer: [] + - ApiKeyAuth: [] + summary: 知识问答 + tags: + - 问答 /knowledge/{id}: delete: consumes: @@ -9842,6 +10059,40 @@ paths: summary: 批量重新解析知识 tags: - 知识管理 + /knowledge/folder: + post: + consumes: + - application/json + description: 批量修改知识条目所属文件夹。文件夹由路径推导而来,因此目标路径不存在时会自动创建;空路径表示知识库顶层。仅调整归类,不会重新解析文档 + parameters: + - description: 移动请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handler.MoveKnowledgeToFolderRequest' + produces: + - application/json + responses: + "200": + description: 移动成功 + schema: + additionalProperties: true + type: object + "400": + description: 请求参数错误 + schema: + $ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError' + "403": + description: 权限不足 + schema: + $ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError' + security: + - Bearer: [] + - ApiKeyAuth: [] + summary: 移动知识到文件夹 + tags: + - 知识管理 /knowledge/image/{id}/{chunk_id}: put: consumes: @@ -11369,6 +11620,14 @@ paths: in: query name: before_time type: string + - default: handle + description: 文件引用形式,public 返回可加载直链 + enum: + - handle + - public + in: query + name: resource_urls + type: string produces: - application/json responses: @@ -12521,76 +12780,6 @@ paths: summary: 取消置顶会话 tags: - 会话 - /sessions/{session_id}/agent-qa: - post: - consumes: - - application/json - description: 基于Agent的智能问答,支持多轮对话和SSE流式响应 - parameters: - - description: 会话ID - in: path - name: session_id - required: true - type: string - - description: 问答请求 - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_handler_session.CreateKnowledgeQARequest' - produces: - - text/event-stream - responses: - "200": - description: 问答结果(SSE流) - schema: - additionalProperties: true - type: object - "400": - description: 请求参数错误 - schema: - $ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError' - security: - - Bearer: [] - - ApiKeyAuth: [] - summary: Agent问答 - tags: - - 问答 - /sessions/{session_id}/knowledge-qa: - post: - consumes: - - application/json - description: 基于知识库的问答(使用LLM总结),支持SSE流式响应 - parameters: - - description: 会话ID - in: path - name: session_id - required: true - type: string - - description: 问答请求 - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_handler_session.CreateKnowledgeQARequest' - produces: - - text/event-stream - responses: - "200": - description: 问答结果(SSE流) - schema: - additionalProperties: true - type: object - "400": - description: 请求参数错误 - schema: - $ref: '#/definitions/github_com_Tencent_WeKnora_internal_errors.AppError' - security: - - Bearer: [] - - ApiKeyAuth: [] - summary: 知识问答 - tags: - - 问答 /sessions/{session_id}/messages/{message_id}/suggestions: get: parameters: @@ -12826,6 +13015,14 @@ paths: name: message_id required: true type: string + - default: handle + description: 文件引用形式,public 返回可加载直链 + enum: + - handle + - public + in: query + name: resource_urls + type: string produces: - text/event-stream responses: @@ -12856,6 +13053,14 @@ paths: required: true schema: $ref: '#/definitions/internal_handler_session.SearchKnowledgeRequest' + - default: handle + description: 文件引用形式,public 返回可加载直链 + enum: + - handle + - public + in: query + name: resource_urls + type: string produces: - application/json responses: diff --git "a/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221.md" "b/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221.md" index 171faedf56..cdf4e8927c 100644 --- "a/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221.md" +++ "b/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221.md" @@ -29,6 +29,22 @@ WeKnora 的数据源导入模块支持从外部平台(飞书、企业微信、 > 注意:飞书国际版(Lark)同样支持,自动适配 `https://open.larksuite.com` 的 API 地址 +## 飞书 docx 解析模式与环境变量 + +飞书新版云文档(docx)的解析路径由环境变量 `FEISHU_DOCX_PARSE_MODE` 控制(作用于 app 服务,对飞书知识库和飞书云盘两个连接器同时生效): + +| 模式 | 值 | 解析路径 | 图片与文档关联 | 速度 | docx 内附件 | +|---|---|---|---|---|---| +| export(默认) | 留空 / `export` | 异步导出 -> .docx 二进制 -> docreader 解析 | ✅ 图片 inline 进父文档,`parent_chunk_id` 关联 | 慢 | 丢失 | +| blocks | `blocks` | blocks API -> Markdown | ❌ 图片作为独立知识条目,与文档割裂 | 快 | 保留 | + +**优缺点对比:** + +- **export**:导出 .docx 交 docreader 解析,图片 inline 进父文档(与普通 docx 上传一致),通过 `parent_chunk_id` 建立同知识条目父子关联,三个场景都能关联图片内容;代价是同步变慢(异步导出 + docx 解析)、docx 内附件丢失、图片 OCR/caption 依赖多模态配置。 +- **blocks**:图片 block 渲染成空 `![图片]()` 占位符,图片单独下载成独立知识条目,检索 / Wiki / 智能体无法把图片内容关联回文档;但同步快、保留 docx 内 file block 附件。 + +配置方法:默认即为 export,无需设置;需要 blocks 模式时在 `.env` 或 `docker-compose.yml` 的 app 服务环境变量中设置 `FEISHU_DOCX_PARSE_MODE=blocks`,重启 app 服务生效。详见 [飞书云盘数据源接入说明](飞书云盘数据源接入说明.md#6-docx-解析模式与环境变量)。 + ## 架构设计 ``` diff --git "a/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\351\243\236\344\271\246\344\272\221\347\233\230\346\225\260\346\215\256\346\272\220\346\216\245\345\205\245\350\257\264\346\230\216.md" "b/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\351\243\236\344\271\246\344\272\221\347\233\230\346\225\260\346\215\256\346\272\220\346\216\245\345\205\245\350\257\264\346\230\216.md" new file mode 100644 index 0000000000..9b172a9978 --- /dev/null +++ "b/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\351\243\236\344\271\246\344\272\221\347\233\230\346\225\260\346\215\256\346\272\220\346\216\245\345\205\245\350\257\264\346\230\216.md" @@ -0,0 +1,167 @@ +# 飞书云盘数据源使用说明 + +飞书云盘数据源(`feishu_drive` / `lark_drive`)可以把飞书/Lark 云盘某个文件夹下的文档和文件自动同步到 WeKnora 知识库,支持增量同步、定时同步和子文件夹递归。 + +--- + +## 1. 前置条件:创建飞书应用 + +1. 登录[飞书开放平台](https://open.feishu.cn/app)(Lark 用户使用 [Lark 开放平台](https://open.larksuite.com/app)),创建**企业自建应用**。 +2. 记录应用的 **App ID**(`cli_` 开头)和 **App Secret**,配置数据源时需要填写。 +3. 按下节表格开通权限,并**发布应用版本**(权限修改后必须重新发布才生效)。 + +> 注意:飞书(open.feishu.cn)和 Lark(open.larksuite.com)是两个独立体系,应用不通用。同步飞书云盘用飞书应用,同步 Lark Drive 用 Lark 应用,凭据不能混用。 + +--- + +## 2. 所需权限(详细) + +在应用后台「权限管理」中开通以下 **3 个权限**: + +| 权限标识 | 名称 | 用途 | 缺少时的表现 | +|---|---|---|---| +| `drive:drive:readonly` | 查看云空间中的文件 | 列举文件夹内容(list API)、下载云盘普通文件 | 加载文件夹/同步时报 403,提示「需先将文件夹分享给应用所在的群」 | +| `drive:export:readonly` | 导出云文档 | 把 docx/doc/sheet/bitable 导出为 docx/xlsx 再解析 | 云文档类文件同步失败 | +| `docx:document:readonly` | 读取新版文档内容 | 通过 blocks API 解析 docx 文档正文与附件(导出失败时的主路径) | docx 文档解析失败或回退导出也失败 | + +说明: + +- 与「飞书知识库」连接器相比,云盘**不需要** `wiki:wiki:readonly`,其余权限相同。 +- list API 本身还接受 `drive:drive`(读写)或 `space:document:retrieve` 作为替代,但推荐只开只读的 `drive:drive:readonly`,最小授权。 +- 权限开通后必须**创建并发布新版本**,否则接口仍报无权限。 + +--- + +## 3. 关键一步:把文件夹分享给应用 + +飞书的权限模型要求:即使应用开通了上述 API 权限,也只能访问**被显式分享给它的文件**。 + +1. 创建一个飞书(Lark)群或复用某个飞书(Lark)群 +2. 将需要导入的**飞书云盘管理者**拉进群 +> PS: 不拉进群也会导致没有访问权限 +3. 在飞书云盘中打开目标文件夹; +4. 点击「分享」/「··· → 添加协作者」,把文件夹分享给**应用所在的群**(把应用拉进一个群,再将文件夹分享给该群),权限给「可阅读」即可; +5. 子文件夹和其中的文件会随父文件夹一起获得授权,无需逐个分享; +6. 如果之后新增的文件同步报 403,检查该文件是否在这棵已分享的目录树下。 + +这是一次性操作,但不做的话,加载文件夹会直接报「应用无权访问该文件夹」。 + +--- + +## 4. 配置数据源(四步) + +入口:知识库 → 设置 → 数据源 → 新建数据源,选择「飞书云盘」。 + +### 第 1 步:选择类型 + +选择「飞书云盘」(国际版选「Lark Drive」)。 + +### 第 2 步:配置凭证 + +填写 App ID 和 App Secret,点击下一步时系统会自动测试连接(验证 tenant_access_token 能否获取)。此步只验证应用身份,不验证文件夹权限。 + +### 第 3 步:选择范围 + +1. 在「云盘文件夹 Token」输入框填入目标文件夹的 `folder_token`,**或直接粘贴文件夹的完整链接**(飞书 `https://xxx.feishu.cn/drive/folder/` 或 Lark `https://xxx.larksuite.com/drive/folder/`,系统按路径自动提取 token,两种链接都支持); +2. 点击「加载」,列出该文件夹下的完整目录树; +3. 勾选要同步的文件/文件夹,支持逐级展开、全选/折叠分支。 + +注意: + +- **不支持云空间根目录**(根目录不分页且不返回快捷方式),必须选择具体文件夹; +- 加载失败时按提示处理:403 → 回到第 3 节分享文件夹;token 无效 → 重新从文件夹 URL 复制。 + +### 第 4 步:同步策略 + +| 配置项 | 说明 | 默认值 | +|---|---|---| +| 同步计划 | cron 表达式,默认每 6 小时一次;留空则只手动触发 | `0 0 */6 * * *` | +| 同步模式 | 增量(按修改时间游标)/ 全量 | 增量 | +| 冲突策略 | 内容变更时覆盖 / 跳过 | 覆盖 | +| 同步删除 | 源端删除的文档**只计数,不自动删除知识库内容**,需在知识库手动删除 | 开启 | + +保存后数据源开始按策略运行,也可在数据源卡片上手动「触发同步」。 + +--- + +## 5. 支持的文件类型 + +| 云盘类型 | 处理方式 | +|---|---| +| `docx` / `doc`(新旧文档) | blocks API 解析正文与附件,失败时回退导出为 docx 解析 | +| `sheet` / `bitable`(表格/多维表格) | 导出为 xlsx 解析 | +| `file`(普通上传文件,如 PDF/PPT/图片) | 直接下载后按文件类型解析 | +| `shortcut`(快捷方式) | 自动解析为目标文件同步(快捷方式不能指向文件夹) | +| `folder`(文件夹) | 递归遍历 | +| `mindnote` / `slides` / `board` | 不支持,跳过 | + +补充行为: + +- docx 中的**附件**会作为独立知识条目同步(与父文档关联,父文档更新时自动清理已移除的附件); +- 文档内嵌图片会尝试 OCR/多模态解析,未配置对象存储或 VLM 时自动跳过,不影响正文同步。 + +--- + +## 6. docx 解析模式与环境变量 + +飞书新版云文档(docx)有两种解析路径,由环境变量 `FEISHU_DOCX_PARSE_MODE` 控制。该变量作用于 WeKnora **app 服务**(不是数据源配置),对飞书云盘和飞书知识库两个连接器同时生效。 + +### 模式对比 + +| | export(默认) | blocks | +|---|---|---| +| 环境变量值 | 留空 / `export` | `blocks` | +| 解析路径 | 异步导出 API -> .docx 二进制 -> docreader 解析 | blocks API -> Markdown | +| 图片与文档关联 | ✅ 图片 inline 进父文档,`parent_chunk_id` 关联 | ❌ 图片作为独立知识条目,与文档割裂 | +| 检索 / Wiki / 智能体能否关联图片 | 是 | 否 | +| 同步速度 | 慢(异步导出 + docx 解析) | 快 | +| docx 内附件(file block) | 丢失(.docx 导出不含) | 保留,作为独立条目 | +| 所需权限 | `drive:drive:readonly` + `drive:export:readonly` | `drive:drive:readonly` + `drive:export:readonly` + `docx:document:readonly` | + +### 为什么图片关联有差异 + +- **export 模式**:导出 .docx 后由 docreader 解析,图片 inline 进父文档(与普通 docx 上传一致),通过 `parent_chunk_id` 建立同知识条目的父子关联,三个场景都能在一次检索中把图片内容与文档一起返回。 +- **blocks 模式**:走 blocks API,图片 block 渲染成空 `![图片]()` 占位符,图片单独下载成独立知识条目,与父文档只有元数据级弱关联。WeKnora 的检索、Wiki 构建、智能体问答链路都不会把图片内容关联回文档,图片和正文是割裂的。 + +### 配置方法 + +在 WeKnora 服务的 `.env` 或 `docker-compose.yml` 的 app 服务环境变量中设置: + +```env +FEISHU_DOCX_PARSE_MODE=blocks +``` + +不设置或设为 `export` 即用默认模式。修改后需重启 app 服务生效。 + +### export 模式的代价 + +- **同步变慢**:每个 docx 都要走异步导出(创建任务 + 轮询 + 下载)+ docreader 解析,比 blocks API 慢。 +- **附件丢失**:docx 内 file block 附件不随 .docx 导出下载,如需附件用 blocks 模式或单独同步。 +- **图片内容依赖多模态**:图片 inline 后 OCR/caption 由多模态服务异步生成,未配置对象存储或 VLM 时图片只存储不生成内容(前端展示正常,但检索层面仍弱)。 + +### 选择建议 + +- 需要图片内容在检索 / Wiki / 智能体中与文档关联:用默认 **export**。 +- 只需文档正文、要保留附件、追求同步速度:用 **blocks**。 + +--- + +## 7. 同步行为说明 + +- **增量同步**:以文件修改时间为游标,只拉取上次同步后变更的内容;中断后从断点续传。 +- **部分失败不中断**:某个子文件夹无权限或某个文件下载失败时,该条目记为失败,其余内容继续同步,失败明细可在「同步日志」中查看。 +- **更新语义**:内容变更的文件会先删除旧知识条目再重建,解析期间该文档短暂不可用,属正常现象。 +- **安全约束**:为避免误删,源端删除的文件不会自动从知识库移除(见第 4 步「同步删除」)。 + +--- + +## 8. 常见问题 + +| 现象 | 原因与处理 | +|---|---| +| 「请输入具体文件夹的 folder_token,不支持云空间根目录」 | 输入为空或粘贴的是根目录链接,换具体文件夹链接 | +| 「应用无权访问该文件夹。请…分享给应用所在的群」 | 未完成第 3 节的分享,或分享的对象不是应用所在的群 | +| 「应用凭证无效或缺少云盘权限」 | App ID/Secret 错误,或第 2 节权限未开通/未发布版本 | +| 「folder_token 不存在或已删除」 | token 复制有误,从文件夹「分享 → 复制链接」重新获取 | +| 同步日志中部分条目失败 | 点开日志看失败阶段:`list_children` 多为子文件夹未授权,`fetch` 多为单文件权限或类型不支持 | +| 知识列表中来源显示 | 云盘同步的文档来源标记为「飞书云盘」,与知识库同步的「飞书」区分 | diff --git "a/docs/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221\346\226\207\346\241\243.md" index 875880e0f1..cfb6cd444b 100644 --- "a/docs/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221\346\226\207\346\241\243.md" +++ "b/docs/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -695,7 +695,7 @@ GET /open-apis/wiki/v2/spaces (分页, page_size=50) | `obj_type` | 支持 | 获取方式 | 导出格式 | |------------|------|---------|---------| -| `docx` | 是 | 导出任务 (Export API) | `.docx` | +| `docx` | 是 | 导出 .docx(默认)/ blocks API 转 Markdown(blocks 模式) | `.docx` / Markdown | | `doc` | 是 | 导出任务 (Export API) | `.docx` | | `sheet` | 是 | 导出任务 (Export API) | `.xlsx` | | `bitable` | 是 | 导出任务 (Export API) | `.xlsx` | @@ -705,29 +705,63 @@ GET /open-apis/wiki/v2/spaces (分页, page_size=50) #### 内容获取流程 -**文档 (docx/doc/sheet/bitable):** - -``` -1. CreateExportTask → 创建导出任务 -2. 轮询 GetExportTaskStatus(间隔 2 秒,最长约 60 秒) -3. DownloadExportFile → 用 file_token 下载导出文件 -4. 清理文件名 (sanitizeFileName) + 补全扩展名 -``` +**docx(新版云文档):** 由环境变量 `FEISHU_DOCX_PARSE_MODE` 控制解析路径(作用于 app 服务,飞书知识库和飞书云盘连接器同时生效)。 + +- **export 模式(默认,留空或 `export`)**:走异步导出 API + ``` + 1. CreateExportTask -> 创建导出任务 + 2. 轮询 GetExportTaskStatus(间隔 2 秒,最长约 60 秒) + 3. DownloadExportFile -> 下载 .docx 二进制 + 4. .docx 交 docreader 解析(图片 inline 进父文档,与普通 docx 上传一致) + ``` +- **blocks 模式(`FEISHU_DOCX_PARSE_MODE=blocks`)**:走 blocks API + ``` + 1. GET /open-apis/docx/v1/documents/{obj_token}/blocks (分页 500) + 2. blocksToMarkdown -> 转换为 Markdown 正文 + ├─ 文本/标题/列表/表格/代码块 -> Markdown + ├─ image block -> ![图片]() 空占位符(图片单独下载为独立知识条目) + └─ file block -> 附件,作为独立知识条目 + 3. blocks API 失败或渲染空 -> 回退 export + ``` + +**doc / sheet / bitable:** 走异步导出 API(同 export 模式流程),导出为 .docx / .xlsx 后交 docreader 解析。 **文件 (file):** ``` -DownloadDriveFile → GET /drive/v1/files/{token}/download +DownloadDriveFile -> GET /drive/v1/files/{token}/download ``` +##### blocks vs export 优缺点 + +| | export(默认) | blocks | +|---|---|---| +| 解析路径 | 导出 .docx -> docreader | blocks API -> Markdown | +| 图片与文档关联 | ✅ 图片 inline 进父文档,`parent_chunk_id` 关联 | ❌ 图片作为独立知识条目,与文档割裂 | +| 检索 / Wiki / 智能体关联图片 | 是 | 否 | +| 同步速度 | 慢(异步导出 + docx 解析) | 快 | +| docx 内附件(file block) | 丢失(.docx 导出不含) | 保留 | +| 所需权限 | 不需要(但建议保留以便切换) | 需 `docx:document:readonly` | + +- **export**:图片 inline 进父文档(同普通 docx 上传),通过 `parent_chunk_id` 建立同知识条目父子关联,三个场景都能关联图片内容;代价是同步慢、docx 内附件丢失、图片 OCR/caption 依赖多模态配置。 +- **blocks**:快、保留附件,但图片 block 渲染成空占位符、图片独立入库,与父文档只有元数据级弱关联,检索 / Wiki / 智能体都无法把图片内容关联回文档。 + +> 环境变量配置见 `.env.example` 的 E9 段。 + #### 源码文件 | 文件 | 职责 | |------|------| -| `internal/datasource/connector/feishu/types.go` | 飞书 API 类型定义、配置结构、常量 | -| `internal/datasource/connector/feishu/client.go` | API 客户端:Token 管理、Wiki/Drive API 调用、导出/下载 | -| `internal/datasource/connector/feishu/connector.go` | Connector 接口实现:Validate、ListResources、FetchAll、FetchIncremental | -| `internal/datasource/connector/feishu/connector_test.go` | 单元测试:使用 HTTP Mock 模拟飞书开放平台 | +| `internal/datasource/connector/feishu/core/types.go` | 飞书 API 类型定义、配置结构(Config)、Region 常量 | +| `internal/datasource/connector/feishu/core/client.go` | API 客户端:Token 管理、Wiki/Drive API 调用、导出/下载 | +| `internal/datasource/connector/feishu/core/blocks.go` | docx blocks API 类型(DocxBlock 等)、listDocumentBlocks | +| `internal/datasource/connector/feishu/core/markdown.go` | blocksToMarkdown:block 数组转 Markdown | +| `internal/datasource/connector/feishu/core/shared.go` | 共享逻辑:FetchDocxWithBlocks、ParseFeishuConfig、exportDocxFallback | +| `internal/datasource/connector/feishu/core/engine.go` | 通用同步引擎:NodeOps 接口、FetchStreamEngine / FetchAllEngine | +| `internal/datasource/connector/feishu/core/region.go` | Region(飞书 / Lark 云区分)、URL 构造 | +| `internal/datasource/connector/feishu/wiki/connector.go` | 飞书知识库 Connector 实现(wikiOps) | +| `internal/datasource/connector/feishu/drive/connector.go` | 飞书云盘 Connector 实现(driveOps) | +| `internal/datasource/connector/feishu/{core,wiki,drive}/*_test.go` | 单元测试:使用 HTTP Mock 模拟飞书开放平台 | ## 定时调度 diff --git a/examples/mcp-demo/.gitignore b/examples/mcp-demo/.gitignore new file mode 100644 index 0000000000..21d0b898ff --- /dev/null +++ b/examples/mcp-demo/.gitignore @@ -0,0 +1 @@ +.venv/ diff --git a/examples/mcp-demo/README.md b/examples/mcp-demo/README.md new file mode 100644 index 0000000000..bea4fba75f --- /dev/null +++ b/examples/mcp-demo/README.md @@ -0,0 +1,85 @@ +# WeKnora 本地 MCP Demo + +最小外部 MCP 服务,用来测试 WeKnora **作为 MCP 客户端**接入第三方工具。 + +提供 6 个演示工具: + +| 工具 | 作用 | +| --- | --- | +| `echo` | 连通性自检 | +| `add` | 两数相加 | +| `server_time` | 返回服务器 UTC 时间 | +| `lookup_policy` | 查询演示政策(保修、报销、POC 等,与 `website-docs/sample-data/` 一致) | +| `list_team_contacts` | 列出演示项目团队成员 | +| `send_demo_alert` | 模拟外发通知(适合测工具人工审批) | + +## 1. 启动 + +```bash +cd examples/mcp-demo +chmod +x start.sh +./start.sh +``` + +`start.sh` 会自动创建 `.venv` 并安装依赖。默认监听 `http://127.0.0.1:8010/mcp`,鉴权令牌 `weknora-demo-token`。 + +自定义: + +```bash +export MCP_SERVER_AUTH_TOKEN=my-secret +export MCP_PORT=9000 +./start.sh +``` + +## 2. 自检 + +另开终端: + +```bash +cd examples/mcp-demo +source .venv/bin/activate +python test_tools.py +``` + +应列出 6 个工具。 + +## 3. 接入 WeKnora + +1. 打开 **设置 → MCP 服务 → 新建** +2. 填写: + +| 字段 | 值 | +| --- | --- | +| 名称 | `本地 MCP Demo` | +| 传输 | **HTTP Streamable** | +| URL | `http://127.0.0.1:8010/mcp` | +| 认证 | **Bearer** | +| 令牌 | `weknora-demo-token`(与 `MCP_SERVER_AUTH_TOKEN` 一致) | + +3. 保存后点 **测试连接**,应发现 6 个工具。 +4. 在 **智能体** 配置里勾选该 MCP 服务(或选全部工具)。 +5. (可选)对 `send_demo_alert` 开启**人工审批**,对话时 Agent 调用前会弹出确认。 + +## 4. 建议试的问题 + +在 Agent 对话里问: + +- 「调用 MCP 工具查一下智能家居中控保修多久」→ 应触发 `lookup_policy` +- 「研发部 POC 负责人是谁」→ `lookup_policy` 或 `list_team_contacts` +- 「现在 MCP Demo 服务器几点」→ `server_time` + +若同时导入了 `website-docs/sample-data/` 里的文档,可以对比 **知识库检索答案** 与 **MCP 工具返回** 是否一致。 + +## 5. 注意事项 + +- WeKnora UI **不支持 stdio** 传输;必须用 **HTTP Streamable** 或 **SSE**。 +- Demo 只绑定 `127.0.0.1`,不要暴露到公网。 +- `send_demo_alert` 不会真正发送消息,仅返回模拟结果。 + +## 6. SSE 模式(可选) + +```bash +MCP_TRANSPORT=sse MCP_PORT=8011 ./start.sh +``` + +WeKnora 里传输选 **SSE**,URL 填 `http://127.0.0.1:8011/sse`。 diff --git a/examples/mcp-demo/requirements.txt b/examples/mcp-demo/requirements.txt new file mode 100644 index 0000000000..87696d841f --- /dev/null +++ b/examples/mcp-demo/requirements.txt @@ -0,0 +1,4 @@ +mcp>=2,<3 +starlette>=0.27.0 +uvicorn>=0.24.0 +httpx>=0.27.0 diff --git a/examples/mcp-demo/server.py b/examples/mcp-demo/server.py new file mode 100644 index 0000000000..ede7fc198b --- /dev/null +++ b/examples/mcp-demo/server.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +WeKnora 本地 MCP Demo Server + +最小可运行的外部 MCP 服务,用于在 WeKnora「设置 → MCP 服务」里测试客户端接入。 +默认以 Streamable HTTP 监听 http://127.0.0.1:8010/mcp + +启动: + export MCP_SERVER_AUTH_TOKEN=weknora-demo-token + python server.py + +WeKnora 配置: + 传输:HTTP Streamable + URL:http://127.0.0.1:8010/mcp + 认证:Bearer,令牌与 MCP_SERVER_AUTH_TOKEN 一致 +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import secrets +import sys +from datetime import datetime, timezone +from typing import Any + +from mcp.server import MCPServer + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") +logger = logging.getLogger("mcp-demo") + +mcp = MCPServer("weknora-mcp-demo", version="0.1.0") + +# 与 website-docs/sample-data/ 配套的演示语料,方便 Agent 调用后对照知识库答案。 +DEMO_POLICIES: dict[str, str] = { + "warranty": "智能家居中控 Pro 整机保修 24 个月,电池类配件 12 个月;人为拆解、进水不在保修范围。", + "offline_voice": "若语音走云端识别,断外网后仅支持 App 与本地触摸屏;配置本地语音包后可继续使用基础指令。", + "device_limit": "个人版账号最多绑定 3 台中控;企业版按合同授权,默认 50 台。", + "travel_hotel_tier1": "一线城市(北上广深)出差住宿报销上限 600 元/晚(含税)。", + "travel_meal": "出差期间餐饮费不单独报销;一线城市差旅补贴 150 元/天。", + "poc_owner": "售后知识库 POC 技术负责人是研发部张明,产品对接人是李薇,测试负责人是赵磊。", + "poc_deadline": "售后知识库 POC 目标 2024-03-01 前完成内网演示。", + "matter_cert": "固件 3.5 计划在 2024 年 3 月底前发布灰度,完成 Matter 1.2 认证。", +} + +DEMO_CONTACTS: list[dict[str, str]] = [ + {"name": "陈浩", "role": "产品总监", "department": "产品部"}, + {"name": "张明", "role": "知识库与 AI 模块负责人", "department": "研发部"}, + {"name": "李薇", "role": "产品运营", "department": "产品部"}, + {"name": "王雪", "role": "交互设计负责人", "department": "设计部"}, + {"name": "赵磊", "role": "测试经理", "department": "测试部"}, +] + + +def network_transport_auth_token() -> str: + return os.getenv("MCP_SERVER_AUTH_TOKEN", "").strip() + + +def require_network_transport_auth(transport: str) -> str: + token = network_transport_auth_token() + if transport in ("sse", "http") and not token: + logger.error( + "MCP_SERVER_AUTH_TOKEN is required for %s transport. " + "Example: export MCP_SERVER_AUTH_TOKEN=weknora-demo-token", + transport, + ) + sys.exit(1) + return token + + +class MCPAuthMiddleware: + """SSE / HTTP 传输的 Bearer 鉴权中间件。""" + + def __init__(self, app, token: str): + self.app = app + self.token = token + + async def __call__(self, scope, receive, send): + if scope.get("type") != "http": + await self.app(scope, receive, send) + return + + headers = { + k.decode("latin-1").lower(): v.decode("latin-1") + for k, v in scope.get("headers", []) + } + provided = "" + auth = headers.get("authorization", "") + if auth.lower().startswith("bearer "): + provided = auth[7:].strip() + elif "x-mcp-auth-token" in headers: + provided = headers["x-mcp-auth-token"] + + if not provided or not secrets.compare_digest(provided, self.token): + body = b'{"error":"unauthorized"}' + await send( + { + "type": "http.response.start", + "status": 401, + "headers": [[b"content-type", b"application/json"]], + } + ) + await send({"type": "http.response.body", "body": body}) + return + + await self.app(scope, receive, send) + + +@mcp.tool() +def echo(message: str) -> dict[str, Any]: + """回显一条消息,用于验证 MCP 连通性。""" + return {"echo": message} + + +@mcp.tool() +def add(a: float, b: float) -> dict[str, Any]: + """计算两个数字之和。""" + return {"a": a, "b": b, "sum": a + b} + + +@mcp.tool() +def server_time() -> dict[str, str]: + """返回 MCP Demo 服务器当前 UTC 时间。""" + now = datetime.now(timezone.utc) + return { + "iso": now.isoformat(), + "unix": str(int(now.timestamp())), + } + + +@mcp.tool() +def lookup_policy(topic: str) -> dict[str, Any]: + """查询演示政策/项目信息。topic 可用 warranty/offline_voice/device_limit/travel_hotel_tier1/travel_meal/poc_owner/poc_deadline/matter_cert,或中文关键词如「保修」「报销」「POC」。""" + key = topic.strip().lower().replace(" ", "_") + aliases = { + "保修": "warranty", + "质保": "warranty", + "离线": "offline_voice", + "语音": "offline_voice", + "设备数": "device_limit", + "住宿": "travel_hotel_tier1", + "报销": "travel_hotel_tier1", + "餐饮": "travel_meal", + "补贴": "travel_meal", + "负责人": "poc_owner", + "张明": "poc_owner", + "poc": "poc_owner", + "验收": "poc_deadline", + "matter": "matter_cert", + "认证": "matter_cert", + } + for alias, mapped in aliases.items(): + if alias in topic: + key = mapped + break + + if key in DEMO_POLICIES: + return {"topic": key, "answer": DEMO_POLICIES[key], "source": "mcp-demo/static"} + + matches = { + k: v + for k, v in DEMO_POLICIES.items() + if key in k or any(ch in k for ch in key if len(key) >= 2) + } + if len(matches) == 1: + only_key = next(iter(matches)) + return {"topic": only_key, "answer": matches[only_key], "source": "mcp-demo/static"} + + return { + "topic": topic, + "available_topics": sorted(DEMO_POLICIES.keys()), + "hint": "传入 topic 为上述键名,或中文关键词如「保修」「报销」「POC」。", + } + + +@mcp.tool() +def list_team_contacts(department: str = "") -> dict[str, Any]: + """列出演示项目团队成员;可按部门名过滤(产品部 / 研发部 / 设计部 / 测试部)。""" + rows = DEMO_CONTACTS + if department.strip(): + needle = department.strip() + rows = [c for c in rows if needle in c["department"]] + return {"count": len(rows), "contacts": rows} + + +@mcp.tool() +def send_demo_alert(channel: str, message: str) -> dict[str, Any]: + """模拟向外部渠道发送通知(演示用,不会真正外发)。 + + 适合在 WeKnora 里测试 MCP 工具人工审批:建议把此工具标记为需要审批。 + """ + return { + "ok": True, + "simulated": True, + "channel": channel, + "message": message, + "sent_at": datetime.now(timezone.utc).isoformat(), + } + + +async def run_http(host: str, port: int) -> None: + auth_token = require_network_transport_auth("http") + try: + import uvicorn + except ImportError as e: + raise ImportError("HTTP transport requires: pip install starlette uvicorn") from e + + starlette_app = MCPAuthMiddleware( + mcp.streamable_http_app(host=host, stateless_http=True), + auth_token, + ) + logger.info("Streamable HTTP MCP demo listening on http://%s:%d/mcp", host, port) + config = uvicorn.Config(starlette_app, host=host, port=port, log_level="info") + server = uvicorn.Server(config) + await server.serve() + + +async def run_sse(host: str, port: int) -> None: + auth_token = require_network_transport_auth("sse") + try: + import uvicorn + except ImportError as e: + raise ImportError("SSE transport requires: pip install starlette uvicorn") from e + + starlette_app = MCPAuthMiddleware( + mcp.sse_app(host=host, message_path="/sse/messages/"), + auth_token, + ) + logger.info("SSE MCP demo listening on http://%s:%d/sse", host, port) + config = uvicorn.Config(starlette_app, host=host, port=port, log_level="info") + server = uvicorn.Server(config) + await server.serve() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="WeKnora local MCP demo server") + parser.add_argument( + "--transport", + choices=["http", "sse"], + default=os.getenv("MCP_TRANSPORT", "http"), + help="Network transport (default: http / Streamable HTTP)", + ) + parser.add_argument("--host", default=os.getenv("MCP_HOST", "127.0.0.1")) + parser.add_argument("--port", type=int, default=int(os.getenv("MCP_PORT", "8010"))) + return parser.parse_args() + + +async def main() -> None: + args = parse_args() + if args.transport == "http": + await run_http(args.host, args.port) + else: + await run_sse(args.host, args.port) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("stopped") diff --git a/examples/mcp-demo/start.sh b/examples/mcp-demo/start.sh new file mode 100755 index 0000000000..c819c874d3 --- /dev/null +++ b/examples/mcp-demo/start.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +export MCP_SERVER_AUTH_TOKEN="${MCP_SERVER_AUTH_TOKEN:-weknora-demo-token}" +export MCP_HOST="${MCP_HOST:-127.0.0.1}" +export MCP_PORT="${MCP_PORT:-8010}" +export MCP_TRANSPORT="${MCP_TRANSPORT:-http}" + +VENV_DIR=".venv" +if [[ ! -d "$VENV_DIR" ]]; then + echo "Creating virtualenv in $VENV_DIR ..." + python3 -m venv "$VENV_DIR" +fi +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" + +if ! python -c "import mcp" 2>/dev/null; then + echo "Installing dependencies..." + pip install -r requirements.txt +fi + +echo "MCP Demo" +echo " transport : ${MCP_TRANSPORT}" +echo " endpoint : http://${MCP_HOST}:${MCP_PORT}/$([ "$MCP_TRANSPORT" = http ] && echo mcp || echo sse)" +echo " auth token: ${MCP_SERVER_AUTH_TOKEN}" +echo +echo "WeKnora UI → 设置 → MCP 服务 → 新建" +echo " 传输: HTTP Streamable" +echo " URL : http://${MCP_HOST}:${MCP_PORT}/mcp" +echo " 认证: Bearer / ${MCP_SERVER_AUTH_TOKEN}" +echo + +exec python server.py --transport "$MCP_TRANSPORT" --host "$MCP_HOST" --port "$MCP_PORT" diff --git a/examples/mcp-demo/test_tools.py b/examples/mcp-demo/test_tools.py new file mode 100644 index 0000000000..87f67aa292 --- /dev/null +++ b/examples/mcp-demo/test_tools.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""列出 MCP Demo 暴露的工具(需先启动 server.py)。""" + +from __future__ import annotations + +import asyncio +import os +import sys + +import httpx +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + + +async def main() -> int: + base = os.getenv("MCP_DEMO_URL", "http://127.0.0.1:8010/mcp") + token = os.getenv("MCP_SERVER_AUTH_TOKEN", "weknora-demo-token") + + async with httpx.AsyncClient( + headers={"Authorization": f"Bearer {token}"}, + timeout=30.0, + ) as http_client: + async with streamable_http_client(base, http_client=http_client) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + print(f"connected: {base}") + print(f"tools ({len(tools.tools)}):") + for tool in tools.tools: + print(f" - {tool.name}: {tool.description}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(asyncio.run(main())) + except Exception as exc: # noqa: BLE001 + print(f"failed: {exc}", file=sys.stderr) + print("start the server first: ./start.sh", file=sys.stderr) + raise SystemExit(1) from exc diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index a519666fbc..7eecec5199 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -264,6 +264,14 @@ export function listKnowledgeFiles( source?: string; start_time?: string; end_time?: string; + /** + * Folder to browse. An empty string means the knowledge base root, so the + * parameter is only sent when it is defined — leaving it out lists every + * folder (the flat view). + */ + folder_path?: string; + /** Include documents stored in sub-folders of folder_path. */ + folder_recursive?: boolean; }, ) { const query = new URLSearchParams(); @@ -276,10 +284,57 @@ export function listKnowledgeFiles( if (params.source) query.append('source', params.source); if (params.start_time) query.append('start_time', params.start_time); if (params.end_time) query.append('end_time', params.end_time); + if (params.folder_path !== undefined) { + query.append('folder_path', params.folder_path); + if (params.folder_recursive) query.append('folder_recursive', 'true'); + } const qs = query.toString(); return get(`/api/v1/knowledge-bases/${kbId}/knowledge?${qs}`); } +/** One node of the knowledge base folder tree. */ +export interface KnowledgeFolderNode { + /** Canonical folder path, e.g. "docs/spec". */ + path: string; + /** Last segment of the path, used as the row label. */ + name: string; + /** Documents stored directly in this folder. */ + document_count: number; + /** Documents in this folder plus every descendant folder. */ + total_count: number; + children?: KnowledgeFolderNode[]; +} + +export interface KnowledgeFolderTree { + /** Documents that are not part of any uploaded folder. */ + root_document_count: number; + /** Documents in the whole knowledge base. */ + total_document_count: number; + folders: KnowledgeFolderNode[]; +} + +export function listKnowledgeFolders(kbId: string) { + return get(`/api/v1/knowledge-bases/${kbId}/knowledge/folders`); +} + +/** + * Re-file documents under `folderPath` ('' = knowledge base top level). Folders + * are derived from the stored paths, so a path that does not exist yet is + * created by this call. Only the grouping changes; documents are not re-parsed. + */ +export function moveKnowledgeToFolder(kbId: string, ids: string[], folderPath: string) { + return post('/api/v1/knowledge/folder', { + kb_id: kbId, + knowledge_ids: ids, + folder_path: folderPath, + }); +} + +/** Rename or move a folder together with everything below it. */ +export function renameKnowledgeFolder(kbId: string, from: string, to: string) { + return put(`/api/v1/knowledge-bases/${kbId}/knowledge/folders`, { from, to }); +} + export function getKnowledgeDetails(id: string, options?: { agent_id?: string; agent_source_tenant_id?: string }) { const query = new URLSearchParams(); if (options?.agent_id) query.set('agent_id', options.agent_id); diff --git a/frontend/src/api/system/index.ts b/frontend/src/api/system/index.ts index 771114f15a..ac09014b2b 100644 --- a/frontend/src/api/system/index.ts +++ b/frontend/src/api/system/index.ts @@ -180,9 +180,9 @@ export interface StorageEngineConfig { path_prefix: string } s3: { - endpoint: string + endpoint: string // optional for standard AWS S3 region: string - access_key: string + access_key: string // both keys empty => AWS default credential chain secret_key: string bucket_name: string path_prefix: string diff --git a/frontend/src/components/UploadConfirmHost.vue b/frontend/src/components/UploadConfirmHost.vue index 74f4690e87..63814f2d7e 100644 --- a/frontend/src/components/UploadConfirmHost.vue +++ b/frontend/src/components/UploadConfirmHost.vue @@ -25,6 +25,8 @@ const handleCancel = () => { :reparse-preview="uploadConfirmStore.reparse" :accept-file-types="uploadConfirmStore.acceptFileTypes" :supported-file-types="uploadConfirmStore.supportedFileTypes" + :target-folder="uploadConfirmStore.targetFolder" + :folder-options="uploadConfirmStore.folderOptions" @confirm="handleConfirm" @cancel="handleCancel" /> diff --git a/frontend/src/components/doc-content.vue b/frontend/src/components/doc-content.vue index 0b2652dc2b..0e04a2db05 100644 --- a/frontend/src/components/doc-content.vue +++ b/frontend/src/components/doc-content.vue @@ -967,6 +967,10 @@ const channelLabelMap: Record = { wechat: 'knowledgeBase.channelWechat', wecom: 'knowledgeBase.channelWecom', feishu: 'knowledgeBase.channelFeishu', + // Drive (云盘) connectors get their own channel so Drive docs show + // "飞书云盘" / "Lark 云盘", distinct from the wiki connector's "飞书". + feishu_drive: 'knowledgeBase.channelFeishuDrive', + lark_drive: 'knowledgeBase.channelLarkDrive', dingtalk: 'knowledgeBase.channelDingtalk', slack: 'knowledgeBase.channelSlack', im: 'knowledgeBase.channelIm', diff --git a/frontend/src/hooks/useKnowledgeBase.ts b/frontend/src/hooks/useKnowledgeBase.ts index d863ca0c9d..e791309415 100644 --- a/frontend/src/hooks/useKnowledgeBase.ts +++ b/frontend/src/hooks/useKnowledgeBase.ts @@ -51,6 +51,8 @@ export default function (knowledgeBaseId?: string) { source?: string; start_time?: string; end_time?: string; + folder_path?: string; + folder_recursive?: boolean; } = { page: 1, page_size: 35 }, kbId?: string, ): Promise => { @@ -76,6 +78,7 @@ export default function (knowledgeBaseId?: string) { original_file_name: item.file_name, display_name: displayName, file_name: displayName, + folder_path: item.folder_path || '', updated_at: formatStringDate(new Date(item.updated_at)), isMore: false, file_type: fileTypeSource ? String(fileTypeSource).toLocaleUpperCase() : '', diff --git a/frontend/src/i18n/embed.ts b/frontend/src/i18n/embed.ts index dd9c1b881f..e01c0c10ee 100644 --- a/frontend/src/i18n/embed.ts +++ b/frontend/src/i18n/embed.ts @@ -486,6 +486,8 @@ const messages = { "knowledgeEditor": { "wikiBrowser": { "viewInGraph": "在图谱中查看", + "editingBadge": "编辑中", + "pageActions": "页面操作", "version": "v{ver}", "filterSummary": "摘要", "filterEntity": "实体", @@ -980,6 +982,8 @@ const messages = { "knowledgeEditor": { "wikiBrowser": { "viewInGraph": "View in Graph", + "editingBadge": "Editing", + "pageActions": "Page actions", "version": "v{ver}", "filterSummary": "Summaries", "filterEntity": "Entities", diff --git a/frontend/src/i18n/localeKeyAudit.test.ts b/frontend/src/i18n/localeKeyAudit.test.ts index 3bc28cfc69..d96b17c25d 100644 --- a/frontend/src/i18n/localeKeyAudit.test.ts +++ b/frontend/src/i18n/localeKeyAudit.test.ts @@ -68,6 +68,51 @@ const PARSER_ENGINE_NAMES = [ 'opendataloader', ] as const +/** Worker pools / queues use dynamic te() paths in RuntimeQueues.vue; keep in sync with internal/types/task.go. */ +const RUNTIME_WORKER_POOL_NAMES = [ + 'core', + 'postprocess', + 'enrichment', + 'maintenance', + 'shared', + 'wiki', +] as const + +const RUNTIME_QUEUE_NAMES = [ + 'default', + 'chat_attachment', + 'postprocess', + 'summary', + 'sync', + 'low', + 'multimodal', + 'graph', + 'question', + 'wiki', +] as const + +const RUNTIME_TASK_TYPE_KEYS = [ + 'documentProcess', + 'manualProcess', + 'temporaryDocumentProcess', + 'postProcess', + 'summary', + 'tableSummary', + 'question', + 'multimodal', + 'graph', + 'sync', + 'faqImport', + 'batchReparse', + 'batchDelete', + 'move', + 'indexDelete', + 'kbClone', + 'kbDelete', + 'wikiIngest', + 'wikiFinalize', +] as const + test('registered audit action labels exist as flat keys in every locale', () => { const failures: string[] = [] @@ -178,6 +223,42 @@ test('prune rebuild restores registered audit keys from baked-in English default ) }) +test('runtime queue worker pool and queue labels exist in every locale', () => { + const failures: string[] = [] + + for (const pool of RUNTIME_WORKER_POOL_NAMES) { + for (const [localeName, keys] of Object.entries(localeKeysByName) as Array<[LocaleName, Set]>) { + if (!keys.has(`system.globalSettings.runtime.pools.${pool}`)) { + failures.push(`${localeName}: missing system.globalSettings.runtime.pools.${pool}`) + } + if (!keys.has(`system.globalSettings.runtime.poolDescriptions.${pool}`)) { + failures.push(`${localeName}: missing system.globalSettings.runtime.poolDescriptions.${pool}`) + } + } + } + + for (const queue of RUNTIME_QUEUE_NAMES) { + for (const [localeName, keys] of Object.entries(localeKeysByName) as Array<[LocaleName, Set]>) { + if (!keys.has(`system.globalSettings.runtime.queueNames.${queue}`)) { + failures.push(`${localeName}: missing system.globalSettings.runtime.queueNames.${queue}`) + } + if (!keys.has(`system.globalSettings.runtime.queueDescriptions.${queue}`)) { + failures.push(`${localeName}: missing system.globalSettings.runtime.queueDescriptions.${queue}`) + } + } + } + + for (const taskType of RUNTIME_TASK_TYPE_KEYS) { + for (const [localeName, keys] of Object.entries(localeKeysByName) as Array<[LocaleName, Set]>) { + if (!keys.has(`system.globalSettings.runtime.tasks.taskTypes.${taskType}`)) { + failures.push(`${localeName}: missing system.globalSettings.runtime.tasks.taskTypes.${taskType}`) + } + } + } + + assert.deepEqual(failures, [], failures.slice(0, 20).join('\n')) +}) + test('parser engine display keys exist in every locale', () => { const failures: string[] = [] diff --git a/frontend/src/i18n/localeKeyAudit.ts b/frontend/src/i18n/localeKeyAudit.ts index db6b86f1d3..c9b2f46adc 100644 --- a/frontend/src/i18n/localeKeyAudit.ts +++ b/frontend/src/i18n/localeKeyAudit.ts @@ -91,6 +91,11 @@ const EXTRA_PREFIXES = [ 'integrations.tabs.', 'knowledgeStages.stage.', 'knowledgeStages.status.', + 'system.globalSettings.runtime.pools.', + 'system.globalSettings.runtime.poolDescriptions.', + 'system.globalSettings.runtime.queueNames.', + 'system.globalSettings.runtime.queueDescriptions.', + 'system.globalSettings.runtime.tasks.taskTypes.', 'organization.role.', 'inviteRegister.', 'modelSettings.builtinModels.', diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 05f5796e8e..d28822f68e 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -369,6 +369,38 @@ export default { tagEditSelectedSection: 'Selected', tagEditAvailableSection: 'Available', tagEditNoSelected: 'None selected', + folderTree: { + title: 'Folders', + rootRow: 'Root', + rootRowTip: 'Knowledge base root; documents not in a subfolder live here', + folderCardCount: '{count} documents', + searchingSubtree: '(including sub-folders)', + emptyFolder: 'This folder has no documents yet', + emptySearch: 'No matching documents', + collapse: 'Collapse folders', + expand: 'Expand folders', + collapseFolder: 'Collapse this folder', + expandFolder: 'Expand this folder', + rename: 'Rename', + renamePlaceholder: 'Folder name', + renameSuccess: 'Folder renamed', + renameFailed: 'Could not rename the folder', + renameInvalid: 'A folder cannot be moved inside itself', + }, + moveToFolder: { + action: 'Move to folder', + newFolder: 'New sub-folder', + newFolderPlaceholder: 'New folder name', + newFolderCreate: 'Create', + newFolderAddRoot: 'New sub-folder under root', + newFolderAddUnder: 'New sub-folder under “{folder}”', + newFolderHint: 'Press Enter to create and move', + newFolderHintRoot: 'Will be created under root', + newFolderHintUnder: 'Will be created under “{folder}”', + success: 'Moved {count} documents', + failed: 'Could not move the documents', + duplicate: 'That folder already exists', + }, tagFilterTitle: 'Filter by tag', tagFilterPlaceholder: 'Tags', tagFilterMulti: '{count} tags', @@ -427,6 +459,8 @@ export default { channelWechat: 'WeChat', channelWecom: 'WeCom', channelFeishu: 'Feishu', + channelFeishuDrive: 'Feishu Drive', + channelLarkDrive: 'Lark Drive', channelDingtalk: 'DingTalk', channelSlack: 'Slack', channelIm: 'IM Channel', @@ -657,6 +691,11 @@ export default { description: 'Useful for web-print, scanned, or image-heavy PDFs. Every page will be rendered as an image and processed via OCR/VLM. May increase processing time and model costs.' }, continueAdd: 'Add more', + destinationLabel: 'Upload location', + destinationChange: 'Change upload location', + destinationToRoot: 'Use root', + folderUploadTitle: 'Folder “{name}”', + folderUploadHint: '{count} files; the local folder structure will be kept', filesAdded: 'Added {count} file(s)', filesAllDuplicate: 'Selected files are already in the list', titleManual: 'Confirm online publish', @@ -1207,6 +1246,8 @@ export default { s3Desc: 'AWS S3 and S3-compatible object storage services, suitable for public cloud deployment.', s3AccessKeyPlaceholder: 'AWS Access Key', s3SecretKeyPlaceholder: 'AWS Secret Key', + s3DefaultCredentialsHint: 'Leave both keys empty to use the AWS default credential chain (IAM role, IRSA / web identity, environment, or shared config).', + s3EndpointPlaceholder: 'Optional; leave empty to use the AWS regional endpoint', ks3Title: 'Kingsoft Cloud KS3', ks3Desc: 'Kingsoft Cloud Object Storage Service (KS3), suitable for public cloud deployment.', ks3AccessKeyPlaceholder: 'Kingsoft Cloud Access Key', @@ -2155,12 +2196,30 @@ export default { revisionDiff: 'Diff vs current', revisionRaw: 'Raw content', revisionDiffCaption: 'v{from} → v{to} (red = that version, green = current)', + revisionDiffIncremental: 'Version change', + revisionDiffCumulative: 'Vs current', + revisionDiffBasisLabel: 'Compare mode', + revisionViewModeLabel: 'View mode', + revisionLatestChangeHint: 'Changes from the previous version to current', + revisionIncrementalHint: 'Changes that produced v{ver}', + revisionInitialRange: 'Initial → v{ver}', + revisionInitialCreationHint: 'Initial content at creation', + revisionCumulativeHint: 'All changes from this version to current', + revisionDiffIncrementalCaption: 'v{from} → v{to} (adjacent versions; red = old, green = new)', + revisionDiffCumulativeCaption: 'v{from} → v{to} (cumulative changes to current)', + revisionFirstVersionHint: 'This is the first version — there is no previous version to compare.', + revisionDiffTitle: 'Title', + revisionDiffSummary: 'Summary', + revisionDiffContent: 'Body', + revisionDiffEmpty: 'No differences in title, summary, or body vs current', revisionLoadFailed: 'Failed to load revision history', revertBtn: 'Revert to this version', revertConfirm: 'Revert to v{ver}? The current content is snapshotted first, so the revert itself can be undone.', revertSuccess: 'Reverted to v{ver}', revertFailed: 'Failed to revert', viewInGraph: 'View in Graph', + editingBadge: 'Editing', + pageActions: 'Page actions', tabDocuments: 'Documents', tabGraph: 'Graph', tabGraphTip: 'A graph of links between Wiki pages (page-link graph). This is NOT the same as the LLM-extracted entity-relationship Knowledge Graph configured under "KB Settings → Knowledge Graph".', @@ -3032,6 +3091,27 @@ export default { cancel: 'Failed to cancel task', run_now: 'Failed to run task', delete: 'Failed to clear record' + }, + taskTypes: { + documentProcess: 'Document parsing', + manualProcess: 'Manual reprocessing', + temporaryDocumentProcess: 'Chat attachment parsing', + postProcess: 'Document post-processing', + summary: 'Summary generation', + tableSummary: 'Table summary generation', + question: 'Question generation', + multimodal: 'Image multimodal processing', + graph: 'Knowledge graph extraction', + sync: 'Data-source sync', + faqImport: 'FAQ import', + batchReparse: 'Batch reparse', + batchDelete: 'Batch delete', + move: 'Document move', + indexDelete: 'Index deletion', + kbClone: 'Knowledge-base clone', + kbDelete: 'Knowledge-base deletion', + wikiIngest: 'Wiki content generation', + wikiFinalize: 'Wiki finalization' } }, models: { @@ -3051,6 +3131,46 @@ export default { queued: 'Throttling', full: 'At limit' } + }, + pools: { + core: 'Core parsing', + postprocess: 'Post-process orchestration', + enrichment: 'Enrichment', + maintenance: 'Maintenance & sync', + shared: 'Shared elastic', + wiki: 'Wiki pool' + }, + poolDescriptions: { + core: 'Guaranteed document and manual parsing capacity', + postprocess: 'Parse finalization and enrichment fan-out', + enrichment: 'Summaries, images, graph, and question generation', + maintenance: 'Source sync, batch work, and deletion cleanup', + shared: 'Borrowed by core or enrichment according to backlog', + wiki: 'Wiki content generation and global finalization' + }, + queueNames: { + default: 'Document parsing', + chat_attachment: 'Chat attachments', + postprocess: 'Post-process', + summary: 'Summaries', + sync: 'Source sync', + low: 'Maintenance & batch', + multimodal: 'Multimodal', + graph: 'Graph extraction', + question: 'Questions', + wiki: 'Wiki pipeline' + }, + queueDescriptions: { + default: 'Document parse, manual reparse', + chat_attachment: 'Session-scoped chat upload parsing', + postprocess: 'Parse finalization, enrichment fan-out', + summary: 'Document & table summaries', + sync: 'Manual & scheduled sync', + low: 'FAQ import, batch reparse, cleanup', + multimodal: 'Image OCR, vision captions', + graph: 'Chunk-level graph extraction', + question: 'Chunk-level question generation', + wiki: 'Content generation, index finalize' } }, keyLabels: { @@ -5134,6 +5254,8 @@ export default { connector: { feishu: 'Feishu', lark: 'Lark', + feishu_drive: 'Feishu Drive', + lark_drive: 'Lark Drive', notion: 'Notion', yuque: 'Yuque', rss: 'RSS / Atom Feed' @@ -5141,17 +5263,32 @@ export default { connectorDesc: { feishu: 'Sync documents, spreadsheets and files from Feishu Wiki', lark: 'Sync documents, spreadsheets and files from Lark Wiki (Feishu international)', + feishu_drive: 'Sync documents, spreadsheets and files from a Feishu Drive folder', + lark_drive: 'Sync documents, spreadsheets and files from a Lark Drive folder (Feishu international)', notion: 'Sync pages and databases from Notion', yuque: 'Sync documents from Yuque knowledge bases', rss: 'Sync articles from RSS / Atom feeds' }, + drive: { + folderTokenLabel: 'Drive folder token', + folderTokenPlaceholder: 'Enter a folder_token or a Feishu Drive folder URL', + folderTokenRequired: 'Please enter a concrete folder_token; the cloud-space root is not supported', + rootNotSupportedHint: 'The root folder is not paginated and does not return shortcuts; pick a concrete folder', + load: 'Load', + shareHint: 'Share the Drive folder with the app’s group first, otherwise the app cannot access it', + placeholderTitle: 'Load a Drive folder first', + placeholderDesc: 'Enter a folder_token (or paste a Feishu Drive folder URL) above and click "Load"', + loadForbiddenHint: 'The app has no access to this folder. Share the folder with the app’s group in Feishu Drive and retry.', + loadAuthHint: 'App credentials are invalid or missing Drive scopes. Check App ID / App Secret and drive:drive:readonly permissions.', + loadNotFoundHint: 'folder_token does not exist or has been deleted. Verify the token copied from the Feishu Drive folder URL.', + }, field: { appId: 'App ID', appSecret: 'App Secret', integrationToken: 'Integration Token', apiToken: 'API Token', baseUrl: 'Base URL (optional)', - baseUrlHint: 'Leave empty to use the Yuque public cloud (https://www.yuque.com). For Yuque Enterprise or self-hosted deployments, enter your company domain (e.g. https://your-company.yuque.com).', + baseUrlHint: 'Leave empty to use the default public cloud address. For private/enterprise deployments or when accessing via reverse proxy, enter your custom address (e.g. https://api-proxy.example.com).', feedUrls: 'Feed URLs', feedUrlsHint: 'One RSS / Atom feed URL per line; multiple feeds are supported.', authHeaders: 'Custom headers (optional)', @@ -5168,6 +5305,30 @@ export default { prereqStep2Desc_yuque: 'Check at least repo:read and doc:read (read knowledge base and document content)', prereqStep3Brief_yuque: '(Optional) Enter Base URL for enterprise deployments', prereqStep3Desc_yuque: 'Leave empty for public cloud; for Yuque Enterprise or self-hosted, enter your company domain.', + prereqStep1Brief_feishu: 'Create Feishu custom app', + prereqStep1Desc_feishu: 'Login Feishu Open Platform → Create enterprise custom app', + prereqStep2Brief_feishu: 'Add bot capability', + prereqStep2Desc_feishu: 'Open Platform → Your app → Add app capability → Bot', + prereqStep3Brief_feishu: 'Configure app permissions', + prereqStep3Desc_feishu: 'Enable wiki:wiki:readonly, drive:drive:readonly, drive:export:readonly, docx:document:readonly permissions', + prereqStep1Brief_lark: 'Create Lark custom app', + prereqStep1Desc_lark: 'Login Lark Open Platform → Create enterprise custom app', + prereqStep2Brief_lark: 'Add bot capability', + prereqStep2Desc_lark: 'Open Platform → Your app → Add app capability → Bot', + prereqStep3Brief_lark: 'Configure app permissions', + prereqStep3Desc_lark: 'Enable wiki:wiki:readonly, drive:drive:readonly, drive:export:readonly, docx:document:readonly permissions', + prereqStep1Brief_feishu_drive: 'Create Feishu custom app', + prereqStep1Desc_feishu_drive: 'Login Feishu Open Platform → Create enterprise custom app', + prereqStep2Brief_feishu_drive: 'Add bot capability', + prereqStep2Desc_feishu_drive: 'Open Platform → Your app → Add app capability → Bot', + prereqStep3Brief_feishu_drive: 'Configure app permissions', + prereqStep3Desc_feishu_drive: 'Enable drive:drive:readonly, drive:export:readonly, docx:document:readonly permissions', + prereqStep1Brief_lark_drive: 'Create Lark custom app', + prereqStep1Desc_lark_drive: 'Login Lark Open Platform → Create enterprise custom app', + prereqStep2Brief_lark_drive: 'Add bot capability', + prereqStep2Desc_lark_drive: 'Open Platform → Your app → Add app capability → Bot', + prereqStep3Brief_lark_drive: 'Configure app permissions', + prereqStep3Desc_lark_drive: 'Enable drive:drive:readonly, drive:export:readonly, docx:document:readonly permissions', prereqOpenConsole_yuque: 'Open Yuque Token settings', prereqBotBrief: 'Add "Bot" capability to your app', prereqBotDesc: 'Open Platform > Add App Capability > Bot > create version and publish', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index e7e6414ca1..4f7b4cfefa 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -595,6 +595,30 @@ export default { prereqStep2Desc_yuque: '최소한 repo:read 와 doc:read 를 선택하세요 (지식베이스 및 문서 콘텐츠 읽기)', prereqStep3Brief_yuque: '(선택) Enterprise 사용 시 Base URL 입력', prereqStep3Desc_yuque: '퍼블릭 클라우드 사용자는 입력하지 않아도 됩니다. Yuque Enterprise 또는 사설 배포 시 기업 도메인을 입력하세요', + prereqStep1Brief_feishu: "Feishu 커스텀 앱 생성", + prereqStep1Desc_feishu: "Feishu Open Platform 로그인 → 엔터프라이즈 커스텀 앱 생성", + prereqStep2Brief_feishu: "봇 기능 추가", + prereqStep2Desc_feishu: "Open Platform → 앱 → 앱 기능 추가 → 봇", + prereqStep3Brief_feishu: "앱 권한 구성", + prereqStep3Desc_feishu: "wiki:wiki:readonly, drive:drive:readonly, drive:export:readonly, docx:document:readonly 권한 활성화", + prereqStep1Brief_lark: "Lark 커스텀 앱 생성", + prereqStep1Desc_lark: "Lark Open Platform 로그인 → 엔터프라이즈 커스텀 앱 생성", + prereqStep2Brief_lark: "봇 기능 추가", + prereqStep2Desc_lark: "Open Platform → 앱 → 앱 기능 추가 → 봇", + prereqStep3Brief_lark: "앱 권한 구성", + prereqStep3Desc_lark: "wiki:wiki:readonly, drive:drive:readonly, drive:export:readonly, docx:document:readonly 권한 활성화", + prereqStep1Brief_feishu_drive: "Feishu 커스텀 앱 생성", + prereqStep1Desc_feishu_drive: "Feishu Open Platform 로그인 → 엔터프라이즈 커스텀 앱 생성", + prereqStep2Brief_feishu_drive: "봇 기능 추가", + prereqStep2Desc_feishu_drive: "Open Platform → 앱 → 앱 기능 추가 → 봇", + prereqStep3Brief_feishu_drive: "앱 권한 구성", + prereqStep3Desc_feishu_drive: "drive:drive:readonly, drive:export:readonly, docx:document:readonly 권한 활성화", + prereqStep1Brief_lark_drive: "Lark 커스텀 앱 생성", + prereqStep1Desc_lark_drive: "Lark Open Platform 로그인 → 엔터프라이즈 커스텀 앱 생성", + prereqStep2Brief_lark_drive: "봇 기능 추가", + prereqStep2Desc_lark_drive: "Open Platform → 앱 → 앱 기능 추가 → 봇", + prereqStep3Brief_lark_drive: "앱 권한 구성", + prereqStep3Desc_lark_drive: "drive:drive:readonly, drive:export:readonly, docx:document:readonly 권한 활성화", prereqOpenConsole_yuque: 'Yuque Token 설정으로 이동', prereqBotBrief: '앱에 \'봇\' 기능 추가', prereqBotDesc: '오픈 플랫폼 → 앱 기능 추가 → 봇 → 버전 생성 후 게시', @@ -633,7 +657,7 @@ export default { integrationToken: 'Integration Token', apiToken: 'API Token', baseUrl: 'Base URL', - baseUrlHint: '비워두면 Yuque 퍼블릭 클라우드 https://www.yuque.com 를 사용합니다. Yuque Enterprise 또는 사설 배포를 사용하는 경우 기업 도메인(예: https://your-company.yuque.com)을 입력하세요', + baseUrlHint: "비워두면 기본 퍼블릭 클라우드 주소가 사용됩니다. 프라이빗/엔터프라이즈 배포거나 리버스 프록시를 통해 액세스해야 하는 경우 사용자 정의 주소를 입력하세요 (예: https://api-proxy.example.com)", feedUrls: '피드 주소', feedUrlsHint: '한 줄에 하나씩 RSS / Atom 피드 주소를 입력하세요. 여러 개를 함께 입력할 수 있습니다.', authHeaders: '사용자 지정 헤더 (선택)', @@ -642,6 +666,8 @@ export default { connectorDesc: { feishu: '페이슈 위키에서 문서, 스프레드시트, 파일 동기화', lark: 'Lark 위키에서 문서, 스프레드시트, 파일 동기화', + feishu_drive: "페이슈 드라이브 폴더에서 문서, 스프레드시트, 파일 동기화", + lark_drive: "Lark 드라이브 폴더에서 문서, 스프레드시트, 파일 동기화", notion: 'Notion에서 페이지 및 데이터베이스 동기화', yuque: '위큐 지식베이스에서 문서 동기화', rss: 'RSS / Atom 피드에서 글 동기화' @@ -649,6 +675,8 @@ export default { connector: { feishu: '페이슈 (Feishu)', lark: 'Lark (Feishu 글로벌)', + feishu_drive: "페이슈 드라이브", + lark_drive: "Lark 드라이브", notion: 'Notion', yuque: '위큐 (Yuque)', rss: 'RSS / Atom 피드' @@ -696,7 +724,20 @@ export default { syncMode: { incremental: '증분 동기화', full: '전체 동기화' - } + }, + drive: { + folderTokenLabel: "드라이브 폴더 토큰", + folderTokenPlaceholder: "folder_token 또는 페이슈 드라이브 폴더 URL 입력", + folderTokenRequired: "구체적인 폴더의 folder_token을 입력하세요. 클라우드 루트는 지원되지 않습니다", + rootNotSupportedHint: "루트 폴더는 페이지네이션되지 않고 바로가기를 반환하지 않습니다. 구체적인 폴더를 선택하세요", + load: "로드", + shareHint: "앱이 접근할 수 있도록 먼저 드라이브 폴더를 앱이 속한 그룹에 공유하세요", + placeholderTitle: "먼저 드라이브 폴더를 로드하세요", + placeholderDesc: "위에 folder_token(또는 페이슈 드라이브 폴더 URL)을 입력하고 '로드'를 클릭하세요", + loadForbiddenHint: "앱이 이 폴더에 접근할 수 없습니다. 페이슈 드라이브에서 폴더를 앱이 속한 그룹에 공유한 후 다시 시도하세요.", + loadAuthHint: "앱 자격 증명이 유효하지 않거나 드라이브 권한이 없습니다. App ID / App Secret 및 drive:drive:readonly 권한을 확인하세요.", + loadNotFoundHint: "folder_token이 존재하지 않거나 삭제되었습니다. 페이슈 드라이브 폴더 URL에서 복사한 토큰이 맞는지 확인하세요.", + }, }, ollama: { unknown: '알 수 없음', @@ -2834,6 +2875,27 @@ export default { retry: '재시도 중', archived: '최종 실패', completed: '완료' + }, + taskTypes: { + documentProcess: '문서 파싱', + manualProcess: '수동 재처리', + temporaryDocumentProcess: '채팅 첨부 파일 파싱', + postProcess: '문서 후처리', + summary: '요약 생성', + tableSummary: '표 요약 생성', + question: '질문 생성', + multimodal: '이미지 멀티모달 처리', + graph: '지식 그래프 추출', + sync: '데이터 소스 동기화', + faqImport: 'FAQ 가져오기', + batchReparse: '일괄 재파싱', + batchDelete: '일괄 삭제', + move: '문서 이동', + indexDelete: '인덱스 삭제', + kbClone: '지식 베이스 복제', + kbDelete: '지식 베이스 삭제', + wikiIngest: 'Wiki 콘텐츠 생성', + wikiFinalize: 'Wiki 마무리' } }, failedNotice: { @@ -2866,6 +2928,46 @@ export default { retry: '재시도 중', archived: '최종 실패' }, + pools: { + core: '핵심 파싱', + postprocess: '후처리 오케스트레이션', + enrichment: '콘텐츠 보강', + maintenance: '유지 관리 및 동기화', + shared: '공유 탄력 풀', + wiki: 'Wiki 풀' + }, + poolDescriptions: { + core: '문서 및 수동 파싱 보장 용량', + postprocess: '파싱 마무리 및 보강 작업 분배', + enrichment: '요약, 이미지, 그래프 및 질문 생성', + maintenance: '데이터 소스 동기화, 일괄 작업 및 삭제 정리', + shared: '적체에 따라 핵심 파싱 또는 보강이 공유', + wiki: 'Wiki 콘텐츠 생성 및 전체 마무리' + }, + queueNames: { + default: '문서 파싱', + chat_attachment: '대화 첨부파일 파싱', + postprocess: '후처리 오케스트레이션', + summary: '요약 생성', + sync: '데이터 소스 동기화', + low: '유지 관리 및 일괄 작업', + multimodal: '멀티모달 처리', + graph: '그래프 추출', + question: '질문 생성', + wiki: 'Wiki 처리' + }, + queueDescriptions: { + default: '문서 파싱, 수동 재파싱', + chat_attachment: '세션 내 업로드 첨부파일 파싱', + postprocess: '파싱 마무리, 보강 분배', + summary: '문서 요약, 테이블 요약', + sync: '수동 및 예약 동기화', + low: 'FAQ 가져오기, 일괄 재파싱, 삭제 정리', + multimodal: '이미지 OCR, 시각 설명', + graph: '청크 단위 그래프 추출', + question: '청크 단위 질문 생성', + wiki: '콘텐츠 생성, 인덱스 마무리' + }, errors: { generic: '큐 상태를 불러오지 못했습니다' } @@ -3501,12 +3603,30 @@ export default { revisionDiff: '현재와 비교', revisionRaw: '원문 보기', revisionDiffCaption: 'v{from} → v{to} (빨강 = 해당 버전, 초록 = 현재)', + revisionDiffIncremental: '버전 변경', + revisionDiffCumulative: '현재와 비교', + revisionDiffBasisLabel: '비교 방식', + revisionViewModeLabel: '보기 방식', + revisionLatestChangeHint: '이전 버전에서 현재 버전으로의 변경', + revisionIncrementalHint: 'v{ver} 생성 시 변경 사항', + revisionInitialRange: '초기 → v{ver}', + revisionInitialCreationHint: '최초 생성 내용', + revisionCumulativeHint: '이 버전에서 현재까지의 누적 변경', + revisionDiffIncrementalCaption: 'v{from} → v{to} (인접 버전, 빨강=이전·초록=이후)', + revisionDiffCumulativeCaption: 'v{from} → v{to} (현재까지 누적 변경)', + revisionFirstVersionHint: '첫 번째 버전이라 이전 버전과 비교할 수 없습니다.', + revisionDiffTitle: '제목', + revisionDiffSummary: '요약', + revisionDiffContent: '본문', + revisionDiffEmpty: '제목, 요약, 본문에서 현재 버전과 차이가 없습니다', revisionLoadFailed: '버전 기록을 불러오지 못했습니다', revertBtn: '이 버전으로 롤백', revertConfirm: 'v{ver}(으)로 롤백하시겠습니까? 현재 내용은 먼저 기록으로 저장됩니다.', revertSuccess: 'v{ver}(으)로 롤백했습니다', revertFailed: '롤백에 실패했습니다', viewInGraph: '그래프에서 보기', + editingBadge: '편집 중', + pageActions: '페이지 작업', tabDocuments: '문서', tabGraph: '그래프', tabGraphTip: 'Wiki 페이지 간의 링크 관계 그래프(페이지 링크 그래프)입니다. \'지식 베이스 설정 → 지식 그래프\'에서 구성하는 LLM 기반 엔티티-관계 지식 그래프와는 다른 개념입니다.', @@ -4552,6 +4672,8 @@ export default { s3Desc: 'AWS S3 및 호환 오브젝트 스토리지 서비스, 퍼블릭 클라우드 배포에 적합합니다.', s3AccessKeyPlaceholder: 'AWS Access Key', s3SecretKeyPlaceholder: 'AWS Secret Key', + s3DefaultCredentialsHint: '두 키를 모두 비워 두면 AWS 기본 자격 증명 체인(IAM 역할, IRSA / 웹 자격 증명, 환경 변수 또는 공유 구성)을 사용합니다.', + s3EndpointPlaceholder: '선택 사항; 비워 두면 AWS 리전 기본 엔드포인트 사용', ks3Title: 'Kingsoft Cloud KS3', ks3Desc: 'Kingsoft Cloud 오브젝트 스토리지 서비스(KS3), 퍼블릭 클라우드 배포에 적합합니다.', ks3AccessKeyPlaceholder: 'Kingsoft Cloud Access Key', @@ -5097,6 +5219,11 @@ export default { vlmModelSelectRequired: '멀티모달이 활성화되었습니다. VLM 모델을 선택하세요', asrModelSelectRequired: '음성 인식이 활성화되었습니다. ASR 모델을 선택하세요', continueAdd: '계속 추가', + destinationLabel: '업로드 위치', + destinationChange: '업로드 위치 변경', + destinationToRoot: '루트로 변경', + folderUploadTitle: '폴더 「{name}」', + folderUploadHint: '파일 {count}개, 로컬 폴더 구조를 유지합니다', filesAdded: '{count}개 파일이 추가되었습니다', filesAllDuplicate: '선택한 파일이 이미 목록에 있습니다', titleManual: '온라인 편집 게시 확인', @@ -5123,6 +5250,38 @@ export default { tagEditSelectedSection: '선택된 태그', tagEditAvailableSection: '선택 가능', tagEditNoSelected: '선택 없음', + folderTree: { + title: '폴더', + rootRow: '루트', + rootRowTip: '지식 베이스 루트 디렉터리, 하위 폴더에 없는 문서가 여기에 있습니다', + folderCardCount: '문서 {count}개', + searchingSubtree: '(하위 폴더 포함)', + emptyFolder: '이 폴더에는 아직 문서가 없습니다', + emptySearch: '일치하는 문서가 없습니다', + collapse: '폴더 접기', + expand: '폴더 펼치기', + collapseFolder: '이 폴더 접기', + expandFolder: '이 폴더 펼치기', + rename: '이름 변경', + renamePlaceholder: '폴더 이름', + renameSuccess: '폴더 이름을 변경했습니다', + renameFailed: '폴더 이름을 변경할 수 없습니다', + renameInvalid: '폴더를 자기 하위로 이동할 수 없습니다', + }, + moveToFolder: { + action: '폴더로 이동', + newFolder: '새 하위 폴더', + newFolderPlaceholder: '새 폴더 이름', + newFolderHint: 'Enter로 만들고 이동', + newFolderCreate: '생성', + newFolderAddRoot: '루트 아래에 하위 디렉터리 만들기', + newFolderAddUnder: '「{folder}」 아래에 하위 디렉터리 만들기', + newFolderHintRoot: '루트 아래에 생성됩니다', + newFolderHintUnder: '「{folder}」 아래에 생성됩니다', + success: '문서 {count}개를 이동했습니다', + failed: '문서를 이동할 수 없습니다', + duplicate: '이미 존재하는 폴더입니다', + }, tagFilterTitle: '태그로 필터', tagFilterPlaceholder: '태그', tagFilterMulti: '태그 {count}개', @@ -5181,6 +5340,8 @@ export default { channelWechat: 'WeChat', channelWecom: 'WeCom', channelFeishu: 'Feishu', + channelFeishuDrive: "페이슈 드라이브", + channelLarkDrive: "Lark 드라이브", channelDingtalk: 'DingTalk', channelSlack: 'Slack', channelIm: 'IM 채널', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 533caa264e..dbb4af1ac3 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -596,6 +596,30 @@ export default { prereqStep3Brief_yuque: '(Опционально) Для Enterprise укажите Base URL', prereqStep3Desc_yuque: 'Пользователям публичного облака указывать не нужно. Для Yuque Enterprise или приватного развёртывания укажите корпоративный домен', prereqOpenConsole_yuque: 'Перейти к настройкам Yuque Token', + prereqStep1Brief_feishu: 'Создать частное приложение Feishu', + prereqStep1Desc_feishu: 'Войдите в Feishu Open Platform → Создать корпоративное частное приложение', + prereqStep2Brief_feishu: 'Добавить возможность бота', + prereqStep2Desc_feishu: 'Open Platform → Ваше приложение → Добавить возможность приложения → Bot', + prereqStep3Brief_feishu: 'Настроить разрешения приложения', + prereqStep3Desc_feishu: 'Включите разрешения: wiki:wiki:readonly, drive:drive:readonly, drive:export:readonly, docx:document:readonly', + prereqStep1Brief_lark: 'Создать частное приложение Lark', + prereqStep1Desc_lark: 'Войдите в Lark Open Platform → Создать корпоративное частное приложение', + prereqStep2Brief_lark: 'Добавить возможность бота', + prereqStep2Desc_lark: 'Open Platform → Ваше приложение → Добавить возможность приложения → Bot', + prereqStep3Brief_lark: 'Настроить разрешения приложения', + prereqStep3Desc_lark: 'Включите разрешения: wiki:wiki:readonly, drive:drive:readonly, drive:export:readonly, docx:document:readonly', + prereqStep1Brief_feishu_drive: 'Создать частное приложение Feishu', + prereqStep1Desc_feishu_drive: 'Войдите в Feishu Open Platform → Создать корпоративное частное приложение', + prereqStep2Brief_feishu_drive: 'Добавить возможность бота', + prereqStep2Desc_feishu_drive: 'Open Platform → Ваше приложение → Добавить возможность приложения → Bot', + prereqStep3Brief_feishu_drive: 'Настроить разрешения приложения', + prereqStep3Desc_feishu_drive: 'Включите разрешения: drive:drive:readonly, drive:export:readonly, docx:document:readonly', + prereqStep1Brief_lark_drive: 'Создать частное приложение Lark', + prereqStep1Desc_lark_drive: 'Войдите в Lark Open Platform → Создать корпоративное частное приложение', + prereqStep2Brief_lark_drive: 'Добавить возможность бота', + prereqStep2Desc_lark_drive: 'Open Platform → Ваше приложение → Добавить возможность приложения → Bot', + prereqStep3Brief_lark_drive: 'Настроить разрешения приложения', + prereqStep3Desc_lark_drive: 'Включите разрешения: drive:drive:readonly, drive:export:readonly, docx:document:readonly', prereqBotBrief: 'Добавьте приложению возможность «Бот»', prereqBotDesc: 'Открытая платформа → Добавить возможность приложения → Бот → Создать версию и опубликовать', prereqPermBrief: 'Включите права API', @@ -633,7 +657,7 @@ export default { integrationToken: 'Integration Token', apiToken: 'API Token', baseUrl: 'Base URL', - baseUrlHint: 'Оставьте пустым, чтобы использовать публичное облако Yuque https://www.yuque.com. Если вы используете Yuque Enterprise или приватное развёртывание, укажите корпоративный домен (например, https://your-company.yuque.com)', + baseUrlHint: 'Оставьте пустым, чтобы использовать адрес общедоступного облака по умолчанию. Для частных/корпоративных развертываний или при доступе через обратный прокси введите ваш собственный адрес (например, https://api-proxy.example.com)', feedUrls: 'Адреса лент', feedUrlsHint: 'По одному адресу ленты RSS / Atom в строке; можно указать несколько.', authHeaders: 'Пользовательские заголовки (необязательно)', @@ -642,6 +666,8 @@ export default { connectorDesc: { feishu: 'Синхронизация документов, таблиц и файлов из Feishu Wiki', lark: 'Синхронизация документов, таблиц и файлов из Lark Wiki', + feishu_drive: 'Синхронизация документов, таблиц и файлов из папки Feishu Drive', + lark_drive: 'Синхронизация документов, таблиц и файлов из папки Lark Drive', notion: 'Синхронизация страниц и баз данных из Notion', yuque: 'Синхронизация документов из баз знаний Yuque', rss: 'Синхронизация статей из лент RSS / Atom' @@ -649,6 +675,8 @@ export default { connector: { feishu: 'Feishu (Фэйшу)', lark: 'Lark', + feishu_drive: 'Feishu Drive', + lark_drive: 'Lark Drive', notion: 'Notion', yuque: 'Yuque (Юйцюэ)', rss: 'RSS / Atom лента' @@ -696,7 +724,20 @@ export default { syncMode: { incremental: 'Инкрементная', full: 'Полная' - } + }, + drive: { + folderTokenLabel: 'Токен папки Drive', + folderTokenPlaceholder: 'Введите folder_token или URL папки Feishu Drive', + folderTokenRequired: 'Введите конкретный folder_token; корень облачного пространства не поддерживается', + rootNotSupportedHint: 'Корневая папка не поддерживает постраничный вывод и не возвращает ярлыки; выберите конкретную папку', + load: 'Загрузить', + shareHint: 'Сначала поделитесь папкой Drive с группой приложения, иначе приложение не получит к ней доступ', + placeholderTitle: 'Сначала загрузите папку Drive', + placeholderDesc: 'Введите выше folder_token (или вставьте URL папки Feishu Drive) и нажмите «Загрузить»', + loadForbiddenHint: 'У приложения нет доступа к этой папке. Поделитесь папкой с группой приложения в Feishu Drive и повторите.', + loadAuthHint: 'Учётные данные приложения недействительны или отсутствуют области Drive. Проверьте App ID / App Secret и разрешения drive:drive:readonly.', + loadNotFoundHint: 'folder_token не существует или удалён. Проверьте токен, скопированный из URL папки Feishu Drive.', + }, }, ollama: { unknown: 'Неизвестно', @@ -2834,6 +2875,27 @@ export default { retry: 'Повтор', archived: 'Окончательный сбой', completed: 'Завершены' + }, + taskTypes: { + documentProcess: 'Разбор документа', + manualProcess: 'Ручная повторная обработка', + temporaryDocumentProcess: 'Обработка вложения чата', + postProcess: 'Постобработка документа', + summary: 'Создание сводки', + tableSummary: 'Создание сводки таблицы', + question: 'Генерация вопросов', + multimodal: 'Обработка изображений', + graph: 'Извлечение графа знаний', + sync: 'Синхронизация источника', + faqImport: 'Импорт FAQ', + batchReparse: 'Пакетный повторный разбор', + batchDelete: 'Пакетное удаление', + move: 'Перемещение документа', + indexDelete: 'Удаление индекса', + kbClone: 'Клонирование базы знаний', + kbDelete: 'Удаление базы знаний', + wikiIngest: 'Создание Wiki-контента', + wikiFinalize: 'Завершение Wiki' } }, failedNotice: { @@ -2866,6 +2928,46 @@ export default { retry: 'Повтор', archived: 'Окончательный сбой' }, + pools: { + core: 'Основной разбор', + postprocess: 'Оркестрация постобработки', + enrichment: 'Обогащение', + maintenance: 'Обслуживание и синхронизация', + shared: 'Общий эластичный пул', + wiki: 'Пул Wiki' + }, + poolDescriptions: { + core: 'Гарантированная ёмкость разбора документов', + postprocess: 'Завершение разбора и запуск обогащения', + enrichment: 'Сводки, изображения, граф и генерация вопросов', + maintenance: 'Синхронизация источников, пакетные задачи и удаление', + shared: 'Используется разбором или обогащением по мере очереди', + wiki: 'Создание содержимого Wiki и финальная обработка' + }, + queueNames: { + default: 'Разбор документов', + chat_attachment: 'Разбор вложений чата', + postprocess: 'Постобработка', + summary: 'Сводки', + sync: 'Синхронизация источников', + low: 'Обслуживание и пакеты', + multimodal: 'Мультимодальные', + graph: 'Извлечение графа', + question: 'Вопросы', + wiki: 'Wiki-конвейер' + }, + queueDescriptions: { + default: 'Разбор документов, ручной повторный разбор', + chat_attachment: 'Разбор вложений, загруженных в сессии', + postprocess: 'Завершение разбора, запуск обогащения', + summary: 'Сводки документов и таблиц', + sync: 'Ручная и плановая синхронизация', + low: 'Импорт FAQ, пакетный повторный разбор, очистка', + multimodal: 'OCR изображений, визуальные описания', + graph: 'Извлечение графа по фрагментам', + question: 'Генерация вопросов по фрагментам', + wiki: 'Создание контента, финализация индекса' + }, errors: { generic: 'Не удалось загрузить состояние очередей' } @@ -3501,12 +3603,30 @@ export default { revisionDiff: 'Сравнить с текущей', revisionRaw: 'Исходный текст', revisionDiffCaption: 'v{from} → v{to} (красный — та версия, зелёный — текущая)', + revisionDiffIncremental: 'Изменение версии', + revisionDiffCumulative: 'С текущей', + revisionDiffBasisLabel: 'Режим сравнения', + revisionViewModeLabel: 'Режим просмотра', + revisionLatestChangeHint: 'Изменения от предыдущей версии к текущей', + revisionIncrementalHint: 'Изменения, создавшие v{ver}', + revisionInitialRange: 'Начало → v{ver}', + revisionInitialCreationHint: 'Исходное содержимое при создании', + revisionCumulativeHint: 'Все изменения от этой версии до текущей', + revisionDiffIncrementalCaption: 'v{from} → v{to} (соседние версии; красный — старая, зелёный — новая)', + revisionDiffCumulativeCaption: 'v{from} → v{to} (накопленные изменения до текущей)', + revisionFirstVersionHint: 'Это первая версия — сравнивать не с чем.', + revisionDiffTitle: 'Заголовок', + revisionDiffSummary: 'Резюме', + revisionDiffContent: 'Содержимое', + revisionDiffEmpty: 'Нет различий в заголовке, резюме и содержимом с текущей версией', revisionLoadFailed: 'Не удалось загрузить историю версий', revertBtn: 'Откатить к этой версии', revertConfirm: 'Откатить к v{ver}? Текущее содержимое сначала будет сохранено в историю.', revertSuccess: 'Выполнен откат к v{ver}', revertFailed: 'Не удалось выполнить откат', viewInGraph: 'Открыть в графе', + editingBadge: 'Редактирование', + pageActions: 'Действия со страницей', tabDocuments: 'Документы', tabGraph: 'Граф', tabGraphTip: 'Граф связей между Wiki-страницами (граф ссылок страниц). Это НЕ то же самое, что граф знаний на основе сущностей и отношений, настраиваемый в «Настройки БЗ → Граф знаний».', @@ -4552,6 +4672,8 @@ export default { s3Desc: 'AWS S3 и совместимые сервисы объектного хранилища для публичного облака.', s3AccessKeyPlaceholder: 'AWS Access Key', s3SecretKeyPlaceholder: 'AWS Secret Key', + s3DefaultCredentialsHint: 'Оставьте оба ключа пустыми, чтобы использовать стандартную цепочку учётных данных AWS (IAM role, IRSA / web identity, переменные среды или общий профиль).', + s3EndpointPlaceholder: 'Необязательно; оставьте пустым для регионального endpoint AWS', ks3Title: 'Kingsoft Cloud KS3', ks3Desc: 'Объектное хранилище Kingsoft Cloud (KS3), подходит для публичного облака.', ks3AccessKeyPlaceholder: 'Kingsoft Cloud Access Key', @@ -5097,6 +5219,11 @@ export default { vlmModelSelectRequired: 'Мультимодальность включена. Выберите модель VLM.', asrModelSelectRequired: 'Распознавание речи включено. Выберите модель ASR.', continueAdd: 'Добавить ещё', + destinationLabel: 'Куда загрузить', + destinationChange: 'Изменить папку загрузки', + destinationToRoot: 'В корень', + folderUploadTitle: 'Папка «{name}»', + folderUploadHint: '{count} файлов; структура локальной папки сохранится', filesAdded: 'Добавлено файлов: {count}', filesAllDuplicate: 'Выбранные файлы уже в списке', titleManual: 'Подтверждение публикации', @@ -5123,6 +5250,38 @@ export default { tagEditSelectedSection: 'Выбранные', tagEditAvailableSection: 'Доступные', tagEditNoSelected: 'Ничего не выбрано', + folderTree: { + title: 'Папки', + rootRow: 'Корень', + rootRowTip: 'Корневая папка базы знаний; документы без подпапки находятся здесь', + folderCardCount: 'Документов: {count}', + searchingSubtree: '(с вложенными папками)', + emptyFolder: 'В этой папке пока нет документов', + emptySearch: 'Подходящих документов нет', + collapse: 'Свернуть папки', + expand: 'Развернуть папки', + collapseFolder: 'Свернуть эту папку', + expandFolder: 'Развернуть эту папку', + rename: 'Переименовать', + renamePlaceholder: 'Название папки', + renameSuccess: 'Папка переименована', + renameFailed: 'Не удалось переименовать папку', + renameInvalid: 'Папку нельзя переместить внутрь себя', + }, + moveToFolder: { + action: 'Переместить в папку', + newFolder: 'Новая вложенная папка', + newFolderPlaceholder: 'Название новой папки', + newFolderHint: 'Enter — создать и переместить', + newFolderCreate: 'Создать', + newFolderAddRoot: 'Создать подкаталог в корне', + newFolderAddUnder: 'Создать подкаталог в «{folder}»', + newFolderHintRoot: 'Будет создана в корне', + newFolderHintUnder: 'Будет создана внутри «{folder}»', + success: 'Перемещено документов: {count}', + failed: 'Не удалось переместить документы', + duplicate: 'Такая папка уже существует', + }, tagFilterTitle: 'Фильтр по тегу', tagFilterPlaceholder: 'Теги', tagFilterMulti: '{count} тегов', @@ -5181,6 +5340,8 @@ export default { channelWechat: 'WeChat', channelWecom: 'WeCom', channelFeishu: 'Feishu', + channelFeishuDrive: 'Feishu Drive', + channelLarkDrive: 'Lark Drive', channelDingtalk: 'DingTalk', channelSlack: 'Slack', channelIm: 'IM канал', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index aab24a9d5f..06eb7a75cb 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -596,6 +596,30 @@ export default { prereqStep3Brief_yuque: '(可选)企业版填写 Base URL', prereqStep3Desc_yuque: '公有云用户无需填写;语雀企业版或私有部署请填写企业域名', prereqOpenConsole_yuque: '前往语雀 Token 设置', + prereqStep1Brief_feishu: "创建飞书自建应用", + prereqStep1Desc_feishu: "登录飞书开放平台 → 创建企业自建应用", + prereqStep2Brief_feishu: "添加机器人能力", + prereqStep2Desc_feishu: "开放平台 → 你的应用 → 添加应用能力 → 机器人", + prereqStep3Brief_feishu: "配置应用权限", + prereqStep3Desc_feishu: "为应用开通 wiki:wiki:readonly, drive:drive:readonly, drive:export:readonly, docx:document:readonly 权限", + prereqStep1Brief_lark: "创建 Lark 自建应用", + prereqStep1Desc_lark: "登录 Lark 开放平台 → 创建企业自建应用", + prereqStep2Brief_lark: "添加机器人能力", + prereqStep2Desc_lark: "开放平台 → 你的应用 → 添加应用能力 → 机器人", + prereqStep3Brief_lark: "配置应用权限", + prereqStep3Desc_lark: "为应用开通 wiki:wiki:readonly, drive:drive:readonly, drive:export:readonly, docx:document:readonly 权限", + prereqStep1Brief_feishu_drive: "创建飞书自建应用", + prereqStep1Desc_feishu_drive: "登录飞书开放平台 → 创建企业自建应用", + prereqStep2Brief_feishu_drive: "添加机器人能力", + prereqStep2Desc_feishu_drive: "开放平台 → 你的应用 → 添加应用能力 → 机器人", + prereqStep3Brief_feishu_drive: "配置应用权限", + prereqStep3Desc_feishu_drive: "为应用开通 drive:drive:readonly, drive:export:readonly, docx:document:readonly 权限", + prereqStep1Brief_lark_drive: "创建 Lark 自建应用", + prereqStep1Desc_lark_drive: "登录 Lark 开放平台 → 创建企业自建应用", + prereqStep2Brief_lark_drive: "添加机器人能力", + prereqStep2Desc_lark_drive: "开放平台 → 你的应用 → 添加应用能力 → 机器人", + prereqStep3Brief_lark_drive: "配置应用权限", + prereqStep3Desc_lark_drive: "为应用开通 drive:drive:readonly, drive:export:readonly, docx:document:readonly 权限", prereqBotBrief: '为应用添加「机器人」能力', prereqBotDesc: '开放平台 → 添加应用能力 → 机器人 → 创建版本并发布', prereqPermBrief: '开通 API 权限', @@ -633,7 +657,7 @@ export default { integrationToken: 'Integration Token', apiToken: 'API Token', baseUrl: 'Base URL(可选)', - baseUrlHint: '留空将使用语雀公有云 https://www.yuque.com;如果你使用的是语雀企业版或私有部署,请填写企业域名(例如 https://your-company.yuque.com)', + baseUrlHint: '留空将使用默认公有云地址;如果是私有部署/企业内网部署,或需要通过反向代理访问,请填写自定义地址(例如 https://api-proxy.example.com)', feedUrls: '订阅源地址', feedUrlsHint: '每行一个 RSS / Atom 订阅源地址,支持同时填写多个', authHeaders: '自定义请求头(可选)', @@ -642,6 +666,8 @@ export default { connectorDesc: { feishu: '同步飞书知识库中的文档、表格、文件', lark: '同步 Lark 知识库中的文档、表格、文件(飞书国际版)', + feishu_drive: "同步飞书云盘文件夹中的文档、表格、文件", + lark_drive: "同步 Lark 云盘文件夹中的文档、表格、文件(飞书国际版)", notion: '同步 Notion 中的页面和数据库', yuque: '同步语雀知识库中的文档', rss: '同步 RSS / Atom 订阅源中的文章' @@ -649,6 +675,8 @@ export default { connector: { feishu: '飞书', lark: 'Lark(飞书国际版)', + feishu_drive: "飞书云盘", + lark_drive: "Lark 云盘", notion: 'Notion', yuque: '语雀', rss: 'RSS / Atom 订阅' @@ -696,7 +724,20 @@ export default { syncMode: { incremental: '增量同步', full: '全量同步' - } + }, + drive: { + folderTokenLabel: "云盘文件夹 Token", + folderTokenPlaceholder: "输入 folder_token 或飞书云盘文件夹链接", + folderTokenRequired: "请输入具体文件夹的 folder_token,不支持云空间根目录", + rootNotSupportedHint: "根目录不分页且不返回快捷方式,请选择具体文件夹", + load: "加载", + shareHint: "需先将该云盘文件夹分享给应用所在的群,应用才能访问", + placeholderTitle: "请先加载云盘文件夹", + placeholderDesc: "在上方输入 folder_token(或从飞书云盘文件夹 URL 复制)并点击「加载」", + loadForbiddenHint: "应用无权访问该文件夹。请在飞书云盘中将该文件夹分享给应用所在的群后再试。", + loadAuthHint: "应用凭证无效或缺少云盘权限,请检查 App ID / App Secret 及 drive:drive:readonly 等权限。", + loadNotFoundHint: "folder_token 不存在或已删除,请确认从飞书云盘文件夹 URL 复制的 token 正确。", + }, }, ollama: { unknown: '未知', @@ -2834,6 +2875,27 @@ export default { retry: '重试中', archived: '最终失败', completed: '已完成' + }, + taskTypes: { + documentProcess: '文档解析', + manualProcess: '手工重新处理', + temporaryDocumentProcess: '聊天附件解析', + postProcess: '文档后处理', + summary: '摘要生成', + tableSummary: '表格摘要生成', + question: '问题生成', + multimodal: '图片多模态处理', + graph: '知识图谱抽取', + sync: '数据源同步', + faqImport: 'FAQ 导入', + batchReparse: '批量重新解析', + batchDelete: '批量删除', + move: '文档移动', + indexDelete: '索引删除', + kbClone: '知识库复制', + kbDelete: '知识库删除', + wikiIngest: 'Wiki 内容生成', + wikiFinalize: 'Wiki 收尾处理' } }, failedNotice: { @@ -2866,6 +2928,46 @@ export default { retry: '重试中', archived: '最终失败' }, + pools: { + core: '核心解析', + postprocess: '后处理编排', + enrichment: '内容富化', + maintenance: '维护与同步', + shared: '共享弹性', + wiki: 'Wiki 池' + }, + poolDescriptions: { + core: '文档解析与手工重解析的保底容量', + postprocess: '解析完成后的收尾与富化扇出', + enrichment: '摘要、图片、图谱与问题生成', + maintenance: '数据源同步、批处理与删除清理', + shared: '由核心解析与内容富化按积压借用', + wiki: 'Wiki 内容生成与全局收尾' + }, + queueNames: { + default: '文档解析', + chat_attachment: '对话附件解析', + postprocess: '后处理编排', + summary: '摘要生成', + sync: '数据源同步', + low: '维护与批处理', + multimodal: '多模态处理', + graph: '图谱抽取', + question: '问题生成', + wiki: 'Wiki 处理' + }, + queueDescriptions: { + default: '文档解析、手工重解析', + chat_attachment: '会话内上传附件解析', + postprocess: '解析收尾、富化扇出', + summary: '文档摘要、表格摘要', + sync: '手动与定时同步', + low: 'FAQ 导入、批量重解析、删除清理', + multimodal: '图片 OCR、视觉描述', + graph: '分块图谱抽取', + question: '分块问题生成', + wiki: '内容生成、索引收尾' + }, errors: { generic: '获取队列状态失败' } @@ -3500,13 +3602,31 @@ export default { revisionEmpty: '暂无历史版本,页面内容变化后会自动记录快照', revisionDiff: '对比当前', revisionRaw: '查看原文', - revisionDiffCaption: 'v{from} → v{to}(红色为该版本内容,绿色为当前内容)', + revisionDiffCaption: 'v{from} → v{to}(红色为该版本,绿色为当前)', + revisionDiffIncremental: '版本变更', + revisionDiffCumulative: '对比当前', + revisionDiffBasisLabel: '对比方式', + revisionViewModeLabel: '查看方式', + revisionLatestChangeHint: '上一版到当前版的变更', + revisionIncrementalHint: '产生 v{ver} 的变更', + revisionInitialRange: '初始 → v{ver}', + revisionInitialCreationHint: '初始创建内容', + revisionCumulativeHint: '该版本到当前版的累计变更', + revisionDiffIncrementalCaption: 'v{from} → v{to}(相邻版本,红色为旧、绿色为新)', + revisionDiffCumulativeCaption: 'v{from} → v{to}(距当前的累计变更)', + revisionFirstVersionHint: '这是首个版本,没有上一版可对比。', + revisionDiffTitle: '标题', + revisionDiffSummary: '摘要', + revisionDiffContent: '正文', + revisionDiffEmpty: '此版本与当前在标题、摘要和正文上均无差异', revisionLoadFailed: '加载版本历史失败', revertBtn: '回滚到此版本', revertConfirm: '确定回滚到 v{ver} 吗?当前内容会先保存为历史版本,回滚操作可再次撤销。', revertSuccess: '已回滚到 v{ver}', revertFailed: '回滚失败', viewInGraph: '在图谱中查看', + editingBadge: '编辑中', + pageActions: '页面操作', tabDocuments: '文档', tabGraph: '图谱', tabGraphTip: 'Wiki 页面之间的引用关系图(即页面链接图谱),与「知识库设置 → 知识图谱」中基于 LLM 抽取的实体-关系图谱不是同一个概念', @@ -4552,6 +4672,8 @@ export default { s3Desc: 'AWS S3 及兼容的对象存储服务,适合公有云部署。', s3AccessKeyPlaceholder: 'AWS Access Key', s3SecretKeyPlaceholder: 'AWS Secret Key', + s3DefaultCredentialsHint: 'Access Key 与 Secret Key 同时留空时,将使用 AWS 默认凭证链(IAM Role、IRSA / Web Identity、环境变量或共享配置)。', + s3EndpointPlaceholder: '可选,留空使用 AWS 区域默认端点', ks3Title: '金山云 KS3', ks3Desc: '金山云对象存储服务(KS3),适合公有云部署。', ks3AccessKeyPlaceholder: '金山云 Access Key', @@ -5097,6 +5219,11 @@ export default { vlmModelSelectRequired: '已启用多模态,请选择 VLM 模型', asrModelSelectRequired: '已启用语音识别,请选择 ASR 模型', continueAdd: '继续添加', + destinationLabel: '上传位置', + destinationChange: '更改上传位置', + destinationToRoot: '改到根目录', + folderUploadTitle: '文件夹「{name}」', + folderUploadHint: '共 {count} 个文件,将保留本地目录结构', filesAdded: '已添加 {count} 个文件', filesAllDuplicate: '所选文件已在列表中', titleManual: '在线编辑发布确认', @@ -5123,6 +5250,38 @@ export default { tagEditSelectedSection: '已选标签', tagEditAvailableSection: '可选标签', tagEditNoSelected: '暂未选择', + folderTree: { + title: '目录', + rootRow: '根目录', + rootRowTip: '知识库根目录,未归入子文件夹的文档在此', + folderCardCount: '{count} 个文档', + searchingSubtree: '(含子目录)', + emptyFolder: '这个文件夹里还没有文档', + emptySearch: '没有匹配的文档', + collapse: '收起目录', + expand: '展开目录', + collapseFolder: '收起该文件夹', + expandFolder: '展开该文件夹', + rename: '重命名', + renamePlaceholder: '输入文件夹名称', + renameSuccess: '文件夹已重命名', + renameFailed: '文件夹重命名失败', + renameInvalid: '不能把文件夹移动到它自己的子目录下', + }, + moveToFolder: { + action: '移动到目录', + newFolder: '新建子目录', + newFolderPlaceholder: '输入新目录名称', + newFolderCreate: '创建', + newFolderAddRoot: '在根目录下新建子目录', + newFolderAddUnder: '在「{folder}」下新建子目录', + newFolderHint: '回车创建并移动', + newFolderHintRoot: '将在根目录下创建', + newFolderHintUnder: '将在「{folder}」下创建', + success: '已移动 {count} 个文档', + failed: '移动失败', + duplicate: '该目录已存在', + }, tagFilterTitle: '按标签筛选', tagFilterPlaceholder: '标签', tagFilterMulti: '{count} 个标签', @@ -5181,6 +5340,8 @@ export default { channelWechat: '微信', channelWecom: '企业微信', channelFeishu: '飞书', + channelFeishuDrive: "飞书云盘", + channelLarkDrive: "Lark 云盘", channelDingtalk: '钉钉', channelSlack: 'Slack', channelIm: 'IM 渠道', diff --git a/frontend/src/stores/uploadConfirm.ts b/frontend/src/stores/uploadConfirm.ts index 350bd4ddd8..3dfc2abd50 100644 --- a/frontend/src/stores/uploadConfirm.ts +++ b/frontend/src/stores/uploadConfirm.ts @@ -26,6 +26,13 @@ export interface UploadConfirmResult { urls?: string[] manual?: UploadConfirmManualSource reparse?: UploadConfirmReparseSource + /** + * Folder the batch is uploaded into ('' = knowledge base root). Returned as + * part of the result because the dialog lets the user change it, so callers + * must use this value rather than whatever folder was open when they opened + * the dialog. + */ + targetFolder?: string } export interface OpenUploadConfirmOptions { @@ -38,6 +45,10 @@ export interface OpenUploadConfirmOptions { reparse?: UploadConfirmReparseSource acceptFileTypes?: string supportedFileTypes?: string[] + /** Folder pre-selected from the sidebar tree; '' means the root. */ + targetFolder?: string + /** Existing folders the dialog can offer as upload destinations. */ + folderOptions?: Array<{ path: string; name: string; depth: number }> } export const useUploadConfirmStore = defineStore('uploadConfirm', { @@ -52,6 +63,8 @@ export const useUploadConfirmStore = defineStore('uploadConfirm', { reparse: null as UploadConfirmReparseSource | null, acceptFileTypes: '', supportedFileTypes: [] as string[], + targetFolder: '', + folderOptions: [] as Array<{ path: string; name: string; depth: number }>, pendingResolve: null as ((value: UploadConfirmResult) => void) | null, pendingReject: null as (() => void) | null, }), @@ -71,6 +84,8 @@ export const useUploadConfirmStore = defineStore('uploadConfirm', { this.reparse = options.reparse || null this.acceptFileTypes = options.acceptFileTypes || '' this.supportedFileTypes = options.supportedFileTypes ? [...options.supportedFileTypes] : [] + this.targetFolder = options.targetFolder || '' + this.folderOptions = options.folderOptions ? [...options.folderOptions] : [] this.pendingResolve = resolve this.pendingReject = reject }) @@ -97,6 +112,8 @@ export const useUploadConfirmStore = defineStore('uploadConfirm', { this.reparse = null this.acceptFileTypes = '' this.supportedFileTypes = [] + this.targetFolder = '' + this.folderOptions = [] this.pendingResolve = null this.pendingReject = null }, diff --git a/frontend/src/types/chunker.ts b/frontend/src/types/chunker.ts index 7065fa5126..7cf536592f 100644 --- a/frontend/src/types/chunker.ts +++ b/frontend/src/types/chunker.ts @@ -65,6 +65,9 @@ export interface PreviewChunkingRequest { chunk_size: number chunk_overlap: number separators: string[] + enable_parent_child?: boolean + parent_chunk_size?: number + child_chunk_size?: number strategy?: string token_limit?: number languages?: string[] diff --git a/frontend/src/utils/markdownDomPurify.ts b/frontend/src/utils/markdownDomPurify.ts index 98354e3cc3..7507a1943f 100644 --- a/frontend/src/utils/markdownDomPurify.ts +++ b/frontend/src/utils/markdownDomPurify.ts @@ -2,7 +2,7 @@ export const domPurifyForbidTags = ['script', 'style', 'object', 'embed', 'form' export const domPurifyForbidAttr = ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'] as const; export const domPurifyAllowedUriRegexp = - /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|blob):|(?:resource|storage|local|minio|cos|tos|s3|oss|ks3|obs):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i; + /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|blob):|data:image\/|(?:resource|storage|local|minio|cos|tos|s3|oss|ks3|obs):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i; /** Shared DOMPurify security options (FORBID_*, URI scheme, DOM flags). */ export const domPurifySecurityOptions = { diff --git a/frontend/src/utils/wikiLineDiff.test.ts b/frontend/src/utils/wikiLineDiff.test.ts index 82f8fae834..bbf70ec879 100644 --- a/frontend/src/utils/wikiLineDiff.test.ts +++ b/frontend/src/utils/wikiLineDiff.test.ts @@ -27,8 +27,7 @@ test('pure insertion and pure deletion', () => { test('empty sides', () => { assert.equal(diffWikiLines('', 'x\ny').filter((l) => l.type === 'add').length, 2) assert.equal(diffWikiLines('x\ny', '').filter((l) => l.type === 'del').length, 2) - // '' splits into one empty line on each side → one same row. - assert.deepEqual(diffWikiLines('', ''), [{ type: 'same', text: '' }]) + assert.deepEqual(diffWikiLines('', ''), []) }) test('unchanged prefix/suffix stay in order around a block edit', () => { diff --git a/frontend/src/utils/wikiLineDiff.ts b/frontend/src/utils/wikiLineDiff.ts index a191e84fe0..8bed15b105 100644 --- a/frontend/src/utils/wikiLineDiff.ts +++ b/frontend/src/utils/wikiLineDiff.ts @@ -18,8 +18,8 @@ const LCS_LINE_LIMIT = 1500 // detected first so typical wiki edits (one section changed) stay cheap even // on long pages. export function diffWikiLines(oldText: string, newText: string): WikiDiffLine[] { - const oldLines = oldText.split('\n') - const newLines = newText.split('\n') + const oldLines = oldText ? oldText.split('\n') : [] + const newLines = newText ? newText.split('\n') : [] // Trim common prefix. let start = 0 diff --git a/frontend/src/utils/wikiRevisionDiff.test.ts b/frontend/src/utils/wikiRevisionDiff.test.ts new file mode 100644 index 0000000000..23b9cfefcd --- /dev/null +++ b/frontend/src/utils/wikiRevisionDiff.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { diffWikiRevision } from './wikiRevisionDiff.ts' + +test('title-only edits produce a title section', () => { + const sections = diffWikiRevision( + { title: 'Old', summary: 'same', content: 'body' }, + { title: 'New', summary: 'same', content: 'body' }, + ) + assert.equal(sections.length, 1) + assert.equal(sections[0].field, 'title') + assert.ok(sections[0].lines.some((line) => line.type === 'del')) + assert.ok(sections[0].lines.some((line) => line.type === 'add')) +}) + +test('summary-only edits produce a summary section', () => { + const sections = diffWikiRevision( + { title: 'same', summary: 'old summary', content: 'body' }, + { title: 'same', summary: 'new summary', content: 'body' }, + ) + assert.equal(sections.length, 1) + assert.equal(sections[0].field, 'summary') +}) + +test('content edits still produce a content section', () => { + const sections = diffWikiRevision( + { title: 'same', summary: 'same', content: 'old body' }, + { title: 'same', summary: 'same', content: 'new body' }, + ) + assert.equal(sections.length, 1) + assert.equal(sections[0].field, 'content') +}) + +test('mixed field edits preserve section order', () => { + const sections = diffWikiRevision( + { title: 'A', summary: 's1', content: 'c1' }, + { title: 'B', summary: 's2', content: 'c2' }, + ) + assert.deepEqual( + sections.map((section) => section.field), + ['title', 'summary', 'content'], + ) +}) + +test('first version diffs from empty baseline', () => { + const sections = diffWikiRevision( + { title: '', summary: '', content: '' }, + { title: 'Page', summary: 'Intro', content: 'Body' }, + ) + assert.deepEqual( + sections.map((section) => section.field), + ['title', 'summary', 'content'], + ) + assert.ok(sections.every((section) => section.lines.every((line) => line.type === 'add'))) +}) diff --git a/frontend/src/utils/wikiRevisionDiff.ts b/frontend/src/utils/wikiRevisionDiff.ts new file mode 100644 index 0000000000..7fd7268743 --- /dev/null +++ b/frontend/src/utils/wikiRevisionDiff.ts @@ -0,0 +1,43 @@ +import { diffWikiLines, type WikiDiffLine } from './wikiLineDiff' + +export type WikiRevisionDiffField = 'title' | 'summary' | 'content' + +export interface WikiRevisionDiffSection { + field: WikiRevisionDiffField + lines: WikiDiffLine[] +} + +export interface WikiRevisionSnapshot { + title: string + summary: string + content: string +} + +function hasChanges(lines: WikiDiffLine[]): boolean { + return lines.some((line) => line.type !== 'same') +} + +/** Compare a stored revision against the current page across title, summary, and body. */ +export function diffWikiRevision( + oldSnap: WikiRevisionSnapshot, + newSnap: WikiRevisionSnapshot, +): WikiRevisionDiffSection[] { + const sections: WikiRevisionDiffSection[] = [] + + const titleLines = diffWikiLines(oldSnap.title, newSnap.title) + if (hasChanges(titleLines)) { + sections.push({ field: 'title', lines: titleLines }) + } + + const summaryLines = diffWikiLines(oldSnap.summary, newSnap.summary) + if (hasChanges(summaryLines)) { + sections.push({ field: 'summary', lines: summaryLines }) + } + + const contentLines = diffWikiLines(oldSnap.content, newSnap.content) + if (hasChanges(contentLines)) { + sections.push({ field: 'content', lines: contentLines }) + } + + return sections +} diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index b6ff33e8c5..1b1963bfce 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -35,6 +35,10 @@ import { batchReparseKnowledge, getKnowledgeSpans, getKnowledgeDetails, + listKnowledgeFolders, + moveKnowledgeToFolder, + renameKnowledgeFolder, + type KnowledgeFolderTree, } from "@/api/knowledge-base/index"; import { knowledgeSpansPayloadHasTrace } from '@/utils/knowledgeTrace'; import FAQEntryManager from './components/FAQEntryManager.vue'; @@ -42,6 +46,7 @@ import DocumentListView from './components/DocumentListView.vue'; import DocumentCardView from './components/DocumentCardView.vue'; import DocumentBatchBar from './components/DocumentBatchBar.vue'; import KbUploadSourceDropdown from './components/KbUploadSourceDropdown.vue'; +import KbFolderTree from './components/KbFolderTree.vue'; import TagEditDialog from './components/TagEditDialog.vue'; import BatchTagDialog from './components/BatchTagDialog.vue'; import KbTagManageDrawer from './components/KbTagManageDrawer.vue'; @@ -55,6 +60,16 @@ import { shouldRefreshWikiStatusAfterKnowledgePoll, } from './wikiStatusRefresh'; import { listMoveTargets, moveKnowledge, getKnowledgeMoveProgress } from '@/api/knowledge-base'; +import { + buildUploadFileName, + canMoveFolderTo, + childFolders, + folderBreadcrumbs as buildFolderBreadcrumbs, + folderPathExists as folderExistsInTree, + isFilteringDocuments, + isFolderUpload, + ROOT_FOLDER_PATH, +} from './folderTree'; import { useI18n } from 'vue-i18n'; import { useMarqueeSelect } from '@/hooks/useMarqueeSelect'; import type { ParserEngineInfo } from '@/api/system'; @@ -579,6 +594,7 @@ const sourceOptions = computed(() => [ { label: t('knowledgeBase.sourceApi'), value: 'api' }, { label: t('knowledgeBase.sourceBrowserExtension'), value: 'browser_extension' }, { label: t('knowledgeBase.channelFeishu'), value: 'feishu' }, + { label: t('knowledgeBase.channelFeishuDrive'), value: 'feishu_drive' }, { label: t('knowledgeBase.channelNotion'), value: 'notion' }, { label: t('knowledgeBase.channelYuque'), value: 'yuque' }, { label: t('knowledgeBase.channelWechat'), value: 'wechat' }, @@ -591,6 +607,58 @@ const sourceOptions = computed(() => [ const updatedTimeRange = ref([]); // Disable any date after today so users cannot filter into the future. const disableFutureDate = { after: new Date(new Date().setHours(23, 59, 59, 999)) }; + +// ── Folder tree (documents uploaded as a folder keep their relative path) ── +const FOLDER_TREE_COLLAPSED_KEY = 'weknora.kbFolderTreeCollapsed'; +const readStoredFlag = (key: string, fallback = false) => { + try { + const raw = localStorage.getItem(key); + return raw === null ? fallback : raw === 'true'; + } catch { + return fallback; + } +}; +const writeStoredFlag = (key: string, value: boolean) => { + try { + localStorage.setItem(key, String(value)); + } catch { + // Private-mode storage failures must not break navigation. + } +}; +const folderTree = ref(null); +const folderTreeLoading = ref(false); +// The folder being browsed; ROOT_FOLDER_PATH ('') is the knowledge base top +// level, a real node of the tree rather than a separate mode. +const selectedFolderPath = ref(ROOT_FOLDER_PATH); +const folderTreeCollapsed = ref(readStoredFlag(FOLDER_TREE_COLLAPSED_KEY)); +const hasFolders = computed(() => (folderTree.value?.folders?.length ?? 0) > 0); +// The folder column only earns its space once the knowledge base actually has +// folders, so knowledge bases filled with single-file uploads look unchanged. +const showFolderTree = computed(() => !isFAQ.value && hasFolders.value); +// Browsing lists one folder's own contents; filtering searches its whole +// subtree. There is no mode switch: the list follows what the user is doing. +const isFiltering = computed(() => + isFilteringDocuments({ + keyword: docSearchKeyword.value, + tagIds: selectedTagIds.value, + fileType: selectedFileType.value, + parseStatus: selectedParseStatus.value, + source: selectedSource.value, + timeRange: updatedTimeRange.value, + }), +); +// Sub-folder entries shown at the top of the list while browsing. Search results +// are flat, so they are dropped as soon as a filter is active. When the sidebar +// tree is open it already lists the same folders, so skip the duplicate rows. +const currentChildFolders = computed(() => { + if (isFiltering.value) return []; + if (showFolderTree.value && !folderTreeCollapsed.value) return []; + return childFolders(folderTree.value, selectedFolderPath.value); +}); +// A row's folder is worth showing only when the list can span folders. +const showDocumentFolderPath = computed(() => hasFolders.value && isFiltering.value); +const folderBreadcrumbs = computed(() => buildFolderBreadcrumbs(selectedFolderPath.value)); + const filterParams = computed(() => { const [start, end] = updatedTimeRange.value || []; return { @@ -601,6 +669,10 @@ const filterParams = computed(() => { source: selectedSource.value || undefined, start_time: start ? `${start} 00:00:00` : undefined, end_time: end ? `${end} 23:59:59` : undefined, + folder_path: selectedFolderPath.value, + // Searching descends into sub-folders; browsing shows one level, with the + // sub-folders themselves rendered as entries in the list. + folder_recursive: isFiltering.value, }; }); const tagMap = computed>(() => { @@ -701,6 +773,103 @@ const loadKnowledgeFiles = (kbIdValue: string): Promise => { const isCurrentKb = (targetKbId: string) => targetKbId === kbId.value; +const loadFolderTree = async (kbIdValue: string) => { + if (!kbIdValue || isFAQ.value) { + folderTree.value = null; + return; + } + folderTreeLoading.value = true; + try { + const res: any = await listKnowledgeFolders(kbIdValue); + if (!isCurrentKb(kbIdValue)) return; + folderTree.value = (res?.data as KnowledgeFolderTree) || null; + // A folder can disappear (its last document was deleted or moved); fall + // back to the root instead of leaving an empty, unreachable view. + if (!folderExistsInTree(folderTree.value?.folders || [], selectedFolderPath.value)) { + selectedFolderPath.value = ROOT_FOLDER_PATH; + } + } catch (error) { + if (!isCurrentKb(kbIdValue)) return; + console.error('Failed to load knowledge folders', error); + folderTree.value = null; + } finally { + if (isCurrentKb(kbIdValue)) { + folderTreeLoading.value = false; + } + } +}; + +const handleFolderSelect = (path: string) => { + if (selectedFolderPath.value === path) return; + selectedFolderPath.value = path; +}; + +// ── Re-filing documents and renaming folders ── +// folder_path is display-only, so both operations are a plain column update: +// nothing is re-parsed, re-chunked or re-embedded. + +// Flat folder list shared by every "move to folder" picker. +const folderOptions = computed(() => { + const result: Array<{ path: string; name: string; depth: number }> = []; + const walk = (nodes: KnowledgeFolderTree['folders'], depth: number) => { + nodes.forEach((node) => { + result.push({ path: node.path, name: node.name, depth }); + walk(node.children || [], depth + 1); + }); + }; + walk(folderTree.value?.folders || [], 0); + return result; +}); + +const moveKnowledgeIntoFolder = async (ids: string[], folderPath: string) => { + if (!kbId.value || ids.length === 0) return; + try { + await moveKnowledgeToFolder(kbId.value, ids, folderPath); + MessagePlugin.success(t('knowledgeBase.moveToFolder.success', { count: ids.length })); + clearSelection(); + batchMode.value = false; + resetPage(); + await loadKnowledgeFiles(kbId.value); + await loadFolderTree(kbId.value); + } catch (error: any) { + MessagePlugin.error(error?.message || t('knowledgeBase.moveToFolder.failed')); + } +}; + +const handleFolderRename = async ({ from, to }: { from: string; to: string }) => { + if (!kbId.value || !to || from === to) return; + if (!canMoveFolderTo(from, to)) { + MessagePlugin.warning(t('knowledgeBase.folderTree.renameInvalid')); + return; + } + try { + const res: any = await renameKnowledgeFolder(kbId.value, from, to); + const movedCount = res?.data?.moved_count ?? 0; + if (movedCount === 0) { + MessagePlugin.warning(t('knowledgeBase.folderTree.renameFailed')); + await loadFolderTree(kbId.value); + return; + } + MessagePlugin.success(t('knowledgeBase.folderTree.renameSuccess')); + // Follow the folder to its new path so the user stays where they were. + if (selectedFolderPath.value === from) { + selectedFolderPath.value = to; + } else if (selectedFolderPath.value.startsWith(`${from}/`)) { + selectedFolderPath.value = to + selectedFolderPath.value.slice(from.length); + } + resetPage(); + await loadKnowledgeFiles(kbId.value); + await loadFolderTree(kbId.value); + } catch (error: any) { + MessagePlugin.error(error?.message || t('knowledgeBase.folderTree.renameFailed')); + } +}; + +const handleFolderTreeCollapsedChange = (value: boolean) => { + folderTreeCollapsed.value = value; + writeStoredFlag(FOLDER_TREE_COLLAPSED_KEY, value); +}; + const loadTags = async (kbIdValue: string, reset = false) => { if (!kbIdValue) { tagList.value = []; @@ -859,9 +1028,11 @@ const loadKnowledgeBaseInfo = async (targetKbId: string, force = false) => { uiStore.clearSelectedTagIds(); if (!isFAQ.value) { loadKnowledgeFiles(targetKbId); + void loadFolderTree(targetKbId); } else { cardList.value = []; total.value = 0; + folderTree.value = null; } loadTags(targetKbId, true); } catch (error) { @@ -936,6 +1107,8 @@ watch(() => kbId.value, (newKbId, oldKbId) => { tagSearchQuery.value = ''; tagPage.value = 1; uiStore.clearSelectedTagIds(); + folderTree.value = null; + selectedFolderPath.value = ROOT_FOLDER_PATH; } loadKnowledgeBaseInfo(newKbId); }, { immediate: true }); @@ -990,6 +1163,15 @@ watch([selectedParseStatus, selectedSource, updatedTimeRange], () => { } }, { deep: true }); +// 切换目录只改变列表范围,行为与其他筛选一致。浏览态与筛选态之间的切换由各筛选项 +// 自身的 watcher 触发刷新,这里不重复请求。 +watch(selectedFolderPath, () => { + if (!kbId.value || isFAQ.value) return; + clearSelection(); + resetPage(); + loadKnowledgeFiles(kbId.value); +}); + // 监听文件上传事件 const handleFileUploaded = (event: CustomEvent) => { const uploadedKbId = event.detail.kbId; @@ -1000,6 +1182,7 @@ const handleFileUploaded = (event: CustomEvent) => { resetPage(); // Reset page counter when reloading files after upload loadKnowledgeFiles(uploadedKbId); loadTags(uploadedKbId); + void loadFolderTree(uploadedKbId); // 启动几次探测,尽快让面包屑的"索引中"亮起。 scheduleWikiStatusProbes(); } @@ -1248,6 +1431,7 @@ const confirmDeleteKnowledge = (index: number, item: KnowledgeCard) => { await new Promise((r) => setTimeout(r, delayMs)); } loadTags(kbId.value, true); + void loadFolderTree(kbId.value); }); }; @@ -1309,6 +1493,7 @@ const handleMoveConfirm = async () => { moveSubmitting.value = false; resetPage(); // Reset page counter when reloading files after move loadKnowledgeFiles(kbId.value); + void loadFolderTree(kbId.value); } } catch (e: any) { MessagePlugin.error(e?.message || t('knowledgeBase.moveFailed')); @@ -1334,6 +1519,7 @@ const startMovePoll = (taskId: string) => { } resetPage(); // Reset page counter when reloading files after move completion loadKnowledgeFiles(kbId.value); + void loadFolderTree(kbId.value); } else if (data.status === 'failed') { stopMovePoll(); moveSubmitting.value = false; @@ -1356,6 +1542,7 @@ const manualEditorSuccess = ({ kbId: savedKbId }: { kbId: string; knowledgeId: s if (savedKbId === kbId.value && !isFAQ.value) { resetPage(); // Reset page counter when reloading files after manual edit loadKnowledgeFiles(savedKbId); + void loadFolderTree(savedKbId); } }; @@ -1399,14 +1586,8 @@ const AUDIO_EXTENSIONS = ['mp3', 'wav', 'm4a', 'flac', 'ogg']; const uploadConfirmStore = useUploadConfirmStore(); -const getFolderUploadFileName = (file: File) => { - const relativePath = (file as any).webkitRelativePath; - if (!relativePath) return undefined; - const pathParts = relativePath.split('/'); - if (pathParts.length <= 2) return undefined; - const subPath = pathParts.slice(1, -1).join('/'); - return `${subPath}/${file.name}`; -}; +const getFolderUploadFileName = (file: File, targetFolder: string) => + buildUploadFileName(file, targetFolder); const showUploadResultMessages = ( successCount: number, @@ -1443,7 +1624,12 @@ const showUploadResultMessages = ( const executeUploadBatch = async ( files: File[], - options: { processConfig?: KnowledgeProcessOverrides; tagIds?: string[] } = {}, + options: { + processConfig?: KnowledgeProcessOverrides; + tagIds?: string[]; + /** Destination folder confirmed in the upload dialog; '' is the root. */ + targetFolder?: string; + } = {}, ) => { const targetKbId = kbId.value; if (!targetKbId || files.length === 0) { @@ -1456,10 +1642,7 @@ const executeUploadBatch = async ( let successCount = 0; let failCount = 0; const totalCount = files.length; - const hasFolderPaths = files.some((file) => { - const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath; - return !!relativePath && relativePath.split('/').length > 2; - }); + const hasFolderPaths = files.some(isFolderUpload); for (const file of files) { try { @@ -1470,7 +1653,7 @@ const executeUploadBatch = async ( process_config?: KnowledgeProcessOverrides } = { file, tag_ids: tagIdsToUpload }; - const fileName = getFolderUploadFileName(file); + const fileName = getFolderUploadFileName(file, options.targetFolder || ROOT_FOLDER_PATH); if (fileName) uploadData.fileName = fileName; if (options.processConfig) { uploadData.process_config = options.processConfig; @@ -1573,14 +1756,15 @@ const handleUploadConfirmResult = async (result: UploadConfirmResult) => { const tagIds = result.tagIds || []; if (files.length > 0) { - const hasFolderPaths = files.some((file) => { - const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath; - return !!relativePath && relativePath.split('/').length > 2; - }); + const hasFolderPaths = files.some(isFolderUpload); if (hasFolderPaths) { MessagePlugin.info(t('knowledgeBase.uploadingFolder', { total: files.length })); } - await executeUploadBatch(files, { processConfig, tagIds }); + await executeUploadBatch(files, { + processConfig, + tagIds, + targetFolder: result.targetFolder || ROOT_FOLDER_PATH, + }); } for (const url of urls) { @@ -1600,6 +1784,10 @@ const openUploadConfirmDialog = async (files: File[], urls: string[] = []) => { urls, acceptFileTypes: acceptFileTypes.value, supportedFileTypes: [...supportedFileTypes.value], + // Pre-fill the destination with the folder being browsed; the dialog shows + // it and lets the user pick another folder (or the root) before confirming. + targetFolder: selectedFolderPath.value, + folderOptions: folderOptions.value, }); await handleUploadConfirmResult(result); } catch { @@ -1906,6 +2094,7 @@ const confirmBatchDelete = async () => { await new Promise((r) => setTimeout(r, delayMs)); } loadTags(kbId.value, true); + void loadFolderTree(kbId.value); } else { MessagePlugin.error(res?.message || t('knowledgeBase.batchDeleteFailed')); } @@ -1958,7 +2147,7 @@ const confirmCancelParseKnowledge = async (item: KnowledgeCard) => { // Bridge card-view actions back to existing per-card handlers. const handleCardAction = ( - action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'delete' | 'view-trace' | 'batch-manage', + action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'move-folder' | 'delete' | 'view-trace' | 'batch-manage', item: KnowledgeCard, ) => { const idx = (cardList.value || []).findIndex((i: KnowledgeCard) => i.id === item.id); @@ -1976,7 +2165,7 @@ const handleCardAction = ( // Bridge list-view actions back to existing per-card handlers. const handleListAction = ( - action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'delete' | 'view-trace' | 'batch-manage', + action: 'edit' | 'reparse' | 'cancel-parse' | 'move' | 'move-folder' | 'delete' | 'view-trace' | 'batch-manage', item: KnowledgeCard, ) => { const idx = (cardList.value || []).findIndex((i: KnowledgeCard) => i.id === item.id); @@ -2149,8 +2338,42 @@ async function createNewSession(value: string): Promise {