feat: 增强项目 API,添加项目路径和文件过滤功能;更新 BotHelperModal 和 NodeInfo 组件,优化状态管理和交互体验;改进 ProjectDialog,支持多选和项目初始化功能
This commit is contained in:
@@ -276,7 +276,6 @@ const api = {
|
||||
*
|
||||
* @param data - Request parameters
|
||||
* @param data.q - {string} 搜索关键词,选填;留空或不传则返回全部文件
|
||||
* @param data.projectPath - {string} 按项目根目录路径过滤,仅返回该项目下的文件,选填
|
||||
* @param data.filepath - {string} 按文件绝对路径过滤,选填
|
||||
* @param data.repo - {string} 按代码仓库标识过滤(如 owner/repo),选填
|
||||
* @param data.title - {string} 按人工标注的标题字段过滤,选填
|
||||
@@ -287,6 +286,7 @@ const api = {
|
||||
* @param data.sort - {array} 排序规则数组,格式为 ["字段:asc"] 或 ["字段:desc"],选填,当 q 为空时默认为 ["projectPath:asc"]
|
||||
* @param data.limit - {number} 返回结果数量上限,选填,当 q 为空时默认为 1000
|
||||
* @param data.getContent - {boolean} 是否返回文件内容,默认为 false;如果为 true,则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景
|
||||
* @param data.projects - {array} 按项目名称列表过滤,选填,默认不穿,只过滤当前工作区的项目
|
||||
*/
|
||||
"files": {
|
||||
"path": "project-search",
|
||||
@@ -300,12 +300,120 @@ const api = {
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"projectPath": {
|
||||
"filepath": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "按项目根目录路径过滤,仅返回该项目下的文件,选填",
|
||||
"description": "按文件绝对路径过滤,选填",
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"repo": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "按代码仓库标识过滤(如 owner/repo),选填",
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"title": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "按人工标注的标题字段过滤,选填",
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"tags": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "按人工标注的标签列表过滤,选填",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
"summary": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "按人工标注的摘要字段过滤,选填",
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"description": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "按人工标注的描述字段过滤,选填",
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"link": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "按人工标注的外部链接字段过滤,选填",
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"sort": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "排序规则数组,格式为 [\"字段:asc\"] 或 [\"字段:desc\"],选填,当 q 为空时默认为 [\"projectPath:asc\"]",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
"limit": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "返回结果数量上限,选填,当 q 为空时默认为 1000",
|
||||
"type": "number",
|
||||
"optional": true
|
||||
},
|
||||
"getContent": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "是否返回文件内容,默认为 false;如果为 true,则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景",
|
||||
"type": "boolean",
|
||||
"optional": true
|
||||
},
|
||||
"projects": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "按项目名称列表过滤,选填,默认不穿,只过滤当前工作区的项目",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"url": "/root/v1/cnb-dev",
|
||||
"source": "query-proxy-api"
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 在已索引的项目文件中执行全文搜索,支持按仓库、目录、标签等字段过滤,以及自定义排序和数量限制
|
||||
*
|
||||
* @param data - Request parameters
|
||||
* @param data.q - {string} 搜索关键词,选填;留空或不传则返回全部文件
|
||||
* @param data.projectPath - {string} 按项目根目录路径过滤,仅返回该项目下的文件,必填
|
||||
* @param data.filepath - {string} 按文件绝对路径过滤,选填
|
||||
* @param data.repo - {string} 按代码仓库标识过滤(如 owner/repo),选填
|
||||
* @param data.title - {string} 按人工标注的标题字段过滤,选填
|
||||
* @param data.tags - {array} 按人工标注的标签列表过滤,选填
|
||||
* @param data.summary - {string} 按人工标注的摘要字段过滤,选填
|
||||
* @param data.description - {string} 按人工标注的描述字段过滤,选填
|
||||
* @param data.link - {string} 按人工标注的外部链接字段过滤,选填
|
||||
* @param data.sort - {array} 排序规则数组,格式为 ["字段:asc"] 或 ["字段:desc"],选填,当 q 为空时默认为 ["projectPath:asc"]
|
||||
* @param data.limit - {number} 返回结果数量上限,选填,当 q 为空时默认为 1000
|
||||
* @param data.getContent - {boolean} 是否返回文件内容,默认为 false;如果为 true,则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景
|
||||
*/
|
||||
"search": {
|
||||
"path": "project-search",
|
||||
"key": "search",
|
||||
"description": "在已索引的项目文件中执行全文搜索,支持按仓库、目录、标签等字段过滤,以及自定义排序和数量限制",
|
||||
"metadata": {
|
||||
"args": {
|
||||
"q": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "搜索关键词,选填;留空或不传则返回全部文件",
|
||||
"type": "string",
|
||||
"optional": true
|
||||
},
|
||||
"projectPath": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "string",
|
||||
"description": "按项目根目录路径过滤,仅返回该项目下的文件,必填"
|
||||
},
|
||||
"filepath": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "按文件绝对路径过滤,选填",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BotIcon, XIcon, FileIcon, FolderIcon, DatabaseIcon } from 'lucide-react';
|
||||
import { BotIcon, XIcon, FileIcon, FolderIcon, DatabaseIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||
import { getDynamicBasename, wrapBasename } from '@/modules/basename';
|
||||
import { useBotHelperStore, BOT_KEYS, BotKey } from '../store/bot-helper';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useCodeGraphStore, NodeInfoData } from '../store';
|
||||
@@ -18,7 +19,7 @@ function NodeIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }
|
||||
}
|
||||
|
||||
export function BotHelperModal() {
|
||||
const { open, input, setInput, closeModal, activeKey, setActiveKey } = useBotHelperStore(
|
||||
const botHelperStore = useBotHelperStore(
|
||||
useShallow((s) => ({
|
||||
open: s.open,
|
||||
input: s.input,
|
||||
@@ -40,16 +41,16 @@ export function BotHelperModal() {
|
||||
const handleConfirm = async () => {
|
||||
if (nodeInfoData) {
|
||||
const res = await createQuestion({
|
||||
question: input,
|
||||
question: botHelperStore.input,
|
||||
projectPath: nodeInfoData.projectPath,
|
||||
engine: activeKey,
|
||||
engine: botHelperStore.activeKey,
|
||||
});
|
||||
console.log(res);
|
||||
}
|
||||
closeModal();
|
||||
botHelperStore.closeModal();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
if (!botHelperStore.open) return null;
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-[200] flex items-center justify-center'>
|
||||
@@ -62,11 +63,22 @@ 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();
|
||||
window.open(`/code-graph?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'>
|
||||
@@ -88,8 +100,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 +114,8 @@ 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)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -180,17 +180,18 @@ function buildGraph3DData(files: FileProjectData[]): Graph3DData {
|
||||
interface Code3DGraphProps {
|
||||
files: FileProjectData[];
|
||||
className?: string;
|
||||
type?: "map" | 'minimap';
|
||||
}
|
||||
|
||||
// ─── 主组件 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Code3DGraph({ files, className }: Code3DGraphProps) {
|
||||
export function Code3DGraph({ files, className, type }: Code3DGraphProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<ForceGraph3DInstance | null>(null);
|
||||
const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
|
||||
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
|
||||
|
||||
const { setNodeInfo, selectedNodeId } = useCodeGraphStore(
|
||||
const codeGraphStore = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
setNodeInfo: s.setNodeInfo,
|
||||
selectedNodeId: s.nodeInfoData?.fullPath ?? null,
|
||||
@@ -200,8 +201,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) => {
|
||||
@@ -301,7 +302,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 +346,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [files, focusNode, setNodeInfo, nodeToScreenPos]);
|
||||
}, [files, focusNode, codeGraphStore.setNodeInfo, nodeToScreenPos]);
|
||||
|
||||
// 卸载时销毁
|
||||
useEffect(() => {
|
||||
@@ -361,7 +362,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
|
||||
if (graph) {
|
||||
graph.refresh();
|
||||
}
|
||||
}, [selectedNodeId]);
|
||||
}, [codeGraphStore.selectedNodeId]);
|
||||
|
||||
return (
|
||||
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
|
||||
@@ -378,7 +379,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
|
||||
if (!n) return;
|
||||
setTimeout(() => {
|
||||
const pos = nodeToScreenPos(n);
|
||||
setNodeInfo(
|
||||
codeGraphStore.setNodeInfo(
|
||||
{
|
||||
label: n.label,
|
||||
fullPath: n.fullPath,
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FileIcon, FolderIcon, DatabaseIcon, XIcon, MoveIcon, SquarePenIcon, Bot
|
||||
import { useCodeGraphStore, NodeInfoData } from '../store';
|
||||
import { useBotHelperStore } from '../store/bot-helper';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function KindIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }) {
|
||||
const cls = 'size-4 shrink-0';
|
||||
@@ -21,7 +22,6 @@ export function NodeInfo() {
|
||||
const codeGraphStore = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
nodeInfoData: s.nodeInfoData,
|
||||
|
||||
})),
|
||||
);
|
||||
const projectPath = codeGraphStore.nodeInfoData?.projectPath || '';
|
||||
@@ -51,14 +51,18 @@ export function NodeInfo() {
|
||||
</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,
|
||||
setNodeInfoOpen: s.setNodeInfo,
|
||||
closeNodeInfo: s.closeNodeInfo,
|
||||
setCodePodOpen: s.setCodePodOpen,
|
||||
setCodePodAttrs: s.setCodePodAttrs,
|
||||
isMobile: s.isMobile,
|
||||
setIsMobile: s.setIsMobile,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -75,18 +79,21 @@ export const NodeInfoContainer = () => {
|
||||
kind: nodeInfoData.kind,
|
||||
fileId: nodeInfoData.fileId,
|
||||
});
|
||||
|
||||
setCodePodOpen(true);
|
||||
// 移到左上角,避免遮挡编辑器
|
||||
setPinLeft(true);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const openBotModal = useBotHelperStore((s) => s.openModal);
|
||||
const botHelperStore = useBotHelperStore(useShallow((s) => ({
|
||||
openModal: s.openModal,
|
||||
setProjectInfo: s.setProjectInfo,
|
||||
})));
|
||||
|
||||
// 拖拽偏移
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [pinLeft, setPinLeft] = useState(false); // 编辑后固定到右下角
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// 检测屏幕大小
|
||||
useEffect(() => {
|
||||
@@ -143,11 +150,11 @@ export const NodeInfoContainer = () => {
|
||||
: { left: nodeInfoPos.x + offset.x + 40, top: nodeInfoPos.y + offset.y - 40 };
|
||||
|
||||
const name = nodeInfoData.fullPath.split('/').pop() || nodeInfoData.label;
|
||||
const projectPath = nodeInfoData.projectPath || '';
|
||||
const relativePath = nodeInfoData.fullPath.replace(projectPath + '/', '') || '/';
|
||||
return (
|
||||
<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 +171,13 @@ export const NodeInfoContainer = () => {
|
||||
<SquarePenIcon className='size-3.5' />
|
||||
</button>
|
||||
<button
|
||||
onClick={openBotModal}
|
||||
onClick={() => {
|
||||
botHelperStore.setProjectInfo({
|
||||
filepath: nodeInfoData.fullPath,
|
||||
projectPath: nodeInfoData.projectPath,
|
||||
});
|
||||
botHelperStore.openModal();
|
||||
}}
|
||||
title='AI 助手'
|
||||
className='ml-1 text-slate-500 hover:text-emerald-400 transition-colors'>
|
||||
<BotIcon className='size-3.5' />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } 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 { useCodeGraphStore } from '../store';
|
||||
|
||||
export function ProjectDialog() {
|
||||
@@ -18,6 +19,7 @@ export function ProjectDialog() {
|
||||
addProject,
|
||||
removeProject,
|
||||
toggleProjectStatus,
|
||||
initProject,
|
||||
} = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
projectDialogOpen: s.projectDialogOpen,
|
||||
@@ -28,6 +30,7 @@ export function ProjectDialog() {
|
||||
addProject: s.addProject,
|
||||
removeProject: s.removeProject,
|
||||
toggleProjectStatus: s.toggleProjectStatus,
|
||||
initProject: s.initProject,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -50,6 +53,64 @@ export function ProjectDialog() {
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [pendingDeleteProject, setPendingDeleteProject] = useState<{ path: string; name?: string } | null>(null);
|
||||
|
||||
// 初始化确认弹窗
|
||||
const [initConfirmOpen, setInitConfirmOpen] = useState(false);
|
||||
const [initLoading, setInitLoading] = 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);
|
||||
@@ -114,27 +175,42 @@ export function ProjectDialog() {
|
||||
setPendingDeleteProject(null);
|
||||
};
|
||||
|
||||
// 确认初始化
|
||||
const handleConfirmInit = async () => {
|
||||
setInitLoading(true);
|
||||
await initProject();
|
||||
setInitLoading(false);
|
||||
setInitConfirmOpen(false);
|
||||
};
|
||||
|
||||
// 取消初始化
|
||||
const handleCancelInit = () => {
|
||||
setInitConfirmOpen(false);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* 内容区域 - 可滚动 */}
|
||||
<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'>
|
||||
@@ -241,6 +317,104 @@ export function ProjectDialog() {
|
||||
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 ${initLoading ? 'animate-spin' : ''}`} />
|
||||
</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>
|
||||
|
||||
@@ -261,7 +435,19 @@ export function ProjectDialog() {
|
||||
{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'>
|
||||
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'}`} />
|
||||
@@ -312,6 +498,7 @@ export function ProjectDialog() {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* 状态切换确认弹窗 */}
|
||||
@@ -382,6 +569,45 @@ export function ProjectDialog() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 初始化确认弹窗 */}
|
||||
<Dialog open={initConfirmOpen} onOpenChange={(open) => !open && handleCancelInit()}>
|
||||
<DialogContent className='sm:max-w-md bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl'>
|
||||
<DialogHeader>
|
||||
<div className='w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3 bg-indigo-500/20'>
|
||||
<DownloadIcon className='w-6 h-6 text-indigo-400' />
|
||||
</div>
|
||||
<DialogTitle className='text-center text-lg font-semibold'>
|
||||
初始化项目
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-center text-slate-400'>
|
||||
确定要初始化 workspace/projects 仓库吗?初始化过程可能需要一些时间。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className='gap-2 sm:justify-center'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleCancelInit}
|
||||
disabled={initLoading}
|
||||
className='bg-transparent border-white/20 text-slate-300 hover:bg-white/10 hover:border-white/30 flex-1'>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmInit}
|
||||
disabled={initLoading}
|
||||
className='bg-indigo-600 hover:bg-indigo-500 text-white flex-1 shadow-lg shadow-indigo-500/25 disabled:opacity-50'>
|
||||
{initLoading ? (
|
||||
<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>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ type BotHelperState = {
|
||||
setOpen: (open: boolean) => void;
|
||||
setInput: (input: string) => void;
|
||||
setActiveKey: (key: BotKey) => void;
|
||||
projectInfo: {
|
||||
filepath: string;
|
||||
projectPath: string;
|
||||
} | null;
|
||||
setProjectInfo: (info: { filepath: string; projectPath: string } | null) => void;
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
@@ -21,7 +26,10 @@ export const useBotHelperStore = create<BotHelperState>()((set) => ({
|
||||
activeKey: 'opencode',
|
||||
setOpen: (open) => set({ open }),
|
||||
setInput: (input) => set({ input }),
|
||||
|
||||
setActiveKey: (key) => set({ activeKey: key }),
|
||||
projectInfo: null,
|
||||
setProjectInfo: (info) => set({ projectInfo: info }),
|
||||
openModal: () => set({ open: true }),
|
||||
closeModal: () => set({ open: false, input: '' }),
|
||||
closeModal: () => set({ open: false, input: '', projectInfo: null }),
|
||||
}));
|
||||
|
||||
@@ -43,8 +43,10 @@ type State = {
|
||||
addProject: (filepath: string, name?: string, type?: 'filepath' | 'cnb-repo') => Promise<boolean>;
|
||||
removeProject: (path: string) => Promise<void>;
|
||||
toggleProjectStatus: (path: string) => Promise<void>;
|
||||
initProject: () => Promise<void>;
|
||||
// NodeInfo 弹窗
|
||||
nodeInfoOpen: boolean;
|
||||
setNodeInfoOpen: (open: boolean) => void;
|
||||
nodeInfoData: NodeInfoData | null;
|
||||
nodeInfoPos: { x: number; y: number };
|
||||
setNodeInfo: (data: NodeInfoData | null, pos?: { x: number; y: number }) => void;
|
||||
@@ -60,6 +62,8 @@ type State = {
|
||||
}) => Promise<Result<{ list: FileProjectData[] }>>;
|
||||
createQuestion: (opts: { question: string, projectPath: string, engine?: 'openclaw' | 'opencode' }) => any;
|
||||
saveFile: (filepath: string, content: string) => Promise<void>;
|
||||
isMobile: boolean;
|
||||
setIsMobile: (isMobile: boolean) => void;
|
||||
};
|
||||
|
||||
export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
@@ -117,6 +121,20 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
initProject: async () => {
|
||||
try {
|
||||
const url = get().url || API_URL;
|
||||
const res = await projectApi.project.init(undefined, { url });
|
||||
if (res.code === 200) {
|
||||
toast.success('项目初始化成功');
|
||||
await get().loadProjects();
|
||||
} else {
|
||||
toast.error(res.message ?? '项目初始化失败');
|
||||
}
|
||||
} catch {
|
||||
toast.error('项目初始化失败');
|
||||
}
|
||||
},
|
||||
removeProject: async (path) => {
|
||||
try {
|
||||
const url = get().url || API_URL;
|
||||
@@ -165,6 +183,7 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
nodeInfoOpen: false,
|
||||
setNodeInfoOpen: (open) => set({ nodeInfoOpen: open }),
|
||||
nodeInfoData: null,
|
||||
nodeInfoPos: { x: 0, y: 0 },
|
||||
setNodeInfo: (data, pos) =>
|
||||
@@ -221,6 +240,8 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
toast.error('保存失败');
|
||||
}
|
||||
},
|
||||
isMobile: false,
|
||||
setIsMobile: (isMobile) => set({ isMobile }),
|
||||
createQuestion: async (opts) => {
|
||||
const { question, projectPath, engine = 'opencode' } = opts;
|
||||
const url = get().url
|
||||
@@ -231,7 +252,8 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
question: q,
|
||||
directory: projectPath,
|
||||
}, {
|
||||
url
|
||||
url,
|
||||
timeout: 60 * 1000 * 15, // 15分钟
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user