feat: 线程操作与 skills/MCP 管理面暴露(host CLI + 服务端 API + 控制台)

线程:host.py 新增 thread-list/read/rename/archive/unarchive/compact 子命令(纯 JSON stdout 契约,read/rename/unarchive/compact 先 resume);服务端按任务定 scope(thread/list 的 cwd 过滤解析,Docker/cloud/异机 409,改操作仅终态,审计 thread_*);控制台线程区(元数据、列表、重命名、归档、取消归档、压缩,终态门控)。

skills/MCP:host.py 新增 skills-list/apps-list/mcp-servers-list/mcp-servers-reload/mcp-server-oauth-login 子命令;服务端按项目 scope(读 read,改 manage_project,审计 mcp_*);控制台技能与 MCP 区(列表、重载、OAuth 发起展示,绝不谎称登录完成)。

测试:host 84、cloud 687(4 跳过)、deploy 19 全过。诚实边界:Docker 任务线程在容器内故 409;无仓库任务推不出 cwd 时 404;OAuth 只做到发起透传,完成态靠协议通知;merged/merge-failed 改操作 409(比控制台门控窄,服务端报错原文展示)。
This commit is contained in:
host committed 2026-10-27 16:37:28 +00:00
1 parent 154c18e25d
commit ef532a38c2
7 files changed
+2128 -2

No files matched your search

+168 -1
View File
@@ -150,6 +150,31 @@
</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">
@@ -379,6 +404,19 @@
<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>
@@ -390,6 +428,7 @@ 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;")
@@ -1018,6 +1057,11 @@ async function showDetail(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>"; }
}
@@ -1279,7 +1323,10 @@ $("#refreshProjects").onclick = loadProjects;
$("#refreshWorkers").onclick = loadWorkers;
$("#refreshTasks").onclick = loadTasks;
$("#refreshEnvironments").onclick = loadEnvironments;
$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); loadMembers(); };
// 合并取舍(P2 rebase 到 97f019d):两侧全留——项目下拉切换时
// 同时刷新任务、环境、成员与项目技能;成员、筛选、链路、管理
// 绑定与技能加载均保留,任一侧都不丢。
$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); loadMembers(); loadProjectSkills(); };
$("#refreshMembers").onclick = loadMembers;
$("#inviteMember").onclick = inviteMember;
$("#taskStatusFilter").onchange = renderTasks;
@@ -1500,10 +1547,130 @@ async function postPeerMessage(mode) {
} 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 {