Files
codex-app-server/cloud/console.html
T
openqjandhost fc016fd459 style: 命令台 GUI 美化 (#62)
只改样式与布局骨架,不动 JS 逻辑与接口:
- 卡片化分区(API Key / 健康 / 项目+Workers 两栏 / 新建任务 / 任务列表 / 任务详情),柔和底色 + 圆角 + 细阴影
- 按钮分主次:保存 key 与提交任务为主按钮(蓝),取消为红色弱化按钮;输入框获焦有蓝圈
- 任务状态改圆点 + 彩色文字(running 蓝、completed 绿、failed 红、merged 紫、interrupted 橙),行悬停高亮
- 表格去网格线、表头浅灰,详情/链路区改浅灰圆角框;窄屏单栏自适应
- 验证:HTML 标签平衡、全部 41 个 JS 用 id 齐全、接口标记齐全、零外部引用、JS 经 node --check、test_console 4/4

Co-authored-by: host <host@test.local>
2026-10-27 02:45:08 +08:00

538 lines
24 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>
</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="新项目名">
<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>新建任务</h2>
<div class="bar">
<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>
</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="goalFields" style="display:none">
<input id="goal" type="text" placeholder="goal(缺省用 prompt)" style="width:100%; box-sizing:border-box; margin:6px 0;">
<input id="verifyCommand" type="text" placeholder="verify_command 验收命令(goal 模式必填项按服务端规则)" style="width:100%; box-sizing:border-box; margin:6px 0;">
<label>轮次 <input id="goalRounds" type="number" value="3" min="1" style="width:60px;"></label>
</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>prompt</th><th>状态</th><th>executor</th><th>主机</th><th>PR</th><th>更新</th></tr></thead>
<tbody id="taskRows"><tr><td colspan="7" 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="btnCost">查成本</button>
<span id="actionMsg" class="muted"></span>
</div>
<pre id="detailBody" style="display:none"></pre>
<h3>实时链路</h3>
<div id="traceBox" class="muted">点任务行查看详情后,这里显示过程链路</div>
<div id="costBox"></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: "计划" };
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 += "(触发限流,稍后再试)";
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 || [];
sel.innerHTML = "<option value=''>模型:默认(跟随配置)</option>" +
models.map(function (m) {
return "<option value='" + esc(m) + "'>" + esc(m) + "</option>";
}).join("");
$("#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 loadProjects() {
try {
var d = await api("/projects", "GET");
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 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) {
return esc(w.worker_id || w.id || "?") + " / " + esc(w.executor || "") +
" / capacity " + esc(w.capacity || "") +
" / last_seen " + esc(w.last_seen || w.last_seen_seconds_ago || "");
}).join("<br>");
} catch (e) { $("#workers").textContent = "加载失败:" + e.message; }
}
async function loadTasks() {
var pid = $("#projectSelect").value;
if (!pid) { $("#taskRows").innerHTML = "<tr><td colspan='7' 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='7' 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>" + 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='7' class='err'>加载失败:" + esc(e.message) + "</td></tr>"; }
}
function stopTrace() {
if (traceAbort) { try { traceAbort.abort(); } catch (e) {} traceAbort = 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 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;
}
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, "");
} 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, "");
} 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 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.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";
$("#actionMsg").textContent = "";
$("#costBox").innerHTML = "";
startTrace(taskId, t.status);
} 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>"; }
}
$("#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();
if (getKey()) loadRelayModels();
};
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); };
$("#refreshHealth").onclick = loadHealth;
$("#refreshProjects").onclick = loadProjects;
$("#refreshWorkers").onclick = loadWorkers;
$("#refreshTasks").onclick = loadTasks;
$("#projectSelect").onchange = loadTasks;
$("#goalMode").onchange = function () { $("#goalFields").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>"; }
};
$("#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 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 ($("#goalMode").checked) {
body.goal = $("#goal").value.trim() || prompt;
body.verify_command = $("#verifyCommand").value.trim();
body.goal_max_rounds = parseInt($("#goalRounds").value, 10) || 3;
}
try {
var t = await api("/tasks", "POST", body);
$("#taskMsg").innerHTML = "<span class='ok'>任务已建:" + esc(shortId(t.id)) + "</span>";
$("#prompt").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"); };
$("#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();
setInterval(function () { if ($("#projectSelect").value) loadTasks(); loadHealth(); }, 10000);
</script>
</body>
</html>