From cd1d6af08b6a9f5bbe93538d971c767f74cb70c3 Mon Sep 17 00:00:00 2001 From: aries_ckt <916701291@qq.com> Date: Sat, 4 Jul 2026 23:48:12 +0800 Subject: [PATCH] feat: knowledge base git-repo UI, kb tool rendering and codegraph step styling - Add knowledge base git-repo sync form, file tree, search-tools panel and knowledge detail page - Render kb_ls/kb_cat/kb_grep tool outputs as structured viewers (file list, code viewer with line numbers, grep grouping) in ManusRightPanel instead of raw terminal text - Add 'kb' StepType with teal folder icon for codegraph/kb tools in ManusLeftPanel and ManusRightPanel; render kb tool action detail as a friendly parameter card instead of raw 'Action: ...' text - Add zh/en translations for step_type_kb and kb tool labels - Update knowledge API client, request layer, space form, doc panel, hooks and connector modals to support git-repo spaces and search tools Co-Authored-By: Claude --- web/client/api/knowledge/index.ts | 130 +++- web/client/api/request.ts | 54 +- web/components/knowledge/doc-panel.tsx | 305 ++++---- .../knowledge/git-repo-sync-form.tsx | 116 +++ web/components/knowledge/knowledge-tree.tsx | 233 ++++++ .../knowledge/search-tools-panel.tsx | 702 ++++++++++++++++++ web/components/knowledge/space-form.tsx | 633 ++++++++++++++-- web/hooks/use-scheduled-task.ts | 57 +- web/hooks/use-summary.ts | 2 +- web/locales/en/chat.ts | 1 + web/locales/en/common.ts | 62 ++ web/locales/zh/chat.ts | 1 + web/locales/zh/common.ts | 62 ++ web/new-components/chat/ChatPage.tsx | 6 +- .../chat/content/ManusLeftPanel.tsx | 35 +- .../chat/content/ManusRightPanel.tsx | 299 +++++++- .../chat/content/ManusStepCard.tsx | 4 +- .../chat/content/QuestionDock.tsx | 52 +- .../connector/ConnectorToolsModal.tsx | 35 +- .../connector/useConfirmPolling.ts | 31 +- web/next.config.js | 10 +- web/package.json | 2 +- web/pages/construct/knowledge/detail.tsx | 418 +++++++++++ web/pages/construct/knowledge/index.tsx | 88 ++- web/pages/index.tsx | 17 +- web/types/knowledge.ts | 41 + 26 files changed, 2994 insertions(+), 402 deletions(-) create mode 100644 web/components/knowledge/git-repo-sync-form.tsx create mode 100644 web/components/knowledge/knowledge-tree.tsx create mode 100644 web/components/knowledge/search-tools-panel.tsx create mode 100644 web/pages/construct/knowledge/detail.tsx diff --git a/web/client/api/knowledge/index.ts b/web/client/api/knowledge/index.ts index ef515cb04..b8d752f7c 100644 --- a/web/client/api/knowledge/index.ts +++ b/web/client/api/knowledge/index.ts @@ -1,4 +1,11 @@ -import { AddYuqueProps, RecallTestChunk, RecallTestProps, SearchDocumentParams } from '@/types/knowledge'; +import { + AddYuqueProps, + KbLsJsonResponse, + KnowledgeSpaceStats, + RecallTestChunk, + RecallTestProps, + SearchDocumentParams, +} from '@/types/knowledge'; import { GET, POST } from '../index'; /** @@ -59,3 +66,124 @@ export const searchChunk = (data: { document_id: string; content: string }, name export const chunkAddQuestion = (data: { chunk_id: string; questions: string[] }) => { return POST<{ chunk_id: string; questions: string[] }, string[]>(`/knowledge/questions/chunk/edit`, data); }; + +// ============ Git 仓库同步 API (v2) ============ + +const KB_V2_PREFIX = '/api/v2/serve/knowledge'; + +export interface GitRepoSyncParams { + repo_url: string; + branch: string; + exclude_dirs?: string[]; + exclude_extensions?: string[]; + include_dirs?: string[]; + build_graph?: boolean; + chunk_strategy?: string; +} + +export interface GitRepoSyncResult { + status: string; + head_commit?: string; + total_files?: number; + indexed?: number; + skipped?: number; + failed?: number; + added?: number; + modified?: number; + deleted?: number; +} + +export interface GitRepoSyncStatus { + status: string; + total_files: number; + finished: number; + running: number; + failed: number; + todo: number; + last_sync_commit?: string | null; + last_sync_time?: string | null; + last_sync_mode?: string | null; + repo_url?: string | null; + branch?: string | null; +} + +/** 同步 Git 仓库到知识空间(服务端 clone 模式) */ +export const syncGitRepo = (spaceId: string | number, data: GitRepoSyncParams) => { + return POST(`${KB_V2_PREFIX}/${spaceId}/git/sync`, data); +}; + +/** 增量同步 Git 仓库 */ +export const incrementalSyncGitRepo = ( + spaceId: string | number, + data: { repo_url: string; branch: string; last_commit?: string }, +) => { + return POST<{ repo_url: string; branch: string; last_commit?: string }, GitRepoSyncResult>( + `${KB_V2_PREFIX}/${spaceId}/git/incremental-sync`, + data, + ); +}; + +/** 查询 Git 仓库同步状态 */ +export const getGitSyncStatus = (spaceId: string | number) => { + return GET(`${KB_V2_PREFIX}/${spaceId}/git/sync-status`); +}; + +// ============ 搜索工具 API (v2) ============ + +export interface KbSearchParams { + knowledge_id?: string; + query?: string; + path?: string; + file_pattern?: string; + start_line?: number; + end_line?: number; + offset?: number; + limit?: number; + top_k?: number; + score_threshold?: number; +} + +/** kb_ls - 列出知识库目录 */ +export const kbLs = (spaceId: string | number, data: KbSearchParams) => { + return POST(`${KB_V2_PREFIX}/${spaceId}/tools/ls`, data); +}; + +/** kb_glob - 按文件名搜索 */ +export const kbGlob = (spaceId: string | number, data: KbSearchParams) => { + return POST(`${KB_V2_PREFIX}/${spaceId}/tools/glob`, data); +}; + +/** kb_grep - 按内容关键词搜索 */ +export const kbGrep = (spaceId: string | number, data: KbSearchParams) => { + return POST(`${KB_V2_PREFIX}/${spaceId}/tools/grep`, data); +}; + +/** kb_cat - 读取文件内容 */ +export const kbCat = (spaceId: string | number, data: KbSearchParams) => { + return POST(`${KB_V2_PREFIX}/${spaceId}/tools/cat`, data); +}; + +/** kb_semantic_search - 语义搜索 */ +export const kbSemanticSearch = (spaceId: string | number, data: KbSearchParams) => { + return POST(`${KB_V2_PREFIX}/${spaceId}/tools/semantic_search`, data); +}; + +// ============ 知识空间统计 API (v2) ============ + +/** 获取知识空间聚合统计信息 */ +export const getKnowledgeSpaceStats = (spaceId: string | number) => { + return GET(`${KB_V2_PREFIX}/${spaceId}/stats`); +}; + +// ============ 结构化目录列表 API (v2) ============ + +/** 获取知识空间结构化目录列表(JSON格式) */ +export const kbLsJson = ( + spaceId: string | number, + data?: { path?: string; offset?: number; limit?: number }, +) => { + return POST<{ path?: string; offset?: number; limit?: number }, KbLsJsonResponse>( + `${KB_V2_PREFIX}/${spaceId}/tools/ls-json`, + data ?? {}, + ); +}; diff --git a/web/client/api/request.ts b/web/client/api/request.ts index ef30aaf79..a7fd32760 100644 --- a/web/client/api/request.ts +++ b/web/client/api/request.ts @@ -199,59 +199,77 @@ export const getEditorSql = (id: string, round: string | number) => { /** knowledge */ export const getArguments = (knowledgeName: string) => { - return POST(`/knowledge/${knowledgeName}/arguments`, {}); + return POST(`/api/v1/knowledge/${knowledgeName}/arguments`, {}); }; export const saveArguments = (knowledgeName: string, data: ArgumentsParams) => { - return POST(`/knowledge/${knowledgeName}/argument/save`, data); + return POST(`/api/v1/knowledge/${knowledgeName}/argument/save`, data); }; export const getRetrieveStrategyList = () => { - return POST>(`/knowledge/retrieve_strategy_list`, {}); + return POST>(`/api/v1/knowledge/retrieve_strategy_list`, {}); }; export const getSpaceList = (data?: any) => { - return POST>('/knowledge/space/list', data ?? {}); + return POST>('/api/v1/knowledge/space/list', data ?? {}); }; export const getDocumentList = (spaceName: string, data: Record>) => { - return POST>, IDocumentResponse>(`/knowledge/${spaceName}/document/list`, data); + return POST>, IDocumentResponse>( + `/api/v1/knowledge/${spaceName}/document/list`, + data, + ); }; export const getGraphVis = (spaceName: string, data: { limit: number }) => { - return POST, GraphVisResult>(`/knowledge/${spaceName}/graphvis`, data); + return POST, GraphVisResult>(`/api/v1/knowledge/${spaceName}/graphvis`, data); +}; + +export const getCodeGraphVisualizeHtml = (spaceName: string) => { + // This endpoint returns raw HTML, not JSON - use axios directly + return fetch(`/api/v1/knowledge/${encodeURIComponent(spaceName)}/codegraph/visualize`, { + method: 'GET', + }).then(async response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.text(); + }); }; export const addDocument = (knowledgeName: string, data: DocumentParams) => { - return POST(`/knowledge/${knowledgeName}/document/add`, data); + return POST(`/api/v1/knowledge/${knowledgeName}/document/add`, data); }; export const addSpace = (data: AddKnowledgeParams) => { - return POST(`/knowledge/space/add`, data); + return POST(`/api/v1/knowledge/space/add`, data); }; export const getChunkStrategies = () => { - return GET>('/knowledge/document/chunkstrategies'); + return GET>('/api/v1/knowledge/document/chunkstrategies'); }; export const syncDocument = (spaceName: string, data: Record>) => { - return POST>, string | null>(`/knowledge/${spaceName}/document/sync`, data); + return POST>, string | null>(`/api/v1/knowledge/${spaceName}/document/sync`, data); }; export const syncBatchDocument = (spaceName: string, data: Array) => { - return POST, ISyncBatchResponse>(`/knowledge/${spaceName}/document/sync_batch`, data); + return POST, ISyncBatchResponse>( + `/api/v1/knowledge/${spaceName}/document/sync_batch`, + data, + ); }; export const uploadDocument = (knowLedgeName: string, data: FormData) => { - return POST(`/knowledge/${knowLedgeName}/document/upload`, data); + return POST(`/api/v1/knowledge/${knowLedgeName}/document/upload`, data); }; export const getChunkList = (spaceName: string, data: ChunkListParams) => { - return POST(`/knowledge/${spaceName}/chunk/list`, data); + return POST(`/api/v1/knowledge/${spaceName}/chunk/list`, data); }; export const delDocument = (spaceName: string, data: Record) => { - return POST, null>(`/knowledge/${spaceName}/document/delete`, data); + return POST, null>(`/api/v1/knowledge/${spaceName}/document/delete`, data); }; export const delSpace = (data: Record) => { - return POST, null>(`/knowledge/space/delete`, data); + return POST, null>(`/api/v1/knowledge/space/delete`, data); }; /** models */ @@ -418,10 +436,10 @@ export const modelSearch = (data: Record) => { }; export const getKnowledgeAdmins = (spaceId: string) => { - return GET>(`/knowledge/users/list?space_id=${spaceId}`); + return GET>(`/api/v1/knowledge/users/list?space_id=${spaceId}`); }; export const updateKnowledgeAdmins = (data: Record) => { - return POST, any[]>(`/knowledge/users/update`, data); + return POST, any[]>(`/api/v1/knowledge/users/update`, data); }; /** AWEL Flow */ @@ -432,5 +450,5 @@ export const delApp = (data: Record) => { }; export const getSpaceConfig = () => { - return GET(`/knowledge/space/config`); + return GET(`/api/v1/knowledge/space/config`); }; diff --git a/web/components/knowledge/doc-panel.tsx b/web/components/knowledge/doc-panel.tsx index 3c52426ae..ca11f5b39 100644 --- a/web/components/knowledge/doc-panel.tsx +++ b/web/components/knowledge/doc-panel.tsx @@ -24,7 +24,7 @@ import { WarningOutlined, } from '@ant-design/icons'; import { useRequest } from 'ahooks'; -import { Button, Card, Divider, Dropdown, Empty, Form, Input, Modal, Space, Spin, Tag, Tooltip, message } from 'antd'; +import { Button, Divider, Dropdown, Empty, Form, Input, Modal, Space, Spin, Tag, Tooltip, message } from 'antd'; import cls from 'classnames'; import moment from 'moment'; import { useRouter } from 'next/router'; @@ -33,6 +33,7 @@ import { useTranslation } from 'react-i18next'; import RecallTestModal from './RecallTestModal'; import ArgumentsModal from './arguments-modal'; import DocIcon from './doc-icon'; +import SearchToolsPanel from './search-tools-panel'; interface IProps { space: ISpace; @@ -89,6 +90,9 @@ export default function DocPanel(props: IProps) { // 召回测试弹窗 const [recallTestOpen, setRecallTestOpen] = useState(false); + // 搜索工具面板 + const [searchToolsOpen, setSearchToolsOpen] = useState(false); + const currentPageRef = useRef(1); const hasMore = useMemo(() => { @@ -264,158 +268,151 @@ export default function DocPanel(props: IProps) { const renderDocumentCard = () => { return (
-
- {/*
管理员(工号,去前缀0):
*/} -
- {/* } + placeholder={t('please_enter_the_keywords')} + onChange={async e => { + await search(space.id, e.target.value); + }} + allowClear + /> +
-
- {documents?.length > 0 ? ( - <> -
- } - placeholder={t('please_enter_the_keywords')} - onChange={async e => { - await search(space.id, e.target.value); - }} - allowClear - /> -
- - <> - {searchDocuments.length > 0 ? ( -
- {searchDocuments.map((document: IDocument) => { - return ( - -
- - {document.doc_name} -
- - } - extra={ - { - router.push( - `/construct/knowledge/chunk/?spaceName=${space.name}&id=${document.id}`, - ); - }} - > - - {t('detail')} - - ), - }, - { - key: `${t('Sync')}`, - label: , - }, - { - key: 'edit', - label: ( - { - setEditOpen(true); - setCurDoc(document); - }} - > - - {t('Edit')} - - ), - }, - { - key: 'del', - label: ( - { - showDeleteConfirm(document); - }} - > - - {t('Delete')} - - ), - }, - ], - }} - getPopupContainer={node => node.parentNode as HTMLElement} - placement='bottomRight' - autoAdjustOverflow={false} - className='rounded-md' - > - - - } - > -

{t('Size')}:

-

{document.chunk_size} chunks

-

{t('Last_Sync')}:

-

{moment(document.last_sync).format('YYYY-MM-DD HH:MM:SS')}

-

{renderResultTag(document.status, document.result)}

-
- ); - })} + {documents?.length > 0 ? ( + + {searchDocuments.length > 0 ? ( +
+ {/* Table header */} +
+
{t('Document_name')}
+
{t('Size')}
+
{t('Status')}
+
{t('Last_Sync')}
+
{t('scheduled.col.actions')}
+
+ {/* Table rows */} +
+ {searchDocuments.map((document: IDocument) => ( +
{ + router.push(`/construct/knowledge/chunk/?spaceName=${space.name}&id=${document.id}`); + }} + > + {/* Name */} +
+ + + {document.doc_name} + +
+ {/* Chunks */} +
+ {document.chunk_size} chunks +
+ {/* Status */} +
{renderResultTag(document.status, document.result)}
+ {/* Last Sync */} +
+ {document.last_sync ? moment(document.last_sync).format('YYYY-MM-DD HH:mm') : '-'} +
+ {/* Actions */} +
e.stopPropagation()}> + + + {t('detail')} + + ), + onClick: () => { + router.push(`/construct/knowledge/chunk/?spaceName=${space.name}&id=${document.id}`); + }, + }, + { + key: 'sync', + label: , + }, + { + key: 'edit', + label: ( + + + {t('Edit')} + + ), + onClick: () => { + setEditOpen(true); + setCurDoc(document); + }, + }, + { + key: 'delete', + danger: true, + label: ( + + + {t('Delete')} + + ), + onClick: () => { + showDeleteConfirm(document); + }, + }, + ], + }} + getPopupContainer={node => node.parentNode as HTMLElement} + placement='bottomRight' + autoAdjustOverflow={false} + > +
- ) : ( - - )} - + ))} +
{hasMore && ( - - +
+ {t('Load_more')} - +
)} - - - ) : ( - - - - )} -
+
+ ) : ( + + )} +
+ ) : ( + + + + )}
); }; @@ -462,6 +459,9 @@ export default function DocPanel(props: IProps) { + {renderDocumentCard()} @@ -541,6 +541,17 @@ export default function DocPanel(props: IProps) { {/* 召回测试弹窗 */} + {/* 搜索工具面板 */} + setSearchToolsOpen(false)} + footer={null} + width={'80%'} + destroyOnClose={true} + > + +
); } diff --git a/web/components/knowledge/git-repo-sync-form.tsx b/web/components/knowledge/git-repo-sync-form.tsx new file mode 100644 index 000000000..351c8000c --- /dev/null +++ b/web/components/knowledge/git-repo-sync-form.tsx @@ -0,0 +1,116 @@ +import { apiInterceptors, syncGitRepo } from '@/client/api'; +import { Button, Form, Input, Switch, message } from 'antd'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type FieldType = { + repo_url: string; + branch: string; + exclude_dirs: string; + include_dirs: string; + build_graph: boolean; +}; + +type IProps = { + spaceName: string; + onSuccess?: () => void; +}; + +/** + * Git 仓库同步表单 + * 在创建 git_repo 类型的知识空间后,配置 Git 仓库并触发同步 + */ +export default function GitRepoSyncForm(props: IProps) { + const { spaceName, onSuccess } = props; + const { t } = useTranslation(); + const [spinning, setSpinning] = useState(false); + const [form] = Form.useForm(); + + const handleFinish = async (fieldsValue: FieldType) => { + const { repo_url, branch, exclude_dirs, include_dirs, build_graph } = fieldsValue; + setSpinning(true); + const [err, data] = await apiInterceptors( + syncGitRepo(spaceName, { + repo_url, + branch: branch || 'main', + exclude_dirs: exclude_dirs + ? exclude_dirs + .split(',') + .map(s => s.trim()) + .filter(Boolean) + : [], + include_dirs: include_dirs + ? include_dirs + .split(',') + .map(s => s.trim()) + .filter(Boolean) + : [], + build_graph: build_graph ?? false, + chunk_strategy: 'CHUNK_BY_MARKDOWN_HEADER', + }), + ); + setSpinning(false); + if (err) { + message.error(t('sync_failed') + ': ' + (err as Error).message); + return; + } + message.success( + `${t('sync_completed')}: ${data?.indexed ?? 0} ${t('files_indexed')}, ${data?.skipped ?? 0} ${t('skipped')}`, + ); + onSuccess?.(); + }; + + return ( +
+
+
+ + + +
+
+ Git Repository +

{t('ds_git_repo_desc')}

+
+
+
+ + label={t('Repository_URL')} + name='repo_url' + rules={[{ required: true, message: t('Please_input_the_repo_url') }]} + > + + +
+ label={t('Branch')} name='branch'> + + + label={t('Build_CodeGraph')} name='build_graph' valuePropName='checked'> + + +
+
+ label={t('Exclude_Dirs')} name='exclude_dirs'> + + + label={t('Include_Dirs')} name='include_dirs'> + + +
+ + + + +
+ ); +} diff --git a/web/components/knowledge/knowledge-tree.tsx b/web/components/knowledge/knowledge-tree.tsx new file mode 100644 index 000000000..0e22038a1 --- /dev/null +++ b/web/components/knowledge/knowledge-tree.tsx @@ -0,0 +1,233 @@ +import { apiInterceptors, getDocumentList, kbLsJson } from '@/client/api'; +import { IDocument, ISpace, KbFileEntry } from '@/types/knowledge'; +import { FileOutlined, FileTextOutlined, FolderOpenOutlined, FolderOutlined, LoadingOutlined } from '@ant-design/icons'; +import { Tree } from 'antd'; +import type { DataNode } from 'antd/es/tree'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface IProps { + currentSpaceName: string; + currentSpace?: ISpace | null; + onSelectDocument: (doc: IDocument | null) => void; + onSelectSpace: (space: ISpace) => void; +} + +type TreeNodeData = DataNode & { + path?: string; + isDir?: boolean; + docId?: number; + fileData?: KbFileEntry; +}; + +/** + * Knowledge Tree sidebar. + * Shows only the current space's file directory tree. + * Directories are lazy-loaded when expanded. + * First level is auto-expanded on load. + */ +export default function KnowledgeTree({ currentSpaceName, onSelectDocument }: IProps) { + const { t } = useTranslation(); + const [treeData, setTreeData] = useState([]); + const [loadedKeys, setLoadedKeys] = useState>(new Set()); + const [loadingKeys, setLoadingKeys] = useState>(new Set()); + const [selectedKeys, setSelectedKeys] = useState([]); + const [expandedKeys, setExpandedKeys] = useState([]); + const [isFileTree, setIsFileTree] = useState(true); // whether space has file_path metadata + + // Root node key for the space + const rootKey = `space-${currentSpaceName}`; + + // Load root-level entries on mount + useEffect(() => { + if (!currentSpaceName) return; + (async () => { + // Try the structured file tree first (kbLsJson) + const [, data] = await apiInterceptors(kbLsJson(currentSpaceName, { path: '', limit: 500 })); + + if (data && data.entries && data.entries.length > 0) { + // Space has file_path metadata — build file tree + setIsFileTree(true); + const childNodes: TreeNodeData[] = data.entries.map((entry: KbFileEntry) => ({ + key: entry.is_dir ? `dir-${entry.path}` : `file-${entry.path}`, + title: entry.is_dir ? ( + + {entry.name} ({entry.child_count}) + + ) : ( + + {entry.name} {entry.language && {entry.language}} + + ), + icon: entry.is_dir ? : , + isLeaf: !entry.is_dir, + isDir: entry.is_dir, + path: entry.path, + docId: entry.doc_id, + fileData: entry, + })); + + setTreeData([ + { + key: rootKey, + title: currentSpaceName, + icon: , + isLeaf: false, + children: childNodes, + }, + ]); + // Auto-expand the root node to show first level + setExpandedKeys([rootKey]); + setLoadedKeys(new Set([rootKey])); + } else { + // Fallback: no file_path metadata — show document list from getDocumentList + setIsFileTree(false); + const [, docData] = await apiInterceptors(getDocumentList(currentSpaceName, { page: 1, page_size: 200 })); + const docNodes: TreeNodeData[] = (docData?.data || []).map((doc: IDocument) => ({ + key: `doc-${doc.id}`, + title: doc.doc_name, + icon: , + isLeaf: true, + isDir: false, + docId: doc.id, + })); + + setTreeData([ + { + key: rootKey, + title: currentSpaceName, + icon: , + isLeaf: false, + children: docNodes, + }, + ]); + setExpandedKeys([rootKey]); + setLoadedKeys(new Set([rootKey])); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentSpaceName]); + + // Lazy-load directory contents on expand + const handleExpand = async (keys: React.Key[], info: any) => { + setExpandedKeys(keys as string[]); + const expandedNode = info.node as TreeNodeData; + const nodeKey = expandedNode.key as string; + + // Only load if not already loaded and it's a directory node + if (loadedKeys.has(nodeKey) || loadingKeys.has(nodeKey) || !expandedNode.isDir) { + return; + } + + setLoadingKeys(prev => new Set(prev).add(nodeKey)); + + const dirPath = expandedNode.path || ''; + const [, data] = await apiInterceptors(kbLsJson(currentSpaceName, { path: dirPath, limit: 500 })); + + const childNodes: TreeNodeData[] = (data?.entries || []).map((entry: KbFileEntry) => ({ + key: entry.is_dir ? `dir-${entry.path}` : `file-${entry.path}`, + title: entry.is_dir ? ( + + {entry.name} ({entry.child_count}) + + ) : ( + + {entry.name} {entry.language && {entry.language}} + + ), + icon: entry.is_dir ? : , + isLeaf: !entry.is_dir, + isDir: entry.is_dir, + path: entry.path, + docId: entry.doc_id, + fileData: entry, + })); + + setTreeData(prev => updateTreeChildren(prev, nodeKey, childNodes)); + setLoadedKeys(prev => new Set(prev).add(nodeKey)); + setLoadingKeys(prev => { + const next = new Set(prev); + next.delete(nodeKey); + return next; + }); + }; + + const handleSelect = (keys: React.Key[], info: any) => { + setSelectedKeys(keys as string[]); + const node = info.node as TreeNodeData; + + if (!isFileTree) { + // Document list mode — construct IDocument from node + if (node.docId) { + onSelectDocument({ + id: node.docId, + doc_name: (node.title as string) || '', + doc_type: '', + content: '', + chunk_size: 0, + gmt_created: '', + gmt_modified: '', + last_sync: '', + result: '', + space: currentSpaceName, + status: '', + vector_ids: '', + }); + } + return; + } + + // File tree mode — when a file is clicked, construct a minimal IDocument + if (!node.isDir && node.docId) { + const fileName = node.fileData?.name || (node.title as string) || ''; + onSelectDocument({ + id: node.docId, + doc_name: fileName, + doc_type: node.fileData?.file_type || '', + content: '', + chunk_size: 0, + gmt_created: '', + gmt_modified: '', + last_sync: '', + result: '', + space: currentSpaceName, + status: '', + vector_ids: '', + }); + } + }; + + return ( +
+
+ {t('Knowledge_Space')} +
+
+ } + /> +
+
+ ); +} + +/** Recursively update children of a specific node in the tree. */ +function updateTreeChildren(tree: TreeNodeData[], targetKey: string, newChildren: TreeNodeData[]): TreeNodeData[] { + return tree.map(node => { + if (node.key === targetKey) { + return { ...node, children: newChildren }; + } + if (node.children) { + return { ...node, children: updateTreeChildren(node.children, targetKey, newChildren) }; + } + return node; + }); +} diff --git a/web/components/knowledge/search-tools-panel.tsx b/web/components/knowledge/search-tools-panel.tsx new file mode 100644 index 000000000..12b3de8e4 --- /dev/null +++ b/web/components/knowledge/search-tools-panel.tsx @@ -0,0 +1,702 @@ +import { apiInterceptors, kbCat, kbGlob, kbGrep, kbLsJson, kbSemanticSearch } from '@/client/api'; +import { ISpace, KbFileEntry } from '@/types/knowledge'; +import { + CodeOutlined, + CopyOutlined, + FileSearchOutlined, + FolderOpenOutlined, + InfoCircleOutlined, + ReadOutlined, + SearchOutlined, + SendOutlined, +} from '@ant-design/icons'; +import { Button, Input, InputNumber, Spin, Tooltip, message } from 'antd'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type ToolType = 'ls' | 'glob' | 'grep' | 'cat' | 'semantic'; + +type IProps = { + space: ISpace; +}; + +interface ToolConfig { + key: ToolType; + icon: React.ReactNode; + color: string; + bgColor: string; + descKey: string; + showQuery: boolean; + showPath: boolean; + showFilePattern: boolean; + showLineRange: boolean; + showTopK: boolean; + showLimit: boolean; +} + +const TOOLS: ToolConfig[] = [ + { + key: 'grep', + icon: , + color: '#FA8C16', + bgColor: '#FFF7E6', + descKey: 'kb_grep_desc', + showQuery: true, + showPath: true, + showFilePattern: true, + showLineRange: false, + showTopK: false, + showLimit: true, + }, + { + key: 'semantic', + icon: , + color: '#52C41A', + bgColor: '#F6FFED', + descKey: 'kb_semantic_desc', + showQuery: true, + showPath: false, + showFilePattern: false, + showLineRange: false, + showTopK: true, + showLimit: false, + }, + { + key: 'ls', + icon: , + color: '#1677FF', + bgColor: '#E6F4FF', + descKey: 'kb_ls_desc', + showQuery: false, + showPath: true, + showFilePattern: false, + showLineRange: false, + showTopK: false, + showLimit: true, + }, + { + key: 'glob', + icon: , + color: '#722ED1', + bgColor: '#F9F0FF', + descKey: 'kb_glob_desc', + showQuery: true, + showPath: false, + showFilePattern: false, + showLineRange: false, + showTopK: false, + showLimit: true, + }, + { + key: 'cat', + icon: , + color: '#13C2C2', + bgColor: '#E6FFFB', + descKey: 'kb_cat_desc', + showQuery: false, + showPath: true, + showFilePattern: false, + showLineRange: true, + showTopK: false, + showLimit: false, + }, +]; + +const TOOL_MAP = Object.fromEntries(TOOLS.map(t => [t.key, t])); + +/** + * Search Tools Panel — redesigned with card-style tool selector and rich result display. + * ls/glob file entries are clickable and will auto-switch to cat mode. + */ +export default function SearchToolsPanel(props: IProps) { + const { space } = props; + const { t: tt } = useTranslation(); + const [tool, setTool] = useState('grep'); + const [query, setQuery] = useState(''); + const [path, setPath] = useState(''); + const [filePattern, setFilePattern] = useState(''); + const [startLine, setStartLine] = useState(1); + const [endLine, setEndLine] = useState(0); + const [topK, setTopK] = useState(5); + const [scoreThreshold, setScoreThreshold] = useState(0); + const [limit, setLimit] = useState(20); + const [result, setResult] = useState(''); + const [lsEntries, setLsEntries] = useState(null); + const [lsPath, setLsPath] = useState(''); + const [loading, setLoading] = useState(false); + + const cfg = TOOL_MAP[tool]; + + /** Switch to cat tool and read a specific file */ + const openFile = useCallback( + (filePath: string) => { + setTool('cat'); + setPath(filePath); + setStartLine(1); + setEndLine(0); + // Trigger the cat search immediately + doCatSearch(filePath); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [space.id], + ); + + /** Execute cat search with a given path (used by openFile) */ + const doCatSearch = async (filePath: string) => { + setLoading(true); + setResult(''); + setLsEntries(null); + const spaceId = space.id; + try { + const [err, data] = await apiInterceptors(kbCat(spaceId, { path: filePath, start_line: 1, end_line: 0 })); + if (err) { + message.error((err as Error).message); + setResult(`Error: ${(err as Error).message}`); + } else { + setResult(data || tt('No_Results')); + } + } catch (e: any) { + message.error(e.message); + setResult(`Error: ${e.message}`); + } finally { + setLoading(false); + } + }; + + const handleSearch = async () => { + setLoading(true); + setResult(''); + setLsEntries(null); + const spaceId = space.id; + let err: any; + let data: any; + try { + if (tool === 'ls') { + [err, data] = await apiInterceptors(kbLsJson(spaceId, { path, limit })); + if (!err && data) { + setLsEntries(data.entries || []); + setLsPath(data.path || path); + } + } else if (tool === 'glob') { + [err, data] = await apiInterceptors(kbGlob(spaceId, { query, limit })); + } else if (tool === 'grep') { + [err, data] = await apiInterceptors(kbGrep(spaceId, { query, path, file_pattern: filePattern, limit })); + } else if (tool === 'cat') { + [err, data] = await apiInterceptors(kbCat(spaceId, { path, start_line: startLine, end_line: endLine })); + } else if (tool === 'semantic') { + [err, data] = await apiInterceptors( + kbSemanticSearch(spaceId, { query, top_k: topK, score_threshold: scoreThreshold }), + ); + } + if (err) { + message.error((err as Error).message); + setResult(`Error: ${(err as Error).message}`); + } else { + setResult(data || tt('No_Results')); + } + } catch (e: any) { + message.error(e.message); + setResult(`Error: ${e.message}`); + } finally { + setLoading(false); + } + }; + + /** Parse the plain-text result into structured lines for rendering */ + const renderResult = () => { + if (!result) { + return ( +
+ +

{tt('Search_Tools_Empty')}

+
+ ); + } + + // For ls results — render from structured JSON entries + if (tool === 'ls') { + const entries = lsEntries; + if (!entries || entries.length === 0) { + return ( +
+ +

{tt('No_Results')}

+
+ ); + } + // Sort: directories first, then files, alphabetically + const sorted = [...entries].sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return ( +
+ {lsPath && ( +
+ + {lsPath}/ +
+ )} + {sorted.map((entry, i) => { + if (entry.is_dir) { + return ( +
{ + setPath(entry.path); + setTool('ls'); + // Trigger ls search on the new directory + setTimeout(() => handleSearch(), 0); + }} + > + + {entry.name}/ + {entry.child_count != null && ( + {entry.child_count} items + )} +
+ ); + } + return ( +
openFile(entry.path)} + title={tt('kb_cat_desc' as any) || `Read ${entry.name}`} + > + + + {entry.name} + + {entry.language && ( + + {entry.language} + + )} +
+ ); + })} +
+ ); + } + + // For glob results — render as a file listing with icons; files are clickable + if (tool === 'glob') { + const lines = result.split('\n').filter(Boolean); + return ( +
+ {lines.map((line, i) => { + const isDir = line.trim().endsWith('/'); + const isHeader = line.startsWith('Directory:') || line.startsWith('Matching'); + if (isHeader) { + return ( +
+ {line.trim()} +
+ ); + } + if (isDir) { + const parts = line.trim().split('\t'); + const dirName = parts[0]?.replace(/\/$/, '') || ''; + const dirPath = dirName; // For ls, the directory name IS the path for sub-navigation + return ( +
{ + // Navigate into directory: set path and re-run ls + setPath(dirPath); + setTool('ls'); + setTimeout(() => handleSearch(), 0); + }} + > + + {dirName}/ + {parts[1] && {parts[1]}} +
+ ); + } + // File entry — split name and language tag + const parts = line.trim().split('\t'); + const fileName = parts[0]?.replace(/^\s+/, '') || ''; + const lang = parts[1] || ''; + return ( +
openFile(fileName)} + title={tt('kb_cat_desc' as any) || `Read ${fileName}`} + > + + + {fileName} + + {lang && ( + + {lang} + + )} +
+ ); + })} +
+ ); + } + + // For grep results — render with file path headers and highlighted line numbers + if (tool === 'grep') { + const lines = result.split('\n').filter(Boolean); + return ( +
+ {lines.map((line, i) => { + const isHeader = line.startsWith("'") && line.includes('matched'); + if (isHeader) { + return ( +
+ + {line.trim()} +
+ ); + } + // File path header (ends with ":" and not indented) + if (line.trim().endsWith(':') && !line.trim().startsWith(' ')) { + const filePath = line.trim().replace(/:$/, ''); + return ( +
openFile(filePath)} + title={tt('kb_cat_desc' as any) || `Read ${filePath}`} + > + + {filePath} +
+ ); + } + // Matched line with line number (format: " 123 | code" or " 123: code") + const match = line.match(/^\s*(\d+)[|:]\s*(.*)/); + if (match) { + return ( +
+ + {match[1]} + + {match[2]} +
+ ); + } + return ( +
+ {line} +
+ ); + })} +
+ ); + } + + // For cat results — render as a code viewer with line numbers and file header + if (tool === 'cat') { + const lines = result.split('\n').filter(Boolean); + + // Handle error messages (e.g., "File 'xxx' not found" or "File 'xxx' is empty") + if ( + lines.length === 1 && + (result.includes('not found') || result.includes('is empty') || result.includes('does not exist')) + ) { + return ( +
+ +

{result}

+
+ ); + } + + // Parse header: "path/to/file.py (python, 150 lines)" + let filePath = ''; + let fileLang = ''; + let fileLines = 0; + let codeStartIdx = 0; + const headerMatch = lines[0]?.match(/^(.+?)\s*\((\w*)?,?\s*(\d+)\s*lines?\)/); + if (headerMatch) { + filePath = headerMatch[1].trim(); + fileLang = headerMatch[2] || ''; + fileLines = parseInt(headerMatch[3], 10); + codeStartIdx = 1; + } + + // Detect truncation line + const truncationLine = lines.findIndex(l => l.includes('truncated, use start_line=')); + const effectiveLines = + truncationLine >= 0 ? lines.slice(codeStartIdx, truncationLine) : lines.slice(codeStartIdx); + + return ( +
+ {/* File header bar */} + {filePath && ( +
+ + + {filePath} + + {fileLang && ( + + {fileLang} + + )} + + + + {fileLines} lines + + + + + +
+ )} + {/* Code area */} +
+ {effectiveLines.map((line, i) => { + // Numbered line (format: " 123 | code" or " 123: code") + const match = line.match(/^\s*(\d+)\s*[|:]\s?(.*)/); + const isEven = i % 2 === 1; + const rowBg = isEven + ? 'bg-gray-50/40 dark:bg-white/[0.015]' + : 'bg-white dark:bg-transparent'; + if (match) { + return ( +
+ + {match[1]} + + + {match[2] || ' '} + +
+ ); + } + // Fallback: plain line with empty line number gutter + return ( +
+ + + {line || ' '} + +
+ ); + })} +
+ {/* Truncation notice */} + {truncationLine >= 0 && ( +
+ + {lines[truncationLine]?.trim()} +
+ )} +
+ ); + } + + // For semantic search results — render as markdown-like chunks + if (tool === 'semantic') { + const sections = result.split(/---\n/); + return ( +
+ {sections.map((section, i) => { + const lines = section.trim().split('\n'); + if (lines.length === 0) return null; + // First line might be the header + const headerMatch = lines[0].match(/^###\s+(.+)/); + const header = headerMatch ? headerMatch[1] : null; + const contentStart = headerMatch ? 1 : 0; + const content = lines.slice(contentStart).join('\n').trim(); + return ( +
+ {header && ( +
+ + {header} +
+ )} + {content && ( +
+                    {content}
+                  
+ )} +
+ ); + })} +
+ ); + } + + // Fallback: plain text + return ( +
+        {result}
+      
+ ); + }; + + return ( +
+ {/* Tool selector — card-style tabs */} +
+ {TOOLS.map(t => { + const active = tool === t.key; + return ( + + + + ); + })} +
+ + {/* Search inputs */} +
+ {cfg.showQuery && ( +
+ {tt('Search_Query')} + setQuery(e.target.value)} + onPressEnter={handleSearch} + allowClear + /> +
+ )} + {cfg.showPath && ( +
+ {tt('File_Path')} + setPath(e.target.value)} + onPressEnter={handleSearch} + allowClear + /> +
+ )} + {cfg.showFilePattern && ( +
+ {tt('File_Pattern')} + setFilePattern(e.target.value)} + onPressEnter={handleSearch} + allowClear + /> +
+ )} + {cfg.showLineRange && ( + <> +
+ {tt('Start_Line')} + setStartLine(v || 1)} /> +
+
+ {tt('End_Line')} + setEndLine(v || 0)} /> +
+ + )} + {cfg.showTopK && ( + <> +
+ {tt('Top_K')} + setTopK(v || 5)} /> +
+
+ {tt('Score_Threshold')} + setScoreThreshold(v || 0)} + /> +
+ + )} + {cfg.showLimit && ( +
+ Limit + setLimit(v || 20)} /> +
+ )} + +
+ + {/* Results */} +
+ +
{renderResult()}
+
+
+
+ ); +} diff --git a/web/components/knowledge/space-form.tsx b/web/components/knowledge/space-form.tsx index 8858771e6..ed5c92abf 100644 --- a/web/components/knowledge/space-form.tsx +++ b/web/components/knowledge/space-form.tsx @@ -1,27 +1,162 @@ -import { addSpace, apiInterceptors } from '@/client/api'; -import { IStorage, StepChangeParams } from '@/types/knowledge'; -import { Button, Form, Input, Select, Spin } from 'antd'; -import { useEffect, useState } from 'react'; +import { addSpace, apiInterceptors, getChunkStrategies, syncGitRepo } from '@/client/api'; +import { IChunkStrategyResponse, IStorage, StepChangeParams } from '@/types/knowledge'; +import { FileTextOutlined, LinkOutlined, ReadOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Divider, Form, Input, Select, Spin, Switch, Upload, message } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +type DataSourceType = 'DOCUMENT' | 'GIT_REPO' | 'URL' | 'TEXT' | 'YUQUEURL' | 'NOTION'; + type FieldType = { spaceName: string; owner: string; description: string; storage: string; - field: string; + domain: string; + // Index methods - multi-select + index_methods?: string[]; + // Data source config + dataSourceType: DataSourceType; + // Git repo + repo_url?: string; + branch?: string; + exclude_dirs?: string; + include_dirs?: string; + build_graph?: boolean; + // Document upload + doc_files?: any[]; + // URL / Text / Yuque + web_url?: string; + raw_text?: string; + yuque_url?: string; + doc_token?: string; + // Chunk strategy + chunk_strategy: string; + chunk_size?: number; + chunk_overlap?: number; }; +// Index method options +const INDEX_METHODS = [ + { value: 'VectorStore', label: '向量索引', labelEn: 'Vector Index', desc: '语义搜索', descEn: 'Semantic search' }, + { + value: 'FullText', + label: '结构索引', + labelEn: 'Structural Index', + desc: '结构化检索', + descEn: 'Structured retrieval', + }, + { + value: 'KnowledgeGraph', + label: '图索引', + labelEn: 'Graph Index', + desc: '知识图谱', + descEn: 'Knowledge graph', + onlyCode: true, + }, +]; + type IProps = { handleStepChange: (params: StepChangeParams) => void; spaceConfig: IStorage | null; + onSuccess?: () => void; }; +const { Dragger } = Upload; + +/* ── Data source card definition ── */ +interface DataSourceCardDef { + key: DataSourceType; + icon: React.ReactNode; + color: string; + bgLight: string; + bgDark: string; + disabled?: boolean; +} + +const DS_CARDS: DataSourceCardDef[] = [ + { + key: 'DOCUMENT', + icon: , + color: '#1677FF', + bgLight: '#E6F4FF', + bgDark: '#111D2C', + }, + { + key: 'GIT_REPO', + icon: ( + + + + ), + color: '#24292F', + bgLight: '#F6F8FA', + bgDark: '#1C2128', + }, + { + key: 'URL', + icon: , + color: '#722ED1', + bgLight: '#F9F0FF', + bgDark: '#1E1326', + }, + { + key: 'TEXT', + icon: , + color: '#FA8C16', + bgLight: '#FFF7E6', + bgDark: '#2B2111', + }, + { + key: 'YUQUEURL', + icon: ( + + + + ), + color: '#13C2C2', + bgLight: '#E6FFFB', + bgDark: '#112123', + }, + { + key: 'NOTION', + icon: ( + + + + + + ), + color: '#000000', + bgLight: '#F1F1F1', + bgDark: '#2D2D2D', + disabled: true, + }, +]; + +/** + * Unified Create Knowledge Space Form. + * + * Consolidates all configuration into one window: + * - Basic info (name, storage, domain, description) + * - Data source selection (card grid): Document / Git Repo / URL / Text / Yuque + * - Data source specific config (Git: repo url, branch, codegraph; Document: upload) + * - Chunk strategy (front-loaded) + * + * On submit: creates space → (Git) triggers sync → closes. + */ export default function SpaceForm(props: IProps) { const { t } = useTranslation(); - const { handleStepChange, spaceConfig } = props; + const { handleStepChange, spaceConfig, onSuccess } = props; const [spinning, setSpinning] = useState(false); const [storage, setStorage] = useState(); + const [dataSourceType, setDataSourceType] = useState('DOCUMENT'); + const [strategies, setStrategies] = useState>([]); + const [files, setFiles] = useState([]); const [form] = Form.useForm(); @@ -34,30 +169,123 @@ export default function SpaceForm(props: IProps) { setStorage(data); }; + useEffect(() => { + (async () => { + const [err, data] = await apiInterceptors(getChunkStrategies()); + if (err) { + console.error('Failed to load chunk strategies:', err); + } + if (data) { + setStrategies(data); + } + })(); + }, []); + + const isGitRepo = dataSourceType === 'GIT_REPO'; + const isDocument = dataSourceType === 'DOCUMENT'; + + // Update index_methods when dataSourceType changes + useEffect(() => { + const currentIndexMethods = form.getFieldValue('index_methods') || []; + if (dataSourceType === 'GIT_REPO') { + // Git repo: all three index methods available + if (!currentIndexMethods.includes('KnowledgeGraph')) { + form.setFieldValue('index_methods', ['VectorStore', 'FullText', 'KnowledgeGraph']); + } + } else { + // Non-Git repo: remove KnowledgeGraph + const filtered = currentIndexMethods.filter((m: string) => m !== 'KnowledgeGraph'); + form.setFieldValue('index_methods', filtered.length > 0 ? filtered : ['VectorStore', 'FullText']); + } + }, [dataSourceType]); + + const dataSourceLabels: Record = useMemo( + () => ({ + DOCUMENT: { title: t('Document'), desc: t('ds_document_desc') }, + GIT_REPO: { title: 'Git Repository', desc: t('ds_git_repo_desc') }, + URL: { title: t('URL'), desc: t('ds_url_desc') }, + TEXT: { title: t('Text'), desc: t('ds_text_desc') }, + YUQUEURL: { title: t('yuque'), desc: t('ds_yuque_desc') }, + NOTION: { title: 'Notion', desc: t('ds_notion_desc') }, + }), + [t], + ); + const handleFinish = async (fieldsValue: FieldType) => { - const { spaceName, owner, description, storage, field } = fieldsValue; + const { spaceName, owner, description, storage, domain, dataSourceType: dst, index_methods } = fieldsValue; setSpinning(true); - const vector_type = storage; - const domain_type = field; - const [_, data, res] = await apiInterceptors( + + // 1. Create knowledge space + // Use first selected index method as primary vector_type + const primaryIndex = index_methods?.[0] || storage; + const domain_type = dst === 'GIT_REPO' ? 'GitRepo' : domain || 'Normal'; + const [err, _data, res] = await apiInterceptors( addSpace({ name: spaceName, - vector_type: vector_type, + vector_type: primaryIndex, owner, desc: description, - domain_type: domain_type, + domain_type, + index_methods: index_methods, }), ); + if (err || !res?.success) { + setSpinning(false); + message.error(t('create_failed') + ': ' + (err as Error)?.message); + return; + } + // addSpace v1 API returns [] — use spaceName as the identifier + // (backend _resolve_space supports both id and name) + localStorage.setItem('cur_space_id', JSON.stringify(spaceName)); + + // 2. For Git Repo, trigger sync immediately + if (dst === 'GIT_REPO') { + const { repo_url, branch, exclude_dirs, include_dirs, build_graph, chunk_strategy } = fieldsValue; + if (!repo_url) { + setSpinning(false); + message.error(t('Please_input_the_repo_url')); + return; + } + const [syncErr, syncData] = await apiInterceptors( + syncGitRepo(spaceName, { + repo_url, + branch: branch || 'main', + exclude_dirs: exclude_dirs + ? exclude_dirs + .split(',') + .map(s => s.trim()) + .filter(Boolean) + : [], + include_dirs: include_dirs + ? include_dirs + .split(',') + .map(s => s.trim()) + .filter(Boolean) + : [], + build_graph: build_graph ?? false, + chunk_strategy: chunk_strategy || 'CHUNK_BY_MARKDOWN_HEADER', + }), + ); + setSpinning(false); + if (syncErr) { + message.error(t('sync_failed') + ': ' + (syncErr as Error).message); + return; + } + message.success(`${t('sync_completed')}: ${syncData?.indexed ?? 0} ${t('files_indexed')}`); + onSuccess?.(); + handleStepChange({ label: 'finish' }); + return; + } + + // 3. For other types, forward to upload step setSpinning(false); - const is_financial = domain_type === 'FinancialReport'; - localStorage.setItem('cur_space_id', JSON.stringify(data)); - res?.success && - handleStepChange({ - label: 'forward', - spaceName, - pace: is_financial ? 2 : 1, - docType: is_financial ? 'DOCUMENT' : '', - }); + handleStepChange({ + label: 'forward', + spaceName, + pace: 2, + docType: dst, + files, + }); }; return ( @@ -67,11 +295,22 @@ export default function SpaceForm(props: IProps) { size='large' className='mt-4' layout='vertical' - name='basic' - initialValues={{ remember: true }} + name='create_knowledge' + initialValues={{ + storage: spaceConfig?.[0]?.name, + dataSourceType: 'DOCUMENT', + branch: 'main', + build_graph: false, + chunk_strategy: 'Automatic', + index_methods: ['VectorStore', 'FullText', 'KnowledgeGraph'], + }} autoComplete='off' onFinish={handleFinish} > + {/* ── Section 1: Basic Info ── */} +
+ {t('Knowledge_Space_Config')} +
label={t('Knowledge_Space_Name')} name='spaceName' @@ -79,7 +318,7 @@ export default function SpaceForm(props: IProps) { { required: true, message: t('Please_input_the_name') }, () => ({ validator(_, value) { - if (/[^\u4e00-\u9fa50-9a-zA-Z_-]/.test(value)) { + if (/[^一-龥0-9a-zA-Z_-]/.test(value)) { return Promise.reject(new Error(t('the_name_can_only_contain'))); } return Promise.resolve(); @@ -89,51 +328,329 @@ export default function SpaceForm(props: IProps) { > - - label={t('Storage')} - name='storage' - rules={[{ required: true, message: t('Please_select_the_storage') }]} - > - + {spaceConfig?.map((item: any) => ( {item.desc} - ); - })} - - - - label={t('Domain')} - name='field' - rules={[{ required: true, message: t('Please_select_the_domain_type') }]} - > - + + label={t('Domain')} name='domain'> + - - - label={t('Description')} - name='description' - rules={[{ required: true, message: t('Please_input_the_description') }]} - > + ))} + + +
+ label={t('Description')} name='description' rules={[{ required: true }]}> + + {/* ── Section 1b: Index Methods ── */} +
+ {t('Index_Method') || '索引方式'} +
+ name='index_methods' initialValue={['VectorStore', 'FullText', 'KnowledgeGraph']}> + { + // If Git Repo, ensure KnowledgeGraph is available + // If not Git Repo, remove KnowledgeGraph from selection + if (dataSourceType !== 'GIT_REPO') { + const filtered = values.filter(v => v !== 'KnowledgeGraph'); + form.setFieldValue('index_methods', filtered); + } + }} + > + {INDEX_METHODS.map(method => { + const isCodeOnly = method.onlyCode && dataSourceType !== 'GIT_REPO'; + const isDisabled = isCodeOnly; + return ( + +
+ + {t('language') === 'en' ? method.labelEn : method.label} + + + {t('language') === 'en' ? method.descEn : method.desc} + + {method.onlyCode && 仅代码} +
+
+ ); + })} +
+ + + + + {/* ── Section 2: Data Source — Card Grid ── */} +
+ {t('Choose_a_Datasource_type')} +
+ name='dataSourceType' rules={[{ required: true }]}> + + +
+ {DS_CARDS.map(card => { + const selected = dataSourceType === card.key; + const label = dataSourceLabels[card.key]; + const isDisabled = card.disabled; + return ( +
{ + if (isDisabled) return; + setDataSourceType(card.key); + form.setFieldValue('dataSourceType', card.key); + }} + className={` + group relative flex flex-col items-center gap-2 rounded-xl p-4 pt-5 pb-4 + transition-all duration-200 select-none + border-2 bg-white dark:bg-gray-800/60 + ${isDisabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'} + ${!isDisabled && selected ? 'shadow-md scale-[1.02]' : ''} + ${ + !isDisabled && !selected + ? 'border-transparent hover:border-gray-200 dark:hover:border-gray-600 hover:shadow-sm' + : '' + } + `} + style={{ + borderColor: selected && !isDisabled ? card.color : undefined, + background: selected && !isDisabled ? card.bgLight : undefined, + }} + > + {/* Coming soon badge */} + {isDisabled && ( +
+ {t('ds_coming_soon')} +
+ )} + {/* Icon circle — always uses brand color */} +
+ {card.icon} +
+ {/* Title */} + + {label?.title ?? card.key} + + {/* Description */} + + {label?.desc ?? ''} + + {/* Selected indicator — check mark */} + {selected && !isDisabled && ( +
+ + + +
+ )} +
+ ); + })} +
+ + {/* ── Section 2b: Data source specific config ── */} + {isGitRepo && ( +
+
+ + + + Git Repository + — {t('ds_git_repo_desc')} +
+ + label={t('Repository_URL')} + name='repo_url' + rules={[{ required: true, message: t('Please_input_the_repo_url') }]} + > + + +
+ label={t('Branch')} name='branch'> + + + label={t('Build_CodeGraph')} name='build_graph' valuePropName='checked'> + + +
+
+ label={t('Exclude_Dirs')} name='exclude_dirs'> + + + label={t('Include_Dirs')} name='include_dirs'> + + +
+
+ )} + + {isDocument && ( +
+
+ + {t('Document')} + — {t('ds_document_desc')} +
+ label={t('Upload_a_document')} name='doc_files'> + { + setFiles(prev => [...prev, file]); + return false; + }} + onRemove={file => { + setFiles(prev => prev.filter(f => f.uid !== file.uid)); + }} + fileList={files} + > +

+ +

+

+ {t('click_or_drag_to_upload')} +

+

PDF, PPT, Excel, Word, Text, Markdown, CSV

+
+ +
+ )} + + {dataSourceType === 'URL' && ( +
+
+ + {t('URL')} + — {t('ds_url_desc')} +
+ label='URL' name='web_url' rules={[{ required: true }]}> + + +
+ )} + + {dataSourceType === 'TEXT' && ( +
+
+ + {t('Text')} + — {t('ds_text_desc')} +
+ label={t('Text')} name='raw_text' rules={[{ required: true }]}> + + +
+ )} + + {dataSourceType === 'YUQUEURL' && ( +
+
+ + + + {t('yuque')} + — {t('ds_yuque_desc')} +
+ label={t('yuque')} name='yuque_url' rules={[{ required: true }]}> + + + label='Token' name='doc_token'> + + +
+ )} + + + + {/* ── Section 3: Chunk Strategy ── */} +
{t('Segmentation')}
+
+ label={t('chunk_strategy')} name='chunk_strategy'> + + + label={t('chunk_size')} name='chunk_size'> + + + label={t('chunk_overlap')} name='chunk_overlap'> + + +
+ - +
+ + +
); } + +/* ── Small helper: Plus icon for upload ── */ +function PlusIcon() { + return ( + + + + + ); +} diff --git a/web/hooks/use-scheduled-task.ts b/web/hooks/use-scheduled-task.ts index cc409f778..61296077f 100644 --- a/web/hooks/use-scheduled-task.ts +++ b/web/hooks/use-scheduled-task.ts @@ -1,11 +1,6 @@ -import { useCallback } from 'react'; +import type { CreateTaskRequest, RunResponse, TaskResponse, UpdateTaskRequest } from '@/types/scheduled-task'; import axios from '@/utils/ctx-axios'; -import type { - CreateTaskRequest, - UpdateTaskRequest, - TaskResponse, - RunResponse, -} from '@/types/scheduled-task'; +import { useCallback } from 'react'; const BASE = '/api/v2/serve/scheduled-tasks'; @@ -46,22 +41,16 @@ export function useScheduledTask() { }, []); /** PUT /api/v2/serve/scheduled-tasks/{task_id} — 更新任务 */ - const updateTask = useCallback( - async (taskId: string, body: UpdateTaskRequest): Promise => { - const res = await axios.put(`${BASE}/${taskId}`, body); - return unwrap(res); - }, - [], - ); + const updateTask = useCallback(async (taskId: string, body: UpdateTaskRequest): Promise => { + const res = await axios.put(`${BASE}/${taskId}`, body); + return unwrap(res); + }, []); /** POST /api/v2/serve/scheduled-tasks/{task_id}/toggle — 启停任务 */ - const toggleTask = useCallback( - async (taskId: string, enabled: boolean): Promise => { - const res = await axios.post(`${BASE}/${taskId}/toggle`, { enabled }); - return unwrap(res); - }, - [], - ); + const toggleTask = useCallback(async (taskId: string, enabled: boolean): Promise => { + const res = await axios.post(`${BASE}/${taskId}/toggle`, { enabled }); + return unwrap(res); + }, []); /** DELETE /api/v2/serve/scheduled-tasks/{task_id} — 删除任务 */ const deleteTask = useCallback(async (taskId: string): Promise => { @@ -69,24 +58,18 @@ export function useScheduledTask() { }, []); /** GET /api/v2/serve/scheduled-tasks/{task_id}/runs?limit=&offset= — 执行历史列表 */ - const listRuns = useCallback( - async (taskId: string, limit = 50, offset = 0): Promise => { - const res = await axios.get(`${BASE}/${taskId}/runs`, { - params: { limit, offset }, - }); - return unwrap(res) ?? []; - }, - [], - ); + const listRuns = useCallback(async (taskId: string, limit = 50, offset = 0): Promise => { + const res = await axios.get(`${BASE}/${taskId}/runs`, { + params: { limit, offset }, + }); + return unwrap(res) ?? []; + }, []); /** GET /api/v2/serve/scheduled-tasks/{task_id}/runs/{run_id} — 单次执行详情 */ - const getRun = useCallback( - async (taskId: string, runId: string): Promise => { - const res = await axios.get(`${BASE}/${taskId}/runs/${runId}`); - return unwrap(res); - }, - [], - ); + const getRun = useCallback(async (taskId: string, runId: string): Promise => { + const res = await axios.get(`${BASE}/${taskId}/runs/${runId}`); + return unwrap(res); + }, []); return { createTask, diff --git a/web/hooks/use-summary.ts b/web/hooks/use-summary.ts index f76cbe4a0..803de07ba 100644 --- a/web/hooks/use-summary.ts +++ b/web/hooks/use-summary.ts @@ -6,7 +6,7 @@ import useChat from './use-chat'; const useSummary = () => { const { history, setHistory, chatId, model, docId } = useContext(ChatContext); - const { chat } = useChat({ queryAgentURL: '/knowledge/document/summary' }); + const { chat } = useChat({ queryAgentURL: '/api/v1/knowledge/document/summary' }); const summary = useCallback( async (curDocId?: number) => { diff --git a/web/locales/en/chat.ts b/web/locales/en/chat.ts index 1f21ee413..97ff38cc0 100644 --- a/web/locales/en/chat.ts +++ b/web/locales/en/chat.ts @@ -101,6 +101,7 @@ export const ChatEn = { step_type_html: 'HTML', step_type_sql: 'SQL Query', step_type_question: 'Ask User', + step_type_kb: 'Knowledge', step_type_other: 'Action', waiting_for_user: 'Waiting for User', user_confirmation: 'Needs your confirmation', diff --git a/web/locales/en/common.ts b/web/locales/en/common.ts index 143382cce..71b81268c 100644 --- a/web/locales/en/common.ts +++ b/web/locales/en/common.ts @@ -16,6 +16,7 @@ export const CommonEn = { Description: 'Description', Storage: 'Storage', Domain: 'Domain', + Index_Method: 'Index Method', Please_input_the_description: 'Please input the description', Please_select_the_storage: 'Please select the storage', Please_select_the_domain_type: 'Please select the domain type', @@ -739,4 +740,65 @@ export const CommonEn = { // ── FromTaskBanner ── 'scheduled.banner.autoGenerated': 'This conversation was auto-generated by a scheduled task', 'scheduled.banner.backToDetail': 'Back to task details', + // ── Git Repo Knowledge ── + Repository_URL: 'Repository URL', + Branch: 'Branch', + Exclude_Dirs: 'Exclude Directories', + Include_Dirs: 'Include Directories', + Build_CodeGraph: 'Build Code Graph', + Start_Sync: 'Start Sync', + git_repo_sync_desc: 'Clone and index a Git repository. Files are split by AST (code) or markdown headers.', + sync_failed: 'Sync failed', + sync_completed: 'Sync completed', + files_indexed: 'files indexed', + skipped: 'skipped', + Please_input_the_repo_url: 'Please input the repository URL', + Please_input_the_branch: 'Please input the branch', + // ── Search Tools ── + Search_Tools: 'Search Tools', + kb_ls: 'List Files', + kb_glob: 'Search by Name', + kb_grep: 'Search Content', + kb_cat: 'Read File', + kb_semantic: 'Semantic Search', + kb_ls_desc: 'List files and directories in the knowledge base', + kb_glob_desc: 'Search files by name or glob pattern', + kb_grep_desc: 'Search file contents by keyword', + kb_cat_desc: 'Read the content of a specific file', + kb_semantic_desc: 'Semantic search using vector retrieval', + Search_Tools_Empty: 'Enter a query and click Search to explore the knowledge base', + Search_Query: 'Search Query', + File_Pattern: 'File Pattern', + File_Path: 'File Path', + Start_Line: 'Start Line', + End_Line: 'End Line', + Top_K: 'Top K', + Score_Threshold: 'Score Threshold', + Search_Results: 'Search Results', + No_Results: 'No results', + // ── Create Knowledge Unified Form ── + create_and_sync: 'Create & Sync', + create_failed: 'Create failed', + click_or_drag_to_upload: 'Click or drag file to upload', + chunk_strategy: 'Chunk Strategy', + chunk_size: 'Chunk Size', + chunk_overlap: 'Chunk Overlap', + // ── Data Source Card Descriptions ── + ds_document_desc: 'Upload PDF, Word, Excel, PPT, Markdown and more', + ds_git_repo_desc: 'Clone & index a repo, split by AST or markdown headers', + ds_url_desc: 'Fetch and index content from a web page URL', + ds_text_desc: 'Paste raw text content directly', + ds_yuque_desc: 'Import documents from Yuque knowledge base', + ds_notion_desc: 'Import pages and databases from Notion workspace', + ds_coming_soon: 'Coming Soon', + // ── Knowledge Detail Page ── + File_View: 'File View', + Code_Graph_View: 'Code Graph', + Code_Graph_View_desc: 'Visualize code structure, dependencies and relationships as an interactive graph', + Open_Graph_View: 'Open Graph View', + Sync_Progress: 'Sync Progress', + Graph_Nodes: 'Nodes', + Graph_Edges: 'Edges', + Communities: 'Communities', + Files: 'Files', } as const; diff --git a/web/locales/zh/chat.ts b/web/locales/zh/chat.ts index 632d26e51..300d3c5a9 100644 --- a/web/locales/zh/chat.ts +++ b/web/locales/zh/chat.ts @@ -109,6 +109,7 @@ export const ChatZh: Resources['translation'] = { step_type_html: 'HTML', step_type_sql: 'SQL查询', step_type_question: '用户确认', + step_type_kb: '知识库', step_type_other: '操作', waiting_for_user: '等待用户回答', user_confirmation: '需要您的确认', diff --git a/web/locales/zh/common.ts b/web/locales/zh/common.ts index b84f27b86..a6617cfc7 100644 --- a/web/locales/zh/common.ts +++ b/web/locales/zh/common.ts @@ -24,6 +24,7 @@ export const CommonZh: Resources['translation'] = { Description: '描述', Storage: '存储类型', Domain: '领域类型', + Index_Method: '索引方式', Please_input_the_description: '请输入描述', Please_select_the_storage: '请选择存储类型', Please_select_the_domain_type: '请选择领域类型', @@ -738,4 +739,65 @@ export const CommonZh: Resources['translation'] = { // ── FromTaskBanner ── 'scheduled.banner.autoGenerated': '此对话由定时任务自动生成', 'scheduled.banner.backToDetail': '返回任务详情', + // ── Git Repo Knowledge ── + Repository_URL: '仓库地址', + Branch: '分支', + Exclude_Dirs: '排除目录', + Include_Dirs: '包含目录', + Build_CodeGraph: '构建代码图谱', + Start_Sync: '开始同步', + git_repo_sync_desc: '克隆并索引 Git 仓库。代码文件按 AST 切分,Markdown 文件按标题切分。', + sync_failed: '同步失败', + sync_completed: '同步完成', + files_indexed: '个文件已索引', + skipped: '个跳过', + Please_input_the_repo_url: '请输入仓库地址', + Please_input_the_branch: '请输入分支', + // ── Search Tools ── + Search_Tools: '搜索工具', + kb_ls: '列出文件', + kb_glob: '按名称搜索', + kb_grep: '搜索内容', + kb_cat: '读取文件', + kb_semantic: '语义搜索', + kb_ls_desc: '列出知识库中的文件和目录', + kb_glob_desc: '按文件名或通配符模式搜索文件', + kb_grep_desc: '按关键词搜索文件内容', + kb_cat_desc: '读取知识库中指定文件的内容', + kb_semantic_desc: '使用向量检索进行语义搜索', + Search_Tools_Empty: '输入查询条件,点击搜索探索知识库', + Search_Query: '搜索内容', + File_Pattern: '文件类型', + File_Path: '文件路径', + Start_Line: '起始行', + End_Line: '结束行', + Top_K: '返回数量', + Score_Threshold: '分数阈值', + Search_Results: '搜索结果', + No_Results: '暂无结果', + // ── Create Knowledge Unified Form ── + create_and_sync: '创建并同步', + create_failed: '创建失败', + click_or_drag_to_upload: '点击或拖拽文件上传', + chunk_strategy: '分片策略', + chunk_size: '分片大小', + chunk_overlap: '分片重叠', + // ── Data Source Card Descriptions ── + ds_document_desc: '上传 PDF、Word、Excel、PPT、Markdown 等文档', + ds_git_repo_desc: '克隆并索引 Git 仓库,代码按 AST 切分,文档按标题切分', + ds_url_desc: '从网页 URL 抓取并索引内容', + ds_text_desc: '直接粘贴原始文本内容', + ds_yuque_desc: '从语雀知识库导入文档', + ds_notion_desc: '从 Notion 工作空间导入页面和数据库', + ds_coming_soon: '即将开放', + // ── Knowledge Detail Page ── + File_View: '文件视图', + Code_Graph_View: '代码图谱', + Code_Graph_View_desc: '以交互图谱的方式可视化代码结构、依赖关系和调用链路', + Open_Graph_View: '打开图谱视图', + Sync_Progress: '同步进度', + Graph_Nodes: '图节点', + Graph_Edges: '图边', + Communities: '社区', + Files: '文件', } as const; diff --git a/web/new-components/chat/ChatPage.tsx b/web/new-components/chat/ChatPage.tsx index 6f38f58aa..207f7ffe1 100644 --- a/web/new-components/chat/ChatPage.tsx +++ b/web/new-components/chat/ChatPage.tsx @@ -128,11 +128,7 @@ const ChatPage: React.FC = ({
{pendingQuestion && onReplyQuestion && onRejectQuestion && (
- +
)} { return ; case 'sql': return ; + case 'kb': + return ; default: return ; } @@ -184,6 +187,7 @@ const getTypeLabel = (type: StepType, t: any): string => { python: t('step_type_python'), html: t('step_type_html'), question: t('step_type_question') || 'Ask User', + kb: t('step_type_kb') || 'Knowledge', other: t('step_type_other'), }; return labels[type] || t('step_type_other'); @@ -204,6 +208,7 @@ const getIconBgClass = (type: StepType): string => { skill: 'bg-indigo-50 dark:bg-indigo-900/30', sql: 'bg-emerald-50 dark:bg-emerald-900/30', question: 'bg-amber-50 dark:bg-amber-900/30', + kb: 'bg-teal-50 dark:bg-teal-900/30', other: 'bg-gray-50 dark:bg-gray-800', }; return bgClasses[type] || 'bg-gray-50 dark:bg-gray-800'; @@ -498,7 +503,8 @@ const StepCard: React.FC<{ step: ExecutionStep; isActive: boolean; onClick: () => void; -}> = memo(({ step, isActive, onClick }) => { + thought?: string; +}> = memo(({ step, isActive, onClick, thought }) => { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); const detailLine = step.description ? step.description.split('\n')[0] : ''; @@ -706,7 +712,24 @@ const StepCard: React.FC<{
{step.title} - {detailLine && {detailLine}} + {thought && + (() => { + const [intention, ...reasonLines] = (typeof thought === 'string' ? thought : '').split('\n'); + const reason = reasonLines.join('\n').trim(); + return ( +
+ + {step.status === 'running' ? : intention} + + {reason && ( + {reason} + )} +
+ ); + })()} + {!thought && step.subtitle && ( + {step.subtitle} + )}
{step.status === 'pending' && } @@ -917,7 +940,6 @@ const SectionBlock: React.FC<{ {section.steps.map(step => ( - {stepThoughts?.[step.id] && } {step.description?.includes('Action: get_skill_resource') ? ( onStepClick(step.id)} /> ) : ( - onStepClick(step.id)} /> + onStepClick(step.id)} + thought={stepThoughts?.[step.id]} + /> )} {step.description?.includes('Observation:') && } diff --git a/web/new-components/chat/content/ManusRightPanel.tsx b/web/new-components/chat/content/ManusRightPanel.tsx index 7f718593c..7fd57715f 100644 --- a/web/new-components/chat/content/ManusRightPanel.tsx +++ b/web/new-components/chat/content/ManusRightPanel.tsx @@ -7,6 +7,7 @@ import { BarChartOutlined, CheckCircleFilled, CheckOutlined, + ClockCircleOutlined, CloseCircleFilled, CodeOutlined, ConsoleSqlOutlined, @@ -32,7 +33,6 @@ import { PlusOutlined, RightOutlined, SearchOutlined, - ClockCircleOutlined, SyncOutlined, TableOutlined, UpOutlined, @@ -133,6 +133,8 @@ const getStepTypeIcon = (type: StepType) => { return ; case 'sql': return ; + case 'kb': + return ; default: return ; } @@ -351,16 +353,241 @@ const FileListItem: React.FC<{ artifact: ArtifactItem; onClick?: () => void }> = FileListItem.displayName = 'FileListItem'; -// Output Renderer Component -const OutputRenderer: React.FC<{ output: ExecutionOutput; index: number }> = memo(({ output, index: _index }) => { - const content = output.content; +// ── kb tool output rendering ──────────────────────────────────────────────── +// kb_ls/kb_cat/kb_grep/kb_glob return plain-text observations; render them with +// structured viewers (file list / code viewer) instead of the raw terminal style. +const KB_TOOLS = new Set(['kb_ls', 'kb_cat', 'kb_grep', 'kb_glob', 'semantic_search']); - if (output.output_type === 'thought') { - return null; // Don't render thoughts - } +// Friendly labels and icons for each kb tool action (used in the right-panel header card) +const KB_ACTION_LABELS: Record = { + kb_ls: 'List Files', + kb_glob: 'Search by Name', + kb_grep: 'Search Content', + kb_cat: 'Read File', + semantic_search: 'Semantic Search', +}; +const KB_ACTION_ICONS: Record = { + kb_ls: , + kb_glob: , + kb_grep: , + kb_cat: , + semantic_search: , +}; + +const KB_FILE_LINE_RE = /^(\S.*?)(?:\t(\S+))?$/; + +/** Render kb_ls / kb_glob output as a file listing. Returns null if not parseable. */ +function renderKbFileList(text: string, t: any): React.ReactNode { + const lines = text.split('\n').filter(l => l.trim() && !l.startsWith('Directory:') && !l.startsWith('Matching')); + if (lines.length === 0) return null; + const entries = lines.map(line => { + const m = line.match(KB_FILE_LINE_RE); + if (!m) return null; + const name = m[1]?.replace(/\/$/, '') || ''; + const isDir = line.trim().endsWith('/'); + return { name, isDir, lang: m[2] || '' }; + }).filter(Boolean) as { name: string; isDir: boolean; lang: string }[]; + if (entries.length === 0) return null; + + const sorted = [...entries].sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); return ( - <> +
+
+ + + {t('kb_ls') || 'Files'} + + {entries.length} entries +
+
+ {sorted.map((e, i) => ( +
+ {e.isDir ? ( + + ) : ( + + )} + + {e.name}{e.isDir ? '/' : ''} + + {e.lang && ( + + {e.lang} + + )} +
+ ))} +
+
+ ); +} + +/** Render kb_cat output as a code viewer with line numbers. Returns null if not parseable. */ +function renderKbCat(text: string, t: any): React.ReactNode { + const lines = text.split('\n'); + // Header: "path/to/file.py (python, 150 lines)" + const headerMatch = lines[0]?.match(/^(.+?)\s*\((\w*)?,?\s*(\d+)\s*lines?\)/); + if (!headerMatch) return null; + const filePath = headerMatch[1].trim(); + const fileLang = headerMatch[2] || ''; + const fileLines = parseInt(headerMatch[3], 10); + + const truncationIdx = lines.findIndex(l => l.includes('truncated, use start_line=')); + const codeLines = truncationIdx >= 0 ? lines.slice(1, truncationIdx) : lines.slice(1); + if (codeLines.length === 0) return null; + + return ( +
+
+ + {filePath} + {fileLang && ( + + {fileLang} + + )} + + + + {fileLines} lines + + + + + +
+
+ {codeLines.map((line, i) => { + const m = line.match(/^\s*(\d+)\s*[|:]\s?(.*)/); + const isEven = i % 2 === 1; + const rowBg = isEven ? 'bg-gray-50/40 dark:bg-white/[0.015]' : 'bg-white dark:bg-transparent'; + if (m) { + return ( +
+ + {m[1]} + + {m[2] || ' '} +
+ ); + } + return ( +
+ + {line || ' '} +
+ ); + })} +
+ {truncationIdx >= 0 && ( +
+ {lines[truncationIdx]?.trim()} +
+ )} +
+ ); +} + +/** Render kb_grep output with file path headers and line numbers. Returns null if not parseable. */ +function renderKbGrep(text: string, t: any): React.ReactNode { + const lines = text.split('\n').filter(Boolean); + if (lines.length === 0) return null; + // Requires at least one matched-line pattern to treat as grep output + const hasMatch = lines.some(l => /^\s*\d+\s*[|:]\s?/.test(l)); + if (!hasMatch) return null; + + return ( +
+
+ + + {t('kb_grep') || 'Grep'} + +
+
+ {lines.map((line, i) => { + // File path header (ends with ':') + if (line.trim().endsWith(':') && !/^\s*\d+\s*[|:]/.test(line)) { + const fp = line.trim().replace(/:$/, ''); + return ( +
+ + {fp} +
+ ); + } + const m = line.match(/^\s*(\d+)\s*[|:]\s?(.*)/); + if (m) { + return ( +
+ + {m[1]} + + {m[2]} +
+ ); + } + return ( +
{line}
+ ); + })} +
+
+ ); +} + +function renderKbToolOutput(action: string, text: string, t: any): React.ReactNode | null { + if (action === 'kb_ls' || action === 'kb_glob') return renderKbFileList(text, t); + if (action === 'kb_cat') return renderKbCat(text, t); + if (action === 'kb_grep') return renderKbGrep(text, t); + return null; +} +// ── end kb tool output rendering ──────────────────────────────────────────── + +// Output Renderer Component +const OutputRenderer: React.FC<{ output: ExecutionOutput; index: number; action?: string }> = memo( + ({ output, index: _index, action }) => { + const { t } = useTranslation(); + const content = output.content; + + if (output.output_type === 'thought') { + return null; // Don't render thoughts + } + + // kb tools return plain text — render with a structured viewer when possible + if (output.output_type === 'text' && action && KB_TOOLS.has(action)) { + const text = typeof content === 'string' ? content : String(content ?? ''); + const rendered = renderKbToolOutput(action, text, t); + if (rendered) return rendered; + } + + return ( + <> {output.output_type === 'code' && ( = ({
{panelView === 'skill-preview' && skillName ? ( @@ -1931,6 +2160,7 @@ const ManusRightPanel: React.FC = ({ 'bg-blue-50 dark:bg-blue-900/30': activeStep.type === 'python', 'bg-orange-50 dark:bg-orange-900/30': activeStep.type === 'html', 'bg-indigo-50 dark:bg-indigo-900/30': activeStep.type === 'task' || activeStep.type === 'skill', + 'bg-teal-50 dark:bg-teal-900/30': activeStep.type === 'kb', 'bg-gray-50 dark:bg-gray-800': activeStep.type === 'other', })} > @@ -2178,7 +2408,54 @@ const ManusRightPanel: React.FC = ({
); - })()) || ( + })()) || + (activeStep.type === 'kb' && + activeStep.action && + KB_TOOLS.has(activeStep.action) && + (() => { + const kbLabel = KB_ACTION_LABELS[activeStep.action!] || activeStep.action; + const kbIcon = KB_ACTION_ICONS[activeStep.action!] || ; + // Parse action input params + let params: Record = {}; + if (activeStep.actionInput) { + try { + params = + typeof activeStep.actionInput === 'string' + ? JSON.parse(activeStep.actionInput) + : activeStep.actionInput; + } catch { + params = {}; + } + } + const paramEntries = Object.entries(params).filter( + ([, v]) => v !== '' && v != null, + ); + return ( +
+
+
+ {kbIcon} +
+ {kbLabel} +
+ {paramEntries.length > 0 && ( +
+ {paramEntries.map(([k, v]) => ( +
+ + {k}: + + + {typeof v === 'string' ? v : JSON.stringify(v)} + +
+ ))} +
+ )} +
+ ); + })()) || + (
{activeStep.detail}
@@ -2207,7 +2484,7 @@ const ManusRightPanel: React.FC = ({ ) : group.type === 'html-tabbed' ? ( ) : ( - + ); })}
diff --git a/web/new-components/chat/content/ManusStepCard.tsx b/web/new-components/chat/content/ManusStepCard.tsx index ee7b5805f..6f2b249c0 100644 --- a/web/new-components/chat/content/ManusStepCard.tsx +++ b/web/new-components/chat/content/ManusStepCard.tsx @@ -195,7 +195,9 @@ const ManusStepCard: React.FC = ({ {stats.deletions !== undefined && stats.deletions > 0 && ( -{stats.deletions} )} - {stats.files !== undefined && {t('files_count', { count: stats.files })}} + {stats.files !== undefined && ( + {t('files_count', { count: stats.files })} + )} )} diff --git a/web/new-components/chat/content/QuestionDock.tsx b/web/new-components/chat/content/QuestionDock.tsx index e002dd031..6c9e155e4 100644 --- a/web/new-components/chat/content/QuestionDock.tsx +++ b/web/new-components/chat/content/QuestionDock.tsx @@ -26,12 +26,8 @@ interface QuestionDockProps { const QuestionDock: React.FC = ({ request, onReply, onReject }) => { const { t } = useTranslation(); - const [selected, setSelected] = useState( - () => request.questions.map(() => []), - ); - const [customInputs, setCustomInputs] = useState( - () => request.questions.map(() => ''), - ); + const [selected, setSelected] = useState(() => request.questions.map(() => [])); + const [customInputs, setCustomInputs] = useState(() => request.questions.map(() => '')); const canSubmit = useMemo(() => { return request.questions.every((q, i) => { @@ -98,9 +94,7 @@ const QuestionDock: React.FC = ({ request, onReply, onReject - - {t('user_confirmation')} - + {t('user_confirmation')} - -

- {tool.description || '—'} -

- +

{tool.description || '—'}

-

{t('connector.tools.inputSchema')}

{hasParams && ( @@ -362,16 +346,13 @@ const ToolDetail: React.FC<{ tool: ConnectorToolSummary; onCopy: (name: string) )}
- {wholeTruncated || truncatedEntry ? ( ) : !hasParams ? ( @@ -411,7 +392,6 @@ const ToolDetail: React.FC<{ tool: ConnectorToolSummary; onCopy: (name: string)
)} -
{/* Bottom breathing room */}
); @@ -459,10 +439,7 @@ const ErrorState: React.FC<{ error: string; onRetry: () => void }> = ({ error, o message={t('connector.tools.errorTitle')} description={error} action={ - } diff --git a/web/new-components/connector/useConfirmPolling.ts b/web/new-components/connector/useConfirmPolling.ts index 95ddd1cdb..b03cde071 100644 --- a/web/new-components/connector/useConfirmPolling.ts +++ b/web/new-components/connector/useConfirmPolling.ts @@ -24,9 +24,7 @@ export function useConfirmPolling({ const fetchPending = useCallback(async () => { try { - const data = (await axios.get('/api/v2/serve/connectors/pending-confirms')) as - | PendingConfirmation[] - | undefined; + const data = (await axios.get('/api/v2/serve/connectors/pending-confirms')) as PendingConfirmation[] | undefined; if (data && data.length > 0) { const first = data[0]; if (first.confirm_id !== currentConfirmIdRef.current) { @@ -57,18 +55,21 @@ export function useConfirmPolling({ }; }, [isActive, pollInterval, fetchPending]); - const sendConfirm = useCallback(async (approved: boolean) => { - if (!pendingConfirmation) return; - const body: ConfirmActionRequest = { - confirm_id: pendingConfirmation.confirm_id, - approved, - }; - try { - await axios.post('/api/v2/serve/connectors/confirm', body); - } catch {} - currentConfirmIdRef.current = null; - setPendingConfirmation(null); - }, [pendingConfirmation]); + const sendConfirm = useCallback( + async (approved: boolean) => { + if (!pendingConfirmation) return; + const body: ConfirmActionRequest = { + confirm_id: pendingConfirmation.confirm_id, + approved, + }; + try { + await axios.post('/api/v2/serve/connectors/confirm', body); + } catch {} + currentConfirmIdRef.current = null; + setPendingConfirmation(null); + }, + [pendingConfirmation], + ); const approve = useCallback(() => sendConfirm(true), [sendConfirm]); const deny = useCallback(() => sendConfirm(false), [sendConfirm]); diff --git a/web/next.config.js b/web/next.config.js index a92422434..0ec3853be 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -9,8 +9,16 @@ const nextConfig = { typescript: { ignoreBuildErrors: true, }, + async rewrites() { + return [ + { + source: '/api/v1/:path*', + destination: 'http://127.0.0.1:5670/api/v1/:path*', + }, + ]; + }, env: { - API_BASE_URL: process.env.API_BASE_URL, + API_BASE_URL: "http://127.0.0.1:5670", GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, GET_USER_URL: process.env.GET_USER_URL, diff --git a/web/package.json b/web/package.json index 50c7f4d9b..62192eac3 100644 --- a/web/package.json +++ b/web/package.json @@ -126,7 +126,7 @@ }, "resolutions": { "d3-color": "2", - "d3-array": "2", + "d3-array": "3", "d3-shape": "2", "d3-path": "2", "d3-dsv": "2", diff --git a/web/pages/construct/knowledge/detail.tsx b/web/pages/construct/knowledge/detail.tsx new file mode 100644 index 000000000..a75a31844 --- /dev/null +++ b/web/pages/construct/knowledge/detail.tsx @@ -0,0 +1,418 @@ +import { + apiInterceptors, + getChunkList, + getCodeGraphVisualizeHtml, + getKnowledgeSpaceStats, + getSpaceList, +} from '@/client/api'; +import DocPanel from '@/components/knowledge/doc-panel'; +import KnowledgeTree from '@/components/knowledge/knowledge-tree'; +import { IDocument, ISpace, KnowledgeSpaceStats } from '@/types/knowledge'; +import { + ApartmentOutlined, + ArrowLeftOutlined, + CodeOutlined, + FileTextOutlined, + GitlabOutlined, + NodeIndexOutlined, + PartitionOutlined, + ShareAltOutlined, + TeamOutlined, +} from '@ant-design/icons'; +import { Button, Card, Col, Empty, Progress, Row, Segmented, Space, Spin, Statistic, Tag } from 'antd'; +import { useRouter } from 'next/router'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type ViewMode = 'files' | 'graph'; + +/** + * Knowledge Space Detail Page. + * + * Layout: Tree sidebar (left) + Content area (right). + * View modes: File View (document list + chunk detail) / Code Graph View + */ +export default function KnowledgeDetailPage() { + const { t } = useTranslation(); + const router = useRouter(); + const spaceName = (router.query.spaceName as string) || ''; + + const [currentSpace, setCurrentSpace] = useState(null); + const [loading, setLoading] = useState(true); + const [viewMode, setViewMode] = useState('files'); + + // Stats state + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + + // Code graph state + const graphIframeRef = useRef(null); + const [graphHtml, setGraphHtml] = useState(null); + const [graphLoading, setGraphLoading] = useState(false); + const [graphError, setGraphError] = useState(null); + + // Document detail state (when a doc is selected in the tree) + const [selectedDoc, setSelectedDoc] = useState(null); + const [chunks, setChunks] = useState([]); + const [chunksLoading, setChunksLoading] = useState(false); + + useEffect(() => { + if (!spaceName) return; + (async () => { + setLoading(true); + const [, data] = await apiInterceptors(getSpaceList({ name: spaceName })); + if (data && data.length > 0) { + setCurrentSpace(data[0]); + } + setLoading(false); + })(); + }, [spaceName]); + + // Fetch stats + useEffect(() => { + if (!spaceName) return; + (async () => { + setStatsLoading(true); + const [, data] = await apiInterceptors(getKnowledgeSpaceStats(spaceName)); + if (data) { + setStats(data); + } + setStatsLoading(false); + })(); + }, [spaceName]); + + // Fetch code graph HTML when switching to graph view + useEffect(() => { + if (viewMode !== 'graph' || !spaceName) { + setGraphHtml(null); + setGraphError(null); + return; + } + (async () => { + setGraphLoading(true); + setGraphError(null); + setGraphHtml(null); + try { + const html = await getCodeGraphVisualizeHtml(spaceName); + if (!html || html.includes('No code graph found')) { + setGraphError('No code graph found. Please build the code graph first.'); + } else { + setGraphHtml(html); + } + } catch (err: any) { + setGraphError(err?.message || t('No_Results') || 'Failed to load code graph'); + } + setGraphLoading(false); + })(); + }, [viewMode, spaceName, t]); + + // Fetch chunks when a document is selected + useEffect(() => { + if (!selectedDoc || !spaceName) { + setChunks([]); + return; + } + (async () => { + setChunksLoading(true); + const [, data] = await apiInterceptors( + getChunkList(spaceName, { + document_id: String(selectedDoc.id), + page: 1, + page_size: 50, + }), + ); + setChunks(data?.data || []); + setChunksLoading(false); + })(); + }, [selectedDoc, spaceName]); + + const handleSelectSpace = (space: ISpace) => { + setCurrentSpace(space); + setSelectedDoc(null); + setChunks([]); + router.replace(`/construct/knowledge/detail?spaceName=${space.name}`, undefined, { shallow: true }); + }; + + const handleSelectDocument = (doc: IDocument | null) => { + setSelectedDoc(doc); + }; + + const handleBack = () => { + router.push('/construct/knowledge'); + }; + + const handleBackToDocList = () => { + setSelectedDoc(null); + setChunks([]); + }; + + const viewModeOptions = [ + { + label: ( + + + {t('File_View')} + + ), + value: 'files', + }, + { + label: ( + + + {t('Code_Graph_View')} + + ), + value: 'graph', + }, + ]; + + /** Render the metadata stats panel */ + const renderStatsPanel = () => { + if (statsLoading) { + return ( +
+ +
+ ); + } + if (!stats) return null; + + const hasSyncProgress = stats.sync_status && stats.sync_total_files !== null && stats.sync_total_files > 0; + const hasGraphStats = + stats.graph_vertex_count !== null || stats.graph_edge_count !== null || stats.graph_community_count !== null; + + const syncPercent = + hasSyncProgress && stats.sync_total_files + ? Math.round(((stats.sync_finished || 0) / stats.sync_total_files) * 100) + : 0; + + return ( +
+ {/* Tags row */} +
+ {stats.domain_type && {stats.domain_type}} + {stats.vector_type && {stats.vector_type}} + {stats.index_methods && + stats.index_methods.map((m: string) => ( + + {m} + + ))} + {stats.repo_url && ( + } color='volcano'> + {stats.branch || 'main'} + + )} +
+ + {/* Stats row */} + + + } + valueStyle={{ fontSize: 18 }} + /> + + + } + valueStyle={{ fontSize: 18 }} + /> + + {hasGraphStats && ( + <> + + } + valueStyle={{ fontSize: 18 }} + /> + + + } + valueStyle={{ fontSize: 18 }} + /> + + + } + valueStyle={{ fontSize: 18 }} + /> + + + )} + + + {/* Sync progress bar (for GitRepo spaces) */} + {hasSyncProgress && ( +
+
+ {t('Sync_Progress')} + + {stats.sync_finished || 0}/{stats.sync_total_files} {t('Files')} + {stats.sync_failed ? ` (${stats.sync_failed} failed)` : ''} + +
+ +
+ )} +
+ ); + }; + + return ( +
+ {/* Header */} +
+
+
+ + setViewMode(val as ViewMode)} /> + +
+ + {/* Stats Panel */} + {renderStatsPanel()} + + {/* Body: Tree + Content */} +
+ {/* Tree Sidebar */} +
+ +
+ + {/* Content Area */} +
+ {loading ? ( +
+ +
+ ) : viewMode === 'graph' ? ( + /* Code Graph View */ +
+ {graphLoading ? ( +
+ +
+ ) : graphError ? ( +
+ +

{graphError}

+
+ ) : graphHtml ? ( +