CodeReview/frontend/src/components/agent/CreateAgentTaskDialog.tsx

594 lines
22 KiB
TypeScript
Raw Normal View History

/**
* Agent
* Agent Audit UI
*/
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Search,
ChevronRight,
GitBranch,
Package,
Globe,
Loader2,
Bot,
Settings2,
Play,
Upload,
FolderOpen,
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/shared/config/database";
import { createAgentTask } from "@/shared/api/agentTasks";
import { isRepositoryProject, isZipProject } from "@/shared/utils/projectUtils";
import { getZipFileInfo, type ZipFileMeta } from "@/shared/utils/zipStorage";
import { validateZipFile } from "@/features/projects/services/repoZipScan";
import type { Project } from "@/shared/types";
import FileSelectionDialog from "@/components/audit/FileSelectionDialog";
interface CreateAgentTaskDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DEFAULT_EXCLUDES = [
"node_modules/**",
".git/**",
"dist/**",
"build/**",
"*.log",
];
export default function CreateAgentTaskDialog({
open,
onOpenChange,
}: CreateAgentTaskDialogProps) {
const navigate = useNavigate();
// 状态
const [projects, setProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(true);
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
const [searchTerm, setSearchTerm] = useState("");
const [branch, setBranch] = useState("main");
const [branches, setBranches] = useState<string[]>([]);
const [loadingBranches, setLoadingBranches] = useState(false);
const [excludePatterns, setExcludePatterns] = useState(DEFAULT_EXCLUDES);
const [showAdvanced, setShowAdvanced] = useState(false);
const [creating, setCreating] = useState(false);
// ZIP 文件状态
const [zipFile, setZipFile] = useState<File | null>(null);
const [storedZipInfo, setStoredZipInfo] = useState<ZipFileMeta | null>(null);
const [useStoredZip, setUseStoredZip] = useState(true);
// 文件选择状态
const [selectedFiles, setSelectedFiles] = useState<string[] | undefined>();
const [showFileSelection, setShowFileSelection] = useState(false);
const selectedProject = projects.find((p) => p.id === selectedProjectId);
// 加载项目列表
useEffect(() => {
if (open) {
setLoadingProjects(true);
api.getProjects()
.then((data) => {
setProjects(data.filter((p: Project) => p.is_active));
})
.catch(() => {
toast.error("加载项目列表失败");
})
.finally(() => setLoadingProjects(false));
// 重置状态
setSelectedProjectId("");
setSearchTerm("");
setBranch("main");
setExcludePatterns(DEFAULT_EXCLUDES);
setShowAdvanced(false);
setZipFile(null);
setStoredZipInfo(null);
setSelectedFiles(undefined);
}
}, [open]);
// 加载分支列表
useEffect(() => {
const loadBranches = async () => {
// 使用 selectedProjectId 从 projects 中获取最新的 project 对象
const project = projects.find((p) => p.id === selectedProjectId);
if (!project || !isRepositoryProject(project)) {
setBranches([]);
return;
}
setLoadingBranches(true);
try {
const result = await api.getProjectBranches(project.id);
console.log("[Branch] 加载分支结果:", result);
if (result.error) {
console.warn("[Branch] 加载分支警告:", result.error);
toast.error(`加载分支失败: ${result.error}`);
}
setBranches(result.branches);
if (result.default_branch) {
setBranch(result.default_branch);
}
} catch (err) {
const msg = err instanceof Error ? err.message : "未知错误";
console.error("[Branch] 加载分支失败:", msg);
toast.error(`加载分支失败: ${msg}`);
setBranches([project.default_branch || "main"]);
} finally {
setLoadingBranches(false);
}
};
loadBranches();
}, [selectedProjectId, projects]);
// 加载 ZIP 文件信息
useEffect(() => {
const loadZipInfo = async () => {
if (!selectedProject || !isZipProject(selectedProject)) {
setStoredZipInfo(null);
return;
}
try {
const info = await getZipFileInfo(selectedProject.id);
setStoredZipInfo(info);
setUseStoredZip(info.has_file);
} catch {
setStoredZipInfo(null);
}
};
loadZipInfo();
}, [selectedProject?.id]);
// 过滤项目
const filteredProjects = useMemo(() => {
if (!searchTerm) return projects;
const term = searchTerm.toLowerCase();
return projects.filter(
(p) =>
p.name.toLowerCase().includes(term) ||
p.description?.toLowerCase().includes(term)
);
}, [projects, searchTerm]);
// 是否可以开始
const canStart = useMemo(() => {
if (!selectedProject) return false;
if (isZipProject(selectedProject)) {
return (useStoredZip && storedZipInfo?.has_file) || !!zipFile;
}
return !!selectedProject.repository_url && !!branch.trim();
}, [selectedProject, useStoredZip, storedZipInfo, zipFile, branch]);
// 创建任务
const handleCreate = async () => {
if (!selectedProject) return;
setCreating(true);
try {
const agentTask = await createAgentTask({
project_id: selectedProject.id,
name: `Agent审计-${selectedProject.name}`,
branch_name: isRepositoryProject(selectedProject) ? branch : undefined,
exclude_patterns: excludePatterns,
target_files: selectedFiles,
verification_level: "sandbox",
});
onOpenChange(false);
toast.success("Agent 审计任务已创建");
navigate(`/agent-audit/${agentTask.id}`);
} catch (err) {
const msg = err instanceof Error ? err.message : "创建失败";
toast.error(msg);
} finally {
setCreating(false);
}
};
// 处理文件上传
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const validation = validateZipFile(file);
if (!validation.valid) {
toast.error(validation.error || "文件无效");
e.target.value = "";
return;
}
setZipFile(file);
setUseStoredZip(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!w-[min(90vw,480px)] !max-w-none max-h-[85vh] flex flex-col p-0 gap-0 bg-[#0d0d12] border border-gray-800 rounded-lg">
{/* Header */}
<DialogHeader className="px-5 py-4 border-b border-gray-800 flex-shrink-0">
<DialogTitle className="flex items-center gap-3 font-mono text-white">
<div className="p-2 bg-primary/20 rounded">
<Bot className="w-5 h-5 text-primary" />
</div>
<div>
<span className="text-base font-bold">New Agent Audit</span>
<p className="text-xs text-gray-500 font-normal mt-0.5">
AI-Powered Security Analysis
</p>
</div>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{/* 项目选择 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-mono font-bold uppercase text-gray-400">
Select Project
</span>
<Badge variant="outline" className="border-gray-700 text-gray-500 font-mono text-[10px]">
{filteredProjects.length} available
</Badge>
</div>
{/* 搜索框 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-600" />
<Input
placeholder="Search projects..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-10 bg-gray-900/50 border-gray-800 text-white font-mono placeholder:text-gray-600 focus:border-primary focus:ring-0"
/>
</div>
{/* 项目列表 */}
<ScrollArea className="h-[200px] border border-gray-800 rounded-lg bg-gray-900/30">
{loadingProjects ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
</div>
) : filteredProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-600 font-mono">
<Package className="w-8 h-8 mb-2 opacity-50" />
<span className="text-sm">{searchTerm ? "No matches" : "No projects"}</span>
</div>
) : (
<div className="p-1">
{filteredProjects.map((project) => (
<ProjectItem
key={project.id}
project={project}
selected={selectedProjectId === project.id}
onSelect={() => setSelectedProjectId(project.id)}
/>
))}
</div>
)}
</ScrollArea>
</div>
{/* 配置区域 */}
{selectedProject && (
<div className="space-y-4">
{/* 仓库项目:分支选择 */}
{isRepositoryProject(selectedProject) && (
<div className="flex items-center gap-3 p-3 border border-gray-800 rounded-lg bg-blue-950/20">
<GitBranch className="w-5 h-5 text-blue-400" />
<span className="font-mono text-sm text-gray-400 w-16">Branch</span>
{loadingBranches ? (
<div className="flex items-center gap-2 flex-1">
<Loader2 className="w-4 h-4 animate-spin text-blue-400" />
<span className="text-sm text-blue-400 font-mono">Loading...</span>
</div>
) : (
<Select value={branch} onValueChange={setBranch}>
<SelectTrigger className="flex-1 h-9 bg-gray-900/50 border-gray-700 text-white font-mono focus:ring-0">
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent className="bg-gray-900 border-gray-700">
{branches.map((b) => (
<SelectItem key={b} value={b} className="font-mono text-white">
{b}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{/* ZIP 项目:文件选择 */}
{isZipProject(selectedProject) && (
<div className="p-3 border border-gray-800 rounded-lg bg-amber-950/20 space-y-3">
<div className="flex items-center gap-3">
<Package className="w-5 h-5 text-amber-400" />
<span className="font-mono text-sm text-gray-400">ZIP File</span>
</div>
{storedZipInfo?.has_file && (
<div
className={`p-2 rounded border cursor-pointer transition-colors ${
useStoredZip
? 'border-green-500 bg-green-950/30'
: 'border-gray-700 hover:border-gray-600'
}`}
onClick={() => setUseStoredZip(true)}
>
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full border-2 ${
useStoredZip ? 'border-green-500 bg-green-500' : 'border-gray-600'
}`} />
<span className="text-sm text-white font-mono">
{storedZipInfo.original_filename}
</span>
<Badge className="bg-green-500/20 text-green-400 border-0 text-[10px]">
Stored
</Badge>
</div>
</div>
)}
<div
className={`p-2 rounded border cursor-pointer transition-colors ${
!useStoredZip && zipFile
? 'border-amber-500 bg-amber-950/30'
: 'border-gray-700 hover:border-gray-600'
}`}
>
<label className="flex items-center gap-2 cursor-pointer">
<div className={`w-3 h-3 rounded-full border-2 ${
!useStoredZip && zipFile ? 'border-amber-500 bg-amber-500' : 'border-gray-600'
}`} />
<Upload className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-400 font-mono">
{zipFile ? zipFile.name : "Upload new file..."}
</span>
<input
type="file"
accept=".zip"
onChange={handleFileChange}
className="hidden"
/>
</label>
</div>
</div>
)}
{/* 高级选项 */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger className="flex items-center gap-2 text-xs font-mono text-gray-500 hover:text-gray-300 transition-colors">
<ChevronRight className={`w-4 h-4 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
<Settings2 className="w-4 h-4" />
<span className="uppercase font-bold">Advanced Options</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
{/* 文件选择 */}
{(() => {
const isRepo = isRepositoryProject(selectedProject);
const isZip = isZipProject(selectedProject);
const hasStoredZip = storedZipInfo?.has_file;
// 可以选择文件的条件:仓库项目 或 ZIP项目使用已存储文件
const canSelectFiles = isRepo || (isZip && useStoredZip && hasStoredZip);
return (
<div className="flex items-center justify-between p-3 border border-dashed border-gray-700 rounded-lg bg-gray-900/30">
<div>
<p className="font-mono text-xs uppercase font-bold text-gray-500">
Scan Scope
</p>
<p className="text-sm text-white font-mono mt-1">
{selectedFiles
? `${selectedFiles.length} files selected`
: "All files"}
</p>
</div>
<div className="flex gap-2">
{selectedFiles && canSelectFiles && (
<Button
size="sm"
variant="ghost"
onClick={() => setSelectedFiles(undefined)}
className="h-8 text-xs text-red-400 hover:bg-red-900/30 hover:text-red-300"
>
Reset
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => setShowFileSelection(true)}
disabled={!canSelectFiles}
className="h-8 text-xs border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white font-mono disabled:opacity-50"
>
<FolderOpen className="w-3 h-3 mr-1" />
Select Files
</Button>
</div>
</div>
);
})()}
{/* 排除模式 */}
<div className="p-3 border border-dashed border-gray-700 rounded-lg bg-gray-900/30 space-y-3">
<div className="flex items-center justify-between">
<span className="font-mono text-xs uppercase font-bold text-gray-500">
Exclude Patterns
</span>
<button
type="button"
onClick={() => setExcludePatterns(DEFAULT_EXCLUDES)}
className="text-xs font-mono text-primary hover:text-primary/80"
>
Reset
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{excludePatterns.map((p) => (
<Badge
key={p}
variant="secondary"
className="bg-gray-800 text-gray-300 border-0 font-mono text-xs cursor-pointer hover:bg-red-900/50 hover:text-red-400"
onClick={() => setExcludePatterns((prev) => prev.filter((x) => x !== p))}
>
{p} ×
</Badge>
))}
</div>
<Input
placeholder="Add pattern, press Enter..."
className="h-8 bg-gray-900/50 border-gray-700 text-white font-mono text-sm placeholder:text-gray-600 focus:ring-0"
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value) {
const val = e.currentTarget.value.trim();
if (val && !excludePatterns.includes(val)) {
setExcludePatterns((prev) => [...prev, val]);
}
e.currentTarget.value = "";
}
}}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</div>
{/* Footer */}
<div className="flex-shrink-0 flex justify-end gap-3 px-5 py-4 bg-gray-900/50 border-t border-gray-800">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={creating}
className="px-4 h-10 font-mono text-gray-400 hover:text-white hover:bg-gray-800"
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!canStart || creating}
className="px-5 h-10 bg-primary hover:bg-primary/90 text-white font-mono font-bold"
>
{creating ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Starting...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Start Audit
</>
)}
</Button>
</div>
</DialogContent>
{/* 文件选择对话框 */}
<FileSelectionDialog
open={showFileSelection}
onOpenChange={setShowFileSelection}
projectId={selectedProjectId}
branch={branch}
excludePatterns={excludePatterns}
onConfirm={setSelectedFiles}
/>
</Dialog>
);
}
// 项目列表项
function ProjectItem({
project,
selected,
onSelect,
}: {
project: Project;
selected: boolean;
onSelect: () => void;
}) {
const isRepo = isRepositoryProject(project);
return (
<div
className={`flex items-center gap-3 p-3 cursor-pointer rounded-lg transition-all ${
selected
? "bg-primary/10 border border-primary/50"
: "hover:bg-gray-800/50 border border-transparent"
}`}
onClick={onSelect}
>
<div className={`p-1.5 rounded ${isRepo ? "bg-blue-500/20" : "bg-amber-500/20"}`}>
{isRepo ? (
<Globe className="w-4 h-4 text-blue-400" />
) : (
<Package className="w-4 h-4 text-amber-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-mono text-sm truncate ${selected ? 'text-white font-bold' : 'text-gray-300'}`}>
{project.name}
</span>
<Badge
variant="outline"
className={`text-[10px] px-1 py-0 font-mono ${
isRepo
? "border-blue-500/50 text-blue-400"
: "border-amber-500/50 text-amber-400"
}`}
>
{isRepo ? "REPO" : "ZIP"}
</Badge>
</div>
{project.description && (
<p className="text-xs text-gray-600 mt-0.5 font-mono truncate">
{project.description}
</p>
)}
</div>
{selected && (
<div className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
</div>
);
}