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 <noreply@anthropic.com>
This commit is contained in:
aries_ckt
2026-07-04 23:48:12 +08:00
parent a0b49a1600
commit cd1d6af08b
26 changed files with 2994 additions and 402 deletions

View File

@@ -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<GitRepoSyncParams, GitRepoSyncResult>(`${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<null, GitRepoSyncStatus>(`${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<KbSearchParams, string>(`${KB_V2_PREFIX}/${spaceId}/tools/ls`, data);
};
/** kb_glob - 按文件名搜索 */
export const kbGlob = (spaceId: string | number, data: KbSearchParams) => {
return POST<KbSearchParams, string>(`${KB_V2_PREFIX}/${spaceId}/tools/glob`, data);
};
/** kb_grep - 按内容关键词搜索 */
export const kbGrep = (spaceId: string | number, data: KbSearchParams) => {
return POST<KbSearchParams, string>(`${KB_V2_PREFIX}/${spaceId}/tools/grep`, data);
};
/** kb_cat - 读取文件内容 */
export const kbCat = (spaceId: string | number, data: KbSearchParams) => {
return POST<KbSearchParams, string>(`${KB_V2_PREFIX}/${spaceId}/tools/cat`, data);
};
/** kb_semantic_search - 语义搜索 */
export const kbSemanticSearch = (spaceId: string | number, data: KbSearchParams) => {
return POST<KbSearchParams, string>(`${KB_V2_PREFIX}/${spaceId}/tools/semantic_search`, data);
};
// ============ 知识空间统计 API (v2) ============
/** 获取知识空间聚合统计信息 */
export const getKnowledgeSpaceStats = (spaceId: string | number) => {
return GET<null, KnowledgeSpaceStats>(`${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 ?? {},
);
};

View File

@@ -199,59 +199,77 @@ export const getEditorSql = (id: string, round: string | number) => {
/** knowledge */
export const getArguments = (knowledgeName: string) => {
return POST<any, IArguments>(`/knowledge/${knowledgeName}/arguments`, {});
return POST<any, IArguments>(`/api/v1/knowledge/${knowledgeName}/arguments`, {});
};
export const saveArguments = (knowledgeName: string, data: ArgumentsParams) => {
return POST<ArgumentsParams, IArguments>(`/knowledge/${knowledgeName}/argument/save`, data);
return POST<ArgumentsParams, IArguments>(`/api/v1/knowledge/${knowledgeName}/argument/save`, data);
};
export const getRetrieveStrategyList = () => {
return POST<any, Array<IRetrieveStrategy>>(`/knowledge/retrieve_strategy_list`, {});
return POST<any, Array<IRetrieveStrategy>>(`/api/v1/knowledge/retrieve_strategy_list`, {});
};
export const getSpaceList = (data?: any) => {
return POST<any, Array<ISpace>>('/knowledge/space/list', data ?? {});
return POST<any, Array<ISpace>>('/api/v1/knowledge/space/list', data ?? {});
};
export const getDocumentList = (spaceName: string, data: Record<string, number | Array<number>>) => {
return POST<Record<string, number | Array<number>>, IDocumentResponse>(`/knowledge/${spaceName}/document/list`, data);
return POST<Record<string, number | Array<number>>, IDocumentResponse>(
`/api/v1/knowledge/${spaceName}/document/list`,
data,
);
};
export const getGraphVis = (spaceName: string, data: { limit: number }) => {
return POST<Record<string, number>, GraphVisResult>(`/knowledge/${spaceName}/graphvis`, data);
return POST<Record<string, number>, 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<DocumentParams, number>(`/knowledge/${knowledgeName}/document/add`, data);
return POST<DocumentParams, number>(`/api/v1/knowledge/${knowledgeName}/document/add`, data);
};
export const addSpace = (data: AddKnowledgeParams) => {
return POST<AddKnowledgeParams, number>(`/knowledge/space/add`, data);
return POST<AddKnowledgeParams, number>(`/api/v1/knowledge/space/add`, data);
};
export const getChunkStrategies = () => {
return GET<null, Array<IChunkStrategyResponse>>('/knowledge/document/chunkstrategies');
return GET<null, Array<IChunkStrategyResponse>>('/api/v1/knowledge/document/chunkstrategies');
};
export const syncDocument = (spaceName: string, data: Record<string, Array<number>>) => {
return POST<Record<string, Array<number>>, string | null>(`/knowledge/${spaceName}/document/sync`, data);
return POST<Record<string, Array<number>>, string | null>(`/api/v1/knowledge/${spaceName}/document/sync`, data);
};
export const syncBatchDocument = (spaceName: string, data: Array<ISyncBatchParameter>) => {
return POST<Array<ISyncBatchParameter>, ISyncBatchResponse>(`/knowledge/${spaceName}/document/sync_batch`, data);
return POST<Array<ISyncBatchParameter>, ISyncBatchResponse>(
`/api/v1/knowledge/${spaceName}/document/sync_batch`,
data,
);
};
export const uploadDocument = (knowLedgeName: string, data: FormData) => {
return POST<FormData, number>(`/knowledge/${knowLedgeName}/document/upload`, data);
return POST<FormData, number>(`/api/v1/knowledge/${knowLedgeName}/document/upload`, data);
};
export const getChunkList = (spaceName: string, data: ChunkListParams) => {
return POST<ChunkListParams, IChunkList>(`/knowledge/${spaceName}/chunk/list`, data);
return POST<ChunkListParams, IChunkList>(`/api/v1/knowledge/${spaceName}/chunk/list`, data);
};
export const delDocument = (spaceName: string, data: Record<string, number>) => {
return POST<Record<string, number>, null>(`/knowledge/${spaceName}/document/delete`, data);
return POST<Record<string, number>, null>(`/api/v1/knowledge/${spaceName}/document/delete`, data);
};
export const delSpace = (data: Record<string, string>) => {
return POST<Record<string, string>, null>(`/knowledge/space/delete`, data);
return POST<Record<string, string>, null>(`/api/v1/knowledge/space/delete`, data);
};
/** models */
@@ -418,10 +436,10 @@ export const modelSearch = (data: Record<string, string>) => {
};
export const getKnowledgeAdmins = (spaceId: string) => {
return GET<string, Record<string, any>>(`/knowledge/users/list?space_id=${spaceId}`);
return GET<string, Record<string, any>>(`/api/v1/knowledge/users/list?space_id=${spaceId}`);
};
export const updateKnowledgeAdmins = (data: Record<string, string>) => {
return POST<Record<string, any>, any[]>(`/knowledge/users/update`, data);
return POST<Record<string, any>, any[]>(`/api/v1/knowledge/users/update`, data);
};
/** AWEL Flow */
@@ -432,5 +450,5 @@ export const delApp = (data: Record<string, string>) => {
};
export const getSpaceConfig = () => {
return GET<string, SpaceConfig>(`/knowledge/space/config`);
return GET<string, SpaceConfig>(`/api/v1/knowledge/space/config`);
};

View File

@@ -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<boolean>(false);
// 搜索工具面板
const [searchToolsOpen, setSearchToolsOpen] = useState<boolean>(false);
const currentPageRef = useRef(1);
const hasMore = useMemo(() => {
@@ -264,158 +268,151 @@ export default function DocPanel(props: IProps) {
const renderDocumentCard = () => {
return (
<div className='w-full h-full'>
<div className='mb-4'>
{/* <div className="mb-1">管理员工号去前缀0</div> */}
<div className='flex w-full justify-end'>
{/* <Select
mode="tags"
value={admins}
style={{ width: '50%' }}
onChange={handleChange}
tokenSeparators={[',']}
options={admins.map((item: string) => ({ label: item, value: item }))}
/> */}
<Button
type='primary'
onClick={async () => {
await refresh();
}}
loading={isLoading}
>
{t('Refresh_status')}
</Button>
</div>
<div className='mb-4 flex items-center justify-between'>
<Input
className='w-64'
prefix={<SearchOutlined />}
placeholder={t('please_enter_the_keywords')}
onChange={async e => {
await search(space.id, e.target.value);
}}
allowClear
/>
<Button
type='primary'
onClick={async () => {
await refresh();
}}
loading={isLoading}
>
{t('Refresh_status')}
</Button>
</div>
<div className='flex flex-col h-full p-3 border rounded-md'>
{documents?.length > 0 ? (
<>
<div className='flex flex-1 justify-between items-center'>
<Input
className='w-1/3'
prefix={<SearchOutlined />}
placeholder={t('please_enter_the_keywords')}
onChange={async e => {
await search(space.id, e.target.value);
}}
allowClear
/>
</div>
<Spin spinning={searchLoading}>
<>
{searchDocuments.length > 0 ? (
<div className='h-96 mt-3 grid grid-cols-3 gap-x-6 gap-y-5 overflow-y-auto'>
{searchDocuments.map((document: IDocument) => {
return (
<Card
key={document.id}
className=' dark:bg-[#484848] relative shrink-0 grow-0 cursor-pointer rounded-[10px] border border-gray-200 border-solid w-full max-h-64'
title={
<Tooltip title={document.doc_name}>
<div className='truncate '>
<DocIcon type={document.doc_type} />
<span>{document.doc_name}</span>
</div>
</Tooltip>
}
extra={
<Dropdown
menu={{
items: [
{
key: 'publish',
label: (
<Space
onClick={() => {
router.push(
`/construct/knowledge/chunk/?spaceName=${space.name}&id=${document.id}`,
);
}}
>
<EyeOutlined />
<span>{t('detail')}</span>
</Space>
),
},
{
key: `${t('Sync')}`,
label: <SyncContent name={space.name} id={document.id} />,
},
{
key: 'edit',
label: (
<Space
onClick={() => {
setEditOpen(true);
setCurDoc(document);
}}
>
<EditOutlined />
<span>{t('Edit')}</span>
</Space>
),
},
{
key: 'del',
label: (
<Space
onClick={() => {
showDeleteConfirm(document);
}}
>
<DeleteOutlined />
<span>{t('Delete')}</span>
</Space>
),
},
],
}}
getPopupContainer={node => node.parentNode as HTMLElement}
placement='bottomRight'
autoAdjustOverflow={false}
className='rounded-md'
>
<EllipsisOutlined className='p-2' />
</Dropdown>
}
>
<p className='mt-2 font-semibold '>{t('Size')}:</p>
<p>{document.chunk_size} chunks</p>
<p className='mt-2 font-semibold '>{t('Last_Sync')}:</p>
<p>{moment(document.last_sync).format('YYYY-MM-DD HH:MM:SS')}</p>
<p className='mt-2 mb-2'>{renderResultTag(document.status, document.result)}</p>
</Card>
);
})}
{documents?.length > 0 ? (
<Spin spinning={searchLoading}>
{searchDocuments.length > 0 ? (
<div className='border rounded-lg overflow-hidden'>
{/* Table header */}
<div className='grid grid-cols-12 gap-2 px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border-b text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
<div className='col-span-5'>{t('Document_name')}</div>
<div className='col-span-2'>{t('Size')}</div>
<div className='col-span-2'>{t('Status')}</div>
<div className='col-span-2'>{t('Last_Sync')}</div>
<div className='col-span-1 text-right'>{t('scheduled.col.actions')}</div>
</div>
{/* Table rows */}
<div className='max-h-[420px] overflow-y-auto'>
{searchDocuments.map((document: IDocument) => (
<div
key={document.id}
className='grid grid-cols-12 gap-2 px-4 py-3 border-b last:border-b-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors items-center cursor-pointer'
onClick={() => {
router.push(`/construct/knowledge/chunk/?spaceName=${space.name}&id=${document.id}`);
}}
>
{/* Name */}
<div className='col-span-5 flex items-center gap-2 min-w-0'>
<DocIcon type={document.doc_type} />
<Tooltip title={document.doc_name}>
<span className='truncate text-sm text-gray-800 dark:text-gray-200'>{document.doc_name}</span>
</Tooltip>
</div>
{/* Chunks */}
<div className='col-span-2 text-sm text-gray-600 dark:text-gray-400'>
{document.chunk_size} chunks
</div>
{/* Status */}
<div className='col-span-2'>{renderResultTag(document.status, document.result)}</div>
{/* Last Sync */}
<div className='col-span-2 text-sm text-gray-500 dark:text-gray-400'>
{document.last_sync ? moment(document.last_sync).format('YYYY-MM-DD HH:mm') : '-'}
</div>
{/* Actions */}
<div className='col-span-1 flex justify-end' onClick={e => e.stopPropagation()}>
<Dropdown
menu={{
items: [
{
key: 'detail',
label: (
<Space>
<EyeOutlined />
<span>{t('detail')}</span>
</Space>
),
onClick: () => {
router.push(`/construct/knowledge/chunk/?spaceName=${space.name}&id=${document.id}`);
},
},
{
key: 'sync',
label: <SyncContent name={space.name} id={document.id} />,
},
{
key: 'edit',
label: (
<Space>
<EditOutlined />
<span>{t('Edit')}</span>
</Space>
),
onClick: () => {
setEditOpen(true);
setCurDoc(document);
},
},
{
key: 'delete',
danger: true,
label: (
<Space>
<DeleteOutlined />
<span>{t('Delete')}</span>
</Space>
),
onClick: () => {
showDeleteConfirm(document);
},
},
],
}}
getPopupContainer={node => node.parentNode as HTMLElement}
placement='bottomRight'
autoAdjustOverflow={false}
>
<Button type='text' size='small' icon={<EllipsisOutlined />} />
</Dropdown>
</div>
</div>
) : (
<Empty
className='flex flex-1 w-full py-10 flex-col items-center justify-center'
image={Empty.PRESENTED_IMAGE_DEFAULT}
/>
)}
</>
))}
</div>
{hasMore && (
<Divider>
<span className='cursor-pointer' onClick={loadMoreDocuments}>
<div className='py-2 text-center border-t'>
<span className='text-sm text-primary cursor-pointer hover:underline' onClick={loadMoreDocuments}>
{t('Load_more')}
</span>
</Divider>
</div>
)}
</Spin>
</>
) : (
<Empty image={Empty.PRESENTED_IMAGE_DEFAULT}>
<Button
type='primary'
className='flex items-center mx-auto'
icon={<PlusOutlined />}
onClick={handleAddDocument}
>
Create Now
</Button>
</Empty>
)}
</div>
</div>
) : (
<Empty
className='flex flex-1 w-full py-10 flex-col items-center justify-center'
image={Empty.PRESENTED_IMAGE_DEFAULT}
/>
)}
</Spin>
) : (
<Empty image={Empty.PRESENTED_IMAGE_DEFAULT}>
<Button
type='primary'
className='flex items-center mx-auto'
icon={<PlusOutlined />}
onClick={handleAddDocument}
>
Create Now
</Button>
</Empty>
)}
</div>
);
};
@@ -462,6 +459,9 @@ export default function DocPanel(props: IProps) {
<Button icon={<ExperimentOutlined />} onClick={() => setRecallTestOpen(true)}>
{t('Recall_test')}
</Button>
<Button icon={<SearchOutlined />} onClick={() => setSearchToolsOpen(true)}>
{t('Search_Tools')}
</Button>
</Space>
<Divider />
<Spin spinning={isLoading}>{renderDocumentCard()}</Spin>
@@ -541,6 +541,17 @@ export default function DocPanel(props: IProps) {
</Modal>
{/* 召回测试弹窗 */}
<RecallTestModal open={recallTestOpen} setOpen={setRecallTestOpen} space={space} />
{/* 搜索工具面板 */}
<Modal
title={t('Search_Tools')}
open={searchToolsOpen}
onCancel={() => setSearchToolsOpen(false)}
footer={null}
width={'80%'}
destroyOnClose={true}
>
<SearchToolsPanel space={space} />
</Modal>
</div>
);
}

View File

@@ -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<boolean>(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 (
<div className='mt-4'>
<div className='flex items-center gap-2 mb-4'>
<div className='flex items-center justify-center w-9 h-9 rounded-full bg-gray-100 dark:bg-gray-700'>
<svg width='18' height='18' viewBox='0 0 24 24' fill='#24292F' className='dark:fill-gray-200'>
<path d='M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943' />
</svg>
</div>
<div>
<span className='text-sm font-semibold text-gray-800 dark:text-gray-200'>Git Repository</span>
<p className='text-xs text-gray-400 dark:text-gray-500 m-0'>{t('ds_git_repo_desc')}</p>
</div>
</div>
<Form
form={form}
size='large'
layout='vertical'
name='git_repo_sync'
initialValues={{ branch: 'main', build_graph: false }}
onFinish={handleFinish}
autoComplete='off'
>
<Form.Item<FieldType>
label={t('Repository_URL')}
name='repo_url'
rules={[{ required: true, message: t('Please_input_the_repo_url') }]}
>
<Input className='h-11' placeholder='https://github.com/org/repo.git' />
</Form.Item>
<div className='grid grid-cols-2 gap-4'>
<Form.Item<FieldType> label={t('Branch')} name='branch'>
<Input className='h-11' placeholder='main' />
</Form.Item>
<Form.Item<FieldType> label={t('Build_CodeGraph')} name='build_graph' valuePropName='checked'>
<Switch />
</Form.Item>
</div>
<div className='grid grid-cols-2 gap-4'>
<Form.Item<FieldType> label={t('Exclude_Dirs')} name='exclude_dirs'>
<Input className='h-11' placeholder='node_modules, .venv, dist' />
</Form.Item>
<Form.Item<FieldType> label={t('Include_Dirs')} name='include_dirs'>
<Input className='h-11' placeholder='src, docs' />
</Form.Item>
</div>
<Form.Item>
<Button type='primary' htmlType='submit' loading={spinning}>
{t('Start_Sync')}
</Button>
</Form.Item>
</Form>
</div>
);
}

View File

@@ -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<TreeNodeData[]>([]);
const [loadedKeys, setLoadedKeys] = useState<Set<string>>(new Set());
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(new Set());
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
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 ? (
<span>
{entry.name} <span className='text-xs text-gray-400'>({entry.child_count})</span>
</span>
) : (
<span>
{entry.name} {entry.language && <span className='text-xs text-gray-400 ml-1'>{entry.language}</span>}
</span>
),
icon: entry.is_dir ? <FolderOutlined /> : <FileOutlined />,
isLeaf: !entry.is_dir,
isDir: entry.is_dir,
path: entry.path,
docId: entry.doc_id,
fileData: entry,
}));
setTreeData([
{
key: rootKey,
title: currentSpaceName,
icon: <FolderOpenOutlined />,
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: <FileTextOutlined />,
isLeaf: true,
isDir: false,
docId: doc.id,
}));
setTreeData([
{
key: rootKey,
title: currentSpaceName,
icon: <FolderOpenOutlined />,
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 ? (
<span>
{entry.name} <span className='text-xs text-gray-400'>({entry.child_count})</span>
</span>
) : (
<span>
{entry.name} {entry.language && <span className='text-xs text-gray-400 ml-1'>{entry.language}</span>}
</span>
),
icon: entry.is_dir ? <FolderOutlined /> : <FileOutlined />,
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 (
<div className='h-full flex flex-col'>
<div className='px-3 py-2 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider'>
{t('Knowledge_Space')}
</div>
<div className='flex-1 overflow-auto'>
<Tree.DirectoryTree
treeData={treeData}
expandedKeys={expandedKeys}
selectedKeys={selectedKeys}
onExpand={handleExpand}
onSelect={handleSelect}
showIcon
blockNode
className='knowledge-tree'
switcherLoadingIcon={<LoadingOutlined />}
/>
</div>
</div>
);
}
/** 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;
});
}

View File

@@ -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: <SearchOutlined />,
color: '#FA8C16',
bgColor: '#FFF7E6',
descKey: 'kb_grep_desc',
showQuery: true,
showPath: true,
showFilePattern: true,
showLineRange: false,
showTopK: false,
showLimit: true,
},
{
key: 'semantic',
icon: <CodeOutlined />,
color: '#52C41A',
bgColor: '#F6FFED',
descKey: 'kb_semantic_desc',
showQuery: true,
showPath: false,
showFilePattern: false,
showLineRange: false,
showTopK: true,
showLimit: false,
},
{
key: 'ls',
icon: <FolderOpenOutlined />,
color: '#1677FF',
bgColor: '#E6F4FF',
descKey: 'kb_ls_desc',
showQuery: false,
showPath: true,
showFilePattern: false,
showLineRange: false,
showTopK: false,
showLimit: true,
},
{
key: 'glob',
icon: <FileSearchOutlined />,
color: '#722ED1',
bgColor: '#F9F0FF',
descKey: 'kb_glob_desc',
showQuery: true,
showPath: false,
showFilePattern: false,
showLineRange: false,
showTopK: false,
showLimit: true,
},
{
key: 'cat',
icon: <ReadOutlined />,
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<ToolType>('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<KbFileEntry[] | null>(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 (
<div className='flex flex-col items-center justify-center py-12 text-gray-400'>
<SearchOutlined style={{ fontSize: 36, marginBottom: 12 }} />
<p className='text-sm m-0'>{tt('Search_Tools_Empty')}</p>
</div>
);
}
// For ls results — render from structured JSON entries
if (tool === 'ls') {
const entries = lsEntries;
if (!entries || entries.length === 0) {
return (
<div className='flex flex-col items-center justify-center py-12 text-gray-400'>
<FolderOpenOutlined style={{ fontSize: 36, marginBottom: 12 }} />
<p className='text-sm m-0'>{tt('No_Results')}</p>
</div>
);
}
// 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 (
<div className='font-mono text-[13px] leading-relaxed'>
{lsPath && (
<div className='flex items-center gap-2 text-gray-500 dark:text-gray-400 font-semibold text-xs uppercase tracking-wider py-1.5 border-b border-gray-200 dark:border-gray-700 mb-1'>
<FolderOpenOutlined style={{ fontSize: 12 }} />
{lsPath}/
</div>
)}
{sorted.map((entry, i) => {
if (entry.is_dir) {
return (
<div
key={i}
className='flex items-center gap-2 py-1 px-2 rounded hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors cursor-pointer group'
onClick={() => {
setPath(entry.path);
setTool('ls');
// Trigger ls search on the new directory
setTimeout(() => handleSearch(), 0);
}}
>
<FolderOpenOutlined style={{ color: '#1677FF', fontSize: 14 }} />
<span className='text-blue-600 dark:text-blue-400 font-medium group-hover:underline'>{entry.name}/</span>
{entry.child_count != null && (
<span className='text-gray-400 text-xs ml-auto'>{entry.child_count} items</span>
)}
</div>
);
}
return (
<div
key={i}
className='flex items-center gap-2 py-1 px-2 rounded hover:bg-teal-50 dark:hover:bg-teal-900/10 transition-colors cursor-pointer group'
onClick={() => openFile(entry.path)}
title={tt('kb_cat_desc' as any) || `Read ${entry.name}`}
>
<ReadOutlined style={{ color: '#13C2C2', fontSize: 13 }} />
<span className='text-gray-800 dark:text-gray-200 group-hover:text-teal-600 dark:group-hover:text-teal-400 group-hover:underline'>
{entry.name}
</span>
{entry.language && (
<span className='ml-auto text-[11px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'>
{entry.language}
</span>
)}
</div>
);
})}
</div>
);
}
// For glob results — render as a file listing with icons; files are clickable
if (tool === 'glob') {
const lines = result.split('\n').filter(Boolean);
return (
<div className='font-mono text-[13px] leading-relaxed'>
{lines.map((line, i) => {
const isDir = line.trim().endsWith('/');
const isHeader = line.startsWith('Directory:') || line.startsWith('Matching');
if (isHeader) {
return (
<div
key={i}
className='text-gray-500 dark:text-gray-400 font-semibold text-xs uppercase tracking-wider py-1.5 border-b border-gray-200 dark:border-gray-700 mb-1'
>
{line.trim()}
</div>
);
}
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 (
<div
key={i}
className='flex items-center gap-2 py-1 px-2 rounded hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors cursor-pointer group'
onClick={() => {
// Navigate into directory: set path and re-run ls
setPath(dirPath);
setTool('ls');
setTimeout(() => handleSearch(), 0);
}}
>
<FolderOpenOutlined style={{ color: '#1677FF', fontSize: 14 }} />
<span className='text-blue-600 dark:text-blue-400 font-medium group-hover:underline'>{dirName}/</span>
{parts[1] && <span className='text-gray-400 text-xs ml-auto'>{parts[1]}</span>}
</div>
);
}
// File entry — split name and language tag
const parts = line.trim().split('\t');
const fileName = parts[0]?.replace(/^\s+/, '') || '';
const lang = parts[1] || '';
return (
<div
key={i}
className='flex items-center gap-2 py-1 px-2 rounded hover:bg-teal-50 dark:hover:bg-teal-900/10 transition-colors cursor-pointer group'
onClick={() => openFile(fileName)}
title={tt('kb_cat_desc' as any) || `Read ${fileName}`}
>
<ReadOutlined style={{ color: '#13C2C2', fontSize: 13 }} />
<span className='text-gray-800 dark:text-gray-200 group-hover:text-teal-600 dark:group-hover:text-teal-400 group-hover:underline'>
{fileName}
</span>
{lang && (
<span className='ml-auto text-[11px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'>
{lang}
</span>
)}
</div>
);
})}
</div>
);
}
// For grep results — render with file path headers and highlighted line numbers
if (tool === 'grep') {
const lines = result.split('\n').filter(Boolean);
return (
<div className='font-mono text-[13px] leading-relaxed'>
{lines.map((line, i) => {
const isHeader = line.startsWith("'") && line.includes('matched');
if (isHeader) {
return (
<div
key={i}
className='flex items-center gap-2 text-amber-700 dark:text-amber-400 font-semibold text-xs py-2 border-b border-gray-200 dark:border-gray-700 mb-1'
>
<SearchOutlined style={{ fontSize: 12 }} />
{line.trim()}
</div>
);
}
// File path header (ends with ":" and not indented)
if (line.trim().endsWith(':') && !line.trim().startsWith(' ')) {
const filePath = line.trim().replace(/:$/, '');
return (
<div
key={i}
className='flex items-center gap-1.5 text-blue-600 dark:text-blue-400 font-semibold mt-3 mb-1 px-1 text-[12px] cursor-pointer hover:underline'
onClick={() => openFile(filePath)}
title={tt('kb_cat_desc' as any) || `Read ${filePath}`}
>
<ReadOutlined style={{ fontSize: 12 }} />
{filePath}
</div>
);
}
// Matched line with line number (format: " 123 | code" or " 123: code")
const match = line.match(/^\s*(\d+)[|:]\s*(.*)/);
if (match) {
return (
<div key={i} className='flex hover:bg-amber-50/50 dark:hover:bg-amber-900/10 transition-colors rounded'>
<span className='w-10 text-right pr-2 text-amber-500 dark:text-amber-400 select-none flex-shrink-0 text-[12px]'>
{match[1]}
</span>
<span className='text-gray-800 dark:text-gray-200 whitespace-pre flex-1 min-w-0'>{match[2]}</span>
</div>
);
}
return (
<div key={i} className='text-gray-700 dark:text-gray-300 whitespace-pre px-1'>
{line}
</div>
);
})}
</div>
);
}
// 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 (
<div className='flex flex-col items-center justify-center py-12 text-gray-400'>
<ReadOutlined style={{ fontSize: 36, marginBottom: 12 }} />
<p className='text-sm m-0'>{result}</p>
</div>
);
}
// 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 (
<div className='rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm'>
{/* File header bar */}
{filePath && (
<div className='flex items-center gap-2 px-3 py-2 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-800/70 border-b border-gray-200 dark:border-gray-700'>
<ReadOutlined style={{ color: '#13C2C2', fontSize: 14 }} />
<span className='text-sm font-mono font-medium text-gray-700 dark:text-gray-200 truncate'>
{filePath}
</span>
{fileLang && (
<span className='text-[11px] px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400 font-mono border border-teal-100 dark:border-teal-800/50'>
{fileLang}
</span>
)}
<span className='ml-auto flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500'>
<span className='flex items-center gap-1'>
<CodeOutlined style={{ fontSize: 11 }} />
{fileLines} lines
</span>
<Tooltip title={tt('Copy_Btn') || 'Copy'}>
<button
onClick={() => {
const codeText = effectiveLines
.map(l => {
const m = l.match(/^\s*(\d+)\s*[|:]\s?(.*)/);
return m ? m[2] : l;
})
.join('\n');
navigator.clipboard?.writeText(codeText);
message.success(tt('copy_to_clipboard_success'));
}}
className='text-gray-400 hover:text-teal-500 dark:hover:text-teal-400 transition-colors'
>
<CopyOutlined style={{ fontSize: 13 }} />
</button>
</Tooltip>
</span>
</div>
)}
{/* Code area */}
<div className='font-mono text-[13px] leading-[1.65] overflow-x-auto bg-white dark:bg-[#1a1d2e]'>
{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 (
<div
key={i}
className={`flex ${rowBg} hover:bg-teal-50/60 dark:hover:bg-teal-900/15 transition-colors group`}
>
<span className='w-14 text-right pr-3 text-gray-300 dark:text-gray-600 select-none flex-shrink-0 border-r border-gray-100 dark:border-gray-700/40 group-hover:text-teal-500 dark:group-hover:text-teal-400 group-hover:bg-teal-50/50 dark:group-hover:bg-teal-900/20'>
{match[1]}
</span>
<span className='text-gray-800 dark:text-gray-200 whitespace-pre pl-3 flex-1 min-w-0'>
{match[2] || ' '}
</span>
</div>
);
}
// Fallback: plain line with empty line number gutter
return (
<div
key={i}
className={`flex ${rowBg} hover:bg-teal-50/60 dark:hover:bg-teal-900/15 transition-colors group`}
>
<span className='w-14 flex-shrink-0 border-r border-gray-100 dark:border-gray-700/40 group-hover:bg-teal-50/50 dark:group-hover:bg-teal-900/20' />
<span className='text-gray-800 dark:text-gray-200 whitespace-pre pl-3 flex-1 min-w-0'>
{line || ' '}
</span>
</div>
);
})}
</div>
{/* Truncation notice */}
{truncationLine >= 0 && (
<div className='px-3 py-2 bg-amber-50/60 dark:bg-amber-900/10 border-t border-amber-100 dark:border-amber-800/40 text-xs text-amber-600 dark:text-amber-400 italic flex items-center gap-1.5'>
<InfoCircleOutlined style={{ fontSize: 12 }} />
{lines[truncationLine]?.trim()}
</div>
)}
</div>
);
}
// For semantic search results — render as markdown-like chunks
if (tool === 'semantic') {
const sections = result.split(/---\n/);
return (
<div className='space-y-3'>
{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 (
<div
key={i}
className='rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/50 p-3'
>
{header && (
<div className='text-xs font-semibold text-green-600 dark:text-green-400 mb-2 flex items-center gap-1.5'>
<CodeOutlined />
{header}
</div>
)}
{content && (
<pre className='text-[13px] text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-mono m-0 leading-relaxed'>
{content}
</pre>
)}
</div>
);
})}
</div>
);
}
// Fallback: plain text
return (
<pre className='text-[13px] text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-mono m-0 leading-relaxed'>
{result}
</pre>
);
};
return (
<div className='flex flex-col h-full'>
{/* Tool selector — card-style tabs */}
<div className='flex gap-2 mb-4 flex-wrap'>
{TOOLS.map(t => {
const active = tool === t.key;
return (
<Tooltip key={t.key} title={tt(t.descKey as any) || t.key}>
<button
onClick={() => {
setTool(t.key);
setLsEntries(null);
setResult('');
}}
className={`
flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all border
${
active
? 'border-transparent shadow-sm text-white'
: 'border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:border-gray-300 dark:hover:border-gray-500'
}
`}
style={active ? { backgroundColor: t.color } : undefined}
>
<span className='text-base'>{t.icon}</span>
<span>{tt(t.key as any) || t.key}</span>
</button>
</Tooltip>
);
})}
</div>
{/* Search inputs */}
<div className='flex flex-wrap gap-3 mb-4 items-end'>
{cfg.showQuery && (
<div className='flex flex-col'>
<span className='text-xs text-gray-500 dark:text-gray-400 mb-1'>{tt('Search_Query')}</span>
<Input
className='w-[300px]'
placeholder={tool === 'semantic' ? 'natural language query...' : 'keyword or pattern...'}
value={query}
onChange={e => setQuery(e.target.value)}
onPressEnter={handleSearch}
allowClear
/>
</div>
)}
{cfg.showPath && (
<div className='flex flex-col'>
<span className='text-xs text-gray-500 dark:text-gray-400 mb-1'>{tt('File_Path')}</span>
<Input
className='w-[240px]'
placeholder='src/auth/login.py or dir/'
value={path}
onChange={e => setPath(e.target.value)}
onPressEnter={handleSearch}
allowClear
/>
</div>
)}
{cfg.showFilePattern && (
<div className='flex flex-col'>
<span className='text-xs text-gray-500 dark:text-gray-400 mb-1'>{tt('File_Pattern')}</span>
<Input
className='w-[160px]'
placeholder='*.py'
value={filePattern}
onChange={e => setFilePattern(e.target.value)}
onPressEnter={handleSearch}
allowClear
/>
</div>
)}
{cfg.showLineRange && (
<>
<div className='flex flex-col'>
<span className='text-xs text-gray-500 dark:text-gray-400 mb-1'>{tt('Start_Line')}</span>
<InputNumber className='w-[100px]' min={1} value={startLine} onChange={v => setStartLine(v || 1)} />
</div>
<div className='flex flex-col'>
<span className='text-xs text-gray-500 dark:text-gray-400 mb-1'>{tt('End_Line')}</span>
<InputNumber className='w-[100px]' min={0} value={endLine} onChange={v => setEndLine(v || 0)} />
</div>
</>
)}
{cfg.showTopK && (
<>
<div className='flex flex-col'>
<span className='text-xs text-gray-500 dark:text-gray-400 mb-1'>{tt('Top_K')}</span>
<InputNumber className='w-[100px]' min={1} max={50} value={topK} onChange={v => setTopK(v || 5)} />
</div>
<div className='flex flex-col'>
<span className='text-xs text-gray-500 dark:text-gray-400 mb-1'>{tt('Score_Threshold')}</span>
<InputNumber
className='w-[120px]'
min={0}
max={1}
step={0.1}
value={scoreThreshold}
onChange={v => setScoreThreshold(v || 0)}
/>
</div>
</>
)}
{cfg.showLimit && (
<div className='flex flex-col'>
<span className='text-xs text-gray-500 dark:text-gray-400 mb-1'>Limit</span>
<InputNumber className='w-[100px]' min={1} max={500} value={limit} onChange={v => setLimit(v || 20)} />
</div>
)}
<Button
type='primary'
loading={loading}
onClick={handleSearch}
size='large'
icon={<SendOutlined />}
style={cfg ? { backgroundColor: cfg.color, borderColor: cfg.color } : undefined}
>
{tt('Search')}
</Button>
</div>
{/* Results */}
<div className='flex-1 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-[#1a1d2e]'>
<Spin spinning={loading}>
<div className='p-4 min-h-[200px] max-h-[500px] overflow-auto'>{renderResult()}</div>
</Spin>
</div>
</div>
);
}

View File

@@ -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: <FileTextOutlined style={{ fontSize: 22 }} />,
color: '#1677FF',
bgLight: '#E6F4FF',
bgDark: '#111D2C',
},
{
key: 'GIT_REPO',
icon: (
<svg width='22' height='22' viewBox='0 0 24 24' fill='currentColor'>
<path d='M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943' />
</svg>
),
color: '#24292F',
bgLight: '#F6F8FA',
bgDark: '#1C2128',
},
{
key: 'URL',
icon: <LinkOutlined style={{ fontSize: 22 }} />,
color: '#722ED1',
bgLight: '#F9F0FF',
bgDark: '#1E1326',
},
{
key: 'TEXT',
icon: <ReadOutlined style={{ fontSize: 22 }} />,
color: '#FA8C16',
bgLight: '#FFF7E6',
bgDark: '#2B2111',
},
{
key: 'YUQUEURL',
icon: (
<svg width='22' height='22' viewBox='64 64 896 896' fill='currentColor'>
<path d='M854.6 370.6c-9.9-39.4 9.9-102.2 73.4-124.4l-67.9-3.6s-25.7-90-143.6-98c-117.9-8.1-195-3-195-3s87.4 55.6 52.4 154.7c-25.6 52.5-65.8 95.6-108.8 144.7-1.3 1.3-2.5 2.6-3.5 3.7C319.4 605 96 860 96 860c245.9 64.4 410.7-6.3 508.2-91.1 20.5-.2 35.9-.3 46.3-.3 135.8 0 250.6-117.6 245.9-248.4-3.2-89.9-31.9-110.2-41.8-149.6z' />
</svg>
),
color: '#13C2C2',
bgLight: '#E6FFFB',
bgDark: '#112123',
},
{
key: 'NOTION',
icon: (
<svg width='22' height='22' viewBox='0 0 33 34' fill='currentColor'>
<path d='M3.8051 3.26755L20.5301 2.04319C22.5839 1.86808 23.1124 1.98538 24.4032 2.91756L29.7421 6.64773C30.623 7.28917 30.9165 7.46381 30.9165 8.16307V28.6217C30.9165 29.9038 30.4468 30.6622 28.804 30.7782L9.38138 31.9442C8.14825 32.0027 7.56135 31.8279 6.91556 31.0114L2.98395 25.9405C2.27947 25.0072 1.98651 24.3088 1.98651 23.4918V5.3068C1.98651 4.25826 2.45649 3.38366 3.8051 3.26755Z' />
<path
fillRule='evenodd'
clipRule='evenodd'
d='M3.64643 1.29903L20.3723 0.0746037C21.3849 -0.0114809 22.3097 -0.0595444 23.1918 0.139197C24.141 0.353054 24.86 0.807308 25.5578 1.31054L30.9002 5.04319L30.9158 5.05461C30.9547 5.08281 30.9968 5.11312 31.0417 5.14536C31.3674 5.37943 31.8354 5.71564 32.1631 6.09295C32.7252 6.73997 32.9031 7.45237 32.9031 8.16303V28.6217C32.9031 29.4467 32.763 30.5442 31.967 31.4425C31.1549 32.3592 30.0175 32.6721 28.9448 32.7479L28.9343 32.7486L9.48857 33.916L9.47602 33.9165C8.79263 33.949 8.01197 33.9383 7.24718 33.6609C6.41395 33.3586 5.82508 32.8277 5.35391 32.2318L5.34799 32.2243L1.40271 27.1359L1.39499 27.1257C0.55231 26.0092 0 24.8994 0 23.4918V5.30675C0 4.51862 0.17342 3.55089 0.82429 2.72219C1.51537 1.84231 2.52546 1.39554 3.6337 1.30013L3.64643 1.29903ZM20.5301 2.04315L3.80509 3.26752C2.45647 3.38361 1.9865 4.25823 1.9865 5.30675V23.4918C1.9865 24.3088 2.27946 25.0072 2.98394 25.9405L6.91553 31.0114C7.56133 31.8279 8.14822 32.0025 9.38137 31.944L28.804 30.7782C30.4468 30.6622 30.9165 29.9039 30.9165 28.6217V8.16303C30.9165 7.50025 30.6529 7.30878 29.8751 6.74438C29.8323 6.71333 29.788 6.68115 29.7421 6.6477L24.4032 2.91752C23.1124 1.98534 22.5839 1.86805 20.5301 2.04315Z'
/>
<path d='M20.5301 2.04318C22.5838 1.86808 23.1124 1.98541 24.4031 2.91757L29.7421 6.64778C30.623 7.28918 30.9167 7.46383 30.9167 8.16301V28.6217C30.9167 29.9039 30.4468 30.6622 28.804 30.7782L9.38127 31.944C8.14822 32.0025 7.56137 31.8279 6.9156 31.0114L2.98396 25.9405C2.27951 25.0072 1.98647 24.3088 1.98645 23.492V5.30687C1.98645 4.25835 2.45646 3.38365 3.80508 3.26754L20.5301 2.04318ZM28.9214 9.91165C28.9214 9.15462 28.6285 8.74625 27.9818 8.80449L8.91064 9.91165C8.20688 9.97045 7.9722 10.3204 7.9722 11.0779V28.4466C7.97222 29.3801 8.44147 29.7293 9.49759 29.6715L27.7471 28.6217C28.8037 28.5641 28.9214 27.922 28.9214 27.1636V9.91165ZM25.988 12.0096C26.1051 12.5347 25.988 13.0592 25.4588 13.1182L24.5795 13.2926V26.1151C23.816 26.5231 23.1122 26.7563 22.5256 26.7563C21.5863 26.7563 21.351 26.4646 20.6475 25.5908L14.8959 16.6149V25.2992L16.7158 25.7076C16.7158 25.7076 16.7159 26.7563 15.2475 26.7563L11.1994 26.9897C11.0818 26.7563 11.1995 26.1739 11.6101 26.0571L12.6664 25.7662V14.2837L11.1997 14.1668C11.0822 13.6417 11.3751 12.8847 12.1972 12.8259L16.5398 12.5349L22.5256 21.6277V13.5839L20.9993 13.4098C20.8821 12.7679 21.351 12.3018 21.9379 12.244L25.988 12.0096ZM23.816 4.43331C23.2877 4.02552 22.5835 3.55846 21.2343 3.67528L5.15507 4.84121C4.56875 4.89903 4.45158 5.19046 4.68509 5.42409L6.97519 7.23083C7.91323 7.98837 8.26511 7.93069 10.0265 7.81388L26.632 6.82259C26.9842 6.82259 26.6915 6.47348 26.5739 6.41536L23.816 4.43331Z' />
</svg>
),
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<boolean>(false);
const [storage, setStorage] = useState<string>();
const [dataSourceType, setDataSourceType] = useState<DataSourceType>('DOCUMENT');
const [strategies, setStrategies] = useState<Array<IChunkStrategyResponse>>([]);
const [files, setFiles] = useState<any[]>([]);
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<DataSourceType, { title: string; desc: string }> = 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 ── */}
<div className='mb-2 text-base font-semibold text-gray-700 dark:text-gray-300'>
{t('Knowledge_Space_Config')}
</div>
<Form.Item<FieldType>
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) {
>
<Input className='h-12' placeholder={t('Please_input_the_name')} />
</Form.Item>
<Form.Item<FieldType>
label={t('Storage')}
name='storage'
rules={[{ required: true, message: t('Please_select_the_storage') }]}
>
<Select className='mb-5 h-12' placeholder={t('Please_select_the_storage')} onChange={handleStorageChange}>
{spaceConfig?.map((item: any) => {
return (
<div className='grid grid-cols-2 gap-4'>
<Form.Item<FieldType> label={t('Storage')} name='storage' rules={[{ required: true }]}>
<Select className='h-12' placeholder={t('Please_select_the_storage')} onChange={handleStorageChange}>
{spaceConfig?.map((item: any) => (
<Select.Option key={item.name} value={item.name}>
{item.desc}
</Select.Option>
);
})}
</Select>
</Form.Item>
<Form.Item<FieldType>
label={t('Domain')}
name='field'
rules={[{ required: true, message: t('Please_select_the_domain_type') }]}
>
<Select className='mb-5 h-12' placeholder={t('Please_select_the_domain_type')}>
{spaceConfig
?.find((item: any) => item.name === storage)
?.domain_types.map((item: any) => {
return (
))}
</Select>
</Form.Item>
<Form.Item<FieldType> label={t('Domain')} name='domain'>
<Select className='h-12' placeholder={t('Please_select_the_domain_type')} allowClear>
{spaceConfig
?.find((item: any) => item.name === storage)
?.domain_types.map((item: any) => (
<Select.Option key={item.name} value={item.name}>
{item.desc}
</Select.Option>
);
})}
</Select>
</Form.Item>
<Form.Item<FieldType>
label={t('Description')}
name='description'
rules={[{ required: true, message: t('Please_input_the_description') }]}
>
))}
</Select>
</Form.Item>
</div>
<Form.Item<FieldType> label={t('Description')} name='description' rules={[{ required: true }]}>
<Input className='h-12' placeholder={t('Please_input_the_description')} />
</Form.Item>
{/* ── Section 1b: Index Methods ── */}
<div className='mb-3 text-base font-semibold text-gray-700 dark:text-gray-300'>
{t('Index_Method') || '索引方式'}
</div>
<Form.Item<FieldType> name='index_methods' initialValue={['VectorStore', 'FullText', 'KnowledgeGraph']}>
<Checkbox.Group
className='grid grid-cols-3 gap-3 w-full'
onChange={(values: string[]) => {
// 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 (
<Checkbox
key={method.value}
value={method.value}
disabled={isDisabled}
className={`
flex items-center gap-3 p-3 rounded-lg border-2 transition-all
${isDisabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer hover:border-blue-400'}
`}
>
<div className='flex flex-col'>
<span className='text-sm font-medium'>
{t('language') === 'en' ? method.labelEn : method.label}
</span>
<span className='text-xs text-gray-400'>
{t('language') === 'en' ? method.descEn : method.desc}
</span>
{method.onlyCode && <span className='text-[10px] text-orange-500'></span>}
</div>
</Checkbox>
);
})}
</Checkbox.Group>
</Form.Item>
<Divider />
{/* ── Section 2: Data Source — Card Grid ── */}
<div className='mb-3 text-base font-semibold text-gray-700 dark:text-gray-300'>
{t('Choose_a_Datasource_type')}
</div>
<Form.Item<FieldType> name='dataSourceType' rules={[{ required: true }]}>
<input type='hidden' />
</Form.Item>
<div className='grid grid-cols-3 sm:grid-cols-6 gap-3 mb-2'>
{DS_CARDS.map(card => {
const selected = dataSourceType === card.key;
const label = dataSourceLabels[card.key];
const isDisabled = card.disabled;
return (
<div
key={card.key}
onClick={() => {
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 && (
<div className='absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400 leading-none'>
{t('ds_coming_soon')}
</div>
)}
{/* Icon circle — always uses brand color */}
<div
className='flex items-center justify-center w-11 h-11 rounded-full transition-all duration-200'
style={{
background: isDisabled ? '#F5F5F5' : card.bgLight,
color: isDisabled ? '#D9D9D9' : card.color,
}}
>
<span style={{ color: isDisabled ? '#D9D9D9' : card.color }}>{card.icon}</span>
</div>
{/* Title */}
<span
className={`text-sm font-medium leading-tight text-center ${selected && !isDisabled ? 'text-gray-900 dark:text-white' : 'text-gray-600 dark:text-gray-400'} ${isDisabled ? 'text-gray-400 dark:text-gray-500' : ''}`}
>
{label?.title ?? card.key}
</span>
{/* Description */}
<span className='text-[11px] leading-[14px] text-center text-gray-400 dark:text-gray-500 line-clamp-2 min-h-[28px]'>
{label?.desc ?? ''}
</span>
{/* Selected indicator — check mark */}
{selected && !isDisabled && (
<div
className='absolute top-1.5 right-1.5 flex items-center justify-center w-5 h-5 rounded-full'
style={{ background: card.color }}
>
<svg width='12' height='12' viewBox='0 0 12 12' fill='none'>
<path
d='M3 6l2.5 2.5L9 4.5'
stroke='white'
strokeWidth='1.8'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
</div>
)}
</div>
);
})}
</div>
{/* ── Section 2b: Data source specific config ── */}
{isGitRepo && (
<div className='rounded-xl border border-gray-200 dark:border-gray-600 bg-gray-50/60 dark:bg-gray-800/40 p-5 mb-2 mt-3'>
<div className='flex items-center gap-2 mb-4'>
<svg width='16' height='16' viewBox='0 0 24 24' fill='#24292F' className='dark:fill-gray-300'>
<path d='M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943' />
</svg>
<span className='text-sm font-semibold text-gray-800 dark:text-gray-200'>Git Repository</span>
<span className='text-xs text-gray-400 dark:text-gray-500'> {t('ds_git_repo_desc')}</span>
</div>
<Form.Item<FieldType>
label={t('Repository_URL')}
name='repo_url'
rules={[{ required: true, message: t('Please_input_the_repo_url') }]}
>
<Input className='h-11' placeholder='https://github.com/org/repo.git' />
</Form.Item>
<div className='grid grid-cols-2 gap-4'>
<Form.Item<FieldType> label={t('Branch')} name='branch'>
<Input className='h-11' placeholder='main' />
</Form.Item>
<Form.Item<FieldType> label={t('Build_CodeGraph')} name='build_graph' valuePropName='checked'>
<Switch />
</Form.Item>
</div>
<div className='grid grid-cols-2 gap-4'>
<Form.Item<FieldType> label={t('Exclude_Dirs')} name='exclude_dirs'>
<Input className='h-11' placeholder='node_modules, .venv, dist' />
</Form.Item>
<Form.Item<FieldType> label={t('Include_Dirs')} name='include_dirs'>
<Input className='h-11' placeholder='src, docs' />
</Form.Item>
</div>
</div>
)}
{isDocument && (
<div className='rounded-xl border border-gray-200 dark:border-gray-600 bg-gray-50/60 dark:bg-gray-800/40 p-5 mb-2 mt-3'>
<div className='flex items-center gap-2 mb-4'>
<FileTextOutlined style={{ color: '#1677FF', fontSize: 16 }} />
<span className='text-sm font-semibold text-gray-800 dark:text-gray-200'>{t('Document')}</span>
<span className='text-xs text-gray-400 dark:text-gray-500'> {t('ds_document_desc')}</span>
</div>
<Form.Item<FieldType> label={t('Upload_a_document')} name='doc_files'>
<Dragger
multiple
beforeUpload={file => {
setFiles(prev => [...prev, file]);
return false;
}}
onRemove={file => {
setFiles(prev => prev.filter(f => f.uid !== file.uid));
}}
fileList={files}
>
<p className='ant-upload-drag-icon'>
<PlusIcon />
</p>
<p className='ant-upload-text text-sm text-gray-500 dark:text-gray-400'>
{t('click_or_drag_to_upload')}
</p>
<p className='ant-upload-hint text-xs text-gray-400'>PDF, PPT, Excel, Word, Text, Markdown, CSV</p>
</Dragger>
</Form.Item>
</div>
)}
{dataSourceType === 'URL' && (
<div className='rounded-xl border border-gray-200 dark:border-gray-600 bg-gray-50/60 dark:bg-gray-800/40 p-5 mb-2 mt-3'>
<div className='flex items-center gap-2 mb-4'>
<LinkOutlined style={{ color: '#722ED1', fontSize: 16 }} />
<span className='text-sm font-semibold text-gray-800 dark:text-gray-200'>{t('URL')}</span>
<span className='text-xs text-gray-400 dark:text-gray-500'> {t('ds_url_desc')}</span>
</div>
<Form.Item<FieldType> label='URL' name='web_url' rules={[{ required: true }]}>
<Input className='h-11' placeholder='https://example.com/page' />
</Form.Item>
</div>
)}
{dataSourceType === 'TEXT' && (
<div className='rounded-xl border border-gray-200 dark:border-gray-600 bg-gray-50/60 dark:bg-gray-800/40 p-5 mb-2 mt-3'>
<div className='flex items-center gap-2 mb-4'>
<ReadOutlined style={{ color: '#FA8C16', fontSize: 16 }} />
<span className='text-sm font-semibold text-gray-800 dark:text-gray-200'>{t('Text')}</span>
<span className='text-xs text-gray-400 dark:text-gray-500'> {t('ds_text_desc')}</span>
</div>
<Form.Item<FieldType> label={t('Text')} name='raw_text' rules={[{ required: true }]}>
<Input.TextArea rows={4} placeholder={t('Fill your raw text')} />
</Form.Item>
</div>
)}
{dataSourceType === 'YUQUEURL' && (
<div className='rounded-xl border border-gray-200 dark:border-gray-600 bg-gray-50/60 dark:bg-gray-800/40 p-5 mb-2 mt-3'>
<div className='flex items-center gap-2 mb-4'>
<svg width='16' height='16' viewBox='64 64 896 896' fill='#13C2C2' className='dark:fill-teal-400'>
<path d='M854.6 370.6c-9.9-39.4 9.9-102.2 73.4-124.4l-67.9-3.6s-25.7-90-143.6-98c-117.9-8.1-195-3-195-3s87.4 55.6 52.4 154.7c-25.6 52.5-65.8 95.6-108.8 144.7-1.3 1.3-2.5 2.6-3.5 3.7C319.4 605 96 860 96 860c245.9 64.4 410.7-6.3 508.2-91.1 20.5-.2 35.9-.3 46.3-.3 135.8 0 250.6-117.6 245.9-248.4-3.2-89.9-31.9-110.2-41.8-149.6z' />
</svg>
<span className='text-sm font-semibold text-gray-800 dark:text-gray-200'>{t('yuque')}</span>
<span className='text-xs text-gray-400 dark:text-gray-500'> {t('ds_yuque_desc')}</span>
</div>
<Form.Item<FieldType> label={t('yuque')} name='yuque_url' rules={[{ required: true }]}>
<Input className='h-11' placeholder='https://yuque.antfin.com/group/book/doc' />
</Form.Item>
<Form.Item<FieldType> label='Token' name='doc_token'>
<Input className='h-11' placeholder='yuque token' />
</Form.Item>
</div>
)}
<Divider />
{/* ── Section 3: Chunk Strategy ── */}
<div className='mb-2 text-base font-semibold text-gray-700 dark:text-gray-300'>{t('Segmentation')}</div>
<div className='grid grid-cols-3 gap-4'>
<Form.Item<FieldType> label={t('chunk_strategy')} name='chunk_strategy'>
<Select className='h-12'>
<Select.Option value='Automatic'>Automatic</Select.Option>
{strategies.map(s => (
<Select.Option key={s.strategy} value={s.strategy}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item<FieldType> label={t('chunk_size')} name='chunk_size'>
<Input className='h-12' placeholder='512' type='number' />
</Form.Item>
<Form.Item<FieldType> label={t('chunk_overlap')} name='chunk_overlap'>
<Input className='h-12' placeholder='50' type='number' />
</Form.Item>
</div>
<Form.Item>
<Button type='primary' htmlType='submit'>
{t('Next')}
</Button>
<div className='flex justify-end gap-3'>
<Button
onClick={() => {
form.resetFields();
setFiles([]);
}}
>
{t('cancel')}
</Button>
<Button type='primary' htmlType='submit' loading={spinning}>
{isGitRepo ? t('create_and_sync') : t('Next')}
</Button>
</div>
</Form.Item>
</Form>
</Spin>
);
}
/* ── Small helper: Plus icon for upload ── */
function PlusIcon() {
return (
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<rect x='4' y='4' width='40' height='40' rx='8' fill='currentColor' className='text-blue-50 dark:text-gray-700' />
<path
d='M24 16v16M16 24h16'
stroke='currentColor'
strokeWidth='2.5'
strokeLinecap='round'
className='text-blue-400 dark:text-blue-300'
/>
</svg>
);
}

View File

@@ -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<TaskResponse> => {
const res = await axios.put(`${BASE}/${taskId}`, body);
return unwrap<TaskResponse>(res);
},
[],
);
const updateTask = useCallback(async (taskId: string, body: UpdateTaskRequest): Promise<TaskResponse> => {
const res = await axios.put(`${BASE}/${taskId}`, body);
return unwrap<TaskResponse>(res);
}, []);
/** POST /api/v2/serve/scheduled-tasks/{task_id}/toggle — 启停任务 */
const toggleTask = useCallback(
async (taskId: string, enabled: boolean): Promise<TaskResponse> => {
const res = await axios.post(`${BASE}/${taskId}/toggle`, { enabled });
return unwrap<TaskResponse>(res);
},
[],
);
const toggleTask = useCallback(async (taskId: string, enabled: boolean): Promise<TaskResponse> => {
const res = await axios.post(`${BASE}/${taskId}/toggle`, { enabled });
return unwrap<TaskResponse>(res);
}, []);
/** DELETE /api/v2/serve/scheduled-tasks/{task_id} — 删除任务 */
const deleteTask = useCallback(async (taskId: string): Promise<void> => {
@@ -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<RunResponse[]> => {
const res = await axios.get(`${BASE}/${taskId}/runs`, {
params: { limit, offset },
});
return unwrap<RunResponse[]>(res) ?? [];
},
[],
);
const listRuns = useCallback(async (taskId: string, limit = 50, offset = 0): Promise<RunResponse[]> => {
const res = await axios.get(`${BASE}/${taskId}/runs`, {
params: { limit, offset },
});
return unwrap<RunResponse[]>(res) ?? [];
}, []);
/** GET /api/v2/serve/scheduled-tasks/{task_id}/runs/{run_id} — 单次执行详情 */
const getRun = useCallback(
async (taskId: string, runId: string): Promise<RunResponse> => {
const res = await axios.get(`${BASE}/${taskId}/runs/${runId}`);
return unwrap<RunResponse>(res);
},
[],
);
const getRun = useCallback(async (taskId: string, runId: string): Promise<RunResponse> => {
const res = await axios.get(`${BASE}/${taskId}/runs/${runId}`);
return unwrap<RunResponse>(res);
}, []);
return {
createTask,

View File

@@ -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) => {

View File

@@ -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',

View File

@@ -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;

View File

@@ -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: '需要您的确认',

View File

@@ -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;

View File

@@ -128,11 +128,7 @@ const ChatPage: React.FC<ChatPageProps> = ({
<div className='flex-shrink-0'>
{pendingQuestion && onReplyQuestion && onRejectQuestion && (
<div className='mx-auto max-w-3xl px-4 pt-2'>
<QuestionDock
request={pendingQuestion}
onReply={onReplyQuestion}
onReject={onRejectQuestion}
/>
<QuestionDock request={pendingQuestion} onReply={onReplyQuestion} onReject={onRejectQuestion} />
</div>
)}
<StandaloneChatInput

View File

@@ -54,6 +54,7 @@ export type StepType =
| 'html'
| 'sql'
| 'question'
| 'kb'
| 'other';
export interface ExecutionStep {
@@ -165,6 +166,8 @@ const getStepIcon = (type: StepType, status: StepStatus) => {
return <QuestionCircleOutlined className={classNames(iconClass, 'text-amber-500')} />;
case 'sql':
return <ConsoleSqlOutlined className={classNames(iconClass, 'text-emerald-600')} />;
case 'kb':
return <FolderOpenOutlined className={classNames(iconClass, 'text-teal-500')} />;
default:
return <FileTextOutlined className={classNames(iconClass, 'text-gray-500')} />;
}
@@ -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<{
</span>
<div className='flex flex-col min-w-0 flex-1'>
<span className='text-sm font-medium text-gray-800 dark:text-gray-200 truncate'>{step.title}</span>
{detailLine && <span className='text-[11px] text-gray-500 dark:text-gray-400 truncate'>{detailLine}</span>}
{thought &&
(() => {
const [intention, ...reasonLines] = (typeof thought === 'string' ? thought : '').split('\n');
const reason = reasonLines.join('\n').trim();
return (
<div className='mt-0.5'>
<span className='text-[12px] leading-4 text-slate-600 dark:text-slate-300'>
{step.status === 'running' ? <StreamingText text={intention} /> : intention}
</span>
{reason && (
<span className='block text-[11px] leading-4 text-slate-400 dark:text-slate-500'>{reason}</span>
)}
</div>
);
})()}
{!thought && step.subtitle && (
<span className='text-[11px] text-gray-500 dark:text-gray-400 truncate'>{step.subtitle}</span>
)}
</div>
<div className='flex-shrink-0'>
{step.status === 'pending' && <ClockCircleOutlined className='text-xs text-gray-400' />}
@@ -917,7 +940,6 @@ const SectionBlock: React.FC<{
{section.steps.map(step => (
<React.Fragment key={step.id}>
{stepThoughts?.[step.id] && <ThoughtBubble text={stepThoughts[step.id]} />}
{step.description?.includes('Action: get_skill_resource') ? (
<SkillResourceCard
step={step}
@@ -925,7 +947,12 @@ const SectionBlock: React.FC<{
onClick={() => onStepClick(step.id)}
/>
) : (
<StepCard step={step} isActive={step.id === activeStepId} onClick={() => onStepClick(step.id)} />
<StepCard
step={step}
isActive={step.id === activeStepId}
onClick={() => onStepClick(step.id)}
thought={stepThoughts?.[step.id]}
/>
)}
{step.description?.includes('Observation:') && <ObservationFormatter observation={step.description} />}
</React.Fragment>

View File

@@ -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 <PlayCircleOutlined className='text-indigo-500' />;
case 'sql':
return <ConsoleSqlOutlined className='text-emerald-600' />;
case 'kb':
return <FolderOpenOutlined className='text-teal-500' />;
default:
return <FileTextOutlined className='text-gray-500' />;
}
@@ -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<string, string> = {
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<string, React.ReactNode> = {
kb_ls: <FolderOpenOutlined className='text-teal-500' />,
kb_glob: <FileSearchOutlined className='text-teal-500' />,
kb_grep: <SearchOutlined className='text-teal-500' />,
kb_cat: <FileTextOutlined className='text-teal-500' />,
semantic_search: <CodeOutlined className='text-teal-500' />,
};
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 (
<>
<div className='rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden bg-white dark:bg-[#1a1d2e]'>
<div className='flex items-center gap-2 px-3 py-2 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-800/70 border-b border-gray-200 dark:border-gray-700'>
<FolderOpenOutlined style={{ color: '#1677FF', fontSize: 14 }} />
<span className='text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider'>
{t('kb_ls') || 'Files'}
</span>
<span className='ml-auto text-[11px] text-gray-400'>{entries.length} entries</span>
</div>
<div className='font-mono text-[13px] leading-relaxed max-h-[400px] overflow-auto'>
{sorted.map((e, i) => (
<div
key={i}
className='flex items-center gap-2 py-1 px-3 hover:bg-blue-50/60 dark:hover:bg-blue-900/15 transition-colors'
>
{e.isDir ? (
<FolderOpenOutlined style={{ color: '#1677FF', fontSize: 13 }} />
) : (
<FileTextOutlined style={{ color: '#13C2C2', fontSize: 13 }} />
)}
<span
className={e.isDir
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-gray-800 dark:text-gray-200'}
>
{e.name}{e.isDir ? '/' : ''}
</span>
{e.lang && (
<span className='ml-auto text-[11px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'>
{e.lang}
</span>
)}
</div>
))}
</div>
</div>
);
}
/** 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 (
<div className='rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm'>
<div className='flex items-center gap-2 px-3 py-2 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-800/70 border-b border-gray-200 dark:border-gray-700'>
<FileTextOutlined style={{ color: '#13C2C2', fontSize: 14 }} />
<span className='text-sm font-mono font-medium text-gray-700 dark:text-gray-200 truncate'>{filePath}</span>
{fileLang && (
<span className='text-[11px] px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400 font-mono border border-teal-100 dark:border-teal-800/50'>
{fileLang}
</span>
)}
<span className='ml-auto flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500'>
<span className='flex items-center gap-1'>
<CodeOutlined style={{ fontSize: 11 }} />
{fileLines} lines
</span>
<Tooltip title={t('Copy_Btn') || 'Copy'}>
<button
onClick={() => {
const codeText = codeLines
.map(l => {
const m = l.match(/^\s*(\d+)\s*[|:]\s?(.*)/);
return m ? m[2] : l;
})
.join('\n');
navigator.clipboard?.writeText(codeText);
message.success(t('copy_to_clipboard_success'));
}}
className='text-gray-400 hover:text-teal-500 dark:hover:text-teal-400 transition-colors'
>
<CopyOutlined style={{ fontSize: 13 }} />
</button>
</Tooltip>
</span>
</div>
<div className='font-mono text-[13px] leading-[1.65] overflow-x-auto bg-white dark:bg-[#1a1d2e] max-h-[500px] overflow-auto'>
{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 (
<div key={i} className={`flex ${rowBg} hover:bg-teal-50/60 dark:hover:bg-teal-900/15 transition-colors group`}>
<span className='w-14 text-right pr-3 text-gray-300 dark:text-gray-600 select-none flex-shrink-0 border-r border-gray-100 dark:border-gray-700/40 group-hover:text-teal-500 dark:group-hover:text-teal-400 group-hover:bg-teal-50/50 dark:group-hover:bg-teal-900/20'>
{m[1]}
</span>
<span className='text-gray-800 dark:text-gray-200 whitespace-pre pl-3 flex-1 min-w-0'>{m[2] || ' '}</span>
</div>
);
}
return (
<div key={i} className={`flex ${rowBg} hover:bg-teal-50/60 dark:hover:bg-teal-900/15 transition-colors group`}>
<span className='w-14 flex-shrink-0 border-r border-gray-100 dark:border-gray-700/40 group-hover:bg-teal-50/50 dark:group-hover:bg-teal-900/20' />
<span className='text-gray-800 dark:text-gray-200 whitespace-pre pl-3 flex-1 min-w-0'>{line || ' '}</span>
</div>
);
})}
</div>
{truncationIdx >= 0 && (
<div className='px-3 py-2 bg-amber-50/60 dark:bg-amber-900/10 border-t border-amber-100 dark:border-amber-800/40 text-xs text-amber-600 dark:text-amber-400 italic'>
{lines[truncationIdx]?.trim()}
</div>
)}
</div>
);
}
/** 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 (
<div className='rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden bg-white dark:bg-[#1a1d2e]'>
<div className='flex items-center gap-2 px-3 py-2 bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/10 border-b border-amber-100 dark:border-amber-800/40'>
<SearchOutlined style={{ color: '#FA8C16', fontSize: 14 }} />
<span className='text-xs font-semibold text-amber-700 dark:text-amber-400 uppercase tracking-wider'>
{t('kb_grep') || 'Grep'}
</span>
</div>
<div className='font-mono text-[13px] leading-relaxed max-h-[500px] overflow-auto'>
{lines.map((line, i) => {
// File path header (ends with ':')
if (line.trim().endsWith(':') && !/^\s*\d+\s*[|:]/.test(line)) {
const fp = line.trim().replace(/:$/, '');
return (
<div key={i} className='flex items-center gap-1.5 text-blue-600 dark:text-blue-400 font-semibold px-3 py-1.5 border-b border-gray-100 dark:border-gray-800 text-[12px] bg-gray-50/50 dark:bg-white/[0.02]'>
<FileTextOutlined style={{ fontSize: 12 }} />
{fp}
</div>
);
}
const m = line.match(/^\s*(\d+)\s*[|:]\s?(.*)/);
if (m) {
return (
<div key={i} className='flex hover:bg-amber-50/40 dark:hover:bg-amber-900/10 transition-colors'>
<span className='w-12 text-right pr-2 text-amber-500 dark:text-amber-400 select-none flex-shrink-0 text-[12px] border-r border-gray-100 dark:border-gray-700/40'>
{m[1]}
</span>
<span className='text-gray-800 dark:text-gray-200 whitespace-pre pl-3 flex-1 min-w-0'>{m[2]}</span>
</div>
);
}
return (
<div key={i} className='text-gray-700 dark:text-gray-300 whitespace-pre px-3 py-0.5'>{line}</div>
);
})}
</div>
</div>
);
}
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' && (
<CodePreview
code={String(content)}
@@ -1779,7 +2006,9 @@ const ManusRightPanel: React.FC<ManusRightPanelProps> = ({
<div
className={classNames(
'flex-1 overflow-y-auto flex flex-col min-h-0',
panelView === 'html-preview' || panelView === 'image-preview' || panelView === 'skill-preview' ? 'p-0' : 'p-5 space-y-4',
panelView === 'html-preview' || panelView === 'image-preview' || panelView === 'skill-preview'
? 'p-0'
: 'p-5 space-y-4',
)}
>
{panelView === 'skill-preview' && skillName ? (
@@ -1931,6 +2160,7 @@ const ManusRightPanel: React.FC<ManusRightPanelProps> = ({
'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<ManusRightPanelProps> = ({
</div>
</div>
);
})()) || (
})()) ||
(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!] || <FolderOpenOutlined className='text-teal-500' />;
// Parse action input params
let params: Record<string, any> = {};
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 (
<div className='rounded-xl border border-teal-200 dark:border-teal-800/40 overflow-hidden bg-white dark:bg-[#1a1b1e]'>
<div className='flex items-center gap-2.5 px-4 py-2.5 bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/10 border-b border-teal-100 dark:border-teal-800/40'>
<div className='flex-shrink-0 w-7 h-7 rounded-md bg-teal-100 dark:bg-teal-900/40 flex items-center justify-center'>
{kbIcon}
</div>
<span className='text-sm font-semibold text-teal-700 dark:text-teal-400'>{kbLabel}</span>
</div>
{paramEntries.length > 0 && (
<div className='px-4 py-3 space-y-1.5'>
{paramEntries.map(([k, v]) => (
<div key={k} className='flex items-start gap-2 text-sm'>
<span className='text-[11px] font-mono text-gray-400 dark:text-gray-500 mt-0.5 min-w-[80px] flex-shrink-0'>
{k}:
</span>
<span className='font-mono text-gray-800 dark:text-gray-200 break-all'>
{typeof v === 'string' ? v : JSON.stringify(v)}
</span>
</div>
))}
</div>
)}
</div>
);
})()) ||
(
<div className='text-xs text-gray-500 dark:text-gray-400 font-mono whitespace-pre-wrap bg-gray-50 dark:bg-[#161719] rounded-lg px-3 py-2'>
{activeStep.detail}
</div>
@@ -2207,7 +2484,7 @@ const ManusRightPanel: React.FC<ManusRightPanelProps> = ({
) : group.type === 'html-tabbed' ? (
<HtmlTabbedRenderer key={`html-tabbed-${gIdx}`} code={group.code} html={group.html} />
) : (
<OutputRenderer key={`output-${gIdx}`} output={group.output} index={gIdx} />
<OutputRenderer key={`output-${gIdx}`} output={group.output} index={gIdx} action={activeStep?.action} />
);
})}
</div>

View File

@@ -195,7 +195,9 @@ const ManusStepCard: React.FC<StepCardProps> = ({
{stats.deletions !== undefined && stats.deletions > 0 && (
<span className='text-red-500 dark:text-red-400'>-{stats.deletions}</span>
)}
{stats.files !== undefined && <span className='text-gray-400'>{t('files_count', { count: stats.files })}</span>}
{stats.files !== undefined && (
<span className='text-gray-400'>{t('files_count', { count: stats.files })}</span>
)}
</div>
)}
</div>

View File

@@ -26,12 +26,8 @@ interface QuestionDockProps {
const QuestionDock: React.FC<QuestionDockProps> = ({ request, onReply, onReject }) => {
const { t } = useTranslation();
const [selected, setSelected] = useState<string[][]>(
() => request.questions.map(() => []),
);
const [customInputs, setCustomInputs] = useState<string[]>(
() => request.questions.map(() => ''),
);
const [selected, setSelected] = useState<string[][]>(() => request.questions.map(() => []));
const [customInputs, setCustomInputs] = useState<string[]>(() => request.questions.map(() => ''));
const canSubmit = useMemo(() => {
return request.questions.every((q, i) => {
@@ -98,9 +94,7 @@ const QuestionDock: React.FC<QuestionDockProps> = ({ request, onReply, onReject
<span className='flex h-6 w-6 items-center justify-center rounded-md bg-amber-50 ring-1 ring-amber-200/80 dark:bg-amber-500/10 dark:ring-amber-500/20'>
<QuestionCircleFilled className='text-[12px] text-amber-500' />
</span>
<span className='text-sm font-semibold leading-5 tracking-tight'>
{t('user_confirmation')}
</span>
<span className='text-sm font-semibold leading-5 tracking-tight'>{t('user_confirmation')}</span>
</div>
<button
onClick={handleDismiss}
@@ -124,12 +118,13 @@ const QuestionDock: React.FC<QuestionDockProps> = ({ request, onReply, onReject
</div>
)}
{/* Question text */}
<div className='mb-2 text-sm leading-5 text-slate-800 dark:text-slate-100'>
{q.question}
</div>
<div className='mb-2 text-sm leading-5 text-slate-800 dark:text-slate-100'>{q.question}</div>
{/* Options */}
<div className='grid gap-2' style={{ gridTemplateColumns: `repeat(${q.options.length <= 3 ? q.options.length : 2}, 1fr)` }}>
{q.options.map((opt) => {
<div
className='grid gap-2'
style={{ gridTemplateColumns: `repeat(${q.options.length <= 3 ? q.options.length : 2}, 1fr)` }}
>
{q.options.map(opt => {
const isSelected = selected[qi].includes(opt.label);
const isMultiple = !!q.multiple;
return (
@@ -142,22 +137,19 @@ const QuestionDock: React.FC<QuestionDockProps> = ({ request, onReply, onReject
: 'border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:hover:bg-white/8'
}`}
>
<span className={`mt-0.5 flex-shrink-0 text-[14px] ${
isSelected
? 'text-sky-500 dark:text-sky-400'
: 'text-slate-300 dark:text-slate-600'
}`}>
{isMultiple
? <CheckSquareFilled />
: <CheckCircleFilled />
}
<span
className={`mt-0.5 flex-shrink-0 text-[14px] ${
isSelected ? 'text-sky-500 dark:text-sky-400' : 'text-slate-300 dark:text-slate-600'
}`}
>
{isMultiple ? <CheckSquareFilled /> : <CheckCircleFilled />}
</span>
<span className='flex flex-col gap-0.5 overflow-hidden'>
<span className={`text-[13px] font-medium leading-5 ${
isSelected
? 'text-sky-700 dark:text-sky-300'
: 'text-slate-700 dark:text-slate-200'
}`}>
<span
className={`text-[13px] font-medium leading-5 ${
isSelected ? 'text-sky-700 dark:text-sky-300' : 'text-slate-700 dark:text-slate-200'
}`}
>
{opt.label}
</span>
{opt.description && (
@@ -177,7 +169,7 @@ const QuestionDock: React.FC<QuestionDockProps> = ({ request, onReply, onReject
type='text'
placeholder={t('or_input_custom')}
value={customInputs[qi]}
onChange={(e) => setCustom(qi, e.target.value)}
onChange={e => setCustom(qi, e.target.value)}
className='w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-[13px] leading-4 text-slate-700 placeholder-slate-400 outline-none transition-colors focus:border-sky-400 focus:ring-1 focus:ring-sky-400/30 dark:border-white/10 dark:bg-white/5 dark:text-slate-200 dark:placeholder-slate-500 dark:focus:border-sky-500/50'
/>
</div>
@@ -210,4 +202,4 @@ const QuestionDock: React.FC<QuestionDockProps> = ({ request, onReply, onReject
);
};
export default QuestionDock;
export default QuestionDock;

View File

@@ -41,12 +41,7 @@ const TypeChip: React.FC<{ type?: string }> = ({ type }) => (
);
const StatusDot: React.FC<{ state?: 'active' | 'inactive' | 'not_mcp' }> = ({ state }) => {
const cls =
state === 'active'
? 'bg-emerald-500'
: state === 'inactive'
? 'bg-amber-500'
: 'bg-gray-400';
const cls = state === 'active' ? 'bg-emerald-500' : state === 'inactive' ? 'bg-amber-500' : 'bg-gray-400';
return <span className={`inline-block w-1.5 h-1.5 rounded-full ${cls}`} />;
};
@@ -142,10 +137,7 @@ const ConnectorToolsModal: React.FC<ConnectorToolsModalProps> = ({ open, instanc
className='connector-tools-modal'
maskClosable
>
<div
className='flex flex-col overflow-hidden rounded-lg bg-white'
style={{ height: 'min(720px, 80vh)' }}
>
<div className='flex flex-col overflow-hidden rounded-lg bg-white' style={{ height: 'min(720px, 80vh)' }}>
{/* ---------------- Header ---------------- */}
<header className='flex items-center gap-3 px-6 py-3.5 border-b border-gray-100 bg-gradient-to-br from-white/95 to-violet-50/60'>
<div className='flex-shrink-0 w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-fuchsia-600 flex items-center justify-center text-white text-base shadow-sm ring-2 ring-white/40'>
@@ -309,10 +301,7 @@ const ConnectorToolsModal: React.FC<ConnectorToolsModalProps> = ({ open, instanc
/* Sub-components */
/* -------------------------------------------------------------------- */
const ToolDetail: React.FC<{ tool: ConnectorToolSummary; onCopy: (name: string) => void }> = ({
tool,
onCopy,
}) => {
const ToolDetail: React.FC<{ tool: ConnectorToolSummary; onCopy: (name: string) => void }> = ({ tool, onCopy }) => {
const { t } = useTranslation();
const argEntries = Object.entries(tool.args ?? {});
@@ -346,13 +335,8 @@ const ToolDetail: React.FC<{ tool: ConnectorToolSummary; onCopy: (name: string)
</button>
</Tooltip>
</div>
<p className='text-[14px] text-gray-700 leading-relaxed whitespace-pre-wrap mb-6'>
{tool.description || '—'}
</p>
<p className='text-[14px] text-gray-700 leading-relaxed whitespace-pre-wrap mb-6'>{tool.description || '—'}</p>
<div className='h-px bg-gray-100 mb-5' />
<div className='flex items-center gap-2 mb-3'>
<h4 className='text-[13px] font-semibold text-gray-800 m-0'>{t('connector.tools.inputSchema')}</h4>
{hasParams && (
@@ -362,16 +346,13 @@ const ToolDetail: React.FC<{ tool: ConnectorToolSummary; onCopy: (name: string)
</>
)}
</div>
{wholeTruncated || truncatedEntry ? (
<Alert
type='info'
showIcon
message={t('connector.tools.argsTruncated', {
byteCount:
(tool.args as unknown as ConnectorToolArgTruncated)?.byte_count ??
truncatedEntry?.byte_count ??
0,
(tool.args as unknown as ConnectorToolArgTruncated)?.byte_count ?? truncatedEntry?.byte_count ?? 0,
})}
/>
) : !hasParams ? (
@@ -411,7 +392,6 @@ const ToolDetail: React.FC<{ tool: ConnectorToolSummary; onCopy: (name: string)
</table>
</div>
)}
<div className='h-12' /> {/* Bottom breathing room */}
</div>
);
@@ -459,10 +439,7 @@ const ErrorState: React.FC<{ error: string; onRetry: () => void }> = ({ error, o
message={t('connector.tools.errorTitle')}
description={error}
action={
<button
onClick={onRetry}
className='text-violet-600 hover:text-violet-700 text-[12px] font-medium px-2 py-1'
>
<button onClick={onRetry} className='text-violet-600 hover:text-violet-700 text-[12px] font-medium px-2 py-1'>
{t('connector.tools.errorRetry')}
</button>
}

View File

@@ -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]);

View File

@@ -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,

View File

@@ -126,7 +126,7 @@
},
"resolutions": {
"d3-color": "2",
"d3-array": "2",
"d3-array": "3",
"d3-shape": "2",
"d3-path": "2",
"d3-dsv": "2",

View File

@@ -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<ISpace | null>(null);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('files');
// Stats state
const [stats, setStats] = useState<KnowledgeSpaceStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
// Code graph state
const graphIframeRef = useRef<HTMLIFrameElement | null>(null);
const [graphHtml, setGraphHtml] = useState<string | null>(null);
const [graphLoading, setGraphLoading] = useState(false);
const [graphError, setGraphError] = useState<string | null>(null);
// Document detail state (when a doc is selected in the tree)
const [selectedDoc, setSelectedDoc] = useState<IDocument | null>(null);
const [chunks, setChunks] = useState<any[]>([]);
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: (
<span className='flex items-center gap-1.5'>
<FileTextOutlined />
{t('File_View')}
</span>
),
value: 'files',
},
{
label: (
<span className='flex items-center gap-1.5'>
<ApartmentOutlined />
{t('Code_Graph_View')}
</span>
),
value: 'graph',
},
];
/** Render the metadata stats panel */
const renderStatsPanel = () => {
if (statsLoading) {
return (
<div className='px-4 py-3 border-b dark:border-gray-700 bg-gray-50 dark:bg-[#1e2130]'>
<Spin size='small' />
</div>
);
}
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 (
<div className='px-4 py-3 border-b dark:border-gray-700 bg-gray-50 dark:bg-[#1e2130]'>
{/* Tags row */}
<div className='flex items-center gap-2 mb-3 flex-wrap'>
{stats.domain_type && <Tag color='blue'>{stats.domain_type}</Tag>}
{stats.vector_type && <Tag color='purple'>{stats.vector_type}</Tag>}
{stats.index_methods &&
stats.index_methods.map((m: string) => (
<Tag key={m} color='geekblue'>
{m}
</Tag>
))}
{stats.repo_url && (
<Tag icon={<GitlabOutlined />} color='volcano'>
{stats.branch || 'main'}
</Tag>
)}
</div>
{/* Stats row */}
<Row gutter={16}>
<Col span={4}>
<Statistic
title={t('Documents')}
value={stats.document_count}
prefix={<FileTextOutlined />}
valueStyle={{ fontSize: 18 }}
/>
</Col>
<Col span={4}>
<Statistic
title={t('Chunks')}
value={stats.chunk_count}
prefix={<PartitionOutlined />}
valueStyle={{ fontSize: 18 }}
/>
</Col>
{hasGraphStats && (
<>
<Col span={4}>
<Statistic
title={t('Graph_Nodes')}
value={stats.graph_vertex_count ?? 0}
prefix={<NodeIndexOutlined />}
valueStyle={{ fontSize: 18 }}
/>
</Col>
<Col span={4}>
<Statistic
title={t('Graph_Edges')}
value={stats.graph_edge_count ?? 0}
prefix={<ShareAltOutlined />}
valueStyle={{ fontSize: 18 }}
/>
</Col>
<Col span={4}>
<Statistic
title={t('Communities')}
value={stats.graph_community_count ?? 0}
prefix={<TeamOutlined />}
valueStyle={{ fontSize: 18 }}
/>
</Col>
</>
)}
</Row>
{/* Sync progress bar (for GitRepo spaces) */}
{hasSyncProgress && (
<div className='mt-3'>
<div className='flex items-center justify-between mb-1'>
<span className='text-xs text-gray-500 dark:text-gray-400'>{t('Sync_Progress')}</span>
<span className='text-xs text-gray-500 dark:text-gray-400'>
{stats.sync_finished || 0}/{stats.sync_total_files} {t('Files')}
{stats.sync_failed ? ` (${stats.sync_failed} failed)` : ''}
</span>
</div>
<Progress
percent={syncPercent}
size='small'
status={
stats.sync_status === 'FAILED' ? 'exception' : stats.sync_status === 'RUNNING' ? 'active' : 'success'
}
/>
</div>
)}
</div>
);
};
return (
<div className='h-full flex flex-col'>
{/* Header */}
<div className='flex items-center justify-between px-4 py-3 border-b dark:border-gray-700 bg-white dark:bg-[#232734]'>
<div className='flex items-center gap-3'>
<Button
type='text'
icon={<ArrowLeftOutlined />}
onClick={selectedDoc ? handleBackToDocList : handleBack}
className='text-gray-500 hover:text-gray-800 dark:hover:text-gray-200'
/>
<div>
<h2 className='text-base font-semibold text-gray-800 dark:text-gray-200 m-0'>
{selectedDoc ? selectedDoc.doc_name : currentSpace?.name || spaceName}
</h2>
<p className='text-xs text-gray-400 dark:text-gray-500 m-0 mt-0.5 truncate max-w-[400px]'>
{selectedDoc
? `${selectedDoc.chunk_size} chunks · ${selectedDoc.doc_type || 'Document'}`
: currentSpace?.desc || ''}
</p>
</div>
</div>
<Space>
<Segmented options={viewModeOptions} value={viewMode} onChange={val => setViewMode(val as ViewMode)} />
</Space>
</div>
{/* Stats Panel */}
{renderStatsPanel()}
{/* Body: Tree + Content */}
<div className='flex-1 flex overflow-hidden'>
{/* Tree Sidebar */}
<div className='w-[280px] min-w-[280px] border-r dark:border-gray-700 bg-white dark:bg-[#1e2130] overflow-hidden'>
<KnowledgeTree
currentSpaceName={spaceName}
currentSpace={currentSpace}
onSelectDocument={handleSelectDocument}
onSelectSpace={handleSelectSpace}
/>
</div>
{/* Content Area */}
<div className='flex-1 overflow-auto bg-gray-50 dark:bg-[#232734]'>
{loading ? (
<div className='flex items-center justify-center h-full'>
<Spin size='large' />
</div>
) : viewMode === 'graph' ? (
/* Code Graph View */
<div className='h-full w-full flex flex-col bg-white dark:bg-[#232734]'>
{graphLoading ? (
<div className='flex items-center justify-center h-full'>
<Spin size='large' />
</div>
) : graphError ? (
<div className='flex flex-col items-center justify-center h-full text-gray-400 gap-3'>
<CodeOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />
<p className='text-sm'>{graphError}</p>
</div>
) : graphHtml ? (
<iframe
ref={graphIframeRef}
title='code-graph'
srcDoc={graphHtml}
className='flex-1 w-full border-0'
sandbox='allow-scripts allow-same-origin'
/>
) : (
<div className='flex flex-col items-center justify-center h-full text-gray-400 gap-3'>
<CodeOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />
<p className='text-sm'>{t('Code_Graph_View_desc')}</p>
</div>
)}
</div>
) : selectedDoc ? (
/* Document Content View — show chunks */
<div className='p-4'>
<div className='mb-4 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<FileTextOutlined style={{ color: '#1677FF', fontSize: 16 }} />
<span className='text-sm font-semibold text-gray-800 dark:text-gray-200'>{selectedDoc.doc_name}</span>
<Tag>{selectedDoc.doc_type}</Tag>
<Tag color='blue'>{selectedDoc.chunk_size} chunks</Tag>
</div>
</div>
<Spin spinning={chunksLoading}>
{chunks.length > 0 ? (
<div className='grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4'>
{chunks.map((chunk: any, index: number) => (
<Card
key={chunk.id || index}
size='small'
className='rounded-lg dark:bg-[#2a2d3a] hover:shadow-md transition-shadow'
title={
<Space>
<Tag color='blue'># {index + 1}</Tag>
</Space>
}
>
<div className='text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-8'>
{chunk.content}
</div>
{chunk.meta_info && (
<div className='mt-2 pt-2 border-t dark:border-gray-600'>
<span className='text-xs text-gray-400'>
{typeof chunk.meta_info === 'string'
? chunk.meta_info.slice(0, 200)
: JSON.stringify(chunk.meta_info).slice(0, 200)}
</span>
</div>
)}
</Card>
))}
</div>
) : (
<Empty description={t('No_Results')} />
)}
</Spin>
</div>
) : currentSpace ? (
/* Space overview — document list */
<DocPanel
space={currentSpace}
onAddDoc={(_name: string) => {
// Could navigate to add doc flow
}}
onDeleteDoc={() => {
// Refresh handled internally
}}
/>
) : (
<div className='flex items-center justify-center h-full text-gray-400'>Knowledge space not found</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,15 +1,15 @@
import { ChatContext } from '@/app/chat-context';
import { apiInterceptors, delSpace, getSpaceConfig, getSpaceList, newDialogue } from '@/client/api';
import DocPanel from '@/components/knowledge/doc-panel';
import DocTypeForm from '@/components/knowledge/doc-type-form';
import DocUploadForm from '@/components/knowledge/doc-upload-form';
import GitRepoSyncForm from '@/components/knowledge/git-repo-sync-form';
import Segmentation from '@/components/knowledge/segmentation';
import SpaceForm from '@/components/knowledge/space-form';
import BlurredCard, { ChatButton, InnerDropdown } from '@/new-components/common/blurredCard';
import ConstructLayout from '@/new-components/layout/Construct';
import { File, ISpace, IStorage, StepChangeParams } from '@/types/knowledge';
import { PlusOutlined, ReadOutlined, SearchOutlined, WarningOutlined } from '@ant-design/icons';
import { Button, Input, Modal, Spin, Steps, Tag } from 'antd';
import { Button, Input, Modal, Spin, Tag } from 'antd';
import classNames from 'classnames';
import { debounce } from 'lodash';
import moment from 'moment';
@@ -21,24 +21,15 @@ const Knowledge = () => {
const { setCurrentDialogInfo } = useContext(ChatContext);
const [spaceList, setSpaceList] = useState<Array<ISpace> | null>([]);
const [isAddShow, setIsAddShow] = useState<boolean>(false);
const [isPanelShow, setIsPanelShow] = useState<boolean>(false);
const [currentSpace, setCurrentSpace] = useState<ISpace>();
const [activeStep, setActiveStep] = useState<number>(0);
const [spaceName, setSpaceName] = useState<string>('');
const [files, setFiles] = useState<Array<File>>([]);
const [docType, setDocType] = useState<string>('');
const [addStatus, setAddStatus] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [spaceConfig, setSpaceConfig] = useState<IStorage | null>(null);
const { t } = useTranslation();
const addKnowledgeSteps = [
{ title: t('Knowledge_Space_Config') },
{ title: t('Choose_a_Datasource_type') },
{ title: t('Upload') },
{ title: t('Segmentation') },
];
const router = useRouter();
async function getSpaces(params?: any) {
@@ -87,7 +78,6 @@ const Knowledge = () => {
getSpaces();
setSpaceName('');
setDocType('');
setAddStatus('finish');
localStorage.removeItem('cur_space_id');
} else if (label === 'forward') {
activeStep === 0 && getSpaces();
@@ -100,12 +90,6 @@ const Knowledge = () => {
docType && setDocType(docType);
};
function onAddDoc(spaceName: string) {
setSpaceName(spaceName);
setActiveStep(1);
setIsAddShow(true);
setAddStatus('start');
}
const showDeleteConfirm = (space: ISpace) => {
Modal.confirm({
title: t('Tips'),
@@ -167,9 +151,7 @@ const Knowledge = () => {
{spaceList?.map((space: ISpace) => (
<BlurredCard
onClick={() => {
setCurrentSpace(space);
setIsPanelShow(true);
localStorage.setItem('cur_space_id', JSON.stringify(space.id));
router.push(`/construct/knowledge/detail?spaceName=${space.name}`);
}}
description={space.desc}
name={space.name}
@@ -212,10 +194,26 @@ const Knowledge = () => {
<span className='flex items-center gap-1'>{space.domain_type || 'Normal'}</span>
</Tag>
{space.vector_type ? (
<Tag>
<Tag color='blue'>
<span className='flex items-center gap-1'>{space.vector_type}</span>
</Tag>
) : null}
{space.index_methods && space.index_methods.length > 0 ? (
<Tag color='purple'>
<span className='flex items-center gap-1'>
{space.index_methods
.map(m => {
const map: Record<string, string> = {
VectorStore: '向量',
FullText: '结构',
KnowledgeGraph: '图谱',
};
return map[m] || m;
})
.join('+')}
</span>
</Tag>
) : null}
</div>
}
LeftBottom={
@@ -237,16 +235,6 @@ const Knowledge = () => {
))}
</div>
</div>
<Modal
className='h-5/6 overflow-hidden'
open={isPanelShow}
width={'70%'}
onCancel={() => setIsPanelShow(false)}
footer={null}
destroyOnClose={true}
>
<DocPanel space={currentSpace!} onAddDoc={onAddDoc} onDeleteDoc={getSpaces} addStatus={addStatus} />
</Modal>
<Modal
title={t('New_knowledge_base')}
centered
@@ -262,15 +250,35 @@ const Knowledge = () => {
}}
footer={null}
>
<Steps current={activeStep} items={addKnowledgeSteps} />
{activeStep === 0 && <SpaceForm handleStepChange={handleStepChange} spaceConfig={spaceConfig} />}
{activeStep === 0 && (
<SpaceForm
handleStepChange={handleStepChange}
spaceConfig={spaceConfig}
onSuccess={() => {
setIsAddShow(false);
getSpaces();
setActiveStep(0);
}}
/>
)}
{activeStep === 1 && <DocTypeForm handleStepChange={handleStepChange} />}
<DocUploadForm
className={classNames({ hidden: activeStep !== 2 })}
spaceName={spaceName}
docType={docType}
handleStepChange={handleStepChange}
/>
{activeStep === 2 && docType === 'GIT_REPO' ? (
<GitRepoSyncForm
spaceName={spaceName}
onSuccess={() => {
setIsAddShow(false);
getSpaces();
setActiveStep(0);
}}
/>
) : (
<DocUploadForm
className={classNames({ hidden: activeStep !== 2 })}
spaceName={spaceName}
docType={docType}
handleStepChange={handleStepChange}
/>
)}
{activeStep === 3 && (
<Segmentation
spaceName={spaceName}

View File

@@ -360,6 +360,14 @@ const convertToManusFormat = (
if (actionLower === 'shell_interpreter') return 'bash';
if (actionLower === 'sql_query') return 'sql';
if (actionLower === 'question') return 'question';
if (
actionLower === 'kb_ls' ||
actionLower === 'kb_glob' ||
actionLower === 'kb_grep' ||
actionLower === 'kb_cat' ||
actionLower === 'semantic_search'
)
return 'kb';
const lower = (title || '').toLowerCase();
if (
@@ -403,11 +411,14 @@ const convertToManusFormat = (
})
.map(step => {
const cleanDetail = step.detail?.replace(/^Thought:.*\n?/gm, '').trim();
// Build a user-friendly subtitle: strip "Action: " prefix from the first line
const firstLine = cleanDetail?.split('\n')[0] || '';
const friendlySubtitle = firstLine.replace(/^Action:\s*/i, '').slice(0, 80);
return {
id: step.id,
type: getStepType(step.title, step.action),
title: step.title || `Step ${step.step}`,
subtitle: cleanDetail?.split('\n')[0]?.slice(0, 80),
subtitle: friendlySubtitle || undefined,
description: cleanDetail || undefined,
phase: (step as any).phase,
status: getStepStatus(step.status),
@@ -667,7 +678,7 @@ const Playground: NextPage = () => {
// Fetch Knowledge Bases
const { data: knowledgeSpaces, loading: _loadingKnowledge } = useRequest(async () => {
try {
const response = await sendSpacePostRequest('/knowledge/space/list', {});
const response = await sendSpacePostRequest('/api/v1/knowledge/space/list', {});
// ctx-axios interceptor returns response.data directly, so response is {success, data, ...}
if (response?.success) {
return response.data || [];
@@ -3868,7 +3879,7 @@ const Playground: NextPage = () => {
type='link'
size='small'
onClick={() => {
router.push('/knowledge');
router.push('/construct/knowledge');
setIsKnowledgePanelOpen(false);
}}
className='text-[10px] p-0 h-auto'

View File

@@ -8,6 +8,7 @@ export interface ISpace {
name: string;
owner: string;
vector_type: string;
index_methods?: string[];
domain_type: string;
}
export type AddKnowledgeParams = {
@@ -16,6 +17,7 @@ export type AddKnowledgeParams = {
owner: string;
desc: string;
domain_type: string;
index_methods?: string[];
};
export type BaseDocumentParams = {
@@ -208,3 +210,42 @@ export type IStorage = Array<{
desc: string;
domain_types: Array<{ name: string; desc: string }>;
}>;
export type KbFileEntry = {
name: string;
path: string;
is_dir: boolean;
file_type?: string;
language?: string;
doc_id?: number;
child_count?: number;
};
export type KbLsJsonResponse = {
path: string;
entries: KbFileEntry[];
total_files: number;
total_dirs: number;
};
export type KnowledgeSpaceStats = {
name: string;
domain_type: string | null;
vector_type: string | null;
index_methods: string[] | null;
desc: string | null;
document_count: number;
chunk_count: number;
sync_status: string | null;
sync_total_files: number | null;
sync_finished: number | null;
sync_running: number | null;
sync_failed: number | null;
sync_todo: number | null;
repo_url: string | null;
branch: string | null;
graph_vertex_count: number | null;
graph_edge_count: number | null;
graph_community_count: number | null;
graph_build_status: string | null;
};