From 1afd39b97052ae06698a685e3330f4369ecd9542 Mon Sep 17 00:00:00 2001 From: xiongxiao Date: Sun, 15 Mar 2026 23:19:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E9=A1=B9=E7=9B=AE=20?= =?UTF-8?q?API=EF=BC=8C=E6=B7=BB=E5=8A=A0=E9=A1=B9=E7=9B=AE=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=92=8C=E6=96=87=E4=BB=B6=E8=BF=87=E6=BB=A4=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=9B=E6=9B=B4=E6=96=B0=20BotHelperModal=20?= =?UTF-8?q?=E5=92=8C=20NodeInfo=20=E7=BB=84=E4=BB=B6=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E5=92=8C=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=BD=93=E9=AA=8C=EF=BC=9B=E6=94=B9=E8=BF=9B=20Projec?= =?UTF-8?q?tDialog=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89=E5=92=8C?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=88=9D=E5=A7=8B=E5=8C=96=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/project-api.ts | 114 ++++++++- .../code-graph/components/BotHelperModal.tsx | 42 ++-- .../code-graph/components/Code3DGraph.tsx | 17 +- src/pages/code-graph/components/CodePod.tsx | 22 +- src/pages/code-graph/components/NodeInfo.tsx | 29 ++- .../code-graph/components/ProjectDialog.tsx | 238 +++++++++++++++++- src/pages/code-graph/store/bot-helper.ts | 10 +- src/pages/code-graph/store/index.ts | 24 +- 8 files changed, 448 insertions(+), 48 deletions(-) diff --git a/src/modules/project-api.ts b/src/modules/project-api.ts index 04f2a6d..321d21a 100644 --- a/src/modules/project-api.ts +++ b/src/modules/project-api.ts @@ -276,7 +276,6 @@ const api = { * * @param data - Request parameters * @param data.q - {string} 搜索关键词,选填;留空或不传则返回全部文件 - * @param data.projectPath - {string} 按项目根目录路径过滤,仅返回该项目下的文件,选填 * @param data.filepath - {string} 按文件绝对路径过滤,选填 * @param data.repo - {string} 按代码仓库标识过滤(如 owner/repo),选填 * @param data.title - {string} 按人工标注的标题字段过滤,选填 @@ -287,6 +286,7 @@ const api = { * @param data.sort - {array} 排序规则数组,格式为 ["字段:asc"] 或 ["字段:desc"],选填,当 q 为空时默认为 ["projectPath:asc"] * @param data.limit - {number} 返回结果数量上限,选填,当 q 为空时默认为 1000 * @param data.getContent - {boolean} 是否返回文件内容,默认为 false;如果为 true,则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景 + * @param data.projects - {array} 按项目名称列表过滤,选填,默认不穿,只过滤当前工作区的项目 */ "files": { "path": "project-search", @@ -300,12 +300,120 @@ const api = { "type": "string", "optional": true }, - "projectPath": { + "filepath": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "按项目根目录路径过滤,仅返回该项目下的文件,选填", + "description": "按文件绝对路径过滤,选填", "type": "string", "optional": true }, + "repo": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "按代码仓库标识过滤(如 owner/repo),选填", + "type": "string", + "optional": true + }, + "title": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "按人工标注的标题字段过滤,选填", + "type": "string", + "optional": true + }, + "tags": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "按人工标注的标签列表过滤,选填", + "type": "array", + "items": { + "type": "string" + }, + "optional": true + }, + "summary": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "按人工标注的摘要字段过滤,选填", + "type": "string", + "optional": true + }, + "description": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "按人工标注的描述字段过滤,选填", + "type": "string", + "optional": true + }, + "link": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "按人工标注的外部链接字段过滤,选填", + "type": "string", + "optional": true + }, + "sort": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "排序规则数组,格式为 [\"字段:asc\"] 或 [\"字段:desc\"],选填,当 q 为空时默认为 [\"projectPath:asc\"]", + "type": "array", + "items": { + "type": "string" + }, + "optional": true + }, + "limit": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "返回结果数量上限,选填,当 q 为空时默认为 1000", + "type": "number", + "optional": true + }, + "getContent": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "是否返回文件内容,默认为 false;如果为 true,则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景", + "type": "boolean", + "optional": true + }, + "projects": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "按项目名称列表过滤,选填,默认不穿,只过滤当前工作区的项目", + "type": "array", + "items": { + "type": "string" + }, + "optional": true + } + }, + "url": "/root/v1/cnb-dev", + "source": "query-proxy-api" + } + }, + /** + * 在已索引的项目文件中执行全文搜索,支持按仓库、目录、标签等字段过滤,以及自定义排序和数量限制 + * + * @param data - Request parameters + * @param data.q - {string} 搜索关键词,选填;留空或不传则返回全部文件 + * @param data.projectPath - {string} 按项目根目录路径过滤,仅返回该项目下的文件,必填 + * @param data.filepath - {string} 按文件绝对路径过滤,选填 + * @param data.repo - {string} 按代码仓库标识过滤(如 owner/repo),选填 + * @param data.title - {string} 按人工标注的标题字段过滤,选填 + * @param data.tags - {array} 按人工标注的标签列表过滤,选填 + * @param data.summary - {string} 按人工标注的摘要字段过滤,选填 + * @param data.description - {string} 按人工标注的描述字段过滤,选填 + * @param data.link - {string} 按人工标注的外部链接字段过滤,选填 + * @param data.sort - {array} 排序规则数组,格式为 ["字段:asc"] 或 ["字段:desc"],选填,当 q 为空时默认为 ["projectPath:asc"] + * @param data.limit - {number} 返回结果数量上限,选填,当 q 为空时默认为 1000 + * @param data.getContent - {boolean} 是否返回文件内容,默认为 false;如果为 true,则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景 + */ + "search": { + "path": "project-search", + "key": "search", + "description": "在已索引的项目文件中执行全文搜索,支持按仓库、目录、标签等字段过滤,以及自定义排序和数量限制", + "metadata": { + "args": { + "q": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "搜索关键词,选填;留空或不传则返回全部文件", + "type": "string", + "optional": true + }, + "projectPath": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "description": "按项目根目录路径过滤,仅返回该项目下的文件,必填" + }, "filepath": { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "按文件绝对路径过滤,选填", diff --git a/src/pages/code-graph/components/BotHelperModal.tsx b/src/pages/code-graph/components/BotHelperModal.tsx index 5c11d1a..7989e92 100644 --- a/src/pages/code-graph/components/BotHelperModal.tsx +++ b/src/pages/code-graph/components/BotHelperModal.tsx @@ -1,4 +1,5 @@ -import { BotIcon, XIcon, FileIcon, FolderIcon, DatabaseIcon } from 'lucide-react'; +import { BotIcon, XIcon, FileIcon, FolderIcon, DatabaseIcon, MoreHorizontalIcon } from 'lucide-react'; +import { getDynamicBasename, wrapBasename } from '@/modules/basename'; import { useBotHelperStore, BOT_KEYS, BotKey } from '../store/bot-helper'; import { useShallow } from 'zustand/react/shallow'; import { useCodeGraphStore, NodeInfoData } from '../store'; @@ -18,7 +19,7 @@ function NodeIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string } } export function BotHelperModal() { - const { open, input, setInput, closeModal, activeKey, setActiveKey } = useBotHelperStore( + const botHelperStore = useBotHelperStore( useShallow((s) => ({ open: s.open, input: s.input, @@ -40,16 +41,16 @@ export function BotHelperModal() { const handleConfirm = async () => { if (nodeInfoData) { const res = await createQuestion({ - question: input, + question: botHelperStore.input, projectPath: nodeInfoData.projectPath, - engine: activeKey, + engine: botHelperStore.activeKey, }); console.log(res); } - closeModal(); + botHelperStore.closeModal(); }; - if (!open) return null; + if (!botHelperStore.open) return null; return (
@@ -62,11 +63,22 @@ export function BotHelperModal() { AI 助手
- +
+ + +
{/* 内容区 */}
@@ -88,8 +100,8 @@ export function BotHelperModal() { - - {selectedFile?.filepath ?? nodeAttrs.fullPath} + setShowFileName((v) => !v)} style={{ cursor: 'pointer' }}> + {showFileName ? filename : selectedFile?.filepath ?? nodeAttrs.fullPath}
{loading && 加载中…} diff --git a/src/pages/code-graph/components/NodeInfo.tsx b/src/pages/code-graph/components/NodeInfo.tsx index 536dc38..7d77f3b 100644 --- a/src/pages/code-graph/components/NodeInfo.tsx +++ b/src/pages/code-graph/components/NodeInfo.tsx @@ -3,6 +3,7 @@ import { FileIcon, FolderIcon, DatabaseIcon, XIcon, MoveIcon, SquarePenIcon, Bot import { useCodeGraphStore, NodeInfoData } from '../store'; import { useBotHelperStore } from '../store/bot-helper'; import { useShallow } from 'zustand/react/shallow'; +import clsx from 'clsx'; function KindIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }) { const cls = 'size-4 shrink-0'; @@ -21,7 +22,6 @@ export function NodeInfo() { const codeGraphStore = useCodeGraphStore( useShallow((s) => ({ nodeInfoData: s.nodeInfoData, - })), ); const projectPath = codeGraphStore.nodeInfoData?.projectPath || ''; @@ -51,14 +51,18 @@ export function NodeInfo() {
) } export const NodeInfoContainer = () => { - const { nodeInfoOpen, nodeInfoData, nodeInfoPos, closeNodeInfo, setCodePodOpen, setCodePodAttrs } = useCodeGraphStore( + const { nodeInfoOpen, nodeInfoData, nodeInfoPos, closeNodeInfo, codePodOpen, setCodePodOpen, setCodePodAttrs, isMobile, setIsMobile } = useCodeGraphStore( useShallow((s) => ({ nodeInfoOpen: s.nodeInfoOpen, + codePodOpen: s.codePodOpen, nodeInfoData: s.nodeInfoData, nodeInfoPos: s.nodeInfoPos, + setNodeInfoOpen: s.setNodeInfo, closeNodeInfo: s.closeNodeInfo, setCodePodOpen: s.setCodePodOpen, setCodePodAttrs: s.setCodePodAttrs, + isMobile: s.isMobile, + setIsMobile: s.setIsMobile, })), ); @@ -75,18 +79,21 @@ export const NodeInfoContainer = () => { kind: nodeInfoData.kind, fileId: nodeInfoData.fileId, }); + setCodePodOpen(true); // 移到左上角,避免遮挡编辑器 setPinLeft(true); setOffset({ x: 0, y: 0 }); }; - const openBotModal = useBotHelperStore((s) => s.openModal); + const botHelperStore = useBotHelperStore(useShallow((s) => ({ + openModal: s.openModal, + setProjectInfo: s.setProjectInfo, + }))); // 拖拽偏移 const [offset, setOffset] = useState({ x: 0, y: 0 }); const [pinLeft, setPinLeft] = useState(false); // 编辑后固定到右下角 - const [isMobile, setIsMobile] = useState(false); // 检测屏幕大小 useEffect(() => { @@ -143,11 +150,11 @@ export const NodeInfoContainer = () => { : { left: nodeInfoPos.x + offset.x + 40, top: nodeInfoPos.y + offset.y - 40 }; const name = nodeInfoData.fullPath.split('/').pop() || nodeInfoData.label; - const projectPath = nodeInfoData.projectPath || ''; - const relativePath = nodeInfoData.fullPath.replace(projectPath + '/', '') || '/'; return (
{/* 标题栏 */} @@ -164,7 +171,13 @@ export const NodeInfoContainer = () => { + + +
setInitConfirmOpen(true)} + className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'> + +
+
+ +

初始化 workspace/projects 仓库

+
+
+ {multiSelectMode ? ( + <> +
+ + +
+ +
+
+ +

全选

+
+
+ + +
+ +
+
+ +

取消全选

+
+
+ + + + + +

全部启动 ({selectedProjects.length})

+
+
+ + + + + +

全部关闭 ({selectedProjects.length})

+
+
+ + +
+ +
+
+ +

退出多选

+
+
+ + ) : ( + + +
+ +
+
+ +

多选

+
+
+ )}
@@ -261,7 +435,19 @@ export function ProjectDialog() { {projects.map((p) => (
  • + className={`group flex items-center gap-3 rounded-xl bg-white/5 px-4 py-3 border border-white/5 hover:bg-white/10 hover:border-white/10 transition-all duration-200 ${multiSelectMode && selectedProjects.includes(p.path) ? 'border-indigo-500/50 bg-indigo-500/10' : ''}`}> + {/* 多选框 */} + {multiSelectMode && ( + + )} {/* 项目图标 */}
    @@ -312,6 +498,7 @@ export function ProjectDialog() { )}
    +
  • {/* 状态切换确认弹窗 */} @@ -382,6 +569,45 @@ export function ProjectDialog() { + + {/* 初始化确认弹窗 */} + !open && handleCancelInit()}> + + +
    + +
    + + 初始化项目 + + + 确定要初始化 workspace/projects 仓库吗?初始化过程可能需要一些时间。 + +
    + + + + +
    +
    ); } diff --git a/src/pages/code-graph/store/bot-helper.ts b/src/pages/code-graph/store/bot-helper.ts index 1ff8b64..f8b826f 100644 --- a/src/pages/code-graph/store/bot-helper.ts +++ b/src/pages/code-graph/store/bot-helper.ts @@ -11,6 +11,11 @@ type BotHelperState = { setOpen: (open: boolean) => void; setInput: (input: string) => void; setActiveKey: (key: BotKey) => void; + projectInfo: { + filepath: string; + projectPath: string; + } | null; + setProjectInfo: (info: { filepath: string; projectPath: string } | null) => void; openModal: () => void; closeModal: () => void; }; @@ -21,7 +26,10 @@ export const useBotHelperStore = create()((set) => ({ activeKey: 'opencode', setOpen: (open) => set({ open }), setInput: (input) => set({ input }), + setActiveKey: (key) => set({ activeKey: key }), + projectInfo: null, + setProjectInfo: (info) => set({ projectInfo: info }), openModal: () => set({ open: true }), - closeModal: () => set({ open: false, input: '' }), + closeModal: () => set({ open: false, input: '', projectInfo: null }), })); diff --git a/src/pages/code-graph/store/index.ts b/src/pages/code-graph/store/index.ts index 0d79c5f..e579569 100644 --- a/src/pages/code-graph/store/index.ts +++ b/src/pages/code-graph/store/index.ts @@ -43,8 +43,10 @@ type State = { addProject: (filepath: string, name?: string, type?: 'filepath' | 'cnb-repo') => Promise; removeProject: (path: string) => Promise; toggleProjectStatus: (path: string) => Promise; + initProject: () => Promise; // NodeInfo 弹窗 nodeInfoOpen: boolean; + setNodeInfoOpen: (open: boolean) => void; nodeInfoData: NodeInfoData | null; nodeInfoPos: { x: number; y: number }; setNodeInfo: (data: NodeInfoData | null, pos?: { x: number; y: number }) => void; @@ -60,6 +62,8 @@ type State = { }) => Promise>; createQuestion: (opts: { question: string, projectPath: string, engine?: 'openclaw' | 'opencode' }) => any; saveFile: (filepath: string, content: string) => Promise; + isMobile: boolean; + setIsMobile: (isMobile: boolean) => void; }; export const useCodeGraphStore = create()((set, get) => ({ @@ -117,6 +121,20 @@ export const useCodeGraphStore = create()((set, get) => ({ return false; } }, + initProject: async () => { + try { + const url = get().url || API_URL; + const res = await projectApi.project.init(undefined, { url }); + if (res.code === 200) { + toast.success('项目初始化成功'); + await get().loadProjects(); + } else { + toast.error(res.message ?? '项目初始化失败'); + } + } catch { + toast.error('项目初始化失败'); + } + }, removeProject: async (path) => { try { const url = get().url || API_URL; @@ -165,6 +183,7 @@ export const useCodeGraphStore = create()((set, get) => ({ } }, nodeInfoOpen: false, + setNodeInfoOpen: (open) => set({ nodeInfoOpen: open }), nodeInfoData: null, nodeInfoPos: { x: 0, y: 0 }, setNodeInfo: (data, pos) => @@ -221,6 +240,8 @@ export const useCodeGraphStore = create()((set, get) => ({ toast.error('保存失败'); } }, + isMobile: false, + setIsMobile: (isMobile) => set({ isMobile }), createQuestion: async (opts) => { const { question, projectPath, engine = 'opencode' } = opts; const url = get().url @@ -231,7 +252,8 @@ export const useCodeGraphStore = create()((set, get) => ({ question: q, directory: projectPath, }, { - url + url, + timeout: 60 * 1000 * 15, // 15分钟 }); return res; },