feat: 新建任务改为右侧抽屉(45%宽)+ 支持上传文件写入描述

- 表单从居中弹窗(Dialog)换成右侧 Sheet,宽 45vw(窄屏全宽),整屏高度可
  滚动,长表单不再受弹窗高度限制;头/脚固定、中间字段区独立滚动。
- 高级参数(超时/心跳/首个意图)收进「高级设置」Collapsible 折叠,常用路径
  只露 描述/目标/LLM;描述与目标 textarea 加高(min-h-32)。
- 描述下方加「上传文件」组件(可多选):建任务前任务无 ID,新增上传
  scope=staging,文件落到 <workDir>/drafts/<draftId>/uploads/,后端返回绝对
  路径(chatAttachment.Abs),前端把绝对路径以提示词形式追加到描述末尾,供
  worker 用 Read/Bash 打开;再次上传追加到同一标记块。draftId 用
  crypto.randomUUID(非安全上下文降级),创建成功后清空。
This commit is contained in:
Autumn-27 committed 2026-08-18 09:01:56 -04:00
1 parent 683f5ee684
commit 6a5e7c1b84
4 files changed
+230 -109

No files matched your search

+12 -4
View File
@@ -26,17 +26,23 @@ type chatAttachment struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
// Abs 是落盘的绝对路径(m.dir 已是绝对)。建任务前暂存(scope=staging)时前端要用它把
// 提示词写进描述;task/session 走 composeAgentMessage 在后端拼路径,不依赖此字段。
Abs string `json:"abs,omitempty"`
}
// chatUpload implements method-1 file support: it saves one or more files into a chat's
// working dir under uploads/, so the agent opens them with its existing Read/Bash tools
// and the sent message carries their paths. No LLM-layer change, no multimodal.
//
// POST /api/chat/upload?scope=task|session&id=<id>, multipart field "file" (repeatable).
// Returns {attachments:[{name,path,size}]}. Target dir mirrors the agent CWD layout:
// POST /api/chat/upload?scope=task|session|staging&id=<id>, multipart field "file"
// (repeatable). Returns {attachments:[{name,path,size,abs}]}. Target dir mirrors the
// agent CWD layout:
//
// scope=task → <workDir>/tasks/<id>/uploads/
// scope=session → <workDir>/sessions/<id>/uploads/
// scope=staging → <workDir>/drafts/<id>/uploads/ (建任务前暂存:任务尚无 ID,
// 文件先落这里,前端按返回的 abs 绝对路径写进任务描述)
func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) {
var sub string
switch r.URL.Query().Get("scope") {
@@ -44,8 +50,10 @@ func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) {
sub = "tasks"
case "session":
sub = "sessions"
case "staging":
sub = "drafts"
default:
writeErr(w, 400, "scope 必须是 task 或 session")
writeErr(w, 400, "scope 必须是 task / session / staging")
return
}
id := r.URL.Query().Get("id")
@@ -80,7 +88,7 @@ func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) {
return
}
base := filepath.Base(dest)
out = append(out, chatAttachment{Name: base, Path: "uploads/" + base, Size: hdr.Size})
out = append(out, chatAttachment{Name: base, Path: "uploads/" + base, Size: hdr.Size, Abs: dest})
}
writeJSON(w, 200, map[string]any{"attachments": out})
}
+215 -103
View File
@@ -10,6 +10,9 @@ import {
StarIcon,
SearchIcon,
XIcon,
ChevronRightIcon,
PaperclipIcon,
Loader2Icon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
@@ -28,15 +31,20 @@ import {
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
@@ -46,7 +54,30 @@ import {
} from "@/components/ui/select";
import { TablePagination } from "@/components/table-pagination";
import { api } from "@/lib/api";
import type { Task, TaskStatus, LLMProfile } from "@/lib/types";
import type { Task, TaskStatus, LLMProfile, ChatAttachment } from "@/lib/types";
// fmtBytes renders a human file size for the upload manifest.
function fmtBytes(n: number): string {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`;
return `${n} B`;
}
// UPLOAD_MARKER labels the auto-appended block of uploaded-file paths inside the task
// description, so re-uploads append under the same block instead of adding a new header.
const UPLOAD_MARKER = "【上传文件(绝对路径)】";
// appendUploads folds newly-uploaded files' ABSOLUTE paths into the description as a
// Read/Bash-friendly manifest — the worker opens them by path. Keeps one marked block:
// first upload adds the header, later uploads append bullets under it.
function appendUploads(desc: string, atts: ChatAttachment[]): string {
const bullets = atts.map((a) => `- ${a.abs ?? a.path}(${fmtBytes(a.size)})`).join("\n");
if (desc.includes(UPLOAD_MARKER)) {
return `${desc.replace(/\s*$/, "")}\n${bullets}\n`;
}
const head = desc.trim() ? `${desc.replace(/\s*$/, "")}\n\n` : "";
return `${head}${UPLOAD_MARKER} worker 可用 Read/Bash 按路径打开:\n${bullets}\n`;
}
// ACTIVE_PROFILE is the sentinel Select value for "use the global active profile".
const ACTIVE_PROFILE = "__active__";
@@ -114,6 +145,11 @@ export default function TasksPage() {
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
const [open, setOpen] = React.useState(false);
// 方式1 文件上传:建任务前把文件暂存到 drafts/<draftId>/uploads/,拿回绝对路径追加进描述。
const [uploading, setUploading] = React.useState(false);
const [uploadCount, setUploadCount] = React.useState(0);
const draftIdRef = React.useRef<string>("");
const fileInputRef = React.useRef<HTMLInputElement>(null);
const [query, setQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<TaskStatus | "all">("all");
const [page, setPage] = React.useState(1);
@@ -166,6 +202,29 @@ export default function TasksPage() {
return () => clearInterval(i);
}, []);
// pickFiles uploads the chosen files into this draft's staging dir and appends their
// absolute paths to the description; the task's agents open them by path via Read/Bash.
async function pickFiles(files: FileList | null) {
if (!files || files.length === 0) return;
// crypto.randomUUID 仅在安全上下文可用(https/localhost);经 IP+http 访问时降级。
if (!draftIdRef.current) {
draftIdRef.current =
globalThis.crypto?.randomUUID?.() ??
`d${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
}
setUploading(true);
try {
const r = await api.chatUpload("staging", draftIdRef.current, Array.from(files));
setDescription((prev) => appendUploads(prev, r.attachments));
setUploadCount((n) => n + r.attachments.length);
} catch (e) {
toast.error("上传失败:" + (e as Error).message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = ""; // allow re-picking the same file
}
}
async function createTask() {
if (!description.trim() || !goal.trim()) {
toast.error("请填写描述与目标");
@@ -183,6 +242,8 @@ export default function TasksPage() {
setTimeoutMin("");
setHeartbeatMin("10");
setSeedFirstIntent(false);
setUploadCount(0);
draftIdRef.current = "";
setOpen(false);
load();
} catch (e) {
@@ -239,106 +300,157 @@ export default function TasksPage() {
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条
</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="sm" className="ml-auto">
<PlusIcon /> 新建任务
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>新建任务</DialogTitle>
<DialogDescription>
填写测试对象与目标。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
id="description"
placeholder="测试对象与背景,例如:测试 example.com 这个站点"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="goal">目标</Label>
<Textarea
id="goal"
placeholder="要达成什么,例如:拿下后台管理权限、获取服务器权限"
value={goal}
onChange={(e) => setGoal(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="llm-profile">LLM 配置</Label>
<Select value={llmProfile} onValueChange={setLlmProfile}>
<SelectTrigger id="llm-profile">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTIVE_PROFILE}>使用激活配置(默认)</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name}
{p.is_default ? "(激活)" : ""} · {p.model}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
本任务的 planner/worker 使用所选配置运行;对话仍用激活配置。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="timeout-min">任务超时(分钟,可选)</Label>
<Input
id="timeout-min"
type="number"
min={0}
className="w-40"
placeholder="留空 = 不限时"
value={timeoutMin}
onChange={(e) => setTimeoutMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="heartbeat-min">planner 心跳(分钟)</Label>
<Input
id="heartbeat-min"
type="number"
min={10}
className="w-40"
placeholder="默认 10"
value={heartbeatMin}
onChange={(e) => setHeartbeatMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
距上轮规划结束/任务开始满该时长且期间无触发,自动触发一轮规划(兜底卡死 + 唤醒去监督在跑的 worker)。下限 10 分钟。
</p>
</div>
<div className="grid gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={seedFirstIntent} onCheckedChange={(v) => setSeedFirstIntent(!!v)} />
直接下发首个意图(描述+目标)
</label>
<p className="text-muted-foreground text-xs">
开启后创建即把「描述+目标」作为一条意图下发,worker 免等首轮规划直接开跑,跑完再由 planner 接手判定/补充。CTF 等常一个 work 直接解决的场景推荐开启;关闭则走标准的先规划再执行。
</p>
</SheetTrigger>
{/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。
内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */}
<SheetContent
side="right"
className="w-full! max-w-none! gap-0 p-0 sm:w-[45vw]! sm:max-w-[45vw]!"
>
<SheetHeader className="border-b p-6">
<SheetTitle>新建任务</SheetTitle>
<SheetDescription>填写测试对象与目标,高级参数可按需展开。</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
id="description"
className="min-h-32"
placeholder="测试对象与背景,例如:测试 example.com 这个站点"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{/* 上传文件(可多选):暂存到 drafts/,把绝对路径追加进上方描述,worker 据此 Read/Bash 打开。 */}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2Icon className="animate-spin" />
) : (
<PaperclipIcon />
)}
上传文件
</Button>
<span className="text-muted-foreground text-xs">
{uploadCount > 0
? `已上传 ${uploadCount} 个文件,绝对路径已追加到描述末尾(可编辑)`
: "可多选;上传后把文件的绝对路径追加到描述,供 worker 用 Read/Bash 打开"}
</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="goal">目标</Label>
<Textarea
id="goal"
className="min-h-32"
placeholder="要达成什么,例如:拿下后台管理权限、获取服务器权限"
value={goal}
onChange={(e) => setGoal(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="llm-profile">LLM 配置</Label>
<Select value={llmProfile} onValueChange={setLlmProfile}>
<SelectTrigger id="llm-profile">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTIVE_PROFILE}>使用激活配置(默认)</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name}
{p.is_default ? "(激活)" : ""} · {p.model}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
本任务的 planner/worker 使用所选配置运行;对话仍用激活配置。
</p>
</div>
{/* 高级参数默认折叠:超时/心跳/首个意图,展开才占空间,常用路径保持清爽。 */}
<Collapsible>
<CollapsibleTrigger className="group flex w-full items-center gap-2 border-t pt-4 text-sm font-medium">
<ChevronRightIcon className="text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-90" />
高级设置
<span className="text-muted-foreground ml-auto text-xs font-normal">
超时 · 心跳 · 首个意图
</span>
</CollapsibleTrigger>
<CollapsibleContent className="grid gap-5 pt-5">
<div className="grid gap-2">
<Label htmlFor="timeout-min">任务超时(分钟,可选)</Label>
<Input
id="timeout-min"
type="number"
min={0}
className="w-40"
placeholder="留空 = 不限时"
value={timeoutMin}
onChange={(e) => setTimeoutMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="heartbeat-min">planner 心跳(分钟)</Label>
<Input
id="heartbeat-min"
type="number"
min={10}
className="w-40"
placeholder="默认 10"
value={heartbeatMin}
onChange={(e) => setHeartbeatMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
距上轮规划结束/任务开始满该时长且期间无触发,自动触发一轮规划(兜底卡死 + 唤醒去监督在跑的 worker)。下限 10 分钟。
</p>
</div>
<div className="grid gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={seedFirstIntent} onCheckedChange={(v) => setSeedFirstIntent(!!v)} />
直接下发首个意图(描述+目标)
</label>
<p className="text-muted-foreground text-xs">
开启后创建即把「描述+目标」作为一条意图下发,worker 免等首轮规划直接开跑,跑完再由 planner 接手判定/补充。CTF 等常一个 work 直接解决的场景推荐开启;关闭则走标准的先规划再执行。
</p>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
<SheetClose asChild>
<Button variant="outline">取消</Button>
</DialogClose>
</SheetClose>
<Button onClick={createTask}>创建</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
{tasks.length === 0 ? (
+2 -2
View File
@@ -318,8 +318,8 @@ export const api = {
chat: (message: string, task?: string, attachments?: ChatAttachment[]) =>
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
// 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。
chatUpload: async (scope: "task" | "session", id: string, files: File[]) => {
if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size })) };
chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => {
if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size, abs: `/mock/${f.name}` })) };
const fd = new FormData();
for (const f of files) fd.append("file", f);
const token = getToken();
+1
View File
@@ -270,6 +270,7 @@ export interface ChatAttachment {
name: string;
path: string;
size: number;
abs?: string; // 绝对路径(scope=staging 暂存上传时返回;建任务前把它写进描述)
}
export interface Activity {