diff --git a/src/modules/opencode-api.ts b/src/modules/opencode-api.ts new file mode 100644 index 0000000..852b649 --- /dev/null +++ b/src/modules/opencode-api.ts @@ -0,0 +1,221 @@ +import { createQueryApi } from '@kevisual/query/api'; +import { query } from '@/modules/query.ts'; +const api = { + "opencode": { + /** + * 创建 OpenCode 客户端,如果存在则复用 + * + * @param data - Request parameters + * @param data.port - {number} OpenCode 服务端口,默认为 4096 + */ + "create": { + "path": "opencode", + "key": "create", + "description": "创建 OpenCode 客户端", + "metadata": { + "tags": [ + "opencode" + ], + "args": { + "port": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "OpenCode 服务端口,默认为 4096", + "type": "number", + "optional": true + } + }, + "skill": "create-opencode-client", + "title": "创建 OpenCode 客户端", + "summary": "创建 OpenCode 客户端,如果存在则复用", + "url": "/root/v1/cnb-dev", + "source": "query-proxy-api" + } + }, + /** + * 关闭 OpenCode 客户端, 未提供端口则关闭默认端口 + * + * @param data - Request parameters + * @param data.port - {number} OpenCode 服务端口,默认为 4096 + */ + "close": { + "path": "opencode", + "key": "close", + "description": "关闭 OpenCode 客户端", + "metadata": { + "tags": [ + "opencode" + ], + "args": { + "port": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "OpenCode 服务端口,默认为 4096", + "type": "number", + "optional": true + } + }, + "skill": "close-opencode-client", + "title": "关闭 OpenCode 客户端", + "summary": "关闭 OpenCode 客户端, 未提供端口则关闭默认端口", + "url": "/root/v1/cnb-dev", + "source": "query-proxy-api" + } + }, + /** + * 重启 OpenCode 客户端 + * + * @param data - Request parameters + * @param data.port - {number} OpenCode 服务端口,默认为 4096 + */ + "restart": { + "path": "opencode", + "key": "restart", + "description": "重启 OpenCode 客户端", + "metadata": { + "tags": [ + "opencode" + ], + "args": { + "port": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "OpenCode 服务端口,默认为 4096", + "type": "number", + "optional": true + } + }, + "skill": "restart-opencode-client", + "title": "重启 OpenCode 客户端", + "summary": "重启 OpenCode 客户端", + "url": "/root/v1/cnb-dev", + "source": "query-proxy-api" + } + }, + /** + * 获取当前 OpenCode 服务的 URL 地址 + * + * @param data - Request parameters + * @param data.port - {number} OpenCode 服务端口,默认为 4096 + */ + "getUrl": { + "path": "opencode", + "key": "getUrl", + "description": "获取 OpenCode 服务 URL", + "metadata": { + "tags": [ + "opencode" + ], + "args": { + "port": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "OpenCode 服务端口,默认为 4096", + "type": "number", + "optional": true + } + }, + "skill": "get-opencode-url", + "title": "获取 OpenCode 服务 URL", + "summary": "获取当前 OpenCode 服务的 URL 地址", + "url": "/root/v1/cnb-dev", + "source": "query-proxy-api" + } + }, + "ls-projects": { + "path": "opencode", + "key": "ls-projects", + "metadata": { + "url": "/root/v1/cnb-dev", + "source": "query-proxy-api" + } + }, + /** + * 运行一个已有的 OpenCode 项目 + * + * @param data - Request parameters + * @param data.projectPath - {string} OpenCode 项目的路径, 默认为 /workspace + */ + "runProject": { + "path": "opencode", + "key": "runProject", + "metadata": { + "tags": [ + "opencode" + ], + "args": { + "projectPath": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "OpenCode 项目的路径, 默认为 /workspace", + "type": "string", + "optional": true + } + }, + "skill": "run-opencode-project", + "title": "运行 OpenCode 项目", + "summary": "运行一个已有的 OpenCode 项目", + "url": "/root/v1/cnb-dev", + "source": "query-proxy-api" + } + } + }, + "opencode-cnb": { + /** + * 创建 OpenCode 客户端 + * + * @param data - Request parameters + * @param data.question - {string} 问题 + * @param data.baseUrl - {string} OpenCode 服务地址,默认为 http://localhost:4096 + * @param data.directory - {string} 运行目录,默认为根目录 + * @param data.messageID - {string} 消息 ID,选填 + * @param data.sessionId - {string} 会话 ID,选填 + * @param data.parts - {array} 消息内容的分块,优先于 question 参数 + */ + "question": { + "path": "opencode-cnb", + "key": "question", + "description": "创建 OpenCode 客户端", + "metadata": { + "args": { + "question": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "description": "问题" + }, + "baseUrl": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "OpenCode 服务地址,默认为 http://localhost:4096", + "type": "string", + "optional": true + }, + "directory": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "运行目录,默认为根目录", + "type": "string", + "optional": true + }, + "messageID": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "消息 ID,选填", + "type": "string", + "optional": true + }, + "sessionId": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "会话 ID,选填", + "type": "string", + "optional": true + }, + "parts": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "消息内容的分块,优先于 question 参数", + "type": "array", + "items": {}, + "optional": true + } + }, + "url": "/root/v1/cnb-dev", + "source": "query-proxy-api" + } + } + } +} as const; +const queryApi = createQueryApi({ api, query }); + +export { queryApi }; diff --git a/src/pages/code-graph/assets/openclaw.svg b/src/pages/code-graph/assets/openclaw.svg new file mode 100644 index 0000000..bcbc1e1 --- /dev/null +++ b/src/pages/code-graph/assets/openclaw.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pages/code-graph/assets/opencode.png b/src/pages/code-graph/assets/opencode.png new file mode 100644 index 0000000..ef9f5ff Binary files /dev/null and b/src/pages/code-graph/assets/opencode.png differ diff --git a/src/pages/code-graph/components/BotHelperModal.tsx b/src/pages/code-graph/components/BotHelperModal.tsx new file mode 100644 index 0000000..5c11d1a --- /dev/null +++ b/src/pages/code-graph/components/BotHelperModal.tsx @@ -0,0 +1,120 @@ +import { BotIcon, XIcon, FileIcon, FolderIcon, DatabaseIcon } from 'lucide-react'; +import { useBotHelperStore, BOT_KEYS, BotKey } from '../store/bot-helper'; +import { useShallow } from 'zustand/react/shallow'; +import { useCodeGraphStore, NodeInfoData } from '../store'; +import openclawSvg from '../assets/openclaw.svg'; +import opencodePng from '../assets/opencode.png'; + +const BOT_ICONS: Record = { + openclaw: openclawSvg, + opencode: opencodePng, +}; + +function NodeIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }) { + const cls = 'size-4 shrink-0'; + if (kind === 'root') return ; + if (kind === 'dir') return ; + return ; +} + +export function BotHelperModal() { + const { open, input, setInput, closeModal, activeKey, setActiveKey } = useBotHelperStore( + useShallow((s) => ({ + open: s.open, + input: s.input, + setInput: s.setInput, + closeModal: s.closeModal, + activeKey: s.activeKey, + setActiveKey: s.setActiveKey, + })), + ); + const { nodeInfoData, createQuestion } = useCodeGraphStore(useShallow((s) => ({ + nodeInfoData: s.nodeInfoData, + createQuestion: s.createQuestion, + }))); + + const relativePath = nodeInfoData + ? nodeInfoData.fullPath.replace((nodeInfoData.projectPath || '') + '/', '') || '/' + : null; + + const handleConfirm = async () => { + if (nodeInfoData) { + const res = await createQuestion({ + question: input, + projectPath: nodeInfoData.projectPath, + engine: activeKey, + }); + console.log(res); + } + closeModal(); + }; + + if (!open) return null; + + return ( + + {/* Mask:点击不关闭 */} + + + {/* 标题栏 */} + + + + AI 助手 + + + + + + {/* 内容区 */} + + {/* 节点信息 + 按钮组 */} + + {nodeInfoData && relativePath && ( + + + + {relativePath} + + + )} + {/* Bot 切换按钮组 */} + + {BOT_KEYS.map((key) => ( + setActiveKey(key)} + className={`p-1.5 rounded-lg border transition-colors ${activeKey === key + ? 'border-emerald-500/60 bg-emerald-500/10' + : 'border-white/5 bg-slate-800/60 opacity-40 hover:opacity-70' + }`}> + + + ))} + + + setInput(e.target.value)} + autoFocus + /> + + 确定 + + + + + ); +} + +export default BotHelperModal; diff --git a/src/pages/code-graph/components/Code3DGraph.tsx b/src/pages/code-graph/components/Code3DGraph.tsx index e28e759..eeb6119 100644 --- a/src/pages/code-graph/components/Code3DGraph.tsx +++ b/src/pages/code-graph/components/Code3DGraph.tsx @@ -100,7 +100,7 @@ function buildGraph3DData(files: FileProjectData[]): Graph3DData { label: name, kind: 'root', color: '#f59e0b', - nodeSize: 10, + nodeSize: 15, fullPath: projectPath, projectPath, }); diff --git a/src/pages/code-graph/components/CodePod.tsx b/src/pages/code-graph/components/CodePod.tsx index ec4f8e5..185dd8d 100644 --- a/src/pages/code-graph/components/CodePod.tsx +++ b/src/pages/code-graph/components/CodePod.tsx @@ -10,8 +10,9 @@ import { markdown } from '@codemirror/lang-markdown'; import { GraphNode } from '../modules/graph'; import { queryApi as projectApi } from '@/modules/project-api'; import { FileProjectData } from '../modules/tree'; -import { getFilesApi } from '../modules/api/get-files'; import './CodePod.css'; +import { useCodeGraphStore } from '../store'; +import { useShallow } from 'zustand/shallow'; // ─── 目录树类型 ──────────────────────────────────────────────────────────────── type TreeNode = { @@ -186,7 +187,9 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) { const isDir = nodeAttrs?.kind === 'dir' || nodeAttrs?.kind === 'root'; const [sidebarOpen, setSidebarOpen] = useState(true); const rootPath = nodeAttrs?.fullPath ?? ''; - + const codeGraphStore = useCodeGraphStore(useShallow((s) => ({ + getFiles: s.getFiles, + }))); // 打开时重置 useEffect(() => { @@ -200,9 +203,9 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) { }, [open, nodeAttrs]); const init = async (nodeAttrs: GraphNode) => { setLoading(true); - const res = await getFilesApi({ + const res = await codeGraphStore.getFiles({ filepath: nodeAttrs.fullPath, - // projectPath: nodeAttrs.projectPath + projectPath: nodeAttrs.projectPath, getContent: true, }); setLoading(false); diff --git a/src/pages/code-graph/components/NodeInfo.tsx b/src/pages/code-graph/components/NodeInfo.tsx index e150472..f05c21f 100644 --- a/src/pages/code-graph/components/NodeInfo.tsx +++ b/src/pages/code-graph/components/NodeInfo.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; -import { FileIcon, FolderIcon, DatabaseIcon, XIcon, MoveIcon, SquarePenIcon } from 'lucide-react'; +import { FileIcon, FolderIcon, DatabaseIcon, XIcon, MoveIcon, SquarePenIcon, BotIcon } from 'lucide-react'; import { useCodeGraphStore, NodeInfoData } from '../store'; +import { useBotHelperStore } from '../store/bot-helper'; import { useShallow } from 'zustand/react/shallow'; function KindIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }) { @@ -47,6 +48,8 @@ export function NodeInfo() { setOffset({ x: 0, y: 0 }); }; + const openBotModal = useBotHelperStore((s) => s.openModal); + // 拖拽偏移 const [offset, setOffset] = useState({ x: 0, y: 0 }); const [pinLeft, setPinLeft] = useState(false); // 编辑后固定到右下角 @@ -115,6 +118,12 @@ export function NodeInfo() { className='ml-1 text-slate-500 hover:text-indigo-400 transition-colors'> + + + {KIND_LABEL[nodeInfoData.kind]} diff --git a/src/pages/code-graph/modules/api/get-files.ts b/src/pages/code-graph/modules/api/get-files.ts deleted file mode 100644 index e6ba387..0000000 --- a/src/pages/code-graph/modules/api/get-files.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { queryApi as projectApi } from "@/modules/project-api"; -import { FileProjectData } from "../tree"; -import { Result } from "@kevisual/query"; -/** 从后端 API 获取文件列表 */ -export const getFilesApi = async (opts?: { - filepath?: string; // 可选的目录路径,默认为根目录 - q?: string; // 可选的搜索关键词 - projectPath?: string; // 项目路径,必填 - getContent?: boolean; // 是否获取文件内容,默认为 false -}, dataOpts?: { - url?: string; // 可选的 API 基础 URL,默认为 "/root/v1/dev-cnb" -}): Promise> => { - const url = dataOpts?.url ?? "/root/v1/cnb-dev"; - const res = await projectApi["project-search"].files({ - ...opts, - q: opts?.q ?? "", - }, { - url - }); - return res; -} \ No newline at end of file diff --git a/src/pages/code-graph/page.tsx b/src/pages/code-graph/page.tsx index 0a1e50a..100d539 100644 --- a/src/pages/code-graph/page.tsx +++ b/src/pages/code-graph/page.tsx @@ -1,7 +1,5 @@ import { useState, useEffect } from 'react'; import { FileProjectData } from './modules/tree'; -import { getFilesApi } from './modules/api/get-files'; -import { toast } from 'sonner'; import { useShallow } from 'zustand/react/shallow'; import { DatabaseIcon } from 'lucide-react'; import { CodePod } from './components/CodePod'; @@ -10,35 +8,32 @@ import CodeGraphView from './components/CodeGraph'; import { Code3DGraph } from './components/Code3DGraph'; import { NodeInfo } from './components/NodeInfo'; import { ProjectDialog } from './components/ProjectDialog'; - +import { BotHelperModal } from './components/BotHelperModal'; +import { useLayoutStore } from '../auth/store'; type ViewMode = '2d' | '3d'; export default function CodeGraphPage() { - const [files, setFiles] = useState([]); const [viewMode, setViewMode] = useState('3d'); - const { codePodOpen, setCodePodOpen, codePodAttrs, setProjectDialogOpen, loadProjects } = useCodeGraphStore( + const layoutStore = useLayoutStore(useShallow((s) => ({ + me: s.me, + }))); + const { codePodOpen, setCodePodOpen, codePodAttrs, setProjectDialogOpen, init, files } = useCodeGraphStore( useShallow((s) => ({ + files: s.files, + setFiles: s.setFiles, codePodOpen: s.codePodOpen, setCodePodOpen: s.setCodePodOpen, codePodAttrs: s.codePodAttrs, setProjectDialogOpen: s.setProjectDialogOpen, - loadProjects: s.loadProjects + init: s.init, })), ); useEffect(() => { - loadData(); - loadProjects(); - }, []); - // 页面加载时从 API 获取文件列表 - const loadData = async () => { - const res = await getFilesApi(); - if (res.code === 200) { - setFiles(res.data!.list); - } else { - toast.error('获取文件列表失败'); - } - }; + if (!layoutStore.me?.username) return; + init(layoutStore.me); + }, [layoutStore.me]); + return ( {/* 顶部工具栏 */} @@ -90,6 +85,8 @@ export default function CodeGraphPage() { {/* 项目管理弹窗 */} + {/* Bot AI 助手弹窗 */} + ); } diff --git a/src/pages/code-graph/store/bot-helper.ts b/src/pages/code-graph/store/bot-helper.ts new file mode 100644 index 0000000..1ff8b64 --- /dev/null +++ b/src/pages/code-graph/store/bot-helper.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand'; + +export type BotKey = 'openclaw' | 'opencode'; + +export const BOT_KEYS: BotKey[] = ['openclaw', 'opencode']; + +type BotHelperState = { + open: boolean; + input: string; + activeKey: BotKey; + setOpen: (open: boolean) => void; + setInput: (input: string) => void; + setActiveKey: (key: BotKey) => void; + openModal: () => void; + closeModal: () => void; +}; + +export const useBotHelperStore = create()((set) => ({ + open: false, + input: '', + activeKey: 'opencode', + setOpen: (open) => set({ open }), + setInput: (input) => set({ input }), + setActiveKey: (key) => set({ activeKey: key }), + openModal: () => set({ open: true }), + closeModal: () => set({ open: false, input: '' }), +})); diff --git a/src/pages/code-graph/store/index.ts b/src/pages/code-graph/store/index.ts index 0ef246a..c8aec48 100644 --- a/src/pages/code-graph/store/index.ts +++ b/src/pages/code-graph/store/index.ts @@ -1,7 +1,11 @@ import { create } from 'zustand'; import { GraphNode } from '../modules/graph'; import { queryApi as projectApi } from '@/modules/project-api'; +import { queryApi as opencodeApi } from '@/modules/opencode-api'; import { toast } from 'sonner'; +import { FileProjectData } from '../modules/tree'; +import { UserInfo } from '@/pages/auth/store'; +import { Result } from '@kevisual/query'; export type ProjectItem = { path: string; @@ -33,6 +37,8 @@ type State = { // 项目列表 projects: ProjectItem[]; projectsLoading: boolean; + files: FileProjectData[]; + setFiles: (files: FileProjectData[]) => void; loadProjects: () => Promise; addProject: (filepath: string, name?: string) => Promise; removeProject: (path: string) => Promise; @@ -42,6 +48,15 @@ type State = { nodeInfoPos: { x: number; y: number }; setNodeInfo: (data: NodeInfoData | null, pos?: { x: number; y: number }) => void; closeNodeInfo: () => void; + url?: string; + init(user: UserInfo): Promise; + getFiles: (opts?: { + filepath?: string; // 可选的目录路径,默认为根目录 + q?: string; // 可选的搜索关键词 + projectPath?: string; // 项目路径,必填 + getContent?: boolean; // 是否获取文件内容,默认为 false + }) => Promise>; + createQuestion: (opts: { question: string, projectPath: string, engine?: 'openclaw' | 'opencode' }) => any; }; export const useCodeGraphStore = create()((set, get) => ({ @@ -53,10 +68,14 @@ export const useCodeGraphStore = create()((set, get) => ({ setProjectDialogOpen: (open) => set({ projectDialogOpen: open }), projects: [], projectsLoading: false, + files: [], + setFiles: (files) => set({ files }), + loadProjects: async () => { set({ projectsLoading: true }); + const url = get().url || API_URL; try { - const res = await projectApi.project.list(undefined, { url: API_URL }); + const res = await projectApi.project.list(undefined, { url }); if (res.code === 200) { set({ projects: (res.data?.list as ProjectItem[]) ?? [] }); } else { @@ -70,9 +89,10 @@ export const useCodeGraphStore = create()((set, get) => ({ }, addProject: async (filepath, name) => { try { + const url = get().url || API_URL; const res = await projectApi.project.add( { filepath, name: name || undefined }, - { url: API_URL }, + { url }, ); if (res.code === 200) { toast.success('项目添加成功'); @@ -89,7 +109,8 @@ export const useCodeGraphStore = create()((set, get) => ({ }, removeProject: async (path) => { try { - const res = await projectApi.project.remove({ filepath: path }, { url: API_URL }); + const url = get().url || API_URL; + const res = await projectApi.project.remove({ filepath: path }, { url }); if (res.code === 200) { toast.success('项目已移除'); set((s) => ({ projects: s.projects.filter((p) => p.path !== path) })); @@ -110,4 +131,47 @@ export const useCodeGraphStore = create()((set, get) => ({ nodeInfoPos: pos ?? { x: 0, y: 0 }, }), closeNodeInfo: () => set({ nodeInfoOpen: false }), + url: API_URL, + init: async (user) => { + // 可以在这里根据用户信息初始化一些数据,比如权限相关的设置等 + console.log('CodeGraphStore initialized for user:', user.username); + const username = user.username; + const url = username ? `/${username}/v1/cnb-dev` : API_URL; + set({ url }); + get().loadProjects(); + const res = await get().getFiles(); + if (res.code === 200) { + set({ files: res.data!.list }); + } else { + toast.error('获取文件列表失败'); + } + }, + getFiles: async (opts?: { + filepath?: string; // 可选的目录路径,默认为根目录 + q?: string; // 可选的搜索关键词 + projectPath?: string; // 项目路径,必填 + getContent?: boolean; // 是否获取文件内容,默认为 false + }) => { + const url = get().url + const res = await projectApi["project-search"].files({ + ...opts, + q: opts?.q ?? "", + }, { + url + }); + return res; + }, + createQuestion: async (opts) => { + const { question, projectPath, engine = 'opencode' } = opts; + const url = get().url + const q = ` + ${question} + 项目路径: ${projectPath}` + const res = await opencodeApi["opencode-cnb"].question({ + question: q, + }, { + url + }); + return res; + }, }));