Files
codex-app-server/cloud/console.html
T
host 1402dc67fd P1 security batch: quota execution checks, cost accounting, goal/coordinator semantics, PR channel, orphan signature, control-plane and console hardening
- Docker quota watchdog + fail-closed quota checks, system tasks need real owner
- max_cost_usd actually accumulated, cancel records cost, estimated rows excluded from unpriced cap, cache_write declared non-billed
- goal-resume keeps original verify on breakpoint resume, goal retry falls back to resume, goal_max_rounds capped at 100 at create and execution
- coordinator children inherit parent model/effort, orphans marked interrupted with audit
- PR jobs on docker workers return explicit needs_local_worker instead of silent empty
- worker code signature covers 6 files and is checked before claim, fail-closed
- sub2api lazy provisioning with rotation, backup whitelist + 0600, push SSRF block, repos/workers desensitized, key/project audits and limits, console buttons/quota/status wording, coordinator/automation quota reserve
2026-10-27 16:27:50 +00:00

1247 lines
65 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codex 命令台</title>
<!--
命令台 v1:纯静态单页,API 用绝对路径,假定与控制面同源同根挂载
(即本页由 server.py 的 /console 直接 serve)。数据一律凭用户在
本页输入的 API key 调接口;key 只存在本浏览器 localStorage,
不经过任何第三方。页面本身不走鉴权(只是个壳,没有 key 时
调不到任何数据);日后若经 nginx 子路径挂载,需改 API 前缀。
-->
<style>
:root {
--bg: #f4f5f7; --card: #ffffff; --text: #1d2129; --muted: #667085;
--border: #e4e7ec; --accent: #2563eb; --accent-soft: #eff4ff;
--accent-dark: #1d4ed8; --success: #067647; --danger: #b42318;
--danger-soft: #fef3f2; --warning: #b54708; --purple: #6941c6;
--radius: 12px; --shadow: 0 1px 2px rgba(0,0,0,.05), 0 1px 3px rgba(0,0,0,.06);
}
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; margin: 0 auto; max-width: 1160px; padding: 24px 20px 48px; color: var(--text); background: var(--bg); line-height: 1.5; }
.hero { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; margin-bottom: 20px; }
.hero h1 { font-size: 26px; margin: 0 0 4px; letter-spacing: .2px; }
.hero p { margin: 0; }
.version { flex: none; font-size: 12px; color: var(--muted); border: 1px solid var(--border); background: var(--card); border-radius: 999px; padding: 4px 10px; }
h2 { font-size: 15px; margin: 0 0 12px; }
h3 { font-size: 14px; margin: 20px 0 8px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px 20px; margin-bottom: 16px; }
.muted { color: var(--muted); font-size: 13px; }
.bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 10px 0; }
.card > .bar:first-of-type { margin-top: 0; }
.card > .bar:last-of-type { margin-bottom: 0; }
input, select, textarea, button { font-size: 14px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); background: #fff; color: var(--text); }
input[type="text"], input[type="password"] { min-width: 260px; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
textarea { width: 100%; min-height: 84px; resize: vertical; }
button { cursor: pointer; font-weight: 500; }
button:hover { border-color: #cbd5e1; background: #f8fafc; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
button.danger { color: var(--danger); border-color: #fecdca; background: var(--danger-soft); }
button.danger:hover { background: #fee4e2; }
.table-wrap { border: 1px solid var(--border); border-radius: var(--radius); overflow: auto; background: var(--card); box-shadow: var(--shadow); }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { padding: 9px 12px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }
th { background: #f9fafb; color: var(--muted); font-size: 12px; font-weight: 600; white-space: nowrap; }
tbody tr { cursor: pointer; }
tbody tr:hover { background: #f9fafb; }
tr.selected { background: var(--accent-soft); }
tbody tr:last-child td { border-bottom: none; }
.st-queued { color: var(--muted); } .st-running { color: var(--accent); font-weight: 600; }
.st-completed { color: var(--success); } .st-failed, .st-merge-failed { color: var(--danger); }
.st-merged { color: var(--purple); } .st-interrupted { color: var(--warning); }
td.st-queued::before, td.st-running::before, td.st-completed::before,
td.st-failed::before, td.st-merge-failed::before, td.st-merged::before,
td.st-interrupted::before { content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; margin-right: 6px; vertical-align: 1px; }
pre { background: #f8f9fb; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; overflow: auto; max-height: 400px; white-space: pre-wrap; word-break: break-word; font-size: 13px; }
#traceBox { background: #f8f9fb; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; max-height: 420px; overflow: auto; font-size: 13px; line-height: 1.75; color: var(--text); }
.err { color: var(--danger); }
.ok { color: var(--success); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.grid > div { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px 20px; }
@media (max-width: 760px) {
.grid { grid-template-columns: 1fr; }
body { padding: 16px 12px 32px; }
input[type="text"], input[type="password"] { min-width: 0; flex: 1; }
.hero { flex-direction: column; align-items: flex-start; }
}
</style>
</head>
<body>
<header class="hero">
<div>
<h1>Codex 命令台</h1>
<p class="muted">控制面操作台 v1:任务是核心操作对象;成本、workers、健康状态附带查看。公网入口未开时经 SSH 隧道访问本机 8765/8766 端口。</p>
</div>
<span class="version">key 只存本浏览器</span>
</header>
<section class="card">
<h2>API Key</h2>
<div class="bar">
<input id="key" type="password" placeholder="粘贴你的 X-API-Key(与调 API 用的同一个)" autocomplete="off">
<button id="saveKey" class="primary">保存到本浏览器</button>
<button id="clearKey">清除</button>
<span id="keyState" class="muted"></span>
</div>
<div><span id="quotaState" class="muted">未加载</span></div>
</section>
<section class="card">
<h2>健康状态</h2>
<div class="bar">
<span id="health" class="muted">未加载</span>
<button id="refreshHealth">刷新</button>
</div>
</section>
<div class="grid">
<div>
<h2>项目</h2>
<div class="bar">
<select id="projectSelect"><option value="">(先加载项目)</option></select>
<button id="refreshProjects">刷新</button>
</div>
<div class="bar">
<input id="newProjectName" type="text" placeholder="新项目名">
<select id="newProjectRepoSelect"><option value="">(从 GitHub App 安装仓库里选,或手动输入)</option></select>
<input id="newProjectRepo" type="text" placeholder="repo_url(可选)">
<button id="createProject">新建项目</button>
</div>
<span id="projectMsg" class="muted"></span>
</div>
<div>
<h2>Workers</h2>
<div class="bar"><button id="refreshWorkers">刷新</button></div>
<div id="workers" class="muted">未加载</div>
</div>
</div>
<section class="card">
<h2>环境 <span class="muted">(选、建、管:当前项目的基准检出)</span></h2>
<div class="bar">
<button id="refreshEnvironments">刷新</button>
<span id="envCount" class="muted"></span>
</div>
<div id="envList" class="muted">先选项目</div>
<div class="bar">
<input id="newEnvName" type="text" placeholder="新环境名">
<input id="newEnvBranch" type="text" placeholder="分支(可选,空=默认分支)">
<select id="newEnvExecutor">
<option value="">执行器:不限</option>
<option value="local">local</option>
<option value="docker">docker</option>
</select>
<button id="createEnvironment">新建环境</button>
</div>
<div class="bar"><span id="envRepoLine" class="muted"></span></div>
<div class="bar" id="envRepoBar" style="display:none">
<select id="envRepoSelect"><option value="">(从 GitHub App 安装仓库里选)</option></select>
<button id="saveEnvRepo">保存仓库</button>
</div>
<span id="envRepoMsg" class="muted"></span>
<span id="envMsg" class="muted"></span>
</section>
<section class="card">
<h2>新建任务</h2>
<div class="bar">
<select id="envSelect"><option value="">环境:不指定(走项目仓库)</option></select>
<select id="executor">
<option value="docker" selected>docker(默认,多用户下隔离最强)</option>
<option value="local">local</option>
<option value="cloud">cloud</option>
</select>
<label><input id="goalMode" type="checkbox"> goal 模式</label>
<label><input id="planMode" type="checkbox"> 计划模式(只读)</label>
</div>
<textarea id="prompt" placeholder="任务 prompt(必填)"></textarea>
<textarea id="coordParts" placeholder="协调员子任务 JSON(可选),留空为普通任务"></textarea>
<div class="muted">协调员格式:[{"name":"后端","prompt":"做后端"},{"name":"前端","prompt":"做前端"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
<div class="bar">
<select id="modelSelect">
<option value="">模型:默认(跟随配置)</option>
</select>
<input id="modelCustom" type="text" placeholder="手动输入模型(中转列表取不到时用)" style="display:none">
<select id="effortSelect">
<option value="">推理强度:默认(跟随配置)</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
</select>
<select id="approvalSelect">
<option value="">权限:默认(跟随配置)</option>
<option value="ask">请求批准</option>
<option value="auto">帮我批准</option>
<option value="full">完全访问</option>
</select>
</div>
<div id="goalHint" style="display:none" class="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div>
<div class="bar">
<span class="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input type="file" id="attFiles" multiple title="文件">
<input type="file" id="attDir" webkitdirectory title="文件夹">
<input type="file" id="attImg" accept="image/*" multiple title="图片">
</div>
<div class="bar">
<button id="createTask" class="primary">提交任务</button>
<span id="taskMsg" class="muted"></span>
</div>
</section>
<section class="card">
<h2>任务列表 <span class="muted">(每 10 秒自动刷新当前项目)</span></h2>
<div class="bar">
<button id="refreshTasks">刷新</button>
<span id="taskCount" class="muted"></span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>任务</th><th>turn</th><th>root turn</th><th>prompt</th><th>状态</th><th>executor</th><th>主机</th><th>PR</th><th>更新</th></tr></thead>
<tbody id="taskRows"><tr><td colspan="9" class="muted">先选项目</td></tr></tbody>
</table>
</div>
</section>
<section class="card">
<h2>任务详情</h2>
<div id="detail" class="muted">点任务行查看详情</div>
<div class="bar" id="detailActions" style="display:none">
<button id="btnRetry">重试</button>
<button id="btnCancel" class="danger">取消</button>
<button id="btnMerge">合并(completed → merged)</button>
<button id="btnPr">提 PR(须 merged)</button>
<button id="btnFork">分支</button>
<button id="btnCost">查成本</button>
<span id="actionMsg" class="muted"></span>
</div>
<div id="forkForm" style="display:none">
<input id="forkMsg" type="text" placeholder="分支首轮消息(必填,带历史的新会话)" style="width:100%; box-sizing:border-box; margin:6px 0;">
<div class="bar"><button id="btnForkConfirm" class="primary">创建分支任务</button><span id="forkMsgSpan" class="muted"></span></div>
</div>
<div id="continueForm" style="display:none">
<h3>继续会话(旧任务接回来,同一会话再跑一轮;不传的选项沿用任务原值)</h3>
<textarea id="continueText" placeholder="本轮要说的话(必填)"></textarea>
<div class="bar">
<select id="continueModel"><option value="">模型:沿用任务原值</option></select>
<select id="continueEffort">
<option value="">强度:沿用任务原值</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
</select>
<select id="continueApproval">
<option value="">权限:沿用任务原值</option>
<option value="ask">请求批准</option>
<option value="auto">帮我批准</option>
<option value="full">完全访问</option>
</select>
<label><input type="checkbox" id="continuePlan"> 计划模式(只读)</label>
<label><input type="checkbox" id="continueGoal"> goal 模式(不限轮次,模型宣告完成)</label>
</div>
<div class="bar"><button id="btnContinueConfirm" class="primary">继续会话</button><span id="continueMsgSpan" class="muted"></span></div>
</div>
<pre id="detailBody" style="display:none"></pre>
<h3>协调子任务</h3>
<div id="coordBox" class="muted">点任务行查看详情后,这里显示协调员的子任务</div>
<h3>子代理链路</h3>
<div id="agentBox" class="muted">点任务行查看详情后,这里显示子代理链路</div>
<h3>评审队列</h3>
<div id="reviewForm">
<select id="reviewTargetType">
<option value="uncommittedChanges">未提交改动(工作区)</option>
<option value="baseBranch">与分支的差异</option>
<option value="commit">指定提交</option>
<option value="custom">自定义评审指令</option>
</select>
<input id="reviewBranch" placeholder="与分支的差异用:分支名(如 main)" style="width:100%">
<input id="reviewSha" placeholder="指定提交用:提交 sha" style="width:100%">
<textarea id="reviewInstructions" placeholder="自定义评审指令用:评审指令正文"></textarea>
<div class="bar">
<button id="btnCreateReview" class="primary" onclick="postReview()">登记评审需求</button>
<button onclick="if (currentTask) loadReviews(currentTask.id)">刷新队列</button>
<span id="reviewMsg" class="muted"></span>
</div>
<div class="muted">本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。</div>
</div>
<div id="reviewList" class="muted">点任务行查看详情后,点“刷新队列”查看评审记录</div>
<h3>实时链路</h3>
<div id="traceBox" class="muted">点任务行查看详情后,这里显示过程链路</div>
<div id="costBox"></div>
<h3>后续消息</h3>
<div id="followupForm">
<textarea id="followupText" placeholder="后续消息(任务运行中/排队中可用;排队不打断当前轮,直接发送跑完当前轮后优先执行)"></textarea>
<div class="bar">
<button id="btnQueueFollowup">排队</button>
<button id="btnDirectFollowup" class="primary">直接发送</button>
<span id="followupMsg" class="muted"></span>
</div>
</div>
<div id="followupList" class="muted"></div>
<h3>运行时审批队列</h3>
<div class="bar"><button id="btnRefreshApprovals" onclick="if(currentTask)loadApprovals(currentTask.id)">刷新审批队列</button><span id="approvalMsg" class="muted"></span></div>
<div class="muted">待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。</div>
<div id="approvalList" class="muted"></div>
<h3>给其他任务发消息</h3>
<div id="peerMessageForm">
<input id="peerTarget" placeholder="目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)" style="width:100%">
<textarea id="peerBody" placeholder="消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)"></textarea>
<div class="bar">
<button id="btnQueuePeerMessage">排队发给对方</button>
<button id="btnDirectPeerMessage" class="primary">直接发送</button>
<span id="peerMsg" class="muted"></span>
</div>
</div>
<div id="peerMessageList" class="muted"></div>
<h3>自动化</h3>
<div id="automationForm">
<input id="autoName" placeholder="自动化名称(同项目内启用中的名称唯一)" style="width:100%">
<textarea id="autoPrompt" placeholder="定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)"></textarea>
<div class="bar">
<input id="autoInterval" placeholder="间隔秒数(默认 86400,最小 60)" style="width:40%">
<input id="autoRepo" placeholder="仓库(可选)" style="width:40%">
<button id="btnCreateAutomation" class="primary">创建自动化</button>
<button id="btnLoadAutomations">刷新自动化</button>
<span id="autoMsg" class="muted"></span>
</div>
</div>
<div id="automationList" class="muted"></div>
<h3>分诊收件箱</h3>
<div class="bar"><button id="btnLoadTriage">刷新分诊</button><span id="triageMsg" class="muted"></span></div>
<div id="triageBox" class="muted"></div>
</section>
<script>
"use strict";
var $ = function (s) { return document.querySelector(s); };
var KEY_STORE = "codex-console-key";
var currentTask = null;
var traceAbort = null;
var TRACE_KIND = { exec: "命令", file: "文件", call: "调用", tool: "工具", message: "消息", reasoning: "思考", plan: "计划", "agent-start": "子代理", "agent-done": "子代理" };
var TRACE_LIMIT = 1000;
var TRACE_TERMINAL = ["completed", "failed", "interrupted", "merged", "merge-failed"];
function esc(s) {
return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function fmt(ts) {
if (!ts) return "";
return new Date(ts * 1000).toLocaleString();
}
function shortId(id) { return id ? id.slice(0, 8) : ""; }
function getKey() { return localStorage.getItem(KEY_STORE) || ""; }
function setKeyState() {
$("#keyState").textContent = getKey() ? "已保存到本浏览器(只用于调本站 API)" : "未填 key,数据接口会返回 401";
}
async function api(path, method, body) {
var headers = { "Content-Type": "application/json" };
var key = getKey();
if (key) headers["X-API-Key"] = key;
var opt = { method: method || "GET", headers: headers };
if (body !== undefined) opt.body = JSON.stringify(body);
var r = await fetch(path, opt);
var data = null;
try { data = await r.json(); } catch (e) { data = null; }
if (!r.ok) {
var msg = (data && data.error) ? data.error : ("HTTP " + r.status);
if (r.status === 401) msg += "(key 未填或无效)";
if (r.status === 429) msg += "(触发限流,稍后再试)";
if (r.status === 402) {
// 402 要区分两种满:服务端在响应里带哪个上限字段就是哪个满。
if (data && data.unpriced_token_cap !== undefined) msg += "(无价 token 已满,联系管理员调整)";
else if (data && data.error === "coordinator_quota_reserve_exceeded") msg = "协调员分工预留不足:剩余美元配额不够覆盖全部分工,子任务费用记父任务创建人账,整个父任务已被挡下(未建任何子任务)";
else msg += "(美元配额已满,联系管理员调整)";
}
throw new Error(msg);
}
return data;
}
async function loadRelayModels() {
// 中转模型下拉:服务端现取现回,只含模型 id。取不到时不挡
// 提交,留手动输入框给用户填。
var sel = $("#modelSelect");
try {
var d = await api("/relay/models", "GET");
var models = d.models || [];
var opts = models.map(function (m) {
return "<option value='" + esc(m) + "'>" + esc(m) + "</option>";
}).join("");
sel.innerHTML = "<option value=''>模型:默认(跟随配置)</option>" + opts;
var csel = $("#continueModel");
if (csel) csel.innerHTML = "<option value=''>模型:沿用任务原值</option>" + opts;
$("#modelCustom").style.display = "none";
} catch (e) {
$("#modelCustom").style.display = "inline";
}
}
async function loadHealth() {
try {
var h = await api("/health", "GET");
$("#health").textContent = "backend=" + h.backend + " db_ok=" + h.db_ok +
" worker_ok=" + h.worker_ok + " commit=" + String(h.commit || "").slice(0, 7);
} catch (e) { $("#health").textContent = "加载失败:" + e.message; }
}
async function loadQuota() {
// 本月配额与用量:只读展示,管理员改配额走 API(/admin/quotas)。
var el = $("#quotaState");
if (!getKey()) { el.textContent = "未填 key,配额不显示"; return; }
try {
var d = await api("/users/me/quota", "GET");
var used = (d.used_usd_micros / 1000000).toFixed(2);
var usdLine;
if (d.quota_usd_micros === null || d.quota_usd_micros === undefined) {
usdLine = "美元:本月用量 $" + used + "(不限额)";
} else {
var q = (d.quota_usd_micros / 1000000).toFixed(2);
var rem = (d.remaining_usd_micros / 1000000).toFixed(2);
usdLine = "美元:本月用量 $" + used + " / $" + q + "(剩余 $" + rem + ")";
}
// 两条进度并列:美元一条,无价 token 一条;402 时按哪条满区分报错。
var tokLine = "无价 token:";
if (d.unpriced_token_cap === undefined || d.unpriced_tokens_used === undefined) {
tokLine += "未返回(旧服务端)";
} else {
tokLine += d.unpriced_tokens_used + " / " + d.unpriced_token_cap + " tokens";
}
el.textContent = usdLine + " | " + tokLine;
} catch (e) { el.textContent = "配额加载失败:" + e.message; }
}
var projectsCache = [];
var githubReposCache = [];
async function loadGithubRepos() {
try {
var d = await api("/github/repos", "GET");
githubReposCache = d.repos || [];
var opts = "<option value=''>(从 GitHub App 安装仓库里选,或手动输入)</option>" +
githubReposCache.map(function (r) {
return "<option value='" + esc(r.clone_url) + "'>" + esc(r.repo_full_name) + "</option>";
}).join("");
var s1 = $("#newProjectRepoSelect"); if (s1) s1.innerHTML = opts;
var s2 = $("#envRepoSelect");
if (s2) s2.innerHTML = "<option value=''>(从 GitHub App 安装仓库里选)</option>" +
githubReposCache.map(function (r) {
return "<option value='" + esc(r.clone_url) + "'>" + esc(r.repo_full_name) + "</option>";
}).join("");
} catch (e) { /* 未登录或无安装记录时只留手动输入,不报错 */ }
}
async function loadProjects() {
try {
var d = await api("/projects", "GET");
projectsCache = d.projects || [];
var sel = $("#projectSelect");
var cur = sel.value;
sel.innerHTML = "";
(d.projects || []).forEach(function (p) {
var o = document.createElement("option");
o.value = p.id; o.textContent = p.name + " (" + shortId(p.id) + ")";
sel.appendChild(o);
});
if (cur && Array.prototype.some.call(sel.options, function (o) { return o.value === cur; })) sel.value = cur;
$("#projectMsg").textContent = (d.projects || []).length + " 个项目";
} catch (e) { $("#projectMsg").textContent = "加载失败:" + e.message; }
}
async function loadApprovals(taskId) {
var box = $("#approvalList");
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/approvals", "GET");
var list = d.approvals || [];
if (!list.length) { box.innerHTML = "(暂无审批项)"; return; }
box.innerHTML = list.map(function (a) {
var receipt = "";
if (a.status === "approved" || a.status === "denied" || a.status === "expired") {
receipt = "|回执:" + esc(a.decision || a.status) + " by " + esc(String(a.decided_by || "").slice(0, 8)) + " " + fmt(a.decided_at);
}
var btns = "";
if (a.status === "pending") {
btns = " <button onclick=\"decideApproval('" + esc(a.id) + "', 'approved')\">记录允许</button>" +
" <button onclick=\"decideApproval('" + esc(a.id) + "', 'denied')\">记录拒绝</button>";
}
return "[" + esc(a.kind) + "|" + esc(a.status) + "] " + esc(a.summary) + receipt + btns;
}).join("<br>");
} catch (e) { box.innerHTML = "<span class='err'>审批队列加载失败:" + esc(e.message) + "</span>"; }
}
async function decideApproval(approvalId, decision) {
if (!currentTask) return;
var verb = decision === "approved" ? "approve" : "deny";
try {
await api("/tasks/" + encodeURIComponent(currentTask.id) + "/approvals/" + encodeURIComponent(approvalId) + "/" + verb, "POST", {});
$("#approvalMsg").innerHTML = "<span class='ok'>已记录" + (decision === "approved" ? "允许" : "拒绝") + "(当前版本只留回执,不拦截实时行为)</span>";
loadApprovals(currentTask.id);
} catch (e) { $("#approvalMsg").innerHTML = "<span class='err'>决定失败:" + esc(e.message) + "</span>"; }
}
async function loadWorkers() {
try {
var d = await api("/workers", "GET");
// 非全局管理员只拿到聚合数字(服务端 P1 收紧):不展示 worker 清单。
if (d.workers === undefined && d.workers_total !== undefined) {
if (!d.workers_total) { $("#workers").textContent = "无 worker 注册"; return; }
$("#workers").textContent = "在线 " + d.workers_online + " / 离线 " + d.workers_offline +
"(共 " + d.workers_total + ";完整 worker 清单仅全局管理员可见)";
return;
}
var list = d.workers || d || [];
if (!list.length) { $("#workers").textContent = "无 worker 注册"; return; }
$("#workers").innerHTML = list.map(function (w) {
var run = (w.running === undefined || w.running === null) ? "?" : w.running;
var cap = (w.capacity === undefined || w.capacity === null) ? "?" : w.capacity;
var live = (w.alive === false) ? " / 离线(占用为上次上报值)" : "";
return esc(w.worker_id || w.id || "?") + " / " + esc(w.executor || "") +
" / 占用 " + esc(run) + "/" + esc(cap) + live +
" / last_seen " + esc(w.last_seen || w.last_seen_seconds_ago || "");
}).join("<br>");
} catch (e) { $("#workers").textContent = "加载失败:" + e.message; }
}
async function loadEnvironments() {
var pid = $("#projectSelect").value;
var sel = $("#envSelect");
var cur = sel.value;
if (!pid) {
$("#envList").textContent = "先选项目";
$("#envCount").textContent = "";
sel.innerHTML = "<option value=''>环境:不指定(走项目仓库)</option>";
return;
}
try {
var d = await api("/environments?project_id=" + encodeURIComponent(pid), "GET");
var envs = d.environments || [];
$("#envCount").textContent = envs.length + " 个环境";
if (!envs.length) { $("#envList").textContent = "该项目暂无环境(先建一个,或不指定环境直接建任务)"; }
else {
$("#envList").innerHTML = envs.map(function (e) {
var btn = e.status === "archived" ? "" : " <button data-env-archive='" + esc(e.id) + "'>归档</button>";
var err = e.status === "failed" && e.error_text ? " / 失败原因:" + esc(e.error_text) : "";
return esc(e.name) + "(" + esc(shortId(e.id)) + ") / 分支 " + esc(e.branch || "默认分支") +
" / 执行器 " + esc(e.executor || "不限") + " / 状态 " + esc(e.status) + err + btn + "<br>";
}).join("");
Array.prototype.forEach.call(document.querySelectorAll("[data-env-archive]"), function (b) {
b.onclick = async function () {
if (!confirm("确定归档该环境吗?归档后新任务不能再选它,进行中的任务不受影响(有进行中任务在用它时归档会被拒绝)。")) return;
try { await api("/environments/" + encodeURIComponent(b.getAttribute("data-env-archive")) + "/archive", "POST", {}); loadEnvironments(); }
catch (e) { $("#envMsg").innerHTML = "<span class='err'>归档失败:" + esc(e.message) + "</span>"; }
};
});
}
// 任务创建的环境下拉:只列未归档环境,未 ready 的置灰(creating
// 时可看到它在准备,ready 后才能选);刷新时尽量保留已选值。
sel.innerHTML = "<option value=''>环境:不指定(走项目仓库)</option>";
envs.forEach(function (e) {
var o = document.createElement("option");
o.value = e.id; o.textContent = e.name + "(" + e.status + ")";
if (e.status !== "ready") o.disabled = true;
sel.appendChild(o);
});
if (cur && Array.prototype.some.call(sel.options, function (o) { return o.value === cur && !o.disabled; })) sel.value = cur;
// 项目仓库行:有仓库就显示;没仓库就亮出补仓库的下拉(截图里
// 那个 400 的死结就卡在这里——不先补仓库,环境永远建不出来)。
var proj = null;
for (var i = 0; i < projectsCache.length; i++) { if (projectsCache[i].id === pid) { proj = projectsCache[i]; break; } }
if (proj && proj.repo_url) {
$("#envRepoLine").textContent = "仓库:" + proj.repo_url;
$("#envRepoBar").style.display = "none";
} else if (proj) {
$("#envRepoLine").textContent = "该项目未绑定仓库,环境无法创建——先选一个仓库保存";
$("#envRepoBar").style.display = "";
} else {
$("#envRepoLine").textContent = "";
$("#envRepoBar").style.display = "none";
}
} catch (e) {
$("#envList").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>";
}
}
async function loadTasks() {
var pid = $("#projectSelect").value;
if (!pid) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>先选项目</td></tr>"; return; }
try {
var d = await api("/tasks?project_id=" + encodeURIComponent(pid), "GET");
var tasks = d.tasks || [];
$("#taskCount").textContent = tasks.length + " 个任务";
if (!tasks.length) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>该项目暂无任务</td></tr>"; return; }
$("#taskRows").innerHTML = tasks.slice().reverse().map(function (t) {
var pr = t.pr_url ? "<a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>#" + esc(t.pr_number) + "</a>" : "";
return "<tr data-id='" + esc(t.id) + "' class='" + (currentTask && currentTask.id === t.id ? "selected" : "") + "'>" +
"<td>" + esc(shortId(t.id)) + "</td>" +
"<td title='" + esc(t.turn_id || "") + "'>" + esc(shortId(t.turn_id)) + "</td>" +
"<td title='" + esc(t.root_turn_id || "") + "'>" + esc(shortId(t.root_turn_id)) + "</td>" +
"<td>" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + (t.coordinator_parts ? "[协调] " : "") + (t.parent_task_id ? "[协调子任务] " : "") + ((t.goal_mode || t.verify_command) ? "[目标] " : "") + (t.environment_id ? "[环境 " + esc(shortId(t.environment_id)) + "] " : "") + ((t.attachments && t.attachments.length) ? "[附件×" + t.attachments.length + "] " : "") + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
"<td class='st-" + esc(t.status) + "'>" + esc(t.status) + "</td>" +
"<td>" + esc(t.executor || "") + "</td>" +
"<td>" + esc(t.worktree_host || "") + "</td>" +
"<td>" + pr + "</td>" +
"<td>" + esc(fmt(t.updated_at)) + "</td></tr>";
}).join("");
Array.prototype.forEach.call(document.querySelectorAll("#taskRows tr[data-id]"), function (tr) {
tr.onclick = function () { showDetail(tr.getAttribute("data-id")); };
});
} catch (e) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='err'>加载失败:" + esc(e.message) + "</td></tr>"; }
}
function stopTrace() {
if (traceAbort) { try { traceAbort.abort(); } catch (e) {} traceAbort = null; }
if (agentTimer) { clearTimeout(agentTimer); agentTimer = null; }
}
function traceLine(e) {
var k = TRACE_KIND[e.kind] || e.kind || "?";
var body = "";
if (e.kind === "exec") body = esc(e.command || "") + "(exit=" + esc(e.exitCode) + "," + esc(e.durationMs) + "ms,cwd=" + esc(e.cwd || "") + ")";
else if (e.kind === "file") body = esc(e.path || "") + " " + esc(e.change || "") + " " + esc(e.status || "");
else if (e.kind === "message") body = (e.phase ? "[" + esc(e.phase) + "] " : "") + esc(e.text || "");
else if (e.kind === "reasoning") body = esc(e.summary || "");
else if (e.kind === "tool") body = esc(e.server || "") + "/" + esc(e.tool || "") + " " + esc(e.status || "");
else if (e.kind === "call") body = esc(e.name || "");
else if (e.kind === "plan") body = esc(e.text || "");
else if (e.kind === "agent-start") body = "开始:" + esc(e.name || "") + (e.prompt ? "(" + esc(e.prompt) + ")" : "");
else if (e.kind === "agent-done") body = "结束:" + esc(e.name || "") + " " + esc(e.status || "");
else body = esc(JSON.stringify(e).slice(0, 300));
return "[" + k + "] " + body;
}
function renderTrace(entries, note, doneStatus) {
var box = $("#traceBox");
var html = "";
if (note) html += "<span class='err'>" + esc(note) + "</span><br>";
if (!entries.length) html += "(暂无链路条目)";
else {
var shown = entries.slice(-TRACE_LIMIT);
if (entries.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + entries.length + " 条)<br>";
html += shown.map(traceLine).join("<br>");
}
if (doneStatus) html += "<br><span class='muted'>链路已结束(" + esc(doneStatus) + ")</span>";
box.innerHTML = html;
}
var AGENT_STATUS = { running: "进行中", completed: "已完成", failed: "失败", interrupted: "已中断", unknown: "未知" };
var AGENT_KIND = { main: "主任务", delegate: "委派子任务", model: "模型子代理" };
var AGENT_CALL_SHOWN = 50;
var agentTimer = null;
function renderAgents(data) {
var box = $("#agentBox");
var html = "";
if (!data) { box.innerHTML = "(暂无子代理链路)"; return; }
if (data.note) html += "<span class='err'>" + esc(data.note) + "</span><br>";
var agents = data.agents || [];
if (!agents.length) html += "(暂无子代理)";
agents.forEach(function (a) {
var st = AGENT_STATUS[a.status] || a.status || "?";
html += "<div style='margin:6px 0;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:#fff'>";
html += "<b>" + esc(AGENT_KIND[a.kind] || a.kind || "") + ":</b>" + esc(a.name || "") +
" <span class='st-" + esc(a.status) + "'>" + esc(st) + "</span>" +
" | 过程调用 " + (a.calls ? a.calls.length : 0) + " 条" +
(a.updated_ts ? " | 最近活动 " + fmt(a.updated_ts / 1000) : "") + "<br>";
if (a.prompt) html += "<span class='muted'>任务:" + esc(a.prompt) + "</span><br>";
if (a.calls && a.calls.length) {
var shown = a.calls.slice(-AGENT_CALL_SHOWN);
if (a.calls.length > AGENT_CALL_SHOWN) html += "(仅显示最近 " + AGENT_CALL_SHOWN + " 条,共 " + a.calls.length + " 条)<br>";
html += shown.map(traceLine).join("<br>");
} else html += "<span class='muted'>(暂无过程调用)</span>";
html += "</div>";
});
box.innerHTML = html;
}
async function loadAgents(taskId) {
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/agents", "GET");
renderAgents(d);
} catch (e) { $("#agentBox").innerHTML = "<span class='err'>子代理链路加载失败:" + esc(e.message) + "</span>"; }
}
function scheduleAgents(taskId) {
if (agentTimer) clearTimeout(agentTimer);
agentTimer = setTimeout(function () { loadAgents(taskId); }, 1000);
}
async function loadCoordChildren(taskId) {
var box = $("#coordBox");
if (!currentTask || !currentTask.coordinator_parts) { box.innerHTML = "<span class='muted'>(非协调员任务)</span>"; return; }
try {
var d = await api("/tasks?project_id=" + encodeURIComponent(currentTask.project_id) + "&parent_task_id=" + encodeURIComponent(taskId), "GET");
var kids = d.tasks || [];
if (!kids.length) { box.innerHTML = "<span class='muted'>(子任务尚未创建)</span>"; return; }
box.innerHTML = kids.map(function (k) {
return "<div>分工 <b>" + esc(k.coordinator_part || "") + "</b> | 状态 <span class='st-" + esc(k.status) + "'>" + esc(k.status) + "</span> | " + esc(shortId(k.id)) + " | " + esc(String(k.prompt || "").slice(0, 60)) + "</div>";
}).join("");
} catch (e) { box.innerHTML = "<span class='err'>协调子任务加载失败:" + esc(e.message) + "</span>"; }
}
async function startTrace(taskId, status) {
stopTrace();
var box = $("#traceBox");
if (TRACE_TERMINAL.indexOf(status) >= 0) {
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/trace", "GET");
renderTrace(d.entries || [], d.note || "", d.status);
} catch (e) { box.innerHTML = "<span class='err'>链路加载失败:" + esc(e.message) + "</span>"; }
return;
}
// 运行中:fetch 流式读 SSE(EventSource 不能带 X-API-Key 头,故不用它)。
var entries = [];
var index = {};
var note = "";
var done = false;
var doneStatus = "";
box.innerHTML = "链路连接中…";
var ctrl = new AbortController();
traceAbort = ctrl;
try {
var headers = {};
var key = getKey();
if (key) headers["X-API-Key"] = key;
var r = await fetch("/tasks/" + encodeURIComponent(taskId) + "/trace/stream", { headers: headers, signal: ctrl.signal });
if (!r.ok) {
var msg = "HTTP " + r.status;
try { var dj = await r.json(); if (dj && dj.error) msg = dj.error; } catch (e) {}
if (r.status === 401) msg += "(key 未填或无效)";
throw new Error(msg);
}
var reader = r.body.getReader();
var decoder = new TextDecoder();
var buf = "";
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
var parts = buf.split("\n\n");
buf = parts.pop();
parts.forEach(function (part) {
var line = part.split("\n").filter(function (l) { return l.indexOf("data: ") === 0; }).map(function (l) { return l.slice(6); }).join("\n");
if (!line) return;
try {
var m = JSON.parse(line);
if (m.type === "snapshot") {
entries = m.entries || []; index = {};
entries.forEach(function (e, i) { index[e.id] = i; });
note = m.note || "";
renderTrace(entries, note, "");
scheduleAgents(taskId);
} else if (m.type === "entry") {
var e = m.entry;
if (e.id in index) entries[index[e.id]] = e; else { index[e.id] = entries.length; entries.push(e); }
renderTrace(entries, note, "");
scheduleAgents(taskId);
} else if (m.type === "done") {
done = true; doneStatus = m.status;
renderTrace(entries, note, doneStatus);
}
} catch (e) {}
});
if (done) break;
}
} catch (e) {
if (e && e.name === "AbortError") return;
box.innerHTML = "<span class='err'>链路流中断:" + esc(e.message) + "</span>";
return;
}
if (done) { loadTasks(); showDetail(taskId); }
}
async function loadReviews(taskId) {
var box = $("#reviewList");
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/reviews", "GET");
var list = d.reviews || [];
if (!list.length) { box.innerHTML = "(该任务暂无评审记录)"; return; }
box.innerHTML = list.map(function (r) {
var target = esc(r.target_type);
if (r.branch) target += ":" + esc(r.branch);
if (r.sha) target += ":" + esc(String(r.sha).slice(0, 12));
if (r.instructions) target += ":" + esc(String(r.instructions).slice(0, 80));
var html = "<div style='margin:6px 0;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:#fff'>";
html += "<b>评审</b> " + esc(String(r.id).slice(0, 8)) + " | " + target +
" | <span class='st-" + esc(r.status) + "'>" + esc(r.status) + "</span>" +
(r.created_at ? " | " + fmt(r.created_at) : "") + "<br>";
html += r.findings ? esc(String(r.findings).slice(0, 1000)) : "<span class='muted'>(暂无 findings:评审尚未执行或尚未写回)</span>";
html += "<br><button onclick=\"submitReviewFindings('" + esc(r.id) + "')\">写 findings</button>";
html += "</div>";
return html;
}).join("");
} catch (e) { box.innerHTML = "<span class='err'>评审队列加载失败:" + esc(e.message) + "</span>"; }
}
async function postReview() {
if (!currentTask) return;
var body = { target_type: $("#reviewTargetType").value };
var branch = $("#reviewBranch").value.trim();
var sha = $("#reviewSha").value.trim();
var instructions = $("#reviewInstructions").value.trim();
if (branch) body.branch = branch;
if (sha) body.sha = sha;
if (instructions) body.instructions = instructions;
try {
await api("/tasks/" + encodeURIComponent(currentTask.id) + "/reviews", "POST", body);
$("#reviewMsg").innerHTML = "<span class='ok'>评审已登记(requested)</span>";
loadReviews(currentTask.id);
} catch (e) { $("#reviewMsg").innerHTML = "<span class='err'>登记评审需求失败:" + esc(e.message) + "</span>"; }
}
async function submitReviewFindings(reviewId) {
if (!currentTask) return;
var text = prompt("输入该条评审的 findings(将写回记录并标为 completed):");
if (text === null) return;
try {
await api("/tasks/" + encodeURIComponent(currentTask.id) + "/reviews/" + encodeURIComponent(reviewId) + "/findings", "POST", { findings: text });
loadReviews(currentTask.id);
} catch (e) { $("#reviewMsg").innerHTML = "<span class='err'>写 findings 失败:" + esc(e.message) + "</span>"; }
}
async function showDetail(taskId) {
stopTrace();
try {
var t = await api("/tasks/" + encodeURIComponent(taskId), "GET");
currentTask = t;
document.querySelectorAll("#taskRows tr").forEach(function (tr) {
tr.classList.toggle("selected", tr.getAttribute("data-id") === taskId);
});
// 每个任务状态配一句中文解释(只解释状态语义,不改后端状态机)。
var STATUS_EXPLAIN = {
queued: "排队中,尚未被 worker 认领",
running: "运行中,模型回合进行中",
completed: "模型回合结束、改动在 worktree 里尚未合并",
failed: "模型回合失败,可重试",
interrupted: "已被取消/中断,可重试",
merged: "控制面已标记 merged,不代表 git 主分支已合入",
"merge-failed": "合并落盘失败,可重试"
};
$("#detail").innerHTML =
"状态 <b class='st-" + esc(t.status) + "'>" + esc(t.status) + "</b>(" +
esc(STATUS_EXPLAIN[t.status] || "未知状态") + ") | " +
"executor " + esc(t.executor || "") + " | 主机 " + esc(t.worktree_host || "") +
" | 模型 " + esc(t.model || "默认") + " | 强度 " + esc(t.effort || "默认") +
" | 权限 " + esc(t.approval || "默认") +
(t.plan ? " | <b>计划模式(只读)</b>" : "") +
(t.forked_from ? " | 分支自 " + esc(shortId(t.forked_from)) : "") +
(t.goal_mode ? " | <b>goal 模式(不限轮次,模型宣告完成)</b>" : (t.verify_command ? " | goal 模式(旧语义,验收命令)" : "")) +
(t.coordinator_parts ? " | <b>协调员:" + t.coordinator_parts.length + " 个子任务(" + esc(t.coordinator_parts.map(function (p) { return p.name; }).join("、")) + ")</b>" : "") +
(t.parent_task_id ? " | 协调子任务,父任务 " + esc(shortId(t.parent_task_id)) + (t.coordinator_part ? "(" + esc(t.coordinator_part) + ")" : "") : "") +
((t.attachments && t.attachments.length) ? " | 附件:" + t.attachments.map(esc).join("、") : "") +
(t.pr_url ? " | <a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>PR #" + esc(t.pr_number) + "</a>" : "") +
"<br>prompt:<br>" + esc(t.prompt || "");
var body = "";
if (t.result_text) body += "=== result ===\n" + t.result_text.slice(0, 8000) + "\n";
if (t.error_text) body += "=== error ===\n" + t.error_text.slice(0, 8000) + "\n";
if (!body) body = "(暂无 result/error 输出)";
var pre = $("#detailBody");
pre.textContent = body;
pre.style.display = "block";
$("#detailActions").style.display = "flex";
$("#btnFork").style.display = TRACE_TERMINAL.indexOf(t.status) >= 0 ? "" : "none";
// 按钮按状态 + 当前用户项目角色禁用并写明原因(前端禁用只是
// 防误点,真正鉴权仍在服务端每次请求时做;角色来自项目列表
// 的 my_role 字段,未知角色时按最严(仅取消可用)处理)。
(function () {
var proj = null;
(projectsCache || []).forEach(function (p) { if (p.id === t.project_id) proj = p; });
var role = proj ? proj.my_role : null;
var isMaintainer = (role === "maintainer" || role === "owner");
var canCreate = (role === "contributor" || isMaintainer);
var terminal = TRACE_TERMINAL.indexOf(t.status) >= 0;
var rules = [
{ btn: "#btnRetry", ok: ["failed", "interrupted", "merge-failed"].indexOf(t.status) >= 0 && isMaintainer,
why: "仅 failed/interrupted/merge-failed 可重试" + (isMaintainer ? "" : ",且仅 maintainer 可重试") },
{ btn: "#btnCancel", ok: !terminal && canCreate,
why: terminal ? "终态任务不可取消" : (canCreate ? "" : "仅项目成员(contributor 及以上)可取消") },
{ btn: "#btnMerge", ok: t.status === "completed" && isMaintainer,
why: "仅 completed 可合并" + (isMaintainer ? "" : ",且仅 maintainer 可合并") },
{ btn: "#btnPr", ok: t.status === "merged" && isMaintainer,
why: "仅 merged 可提 PR" + (isMaintainer ? "" : ",且仅 maintainer 可提 PR") }
];
var reasons = [];
rules.forEach(function (r) {
var b = $(r.btn);
b.disabled = !r.ok;
b.title = r.ok ? "" : r.why;
if (!r.ok && r.why) reasons.push(b.textContent.replace(/(.*$/, "") + ":不可用——" + r.why);
});
$("#actionMsg").textContent = reasons.length ? reasons.join(";") : "";
})();
$("#costBox").innerHTML = "";
$("#forkForm").style.display = "none";
$("#forkMsgSpan").textContent = "";
$("#followupMsg").textContent = "";
$("#followupForm").style.display = ["queued", "running"].indexOf(t.status) >= 0 ? "block" : "none";
$("#continueMsgSpan").textContent = "";
$("#continueForm").style.display = (TRACE_TERMINAL.indexOf(t.status) >= 0 && t.status !== "merged") ? "block" : "none";
startTrace(taskId, t.status);
$("#agentBox").innerHTML = "子代理链路加载中…";
loadAgents(taskId);
$("#coordBox").innerHTML = "协调子任务加载中…";
loadCoordChildren(taskId);
loadFollowups(taskId);
loadPeerMessages(taskId);
$("#peerMsg").textContent = "";
} catch (e) { $("#detail").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>"; }
}
async function doAction(kind) {
if (!currentTask) return;
var confirmNeeded = { cancel: "确定取消该任务吗?", merge: "确定把 completed 任务标为 merged 吗?", pr: "确定为该任务提 PR 吗?(会在仓库真实建 PR)" };
if (confirmNeeded[kind] && !confirm(confirmNeeded[kind])) return;
try {
var r = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/" + kind, "POST", {});
$("#actionMsg").innerHTML = "<span class='ok'>" + kind + " 已提交:" + esc(JSON.stringify(r).slice(0, 300)) + "</span>";
loadTasks();
showDetail(currentTask.id);
} catch (e) { $("#actionMsg").innerHTML = "<span class='err'>" + kind + " 失败:" + esc(e.message) + "</span>"; }
}
async function loadAutomations() {
var pid = $("#projectSelect").value;
var box = $("#automationList");
if (!pid) { box.innerHTML = "(先选项目)"; return; }
try {
var d = await api("/projects/" + encodeURIComponent(pid) + "/automations", "GET");
var list = d.automations || [];
if (!list.length) { box.innerHTML = "(暂无自动化)"; return; }
box.innerHTML = list.map(function (a) {
var html = "[" + esc(a.status) + (a.due ? "|到期" : "") + "] " + esc(a.name) +
"|每 " + esc(a.interval_seconds) + " 秒|下次 " + esc(fmt(a.next_run_at));
if (a.status === "active") html += " <button data-auto-cancel='" + esc(a.id) + "'>取消</button>";
return html;
}).join("<br>");
box.querySelectorAll("[data-auto-cancel]").forEach(function (btn) {
btn.onclick = function () { cancelAutomation(btn.getAttribute("data-auto-cancel")); };
});
} catch (e) { box.innerHTML = "<span class='err'>自动化加载失败:" + esc(e.message) + "</span>"; }
}
async function createAutomation() {
var pid = $("#projectSelect").value;
if (!pid) { $("#autoMsg").textContent = "先选项目"; return; }
var name = $("#autoName").value.trim();
var prompt = $("#autoPrompt").value.trim();
if (!name) { $("#autoMsg").textContent = "自动化名称必填"; return; }
if (!prompt) { $("#autoMsg").textContent = "定时任务内容必填"; return; }
var body = { name: name, prompt: prompt };
var iv = $("#autoInterval").value.trim();
if (iv) body.interval_seconds = parseInt(iv, 10);
var repo = $("#autoRepo").value.trim();
if (repo) body.repo_url = repo;
try {
await api("/projects/" + encodeURIComponent(pid) + "/automations", "POST", body);
$("#autoMsg").innerHTML = "<span class='ok'>自动化已创建(登记定义,到期后由执行器认领)</span>";
$("#autoName").value = ""; $("#autoPrompt").value = ""; $("#autoInterval").value = ""; $("#autoRepo").value = "";
loadAutomations();
} catch (e) { $("#autoMsg").innerHTML = "<span class='err'>创建失败:" + esc(e.message) + "</span>"; }
}
async function cancelAutomation(autoId) {
var pid = $("#projectSelect").value;
if (!pid) return;
try {
await api("/projects/" + encodeURIComponent(pid) + "/automations/" + encodeURIComponent(autoId) + "/cancel", "POST", {});
$("#autoMsg").innerHTML = "<span class='ok'>自动化已取消</span>";
loadAutomations();
} catch (e) { $("#autoMsg").innerHTML = "<span class='err'>取消失败:" + esc(e.message) + "</span>"; }
}
async function loadTriage() {
var pid = $("#projectSelect").value;
var box = $("#triageBox");
if (!pid) { box.innerHTML = "(先选项目)"; return; }
try {
var d = await api("/projects/" + encodeURIComponent(pid) + "/triage", "GET");
var html = "";
html += "<b>到期自动化(" + (d.counts.automations_due || 0) + ")</b><br>";
html += (d.automations_due || []).length ? (d.automations_due || []).map(function (a) { return esc(a.name) + "|每 " + esc(a.interval_seconds) + " 秒"; }).join("<br>") : "(无)";
html += "<br><b>失败/中断任务(" + (d.counts.failed_tasks || 0) + ")</b><br>";
html += (d.failed_tasks || []).length ? (d.failed_tasks || []).map(function (t) { return esc(String(t.id).slice(0, 8)) + "|" + esc(t.status) + "|" + esc(String(t.prompt || "").slice(0, 80)); }).join("<br>") : "(无)";
html += "<br><b>待处理后续消息(" + (d.counts.pending_followups || 0) + ")</b><br>";
html += (d.pending_followups || []).length ? (d.pending_followups || []).map(function (f) { return "任务 " + esc(String(f.task_id).slice(0, 8)) + "|" + esc(String(f.body || "").slice(0, 80)); }).join("<br>") : "(无)";
html += "<br><b>最近自动化产出(" + (d.counts.recent_runs || 0) + ")</b><br>";
html += (d.recent_runs || []).length ? (d.recent_runs || []).map(function (r) { return esc(String(r.automation_id).slice(0, 8)) + "|" + esc(r.status) + (r.task_id ? "|任务 " + esc(String(r.task_id).slice(0, 8)) : "") + (r.task_status ? "|" + esc(r.task_status) : ""); }).join("<br>") : "(无)";
box.innerHTML = html;
} catch (e) { box.innerHTML = "<span class='err'>分诊加载失败:" + esc(e.message) + "</span>"; }
}
$("#btnCreateAutomation").onclick = createAutomation;
$("#btnLoadAutomations").onclick = loadAutomations;
$("#btnLoadTriage").onclick = loadTriage;
$("#projectSelect").addEventListener("change", function () { loadAutomations(); loadTriage(); });
async function loadFollowups(taskId) {
var box = $("#followupList");
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/followups", "GET");
var list = d.followups || [];
if (!list.length) { box.innerHTML = "(暂无后续消息)"; return; }
box.innerHTML = list.map(function (f) {
var from = f.sender_task_id ? "来自任务 " + esc(String(f.sender_task_id).slice(0, 8)) + "|" : "";
return "[" + esc(f.mode) + "|" + esc(f.status) + "] " + from + esc(String(f.body || "").slice(0, 120));
}).join("<br>");
} catch (e) { box.innerHTML = "<span class='err'>后续消息加载失败:" + esc(e.message) + "</span>"; }
}
async function postFollowup(mode) {
if (!currentTask) return;
var text = $("#followupText").value.trim();
if (!text) { $("#followupMsg").textContent = "后续消息不能为空"; return; }
try {
await api("/tasks/" + encodeURIComponent(currentTask.id) + "/followups", "POST", { body: text, mode: mode });
$("#followupMsg").innerHTML = "<span class='ok'>已" + (mode === "direct" ? "直接发送" : "排队") + "</span>";
$("#followupText").value = "";
loadFollowups(currentTask.id);
} catch (e) { $("#followupMsg").innerHTML = "<span class='err'>发送失败:" + esc(e.message) + "</span>"; }
}
$("#saveKey").onclick = function () {
var v = $("#key").value.trim();
if (!v) { $("#keyState").textContent = "key 为空,未保存"; return; }
localStorage.setItem(KEY_STORE, v);
$("#key").value = "";
setKeyState();
// 保存后立即重载数据:此前保存完页面还停留在旧的报错上,
// 用户会误以为 key 无效(2026-10-27 实测)。
loadHealth();
loadProjects();
loadWorkers();
if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); }
if (getKey()) loadRelayModels();
loadQuota();
loadGithubRepos();
};
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); };
$("#refreshHealth").onclick = loadHealth;
$("#refreshProjects").onclick = loadProjects;
$("#refreshWorkers").onclick = loadWorkers;
$("#refreshTasks").onclick = loadTasks;
$("#refreshEnvironments").onclick = loadEnvironments;
$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); };
$("#goalMode").onchange = function () { $("#goalHint").style.display = this.checked ? "block" : "none"; };
$("#createProject").onclick = async function () {
var name = $("#newProjectName").value.trim();
if (!name) { $("#projectMsg").textContent = "项目名必填"; return; }
var body = { name: name };
var repo = $("#newProjectRepo").value.trim();
if (repo) body.repo_url = repo;
try {
await api("/projects", "POST", body);
$("#projectMsg").innerHTML = "<span class='ok'>项目已建</span>";
$("#newProjectName").value = ""; $("#newProjectRepo").value = "";
loadProjects();
} catch (e) { $("#projectMsg").innerHTML = "<span class='err'>新建失败:" + esc(e.message) + "</span>"; }
};
$("#createEnvironment").onclick = async function () {
var pid = $("#projectSelect").value;
var name = $("#newEnvName").value.trim();
if (!pid) { $("#envMsg").textContent = "先选项目"; return; }
if (!name) { $("#envMsg").textContent = "环境名必填"; return; }
var body = { project_id: pid, name: name };
var br = $("#newEnvBranch").value.trim();
if (br) body.branch = br;
var ex = $("#newEnvExecutor").value;
if (ex) body.executor = ex;
try {
await api("/environments", "POST", body);
$("#envMsg").innerHTML = "<span class='ok'>环境已建,worker 准备中(creating → ready,失败会在列表里写原因)</span>";
$("#newEnvName").value = ""; $("#newEnvBranch").value = "";
loadEnvironments();
} catch (e) { $("#envMsg").innerHTML = "<span class='err'>新建失败:" + esc(e.message) + "</span>"; }
};
$("#newProjectRepoSelect").onchange = function () {
var v = this.value;
if (v) $("#newProjectRepo").value = v;
};
$("#saveEnvRepo").onclick = async function () {
var pid = $("#projectSelect").value;
var repo = $("#envRepoSelect").value;
if (!pid) { $("#envRepoMsg").textContent = "先选项目"; return; }
if (!repo) { $("#envRepoMsg").textContent = "先选一个仓库"; return; }
try {
await api("/projects/" + encodeURIComponent(pid), "PATCH", { repo_url: repo });
$("#envRepoMsg").innerHTML = "<span class='ok'>仓库已保存,现在可以建环境了</span>";
loadProjects(); loadEnvironments();
} catch (e) { $("#envRepoMsg").innerHTML = "<span class='err'>保存失败:" + esc(e.message) + "</span>"; }
};
function gatherAttachments() {
// 三路输入合并成 [{path, file}]:文件路用文件名,文件夹路用
// webkitRelativePath(含子目录),图片路用文件名;同路径只留
// 第一个(跨路重复以先选为准,服务端还会再校验一次)。
var out = [], seen = {};
function add(list, pathOf) {
for (var i = 0; i < list.length; i++) {
var p = pathOf(list[i]);
if (!p || seen[p]) continue;
seen[p] = true;
out.push({ path: p, file: list[i] });
}
}
add($("#attFiles").files, function (f) { return f.name; });
add($("#attDir").files, function (f) { return f.webkitRelativePath || f.name; });
add($("#attImg").files, function (f) { return f.name; });
return out;
}
async function uploadAttachments(items) {
// 先 POST /uploads 拿 upload_id,建任务时绑定;上传失败直接
// 抛错,任务不建(避免无附件的任务先跑起来)。注意不能复用
// api()(它会固定写 Content-Type: application/json,multipart
// 的 boundary 必须由浏览器自己生成)。
var fd = new FormData();
fd.append("paths", JSON.stringify(items.map(function (x) { return x.path; })));
items.forEach(function (x, i) { fd.append("file" + i, x.file); });
var r = await fetch("/uploads", {
method: "POST",
headers: { "X-API-Key": getKey() },
body: fd
});
var d = await r.json().catch(function () { return {}; });
if (!r.ok) throw new Error((d && d.error) || ("HTTP " + r.status));
return d.upload_id;
}
$("#createTask").onclick = async function () {
var pid = $("#projectSelect").value;
var prompt = $("#prompt").value.trim();
if (!pid) { $("#taskMsg").textContent = "先选项目"; return; }
if (!prompt) { $("#taskMsg").textContent = "prompt 必填"; return; }
var body = { project_id: pid, prompt: prompt };
var envId = $("#envSelect").value;
if (envId) body.environment_id = envId;
var ex = $("#executor").value;
if (ex) body.executor = ex;
var model = $("#modelCustom").value.trim() || $("#modelSelect").value;
if (model) body.model = model;
var eff = $("#effortSelect").value;
if (eff) body.effort = eff;
var ap = $("#approvalSelect").value;
if (ap) {
if (ap === "full" && !confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认提交吗?")) return;
body.approval = ap;
}
if ($("#planMode").checked) {
if (ap === "full") { $("#taskMsg").textContent = "计划模式不能与完全访问同用"; return; }
if ($("#goalMode").checked) { $("#taskMsg").textContent = "计划模式不能与 goal 模式同用"; return; }
body.plan = 1;
}
if ($("#goalMode").checked) {
// 新 goal 语义:prompt 本身就是目标;不发专用 goal 文本、
// 验收命令与轮次(服务端 goal_mode=1 时一律不限轮次)。
body.goal_mode = 1;
}
var coordRaw = $("#coordParts").value.trim();
if (coordRaw) {
try { body.coordinator_parts = JSON.parse(coordRaw); }
catch (e) { $("#taskMsg").textContent = "协调员子任务 JSON 解析失败"; return; }
if (!Array.isArray(body.coordinator_parts)) { $("#taskMsg").textContent = "协调员子任务必须是数组"; return; }
// 协调员与 goal/计划互斥:提交前校验并在表单处写明冲突
// (服务端同样 400,但不要等到服务端才发现)。
if ($("#goalMode").checked || $("#planMode").checked) {
$("#taskMsg").textContent = "协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON";
return;
}
}
var atts = gatherAttachments();
if (atts.length) {
try {
$("#taskMsg").textContent = "上传附件中…";
body.upload_id = await uploadAttachments(atts);
} catch (e) {
$("#taskMsg").innerHTML = "<span class='err'>附件上传失败:" + esc(e.message) + "</span>";
return;
}
}
try {
var t = await api("/tasks", "POST", body);
$("#taskMsg").innerHTML = "<span class='ok'>任务已建:" + esc(shortId(t.id)) + "</span>";
$("#prompt").value = "";
$("#coordParts").value = "";
$("#attFiles").value = ""; $("#attDir").value = ""; $("#attImg").value = "";
loadTasks();
} catch (e) { $("#taskMsg").innerHTML = "<span class='err'>提交失败:" + esc(e.message) + "</span>"; }
};
$("#btnRetry").onclick = function () { doAction("retry"); };
$("#btnCancel").onclick = function () { doAction("cancel"); };
$("#btnMerge").onclick = function () { doAction("merge"); };
$("#btnPr").onclick = function () { doAction("pr"); };
$("#btnFork").onclick = function () {
if (!currentTask) return;
if (TRACE_TERMINAL.indexOf(currentTask.status) < 0) { $("#forkMsgSpan").textContent = "仅终态任务可分支"; return; }
$("#forkForm").style.display = "block";
};
$("#btnForkConfirm").onclick = async function () {
if (!currentTask) return;
var msg = $("#forkMsg").value.trim();
if (!msg) { $("#forkMsgSpan").textContent = "分支消息必填"; return; }
try {
var t = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/fork", "POST", { message: msg });
$("#forkMsgSpan").innerHTML = "<span class='ok'>分支任务已建:" + esc(shortId(t.id)) + "</span>";
$("#forkMsg").value = "";
loadTasks();
} catch (e) { $("#forkMsgSpan").innerHTML = "<span class='err'>分支失败:" + esc(e.message) + "</span>"; }
};
$("#btnContinueConfirm").onclick = async function () {
if (!currentTask) return;
var msg = $("#continueText").value.trim();
if (!msg) { $("#continueMsgSpan").textContent = "本轮消息必填"; return; }
var body = { message: msg };
var model = $("#continueModel").value;
if (model) body.model = model;
var eff = $("#continueEffort").value;
if (eff) body.effort = eff;
var ap = $("#continueApproval").value;
if (ap) {
if (ap === "full" && !confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认继续吗?")) return;
body.approval = ap;
}
if ($("#continuePlan").checked) body.plan = 1;
if ($("#continueGoal").checked) body.goal_mode = 1;
try {
var t = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/continue", "POST", body);
$("#continueMsgSpan").innerHTML = "<span class='ok'>已重新排队,同一会话继续</span>";
$("#continueText").value = "";
showDetail(currentTask.id);
loadTasks();
} catch (e) { $("#continueMsgSpan").innerHTML = "<span class='err'>继续失败:" + esc(e.message) + "</span>"; }
};
async function loadPeerMessages(taskId) {
var box = $("#peerMessageList");
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/messages", "GET");
var list = d.messages || [];
if (!list.length) { box.innerHTML = "(本任务尚未发出任务间消息)"; return; }
box.innerHTML = list.map(function (f) {
return "[发给 " + esc(String(f.task_id).slice(0, 8)) + "|" + esc(f.mode) + "|" + esc(f.status) + "] " + esc(String(f.body || "").slice(0, 120));
}).join("<br>");
} catch (e) { box.innerHTML = "<span class='err'>发出消息加载失败:" + esc(e.message) + "</span>"; }
}
async function postPeerMessage(mode) {
if (!currentTask) return;
var target = $("#peerTarget").value.trim();
var text = $("#peerBody").value.trim();
if (!target) { $("#peerMsg").textContent = "目标任务 id 不能为空"; return; }
if (!text) { $("#peerMsg").textContent = "消息正文不能为空"; return; }
try {
var d = await api("/tasks/" + encodeURIComponent(target) + "/messages", "POST", { from_task_id: currentTask.id, body: text, mode: mode });
$("#peerMsg").innerHTML = "<span class='ok'>已发送(" + esc(d.delivery) + ")</span>";
$("#peerBody").value = "";
loadPeerMessages(currentTask.id);
} catch (e) { $("#peerMsg").innerHTML = "<span class='err'>发送失败:" + esc(e.message) + "</span>"; }
}
$("#btnQueueFollowup").onclick = function () { postFollowup("queued"); };
$("#btnDirectFollowup").onclick = function () { postFollowup("direct"); };
$("#btnQueuePeerMessage").onclick = function () { postPeerMessage("queued"); };
$("#btnDirectPeerMessage").onclick = function () { postPeerMessage("direct"); };
$("#btnCost").onclick = async function () {
if (!currentTask) return;
try {
var c = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/cost", "GET");
$("#costBox").innerHTML = "<pre>" + esc(JSON.stringify(c, null, 2).slice(0, 4000)) + "</pre>";
} catch (e) { $("#costBox").innerHTML = "<span class='err'>查成本失败:" + esc(e.message) + "</span>"; }
};
setKeyState();
loadHealth();
loadProjects();
loadWorkers();
loadQuota();
loadGithubRepos();
// 回访用户 key 已在 localStorage 里:初始化也要加载模型列表,
// 否则下拉永远只有默认项(此前只在点“保存”时调一次,
// 2026-10-27 实测:存过 key 后重开页面模型选不了)。
if (getKey()) loadRelayModels();
setInterval(function () { if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); } loadHealth(); loadQuota(); }, 10000);
</script>
</body>
</html>