Files
codex-app-server/cloud/console.html
T
host 14757f15c4 feat: 子任务默认模型/强度(child_model/child_effort)
主任务可显式声明派生默认,或在 prompt 里写“子任务用 gpt-6-luna max”
由服务端解析填入;marker task-create 与协调员分工未显式写模型/强度时
按 块/分工 > 派生默认 > 父任务模型/强度 继承,fork 复制派生默认,
continue 可改派生默认;console 新建/继续表单与任务详情同步展示。
2026-10-29 05:27:22 +08:00

1732 lines
90 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" id="adminCard">
<h2>管理区 <span class="muted">(仅全局管理员)</span></h2>
<div id="adminGate" class="muted">填 key 后判断权限…</div>
<div id="adminBody" style="display:none">
<div class="bar">
<input id="adminUserId" type="text" placeholder="要查配额的用户 id">
<button id="btnAdminQuota">查该用户配额</button>
</div>
<div class="bar">
<button id="btnAdminSweep">手动触发 sweep(清理陈旧 running 任务)</button>
<button id="btnAdminBackup">手动触发备份</button>
</div>
<div id="adminResult" class="muted"></div>
</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>
<h3>项目成员</h3>
<div class="bar">
<input id="inviteUser" type="text" placeholder="成员 user_id 或用户名">
<select id="inviteRole">
<option value="viewer">viewer(只读)</option>
<option value="contributor" selected>contributor(可建任务)</option>
<option value="maintainer">maintainer(可批合并)</option>
<option value="owner">owner(可管项目)</option>
</select>
<button id="inviteMember">邀请成员</button>
<button id="refreshMembers">刷新成员</button>
</div>
<div id="memberList" class="muted">先选项目</div>
<span id="memberMsg" 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>技能与 MCP</h2>
<div class="muted">反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。</div>
<div class="bar">
<button id="refreshSkills">刷新</button>
<span id="skillMsg" class="muted">先选项目</span>
</div>
<h3>技能</h3>
<div id="skillList" class="muted">先选项目</div>
<h3>应用</h3>
<div id="appList" class="muted">先选项目</div>
<h3>MCP 服务</h3>
<div id="mcpList" class="muted">先选项目</div>
<div class="bar">
<button id="btnReloadMcp">重载配置</button>
<span id="mcpMsg" class="muted"></span>
</div>
<div class="bar">
<input id="mcpOauthName" type="text" placeholder="MCP 服务名">
<button id="btnMcpOauth">发起 OAuth 登录</button>
<span id="mcpOauthMsg" class="muted"></span>
</div>
<div id="mcpOauthBox" class="muted"></div>
</section>
<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 class="bar">
<select id="childModelSelect">
<option value="">子任务模型:跟随本任务模型</option>
</select>
<input id="childModelCustom" type="text" placeholder="子任务模型手动输入(中转列表里没有时填,如 gpt-6-luna;留空=跟随本任务模型)">
<select id="childEffortSelect">
<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>
</div>
<div class="muted">子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。</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>
<select id="taskStatusFilter">
<option value="">状态:全部</option>
<option value="queued">queued</option>
<option value="running">running</option>
<option value="completed">completed</option>
<option value="failed">failed</option>
<option value="interrupted">interrupted</option>
<option value="merged">merged</option>
<option value="merge-failed">merge-failed</option>
</select>
<select id="taskExecutorFilter">
<option value="">执行器:全部</option>
<option value="local">local</option>
<option value="docker">docker</option>
<option value="cloud">cloud</option>
</select>
<input id="taskSearch" type="text" placeholder="搜索任务 id / prompt">
<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>
<input id="continueChildModel" type="text" placeholder="子任务模型:沿用任务原值(填了则改派生默认)">
<select id="continueChildEffort">
<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>
<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 class="bar">
<select id="traceKindFilter">
<option value="">链路类型:全部</option>
<option value="exec">命令</option>
<option value="file">文件</option>
<option value="call">调用</option>
<option value="tool">工具</option>
<option value="message">消息</option>
<option value="reasoning">思考</option>
<option value="plan">计划</option>
<option value="agent-start">子代理开始</option>
<option value="agent-done">子代理结束</option>
</select>
<button id="btnDownloadTrace">下载完整链路</button>
<span id="traceMsg" class="muted"></span>
</div>
<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>
<h3>线程</h3>
<div id="threadBox" class="muted">点任务行查看详情后,这里显示线程元数据</div>
<div class="bar" id="threadActions">
<input id="threadRenameInput" type="text" placeholder="新线程名">
<button id="btnThreadRename">重命名</button>
<button id="btnThreadArchive">归档</button>
<button id="btnThreadUnarchive">取消归档</button>
<button id="btnThreadCompact">压缩</button>
<span id="threadGateNote" class="muted"></span>
<span id="threadMsg" class="muted"></span>
</div>
<div id="threadList" class="muted"></div>
<div class="muted">限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。</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"];
var THREAD_MUTATION_STATUSES = ["completed", "failed", "interrupted"];
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;
var chsel = $("#childModelSelect");
if (chsel) chsel.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>"; }
}
function workerAgeText(w) {
// last_seen 是 epoch 秒;服务端也可能只给距今秒数(旧字段)。
// 返回 {text, age}:age 为 null 时无法判断在线状态。
var now = Date.now() / 1000;
var age = null;
if (typeof w.last_seen === "number") age = now - w.last_seen;
else if (typeof w.last_seen_seconds_ago === "number") age = w.last_seen_seconds_ago;
if (age === null || age < 0) age = null;
if (age === null) return { text: "未知", age: null };
var s = Math.floor(age);
if (s < 60) return { text: s + " 秒前", age: age };
if (s < 3600) return { text: Math.floor(s / 60) + " 分钟前", age: age };
return { text: Math.floor(s / 3600) + " 小时前", age: age };
}
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;
// 在线判定:优先服务端 alive(阈值 120 秒,与服务端
// alive_threshold_seconds 默认值一致),缺失时按 120 秒自算。
var info = workerAgeText(w);
var alive = (w.alive !== undefined && w.alive !== null)
? !!w.alive
: (info.age !== null && info.age <= 120);
var live = alive ? " / 在线" : " / 离线(占用为上次上报值)";
return esc(w.worker_id || w.id || "?") + " / " + esc(w.executor || "") +
" / 占用 " + esc(run) + "/" + esc(cap) + live +
" / 最后心跳 " + esc(info.text);
}).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>";
}
}
var tasksCache = [];
function filteredTasks() {
// 筛选与搜索是纯前端逻辑:10 秒刷新仍拉全量,筛选条件
// 不随刷新丢失(不改服务端 /tasks 查询语义)。
var st = $("#taskStatusFilter") ? $("#taskStatusFilter").value : "";
var ex = $("#taskExecutorFilter") ? $("#taskExecutorFilter").value : "";
var q = $("#taskSearch") ? $("#taskSearch").value.trim().toLowerCase() : "";
return tasksCache.filter(function (t) {
if (st && t.status !== st) return false;
if (ex && (t.executor || "") !== ex) return false;
if (q) {
var hay = ((t.id || "") + " " + (t.prompt || "")).toLowerCase();
if (hay.indexOf(q) < 0) return false;
}
return true;
});
}
function renderTasks() {
var tasks = filteredTasks();
var total = tasksCache.length;
$("#taskCount").textContent = tasks.length + " 个任务(共 " + total + " 个)";
if (!tasks.length) {
$("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>" +
(total ? "没有符合筛选条件的任务" : "该项目暂无任务") + "</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")); };
});
}
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");
tasksCache = d.tasks || [];
renderTasks();
} 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;
}
var lastTraceEntries = [];
var lastTraceNote = "";
var lastTraceDone = "";
function renderTrace(entries, note, doneStatus) {
// 全量条目先存下来供“下载完整链路”用;屏上显示仍截断
// 最近 TRACE_LIMIT 条,再按类型过滤(过滤不影响下载内容)。
lastTraceEntries = entries || [];
lastTraceNote = note || "";
lastTraceDone = doneStatus || "";
var box = $("#traceBox");
var html = "";
if (note) html += "<span class='err'>" + esc(note) + "</span><br>";
var kind = $("#traceKindFilter") ? $("#traceKindFilter").value : "";
var pool = kind ? lastTraceEntries.filter(function (e) { return e.kind === kind; }) : lastTraceEntries;
if (!pool.length) html += kind ? "(该类型暂无链路条目)" : "(暂无链路条目)";
else {
var shown = pool.slice(-TRACE_LIMIT);
if (pool.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + pool.length + " 条)<br>";
else if (kind) html += "(按类型 " + esc(kind) + " 过滤,共 " + pool.length + " 条)<br>";
html += shown.map(traceLine).join("<br>");
}
if (doneStatus) html += "<br><span class='muted'>链路已结束(" + esc(doneStatus) + ")</span>";
box.innerHTML = html;
}
async function downloadTrace() {
// 下载走同一 /trace 端点全量拉取,不用屏上截断后的条目:
// 屏上是最近 1000 条 + 类型过滤,下载必须是完整链路。
if (!currentTask) { $("#traceMsg").textContent = "先选一个任务"; return; }
try {
var d = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/trace", "GET");
var entries = d.entries || [];
var blob = new Blob([JSON.stringify(
{ task_id: currentTask.id, status: d.status, note: d.note || "",
count: entries.length, entries: entries }, null, 2)],
{ type: "application/json" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "trace-" + currentTask.id.slice(0, 8) + ".json";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
$("#traceMsg").innerHTML = "<span class='ok'>已下载完整链路 " + entries.length + " 条</span>";
} catch (e) { $("#traceMsg").innerHTML = "<span class='err'>下载失败:" + esc(e.message) + "</span>"; }
}
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 || "默认") +
((t.child_model || t.child_effort) ? " | 子任务默认 " + esc(t.child_model || "跟随本任务") + " / " + esc(t.child_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 = "";
$("#threadMsg").textContent = "";
$("#threadRenameInput").value = "";
setThreadGate(t.status);
loadThread(taskId);
loadThreads(taskId);
} 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>"; }
}
function memberErr(e) {
var m = e.message || "";
if (m.indexOf("forbidden") >= 0) return "没有权限:只有项目 owner 或全局管理员可以管理成员";
if (m.indexOf("already_member") >= 0) return "该用户已是成员,改角色请用成员行的下拉框";
if (m.indexOf("cannot demote the only owner") >= 0) return "不能把唯一 owner 降为非 owner:请先把另一位成员升为 owner";
if (m.indexOf("user not found") >= 0) return "找不到该用户:请核对 user_id 或用户名是否正确";
if (m.indexOf("member not found") >= 0) return "该用户不是本项目成员";
if (m.indexOf("unknown role") >= 0) return "角色无效:只能是 viewer / contributor / maintainer / owner";
if (m.indexOf("project not found") >= 0) return "项目不存在(可能已被删除)";
return "成员操作失败:" + m;
}
async function loadMembers() {
var pid = $("#projectSelect").value;
var box = $("#memberList");
if (!pid) { box.textContent = "先选项目"; return; }
try {
var d = await api("/projects/" + encodeURIComponent(pid) + "/members", "GET");
var list = d.members || [];
if (!list.length) { box.textContent = "该项目暂无成员"; return; }
box.innerHTML = list.map(function (m) {
var label = esc(m.username || "") + (m.username ? "(" + esc(String(m.user_id).slice(0, 8)) + ")" : esc(String(m.user_id).slice(0, 8)));
var opts = ["viewer", "contributor", "maintainer", "owner"].map(function (r) {
return "<option value='" + r + "'" + (r === m.role ? " selected" : "") + ">" + r + "</option>";
}).join("");
return "<div style='margin:4px 0'>" + label + " | <select data-member-role='" + esc(m.user_id) + "'>" + opts + "</select></div>";
}).join("");
Array.prototype.forEach.call(box.querySelectorAll("[data-member-role]"), function (sel) {
var prev = sel.value;
sel.onchange = async function () {
var uid = sel.getAttribute("data-member-role");
var role = sel.value;
try {
await api("/projects/" + encodeURIComponent(pid) + "/members/" + encodeURIComponent(uid), "PATCH", { role: role });
prev = role;
$("#memberMsg").innerHTML = "<span class='ok'>角色已改为 " + esc(role) + "</span>";
} catch (e) {
sel.value = prev;
$("#memberMsg").innerHTML = "<span class='err'>" + esc(memberErr(e)) + "</span>";
}
};
});
} catch (e) { box.innerHTML = "<span class='err'>成员加载失败:" + esc(memberErr(e)) + "</span>"; }
}
async function inviteMember() {
var pid = $("#projectSelect").value;
if (!pid) { $("#memberMsg").textContent = "先选项目"; return; }
var raw = $("#inviteUser").value.trim();
if (!raw) { $("#memberMsg").textContent = "请填成员 user_id 或用户名"; return; }
// 输入可能是 user_id 也可能是用户名:服务端优先按 user_id
// 查,查不到时前端再按 username 试一次(避免把用户名误当 id 报 404)。
var body = { user_id: raw, role: $("#inviteRole").value };
try {
await api("/projects/" + encodeURIComponent(pid) + "/members", "POST", body);
} catch (e) {
if (e.message.indexOf("user not found") >= 0) {
try {
await api("/projects/" + encodeURIComponent(pid) + "/members", "POST", { username: raw, role: $("#inviteRole").value });
} catch (e2) { $("#memberMsg").innerHTML = "<span class='err'>" + esc(memberErr(e2)) + "</span>"; return; }
} else {
$("#memberMsg").innerHTML = "<span class='err'>" + esc(memberErr(e)) + "</span>";
return;
}
}
$("#memberMsg").innerHTML = "<span class='ok'>已邀请成员</span>";
$("#inviteUser").value = "";
loadMembers();
}
function adminErr(e) {
var m = e.message || "";
if (m.indexOf("forbidden") >= 0) return "无全局管理员权限:服务端返回 403(CODEX_GLOBAL_ADMINS 未包含当前用户),请联系管理员";
if (m.indexOf("user not found") >= 0) return "找不到该用户:请核对用户 id 是否正确";
return "管理操作失败:" + m;
}
async function probeAdmin() {
// 权限探针复用已有 /admin/quotas:服务端先验全局管理员
// (非管理员 403),通过后才校验 user_id 参数(缺参数 400)。
// 故 400 = 是管理员、403 = 不是管理员,不新增任何端点。
var gate = $("#adminGate");
var body = $("#adminBody");
if (!getKey()) { gate.textContent = "未填 key,管理区不可用"; body.style.display = "none"; return; }
try {
var headers = { "X-API-Key": getKey() };
var r = await fetch("/admin/quotas", { headers: headers });
if (r.status === 400) {
gate.innerHTML = "<span class='ok'>已确认为全局管理员,管理操作可用</span>";
body.style.display = "block";
} else if (r.status === 403) {
gate.textContent = "管理区仅全局管理员可用:当前 key 无全局管理员权限(服务端返回 403)";
body.style.display = "none";
} else if (r.status === 401) {
gate.textContent = "key 无效,管理区不可用(服务端返回 401)";
body.style.display = "none";
} else {
gate.textContent = "管理权限判断失败(HTTP " + r.status + "),管理操作暂不可用";
body.style.display = "none";
}
} catch (e) { gate.textContent = "管理权限判断失败:" + e.message; body.style.display = "none"; }
}
function fmtUsd(micros) {
if (micros === null || micros === undefined) return "不限额";
return "$" + (micros / 1000000).toFixed(2);
}
async function adminQuota() {
var uid = $("#adminUserId").value.trim();
if (!uid) { $("#adminResult").textContent = "请填要查配额的用户 id"; return; }
try {
var d = await api("/admin/quotas?user_id=" + encodeURIComponent(uid), "GET");
$("#adminResult").innerHTML = "用户 " + esc(uid.slice(0, 8)) + ":本月用量 " + esc(fmtUsd(d.used_usd_micros)) +
" / 配额 " + esc(fmtUsd(d.quota_usd_micros)) +
(d.remaining_usd_micros !== null && d.remaining_usd_micros !== undefined ? "(剩余 " + esc(fmtUsd(d.remaining_usd_micros)) + ")" : "") +
" | 月份 " + esc(d.month || "");
} catch (e) { $("#adminResult").innerHTML = "<span class='err'>" + esc(adminErr(e)) + "</span>"; }
}
async function adminSweep() {
if (!confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return;
try {
var d = await api("/admin/sweep", "POST", {});
var swept = d.swept || [];
$("#adminResult").innerHTML = "<span class='ok'>sweep 完成,清理 " + swept.length + " 个任务" +
(swept.length ? ":" + esc(swept.join("、")) : "") + "</span>";
} catch (e) { $("#adminResult").innerHTML = "<span class='err'>" + esc(adminErr(e)) + "</span>"; }
}
async function adminBackup() {
try {
var d = await api("/admin/backup", "POST", {});
$("#adminResult").innerHTML = "<span class='ok'>备份完成:</span>backend=" + esc(d.backend) +
" | 路径 " + esc(d.path) + " | 大小 " + esc(d.size_bytes) + " 字节 | 任务 " + esc(d.task_count) + " 行";
} catch (e) { $("#adminResult").innerHTML = "<span class='err'>" + esc(adminErr(e)) + "</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(); loadMembers(); }
if (getKey()) loadRelayModels();
loadQuota();
loadGithubRepos();
probeAdmin();
};
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); probeAdmin(); };
$("#refreshHealth").onclick = loadHealth;
$("#refreshProjects").onclick = loadProjects;
$("#refreshWorkers").onclick = loadWorkers;
$("#refreshTasks").onclick = loadTasks;
$("#refreshEnvironments").onclick = loadEnvironments;
// 合并取舍(P2 rebase 到 97f019d):两侧全留——项目下拉切换时
// 同时刷新任务、环境、成员与项目技能;成员、筛选、链路、管理
// 绑定与技能加载均保留,任一侧都不丢。
$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); loadMembers(); loadProjectSkills(); };
$("#refreshMembers").onclick = loadMembers;
$("#inviteMember").onclick = inviteMember;
$("#taskStatusFilter").onchange = renderTasks;
$("#taskExecutorFilter").onchange = renderTasks;
$("#taskSearch").oninput = renderTasks;
$("#traceKindFilter").onchange = function () { renderTrace(lastTraceEntries, lastTraceNote, lastTraceDone); };
$("#btnDownloadTrace").onclick = downloadTrace;
$("#btnAdminQuota").onclick = adminQuota;
$("#btnAdminSweep").onclick = adminSweep;
$("#btnAdminBackup").onclick = adminBackup;
$("#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 cmodel = $("#childModelCustom").value.trim() || $("#childModelSelect").value;
if (cmodel) body.child_model = cmodel;
var ceff = $("#childEffortSelect").value;
if (ceff) body.child_effort = ceff;
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 cmodel = $("#continueChildModel").value.trim();
if (cmodel) body.child_model = cmodel;
var ceff = $("#continueChildEffort").value;
if (ceff) body.child_effort = ceff;
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>"; }
}
async function loadThread(taskId) {
var box = $("#threadBox");
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/thread", "GET");
var th = d.thread || d;
var turns = th.turns;
if (turns === undefined || turns === null) turns = th.turn_count;
if (turns === undefined || turns === null) turns = th.turns_count;
box.innerHTML =
"id " + esc(th.id || taskId) +
" | 名字 " + esc(th.name || th.thread_name || "") +
" | 归档 " + esc(th.archived === undefined || th.archived === null ? "" : (th.archived ? "是" : "否")) +
" | cwd " + esc(th.cwd || "") +
(turns !== undefined && turns !== null ? " | turn 数 " + esc(turns) : "");
} catch (e) { box.innerHTML = "<span class='err'>线程加载失败:" + esc(e.message) + "</span>"; }
}
async function loadThreads(taskId) {
var box = $("#threadList");
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/threads", "GET");
var list = d.threads || [];
if (!list.length) { box.innerHTML = "暂无其他线程"; return; }
box.innerHTML = list.map(function (t) {
return esc(t.name || t.thread_name || t.id || "") + " [" + esc(t.id || "") + "]" +
(t.archived ? " [已归档]" : "") + (t.cwd ? " " + esc(t.cwd) : "");
}).join("<br>");
} catch (e) { box.innerHTML = "<span class='err'>线程列表加载失败:" + esc(e.message) + "</span>"; }
}
function setThreadGate(status) {
var ok = THREAD_MUTATION_STATUSES.indexOf(status) >= 0;
["btnThreadRename", "btnThreadArchive", "btnThreadUnarchive", "btnThreadCompact"].forEach(function (id) {
var b = $("#" + id);
if (b) b.disabled = !ok;
});
$("#threadGateNote").textContent = ok ? "终态任务,线程操作可用" : "仅终态任务可操作线程,当前状态 " + status + ",按钮已置灰";
}
async function postThreadAction(kind) {
if (!currentTask) return;
if (THREAD_MUTATION_STATUSES.indexOf(currentTask.status) < 0) {
$("#threadMsg").textContent = "仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)";
return;
}
var body = {};
if (kind === "rename") {
var name = $("#threadRenameInput").value.trim();
if (!name) { $("#threadMsg").textContent = "新线程名不能为空"; return; }
body = { name: name };
}
try {
var threadPaths = { rename: "/thread/rename", archive: "/thread/archive", unarchive: "/thread/unarchive", compact: "/thread/compact" };
var r = await api("/tasks/" + encodeURIComponent(currentTask.id) + threadPaths[kind], "POST", body);
$("#threadMsg").innerHTML = "<span class='ok'>" + kind + " 已提交:" + esc(JSON.stringify(r).slice(0, 300)) + "</span>";
loadThread(currentTask.id);
loadThreads(currentTask.id);
} catch (e) { $("#threadMsg").innerHTML = "<span class='err'>" + kind + " 失败:" + esc(e.message) + "</span>"; }
}
async function loadProjectSkills() {
var pid = $("#projectSelect").value;
if (!pid) {
$("#skillList").textContent = "先选项目";
$("#appList").textContent = "先选项目";
$("#mcpList").textContent = "先选项目";
$("#skillMsg").textContent = "先选项目";
return;
}
$("#skillMsg").textContent = "";
try {
var s = await api("/projects/" + encodeURIComponent(pid) + "/skills", "GET");
var skills = s.skills || [];
$("#skillList").innerHTML = skills.length ? skills.map(function (x) {
return esc(x.name || x.id || "") + (x.description ? " - " + esc(x.description) : "") + (x.detail ? " - " + esc(x.detail) : "");
}).join("<br>") : "暂无技能";
} catch (e) { $("#skillList").innerHTML = "<span class='err'>技能加载失败:" + esc(e.message) + "</span>"; }
try {
var a = await api("/projects/" + encodeURIComponent(pid) + "/apps", "GET");
var apps = a.apps || [];
$("#appList").innerHTML = apps.length ? apps.map(function (x) {
return esc(x.name || x.id || "") + (x.description ? " - " + esc(x.description) : "");
}).join("<br>") : "暂无应用";
} catch (e) { $("#appList").innerHTML = "<span class='err'>应用加载失败:" + esc(e.message) + "</span>"; }
try {
var m = await api("/projects/" + encodeURIComponent(pid) + "/mcp-servers", "GET");
var servers = m.servers || m.mcp_servers || [];
$("#mcpList").innerHTML = servers.length ? servers.map(function (x) {
var det = x.detail || x.details || x.state || "";
return esc(x.name || x.id || "") + " | 状态 " + esc(x.status || "") + (det ? " | " + esc(det) : "");
}).join("<br>") : "暂无 MCP 服务";
} catch (e) { $("#mcpList").innerHTML = "<span class='err'>MCP 服务加载失败:" + esc(e.message) + "</span>"; }
}
$("#btnQueueFollowup").onclick = function () { postFollowup("queued"); };
$("#btnDirectFollowup").onclick = function () { postFollowup("direct"); };
$("#btnQueuePeerMessage").onclick = function () { postPeerMessage("queued"); };
$("#btnDirectPeerMessage").onclick = function () { postPeerMessage("direct"); };
$("#btnThreadRename").onclick = function () { postThreadAction("rename"); };
$("#btnThreadArchive").onclick = function () { postThreadAction("archive"); };
$("#btnThreadUnarchive").onclick = function () { postThreadAction("unarchive"); };
$("#btnThreadCompact").onclick = function () { postThreadAction("compact"); };
$("#refreshSkills").onclick = loadProjectSkills;
$("#btnReloadMcp").onclick = async function () {
var pid = $("#projectSelect").value;
if (!pid) { $("#mcpMsg").textContent = "先选项目"; return; }
try {
await api("/projects/" + encodeURIComponent(pid) + "/mcp-servers/reload", "POST", {});
$("#mcpMsg").innerHTML = "<span class='ok'>配置已重载</span>";
loadProjectSkills();
} catch (e) { $("#mcpMsg").innerHTML = "<span class='err'>重载失败:" + esc(e.message) + "</span>"; }
};
$("#btnMcpOauth").onclick = async function () {
var pid = $("#projectSelect").value;
var name = $("#mcpOauthName").value.trim();
if (!pid) { $("#mcpOauthMsg").textContent = "先选项目"; return; }
if (!name) { $("#mcpOauthMsg").textContent = "服务名不能为空"; return; }
try {
var d = await api("/projects/" + encodeURIComponent(pid) + "/mcp-servers/" + encodeURIComponent(name) + "/oauth-login", "POST", {});
var url = d.url || d.auth_url || d.login_url || d.authorization_url || "";
var steps = d.steps || d.instructions || "";
var html = "登录尚未完成:请在浏览器打开下面的链接或按步骤完成,完成后回到本页刷新 MCP 状态。<br>";
if (url) html += "<a href='" + esc(url) + "' target='_blank' rel='noopener'>" + esc(url) + "</a><br>";
if (steps) html += esc(Array.isArray(steps) ? steps.join("\n") : String(steps)) + "<br>";
html += "<pre>" + esc(JSON.stringify(d, null, 2).slice(0, 4000)) + "</pre>";
$("#mcpOauthBox").innerHTML = html;
$("#mcpOauthMsg").innerHTML = "<span class='ok'>已返回登录信息,见下方</span>";
} catch (e) { $("#mcpOauthMsg").innerHTML = "<span class='err'>发起失败:" + esc(e.message) + "</span>"; }
};
$("#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();
probeAdmin();
setInterval(function () { if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); } loadHealth(); loadQuota(); }, 10000);
</script>
</body>
</html>