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

594 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}