150 lines
11 KiB
TypeScript
150 lines
11 KiB
TypeScript
import { useRef, useState } from "react";
|
||
import { useConsole } from "../context";
|
||
import { Card, Bar } from "../components/ui";
|
||
import { gatherAttachments } from "../api/client";
|
||
import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, isChvExecutor } from "../api/chv";
|
||
import { EFFORTS } from "../api/types";
|
||
|
||
export default function DispatchSection() {
|
||
const ctx = useConsole();
|
||
const [prompt, setPrompt] = useState("");
|
||
const [coordParts, setCoordParts] = useState("");
|
||
const [goalMode, setGoalMode] = useState(false);
|
||
const [goalInvincible, setGoalInvincible] = useState(false);
|
||
const [planMode, setPlanMode] = useState(false);
|
||
const [executor, setExecutor] = useState("docker");
|
||
const [model, setModel] = useState("");
|
||
const [modelCustom, setModelCustom] = useState("");
|
||
const [effort, setEffort] = useState("");
|
||
const [approval, setApproval] = useState("");
|
||
const [childModel, setChildModel] = useState("");
|
||
const [childModelCustom, setChildModelCustom] = useState("");
|
||
const [childEffort, setChildEffort] = useState("");
|
||
const filesRef = useRef<HTMLInputElement>(null);
|
||
const dirRef = useRef<HTMLInputElement>(null);
|
||
const imgRef = useRef<HTMLInputElement>(null);
|
||
const [hasAttachments, setHasAttachments] = useState(false);
|
||
const [msg, setMsg] = useState("");
|
||
const chvAvail = chvAvailability(ctx.workers, ctx.workersSummary, ctx.workersError);
|
||
const isChv = isChvExecutor(executor);
|
||
function refreshAttachments() {
|
||
setHasAttachments(
|
||
!!(filesRef.current?.files?.length || dirRef.current?.files?.length || imgRef.current?.files?.length),
|
||
);
|
||
}
|
||
|
||
async function submit() {
|
||
setMsg("");
|
||
if (!ctx.projectId) { setMsg("先选项目"); return; }
|
||
if (!prompt.trim()) { setMsg("prompt 必填"); return; }
|
||
const body: any = { project_id: ctx.projectId, prompt: prompt.trim() };
|
||
if (ctx.envSelect) body.environment_id = ctx.envSelect;
|
||
if (executor) body.executor = executor;
|
||
const m = modelCustom.trim() || model;
|
||
if (m) body.model = m;
|
||
if (effort) body.effort = effort;
|
||
const cm = childModelCustom.trim() || childModel;
|
||
if (cm) body.child_model = cm;
|
||
if (childEffort) body.child_effort = childEffort;
|
||
if (approval) {
|
||
// 选了完全访问也不再弹确认框:下拉里选 full 本身就是
|
||
// 显式选择,提交时直接送 approval;计划模式与 full
|
||
// 互斥仍在下面拦截。
|
||
body.approval = approval;
|
||
}
|
||
if (planMode) {
|
||
if (approval === "full") { setMsg("计划模式不能与完全访问同用"); return; }
|
||
if (goalMode || goalInvincible) { setMsg("计划模式不能与 goal 模式同用"); return; }
|
||
body.plan = 1;
|
||
}
|
||
// 无敌版 = goal 模式 + 任务不限时:勾了它就同时发 goal_mode 与
|
||
// no_time_limit(服务端已支持该字段);与普通 goal 勾选不冲突,
|
||
// 无敌版优先,单独勾普通 goal 时行为不变。
|
||
if (goalInvincible) { body.goal_mode = 1; body.no_time_limit = 1; }
|
||
else if (goalMode) body.goal_mode = 1;
|
||
const coordRaw = coordParts.trim();
|
||
if (coordRaw) {
|
||
try { body.coordinator_parts = JSON.parse(coordRaw); } catch { setMsg("协调员子任务 JSON 解析失败"); return; }
|
||
if (!Array.isArray(body.coordinator_parts)) { setMsg("协调员子任务必须是数组"); return; }
|
||
if (goalMode || goalInvincible || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; }
|
||
}
|
||
const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null);
|
||
if (chvAttachmentBlocked(executor, atts.length > 0)) { setMsg("cloud-hypervisor 任务不支持附件:请先清空附件再提交(后端对带附件的 CHV 任务会 400)"); return; }
|
||
try {
|
||
const result = await ctx.dispatch(body, atts);
|
||
setMsg(result);
|
||
setPrompt(""); setCoordParts("");
|
||
if (filesRef.current) filesRef.current.value = "";
|
||
if (dirRef.current) dirRef.current.value = "";
|
||
if (imgRef.current) imgRef.current.value = "";
|
||
setHasAttachments(false);
|
||
} catch (e: any) { setMsg("提交失败:" + (e?.message || String(e))); }
|
||
}
|
||
|
||
return (
|
||
<Card id="nav-newtask" className="composer" title="新建任务">
|
||
<Bar>
|
||
<select aria-label="环境" value={ctx.envSelect} onChange={(e) => ctx.setEnvSelect(e.target.value)}>
|
||
<option value="">环境:不指定(走项目仓库)</option>
|
||
{ctx.envs.map((e) => <option key={e.id} value={e.id} disabled={e.status !== "ready"}>{e.name}({e.status})</option>)}
|
||
</select>
|
||
<select aria-label="执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
|
||
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option><option value="cloud-hypervisor">cloud-hypervisor(实验,默认关闭)</option>
|
||
</select>
|
||
<label><input type="checkbox" checked={goalMode} onChange={(e) => setGoalMode(e.target.checked)} /> goal 模式</label>
|
||
<label><input type="checkbox" checked={goalInvincible} onChange={(e) => setGoalInvincible(e.target.checked)} /> goal 模式无敌版</label>
|
||
<label><input type="checkbox" checked={planMode} onChange={(e) => setPlanMode(e.target.checked)} /> 计划模式(只读)</label>
|
||
</Bar>
|
||
{isChv ? <div className="muted">实验执行器,默认关闭:需后端已开启 CHV worker 且宿主有 KVM,否则任务无人认领、一直排队;带附件时建任务即被后端拒绝(错误文案原样显示在下方)。前端只传 executor 值,不绕过后端白名单;默认仍是 docker。</div> : null}
|
||
{isChv ? (
|
||
<div className="muted" aria-live="polite">
|
||
{chvAvailabilityText(chvAvail)}
|
||
{" "}<button type="button" onClick={ctx.refreshWorkers}>刷新 CHV 可用性</button>
|
||
</div>
|
||
) : null}
|
||
<textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
|
||
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
|
||
<Bar>
|
||
<select aria-label="模型" value={model} onChange={(e) => setModel(e.target.value)}>
|
||
<option value="">模型:默认(跟随配置)</option>
|
||
{ctx.models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||
</select>
|
||
{ctx.modelCustomVisible ? <input type="text" placeholder="手动输入模型(中转列表取不到时用)" value={modelCustom} onChange={(e) => setModelCustom(e.target.value)} /> : null}
|
||
<select aria-label="推理强度" value={effort} onChange={(e) => setEffort(e.target.value)}>
|
||
<option value="">推理强度:默认(跟随配置)</option>
|
||
{EFFORTS.map((x) => <option key={x} value={x}>{x}</option>)}
|
||
</select>
|
||
<select aria-label="权限" value={approval} onChange={(e) => setApproval(e.target.value)}>
|
||
<option value="">权限:默认(跟随配置)</option><option value="ask">请求批准</option><option value="auto">帮我批准</option><option value="full">完全访问</option>
|
||
</select>
|
||
</Bar>
|
||
<Bar>
|
||
<select aria-label="子任务模型" value={childModel} onChange={(e) => setChildModel(e.target.value)}>
|
||
<option value="">子任务模型:跟随本任务模型</option>
|
||
{ctx.models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||
</select>
|
||
<input type="text" placeholder="子任务模型手动输入(中转列表里没有时填,如 gpt-6-luna;留空=跟随本任务模型)" value={childModelCustom} onChange={(e) => setChildModelCustom(e.target.value)} />
|
||
<select aria-label="子任务强度" value={childEffort} onChange={(e) => setChildEffort(e.target.value)}>
|
||
<option value="">子任务强度:跟随本任务强度</option>
|
||
{EFFORTS.map((x) => <option key={x} value={x}>{x}</option>)}
|
||
</select>
|
||
</Bar>
|
||
<div className="muted">子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。</div>
|
||
{goalMode ? <div className="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div> : null}
|
||
{goalInvincible ? <div className="muted">goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
|
||
<Bar>
|
||
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
|
||
<input ref={filesRef} type="file" multiple title="文件" aria-label="附件文件" disabled={isChv} onChange={refreshAttachments} />
|
||
<input ref={dirRef} type="file" multiple title="文件夹" aria-label="附件文件夹" disabled={isChv} onChange={refreshAttachments} {...({ webkitdirectory: "" } as any)} />
|
||
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" aria-label="附件图片" disabled={isChv} onChange={refreshAttachments} />
|
||
</Bar>
|
||
{isChv ? <div className="muted">cloud-hypervisor 任务不支持附件:附件输入已禁用(后端对带附件的 CHV 任务会 400,错误文案原样显示在下方;切换回其他执行器可恢复附件输入)。{hasAttachments ? <>当前仍有已选附件,请先清空再提交。 <button type="button" onClick={() => { if (filesRef.current) filesRef.current.value = ""; if (dirRef.current) dirRef.current.value = ""; if (imgRef.current) imgRef.current.value = ""; setHasAttachments(false); }}>清空附件</button></> : ""}</div> : null}
|
||
<Bar>
|
||
<button className="seal" onClick={submit}>提交任务</button>
|
||
<span className="muted" role="status" aria-live="polite">{msg || ctx.dispatchMsg}</span>
|
||
</Bar>
|
||
</Card>
|
||
);
|
||
}
|