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:
1 parent
154c18e25d
commit
ef532a38c2
7 files changed
+2128
-2
No files matched your search
+168
-1
@@ -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, "&").replace(/</g, "<")
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user