Compare commits

...

16 Commits

Author SHA1 Message Date
xiongxiao
48b915c4a4 refactor: 重命名 basename 模块为 dynamic-name,添加 URL hash 参数支持 2026-03-19 20:26:27 +08:00
xiongxiao
6b72054525 update: 优化 PWA 配置,添加自动更新注册 2026-03-19 05:08:27 +08:00
xiongxiao
a65e7b236d feat: 添加项目初始化选择功能,支持多项目选择和URL状态同步 2026-03-19 05:02:24 +08:00
xiongxiao
2a26a3943f feat: 添加文件描述编辑功能,支持标题、标签、摘要、描述和链接管理 2026-03-19 04:12:22 +08:00
xiongxiao
7bf7c5099b feat: 添加 CNB 环境检测,版本更新至 0.0.2 2026-03-19 03:33:52 +08:00
xiongxiao
155a126c6e fix: 更新 API URL,从 /root/v1/cnb-dev 修改为 /root/v1/dev-cnb 2026-03-19 02:28:10 +08:00
xiongxiao
56222183d1 feat: 添加节点信息编辑功能,支持通过 URL 参数传递 filepath 和 projectPath;调整 ProjectPanel 位置 2026-03-18 00:03:50 +08:00
xiongxiao
b288bd179e update 2026-03-17 01:33:29 +08:00
xiongxiao
3a4d02be75 feat: 更新依赖项,添加 PWA 插件,优化环境变量配置;调整开发服务器端口 2026-03-17 01:33:01 +08:00
xiongxiao
4b1d1072b8 update 2026-03-16 03:23:40 +08:00
xiongxiao
8ad1254341 feat: 添加项目面板组件,支持项目点击事件;优化 BotHelperModal 和 Code3DGraph 组件,增强用户交互体验 2026-03-16 01:49:39 +08:00
xiongxiao
66f70b144a update 2026-03-16 01:13:47 +08:00
xiongxiao
31a3c48c48 feat: 添加 3D 图谱配置对话框,增强节点标签显示功能;优化配置界面和交互体验 2026-03-16 00:19:24 +08:00
xiongxiao
070d8d8cd1 feat: 添加聊天助手功能,创建 ChatDev 页面及相关状态管理;更新路由配置,支持聊天助手的路径 2026-03-16 00:09:30 +08:00
xiongxiao
b4980ab4f9 updated files related to chat-dev page, added onSend function to handle sending questions, and updated the button's onClick handler to call this function. Also added new types in code-graph store for handling results from opencode AI SDK. 2026-03-16 00:04:35 +08:00
xiongxiao
1afd39b970 feat: 增强项目 API,添加项目路径和文件过滤功能;更新 BotHelperModal 和 NodeInfo 组件,优化状态管理和交互体验;改进 ProjectDialog,支持多选和项目初始化功能 2026-03-15 23:19:38 +08:00
24 changed files with 2781 additions and 328 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "code-graph",
"private": true,
"version": "0.0.1",
"version": "0.0.2",
"type": "module",
"basename": "/root/code-graph",
"scripts": {
@@ -9,7 +9,7 @@
"build": "vite build",
"preview": "vite preview",
"ui": "bunx shadcn@latest add ",
"pub": "envision deploy ./dist -k code-graph -v 0.0.1 -y y -u"
"pub": "envision deploy ./dist -k code-graph -v 0.0.2 -y y -u"
},
"files": [
"dist"
@@ -27,15 +27,15 @@
"@codemirror/lang-markdown": "^6.5.0",
"@kevisual/api": "^0.0.64",
"@kevisual/context": "^0.0.8",
"@kevisual/router": "0.1.1",
"@kevisual/router": "0.1.2",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.166.7",
"@tanstack/react-router": "^1.167.3",
"@uiw/codemirror-theme-vscode": "^4.25.8",
"@uiw/react-codemirror": "^4.25.8",
"ai": "^6.0.116",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"convex": "^1.33.0",
"dayjs": "^1.11.20",
"es-toolkit": "^1.45.1",
"fuse.js": "^7.1.0",
@@ -44,7 +44,7 @@
"graphology-layout-noverlap": "^0.4.2",
"graphology-types": "^0.24.8",
"lucide-react": "^0.577.0",
"nanoid": "^5.1.6",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -52,19 +52,21 @@
"sigma": "^3.0.2",
"sonner": "^2.0.7",
"three": "^0.183.2",
"zustand": "^5.0.11"
"three-spritetext": "^1.10.0",
"zustand": "^5.0.12"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@kevisual/ai": "0.0.28",
"@kevisual/kv-login": "^0.1.17",
"@kevisual/kv-login": "^0.1.18",
"@kevisual/query": "0.0.53",
"@kevisual/types": "^0.0.12",
"@opencode-ai/sdk": "^1.2.27",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-router-devtools": "^1.166.7",
"@tanstack/router-plugin": "^1.166.7",
"@tanstack/react-router-devtools": "^1.166.9",
"@tanstack/router-plugin": "^1.166.12",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -75,6 +77,7 @@
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "v8.0.0"
"vite": "v8.0.0",
"vite-plugin-pwa": "^1.2.0"
}
}

View File

@@ -0,0 +1,179 @@
import { useState, useEffect } from 'react';
import { queryApi as projectApi } from '@/modules/project-api';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const API_URL = '/root/v1/dev-cnb';
export type FileDescriptionData = {
filepath: string;
title?: string;
tags?: string[];
summary?: string;
description?: string;
link?: string;
};
interface FileDescriptionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: FileDescriptionData;
onSuccess?: () => void;
}
export function FileDescriptionDialog({
open,
onOpenChange,
data,
onSuccess,
}: FileDescriptionDialogProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<FileDescriptionData>({
filepath: data.filepath,
title: '',
tags: [],
summary: '',
description: '',
link: '',
});
// 当打开对话框或数据变化时,同步数据
useEffect(() => {
if (open && data.filepath) {
setFormData({
filepath: data.filepath,
title: data.title || '',
tags: data.tags || [],
summary: data.summary || '',
description: data.description || '',
link: data.link || '',
});
}
}, [open, data]);
const handleSave = async () => {
setLoading(true);
try {
// 过滤掉空值
const updateData: Partial<FileDescriptionData> = {
filepath: formData.filepath,
};
if (formData.title) updateData.title = formData.title;
if (formData.tags && formData.tags.length > 0) updateData.tags = formData.tags;
if (formData.summary) updateData.summary = formData.summary;
if (formData.description) updateData.description = formData.description;
if (formData.link) updateData.link = formData.link;
const res = await projectApi['project-file']['update'](updateData, { url: API_URL });
if (res.code === 200) {
toast.success('保存成功');
onOpenChange(false);
onSuccess?.();
} else {
toast.error(res.message ?? '保存失败');
}
} catch {
toast.error('保存失败');
} finally {
setLoading(false);
}
};
const handleTagsChange = (value: string) => {
// 标签以逗号分隔
const tags = value.split(',').map((t) => t.trim()).filter(Boolean);
setFormData((prev) => ({ ...prev, tags }));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* 文件路径(只读) */}
<div className="grid gap-2">
<Label className="text-slate-400"></Label>
<Input value={formData.filepath} disabled className="font-mono text-xs" />
</div>
{/* 标题 */}
<div className="grid gap-2">
<Label htmlFor="title"></Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
placeholder="输入文件标题"
/>
</div>
{/* 标签 */}
<div className="grid gap-2">
<Label htmlFor="tags"></Label>
<Input
id="tags"
value={formData.tags?.join(', ') || ''}
onChange={(e) => handleTagsChange(e.target.value)}
placeholder="输入标签,用逗号分隔"
/>
<span className="text-[10px] text-slate-500"></span>
</div>
{/* 摘要 */}
<div className="grid gap-2">
<Label htmlFor="summary"></Label>
<Input
id="summary"
value={formData.summary}
onChange={(e) => setFormData((prev) => ({ ...prev, summary: e.target.value }))}
placeholder="简短描述文件内容"
/>
</div>
{/* 链接 */}
<div className="grid gap-2">
<Label htmlFor="link"></Label>
<Input
id="link"
value={formData.link}
onChange={(e) => setFormData((prev) => ({ ...prev, link: e.target.value }))}
placeholder="关联的外部链接如文档、Issue 等)"
/>
</div>
{/* 描述 */}
<div className="grid gap-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="详细描述文件内容、用途等"
className="min-h-[120px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,7 +2,7 @@ import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import './index.css'
import { getDynamicBasename } from './modules/basename'
import { getDynamicBasename } from './modules/dynamic-name.ts'
import './agents/index.ts';
// Set up a Router instance
const router = createRouter({

4
src/modules/cnb.ts Normal file
View File

@@ -0,0 +1,4 @@
export const isCNB = () => {
const hostname = window.location.hostname;
return hostname.endsWith('.cnb.run') || hostname.includes('localhost');
}

View File

@@ -0,0 +1,16 @@
import { basename } from "./basename"
// 动态计算 basename根据当前 URL 路径
export const getDynamicBasename = (): string => {
const path = window.location.pathname
const origin = window.location.origin
const [user, key, id] = path.split('/').filter(Boolean)
if (key === 'v1' && id) {
return `/${user}/v1/${id}`
}
if (origin.includes('cnb.kevisual.cn')) {
return '/';
}
// 默认使用构建时的 basename
return basename
}

View File

@@ -11,6 +11,7 @@ const api = {
"create": {
"path": "opencode",
"key": "create",
"id": "b662fba3c0a8a593",
"description": "创建 OpenCode 客户端",
"metadata": {
"tags": [
@@ -27,7 +28,7 @@ const api = {
"skill": "create-opencode-client",
"title": "创建 OpenCode 客户端",
"summary": "创建 OpenCode 客户端,如果存在则复用",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -40,6 +41,7 @@ const api = {
"close": {
"path": "opencode",
"key": "close",
"id": "49672adea9daa837",
"description": "关闭 OpenCode 客户端",
"metadata": {
"tags": [
@@ -56,7 +58,7 @@ const api = {
"skill": "close-opencode-client",
"title": "关闭 OpenCode 客户端",
"summary": "关闭 OpenCode 客户端, 未提供端口则关闭默认端口",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -69,6 +71,7 @@ const api = {
"restart": {
"path": "opencode",
"key": "restart",
"id": "e0b1564a796ea88b",
"description": "重启 OpenCode 客户端",
"metadata": {
"tags": [
@@ -85,7 +88,7 @@ const api = {
"skill": "restart-opencode-client",
"title": "重启 OpenCode 客户端",
"summary": "重启 OpenCode 客户端",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -98,6 +101,7 @@ const api = {
"getUrl": {
"path": "opencode",
"key": "getUrl",
"id": "c611acf038e41279",
"description": "获取 OpenCode 服务 URL",
"metadata": {
"tags": [
@@ -114,15 +118,16 @@ const api = {
"skill": "get-opencode-url",
"title": "获取 OpenCode 服务 URL",
"summary": "获取当前 OpenCode 服务的 URL 地址",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
"ls-projects": {
"path": "opencode",
"key": "ls-projects",
"id": "ee72cd09da63d13d",
"metadata": {
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -135,6 +140,7 @@ const api = {
"runProject": {
"path": "opencode",
"key": "runProject",
"id": "112127fa82fe1d9d",
"metadata": {
"tags": [
"opencode"
@@ -150,7 +156,7 @@ const api = {
"skill": "run-opencode-project",
"title": "运行 OpenCode 项目",
"summary": "运行一个已有的 OpenCode 项目",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
}
@@ -163,13 +169,17 @@ const api = {
* @param data.question - {string} 问题
* @param data.baseUrl - {string} OpenCode 服务地址,默认为 http://localhost:4096
* @param data.directory - {string} 运行目录,默认为根目录
* @param data.messageID - {string} 消息 ID选填
* @param data.messageId - {string} 消息 ID选填
* @param data.sessionId - {string} 会话 ID选填
* @param data.providerId - {string} 指定使用的提供商 ID默认为空表示使用默认提供商
* @param data.modelId - {string} 指定使用的模型 ID默认为空表示使用默认模型
* @param data.parts - {array} 消息内容的分块,优先于 question 参数
* @param data.awaitAnswer - {boolean} 是否等待回答完成,默认为 false开启后会在回答完成后返回完整回答内容
*/
"question": {
"path": "opencode-cnb",
"key": "question",
"id": "193c9c63bc50cbbc",
"description": "创建 OpenCode 客户端",
"metadata": {
"args": {
@@ -190,7 +200,7 @@ const api = {
"type": "string",
"optional": true
},
"messageID": {
"messageId": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "消息 ID选填",
"type": "string",
@@ -202,15 +212,428 @@ const api = {
"type": "string",
"optional": true
},
"providerId": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "指定使用的提供商 ID默认为空表示使用默认提供商",
"type": "string",
"optional": true
},
"modelId": {
"$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
},
"awaitAnswer": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "是否等待回答完成,默认为 false开启后会在回答完成后返回完整回答内容",
"type": "boolean",
"optional": true
}
},
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 获取 OpenCode 可用模型列表,返回 providerID 和 modelID
*
* @param data - Request parameters
* @param data.baseUrl - {string} OpenCode 服务地址,默认为 http://localhost:4096
*/
"models": {
"path": "opencode-cnb",
"key": "models",
"id": "a66f19f8427e7085",
"description": "获取 OpenCode 可用模型列表,返回 providerID 和 modelID",
"metadata": {
"args": {
"baseUrl": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务地址,默认为 http://localhost:4096",
"type": "string",
"optional": true
}
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
}
},
"opencode-session": {
/**
* 在指定目录创建一个新的 OpenCode 会话
*
* @param data - Request parameters
* @param data.directory - {string} 工作目录,默认为 /workspace
* @param data.port - {number} OpenCode 服务端口,默认为 4096
*/
"create": {
"path": "opencode-session",
"key": "create",
"id": "f07990a69e2a1eaf",
"description": "创建 OpenCode Session",
"metadata": {
"tags": [
"session"
],
"args": {
"directory": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "工作目录,默认为 /workspace",
"type": "string",
"optional": true
},
"port": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务端口,默认为 4096",
"type": "number",
"optional": true
}
},
"skill": "create-opencode-session",
"title": "创建 Session",
"summary": "在指定目录创建一个新的 OpenCode 会话",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 更新指定 OpenCode 会话的属性,如标题
*
* @param data - Request parameters
* @param data.sessionId - {string} Session ID
* @param data.title - {string} 新的会话标题
* @param data.port - {number} OpenCode 服务端口,默认为 4096
*/
"update": {
"path": "opencode-session",
"key": "update",
"id": "0a6f7cd78fa5ff20",
"description": "更新 OpenCode Session",
"metadata": {
"tags": [
"session"
],
"args": {
"sessionId": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "Session ID"
},
"title": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "新的会话标题",
"type": "string",
"optional": true
},
"port": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务端口,默认为 4096",
"type": "number",
"optional": true
}
},
"skill": "update-opencode-session",
"title": "更新 Session",
"summary": "更新指定 OpenCode 会话的属性,如标题",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 根据 ID 删除指定的 OpenCode 会话及其所有数据
*
* @param data - Request parameters
* @param data.sessionId - {string} Session ID
* @param data.port - {number} OpenCode 服务端口,默认为 4096
*/
"delete": {
"path": "opencode-session",
"key": "delete",
"id": "c7bd762b2eccdfc2",
"description": "删除 OpenCode Session",
"metadata": {
"tags": [
"session"
],
"args": {
"sessionId": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "Session ID"
},
"port": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务端口,默认为 4096",
"type": "number",
"optional": true
}
},
"skill": "delete-opencode-session",
"title": "删除 Session",
"summary": "根据 ID 删除指定的 OpenCode 会话及其所有数据",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 中止正在运行的 OpenCode 会话
*
* @param data - Request parameters
* @param data.sessionId - {string} Session ID
* @param data.port - {number} OpenCode 服务端口,默认为 4096
*/
"abort": {
"path": "opencode-session",
"key": "abort",
"id": "0b89922558164ffd",
"description": "中止 OpenCode Session",
"metadata": {
"tags": [
"session"
],
"args": {
"sessionId": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "Session ID"
},
"port": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务端口,默认为 4096",
"type": "number",
"optional": true
}
},
"skill": "abort-opencode-session",
"title": "中止 Session",
"summary": "中止正在运行的 OpenCode 会话",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 对指定的 OpenCode 会话进行内容总结
*
* @param data - Request parameters
* @param data.sessionId - {string} Session ID
* @param data.port - {number} OpenCode 服务端口,默认为 4096
*/
"summarize": {
"path": "opencode-session",
"key": "summarize",
"id": "c51ae8a43b269383",
"description": "总结 OpenCode Session",
"metadata": {
"tags": [
"session"
],
"args": {
"sessionId": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "Session ID"
},
"port": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务端口,默认为 4096",
"type": "number",
"optional": true
}
},
"skill": "summarize-opencode-session",
"title": "总结 Session",
"summary": "对指定的 OpenCode 会话进行内容总结",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 获取当前 OpenCode 会话的运行状态,可按目录过滤
*
* @param data - Request parameters
* @param data.directory - {string} 工作目录
* @param data.port - {number} OpenCode 服务端口,默认为 4096
*/
"status": {
"path": "opencode-session",
"key": "status",
"id": "a2507055e8e1ed42",
"description": "获取 OpenCode Session 状态",
"metadata": {
"tags": [
"session"
],
"args": {
"directory": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "工作目录",
"type": "string",
"optional": true
},
"port": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务端口,默认为 4096",
"type": "number",
"optional": true
}
},
"skill": "get-opencode-session-status",
"title": "获取 Session 状态",
"summary": "获取当前 OpenCode 会话的运行状态,可按目录过滤",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 列出指定 OpenCode 会话的所有消息记录
*
* @param data - Request parameters
* @param data.sessionId - {string} Session ID
* @param data.port - {number} OpenCode 服务端口,默认为 4096
*/
"messages": {
"path": "opencode-session",
"key": "messages",
"id": "04e78517e6e9144e",
"description": "列出 OpenCode Session 消息",
"metadata": {
"tags": [
"session"
],
"args": {
"sessionId": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "Session ID"
},
"port": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务端口,默认为 4096",
"type": "number",
"optional": true
}
},
"skill": "list-opencode-session-messages",
"title": "列出 Session 消息",
"summary": "列出指定 OpenCode 会话的所有消息记录",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 列出 OpenCode 中的所有会话,可按目录过滤
*
* @param data - Request parameters
* @param data.port - {number} OpenCode 服务端口,默认为 4096
*/
"list": {
"path": "opencode-session",
"key": "list",
"id": "a44e79adfcb199dd",
"description": "列出所有 OpenCode Session",
"metadata": {
"tags": [
"session"
],
"args": {
"port": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务端口,默认为 4096",
"type": "number",
"optional": true
}
},
"skill": "list-opencode-sessions",
"title": "列出所有 Session",
"summary": "列出 OpenCode 中的所有会话,可按目录过滤",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 根据 ID 获取指定的 OpenCode 会话信息
*
* @param data - Request parameters
* @param data.id - {string} Session ID
* @param data.port - {number} OpenCode 服务端口,默认为 4096
*/
"get": {
"path": "opencode-session",
"key": "get",
"id": "7acea53865affb10",
"description": "获取指定 OpenCode Session",
"metadata": {
"tags": [
"session"
],
"args": {
"id": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "Session ID"
},
"port": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务端口,默认为 4096",
"type": "number",
"optional": true
}
},
"skill": "get-opencode-session",
"title": "获取 Session",
"summary": "根据 ID 获取指定的 OpenCode 会话信息",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 从指定消息处 Fork 一个 OpenCode 会话
*
* @param data - Request parameters
* @param data.sessionId - {string} Session ID
* @param data.messageId - {string} 从该消息处开始 Fork
* @param data.port - {number} OpenCode 服务端口,默认为 4096
*/
"fork": {
"path": "opencode-session",
"key": "fork",
"id": "d43a8e2282412078",
"description": "Fork OpenCode Session",
"metadata": {
"tags": [
"session"
],
"args": {
"sessionId": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "Session ID"
},
"messageId": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "从该消息处开始 Fork"
},
"port": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "OpenCode 服务端口,默认为 4096",
"type": "number",
"optional": true
}
},
"skill": "fork-opencode-session",
"title": "Fork Session",
"summary": "从指定消息处 Fork 一个 OpenCode 会话",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
}

View File

@@ -1,5 +1,4 @@
import { createQueryApi } from '@kevisual/query/api';
import { query } from '@/modules/query.ts';
const api = {
"project": {
/**
@@ -41,7 +40,15 @@ const api = {
"optional": true
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -64,7 +71,15 @@ const api = {
"description": "要移除的项目根目录绝对路径,必填"
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -87,7 +102,15 @@ const api = {
"description": "要暂停监听的项目根目录绝对路径,必填"
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -110,7 +133,15 @@ const api = {
"description": "要查询的项目根目录绝对路径,必填"
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -122,7 +153,15 @@ const api = {
"key": "list",
"description": "列出所有已注册的项目及其当前运行状态(路径、仓库名称、监听是否活跃等)",
"metadata": {
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -190,7 +229,46 @@ const api = {
"optional": true
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
/**
* 列出项目根目录下的所有文件路径,适用于前端展示项目结构
*
* @param data - Request parameters
* @param data.rootPath - {string} 项目根目录绝对路径,默认为 /workspace/projects指定后只列出该目录下的项目文件
*/
"project-files": {
"path": "project",
"key": "project-files",
"description": "列出项目根目录下的所有文件路径,适用于前端展示项目结构",
"metadata": {
"args": {
"rootPath": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "项目根目录绝对路径,默认为 /workspace/projects指定后只列出该目录下的项目文件",
"type": "string",
"optional": true
}
},
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -199,6 +277,7 @@ const api = {
*
* @param data - Request parameters
* @param data.rootPath - {string} 搜索项目的根目录绝对路径,默认为 /workspace/projects
* @param data.projectPaths - {array} 项目路径列表提供后将直接注册这些路径为项目忽略rootPath参数
*/
"init": {
"path": "project",
@@ -211,9 +290,26 @@ const api = {
"description": "搜索项目的根目录绝对路径,默认为 /workspace/projects",
"type": "string",
"optional": true
},
"projectPaths": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "项目路径列表提供后将直接注册这些路径为项目忽略rootPath参数",
"type": "array",
"items": {
"type": "string"
},
"optional": true
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -236,7 +332,15 @@ const api = {
"optional": true
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -265,7 +369,15 @@ const api = {
"description": "代码仓库标识,用于搜索结果展示和过滤,格式如 owner/repo例如 kevisual/cnb必填"
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
}
@@ -276,7 +388,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 +398,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 +412,6 @@ const api = {
"type": "string",
"optional": true
},
"projectPath": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按项目根目录路径过滤,仅返回该项目下的文件,选填",
"type": "string",
"optional": true
},
"filepath": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "按文件绝对路径过滤,选填",
@@ -371,9 +477,139 @@ const api = {
"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",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"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": "按文件绝对路径过滤,选填",
"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
}
},
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
}
@@ -398,7 +634,15 @@ const api = {
"description": "要读取的文件绝对路径,必填"
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -428,7 +672,15 @@ const api = {
"description": "文件内容的 base64 编码,必填"
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -489,7 +741,15 @@ const api = {
"optional": true
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -512,12 +772,20 @@ const api = {
"description": "要删除的文件绝对路径,必填"
}
},
"url": "/root/v1/cnb-dev",
"viewItem": {
"api": {
"url": "/root/v1/dev-cnb"
},
"type": "api",
"title": "CNB_BOARD",
"routerStatus": "active"
},
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
}
}
} as const;
const queryApi = createQueryApi({ api, query });
const queryApi = createQueryApi({ api });
export { queryApi };

View File

@@ -0,0 +1,204 @@
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { FileIcon, FolderIcon, RefreshCwIcon, TrashIcon, PencilIcon, CheckIcon, XIcon } from 'lucide-react';
import { useChatDevStore } from '../store';
import { useCodeGraphStore } from '@/pages/code-graph/store';
export const OpencodeChat = () => {
const {
question, projectInfo,
sessionId, isLoading,
saveSessionInfo, loadSessionInfo, fetchSession, fetchMessages, clearSession,
setData,
} = useChatDevStore(
useShallow((s) => ({
question: s.question,
projectInfo: s.projectInfo,
setData: s.setData,
sessionId: s.sessionId,
isLoading: s.isLoading,
saveSessionInfo: s.saveSessionInfo,
loadSessionInfo: s.loadSessionInfo,
fetchSession: s.fetchSession,
fetchMessages: s.fetchMessages,
clearSession: s.clearSession,
})),
);
const codeGraphStore = useCodeGraphStore(useShallow((s) => ({
createQuestion: s.createQuestion,
url: s.url,
})));
// 编辑节点信息的状态
const [isEditing, setIsEditing] = useState(false);
const [editFilepath, setEditFilepath] = useState('');
const [editProjectPath, setEditProjectPath] = useState('');
const startEditing = () => {
setEditFilepath(projectInfo?.filepath || '');
setEditProjectPath(projectInfo?.projectPath || '');
setIsEditing(true);
};
const cancelEditing = () => {
setIsEditing(false);
setEditFilepath('');
setEditProjectPath('');
};
const saveEditing = () => {
setData({
projectInfo: {
filepath: editFilepath,
projectPath: editProjectPath,
kind: projectInfo?.kind || 'file',
},
});
setIsEditing(false);
setEditFilepath('');
setEditProjectPath('');
};
// 初始化后尝试从 sessionStorage 恢复 opencode session 并加载历史消息
useEffect(() => {
if (!codeGraphStore.url) return;
const info = loadSessionInfo();
if (info) {
fetchSession(info.sessionId);
fetchMessages(info.sessionId);
}
}, [codeGraphStore.url]);
const relativePath = projectInfo
? (projectInfo.filepath || '').replace((projectInfo.projectPath || '') + '/', '') || '/'
: null;
const onSend = async () => {
const res = await codeGraphStore.createQuestion({
question,
projectPath: projectInfo?.projectPath,
engine: 'opencode',
sessionId: sessionId || undefined,
});
console.log(res);
if (res?.code === 200 && res?.data) {
const { sessionId: newSessionId, messageId: newMessageId } = res.data as any;
if (newSessionId) {
saveSessionInfo(newSessionId, newMessageId || '');
fetchMessages(newSessionId);
}
}
};
return (
<div className='w-full max-w-2xl rounded-xl border border-white/10 bg-slate-900 shadow-2xl flex flex-col'>
{/* Session 信息栏 */}
{sessionId && (
<div className='flex items-center gap-2 px-4 py-2 border-b border-white/10'>
<span className='flex items-center gap-1 text-xs text-slate-400 ml-auto'>
<span className='truncate max-w-[160px]' title={sessionId}>
Session: {sessionId.slice(0, 8)}...
</span>
<button
title='刷新消息'
onClick={() => fetchMessages(sessionId)}
className='p-1 rounded hover:text-emerald-400 transition-colors'>
<RefreshCwIcon className='size-3' />
</button>
<button
title='清除 Session'
onClick={clearSession}
className='p-1 rounded hover:text-red-400 transition-colors'>
<TrashIcon className='size-3' />
</button>
</span>
</div>
)}
{/* 内容区 */}
<div className='px-4 py-4 flex flex-col gap-4'>
{/* 节点信息 */}
{isEditing ? (
<div className='flex flex-col gap-2 px-3 py-2 rounded-lg bg-slate-800/60 border border-white/5'>
<div className='flex items-center gap-2'>
<FolderIcon className='size-4 shrink-0 text-slate-400' />
<input
type='text'
className='flex-1 min-w-0 rounded border border-white/10 bg-slate-700 px-2 py-1 text-xs text-slate-200 font-mono focus:outline-none focus:ring-1 focus:ring-emerald-500'
placeholder='projectPath'
value={editProjectPath}
onChange={(e) => setEditProjectPath(e.target.value)}
/>
</div>
<div className='flex items-center gap-2'>
<FileIcon className='size-4 shrink-0 text-slate-400' />
<input
type='text'
className='flex-1 min-w-0 rounded border border-white/10 bg-slate-700 px-2 py-1 text-xs text-slate-200 font-mono focus:outline-none focus:ring-1 focus:ring-emerald-500'
placeholder='filepath'
value={editFilepath}
onChange={(e) => setEditFilepath(e.target.value)}
/>
</div>
<div className='flex justify-end gap-1 mt-1'>
<button
onClick={cancelEditing}
className='p-1 rounded hover:text-red-400 transition-colors'
title='取消'>
<XIcon className='size-4' />
</button>
<button
onClick={saveEditing}
className='p-1 rounded hover:text-emerald-400 transition-colors'
title='保存'>
<CheckIcon className='size-4' />
</button>
</div>
</div>
) : (
(relativePath || projectInfo?.projectPath) && (
<div className='flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800/60 border border-white/5 min-w-0'>
{relativePath ? (
<>
<FileIcon className='size-4 shrink-0 text-slate-400' />
<span className='text-xs text-slate-300 font-mono truncate' title={relativePath}>
{relativePath}
</span>
</>
) : (
<>
<FolderIcon className='size-4 shrink-0 text-slate-400' />
<span className='text-xs text-slate-300 font-mono truncate'>
{projectInfo?.projectPath}
</span>
</>
)}
<button
onClick={startEditing}
className='p-1 rounded hover:text-emerald-400 transition-colors ml-auto'
title='编辑节点信息'>
<PencilIcon className='size-3' />
</button>
</div>
)
)}
{/* 问题输入区 */}
<textarea
className='w-full rounded-lg border border-white/10 bg-slate-800 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none'
rows={6}
placeholder='请输入内容...'
value={question}
onChange={(e) => setData({ question: e.target.value })}
/>
<button
disabled={isLoading}
className='w-full rounded-lg bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium py-2 transition-colors'
onClick={onSend}>
{isLoading ? '处理中...' : '发送'}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,85 @@
import { useEffect } from 'react';
import { useSearch } from '@tanstack/react-router';
import { useShallow } from 'zustand/react/shallow';
import { BotIcon } from 'lucide-react';
import { useChatDevStore } from './store';
import { BOT_KEYS, BotKey } from '@/pages/code-graph/store/bot-helper';
import openclawSvg from '@/pages/code-graph/assets/openclaw.svg';
import opencodePng from '@/pages/code-graph/assets/opencode.png';
import { useCodeGraphStore } from '../code-graph/store';
import { useLayoutStore } from '../auth/store';
import { OpencodeChat } from './components/OpencodeChat';
const BOT_ICONS: Record<BotKey, string> = {
openclaw: openclawSvg,
opencode: opencodePng,
};
const BOT_LABELS: Record<BotKey, string> = {
openclaw: 'Openclaw',
opencode: 'Opencode',
};
export const App = () => {
const { timestamp } = useSearch({ from: '/chat-dev' });
const { engine, initFromTimestamp, setData } = useChatDevStore(
useShallow((s) => ({
engine: s.engine,
initFromTimestamp: s.initFromTimestamp,
setData: s.setData,
})),
);
const layoutStore = useLayoutStore(useShallow((s) => ({ me: s.me })));
const codeGraphStore = useCodeGraphStore(useShallow((s) => ({
init: s.init,
})));
useEffect(() => {
if (!layoutStore.me?.username) return;
codeGraphStore.init(layoutStore.me, { load: false });
}, [layoutStore.me]);
useEffect(() => {
if (timestamp) {
initFromTimestamp(timestamp);
}
}, [timestamp]);
return (
<div className='h-full bg-slate-950 text-slate-100 flex flex-col items-center py-10 px-4 overflow-auto'>
<div className='w-full max-w-2xl flex flex-col gap-4'>
{/* Bot 切换按钮 —— 最顶层 */}
<div className='flex items-center gap-2 px-1'>
<BotIcon className='size-4 text-emerald-400 shrink-0' />
<span className='text-sm font-medium text-slate-400 mr-1'>Bot</span>
<div className='flex items-center gap-1'>
{BOT_KEYS.map((key) => (
<button
key={key}
title={key}
onClick={() => setData({ engine: key })}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors ${
engine === key
? 'border-emerald-500/60 bg-emerald-500/10 text-emerald-300'
: 'border-white/5 bg-slate-800/60 text-slate-400 opacity-50 hover:opacity-80'
}`}>
<img src={BOT_ICONS[key]} alt={key} className='size-4 object-contain' />
{BOT_LABELS[key]}
</button>
))}
</div>
</div>
{/* Bot 对应的聊天面板 */}
{engine === 'opencode' && <OpencodeChat />}
{engine === 'openclaw' && (
<div className='w-full rounded-xl border border-white/10 bg-slate-900 shadow-2xl flex items-center justify-center py-16 text-slate-500 text-sm'>
Openclaw
</div>
)}
</div>
</div>
);
};
export default App;

View File

@@ -0,0 +1,174 @@
import { create } from 'zustand';
import { BotKey } from '@/pages/code-graph/store/bot-helper';
import { queryApi as opencodeApi } from '@/modules/opencode-api';
import { getApiUrl } from '@/pages/code-graph/store';
export type ChatDevData = {
question: string;
engine: BotKey;
projectInfo: {
filepath: string;
projectPath: string;
kind: 'file' | 'dir' | 'root';
} | null;
};
export type SessionMessage = {
info: any;
parts: Array<any>;
};
export type SessionInfo = {
sessionId: string;
messageId: string;
};
type ChatDevState = {
question: string;
engine: BotKey;
projectInfo: {
filepath: string;
projectPath: string;
kind?: 'file' | 'dir' | 'root';
} | null;
sessionId: string | null;
messageId: string | null;
messages: SessionMessage[];
isLoading: boolean;
setData: (data: Partial<ChatDevData>) => void;
initFromTimestamp: (timestamp: string) => void;
saveSessionInfo: (sessionId: string, messageId: string) => void;
loadSessionInfo: () => SessionInfo | null;
fetchSession: (sessionId: string) => Promise<void>;
fetchMessages: (sessionId: string) => Promise<void>;
clearSession: () => void;
};
const SESSION_KEY_PREFIX = 'chat-dev-';
const SESSION_KEY = 'chat-dev';
const SESSION_INFO_KEY = 'chat-dev-session-info';
export const useChatDevStore = create<ChatDevState>()((set, get) => ({
question: '',
engine: 'opencode',
projectInfo: null,
sessionId: null,
messageId: null,
messages: [],
isLoading: false,
setData: (data) => set((s) => ({ ...s, ...data })),
saveSessionInfo: (sessionId: string, messageId: string) => {
const info: SessionInfo = { sessionId, messageId };
sessionStorage.setItem(SESSION_INFO_KEY, JSON.stringify(info));
set({ sessionId, messageId });
},
loadSessionInfo: () => {
const raw = sessionStorage.getItem(SESSION_INFO_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as SessionInfo;
} catch {
sessionStorage.removeItem(SESSION_INFO_KEY);
return null;
}
},
fetchSession: async (sessionId: string) => {
set({ isLoading: true });
try {
const url = getApiUrl();
const res = await opencodeApi['opencode-session'].get({ id: sessionId }, { url });
if (res.code === 200 && res.data) {
set({ sessionId });
}
} catch {
// 静默失败,不影响主流程
} finally {
set({ isLoading: false });
}
},
fetchMessages: async (sessionId: string) => {
set({ isLoading: true });
try {
const url = getApiUrl();
const res = await opencodeApi['opencode-session'].messages({ sessionId: sessionId }, { url });
if (res.code === 200 && res.data) {
const msgs: SessionMessage[] = Array.isArray(res.data) ? res.data : (res.data as any).list ?? [];
set({ messages: msgs });
}
} catch {
// 静默失败,不影响主流程
} finally {
set({ isLoading: false });
}
},
clearSession: () => {
sessionStorage.removeItem(SESSION_INFO_KEY);
set({ sessionId: null, messageId: null, messages: [] });
},
initFromTimestamp: (timestamp: string) => {
const key = SESSION_KEY_PREFIX + timestamp;
// 优先从 localStorage 读取(首次打开)
const localRaw = localStorage.getItem(key);
if (localRaw) {
try {
const data: ChatDevData = JSON.parse(localRaw);
// 持久化到 sessionStorage供刷新页面使用不带 timestamp
sessionStorage.setItem(SESSION_KEY, localRaw);
// 清除 localStorage
localStorage.removeItem(key);
// filepath和projectPath可以通过URL参数传递优先级高于存储的内容
const urlParams = new URLSearchParams(window.location.search);
const urlFilepath = urlParams.get('filepath');
const urlProjectPath = urlParams.get('projectPath');
if (urlFilepath || urlProjectPath) {
const projectInfo = {
...data.projectInfo,
filepath: urlFilepath ? decodeURI(urlFilepath) : data.projectInfo?.filepath ?? '',
projectPath: urlProjectPath ? decodeURI(urlProjectPath) : data.projectInfo?.projectPath ?? '',
};
set({ question: data.question, engine: data.engine, projectInfo });
} else {
set({ question: data.question, engine: data.engine, projectInfo: data.projectInfo });
}
return;
} catch {
localStorage.removeItem(key);
}
}
// 刷新页面时从 sessionStorage 读取(固定 key不带 timestamp
const sessionRaw = sessionStorage.getItem(SESSION_KEY);
if (sessionRaw) {
try {
const data: ChatDevData = JSON.parse(sessionRaw);
// filepath和projectPath可以通过URL参数传递优先级高于存储的内容
const urlParams = new URLSearchParams(window.location.search);
const urlFilepath = urlParams.get('filepath');
const urlProjectPath = urlParams.get('projectPath');
if (urlFilepath || urlProjectPath) {
const projectInfo = {
...data.projectInfo,
filepath: urlFilepath ? decodeURI(urlFilepath) : data.projectInfo?.filepath ?? '',
projectPath: urlProjectPath ? decodeURI(urlProjectPath) : data.projectInfo?.projectPath ?? '',
};
set({ question: data.question, engine: data.engine, projectInfo });
} else {
set({ question: data.question, engine: data.engine, projectInfo: data.projectInfo });
}
return;
} catch {
sessionStorage.removeItem(SESSION_KEY);
}
}
},
}));

View File

@@ -1,4 +1,6 @@
import { BotIcon, XIcon, FileIcon, FolderIcon, DatabaseIcon } from 'lucide-react';
import { BotIcon, XIcon, FileIcon, FolderIcon, DatabaseIcon, MoreHorizontalIcon } from 'lucide-react';
import { useNavigate, useLocation } from '@tanstack/react-router';
import { toast } from 'sonner';
import { useBotHelperStore, BOT_KEYS, BotKey } from '../store/bot-helper';
import { useShallow } from 'zustand/react/shallow';
import { useCodeGraphStore, NodeInfoData } from '../store';
@@ -18,7 +20,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,
@@ -26,30 +28,44 @@ export function BotHelperModal() {
closeModal: s.closeModal,
activeKey: s.activeKey,
setActiveKey: s.setActiveKey,
projectInfo: s.projectInfo,
})),
);
const { nodeInfoData, createQuestion } = useCodeGraphStore(useShallow((s) => ({
nodeInfoData: s.nodeInfoData,
const { createQuestion } = useCodeGraphStore(useShallow((s) => ({
createQuestion: s.createQuestion,
})));
const relativePath = nodeInfoData
? nodeInfoData.fullPath.replace((nodeInfoData.projectPath || '') + '/', '') || '/'
const location = useLocation();
console.log('BotHelperModal render', botHelperStore.projectInfo);
const basename = location.publicHref.replace(location.href, '');
const projectPath = botHelperStore.projectInfo?.projectPath;
const filepath = botHelperStore.projectInfo?.filepath;
const relativePath = botHelperStore.projectInfo
? filepath?.replace((projectPath || '') + '/', '') || '/'
: null;
const kind = botHelperStore.projectInfo?.kind || 'dir';
const handleConfirm = async () => {
if (nodeInfoData) {
const projectPath = botHelperStore.projectInfo?.projectPath;
if (projectPath) {
const res = await createQuestion({
question: input,
projectPath: nodeInfoData.projectPath,
engine: activeKey,
question: botHelperStore.input,
projectPath,
filePath: botHelperStore.projectInfo?.kind === 'file' ? botHelperStore.projectInfo.filepath : undefined,
engine: botHelperStore.activeKey,
});
console.log(res);
}
closeModal();
toast.success('消息发送成功');
botHelperStore.closeModal();
};
if (!open) return null;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
};
if (!botHelperStore.open) return null;
return (
<div className='fixed inset-0 z-[200] flex items-center justify-center'>
@@ -62,19 +78,35 @@ export function BotHelperModal() {
<BotIcon className='size-4 text-emerald-400' />
<span className='text-sm font-medium text-slate-100'>AI </span>
</div>
<button
onClick={closeModal}
className='text-slate-500 hover:text-slate-200 transition-colors'>
<XIcon className='size-4' />
</button>
<div className='flex items-center gap-1'>
<button
onClick={() => {
const timestamp = Date.now();
localStorage.setItem('chat-dev-' + timestamp, JSON.stringify({
question: botHelperStore.input,
engine: botHelperStore.activeKey,
projectInfo: botHelperStore.projectInfo,
}));
window.open(`${basename}/chat-dev?timestamp=${timestamp}`, '_blank');
}}
className='text-slate-500 hover:text-slate-200 transition-colors p-1 rounded hover:bg-white/10'
title='新窗口打开'>
<MoreHorizontalIcon className='size-4' />
</button>
<button
onClick={botHelperStore.closeModal}
className='text-slate-500 hover:text-slate-200 transition-colors'>
<XIcon className='size-4' />
</button>
</div>
</div>
{/* 内容区 */}
<div className='px-4 py-4 flex flex-col gap-4'>
{/* 节点信息 + 按钮组 */}
<div className='flex items-center gap-2'>
{nodeInfoData && relativePath && (
{botHelperStore.projectInfo && relativePath && (
<div className='flex-1 flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800/60 border border-white/5 min-w-0'>
<NodeIcon kind={nodeInfoData.kind} color={nodeInfoData.color} />
<NodeIcon kind={kind} color={kind === 'dir' ? 'blue' : 'green'} />
<span
className='text-xs text-slate-300 font-mono truncate'
title={relativePath}>
@@ -88,8 +120,8 @@ export function BotHelperModal() {
<button
key={key}
title={key}
onClick={() => setActiveKey(key)}
className={`p-1.5 rounded-lg border transition-colors ${activeKey === key
onClick={() => botHelperStore.setActiveKey(key)}
className={`p-1.5 rounded-lg border transition-colors ${botHelperStore.activeKey === key
? 'border-emerald-500/60 bg-emerald-500/10'
: 'border-white/5 bg-slate-800/60 opacity-40 hover:opacity-70'
}`}>
@@ -102,8 +134,9 @@ export function BotHelperModal() {
className='w-full rounded-lg border border-white/10 bg-slate-800 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none'
rows={5}
placeholder='请输入内容...'
value={input}
onChange={(e) => setInput(e.target.value)}
value={botHelperStore.input}
onChange={(e) => botHelperStore.setInput(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
<button

View File

@@ -2,11 +2,15 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import ForceGraph3D from '3d-force-graph';
import type { NodeObject, LinkObject, ForceGraph3DInstance } from '3d-force-graph';
import * as THREE from 'three';
import SpriteText from 'three-spritetext';
import { SlidersHorizontalIcon } from 'lucide-react';
import { FileProjectData } from '../modules/tree';
import { NodeSearchEntry } from '../modules/graph';
import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox';
import { useCodeGraphStore } from '../store';
import { useShallow } from 'zustand/react/shallow';
import { useGraph3DConfig } from '../modules/graph3d-config';
import { Graph3DConfigDialog } from './Graph3DConfigDialog';
// ─── 类型定义 ─────────────────────────────────────────────────────────────────
@@ -180,17 +184,26 @@ function buildGraph3DData(files: FileProjectData[]): Graph3DData {
interface Code3DGraphProps {
files: FileProjectData[];
className?: string;
type?: "map" | 'minimap';
onProjectFocus?: string;
}
// ─── 主组件 ───────────────────────────────────────────────────────────────────
export function Code3DGraph({ files, className }: Code3DGraphProps) {
export function Code3DGraph({ files, className, type, onProjectFocus }: Code3DGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<ForceGraph3DInstance | null>(null);
const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
const [configOpen, setConfigOpen] = useState(false);
const { setNodeInfo, selectedNodeId } = useCodeGraphStore(
const { config, updateConfig, resetConfig } = useGraph3DConfig();
const configRef = useRef(config);
useEffect(() => {
configRef.current = config;
}, [config]);
const codeGraphStore = useCodeGraphStore(
useShallow((s) => ({
setNodeInfo: s.setNodeInfo,
selectedNodeId: s.nodeInfoData?.fullPath ?? null,
@@ -200,8 +213,8 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
// 用 ref 避免 nodeThreeObject 回调的陈旧闭包
const selectedNodeIdRef = useRef<string | null>(null);
useEffect(() => {
selectedNodeIdRef.current = selectedNodeId;
}, [selectedNodeId]);
selectedNodeIdRef.current = codeGraphStore.selectedNodeId;
}, [codeGraphStore.selectedNodeId]);
// 节点跳转
const focusNode = useCallback((nodeKey: string) => {
@@ -218,6 +231,19 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
);
}, []);
// 外部触发项目跳转
useEffect(() => {
if (!onProjectFocus) return;
const rootKey = `root::${onProjectFocus}`;
const graph = graphRef.current;
if (graph) {
const node = (graph.graphData().nodes as Graph3DNode[]).find((n) => n.id === rootKey);
if (node) {
focusNode(rootKey);
}
}
}, [onProjectFocus, focusNode]);
// 将节点世界坐标投影为屏幕坐标
const nodeToScreenPos = useCallback((node: Graph3DNode): { x: number; y: number } => {
const graph = graphRef.current;
@@ -261,32 +287,48 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
.nodeLabel((node) => (node as Graph3DNode).label)
.nodeThreeObject(((node: Graph3DNode) => {
const n = node as Graph3DNode;
if (!n?.fullPath || n.fullPath !== selectedNodeIdRef.current) return null;
const isSelected = n?.fullPath && n.fullPath === selectedNodeIdRef.current;
const showLabels = configRef.current.showLabels;
const geometry = new THREE.SphereGeometry(n.nodeSize * 1.2, 32, 32);
const material = new THREE.MeshStandardMaterial({
color: n.color,
emissive: n.color,
emissiveIntensity: 1.5,
roughness: 0.3,
metalness: 0.5,
transparent: true,
opacity: 0.9,
});
const group = new THREE.Group();
const mesh = new THREE.Mesh(geometry, material);
// 选中节点:发光球效果
if (isSelected) {
const geometry = new THREE.SphereGeometry(n.nodeSize * 1.2, 32, 32);
const material = new THREE.MeshStandardMaterial({
color: n.color,
emissive: n.color,
emissiveIntensity: 1.5,
roughness: 0.3,
metalness: 0.5,
transparent: true,
opacity: 0.9,
});
const mesh = new THREE.Mesh(geometry, material);
const glowGeometry = new THREE.SphereGeometry(n.nodeSize * 2, 32, 32);
const glowMaterial = new THREE.MeshBasicMaterial({
color: n.color,
transparent: true,
opacity: 0.25,
});
mesh.add(new THREE.Mesh(glowGeometry, glowMaterial));
group.add(mesh);
}
const glowGeometry = new THREE.SphereGeometry(n.nodeSize * 2, 32, 32);
const glowMaterial = new THREE.MeshBasicMaterial({
color: n.color,
transparent: true,
opacity: 0.25,
});
const glowMesh = new THREE.Mesh(glowGeometry, glowMaterial);
mesh.add(glowMesh);
// 文字标签SpriteText
if (showLabels) {
const sprite = new SpriteText(n.label);
(sprite.material as THREE.SpriteMaterial).depthWrite = false;
sprite.color = n.color;
sprite.textHeight = n.kind === 'root' ? 4 : n.kind === 'dir' ? 2.5 : 2;
sprite.center.set(0.5, -0.4);
group.add(sprite);
}
return mesh;
if (group.children.length === 0) return null;
return group;
}) as any)
.nodeThreeObjectExtend(((node: Graph3DNode) => configRef.current.showLabels) as any)
.linkWidth(0)
.linkColor(() => 'rgba(255,255,255,0.6)')
.linkDirectionalParticles(2)
@@ -301,7 +343,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
// 等相机飞行动画结束800ms用节点投影坐标打开 NodeInfo
setTimeout(() => {
const pos = nodeToScreenPos(n);
setNodeInfo(
codeGraphStore.setNodeInfo(
{
label: n.label,
fullPath: n.fullPath,
@@ -345,7 +387,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
return () => {
ro.disconnect();
};
}, [files, focusNode, setNodeInfo, nodeToScreenPos]);
}, [files, focusNode, codeGraphStore.setNodeInfo, nodeToScreenPos]);
// 卸载时销毁
useEffect(() => {
@@ -361,11 +403,37 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
if (graph) {
graph.refresh();
}
}, [selectedNodeId]);
}, [codeGraphStore.selectedNodeId]);
// 配置变化时更新 nodeThreeObjectExtend 并刷新
useEffect(() => {
const graph = graphRef.current;
if (!graph) return;
(graph as any).nodeThreeObjectExtend(((node: Graph3DNode) => configRef.current.showLabels) as any);
graph.refresh();
}, [config]);
return (
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
<div ref={containerRef} className='w-full h-full' />
{/* 设置按钮 */}
<button
onClick={() => setConfigOpen(true)}
className='absolute top-3 right-3 z-10 flex items-center justify-center w-8 h-8 rounded-md bg-slate-800/80 hover:bg-slate-700/90 border border-white/10 text-slate-300 hover:text-white transition-colors backdrop-blur'
title='3D 图谱配置'>
<SlidersHorizontalIcon className='w-4 h-4' />
</button>
{/* 配置弹窗 */}
<Graph3DConfigDialog
open={configOpen}
onOpenChange={setConfigOpen}
config={config}
onUpdate={updateConfig}
onReset={resetConfig}
/>
<div className='absolute top-3 left-1/2 -translate-x-1/2 z-10 w-72'>
<NodeSearchBox
ref={searchBoxRef}
@@ -378,7 +446,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
if (!n) return;
setTimeout(() => {
const pos = nodeToScreenPos(n);
setNodeInfo(
codeGraphStore.setNodeInfo(
{
label: n.label,
fullPath: n.fullPath,
@@ -397,5 +465,3 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
</div>
);
}
export default Code3DGraph;

View File

@@ -184,6 +184,12 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
getFiles: s.getFiles,
saveFile: s.saveFile,
})));
const botHelperStore = useBotHelperStore(useShallow((s) => ({
openModal: s.openModal,
setProjectInfo: s.setProjectInfo,
})));
const [showFileName, setShowFileName] = useState(true); // 是否显示文件名(移动端默认隐藏)
const [showProjectPath, setShowProjectPath] = useState(false); // 是否显示项目路径(移动端默认隐藏)
const projectPath = nodeAttrs?.projectPath ?? '';
const displayPath = rootPath.startsWith(projectPath)
? rootPath.slice(projectPath.length) || '/'
@@ -233,7 +239,11 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
};
const handleAIOpen = () => {
useBotHelperStore.getState().openModal();
botHelperStore.setProjectInfo({
filepath: nodeAttrs?.fullPath ?? '',
projectPath: nodeAttrs?.projectPath ?? '',
});
botHelperStore.openModal();
};
useEffect(() => {
@@ -275,8 +285,8 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
{/* 标题:显示相对路径(去掉 projectPath */}
<div className='px-3 py-2.5 border-b border-white/10 shrink-0 min-w-[14rem]'>
<div className='text-[10px] text-slate-500 mb-0.5'></div>
<div className='text-xs font-semibold text-slate-300 truncate' title={rootPath}>
{displayPath}
<div className='text-xs font-semibold text-slate-300 truncate' title={rootPath} onClick={() => setShowProjectPath((v) => !v)} style={{ cursor: 'pointer' }}>
{showProjectPath ? rootPath : displayPath}
</div>
</div>
{/* 目录树 */}
@@ -299,7 +309,7 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
{/* 编辑器区域 */}
<div className='flex-1 flex flex-col min-w-0'>
{/* 编辑器标题栏 */}
<div className='flex items-center gap-2 px-4 py-2.5 border-b border-white/10 text-xs text-slate-400 shrink-0'>
<div className='flex items-center gap-2 px-4 py-2.5 border-b border-white/10 text-xs text-slate-400 shrink-0 mr-6'>
{/* 侧边栏切换按钮 */}
<button
onClick={() => setSidebarOpen((v) => !v)}
@@ -307,8 +317,8 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}>
{sidebarOpen ? '◀' : '▶'}
</button>
<span className='truncate text-slate-200'>
{selectedFile?.filepath ?? nodeAttrs.fullPath}
<span className='truncate text-slate-200' title={selectedFile?.filepath ?? nodeAttrs.fullPath} onClick={() => setShowFileName((v) => !v)} style={{ cursor: 'pointer' }}>
{showFileName ? filename : selectedFile?.filepath ?? nodeAttrs.fullPath}
</span>
<div className='ml-1 flex items-center gap-1 shrink-0'>
{loading && <span className='text-slate-500 text-xs'></span>}

View File

@@ -0,0 +1,118 @@
import { SlidersHorizontalIcon, TagIcon, SparklesIcon, RotateCcwIcon, CheckIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Graph3DConfig } from '../modules/graph3d-config';
interface Graph3DConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: Graph3DConfig;
onUpdate: (patch: Partial<Graph3DConfig>) => void;
onReset: () => void;
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className='flex items-center gap-2 mb-3'>
<div className='h-px flex-1 bg-gradient-to-r from-white/10 to-transparent' />
<span className='text-[10px] font-semibold tracking-widest text-slate-500 uppercase'>{children}</span>
<div className='h-px flex-1 bg-gradient-to-l from-white/10 to-transparent' />
</div>
);
}
interface ConfigRowProps {
icon: React.ReactNode;
title: string;
description: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
}
function ConfigRow({ icon, title, description, checked, onCheckedChange }: ConfigRowProps) {
return (
<label className='group flex items-center gap-4 rounded-lg border border-white/5 bg-white/[0.03] hover:bg-white/[0.06] hover:border-white/10 px-4 py-3 cursor-pointer transition-all duration-150 select-none'>
<div className='flex-shrink-0 w-8 h-8 rounded-md bg-slate-800 border border-white/10 flex items-center justify-center text-slate-400 group-hover:text-slate-200 transition-colors'>
{icon}
</div>
<div className='flex-1 min-w-0'>
<p className='text-sm font-medium text-slate-200 leading-snug'>{title}</p>
<p className='text-xs text-slate-500 mt-0.5 leading-snug'>{description}</p>
</div>
<Checkbox
checked={checked}
onCheckedChange={(v) => onCheckedChange(!!v)}
className='flex-shrink-0'
/>
</label>
);
}
export function Graph3DConfigDialog({ open, onOpenChange, config, onUpdate, onReset }: Graph3DConfigDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-sm p-0 overflow-hidden border-white/10 bg-slate-900'>
{/* 标题栏 */}
<div className='relative px-5 pt-5 pb-4 border-b border-white/5'>
{/* 顶部光晕装饰 */}
<div className='pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-indigo-500/40 to-transparent' />
<DialogHeader>
<DialogTitle className='flex items-center gap-2.5 text-slate-100'>
<div className='w-7 h-7 rounded-md bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center'>
<SlidersHorizontalIcon className='w-3.5 h-3.5 text-indigo-400' />
</div>
<span className='text-sm font-semibold'>3D </span>
</DialogTitle>
</DialogHeader>
<p className='mt-1.5 text-xs text-slate-500 pl-9'> 3D </p>
</div>
{/* 配置内容 */}
<div className='px-5 py-4 space-y-5'>
{/* ── 显示 ── */}
<section>
<SectionTitle></SectionTitle>
<div className='space-y-2'>
<ConfigRow
icon={<TagIcon className='w-3.5 h-3.5' />}
title='节点文字标签'
description='在节点旁以 3D 精灵字体显示文件名'
checked={config.showLabels}
onCheckedChange={(v) => onUpdate({ showLabels: v })}
/>
</div>
</section>
{/* ── 其他配置暂留 ── */}
<section>
<SectionTitle></SectionTitle>
<div className='flex items-center gap-3 rounded-lg border border-dashed border-white/8 px-4 py-3'>
<SparklesIcon className='w-3.5 h-3.5 text-slate-600 flex-shrink-0' />
<span className='text-xs text-slate-600'></span>
</div>
</section>
</div>
{/* 底部操作栏 */}
<div className='flex items-center justify-between px-5 py-3 border-t border-white/5 bg-slate-950/40'>
<button
onClick={onReset}
className='flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors'
>
<RotateCcwIcon className='w-3 h-3' />
</button>
<Button
size='sm'
className='h-7 px-4 text-xs bg-indigo-600 hover:bg-indigo-500 border-0 gap-1.5'
onClick={() => onOpenChange(false)}
>
<CheckIcon className='w-3 h-3' />
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { FileIcon, FolderIcon, DatabaseIcon, XIcon, MoveIcon, SquarePenIcon, BotIcon } from 'lucide-react';
import { FileIcon, FolderIcon, DatabaseIcon, XIcon, MoveIcon, SquarePenIcon, BotIcon, FileTextIcon } from 'lucide-react';
import { useCodeGraphStore, NodeInfoData } from '../store';
import { useBotHelperStore } from '../store/bot-helper';
import { useShallow } from 'zustand/react/shallow';
import clsx from 'clsx';
import { FileDescriptionDialog, FileDescriptionData } from '@/components/FileDescriptionDialog';
function KindIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }) {
const cls = 'size-4 shrink-0';
@@ -12,7 +14,7 @@ function KindIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }
}
const KIND_LABEL: Record<NodeInfoData['kind'], string> = {
root: '项目根目录',
root: '根目录',
dir: '目录',
file: '文件',
};
@@ -21,7 +23,6 @@ export function NodeInfo() {
const codeGraphStore = useCodeGraphStore(
useShallow((s) => ({
nodeInfoData: s.nodeInfoData,
})),
);
const projectPath = codeGraphStore.nodeInfoData?.projectPath || '';
@@ -51,14 +52,17 @@ export function NodeInfo() {
</div></>)
}
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,
closeNodeInfo: s.closeNodeInfo,
setCodePodOpen: s.setCodePodOpen,
setCodePodAttrs: s.setCodePodAttrs,
isMobile: s.isMobile,
setIsMobile: s.setIsMobile,
})),
);
@@ -75,18 +79,32 @@ 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);
// 描述弹窗
const [descriptionOpen, setDescriptionOpen] = useState(false);
const [descriptionData, setDescriptionData] = useState<FileDescriptionData>({
filepath: '',
title: '',
tags: [],
summary: '',
description: '',
link: '',
});
// 检测屏幕大小
useEffect(() => {
@@ -143,11 +161,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 (
<div
className={`fixed z-50 rounded-xl border border-white/10 bg-slate-900/95 backdrop-blur-sm shadow-2xl select-none ${isMobile ? 'w-full h-full' : 'w-72'}`}
className={clsx(`fixed z-50 rounded-xl border border-white/10 bg-slate-900/95 backdrop-blur-sm shadow-2xl select-none`, isMobile ? 'w-full h-full' : 'w-72',
isMobile && codePodOpen && 'hidden' // 移动端如果 CodePod 打开了,就隐藏 NodeInfo避免遮挡
)}
style={posStyle}
onMouseDown={isMobile ? undefined : onMouseDown}>
{/* 标题栏 */}
@@ -164,7 +182,31 @@ export const NodeInfoContainer = () => {
<SquarePenIcon className='size-3.5' />
</button>
<button
onClick={openBotModal}
onClick={() => {
if (!nodeInfoData) return;
setDescriptionData({
filepath: nodeInfoData.fullPath,
title: nodeInfoData.title,
tags: nodeInfoData.tags,
summary: nodeInfoData.summary,
description: nodeInfoData.description,
link: nodeInfoData.link,
});
setDescriptionOpen(true);
}}
title='编辑描述'
className='ml-1 text-slate-500 hover:text-amber-400 transition-colors'>
<FileTextIcon className='size-3.5' />
</button>
<button
onClick={() => {
botHelperStore.setProjectInfo({
filepath: nodeInfoData.fullPath,
projectPath: nodeInfoData.projectPath,
kind: nodeInfoData.kind,
});
botHelperStore.openModal();
}}
title='AI 助手'
className='ml-1 text-slate-500 hover:text-emerald-400 transition-colors'>
<BotIcon className='size-3.5' />
@@ -181,6 +223,15 @@ export const NodeInfoContainer = () => {
</button>
</div>
<NodeInfo />
{/* 描述编辑弹窗 */}
<FileDescriptionDialog
open={descriptionOpen}
onOpenChange={setDescriptionOpen}
data={descriptionData}
onSuccess={() => {
// 可选:刷新节点数据
}}
/>
</div>
);
}

View File

@@ -1,13 +1,195 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { FolderOpenIcon, PlusIcon, Trash2Icon, RefreshCwIcon, PlayCircleIcon, StopCircleIcon, FolderIcon, AlertCircleIcon, CircleOffIcon } from 'lucide-react';
import { FolderOpenIcon, PlusIcon, Trash2Icon, RefreshCwIcon, PlayCircleIcon, StopCircleIcon, FolderIcon, AlertCircleIcon, CircleOffIcon, DownloadIcon, ListTodoIcon, CheckSquareIcon } from 'lucide-react';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent } from '@/components/ui/card';
import { useCodeGraphStore } from '../store';
// 初始化项目弹窗组件
function ProjectInitDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { initProject, fetchProjectFiles } = useCodeGraphStore(
useShallow((s) => ({
initProject: s.initProject,
fetchProjectFiles: s.fetchProjectFiles,
})),
);
const [loading, setLoading] = useState(false);
const [files, setFiles] = useState<string[]>([]);
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
const [rootPath, setRootPath] = useState('/workspace/projects');
// 加载文件列表
const loadFiles = async () => {
const data = await fetchProjectFiles(rootPath);
setFiles(data);
};
// 打开时加载数据
useEffect(() => {
if (open) {
loadFiles();
}
}, [open]);
// 切换选中状态
const toggleSelection = (path: string) => {
setSelectedPaths((prev) =>
prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path]
);
};
// 全选
const selectAll = () => setSelectedPaths([...files]);
// 取消全选
const deselectAll = () => setSelectedPaths([]);
// 确认初始化
const handleConfirm = async () => {
setLoading(true);
await initProject(selectedPaths.length > 0 ? selectedPaths : undefined);
setLoading(false);
setSelectedPaths([]);
onOpenChange(false);
};
// 取消
const handleCancel = () => {
setSelectedPaths([]);
onOpenChange(false);
};
const allSelected = files.length > 0 && selectedPaths.length === files.length;
const isIndeterminate = selectedPaths.length > 0 && selectedPaths.length < files.length;
return (
<Dialog open={open} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className='sm:max-w-2xl max-h-[80vh] flex flex-col bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl'>
<DialogHeader className='text-center'>
<div className='flex justify-center mb-3'>
<div className='w-12 h-12 rounded-full flex items-center justify-center bg-indigo-500/20'>
<DownloadIcon className='w-6 h-6 text-indigo-400' />
</div>
</div>
<DialogTitle className='text-lg font-semibold'>
</DialogTitle>
<DialogDescription className='text-slate-400'>
</DialogDescription>
</DialogHeader>
{/* 路径输入 */}
<div className='px-6'>
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<FolderIcon className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500' />
<Input
value={rootPath}
onChange={(e) => setRootPath(e.target.value)}
placeholder='项目根目录'
className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm pl-10 pr-4 focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all'
/>
</div>
<Button
size='sm'
onClick={loadFiles}
className='bg-slate-700 hover:bg-slate-600 shrink-0'>
</Button>
</div>
</div>
{/* 项目列表 */}
<div className='flex-1 overflow-hidden px-6 py-2'>
<Card className='bg-slate-800/50 border-white/10 h-full'>
<CardContent className='p-3 h-[280px] overflow-y-auto scrollbar'>
{files.length === 0 ? (
<div className='flex flex-col items-center justify-center h-full text-slate-500 text-sm gap-2'>
<FolderIcon className='w-8 h-8 text-slate-600' />
<span></span>
</div>
) : (
<div className='space-y-1'>
{files.map((path) => (
<div
key={path}
onClick={() => toggleSelection(path)}
className={`flex items-center gap-3 rounded-lg px-3 py-2 cursor-pointer transition-all ${selectedPaths.includes(path)
? 'bg-indigo-500/20 border border-indigo-500/50'
: 'hover:bg-white/5 border border-transparent'
}`}>
<Checkbox
checked={selectedPaths.includes(path)}
onCheckedChange={() => toggleSelection(path)}
className='border-slate-500 data-[checked]:bg-indigo-500 data-[checked]:border-indigo-500'
/>
<FolderIcon className='w-4 h-4 text-slate-400 shrink-0' />
<span className='text-sm text-slate-200 truncate'>{path}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* 操作按钮 */}
<div className='flex items-center justify-between px-6 pb-4'>
<div className='flex items-center gap-3'>
<Button
size='sm'
variant='outline'
onClick={isIndeterminate ? selectAll : allSelected ? deselectAll : selectAll}
disabled={files.length === 0}
className='bg-transparent border-white/20 text-slate-300 hover:bg-white/10 hover:border-white/30'>
{allSelected ? '取消全选' : '全选'}
</Button>
<span className='text-xs text-slate-400'>
{selectedPaths.length}/{files.length}
</span>
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={handleCancel}
disabled={loading}
className='bg-transparent border-white/20 text-slate-300 hover:bg-white/10 hover:border-white/30'>
</Button>
<Button
onClick={handleConfirm}
disabled={loading}
className='bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 disabled:opacity-50'>
{loading ? (
<span className='flex items-center gap-2'>
<span className='w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin' />
</span>
) : (
'确认初始化'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function ProjectDialog() {
const {
projectDialogOpen,
@@ -50,6 +232,63 @@ export function ProjectDialog() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteProject, setPendingDeleteProject] = useState<{ path: string; name?: string } | null>(null);
// 初始化弹窗
const [initConfirmOpen, setInitConfirmOpen] = useState(false);
// 多选模式
const [multiSelectMode, setMultiSelectMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
// 切换多选模式
const toggleMultiSelectMode = () => {
if (multiSelectMode) {
// 退出多选模式时清空选择
setSelectedProjects([]);
}
setMultiSelectMode(!multiSelectMode);
};
// 切换项目选中状态
const toggleProjectSelection = (path: string) => {
setSelectedProjects((prev) =>
prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path]
);
};
// 全选
const selectAll = () => {
setSelectedProjects(projects.map((p) => p.path));
};
// 取消全选
const deselectAll = () => {
setSelectedProjects([]);
};
// 全部启动
const handleStartAll = async () => {
for (const path of selectedProjects) {
const project = projects.find((p) => p.path === path);
if (project && project.status !== 'active') {
await toggleProjectStatus(path);
}
}
setSelectedProjects([]);
setMultiSelectMode(false);
};
// 全部关闭
const handleStopAll = async () => {
for (const path of selectedProjects) {
const project = projects.find((p) => p.path === path);
if (project && project.status === 'active') {
await toggleProjectStatus(path);
}
}
setSelectedProjects([]);
setMultiSelectMode(false);
};
const handleAdd = async () => {
if (!newPath.trim()) return;
setAddLoading(true);
@@ -116,201 +355,311 @@ export function ProjectDialog() {
return (
<Dialog open={projectDialogOpen} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl'>
<DialogContent className='w-[calc(100%-2rem)] sm:max-w-lg max-h-[85vh] flex flex-col bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl overflow-hidden'>
{/* 装饰性背景 */}
<div className='absolute inset-0 overflow-hidden pointer-events-none'>
<div className='absolute -top-20 -right-20 w-40 h-40 bg-indigo-500/10 rounded-full blur-3xl' />
<div className='absolute -bottom-20 -left-20 w-40 h-40 bg-purple-500/10 rounded-full blur-3xl' />
</div>
<DialogHeader className='relative'>
<DialogHeader className='relative shrink-0'>
<DialogTitle className='flex items-center gap-3 text-slate-100 text-lg'>
<div className='p-2 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg shadow-indigo-500/25'>
<div className='p-2 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg shadow-indigo-500/25 shrink-0'>
<FolderOpenIcon className='w-5 h-5 text-white' />
</div>
</DialogTitle>
<DialogDescription className='text-slate-400 ml-14'>
<DialogDescription className='text-slate-400 ml-0'>
</DialogDescription>
</DialogHeader>
{/* 新增项目 */}
{showAddProject && (
<div className='relative space-y-3 rounded-xl bg-white/5 p-4 border border-white/10 shadow-lg backdrop-blur-sm'>
<div className='flex items-center gap-2 mb-1'>
<div className='w-1 h-3 rounded-full bg-gradient-to-b from-indigo-400 to-purple-500' />
<p className='text-sm font-medium text-slate-200'></p>
</div>
<div className='space-y-2'>
{/* 类型选择 */}
<div className='flex gap-2 mb-2'>
<button
type='button'
onClick={() => {
setProjectType('filepath');
if (!newPath.trim()) {
setNewPath('/workspace/projects');
}
}}
className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${
projectType === 'filepath'
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
: 'bg-transparent border-white/10 text-slate-400 hover:border-white/20'
}`}>
</button>
<button
type='button'
onClick={() => {
setProjectType('cnb-repo');
setNewPath('');
}}
className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${
projectType === 'cnb-repo'
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
: 'bg-transparent border-white/10 text-slate-400 hover:border-white/20'
}`}>
CNB
</button>
{/* 内容区域 - 可滚动 */}
<div className='flex-1 overflow-y-auto space-y-4 pr-1 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent -mx-2 px-2'>
{/* 新增项目 */}
{showAddProject && (
<div className='relative space-y-3 rounded-xl bg-white/5 p-4 border border-white/10 shadow-lg backdrop-blur-sm'>
<div className='flex items-center gap-2 mb-1'>
<div className='w-1 h-3 rounded-full bg-gradient-to-b from-indigo-400 to-purple-500' />
<p className='text-sm font-medium text-slate-200'></p>
</div>
<div className='space-y-2'>
{/* 类型选择 */}
<div className='flex gap-2 mb-2'>
<button
type='button'
onClick={() => {
setProjectType('filepath');
if (!newPath.trim()) {
setNewPath('/workspace/projects');
}
}}
className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${projectType === 'filepath'
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
: 'bg-transparent border-white/10 text-slate-400 hover:border-white/20'
}`}>
</button>
<button
type='button'
onClick={() => {
setProjectType('cnb-repo');
setNewPath('');
}}
className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${projectType === 'cnb-repo'
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
: 'bg-transparent border-white/10 text-slate-400 hover:border-white/20'
}`}>
CNB
</button>
</div>
<div className='space-y-1.5'>
<Label className='text-xs text-slate-400 flex items-center gap-1'>
<span>{projectType === 'filepath' ? '项目路径' : 'CNB 仓库'}</span>
<span className='text-red-400'>*</span>
</Label>
<div className='relative'>
<FolderIcon className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500' />
<Input
value={newPath}
onChange={(e) => setNewPath(e.target.value)}
placeholder={projectType === 'filepath' ? '/workspace/projects' : 'kevisual/cnb'}
className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm pl-10 pr-4 focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all'
/>
</div>
</div>
<div className='space-y-1.5'>
<Label className='text-xs text-slate-400'></Label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder='My Project'
className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all'
/>
</div>
</div>
<Button
size='sm'
onClick={handleAdd}
disabled={addLoading || !newPath.trim()}
className='w-full bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white text-sm h-9 mt-2 shadow-lg shadow-indigo-500/25 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:shadow-indigo-500/40'>
<PlusIcon className='w-4 h-4 mr-1.5' />
{addLoading ? (
<span className='flex items-center gap-2'>
<span className='w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin' />
</span>
) : (
'添加项目'
)}
</Button>
</div>
<div className='space-y-1.5'>
<Label className='text-xs text-slate-400 flex items-center gap-1'>
<span>{projectType === 'filepath' ? '项目路径' : 'CNB 仓库'}</span>
<span className='text-red-400'>*</span>
</Label>
<div className='relative'>
<FolderIcon className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500' />
<Input
value={newPath}
onChange={(e) => setNewPath(e.target.value)}
placeholder={projectType === 'filepath' ? '/workspace/projects' : 'kevisual/cnb'}
className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm pl-10 pr-4 focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all'
/>
)}
{/* 项目列表 */}
<div className='relative'>
<div className='flex items-center justify-between mb-3'>
<div className='flex items-center gap-2'>
<div className='w-1 h-3 rounded-full bg-gradient-to-b from-green-400 to-emerald-500' />
<p className='text-sm font-medium text-slate-200'></p>
<span className='text-xs text-slate-500 bg-slate-800/80 px-1.5 py-0.5 rounded-full'>
{projects.length}
</span>
</div>
<div className='flex items-center gap-1'>
<button
onClick={() => setShowAddProject(!showAddProject)}
className={`text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 ${showAddProject ? 'text-indigo-400 bg-white/5' : ''}`}
title={showAddProject ? '隐藏添加' : '添加项目'}>
<PlusIcon className={`w-4 h-4 ${showAddProject ? 'rotate-90' : ''} transition-transform`} />
</button>
<button
onClick={loadProjects}
disabled={projectsLoading}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='刷新'>
<RefreshCwIcon className={`w-4 h-4 ${projectsLoading ? 'animate-spin' : ''}`} />
</button>
<Tooltip>
<TooltipTrigger>
<div
onClick={() => setInitConfirmOpen(true)}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'>
<DownloadIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p> workspace/projects </p>
</TooltipContent>
</Tooltip>
{multiSelectMode ? (
<>
<div className='w-px h-5 bg-white/20 mx-1' />
<Tooltip>
<TooltipTrigger>
<div
onClick={selectAll}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='全选'>
<CheckSquareIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div
onClick={deselectAll}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='取消全选'>
<ListTodoIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<button
onClick={handleStartAll}
disabled={selectedProjects.length === 0}
className='text-slate-500 hover:text-green-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'
title='全部启动'>
<PlayCircleIcon className='w-4 h-4' />
</button>
</TooltipTrigger>
<TooltipContent>
<p> ({selectedProjects.length})</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<button
onClick={handleStopAll}
disabled={selectedProjects.length === 0}
className='text-slate-500 hover:text-red-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'
title='全部关闭'>
<StopCircleIcon className='w-4 h-4' />
</button>
</TooltipTrigger>
<TooltipContent>
<p> ({selectedProjects.length})</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div
onClick={toggleMultiSelectMode}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='退出多选'>
<CheckSquareIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p>退</p>
</TooltipContent>
</Tooltip>
</>
) : (
<Tooltip>
<TooltipTrigger>
<div
onClick={toggleMultiSelectMode}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='多选'>
<CheckSquareIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div className='space-y-1.5'>
<Label className='text-xs text-slate-400'></Label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder='My Project'
className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all'
/>
</div>
</div>
<Button
size='sm'
onClick={handleAdd}
disabled={addLoading || !newPath.trim()}
className='w-full bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white text-sm h-9 mt-2 shadow-lg shadow-indigo-500/25 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:shadow-indigo-500/40'>
<PlusIcon className='w-4 h-4 mr-1.5' />
{addLoading ? (
<span className='flex items-center gap-2'>
<span className='w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin' />
</span>
{projectsLoading ? (
<div className='flex items-center justify-center h-24 text-slate-500 text-sm bg-white/5 rounded-xl border border-white/5'>
<span className='flex items-center gap-2'>
<span className='w-4 h-4 border-2 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin' />
</span>
</div>
) : projects.length === 0 ? (
<div className='flex flex-col items-center justify-center h-24 text-slate-500 text-sm bg-white/5 rounded-xl border border-white/5 gap-2'>
<FolderIcon className='w-8 h-8 text-slate-600' />
<span></span>
</div>
) : (
'添加项目'
)}
</Button>
</div>
)}
<ul className='space-y-2 max-h-72 overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent scrollbar'>
{projects.map((p) => (
<li
key={p.path}
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 && (
<button
onClick={() => toggleProjectSelection(p.path)}
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${selectedProjects.includes(p.path)
? 'bg-indigo-500 border-indigo-500 text-white'
: 'border-slate-500 hover:border-indigo-400'
}`}>
{selectedProjects.includes(p.path) && <CheckSquareIcon className='w-3 h-3' />}
</button>
)}
{/* 项目图标 */}
<div className={`shrink-0 p-2 rounded-lg ${p.status === 'active' ? 'bg-green-500/20' : p.status === 'unlive' ? 'bg-orange-500/20' : 'bg-slate-700/50'}`}>
<FolderIcon className={`w-4 h-4 ${p.status === 'active' ? 'text-green-400' : p.status === 'unlive' ? 'text-orange-400' : 'text-slate-400'}`} />
</div>
{/* 项目列表 */}
<div className='relative'>
<div className='flex items-center justify-between mb-3'>
<div className='flex items-center gap-2'>
<div className='w-1 h-3 rounded-full bg-gradient-to-b from-green-400 to-emerald-500' />
<p className='text-sm font-medium text-slate-200'></p>
<span className='text-xs text-slate-500 bg-slate-800/80 px-1.5 py-0.5 rounded-full'>
{projects.length}
</span>
</div>
<div className='flex items-center gap-1'>
<button
onClick={() => setShowAddProject(!showAddProject)}
className={`text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 ${showAddProject ? 'text-indigo-400 bg-white/5' : ''}`}
title={showAddProject ? '隐藏添加' : '添加项目'}>
<PlusIcon className={`w-4 h-4 ${showAddProject ? 'rotate-90' : ''} transition-transform`} />
</button>
<button
onClick={loadProjects}
disabled={projectsLoading}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='刷新'>
<RefreshCwIcon className={`w-4 h-4 ${projectsLoading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
<div className='flex-1 min-w-0'>
<p className='text-sm font-medium text-slate-200 truncate'>{p.name ?? p.path.split('/').pop()}</p>
<p className='text-xs text-slate-500 truncate'>{p.path}</p>
</div>
{projectsLoading ? (
<div className='flex items-center justify-center h-24 text-slate-500 text-sm bg-white/5 rounded-xl border border-white/5'>
<span className='flex items-center gap-2'>
<span className='w-4 h-4 border-2 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin' />
</span>
</div>
) : projects.length === 0 ? (
<div className='flex flex-col items-center justify-center h-24 text-slate-500 text-sm bg-white/5 rounded-xl border border-white/5 gap-2'>
<FolderIcon className='w-8 h-8 text-slate-600' />
<span></span>
</div>
) : (
<ul className='space-y-2 max-h-72 overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent scrollbar'>
{projects.map((p) => (
<li
key={p.path}
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'>
{/* 项目图标 */}
<div className={`shrink-0 p-2 rounded-lg ${p.status === 'active' ? 'bg-green-500/20' : p.status === 'unlive' ? 'bg-orange-500/20' : 'bg-slate-700/50'}`}>
<FolderIcon className={`w-4 h-4 ${p.status === 'active' ? 'text-green-400' : p.status === 'unlive' ? 'text-orange-400' : 'text-slate-400'}`} />
</div>
<div className='flex-1 min-w-0'>
<p className='text-sm font-medium text-slate-200 truncate'>{p.name ?? p.path.split('/').pop()}</p>
<p className='text-xs text-slate-500 truncate'>{p.path}</p>
</div>
{/* 状态切换按钮 */}
{p.status !== undefined && (
<button
onClick={() => openStatusConfirm(p)}
className={`shrink-0 flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full transition-all duration-200 ${p.status === 'active'
{/* 状态切换按钮 */}
{p.status !== undefined && (
<button
onClick={() => openStatusConfirm(p)}
className={`shrink-0 flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full transition-all duration-200 ${p.status === 'active'
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30 hover:scale-105'
: p.status === 'unlive'
? 'bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 hover:scale-105'
: 'bg-slate-700/50 text-slate-400 hover:bg-slate-600/50 hover:scale-105'
}`}>
{p.status === 'active' ? (
<>
<StopCircleIcon className='w-3 h-3' />
<span></span>
</>
) : p.status === 'unlive' ? (
<>
<CircleOffIcon className='w-3 h-3' />
<span></span>
</>
) : (
<>
<PlayCircleIcon className='w-3 h-3' />
<span></span>
</>
)}
</button>
)}
}`}>
{p.status === 'active' ? (
<>
<StopCircleIcon className='w-3 h-3' />
<span></span>
</>
) : p.status === 'unlive' ? (
<>
<CircleOffIcon className='w-3 h-3' />
<span></span>
</>
) : (
<>
<PlayCircleIcon className='w-3 h-3' />
<span></span>
</>
)}
</button>
)}
{/* 删除按钮 */}
<button
onClick={() => openDeleteConfirm(p)}
className='shrink-0 text-slate-600 hover:text-red-400 transition-all duration-200 p-1.5 rounded-lg hover:bg-red-500/10 opacity-0 group-hover:opacity-100'>
<Trash2Icon className='w-4 h-4' />
</button>
</li>
))}
</ul>
)}
{/* 删除按钮 */}
<button
onClick={() => openDeleteConfirm(p)}
className='shrink-0 text-slate-600 hover:text-red-400 transition-all duration-200 p-1.5 rounded-lg hover:bg-red-500/10 opacity-0 group-hover:opacity-100'>
<Trash2Icon className='w-4 h-4' />
</button>
</li>
))}
</ul>
)}
</div>
</div>
</DialogContent>
@@ -344,8 +693,8 @@ export function ProjectDialog() {
<Button
onClick={handleConfirmStatusChange}
className={`flex-1 ${pendingStatusProject?.status === 'active'
? 'bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/25'
: 'bg-green-600 hover:bg-green-500 shadow-lg shadow-green-500/25'
? 'bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/25'
: 'bg-green-600 hover:bg-green-500 shadow-lg shadow-green-500/25'
}`}>
{pendingStatusProject?.status === 'active' ? '停止监听' : '开始监听'}
</Button>
@@ -382,6 +731,9 @@ export function ProjectDialog() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 初始化弹窗 */}
<ProjectInitDialog open={initConfirmOpen} onOpenChange={setInitConfirmOpen} />
</Dialog>
);
}

View File

@@ -0,0 +1,252 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { GripVertical, X, Sparkles, MoreHorizontal, StopCircle, PlayCircle } from 'lucide-react';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useCodeGraphStore } from '../store';
import { FileProjectData } from '../modules/tree';
import { useBotHelperStore } from '../store/bot-helper';
interface ProjectPanelProps {
onProjectClick?: (projectPath: string, files: FileProjectData[]) => void;
onStopProject?: (projectPath: string) => void;
}
export function ProjectPanel({
onProjectClick,
onStopProject,
}: ProjectPanelProps) {
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 20, y: 100 });
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const dragOffset = useRef({ x: 0, y: 0 });
const panelRef = useRef<HTMLDivElement>(null);
const { projects, files } = useCodeGraphStore(
useShallow((s) => ({
projects: s.projects,
files: s.files,
})),
);
const botHelperStore = useBotHelperStore(useShallow((s) => ({
openModal: s.openModal,
setProjectInfo: s.setProjectInfo,
})));
const codeGraphStore = useCodeGraphStore(useShallow((s) => {
return {
setCodePodAttrs: s.setCodePodAttrs,
setCodePodOpen: s.setCodePodOpen,
setNodeInfo: s.setNodeInfo
};
}));
const activeProjects = projects.filter((p) => p.status === 'active');
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
dragOffset.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
setIsDragging(true);
},
[],
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
setPosition({
x: e.clientX - dragOffset.current.x,
y: e.clientY - dragOffset.current.y,
});
},
[isDragging],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, handleMouseMove, handleMouseUp]);
const handleProjectClick = useCallback(
(projectPath: string) => {
// 如果点击的是已选中的项目,则取消选中
if (selectedProject === projectPath) {
setSelectedProject(null);
window.location.hash = '';
useCodeGraphStore.getState().fetchProjects();
return;
}
// 从 projects 列表中查找对应项目的 repo
const project = projects.find((p) => p.path === projectPath);
const repo = project?.repo;
if (repo) {
// 设置 hash 并刷新
window.location.hash = `repo=${repo}`;
useCodeGraphStore.getState().fetchProjects();
}
setSelectedProject(projectPath);
if (project && onProjectClick) {
onProjectClick(projectPath, files);
}
},
[projects, files, onProjectClick, selectedProject],
);
const [isLargeScreen, setIsLargeScreen] = useState(false);
// 初始化选中状态
useEffect(() => {
const hashParams = new URLSearchParams(window.location.hash.slice(1));
const repo = hashParams.get('repo');
if (repo && projects.length > 0) {
const projectItem = projects.find((p) => p.repo === repo);
if (projectItem) {
setSelectedProject(projectItem.path);
}
}
}, [projects]);
useEffect(() => {
const checkScreen = () => {
setIsLargeScreen(window.innerWidth >= 1024);
};
checkScreen();
window.addEventListener('resize', checkScreen);
return () => window.removeEventListener('resize', checkScreen);
}, []);
if (activeProjects.length === 0 || !isLargeScreen) return null;
return (
<div
ref={panelRef}
className='fixed z-50 select-none'
style={{
left: position.x,
top: position.y,
maxHeight: 'calc(100vh - 100px)',
}}
>
<div
className={`
flex flex-col w-56 bg-slate-900/95 backdrop-blur-sm rounded-lg border border-white/10 shadow-xl
transition-opacity duration-200
${isDragging ? 'cursor-grabbing' : 'cursor-grab'}
`}
onMouseDown={handleMouseDown}
>
{/* 拖动手柄 */}
<div className='flex items-center justify-between px-3 py-2 border-b border-white/10 cursor-grab active:cursor-grabbing'>
<div className='flex items-center gap-2 text-xs font-medium text-slate-300'>
<GripVertical className='w-3 h-3' />
<span></span>
</div>
<span className='text-xs text-slate-500'>{activeProjects.length}</span>
</div>
{/* 项目列表 */}
<div className='flex flex-col py-1 max-h-80 overflow-y-auto scrollbar'>
{activeProjects.map((project) => {
const projectName = project.name || project.path.split('/').pop() || project.path;
const nodeInfoData = {
fullPath: project.path,
projectPath: project.path,
kind: 'dir',
}
return (
<div
key={project.path}
className={`flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors border-l-2 group ${
selectedProject === project.path
? 'bg-indigo-500/20 text-white border-indigo-500'
: 'text-slate-300 hover:bg-white/5 hover:text-white border-transparent hover:border-indigo-500'
}`}
>
<button
onClick={() => handleProjectClick(project.path)}
className='flex-1 flex items-center gap-2 text-left'
>
<span className='truncate' title={projectName}>
{projectName}
</span>
</button>
<div className='flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity'>
<button
onClick={(e) => {
botHelperStore.setProjectInfo({
filepath: nodeInfoData.fullPath,
projectPath: nodeInfoData.projectPath,
kind: 'dir',
});
botHelperStore.openModal();
e.stopPropagation();
}}
title='AI 助手'
className='p-1 rounded-md text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-colors'>
<Sparkles className='w-3.5 h-3.5' />
</button>
<DropdownMenu>
<DropdownMenuTrigger >
<div
onClick={(e) => e.stopPropagation()}
title='更多'
className='p-1 rounded-md text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-colors'>
<MoreHorizontal className='w-3.5 h-3.5' />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='bg-slate-900 border-white/10'>
<DropdownMenuItem
onClick={() => onStopProject?.(project.path)}
className='text-slate-300 focus:bg-white/5 focus:text-slate-100 cursor-pointer'>
<StopCircle className='w-4 h-4 mr-2' />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const projectPath = project.path;
codeGraphStore.setCodePodAttrs({
label: projectPath.split('/').pop() || projectPath,
size: 0,
color: '',
x: 0,
y: 0,
fullPath: projectPath,
projectPath,
kind: 'dir',
});
codeGraphStore.setCodePodOpen(true);
}}
className='text-slate-300 focus:bg-white/5 focus:text-slate-100 cursor-pointer'>
<PlayCircle className='w-4 h-4 mr-2' />
CodePod
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
export default ProjectPanel;

View File

@@ -0,0 +1,50 @@
import { useState, useCallback } from 'react';
const STORAGE_KEY = 'code-graph-3d-config';
export interface Graph3DConfig {
/** 是否默认显示文字标签SpriteText */
showLabels: boolean;
// 其他配置项暂留
}
const DEFAULT_CONFIG: Graph3DConfig = {
showLabels: true,
};
function loadConfig(): Graph3DConfig {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_CONFIG };
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
} catch {
return { ...DEFAULT_CONFIG };
}
}
function saveConfig(config: Graph3DConfig): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch {
// ignore
}
}
export function useGraph3DConfig() {
const [config, setConfigState] = useState<Graph3DConfig>(() => loadConfig());
const updateConfig = useCallback((patch: Partial<Graph3DConfig>) => {
setConfigState((prev) => {
const next = { ...prev, ...patch };
saveConfig(next);
return next;
});
}, []);
const resetConfig = useCallback(() => {
saveConfig(DEFAULT_CONFIG);
setConfigState({ ...DEFAULT_CONFIG });
}, []);
return { config, updateConfig, resetConfig };
}

View File

@@ -1,31 +1,37 @@
import { useState, useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { DatabaseIcon, RefreshCw } from 'lucide-react';
import { CodePod } from './components/CodePod';
import { useCodeGraphStore } from './store';
import CodeGraphView from './components/CodeGraph';
import { Code3DGraph } from './components/Code3DGraph';
import { NodeInfoContainer } from './components/NodeInfo';
import { ProjectDialog } from './components/ProjectDialog';
import { BotHelperModal } from './components/BotHelperModal';
import { ProjectPanel } from './components/ProjectPanel';
import { useBotHelperStore } from './store/bot-helper';
import { useLayoutStore } from '../auth/store';
import type { FileProjectData } from './modules/tree';
import { useCodeGraphStore } from './store';
import { Code3DGraph } from './components/Code3DGraph';
import CodeGraphView from './components/CodeGraph';
import CodePod from './components/CodePod';
import NodeInfoContainer from './components/NodeInfo';
import { ProjectDialog } from './components/ProjectDialog';
type ViewMode = '2d' | '3d';
export default function CodeGraphPage() {
const [viewMode, setViewMode] = useState<ViewMode>('3d');
const [projectFocus, setProjectFocus] = useState<string | null>(null);
const layoutStore = useLayoutStore(useShallow((s) => ({
me: s.me,
})));
const { codePodOpen, setCodePodOpen, codePodAttrs, setProjectDialogOpen, init, files, fetchProjects } = useCodeGraphStore(
const { codePodOpen, setCodePodOpen, codePodAttrs, setCodePodAttrs, setProjectDialogOpen, init, files, fetchProjects, setFiles, toggleProjectStatus } = useCodeGraphStore(
useShallow((s) => ({
files: s.files,
setFiles: s.setFiles,
codePodOpen: s.codePodOpen,
setCodePodOpen: s.setCodePodOpen,
codePodAttrs: s.codePodAttrs,
setCodePodAttrs: s.setCodePodAttrs,
setProjectDialogOpen: s.setProjectDialogOpen,
init: s.init,
fetchProjects: s.fetchProjects,
toggleProjectStatus: s.toggleProjectStatus,
})),
);
@@ -34,6 +40,16 @@ export default function CodeGraphPage() {
init(layoutStore.me);
}, [layoutStore.me]);
const handleProjectClick = (projectPath: string, projectFiles: FileProjectData[]) => {
if (viewMode === '3d') {
setFiles(projectFiles);
setTimeout(() => {
setProjectFocus(projectPath);
setTimeout(() => setProjectFocus(null), 100);
}, 150);
}
};
return (
<div className='flex flex-col h-full bg-slate-950 text-slate-100'>
{/* 顶部工具栏 */}
@@ -77,7 +93,13 @@ export default function CodeGraphPage() {
{/* 图视图 */}
<div className='flex-1 min-h-0'>
{viewMode === '3d' ? (
<Code3DGraph files={files} className='h-full' />
<>
<Code3DGraph files={files} className='h-full' onProjectFocus={projectFocus ?? undefined} />
<ProjectPanel
onProjectClick={handleProjectClick}
onStopProject={(projectPath) => toggleProjectStatus(projectPath)}
/>
</>
) : (
<CodeGraphView files={files} className='h-full' />
)}

View File

@@ -11,6 +11,12 @@ type BotHelperState = {
setOpen: (open: boolean) => void;
setInput: (input: string) => void;
setActiveKey: (key: BotKey) => void;
projectInfo: {
filepath: string;
projectPath: string;
kind: 'file' | 'dir' | 'root';
} | null;
setProjectInfo: (info: { filepath: string; projectPath: string; kind: 'file' | 'dir' | 'root' } | null) => void;
openModal: () => void;
closeModal: () => void;
};
@@ -21,7 +27,12 @@ export const useBotHelperStore = create<BotHelperState>()((set) => ({
activeKey: 'opencode',
setOpen: (open) => set({ open }),
setInput: (input) => set({ input }),
setActiveKey: (key) => set({ activeKey: key }),
openModal: () => set({ open: true }),
closeModal: () => set({ open: false, input: '' }),
projectInfo: null,
setProjectInfo: (info) => set({ projectInfo: info }),
openModal: () => set({
open: true,
}),
closeModal: () => set({ open: false, input: '', projectInfo: null }),
}));

View File

@@ -6,7 +6,8 @@ import { toast } from 'sonner';
import { FileProjectData } from '../modules/tree';
import { UserInfo } from '@/pages/auth/store';
import { Result } from '@kevisual/query';
import { AssistantMessage, Part } from '@opencode-ai/sdk'
import { isCNB } from '@/modules/cnb';
export type ProjectItem = {
path: string;
name?: string;
@@ -14,7 +15,7 @@ export type ProjectItem = {
status?: 'active' | 'inactive' | 'unlive';
};
const API_URL = '/root/v1/cnb-dev';
const API_URL = '/root/v1/dev-cnb';
export type NodeInfoData = {
label: string;
@@ -24,7 +25,16 @@ export type NodeInfoData = {
color: string;
fileId?: string;
nodeSize?: number;
title?: string;
tags?: string[];
summary?: string;
description?: string;
link?: string;
};
export type OpencodeResult = {
info: AssistantMessage;
parts: Array<Part>;
}
type State = {
codePodOpen: boolean;
@@ -43,23 +53,31 @@ type State = {
addProject: (filepath: string, name?: string, type?: 'filepath' | 'cnb-repo') => Promise<boolean>;
removeProject: (path: string) => Promise<void>;
toggleProjectStatus: (path: string) => Promise<void>;
initProject: (projectPaths?: string[]) => Promise<void>;
// 获取项目文件列表
fetchProjectFiles: (rootPath?: string) => Promise<string[]>;
// 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;
closeNodeInfo: () => void;
url?: string;
init(user: UserInfo): Promise<void>;
init(user: UserInfo, opts?: { load?: boolean }): Promise<void>;
fetchProjects: () => Promise<void>;
getFiles: (opts?: {
filepath?: string; // 可选的目录路径,默认为根目录
q?: string; // 可选的搜索关键词
projectPath?: string; // 项目路径,必填
getContent?: boolean; // 是否获取文件内容,默认为 false
repo?: string; // 仓库地址
projects?: string[]
}) => Promise<Result<{ list: FileProjectData[] }>>;
createQuestion: (opts: { question: string, projectPath: string, engine?: 'openclaw' | 'opencode' }) => any;
createQuestion: (opts: { question: string, projectPath?: string, filePath?: string, engine?: 'openclaw' | 'opencode', sessionId?: string }) => any;
saveFile: (filepath: string, content: string) => Promise<void>;
isMobile: boolean;
setIsMobile: (isMobile: boolean) => void;
};
export const useCodeGraphStore = create<State>()((set, get) => ({
@@ -77,20 +95,30 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
loadProjects: async () => {
set({ projectsLoading: true });
const url = get().url || API_URL;
const loadingToast = toast.loading('获取项目列表中...');
try {
const res = await projectApi.project.list(undefined, { url });
toast.dismiss(loadingToast);
if (res.code === 200) {
set({ projects: (res.data?.list as ProjectItem[]) ?? [] });
const projects = (res.data?.list as ProjectItem[]) ?? [];
projects.sort((a, b) => {
if (a.status === 'active' && b.status !== 'active') return -1;
if (a.status !== 'active' && b.status === 'active') return 1;
return a.path.localeCompare(b.path);
});
set({ projects });
} else {
toast.error('获取项目列表失败');
}
} catch {
toast.dismiss(loadingToast);
toast.error('获取项目列表失败');
} finally {
set({ projectsLoading: false });
}
},
addProject: async (filepath, name, type = 'filepath') => {
const loadingToast = toast.loading('添加项目中...');
try {
const url = get().url || API_URL;
let res: Result | null = null;
@@ -104,6 +132,7 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
repo: filepath,
}, { url });
}
toast.dismiss(loadingToast);
if (res.code === 200) {
toast.success('项目添加成功');
await get().loadProjects();
@@ -113,14 +142,43 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
return false;
}
} catch {
toast.dismiss(loadingToast);
toast.error('项目添加失败');
return false;
}
},
initProject: async (projectPaths) => {
const loadingToast = toast.loading('初始化项目中...');
try {
const url = get().url || API_URL;
const res = await projectApi.project.init({ projectPaths }, { url });
toast.dismiss(loadingToast);
if (res.code === 200) {
toast.success('项目初始化成功');
await get().loadProjects();
} else {
toast.error(res.message ?? '项目初始化失败');
}
} catch {
toast.dismiss(loadingToast);
toast.error('项目初始化失败');
}
},
fetchProjectFiles: async (rootPath) => {
const url = get().url || API_URL;
const res = await projectApi['project']['project-files']({ rootPath }, { url });
if (res.code === 200) {
const data = res.data as { projectFiles?: string[]; list?: string[] };
return data?.projectFiles ?? data?.list ?? [];
}
return [];
},
removeProject: async (path) => {
const loadingToast = toast.loading('移除项目中...');
try {
const url = get().url || API_URL;
const res = await projectApi.project.remove({ filepath: path }, { url });
toast.dismiss(loadingToast);
if (res.code === 200) {
toast.success('项目已移除');
set((s) => ({ projects: s.projects.filter((p) => p.path !== path) }));
@@ -128,18 +186,21 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
toast.error(res.message ?? '移除失败');
}
} catch {
toast.dismiss(loadingToast);
toast.error('移除失败');
}
},
toggleProjectStatus: async (path) => {
const project = get().projects.find((p) => p.path === path);
if (!project) return;
const loadingToast = toast.loading(project.status === 'active' ? '停止监听中...' : '启动监听中...');
try {
const url = get().url || API_URL;
const project = get().projects.find((p) => p.path === path);
if (!project) return;
if (project.status === 'active') {
// 暂停项目监听
const res = await projectApi.project.stop({ filepath: path }, { url });
toast.dismiss(loadingToast);
if (res.code === 200) {
toast.success('项目已停止监听');
set((s) => ({
@@ -149,8 +210,8 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
toast.error(res.message ?? '操作失败');
}
} else {
// 重新启动项目监听
const res = await projectApi.project.add({ filepath: path }, { url });
toast.dismiss(loadingToast);
if (res.code === 200) {
toast.success('项目已开始监听');
set((s) => ({
@@ -161,10 +222,12 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
}
}
} catch {
toast.dismiss(loadingToast);
toast.error('操作失败');
}
},
nodeInfoOpen: false,
setNodeInfoOpen: (open) => set({ nodeInfoOpen: open }),
nodeInfoData: null,
nodeInfoPos: { x: 0, y: 0 },
setNodeInfo: (data, pos) =>
@@ -175,17 +238,32 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
}),
closeNodeInfo: () => set({ nodeInfoOpen: false, nodeInfoData: null }),
url: API_URL,
init: async (user) => {
init: async (user, opts = {}) => {
// 可以在这里根据用户信息初始化一些数据,比如权限相关的设置等
console.log('CodeGraphStore initialized for user:', user.username);
const username = user.username;
const url = username ? `/${username}/v1/cnb-dev` : API_URL;
let url = username ? `/${username}/v1/dev-cnb` : API_URL;
if (isCNB()) {
url = `/client/router`;
}
const urlFromHash = new URLSearchParams(window.location.hash.slice(1)).get('url');
if (urlFromHash) {
url = urlFromHash;
}
set({ url });
await get().fetchProjects();
const load = opts.load ?? true;
if (load) {
await get().fetchProjects();
}
},
fetchProjects: async () => {
get().loadProjects();
const res = await get().getFiles();
// 从hash中获取repo参数 (#repo=kevisual/cnb 格式)
const hashParams = new URLSearchParams(window.location.hash.slice(1));
const repo = hashParams.get('repo');
const res = await get().getFiles({
repo: repo || undefined,
});
if (res.code === 200) {
set({ files: res.data!.list });
} else {
@@ -197,6 +275,8 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
q?: string; // 可选的搜索关键词
projectPath?: string; // 项目路径,必填
getContent?: boolean; // 是否获取文件内容,默认为 false
projects?: string[];
repo?: string; // 仓库地址
}) => {
const url = get().url
const res = await projectApi["project-search"].files({
@@ -208,31 +288,49 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
return res;
},
saveFile: async (filepath, content) => {
const url = get().url || API_URL;
const loadingToast = toast.loading('保存文件中...');
try {
const url = get().url || API_URL;
const b64 = btoa(new TextEncoder().encode(content).reduce((s, b) => s + String.fromCharCode(b), ''));
const res = await projectApi['project-file']['update-content']({ filepath, content: b64 }, { url });
toast.dismiss(loadingToast);
if (res.code === 200) {
toast.success('保存成功');
} else {
toast.error(res.message ?? '保存失败');
}
} catch {
toast.dismiss(loadingToast);
toast.error('保存失败');
}
},
isMobile: false,
setIsMobile: (isMobile) => set({ isMobile }),
createQuestion: async (opts) => {
const { question, projectPath, engine = 'opencode' } = opts;
const { question, projectPath, filePath, engine = 'opencode', sessionId } = opts;
const url = get().url
const q = `
${question}
项目路径: ${projectPath}`
let q = question;
if (projectPath) {
q += `
项目路径: ${projectPath}`;
}
if (filePath && filePath !== projectPath) {
q += `
文件路径: ${filePath}`;
}
const res = await opencodeApi["opencode-cnb"].question({
question: q,
directory: projectPath,
sessionId: sessionId || undefined,
}, {
url
url,
timeout: 60 * 1000 * 15, // 15分钟
});
return res;
},
}));
export const getApiUrl = () => {
const store = useCodeGraphStore.getState();
return store.url || API_URL;
}

View File

@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DemoRouteImport } from './routes/demo'
import { Route as CodeGraphRouteImport } from './routes/code-graph'
import { Route as ChatDevRouteImport } from './routes/chat-dev'
import { Route as IndexRouteImport } from './routes/index'
const LoginRoute = LoginRouteImport.update({
@@ -29,6 +30,11 @@ const CodeGraphRoute = CodeGraphRouteImport.update({
path: '/code-graph',
getParentRoute: () => rootRouteImport,
} as any)
const ChatDevRoute = ChatDevRouteImport.update({
id: '/chat-dev',
path: '/chat-dev',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -37,12 +43,14 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/chat-dev': typeof ChatDevRoute
'/code-graph': typeof CodeGraphRoute
'/demo': typeof DemoRoute
'/login': typeof LoginRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/chat-dev': typeof ChatDevRoute
'/code-graph': typeof CodeGraphRoute
'/demo': typeof DemoRoute
'/login': typeof LoginRoute
@@ -50,20 +58,22 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/chat-dev': typeof ChatDevRoute
'/code-graph': typeof CodeGraphRoute
'/demo': typeof DemoRoute
'/login': typeof LoginRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/code-graph' | '/demo' | '/login'
fullPaths: '/' | '/chat-dev' | '/code-graph' | '/demo' | '/login'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/code-graph' | '/demo' | '/login'
id: '__root__' | '/' | '/code-graph' | '/demo' | '/login'
to: '/' | '/chat-dev' | '/code-graph' | '/demo' | '/login'
id: '__root__' | '/' | '/chat-dev' | '/code-graph' | '/demo' | '/login'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ChatDevRoute: typeof ChatDevRoute
CodeGraphRoute: typeof CodeGraphRoute
DemoRoute: typeof DemoRoute
LoginRoute: typeof LoginRoute
@@ -92,6 +102,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CodeGraphRouteImport
parentRoute: typeof rootRouteImport
}
'/chat-dev': {
id: '/chat-dev'
path: '/chat-dev'
fullPath: '/chat-dev'
preLoaderRoute: typeof ChatDevRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -104,6 +121,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ChatDevRoute: ChatDevRoute,
CodeGraphRoute: CodeGraphRoute,
DemoRoute: DemoRoute,
LoginRoute: LoginRoute,

9
src/routes/chat-dev.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
import {App} from '../pages/chat-dev/page'
export const Route = createFileRoute('/chat-dev')({
validateSearch: (search: Record<string, unknown>) => ({
timestamp: search.timestamp as string | undefined,
}),
component: App,
})

View File

@@ -5,19 +5,19 @@ import pkgs from './package.json';
import tailwindcss from '@tailwindcss/vite';
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import dotenv from 'dotenv';
const config = dotenv.config().parsed || {};
console.log('Loaded .env config:', config);
const isDev = process.env.NODE_ENV === 'development';
import { VitePWA } from 'vite-plugin-pwa';
const env = dotenv.config().parsed || {};
const isDev = env.NODE_ENV === 'development' || process.env.NODE_ENV === 'development';
const basename = isDev ? '/' : pkgs?.basename || '/';
let target = config.VITE_API_URL || 'http://localhost:51515';
let target = env.VITE_API_URL || process.env.API_URL || 'http://localhost:51515';
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
let proxy = {
'/root/': apiProxy,
'/api': apiProxy,
'/client': apiProxy,
};
console.log('API Proxy Target:', target);
/**
* @see https://vitejs.dev/config/
*/
@@ -29,7 +29,14 @@ export default defineConfig({
autoCodeSplitting: true,
}),
react(),
tailwindcss()
tailwindcss(),
VitePWA({
injectRegister: 'auto',
registerType: 'autoUpdate',
workbox: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
}
}),
],
resolve: {
alias: {
@@ -41,7 +48,7 @@ export default defineConfig({
BASE_NAME: JSON.stringify(basename),
},
server: {
port: 7009,
port: 7008,
host: '0.0.0.0',
allowedHosts: true,
proxy,