1154 lines
58 KiB
HTML
1154 lines
58 KiB
HTML
<!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="">executor:任意 worker</option>
|
||
<option value="local">local</option>
|
||
<option value="docker">docker</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>
|
||
<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="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>
|
||
<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, "&").replace(/</g, "<")
|
||
.replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
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) 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);
|
||
if (d.quota_usd_micros === null || d.quota_usd_micros === undefined) {
|
||
el.textContent = "本月用量 $" + used + "(不限额)";
|
||
} else {
|
||
var q = (d.quota_usd_micros / 1000000).toFixed(2);
|
||
var rem = (d.remaining_usd_micros / 1000000).toFixed(2);
|
||
el.textContent = "本月用量 $" + used + " / $" + q + "(剩余 $" + rem + ")";
|
||
}
|
||
} 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");
|
||
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.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 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);
|
||
});
|
||
$("#detail").innerHTML =
|
||
"状态 <b class='st-" + esc(t.status) + "'>" + esc(t.status) + "</b> | " +
|
||
"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.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";
|
||
$("#actionMsg").textContent = "";
|
||
$("#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);
|
||
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 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 = "";
|
||
$("#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>
|