mirror of
https://github.com/csunny/DB-GPT.git
synced 2026-07-16 17:15:22 +00:00
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:
@@ -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 ?? {},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
116
web/components/knowledge/git-repo-sync-form.tsx
Normal file
116
web/components/knowledge/git-repo-sync-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
web/components/knowledge/knowledge-tree.tsx
Normal file
233
web/components/knowledge/knowledge-tree.tsx
Normal 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;
|
||||
});
|
||||
}
|
||||
702
web/components/knowledge/search-tools-panel.tsx
Normal file
702
web/components/knowledge/search-tools-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '需要您的确认',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"d3-color": "2",
|
||||
"d3-array": "2",
|
||||
"d3-array": "3",
|
||||
"d3-shape": "2",
|
||||
"d3-path": "2",
|
||||
"d3-dsv": "2",
|
||||
|
||||
418
web/pages/construct/knowledge/detail.tsx
Normal file
418
web/pages/construct/knowledge/detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user