console: React 生产级重写(左列表 + 中对话 + 右详情)
旧单页 console.html 换成 Vite + React + TypeScript 单文件构建产物 (309525 字节,sha 90345ec8…,重建字节一致),源码在 cloud/console-react/,改页面只改 src 再 npm run build:console, 产物不手改。布局:左侧列表(模块导航 + 任务列表)、中间标准对话 (实时链路时间线 + 继续会话聊天框,共用 Chat 组件)、右侧详情 (元数据、操作、评审、审批、协调子任务、子代理、线程)。 API 与后端语义未改,key 只存浏览器 localStorage。 6 个 console 标记单测随实现改名更新(意图不变:壳确实调了 这些接口、turn 信息仍在详情里、旧 goal 输入仍不存在); P1 标记单测未动且通过。
This commit is contained in:
1 parent
3f4e849a93
commit
ebc5624893
57 files changed
+4789
-1746
No files matched your search
@@ -0,0 +1,24 @@
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
|
||||
export default function AdminSection() {
|
||||
const ctx = useConsole();
|
||||
return (
|
||||
<Card id="adminCard" title={<>管理区 <span className="muted">(仅全局管理员)</span></>}>
|
||||
<div className="muted">{ctx.adminGate}</div>
|
||||
{ctx.adminBody ? (
|
||||
<>
|
||||
<Bar>
|
||||
<input type="text" placeholder="要查配额的用户 id" value={ctx.adminUserId} onChange={(e) => ctx.setAdminUserId(e.target.value)} />
|
||||
<button onClick={ctx.adminQuota}>查该用户配额</button>
|
||||
</Bar>
|
||||
<Bar>
|
||||
<button onClick={ctx.adminSweep}>手动触发 sweep(清理陈旧 running 任务)</button>
|
||||
<button onClick={ctx.adminBackup}>手动触发备份</button>
|
||||
</Bar>
|
||||
<div className="muted">{ctx.adminResult}</div>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
|
||||
export default function ApiKeySection() {
|
||||
const ctx = useConsole();
|
||||
const [input, setInput] = useState("");
|
||||
return (
|
||||
<Card id="nav-key" title="API Key">
|
||||
<Bar>
|
||||
<input type="password" placeholder="粘贴你的 X-API-Key(与调 API 用的同一个)" autoComplete="off" value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
<button className="primary" onClick={() => { ctx.saveKeyInput(input); setInput(""); }}>保存到本浏览器</button>
|
||||
<button onClick={ctx.clearKeyInput}>清除</button>
|
||||
<span className="muted">{ctx.keyState}</span>
|
||||
</Bar>
|
||||
<div><span className="muted">{ctx.quotaText}</span></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AutomationPanel } from "./detail/ProjectToolsPanels";
|
||||
import { Card } from "../components/ui";
|
||||
|
||||
export default function AutomationsSection() {
|
||||
return (
|
||||
<Card id="nav-automations" title="自动化">
|
||||
<AutomationPanel />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useConsole } from "../context";
|
||||
import TracePanel from "./detail/TracePanel";
|
||||
import ContinuePanel from "./detail/ContinuePanel";
|
||||
import DispatchSection from "./DispatchSection";
|
||||
|
||||
// Centre column for the task view: the conversation is the protagonist.
|
||||
// Dispatch is kept here as the way a new conversation starts; it is not
|
||||
// a stack of work cards. Trace timeline is above, continue input below.
|
||||
export default function ConversationSection() {
|
||||
const ctx = useConsole();
|
||||
if (!ctx.currentTask) {
|
||||
return (
|
||||
<div className="conversation">
|
||||
<DispatchSection />
|
||||
<div className="muted">左侧点一条任务,对话就切到该任务;也可以先在上面派发一个新任务。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="conversation">
|
||||
<TracePanel />
|
||||
<h3>继续会话</h3>
|
||||
<ContinuePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
import { gatherAttachments } from "../api/client";
|
||||
import { EFFORTS } from "../api/types";
|
||||
|
||||
export default function DispatchSection() {
|
||||
const ctx = useConsole();
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [coordParts, setCoordParts] = useState("");
|
||||
const [goalMode, setGoalMode] = useState(false);
|
||||
const [planMode, setPlanMode] = useState(false);
|
||||
const [executor, setExecutor] = useState("docker");
|
||||
const [model, setModel] = useState("");
|
||||
const [modelCustom, setModelCustom] = useState("");
|
||||
const [effort, setEffort] = useState("");
|
||||
const [approval, setApproval] = useState("");
|
||||
const [childModel, setChildModel] = useState("");
|
||||
const [childModelCustom, setChildModelCustom] = useState("");
|
||||
const [childEffort, setChildEffort] = useState("");
|
||||
const filesRef = useRef<HTMLInputElement>(null);
|
||||
const dirRef = useRef<HTMLInputElement>(null);
|
||||
const imgRef = useRef<HTMLInputElement>(null);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
async function submit() {
|
||||
setMsg("");
|
||||
if (!ctx.projectId) { setMsg("先选项目"); return; }
|
||||
if (!prompt.trim()) { setMsg("prompt 必填"); return; }
|
||||
const body: any = { project_id: ctx.projectId, prompt: prompt.trim() };
|
||||
if (ctx.envSelect) body.environment_id = ctx.envSelect;
|
||||
if (executor) body.executor = executor;
|
||||
const m = modelCustom.trim() || model;
|
||||
if (m) body.model = m;
|
||||
if (effort) body.effort = effort;
|
||||
const cm = childModelCustom.trim() || childModel;
|
||||
if (cm) body.child_model = cm;
|
||||
if (childEffort) body.child_effort = childEffort;
|
||||
if (approval) {
|
||||
if (approval === "full" && !window.confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认提交吗?")) return;
|
||||
body.approval = approval;
|
||||
}
|
||||
if (planMode) {
|
||||
if (approval === "full") { setMsg("计划模式不能与完全访问同用"); return; }
|
||||
if (goalMode) { setMsg("计划模式不能与 goal 模式同用"); return; }
|
||||
body.plan = 1;
|
||||
}
|
||||
if (goalMode) body.goal_mode = 1;
|
||||
const coordRaw = coordParts.trim();
|
||||
if (coordRaw) {
|
||||
try { body.coordinator_parts = JSON.parse(coordRaw); } catch { setMsg("协调员子任务 JSON 解析失败"); return; }
|
||||
if (!Array.isArray(body.coordinator_parts)) { setMsg("协调员子任务必须是数组"); return; }
|
||||
if (goalMode || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; }
|
||||
}
|
||||
const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null);
|
||||
try {
|
||||
const result = await ctx.dispatch(body, atts);
|
||||
setMsg(result);
|
||||
setPrompt(""); setCoordParts("");
|
||||
if (filesRef.current) filesRef.current.value = "";
|
||||
if (dirRef.current) dirRef.current.value = "";
|
||||
if (imgRef.current) imgRef.current.value = "";
|
||||
} catch (e: any) { setMsg("提交失败:" + (e?.message || String(e))); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Card id="nav-newtask" className="composer" title="新建任务">
|
||||
<Bar>
|
||||
<select aria-label="环境" value={ctx.envSelect} onChange={(e) => ctx.setEnvSelect(e.target.value)}>
|
||||
<option value="">环境:不指定(走项目仓库)</option>
|
||||
{ctx.envs.map((e) => <option key={e.id} value={e.id} disabled={e.status !== "ready"}>{e.name}({e.status})</option>)}
|
||||
</select>
|
||||
<select aria-label="执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
|
||||
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
|
||||
</select>
|
||||
<label><input type="checkbox" checked={goalMode} onChange={(e) => setGoalMode(e.target.checked)} /> goal 模式</label>
|
||||
<label><input type="checkbox" checked={planMode} onChange={(e) => setPlanMode(e.target.checked)} /> 计划模式(只读)</label>
|
||||
</Bar>
|
||||
<textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
|
||||
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
|
||||
<Bar>
|
||||
<select aria-label="模型" value={model} onChange={(e) => setModel(e.target.value)}>
|
||||
<option value="">模型:默认(跟随配置)</option>
|
||||
{ctx.models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
{ctx.modelCustomVisible ? <input type="text" placeholder="手动输入模型(中转列表取不到时用)" value={modelCustom} onChange={(e) => setModelCustom(e.target.value)} /> : null}
|
||||
<select aria-label="推理强度" value={effort} onChange={(e) => setEffort(e.target.value)}>
|
||||
<option value="">推理强度:默认(跟随配置)</option>
|
||||
{EFFORTS.map((x) => <option key={x} value={x}>{x}</option>)}
|
||||
</select>
|
||||
<select aria-label="权限" value={approval} onChange={(e) => setApproval(e.target.value)}>
|
||||
<option value="">权限:默认(跟随配置)</option><option value="ask">请求批准</option><option value="auto">帮我批准</option><option value="full">完全访问</option>
|
||||
</select>
|
||||
</Bar>
|
||||
<Bar>
|
||||
<select aria-label="子任务模型" value={childModel} onChange={(e) => setChildModel(e.target.value)}>
|
||||
<option value="">子任务模型:跟随本任务模型</option>
|
||||
{ctx.models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<input type="text" placeholder="子任务模型手动输入(中转列表里没有时填,如 gpt-6-luna;留空=跟随本任务模型)" value={childModelCustom} onChange={(e) => setChildModelCustom(e.target.value)} />
|
||||
<select aria-label="子任务强度" value={childEffort} onChange={(e) => setChildEffort(e.target.value)}>
|
||||
<option value="">子任务强度:跟随本任务强度</option>
|
||||
{EFFORTS.map((x) => <option key={x} value={x}>{x}</option>)}
|
||||
</select>
|
||||
</Bar>
|
||||
<div className="muted">子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。</div>
|
||||
{goalMode ? <div className="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div> : null}
|
||||
<Bar>
|
||||
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
|
||||
<input ref={filesRef} type="file" multiple title="文件" />
|
||||
<input ref={dirRef} type="file" multiple title="文件夹" {...({ webkitdirectory: "" } as any)} />
|
||||
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" />
|
||||
</Bar>
|
||||
<Bar>
|
||||
<button className="seal" onClick={submit}>提交任务</button>
|
||||
<span className="muted">{msg || ctx.dispatchMsg}</span>
|
||||
</Bar>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
|
||||
// Right-panel context for the Environments view: repo binding + counts.
|
||||
// Previously embedded in the environment card; the centre now stays the
|
||||
// environment list/create.
|
||||
export default function EnvContextPanel() {
|
||||
const ctx = useConsole();
|
||||
const [repoSelect, setRepoSelect] = useState("");
|
||||
return (
|
||||
<Card title="仓库与环境上下文">
|
||||
<div className="muted">{ctx.envRepoLine || "先选项目"}</div>
|
||||
<div className="muted">{ctx.envCount}</div>
|
||||
{ctx.envRepoBar ? (
|
||||
<Bar>
|
||||
<select aria-label="补仓库" value={repoSelect} onChange={(e) => setRepoSelect(e.target.value)}>
|
||||
<option value="">(从 GitHub App 安装仓库里选)</option>
|
||||
{ctx.githubRepos.map((r) => <option key={r.clone_url} value={r.clone_url}>{r.repo_full_name}</option>)}
|
||||
</select>
|
||||
<button onClick={() => void ctx.saveEnvRepo(repoSelect)}>保存仓库</button>
|
||||
</Bar>
|
||||
) : null}
|
||||
<span className="muted">{ctx.envRepoMsg}</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar, DataListRaw } from "../components/ui";
|
||||
import { shortId } from "../api/client";
|
||||
|
||||
export default function EnvSection() {
|
||||
const ctx = useConsole();
|
||||
const [name, setName] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [executor, setExecutor] = useState("");
|
||||
return (
|
||||
<Card id="nav-env" title={<>环境 <span className="muted">(选、建、管:当前项目的基准检出)</span></>}>
|
||||
<Bar>
|
||||
<button onClick={ctx.refreshEnvs}>刷新</button>
|
||||
<span className="muted">{ctx.envCount}</span>
|
||||
</Bar>
|
||||
{ctx.envs.length === 0 ? <div className="muted">先选项目</div> : (
|
||||
<DataListRaw>
|
||||
{ctx.envs.map((e) => (
|
||||
<li key={e.id}>
|
||||
<span className="row-main">
|
||||
{e.name}({shortId(e.id)}) / 分支 {e.branch || "默认分支"} / 执行器 {e.executor || "不限"} / 状态 {e.status}
|
||||
{e.status === "failed" && e.error_text ? " / 失败原因:" + e.error_text : ""}
|
||||
</span>
|
||||
{e.status === "archived" ? null : <button onClick={() => void ctx.archiveEnv(e.id)}>归档</button>}
|
||||
</li>
|
||||
))}
|
||||
</DataListRaw>
|
||||
)}
|
||||
<Bar>
|
||||
<input type="text" placeholder="新环境名" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input type="text" placeholder="分支(可选,空=默认分支)" value={branch} onChange={(e) => setBranch(e.target.value)} />
|
||||
<select aria-label="新环境执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
|
||||
<option value="">执行器:不限</option><option value="local">local</option><option value="docker">docker</option>
|
||||
</select>
|
||||
<button onClick={async () => { await ctx.createEnv(name, branch, executor); setName(""); setBranch(""); }}>新建环境</button>
|
||||
</Bar>
|
||||
<span className="muted">{ctx.envMsg}</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
|
||||
// Right-panel context for Workers / Admin / API Key views.
|
||||
export default function HealthPanel() {
|
||||
const ctx = useConsole();
|
||||
return (
|
||||
<Card title="健康与配额">
|
||||
<Bar>
|
||||
<span className="muted">{ctx.healthText}</span>
|
||||
<button onClick={ctx.refreshHealth}>刷新</button>
|
||||
</Bar>
|
||||
<div><span className="muted">{ctx.quotaText}</span></div>
|
||||
<div className="muted">{ctx.workersSummary}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar, DataListRaw } from "../components/ui";
|
||||
import { ROLES } from "../api/types";
|
||||
|
||||
// Right-panel context for the Projects view. Previously embedded in the
|
||||
// projects card; moved here so the centre stays the project list/create
|
||||
// and the right stays the people for the selected project.
|
||||
export default function MembersPanel() {
|
||||
const ctx = useConsole();
|
||||
const [inviteUser, setInviteUser] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState("contributor");
|
||||
const proj = ctx.projects.find((p) => p.id === ctx.projectId);
|
||||
return (
|
||||
<Card title="项目成员">
|
||||
{proj ? <div className="muted">{proj.name} | {proj.id} | 仓库 {proj.repo_url || "未绑定"} | 我的角色 {proj.my_role || "未知"}</div> : <div className="muted">先选项目</div>}
|
||||
<Bar>
|
||||
<input type="text" placeholder="成员 user_id 或用户名" value={inviteUser} onChange={(e) => setInviteUser(e.target.value)} />
|
||||
<select aria-label="邀请角色" value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
|
||||
<option value="viewer">viewer(只读)</option>
|
||||
<option value="contributor">contributor(可建任务)</option>
|
||||
<option value="maintainer">maintainer(可批合并)</option>
|
||||
<option value="owner">owner(可管项目)</option>
|
||||
</select>
|
||||
<button onClick={async () => { await ctx.inviteMember(inviteUser, inviteRole); setInviteUser(""); }}>邀请成员</button>
|
||||
<button onClick={ctx.refreshMembers}>刷新成员</button>
|
||||
</Bar>
|
||||
{ctx.memberError ? <div className="muted">{ctx.memberError}</div>
|
||||
: ctx.members.length === 0 ? <div className="muted">先选项目</div>
|
||||
: (
|
||||
<DataListRaw>
|
||||
{ctx.members.map((m) => {
|
||||
const label = (m.username || "") + (m.username ? "(" + String(m.user_id).slice(0, 8) + ")" : String(m.user_id).slice(0, 8));
|
||||
return (
|
||||
<li key={m.user_id}>
|
||||
<span className="row-main">{label}</span>
|
||||
<select aria-label="成员角色" value={m.role || "viewer"} onChange={(e) => void ctx.changeMemberRole(m.user_id, e.target.value)}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</DataListRaw>
|
||||
)}
|
||||
<span className="muted">{ctx.memberMsg}</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
import { shortId } from "../api/client";
|
||||
|
||||
export default function ProjectsSection() {
|
||||
const ctx = useConsole();
|
||||
const [name, setName] = useState("");
|
||||
const [repoSelect, setRepoSelect] = useState("");
|
||||
const [repo, setRepo] = useState("");
|
||||
return (
|
||||
<Card id="nav-projects" title="项目">
|
||||
<Bar>
|
||||
<select aria-label="项目" value={ctx.projectId} onChange={(e) => ctx.selectProject(e.target.value)}>
|
||||
{ctx.projects.length === 0 ? <option value="">(先加载项目)</option> : null}
|
||||
{ctx.projects.map((p) => <option key={p.id} value={p.id}>{p.name} ({shortId(p.id)})</option>)}
|
||||
</select>
|
||||
<button onClick={ctx.refreshProjects}>刷新</button>
|
||||
</Bar>
|
||||
<Bar>
|
||||
<input type="text" placeholder="新项目名" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<select aria-label="从 GitHub 仓库选" value={repoSelect} onChange={(e) => { setRepoSelect(e.target.value); if (e.target.value) setRepo(e.target.value); }}>
|
||||
<option value="">(从 GitHub App 安装仓库里选,或手动输入)</option>
|
||||
{ctx.githubRepos.map((r) => <option key={r.clone_url} value={r.clone_url}>{r.repo_full_name}</option>)}
|
||||
</select>
|
||||
<input type="text" placeholder="repo_url(可选)" value={repo} onChange={(e) => setRepo(e.target.value)} />
|
||||
<button onClick={async () => { await ctx.createProject(name, repo); setName(""); setRepo(""); setRepoSelect(""); }}>新建项目</button>
|
||||
</Bar>
|
||||
<span className="muted">{ctx.projectMsg}</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar, DataList } from "../components/ui";
|
||||
|
||||
export default function SkillsSection() {
|
||||
const ctx = useConsole();
|
||||
return (
|
||||
<Card id="nav-skills" title="技能与 MCP">
|
||||
<div className="muted">反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。</div>
|
||||
<Bar>
|
||||
<button onClick={ctx.refreshSkills}>刷新</button>
|
||||
<span className="muted">{ctx.skillMsg}</span>
|
||||
</Bar>
|
||||
<h3>技能</h3>
|
||||
{ctx.skillError ? <div className="muted">{ctx.skillError}</div>
|
||||
: <DataList empty="暂无技能" items={ctx.skills.map((x, i) => <span key={i} className="row-main">{x.name || x.id || ""}{x.description ? " - " + x.description : ""}{x.detail ? " - " + x.detail : ""}</span>)} />}
|
||||
<h3>应用</h3>
|
||||
<DataList empty="暂无应用" items={ctx.apps.map((x, i) => <span key={i} className="row-main">{x.name || x.id || ""}{x.description ? " - " + x.description : ""}</span>)} />
|
||||
<h3>MCP 服务</h3>
|
||||
<DataList empty="暂无 MCP 服务" items={ctx.mcpServers.map((x, i) => {
|
||||
const det = x.detail || x.details || x.state || "";
|
||||
return <span key={i} className="row-main">{x.name || x.id || ""} | 状态 {x.status || ""}{det ? " | " + det : ""}</span>;
|
||||
})} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
|
||||
// Right-panel context for the Skills view: MCP reload + OAuth initiate.
|
||||
// Previously embedded in the skills card; the centre now stays the lists.
|
||||
export default function SkillsToolsPanel() {
|
||||
const ctx = useConsole();
|
||||
const [oauthName, setOauthName] = useState("");
|
||||
return (
|
||||
<Card title="MCP 操作">
|
||||
<Bar>
|
||||
<button onClick={ctx.reloadMcp}>重载配置</button>
|
||||
<span className="muted">{ctx.mcpMsg}</span>
|
||||
</Bar>
|
||||
<Bar>
|
||||
<input type="text" placeholder="MCP 服务名" value={oauthName} onChange={(e) => setOauthName(e.target.value)} />
|
||||
<button onClick={() => void ctx.mcpOauth(oauthName)}>发起 OAuth 登录</button>
|
||||
<span className="muted">{ctx.mcpOauthMsg}</span>
|
||||
</Bar>
|
||||
{ctx.mcpOauthBox ? <pre>{ctx.mcpOauthBox}</pre> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useConsole } from "../context";
|
||||
import { Card } from "../components/ui";
|
||||
import { shortId, fmt } from "../api/client";
|
||||
import { STATUS_EXPLAIN } from "../api/types";
|
||||
import DetailActions from "./detail/DetailActions";
|
||||
import ReviewPanel from "./detail/ReviewPanel";
|
||||
import { CoordPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
|
||||
|
||||
// Right detail column for the task view. Everything structured lives
|
||||
// here — never inside a chat bubble: metadata, actions, reviews,
|
||||
// approvals, coordinator children, sub-agents, threads, cost.
|
||||
export default function TaskDetailPanel() {
|
||||
const ctx = useConsole();
|
||||
const t = ctx.currentTask;
|
||||
if (!t) return <Card id="nav-detail" title="任务详情"><div className="muted">点任务行查看详情</div></Card>;
|
||||
return (
|
||||
<Card id="nav-detail" title="任务详情">
|
||||
<div>
|
||||
状态 <b className={"st-" + t.status}>{t.status}</b>({STATUS_EXPLAIN[t.status] || "未知状态"})
|
||||
<br />任务 {t.id} | turn {shortId(t.turn_id)} | root turn {shortId(t.root_turn_id)}
|
||||
<br />executor {t.executor || ""} | 主机 {t.worktree_host || ""}
|
||||
<br />模型 {t.model || "默认"} | 强度 {t.effort || "默认"}
|
||||
{(t.child_model || t.child_effort) ? <><br />子任务默认 {(t.child_model || "跟随本任务") + " / " + (t.child_effort || "跟随本任务")}</> : null}
|
||||
<br />权限 {t.approval || "默认"} | 项目 {shortId(t.project_id)}
|
||||
<br />更新 {fmt(t.updated_at)}
|
||||
{t.plan ? <><br />计划模式(只读)</> : null}
|
||||
{t.forked_from ? <><br />分支自 {shortId(t.forked_from)}</> : null}
|
||||
{t.goal_mode ? <><br />goal 模式(不限轮次,模型宣告完成)</> : t.verify_command ? <><br />goal 模式(旧语义,验收命令)</> : null}
|
||||
{t.coordinator_parts ? <><br />协调员:{t.coordinator_parts.length} 个子任务({t.coordinator_parts.map((p) => p.name).join("、")})</> : null}
|
||||
{t.parent_task_id ? <><br />协调子任务,父任务 {shortId(t.parent_task_id)}{t.coordinator_part ? "(" + t.coordinator_part + ")" : ""}</> : null}
|
||||
{(t.attachments && t.attachments.length) ? <><br />附件:{t.attachments.join("、")}</> : null}
|
||||
{t.pr_url ? <><br /><a href={t.pr_url} target="_blank" rel="noopener noreferrer">PR #{t.pr_number}</a></> : null}
|
||||
<br />prompt:{t.prompt || ""}
|
||||
</div>
|
||||
<DetailActions />
|
||||
<pre>{ctx.detailBody || "(暂无 result/error 输出)"}</pre>
|
||||
{ctx.costBox ? <pre>{ctx.costBox}</pre> : null}
|
||||
<h3>协调子任务</h3>
|
||||
<CoordPanel />
|
||||
<h3>子代理链路</h3>
|
||||
<AgentsPanel />
|
||||
<h3>评审队列</h3>
|
||||
<ReviewPanel />
|
||||
<h3>运行时审批队列</h3>
|
||||
<ApprovalPanel />
|
||||
<h3>给其他任务发消息</h3>
|
||||
<PeerPanel />
|
||||
<h3>线程</h3>
|
||||
<ThreadPanel />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useConsole } from "../context";
|
||||
import { DataListRaw } from "../components/ui";
|
||||
import { shortId } from "../api/client";
|
||||
|
||||
// Left-rail compact task list. The live 9-column table does not fit a
|
||||
// 300px rail, so the rail shows id + status + prompt + executor; the
|
||||
// full metadata (turn, root turn, host, PR, updated) lives in the right
|
||||
// detail panel for the selected task. Filters/search are the live ones.
|
||||
export default function TaskListSection() {
|
||||
const ctx = useConsole();
|
||||
return (
|
||||
<div className="left-tasks">
|
||||
<div className="bar">
|
||||
<select aria-label="状态筛选" value={ctx.statusFilter} onChange={(e) => ctx.setStatusFilter(e.target.value)}>
|
||||
<option value="">状态:全部</option>
|
||||
{["queued", "running", "completed", "failed", "interrupted", "merged", "merge-failed"].map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select aria-label="执行器筛选" value={ctx.executorFilter} onChange={(e) => ctx.setExecutorFilter(e.target.value)}>
|
||||
<option value="">执行器:全部</option><option value="local">local</option><option value="docker">docker</option><option value="cloud">cloud</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" placeholder="搜索任务 id / prompt" value={ctx.searchText} onChange={(e) => ctx.setSearchText(e.target.value)} />
|
||||
<div className="bar">
|
||||
<button onClick={ctx.refreshTasks}>刷新</button>
|
||||
<span className="muted">{ctx.taskCountText}</span>
|
||||
</div>
|
||||
{ctx.taskError ? <div className="err">{ctx.taskError}</div>
|
||||
: !ctx.projectId ? <div className="muted">先选项目</div>
|
||||
: ctx.filteredTasks.length === 0 ? <div className="muted">{ctx.tasks.length ? "没有符合筛选条件的任务" : "该项目暂无任务"}</div>
|
||||
: (
|
||||
<DataListRaw>
|
||||
{ctx.filteredTasks.slice().reverse().map((t) => (
|
||||
<li key={t.id} className={ctx.currentTask?.id === t.id ? "selected-task" : ""} onClick={() => ctx.showDetail(t.id)} style={{ cursor: "pointer" }}>
|
||||
<span className="row-main">
|
||||
<b>{shortId(t.id)}</b> <span className={"st-" + t.status}>{t.status}</span>
|
||||
<br />
|
||||
{t.plan ? "[计划] " : ""}{t.forked_from ? "[分支] " : ""}{t.coordinator_parts ? "[协调] " : ""}{t.parent_task_id ? "[协调子任务] " : ""}{String(t.prompt || "").slice(0, 60)}
|
||||
<br /><span className="muted">{t.executor || ""}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</DataListRaw>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TriagePanel } from "./detail/ProjectToolsPanels";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
import { useConsole } from "../context";
|
||||
|
||||
export default function TriageSection() {
|
||||
const ctx = useConsole();
|
||||
return (
|
||||
<Card id="nav-triage" title="分诊收件箱">
|
||||
<Bar><button onClick={ctx.refreshTriage}>刷新分诊</button></Bar>
|
||||
<TriagePanel />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar, DataListRaw } from "../components/ui";
|
||||
import { workerAgeText, shortId } from "../api/client";
|
||||
|
||||
export default function WorkersSection() {
|
||||
const ctx = useConsole();
|
||||
return (
|
||||
<Card id="nav-workers" title="Workers">
|
||||
<Bar><button onClick={ctx.refreshWorkers}>刷新</button></Bar>
|
||||
{ctx.workersError ? <div className="muted">{ctx.workersError}</div>
|
||||
: ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}</div>
|
||||
: ctx.workers.length === 0 ? <div className="muted">无 worker 注册</div>
|
||||
: (
|
||||
<DataListRaw>
|
||||
{ctx.workers.map((w, i) => {
|
||||
const run = w.running === undefined || w.running === null ? "?" : w.running;
|
||||
const cap = w.capacity === undefined || w.capacity === null ? "?" : w.capacity;
|
||||
const info = workerAgeText(w);
|
||||
const alive = w.alive !== undefined && w.alive !== null ? !!w.alive : (info.age !== null && info.age <= 120);
|
||||
const live = alive ? " / 在线" : " / 离线(占用为上次上报值)";
|
||||
return (
|
||||
<li key={i} className={alive ? "is-online" : "is-offline"}>
|
||||
<span className="row-main">
|
||||
{w.worker_id || w.id || "?"} / {w.executor || ""} / 占用 {run}/{cap}{live} / 最后心跳 {info.text}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</DataListRaw>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from "react";
|
||||
import { useConsole } from "../../context";
|
||||
import { ChatList, ChatInput } from "../../components/chat/Chat";
|
||||
import { traceEntryToChatMessage, type ChatMessage } from "../../components/chat/mapTrace";
|
||||
import { TRACE_TERMINAL } from "../../api/types";
|
||||
|
||||
// Continue chat: same ChatList/ChatBubble/ChatInput as the trace timeline.
|
||||
// History is the task prompt (user) + message-kind trace entries
|
||||
// (assistant) + followups already sent (user). Live followups and trace
|
||||
// entries carry no timestamps, so they cannot be interleaved exactly:
|
||||
// followups are appended after trace messages and this is stated here
|
||||
// and in the final report, not hidden by a fabricated order.
|
||||
// Sending uses only live APIs: terminal tasks -> POST /tasks/:id/continue,
|
||||
// queued/running tasks -> POST /tasks/:id/followups (direct). No new API.
|
||||
export default function ContinuePanel() {
|
||||
const ctx = useConsole();
|
||||
const t = ctx.currentTask;
|
||||
const messages = useMemo<ChatMessage[]>(() => {
|
||||
if (!t) return [];
|
||||
const head: ChatMessage[] = t.prompt ? [{ id: "task-prompt", role: "user", text: t.prompt }] : [];
|
||||
const assistant = ctx.traceEntries.filter((e) => e.kind === "message").map((e, i) => traceEntryToChatMessage(e, i));
|
||||
const followups: ChatMessage[] = ctx.followups.map((f, i) => ({
|
||||
id: "followup-" + i, role: "user" as const, text: String(f.body || ""), label: "后续消息",
|
||||
}));
|
||||
return [...head, ...assistant, ...followups];
|
||||
}, [t, ctx.traceEntries, ctx.followups]);
|
||||
if (!t) return <div className="muted">点任务行查看详情后,这里显示继续会话</div>;
|
||||
const terminal = TRACE_TERMINAL.includes(t.status);
|
||||
const running = ["queued", "running"].includes(t.status);
|
||||
const canSend = terminal || running;
|
||||
const hint = terminal
|
||||
? "终态任务:发送走继续接口(同一会话再跑一轮)"
|
||||
: running
|
||||
? "运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)"
|
||||
: "merged/merge-failed 任务不可继续,也不可发后续消息";
|
||||
return (
|
||||
<div className="chat-column">
|
||||
<ChatList messages={messages} empty="(暂无会话消息)" />
|
||||
<ChatInput
|
||||
disabled={!canSend}
|
||||
placeholder={hint + ",回车发送,Shift+回车换行"}
|
||||
onSend={(text) => void ctx.sendChatMessage(text)}
|
||||
/>
|
||||
<div className="muted">{hint}。{ctx.chatMsg}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../../context";
|
||||
import { Bar } from "../../components/ui";
|
||||
import { TRACE_TERMINAL } from "../../api/types";
|
||||
|
||||
// Detail actions + fork only. Continue/followup input lives in
|
||||
// ContinuePanel (shared ChatInput), not in a second form here.
|
||||
export default function DetailActions() {
|
||||
const ctx = useConsole();
|
||||
const t = ctx.currentTask;
|
||||
const [forkMsg, setForkMsg] = useState("");
|
||||
if (!t) return null;
|
||||
const terminal = TRACE_TERMINAL.includes(t.status);
|
||||
const retryOk = ["failed", "interrupted", "merge-failed"].includes(t.status);
|
||||
return (
|
||||
<>
|
||||
<Bar>
|
||||
<button disabled={!retryOk} title={retryOk ? "" : "仅 failed/interrupted/merge-failed 可重试"} onClick={() => ctx.doAction("retry")}>重试</button>
|
||||
<button className="danger" disabled={terminal} title={terminal ? "终态任务不可取消" : ""} onClick={() => ctx.doAction("cancel")}>取消</button>
|
||||
<button disabled={t.status !== "completed"} title={t.status !== "completed" ? "仅 completed 可合并" : ""} onClick={() => ctx.doAction("merge")}>合并(completed → merged)</button>
|
||||
<button disabled={t.status !== "merged"} title={t.status !== "merged" ? "仅 merged 可提 PR" : ""} onClick={() => ctx.doAction("pr")}>提 PR(须 merged)</button>
|
||||
{terminal ? <button onClick={() => ctx.setForkVisible(true)}>分支</button> : null}
|
||||
<button onClick={ctx.taskCost}>查成本</button>
|
||||
<span className="muted">{ctx.actionMsg} {ctx.actionReasons}</span>
|
||||
</Bar>
|
||||
{ctx.forkVisible ? (
|
||||
<div>
|
||||
<input type="text" placeholder="分支首轮消息(必填,带历史的新会话)" style={{ width: "100%", boxSizing: "border-box", margin: "6px 0" }} value={forkMsg} onChange={(e) => setForkMsg(e.target.value)} />
|
||||
<Bar><button className="seal" onClick={async () => { await ctx.forkTask(forkMsg); setForkMsg(""); }}>创建分支任务</button><span className="muted">{ctx.forkMsg}</span></Bar>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../../context";
|
||||
import { Bar, DataList, DataListRaw } from "../../components/ui";
|
||||
import { shortId, fmt, traceLineText } from "../../api/client";
|
||||
|
||||
export function CoordPanel() {
|
||||
const ctx = useConsole();
|
||||
if (!ctx.currentTask) return <div className="muted">点任务行查看详情后,这里显示协调员的子任务</div>;
|
||||
if (!ctx.currentTask.coordinator_parts) return <div className="muted">(非协调员任务)</div>;
|
||||
if (ctx.coordChildren.length === 0) return <div className="muted">(子任务尚未创建)</div>;
|
||||
return (
|
||||
<DataListRaw>
|
||||
{ctx.coordChildren.map((k) => (
|
||||
<li key={k.id}>
|
||||
<span className="row-main">分工 <b>{k.coordinator_part || ""}</b> | 状态 <span className={"st-" + k.status}>{k.status}</span> | {shortId(k.id)} | {String(k.prompt || "").slice(0, 60)}</span>
|
||||
</li>
|
||||
))}
|
||||
</DataListRaw>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsPanel() {
|
||||
const ctx = useConsole();
|
||||
if (!ctx.currentTask) return <div className="muted">点任务行查看详情后,这里显示子代理链路</div>;
|
||||
if (ctx.agents.length === 0) return <div className="muted">(暂无子代理){ctx.agentsNote ? <span className="err">{ctx.agentsNote}</span> : null}</div>;
|
||||
const kindLabel: Record<string, string> = { main: "主任务", delegate: "委派子任务", model: "模型子代理" };
|
||||
const statusLabel: Record<string, string> = { running: "进行中", completed: "已完成", failed: "失败", interrupted: "已中断", unknown: "未知" };
|
||||
return (
|
||||
<DataListRaw>
|
||||
{ctx.agents.map((a, i) => (
|
||||
<li key={i}>
|
||||
<span className="row-main">
|
||||
<b>{kindLabel[a.kind || ""] || a.kind || ""}:</b>{a.name || ""} <span className={"st-" + (a.status || "")}>{statusLabel[a.status || ""] || a.status || "?"}</span>
|
||||
{" | 过程调用 " + (a.calls ? a.calls.length : 0) + " 条"}
|
||||
{a.updated_ts ? " | 最近活动 " + fmt(a.updated_ts / 1000) : ""}
|
||||
{a.prompt ? <><br /><span className="muted">任务:{a.prompt}</span></> : null}
|
||||
{a.calls && a.calls.length ? <><br />{a.calls.slice(-50).map((c, j) => <div key={j}>{traceLineText(c)}</div>)}</> : <><br /><span className="muted">(暂无过程调用)</span></>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</DataListRaw>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalPanel() {
|
||||
const ctx = useConsole();
|
||||
return (
|
||||
<>
|
||||
<Bar><button onClick={ctx.refreshApprovals}>刷新审批队列</button><span className="muted">{ctx.approvalMsg}</span></Bar>
|
||||
<div className="muted">待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。</div>
|
||||
<DataList empty="(暂无审批项)" items={ctx.approvals.map((a) => (
|
||||
<span key={a.id} className="row-main">
|
||||
[{a.kind}|{a.status}] {a.summary}
|
||||
{["approved", "denied", "expired"].includes(a.status || "") ? "|回执:" + (a.decision || a.status) + " by " + String(a.decided_by || "").slice(0, 8) + " " + fmt(a.decided_at) : ""}
|
||||
{a.status === "pending" ? (
|
||||
<>
|
||||
{" "}<button onClick={() => ctx.decideApproval(a.id, "approved")}>记录允许</button>
|
||||
{" "}<button onClick={() => ctx.decideApproval(a.id, "denied")}>记录拒绝</button>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
))} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeerPanel() {
|
||||
const ctx = useConsole();
|
||||
const [target, setTarget] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
return (
|
||||
<>
|
||||
<input placeholder="目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)" style={{ width: "100%" }} value={target} onChange={(e) => setTarget(e.target.value)} />
|
||||
<textarea placeholder="消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)" value={body} onChange={(e) => setBody(e.target.value)} />
|
||||
<Bar>
|
||||
<button onClick={async () => { await ctx.postPeer("queued", target, body); setBody(""); }}>排队发给对方</button>
|
||||
<button className="primary" onClick={async () => { await ctx.postPeer("direct", target, body); setBody(""); }}>直接发送</button>
|
||||
<span className="muted">{ctx.peerMsg}</span>
|
||||
</Bar>
|
||||
<DataList empty="(本任务尚未发出任务间消息)" items={ctx.peerMessages.map((f, i) => (
|
||||
<span key={i} className="row-main">[发给 {String(f.task_id).slice(0, 8)}|{f.mode}|{f.status}] {String(f.body || "").slice(0, 120)}</span>
|
||||
))} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadPanel() {
|
||||
const ctx = useConsole();
|
||||
const [name, setName] = useState("");
|
||||
const gateOk = ctx.currentTask ? ["completed", "failed", "interrupted"].includes(ctx.currentTask.status) : false;
|
||||
return (
|
||||
<>
|
||||
<div className="muted">{ctx.threadText || "点任务行查看详情后,这里显示线程元数据"}</div>
|
||||
<Bar>
|
||||
<input type="text" placeholder="新线程名" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<button disabled={!gateOk} onClick={() => ctx.threadAction("rename", name)}>重命名</button>
|
||||
<button disabled={!gateOk} onClick={() => ctx.threadAction("archive", "")}>归档</button>
|
||||
<button disabled={!gateOk} onClick={() => ctx.threadAction("unarchive", "")}>取消归档</button>
|
||||
<button disabled={!gateOk} onClick={() => ctx.threadAction("compact", "")}>压缩</button>
|
||||
<span className="muted">{ctx.threadGateNote}</span>
|
||||
<span className="muted">{ctx.threadMsg}</span>
|
||||
</Bar>
|
||||
<DataList empty="暂无其他线程" items={ctx.threadList.map((t, i) => (
|
||||
<span key={i} className="row-main">{t.name || t.thread_name || t.id || ""} [{t.id || ""}]{t.archived ? " [已归档]" : ""}{t.cwd ? " " + t.cwd : ""}</span>
|
||||
))} />
|
||||
<div className="muted">限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../../context";
|
||||
import { Bar, DataList } from "../../components/ui";
|
||||
import { fmt, shortId } from "../../api/client";
|
||||
|
||||
export function AutomationPanel() {
|
||||
const ctx = useConsole();
|
||||
const [name, setName] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [interval, setInterval] = useState("");
|
||||
const [repo, setRepo] = useState("");
|
||||
return (
|
||||
<>
|
||||
<input placeholder="自动化名称(同项目内启用中的名称唯一)" style={{ width: "100%" }} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<textarea placeholder="定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
<Bar>
|
||||
<input placeholder="间隔秒数(默认 86400,最小 60)" style={{ width: "40%" }} value={interval} onChange={(e) => setInterval(e.target.value)} />
|
||||
<input placeholder="仓库(可选)" style={{ width: "40%" }} value={repo} onChange={(e) => setRepo(e.target.value)} />
|
||||
<button className="primary" onClick={async () => { await ctx.createAutomation(name, prompt, interval, repo); setName(""); setPrompt(""); setInterval(""); setRepo(""); }}>创建自动化</button>
|
||||
<button onClick={ctx.refreshAutomations}>刷新自动化</button>
|
||||
<span className="muted">{ctx.autoMsg}</span>
|
||||
</Bar>
|
||||
<DataList empty="(暂无自动化)" items={ctx.automations.map((a) => (
|
||||
<span key={a.id} className="row-main">
|
||||
[{a.status}{a.due ? "|到期" : ""}] {a.name}|每 {a.interval_seconds} 秒|下次 {fmt(a.next_run_at)}
|
||||
{a.status === "active" ? <> <button onClick={() => ctx.cancelAutomation(a.id)}>取消</button></> : null}
|
||||
</span>
|
||||
))} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriagePanel() {
|
||||
const ctx = useConsole();
|
||||
const t = ctx.triage;
|
||||
if (ctx.triageError) return <div className="muted">{ctx.triageError}</div>;
|
||||
if (!t) return <div className="muted">(先选项目)</div>;
|
||||
return (
|
||||
<>
|
||||
<div className="data-group"><b>到期自动化({t.counts?.automations_due || 0})</b>
|
||||
<DataList empty="(无)" items={(t.automations_due || []).map((a, i) => <span key={i} className="row-main">{a.name}|每 {a.interval_seconds} 秒</span>)} />
|
||||
</div>
|
||||
<div className="data-group"><b>失败/中断任务({t.counts?.failed_tasks || 0})</b>
|
||||
<DataList empty="(无)" items={(t.failed_tasks || []).map((x, i) => <span key={i} className="row-main">{shortId(x.id)}|{x.status}|{String(x.prompt || "").slice(0, 80)}</span>)} />
|
||||
</div>
|
||||
<div className="data-group"><b>待处理后续消息({t.counts?.pending_followups || 0})</b>
|
||||
<DataList empty="(无)" items={(t.pending_followups || []).map((f, i) => <span key={i} className="row-main">任务 {shortId(f.task_id)}|{String(f.body || "").slice(0, 80)}</span>)} />
|
||||
</div>
|
||||
<div className="data-group"><b>最近自动化产出({t.counts?.recent_runs || 0})</b>
|
||||
<DataList empty="(无)" items={(t.recent_runs || []).map((r, i) => <span key={i} className="row-main">{shortId(r.automation_id)}|{r.status}{r.task_id ? "|任务 " + shortId(r.task_id) : ""}{r.task_status ? "|" + r.task_status : ""}</span>)} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../../context";
|
||||
import { Bar, DataListRaw } from "../../components/ui";
|
||||
import { shortId, fmt } from "../../api/client";
|
||||
|
||||
export default function ReviewPanel() {
|
||||
const ctx = useConsole();
|
||||
const [targetType, setTargetType] = useState("uncommittedChanges");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [sha, setSha] = useState("");
|
||||
const [instructions, setInstructions] = useState("");
|
||||
return (
|
||||
<>
|
||||
<h3>评审队列</h3>
|
||||
<select aria-label="评审目标" value={targetType} onChange={(e) => setTargetType(e.target.value)}>
|
||||
<option value="uncommittedChanges">未提交改动(工作区)</option>
|
||||
<option value="baseBranch">与分支的差异</option>
|
||||
<option value="commit">指定提交</option>
|
||||
<option value="custom">自定义评审指令</option>
|
||||
</select>
|
||||
<input className="stacked" placeholder="与分支的差异用:分支名(如 main)" style={{ width: "100%" }} value={branch} onChange={(e) => setBranch(e.target.value)} />
|
||||
<input className="stacked" placeholder="指定提交用:提交 sha" style={{ width: "100%" }} value={sha} onChange={(e) => setSha(e.target.value)} />
|
||||
<textarea placeholder="自定义评审指令用:评审指令正文" value={instructions} onChange={(e) => setInstructions(e.target.value)} />
|
||||
<Bar>
|
||||
<button className="primary" onClick={() => void ctx.postReview({ target_type: targetType, branch: branch.trim() || undefined, sha: sha.trim() || undefined, instructions: instructions.trim() || undefined })}>登记评审需求</button>
|
||||
<button onClick={ctx.refreshReviews}>刷新队列</button>
|
||||
<span className="muted">{ctx.reviewMsg}</span>
|
||||
</Bar>
|
||||
<div className="muted">本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。</div>
|
||||
{ctx.reviewError ? <div className="muted">{ctx.reviewError}</div>
|
||||
: ctx.reviews.length === 0 ? <div className="muted">点任务行查看详情后,点“刷新队列”查看评审记录</div>
|
||||
: (
|
||||
<DataListRaw>
|
||||
{ctx.reviews.map((r) => {
|
||||
let target = r.target_type || "";
|
||||
if (r.branch) target += ":" + r.branch;
|
||||
if (r.sha) target += ":" + String(r.sha).slice(0, 12);
|
||||
if (r.instructions) target += ":" + String(r.instructions).slice(0, 80);
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<span className="row-main">
|
||||
<b>评审</b> {shortId(r.id)} | {target} | <span className={"st-" + (r.status || "")}>{r.status}</span>
|
||||
{r.created_at ? " | " + fmt(r.created_at) : ""}
|
||||
<br />
|
||||
{r.findings ? String(r.findings).slice(0, 1000) : <span className="muted">(暂无 findings:评审尚未执行或尚未写回)</span>}
|
||||
</span>
|
||||
<button onClick={() => ctx.submitFindings(r.id)}>写 findings</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</DataListRaw>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from "react";
|
||||
import { useConsole } from "../../context";
|
||||
import { Bar } from "../../components/ui";
|
||||
import { ChatList } from "../../components/chat/Chat";
|
||||
import { traceEntryToChatMessage, type ChatMessage } from "../../components/chat/mapTrace";
|
||||
|
||||
export default function TracePanel() {
|
||||
const ctx = useConsole();
|
||||
const messages = useMemo<ChatMessage[]>(() => {
|
||||
const t = ctx.currentTask;
|
||||
const head: ChatMessage[] = t && t.prompt ? [{ id: "task-prompt", role: "user", text: t.prompt }] : [];
|
||||
const pool = ctx.traceKind ? ctx.traceEntries.filter((e) => e.kind === ctx.traceKind) : ctx.traceEntries;
|
||||
const body = pool.map((e, i) => traceEntryToChatMessage(e, i));
|
||||
const tail: ChatMessage[] = ctx.traceDone ? [{ id: "trace-done", role: "status", text: "链路已结束(" + ctx.traceDone + ")" }] : [];
|
||||
return [...head, ...body, ...tail];
|
||||
}, [ctx.currentTask, ctx.traceEntries, ctx.traceKind, ctx.traceDone]);
|
||||
return (
|
||||
<>
|
||||
<h3>实时链路</h3>
|
||||
<Bar>
|
||||
<select aria-label="链路类型" value={ctx.traceKind} onChange={(e) => ctx.setTraceKind(e.target.value)}>
|
||||
<option value="">链路类型:全部</option>
|
||||
{["exec", "file", "call", "tool", "message", "reasoning", "plan", "agent-start", "agent-done"].map((k) => (
|
||||
<option key={k} value={k}>{k === "exec" ? "命令" : k === "file" ? "文件" : k === "call" ? "调用" : k === "tool" ? "工具" : k === "message" ? "消息" : k === "reasoning" ? "思考" : k === "plan" ? "计划" : "子代理"}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={ctx.downloadTrace}>下载完整链路</button>
|
||||
<span className="muted">{ctx.traceMsg}</span>
|
||||
</Bar>
|
||||
{ctx.traceNote ? <div><span className="err">{ctx.traceNote}</span></div> : null}
|
||||
<div className="chat-column">
|
||||
<ChatList messages={messages} empty="(暂无链路条目)" />
|
||||
</div>
|
||||
<div className="muted">对话时间线:首条是本任务 prompt(用户);助手回复来自 message 条目,命令/文件/工具/调用是工具气泡,计划/思考/子代理开始结束是居中状态提示。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user