feat: harden codex queue runtime
Add model selection, batch enqueue controls, dev-ready health checks, transcript pagination, queue watchdog recovery, and MiniMax judge JSON repair for codex-queue.
This commit is contained in:
@@ -1217,10 +1217,69 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
}
|
||||
.codex-queue-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.52fr) minmax(680px, 1.58fr);
|
||||
grid-template-columns: minmax(300px, 0.52fr) minmax(0, 1.58fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.codex-session-stage {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.codex-session-stage .codex-output-panel {
|
||||
width: 100%;
|
||||
}
|
||||
.codex-session-stage .codex-transcript {
|
||||
min-height: 620px;
|
||||
max-height: calc(100vh - 230px);
|
||||
}
|
||||
.codex-session-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.34fr) minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
.codex-session-shell.queue-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.codex-session-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-right: 1px solid var(--line);
|
||||
background:
|
||||
radial-gradient(circle at 0 0, rgba(78, 183, 168, 0.11), transparent 42%),
|
||||
rgba(6, 10, 13, 0.72);
|
||||
}
|
||||
.codex-session-sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-session-sidebar-head > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-session-sidebar-head span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.codex-session-sidebar-head strong {
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.codex-session-main {
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-left-rail,
|
||||
.codex-main-stage,
|
||||
.codex-detail-grid,
|
||||
@@ -1229,11 +1288,29 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.codex-compose-panel,
|
||||
.codex-compose-panel .panel-body,
|
||||
.codex-task-form,
|
||||
.codex-task-form label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.codex-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 0.8fr) minmax(180px, 1fr) 92px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.codex-form-grid label:nth-child(2) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.codex-task-form textarea,
|
||||
.codex-steer-form textarea,
|
||||
.codex-form-grid input,
|
||||
.codex-form-grid select {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.codex-task-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
@@ -1242,6 +1319,46 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
overflow: auto;
|
||||
align-content: start;
|
||||
}
|
||||
.codex-task-list-session {
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 318px);
|
||||
}
|
||||
.codex-task-section {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-task-section + .codex-task-section {
|
||||
margin-top: 6px;
|
||||
padding-top: 9px;
|
||||
border-top: 1px solid rgba(255,255,255,0.07);
|
||||
}
|
||||
.codex-task-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.codex-task-section-head code {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
.codex-task-section-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-task-section-empty {
|
||||
margin: 0;
|
||||
padding: 8px 9px;
|
||||
border: 1px dashed var(--line-soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.codex-task-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -1282,6 +1399,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.codex-output-panel .panel-body {
|
||||
padding: 0;
|
||||
}
|
||||
.codex-output-stack {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-transcript {
|
||||
min-height: 520px;
|
||||
max-height: calc(100vh - 300px);
|
||||
@@ -1300,6 +1421,87 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,0.025);
|
||||
}
|
||||
.codex-transcript-item {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.045);
|
||||
}
|
||||
.codex-transcript-bullet {
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
.codex-transcript-main {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-transcript-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.codex-transcript-title strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.codex-transcript-title time {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.codex-transcript-title code {
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent-2);
|
||||
}
|
||||
.codex-transcript-command,
|
||||
.codex-transcript-body {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
border-left: 2px solid rgba(78, 183, 168, 0.26);
|
||||
padding: 2px 0 2px 10px;
|
||||
color: #d9e8e7;
|
||||
font-size: 12px;
|
||||
line-height: 1.48;
|
||||
}
|
||||
.codex-transcript-command {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
color: #a7c7c3;
|
||||
border-color: rgba(215, 161, 58, 0.34);
|
||||
background: rgba(215, 161, 58, 0.035);
|
||||
}
|
||||
.codex-transcript-item.explored .codex-output-channel { color: #8fc7ee; border-color: rgba(105, 174, 232, 0.46); background: rgba(105, 174, 232, 0.08); }
|
||||
.codex-transcript-item.edited .codex-output-channel { color: #b6da89; border-color: rgba(182, 218, 137, 0.42); background: rgba(182, 218, 137, 0.07); }
|
||||
.codex-transcript-item.error .codex-output-channel { color: var(--danger); border-color: rgba(207, 106, 84, 0.52); background: rgba(207, 106, 84, 0.08); }
|
||||
.codex-raw-output {
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(6, 10, 13, 0.92);
|
||||
}
|
||||
.codex-raw-output summary {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.codex-raw-output > div {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
.codex-output-line {
|
||||
display: grid;
|
||||
grid-template-columns: 130px minmax(0, 1fr);
|
||||
@@ -1344,6 +1546,46 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.codex-detail-grid {
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
|
||||
}
|
||||
.codex-prompt-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.codex-prompt-detail {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-prompt-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-prompt-meta span:not(.status-badge) {
|
||||
max-width: 100%;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--line-soft);
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,0.025);
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.codex-prompt-full {
|
||||
max-height: 360px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid rgba(215, 161, 58, 0.28);
|
||||
border-left: 3px solid rgba(215, 161, 58, 0.58);
|
||||
color: #e7ded0;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(215, 161, 58, 0.08), transparent 34%),
|
||||
rgba(6, 10, 13, 0.84);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.codex-judge-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -3250,7 +3492,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.pipeline-oa-guarantees { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dispatch-form { grid-template-columns: 1fr 1fr; }
|
||||
.dispatch-actions { align-items: center; }
|
||||
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid, .codex-queue-layout, .codex-queue-hero, .codex-detail-grid { grid-template-columns: 1fr; }
|
||||
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid, .codex-queue-layout, .codex-queue-hero, .codex-detail-grid, .codex-session-shell { grid-template-columns: 1fr; }
|
||||
.codex-session-sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.pipeline-control-shell { grid-template-columns: 1fr; }
|
||||
.pipeline-node-control { max-height: none; min-height: 0; }
|
||||
.findjob-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5), .met-grid .panel:nth-child(3), .met-grid .panel:nth-child(5), .met-detail-panel { grid-column: 1; }
|
||||
|
||||
@@ -33,11 +33,16 @@ async function requestJson(path: string, options: AnyRecord = {}): Promise<any>
|
||||
const response = await fetch(path, { credentials: "same-origin", ...options, body, headers });
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
let parseFailed = false;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parseFailed = true;
|
||||
payload = { text };
|
||||
}
|
||||
if (parseFailed) {
|
||||
throw new Error(`Codex Queue 返回了无效 JSON(${text.length} bytes),可能是代理响应过大或被截断`);
|
||||
}
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
const message = payload?.error?.message || payload?.error || `HTTP ${response.status}`;
|
||||
const error = new Error(message);
|
||||
@@ -106,10 +111,62 @@ function taskRows(data: any): any[] {
|
||||
return Array.isArray(data?.tasks) ? data.tasks : [];
|
||||
}
|
||||
|
||||
function taskSortValue(task: any): number {
|
||||
const time = Date.parse(String(task?.updatedAt || task?.createdAt || ""));
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function mergeTaskRows(groups: any[][], activeTaskId = ""): any[] {
|
||||
const byId = new Map<string, any>();
|
||||
for (const group of groups) {
|
||||
for (const task of group) {
|
||||
const id = String(task?.id || "");
|
||||
if (id.length > 0 && !byId.has(id)) byId.set(id, task);
|
||||
}
|
||||
}
|
||||
const statusRank: Record<string, number> = { running: 0, judging: 1, retry_wait: 2, queued: 3 };
|
||||
return Array.from(byId.values()).sort((left, right) => {
|
||||
const leftActive = String(left?.id || "") === activeTaskId ? 0 : 1;
|
||||
const rightActive = String(right?.id || "") === activeTaskId ? 0 : 1;
|
||||
if (leftActive !== rightActive) return leftActive - rightActive;
|
||||
const rankDelta = (statusRank[String(left?.status || "")] ?? 9) - (statusRank[String(right?.status || "")] ?? 9);
|
||||
if (rankDelta !== 0) return rankDelta;
|
||||
return taskSortValue(right) - taskSortValue(left);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTaskQueue(apiBaseUrl: string, healthResult: any): Promise<any> {
|
||||
const statuses = ["running", "judging", "retry_wait", "queued"];
|
||||
const results = await Promise.all(statuses.map(async (status) => {
|
||||
try {
|
||||
return await requestJson(codexApi(apiBaseUrl, `/api/tasks?status=${encodeURIComponent(status)}&limit=80`));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
const historyResult = await requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=160")).catch(() => null);
|
||||
const queue = results.find((item) => item?.queue)?.queue || historyResult?.queue || healthResult?.queue || healthResult?.body?.queue || {};
|
||||
const rows = mergeTaskRows([...results.map((item) => taskRows(item)), taskRows(historyResult)], String(queue?.activeTaskId || ""));
|
||||
if (rows.length > 0) return { ok: true, queue, tasks: rows };
|
||||
return requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=5"));
|
||||
}
|
||||
|
||||
function taskOutput(task: any): any[] {
|
||||
return Array.isArray(task?.output) ? task.output : [];
|
||||
}
|
||||
|
||||
function taskTranscript(task: any): any[] {
|
||||
if (Array.isArray(task?.transcript)) return task.transcript;
|
||||
return taskOutput(task).map((item: any) => ({
|
||||
seq: item.seq,
|
||||
at: item.at,
|
||||
kind: item.channel === "error" ? "error" : item.channel === "command" ? "ran" : "message",
|
||||
title: item.method || item.channel || "message",
|
||||
bodyPreview: String(item.text || ""),
|
||||
rawSeqs: [item.seq],
|
||||
}));
|
||||
}
|
||||
|
||||
function taskAttempts(task: any): any[] {
|
||||
return Array.isArray(task?.attempts) ? task.attempts : [];
|
||||
}
|
||||
@@ -125,6 +182,11 @@ function splitPromptTasks(prompt: string): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function repeatCountValue(value: any): number {
|
||||
const count = Number(value);
|
||||
return Number.isFinite(count) ? Math.max(1, Math.min(50, Math.floor(count))) : 1;
|
||||
}
|
||||
|
||||
function channelLabel(channel: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
system: "SYS",
|
||||
@@ -139,15 +201,63 @@ function channelLabel(channel: string): string {
|
||||
return labels[channel] || channel.toUpperCase();
|
||||
}
|
||||
|
||||
function transcriptKindLabel(kind: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
ran: "Ran",
|
||||
explored: "Explored",
|
||||
edited: "Edited",
|
||||
plan: "Plan",
|
||||
message: "Message",
|
||||
system: "System",
|
||||
error: "Error",
|
||||
};
|
||||
return labels[kind] || "Message";
|
||||
}
|
||||
|
||||
function omittedLabel(lines: any): string {
|
||||
const count = Number(lines || 0);
|
||||
return Number.isFinite(count) && count > 0 ? `… +${Math.floor(count)} lines` : "";
|
||||
}
|
||||
|
||||
function taskIsActive(task: any): boolean {
|
||||
return ["running", "judging", "retry_wait"].includes(String(task?.status || ""));
|
||||
}
|
||||
|
||||
function taskIsTerminal(task: any): boolean {
|
||||
return ["succeeded", "failed", "canceled"].includes(String(task?.status || ""));
|
||||
}
|
||||
|
||||
function taskHasDetail(task: any): boolean {
|
||||
return Boolean(task?._detailLoaded)
|
||||
|| (Array.isArray(task?.transcript) && task.transcript.length > 0)
|
||||
|| (Array.isArray(task?.output) && task.output.length > 0)
|
||||
|| (Array.isArray(task?.events) && task.events.length > 0);
|
||||
}
|
||||
|
||||
function mergeTranscriptRows(existing: any[], incoming: any[]): any[] {
|
||||
const byKey = new Map<string, any>();
|
||||
for (const item of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(incoming) ? incoming : [])]) {
|
||||
const key = `${Number(item?.seq ?? 0)}:${String(item?.kind || "message")}`;
|
||||
byKey.set(key, item);
|
||||
}
|
||||
return Array.from(byKey.values()).sort((left, right) => Number(left?.seq ?? 0) - Number(right?.seq ?? 0));
|
||||
}
|
||||
|
||||
function transcriptMaxSeq(transcript: any[]): number {
|
||||
return (Array.isArray(transcript) ? transcript : []).reduce((max, item) => Math.max(max, Number(item?.seq ?? 0)), 0);
|
||||
}
|
||||
|
||||
function countValue(counts: AnyRecord, key: string): string {
|
||||
const value = Number(counts[key] ?? 0);
|
||||
return Number.isFinite(value) ? String(value) : "0";
|
||||
}
|
||||
|
||||
function codexModelOptions(queue: any, currentModel: string): string[] {
|
||||
const configured = Array.isArray(queue?.codexModels) ? queue.codexModels : [];
|
||||
const fallback = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.5"];
|
||||
return Array.from(new Set([...configured, ...fallback, currentModel].map((item) => String(item || "").trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function TaskCard({ task, selected, onSelect }: AnyRecord) {
|
||||
const judge = task?.lastJudge || {};
|
||||
return h("button", {
|
||||
@@ -169,22 +279,95 @@ function TaskCard({ task, selected, onSelect }: AnyRecord) {
|
||||
);
|
||||
}
|
||||
|
||||
function Transcript({ task, autoScroll }: AnyRecord) {
|
||||
function TaskListSection({ title, tasks, selectedId, onSelect, emptyText }: AnyRecord) {
|
||||
const rows = Array.isArray(tasks) ? tasks : [];
|
||||
return h("section", { className: "codex-task-section" },
|
||||
h("div", { className: "codex-task-section-head" },
|
||||
h("span", null, title),
|
||||
h("code", null, String(rows.length)),
|
||||
),
|
||||
rows.length === 0
|
||||
? h("p", { className: "codex-task-section-empty" }, emptyText)
|
||||
: h("div", { className: "codex-task-section-list" }, rows.map((task: any) => h(TaskCard, {
|
||||
key: task.id,
|
||||
task,
|
||||
selected: selectedId === task.id,
|
||||
onSelect: () => onSelect(task.id),
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
function Transcript({ task, autoScroll, loading }: AnyRecord) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const output = taskOutput(task);
|
||||
const transcript = taskTranscript(task);
|
||||
useEffect(() => {
|
||||
if (autoScroll && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [autoScroll, output.length, task?.id]);
|
||||
}, [autoScroll, transcript.length, task?.id]);
|
||||
if (!task) return h(EmptyState, { title: "未选择任务", text: "从左侧队列选择任务,或提交新 Codex 任务。" });
|
||||
if (loading && !taskHasDetail(task)) return h("div", { className: "codex-transcript", ref, "data-testid": "codex-output" },
|
||||
h("div", { className: "codex-output-empty" }, "正在加载完整 session 记录..."),
|
||||
);
|
||||
return h("div", { className: "codex-transcript", ref, "data-testid": "codex-output" },
|
||||
output.length === 0 ? h("div", { className: "codex-output-empty" }, "等待 Codex 输出...") : output.map((item: any) => h("article", { key: `${item.seq}-${item.channel}`, className: `codex-output-line ${item.channel || "system"}` },
|
||||
h("div", { className: "codex-output-meta" },
|
||||
h("span", { className: "codex-output-channel" }, channelLabel(String(item.channel || "system"))),
|
||||
h("span", null, fmtDate(item.at)),
|
||||
item.method ? h("code", null, item.method) : null,
|
||||
transcript.length === 0 ? h("div", { className: "codex-output-empty" }, "等待 Codex 输出...") : transcript.map((item: any) => {
|
||||
const kind = String(item.kind || "message");
|
||||
const isCommand = ["ran", "explored", "edited"].includes(kind);
|
||||
const commandMore = omittedLabel(item.commandOmittedLines);
|
||||
const bodyMore = omittedLabel(item.bodyOmittedLines);
|
||||
const commandText = String(item.commandPreview || (isCommand ? item.title || "" : ""));
|
||||
return h("article", { key: `${item.seq}-${kind}`, className: `codex-transcript-item ${kind}` },
|
||||
h("div", { className: "codex-transcript-bullet" }, "•"),
|
||||
h("div", { className: "codex-transcript-main" },
|
||||
h("div", { className: "codex-transcript-title" },
|
||||
h("span", { className: "codex-output-channel" }, transcriptKindLabel(kind)),
|
||||
isCommand ? null : h("strong", null, String(item.title || transcriptKindLabel(kind))),
|
||||
item.status ? h("code", null, String(item.status)) : null,
|
||||
h("time", null, fmtDate(item.at)),
|
||||
),
|
||||
commandText ? h("pre", { className: "codex-transcript-command" },
|
||||
commandText,
|
||||
commandMore ? `\n${commandMore}` : "",
|
||||
) : null,
|
||||
item.bodyPreview ? h("pre", { className: "codex-transcript-body" },
|
||||
String(item.bodyPreview),
|
||||
bodyMore ? `\n${bodyMore} (查看原始JSON获取完整记录)` : "",
|
||||
) : null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function PromptDetail({ task }: AnyRecord) {
|
||||
if (!task) return h(EmptyState, { title: "未选择任务", text: "选择队列或历史 session 后,这里显示完整 prompt、模型和工作目录。" });
|
||||
const promptText = String(task?.prompt || "");
|
||||
const lines = promptText.length > 0 ? promptText.split(/\r\n|\r|\n/u).length : 0;
|
||||
return h("div", { className: "codex-prompt-detail", "data-testid": "codex-task-prompt-detail" },
|
||||
h("div", { className: "codex-prompt-meta" },
|
||||
h(StatusBadge, { status: task?.status }, task?.status || "unknown"),
|
||||
h("span", null, `model=${task?.model || "--"}`),
|
||||
h("span", null, `cwd=${task?.cwd || "--"}`),
|
||||
h("span", null, `created=${fmtDate(task?.createdAt)}`),
|
||||
h("span", null, `${lines} lines / ${promptText.length} chars`),
|
||||
),
|
||||
h("pre", { className: "codex-prompt-full", "data-testid": "codex-task-prompt-full" }, promptText || "空 prompt"),
|
||||
);
|
||||
}
|
||||
|
||||
function RawTranscript({ task }: AnyRecord) {
|
||||
const output = taskOutput(task);
|
||||
if (!task || output.length === 0) return h(EmptyState, { title: "暂无原始消息", text: "原始 Codex app-server 消息会保留在任务 JSON 中。" });
|
||||
return h("details", { className: "codex-raw-output" },
|
||||
h("summary", null, `原始 messages (${output.length})`),
|
||||
h("div", null,
|
||||
output.map((item: any) => h("article", { key: `${item.seq}-${item.channel}`, className: `codex-output-line ${item.channel || "system"}` },
|
||||
h("div", { className: "codex-output-meta" },
|
||||
h("span", { className: "codex-output-channel" }, channelLabel(String(item.channel || "system"))),
|
||||
h("span", null, fmtDate(item.at)),
|
||||
item.method ? h("code", null, item.method) : null,
|
||||
),
|
||||
h("pre", null, String(item.text || "")),
|
||||
)),
|
||||
),
|
||||
h("pre", null, String(item.text || "")),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -215,21 +398,31 @@ function AttemptTable({ task }: AnyRecord) {
|
||||
|
||||
export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "codex-queue") || null;
|
||||
const selectedIdRef = useRef("");
|
||||
const queueLoadTokenRef = useRef(0);
|
||||
const detailLoadTokenRef = useRef(0);
|
||||
const detailInFlightRef = useRef<{ taskId: string; token: number; promise: Promise<void> } | null>(null);
|
||||
const sessionCacheRef = useRef<Map<string, AnyRecord>>(new Map());
|
||||
const [health, setHealth] = useState(null);
|
||||
const [tasksData, setTasksData] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [selectedDetailLoading, setSelectedDetailLoading] = useState(false);
|
||||
const [prompt, setPrompt] = useState("请在 UniDesk 工作区中完成一个很小的验证任务:读取 package.json 并总结项目名称,不要修改文件。");
|
||||
const [model, setModel] = useState("gpt-5.4-mini");
|
||||
const [cwd, setCwd] = useState("/workspace");
|
||||
const [cwd, setCwd] = useState("/root/unidesk");
|
||||
const [maxAttempts, setMaxAttempts] = useState(3);
|
||||
const [repeatCount, setRepeatCount] = useState(1);
|
||||
const [steerPrompt, setSteerPrompt] = useState("");
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [queueSidebarOpen, setQueueSidebarOpen] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [refreshedAt, setRefreshedAt] = useState(null);
|
||||
|
||||
const tasks = taskRows(tasksData);
|
||||
const queuedTasks = tasks.filter((task: any) => !taskIsTerminal(task));
|
||||
const historyTasks = tasks.filter((task: any) => taskIsTerminal(task));
|
||||
const queue = tasksData?.queue || health?.body?.queue || health?.queue || {};
|
||||
const counts = queueCounts(queue);
|
||||
const activeTaskId = queue?.activeTaskId || tasks.find((task: any) => taskIsActive(task))?.id || "";
|
||||
@@ -237,28 +430,122 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
|
||||
const repository = service ? microserviceRepository(service) : {};
|
||||
const backend = service ? microserviceBackend(service) : {};
|
||||
const promptParts = useMemo(() => splitPromptTasks(prompt), [prompt]);
|
||||
const enqueueItems = useMemo(() => {
|
||||
const count = repeatCountValue(repeatCount);
|
||||
return promptParts.flatMap((text) => Array.from({ length: count }, () => text));
|
||||
}, [promptParts, repeatCount]);
|
||||
const codexModels = codexModelOptions(queue, model);
|
||||
const selectedCanSteer = selectedTask?.id && selectedTask?.activeTurnId && String(selectedTask?.status) === "running";
|
||||
const selectedCanInterrupt = selectedTask?.id && !["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
|
||||
const selectedCanRetry = selectedTask?.id && ["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
|
||||
|
||||
async function load(preferId = selectedId): Promise<void> {
|
||||
function publishCachedTask(taskId: string, patch: AnyRecord, token: number): AnyRecord {
|
||||
const cached = sessionCacheRef.current.get(taskId) || {};
|
||||
const existingTask = cached.task || {};
|
||||
const nextTranscript = Object.prototype.hasOwnProperty.call(patch, "transcript")
|
||||
? patch.transcript
|
||||
: (Array.isArray(existingTask.transcript) ? existingTask.transcript : []);
|
||||
const task = {
|
||||
...existingTask,
|
||||
...patch,
|
||||
transcript: nextTranscript,
|
||||
output: Array.isArray(patch.output) ? patch.output : (Array.isArray(existingTask.output) ? existingTask.output : []),
|
||||
events: Array.isArray(patch.events) ? patch.events : (Array.isArray(existingTask.events) ? existingTask.events : []),
|
||||
};
|
||||
const entry = {
|
||||
...cached,
|
||||
task,
|
||||
maxSeq: transcriptMaxSeq(nextTranscript),
|
||||
complete: Boolean(patch._transcriptComplete ?? cached.complete),
|
||||
};
|
||||
sessionCacheRef.current.set(taskId, entry);
|
||||
if (token === detailLoadTokenRef.current && selectedIdRef.current === taskId) setSelectedTask(task);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function loadTaskDetail(taskId: string): Promise<void> {
|
||||
if (!service || !taskId) return;
|
||||
const token = detailLoadTokenRef.current;
|
||||
const cached = sessionCacheRef.current.get(taskId);
|
||||
if (cached?.task) {
|
||||
setSelectedTask(cached.task);
|
||||
setSelectedDetailLoading(false);
|
||||
if (cached.complete && taskIsTerminal(cached.task)) return;
|
||||
} else {
|
||||
setSelectedDetailLoading(true);
|
||||
}
|
||||
const existing = detailInFlightRef.current;
|
||||
if (existing?.taskId === taskId && existing.token === token) return existing.promise;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const meta = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}?meta=1`));
|
||||
if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return;
|
||||
const current = sessionCacheRef.current.get(taskId);
|
||||
const currentTranscript = Array.isArray(current?.task?.transcript) ? current.task.transcript : [];
|
||||
const metaTask = meta?.task || {};
|
||||
publishCachedTask(taskId, { ...metaTask, transcript: currentTranscript, _detailLoaded: currentTranscript.length > 0, _transcriptComplete: current?.complete }, token);
|
||||
const startSeq = currentTranscript.length > 0 && !taskIsTerminal(metaTask)
|
||||
? Math.max(0, transcriptMaxSeq(currentTranscript) - 1)
|
||||
: transcriptMaxSeq(currentTranscript);
|
||||
let afterSeq = current?.complete && taskIsTerminal(metaTask) ? transcriptMaxSeq(currentTranscript) : startSeq;
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const chunk = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}/transcript?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=32`));
|
||||
if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return;
|
||||
const cachedNow = sessionCacheRef.current.get(taskId);
|
||||
const existingTranscript = Array.isArray(cachedNow?.task?.transcript) ? cachedNow.task.transcript : [];
|
||||
const mergedTranscript = mergeTranscriptRows(existingTranscript, Array.isArray(chunk?.transcript) ? chunk.transcript : []);
|
||||
const complete = Boolean(!chunk?.hasMore);
|
||||
publishCachedTask(taskId, {
|
||||
status: chunk?.status || metaTask.status,
|
||||
updatedAt: chunk?.updatedAt || metaTask.updatedAt,
|
||||
transcript: mergedTranscript,
|
||||
_detailLoaded: complete || mergedTranscript.length > 0,
|
||||
_transcriptComplete: complete,
|
||||
}, token);
|
||||
hasMore = Boolean(chunk?.hasMore);
|
||||
afterSeq = Number(chunk?.nextAfterSeq ?? transcriptMaxSeq(mergedTranscript));
|
||||
if (!hasMore) break;
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
} finally {
|
||||
if (detailInFlightRef.current?.taskId === taskId && detailInFlightRef.current?.token === token) detailInFlightRef.current = null;
|
||||
if (token === detailLoadTokenRef.current && selectedIdRef.current === taskId) setSelectedDetailLoading(false);
|
||||
}
|
||||
})();
|
||||
detailInFlightRef.current = { taskId, token, promise };
|
||||
await promise;
|
||||
}
|
||||
|
||||
async function load(preferId = selectedIdRef.current): Promise<void> {
|
||||
if (!service) return;
|
||||
const [healthResult, tasksResult] = await Promise.all([
|
||||
requestJson(`${apiBaseUrl}/microservices/codex-queue/health`),
|
||||
requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=80")),
|
||||
]);
|
||||
const token = queueLoadTokenRef.current + 1;
|
||||
queueLoadTokenRef.current = token;
|
||||
const requestedId = String(preferId || selectedIdRef.current || "");
|
||||
const healthResult = await requestJson(`${apiBaseUrl}/microservices/codex-queue/health`);
|
||||
const tasksResult = await loadTaskQueue(apiBaseUrl, healthResult);
|
||||
if (token !== queueLoadTokenRef.current) return;
|
||||
setHealth(healthResult);
|
||||
setTasksData(tasksResult);
|
||||
const rows = taskRows(tasksResult);
|
||||
const nextId = preferId && rows.some((task: any) => task.id === preferId)
|
||||
? preferId
|
||||
const latestRequestedId = requestedId || selectedIdRef.current;
|
||||
const nextId = latestRequestedId && rows.some((task: any) => task.id === latestRequestedId)
|
||||
? latestRequestedId
|
||||
: (tasksResult?.queue?.activeTaskId || rows.find((task: any) => taskIsActive(task))?.id || rows[0]?.id || "");
|
||||
const previousId = selectedIdRef.current;
|
||||
if (previousId !== nextId) detailLoadTokenRef.current += 1;
|
||||
selectedIdRef.current = nextId;
|
||||
setSelectedId(nextId);
|
||||
if (nextId) {
|
||||
const detail = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(nextId)}`));
|
||||
setSelectedTask(detail?.task || null);
|
||||
} else {
|
||||
const row = rows.find((task: any) => task.id === nextId);
|
||||
if (row) {
|
||||
const cached = sessionCacheRef.current.get(nextId);
|
||||
if (cached?.task) sessionCacheRef.current.set(nextId, { ...cached, task: { ...row, ...cached.task, status: row.status, updatedAt: row.updatedAt } });
|
||||
}
|
||||
if (nextId) void loadTaskDetail(nextId).catch((err) => setError(errorMessage(err, "加载 Codex session 详情失败")));
|
||||
else {
|
||||
detailLoadTokenRef.current += 1;
|
||||
setSelectedTask(null);
|
||||
setSelectedDetailLoading(false);
|
||||
}
|
||||
setRefreshedAt(new Date());
|
||||
}
|
||||
@@ -278,12 +565,13 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
|
||||
async function enqueue(event: any): Promise<void> {
|
||||
event.preventDefault();
|
||||
await guarded(async () => {
|
||||
if (promptParts.length === 0) throw new Error("prompt 不能为空");
|
||||
const body = promptParts.length === 1
|
||||
? { prompt: promptParts[0], model, cwd, maxAttempts: Number(maxAttempts) }
|
||||
: { tasks: promptParts.map((text) => ({ prompt: text, model, cwd, maxAttempts: Number(maxAttempts) })) };
|
||||
const result = await requestJson(codexApi(apiBaseUrl, promptParts.length === 1 ? "/api/tasks" : "/api/tasks/batch"), { method: "POST", body });
|
||||
if (enqueueItems.length === 0) throw new Error("prompt 不能为空");
|
||||
const body = enqueueItems.length === 1
|
||||
? { prompt: enqueueItems[0], model, cwd, maxAttempts: Number(maxAttempts) }
|
||||
: { tasks: enqueueItems.map((text) => ({ prompt: text, model, cwd, maxAttempts: Number(maxAttempts) })) };
|
||||
const result = await requestJson(codexApi(apiBaseUrl, enqueueItems.length === 1 ? "/api/tasks" : "/api/tasks/batch"), { method: "POST", body });
|
||||
const firstId = result?.tasks?.[0]?.id || "";
|
||||
selectedIdRef.current = firstId;
|
||||
await load(firstId);
|
||||
}, "Codex 任务入队失败");
|
||||
}
|
||||
@@ -314,17 +602,85 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
|
||||
}, "重新入队失败");
|
||||
}
|
||||
|
||||
function selectTask(taskId: string): void {
|
||||
selectedIdRef.current = taskId;
|
||||
detailLoadTokenRef.current += 1;
|
||||
setSelectedId(taskId);
|
||||
const cached = sessionCacheRef.current.get(taskId);
|
||||
if (cached?.task) {
|
||||
setSelectedTask(cached.task);
|
||||
setSelectedDetailLoading(false);
|
||||
} else {
|
||||
setSelectedDetailLoading(true);
|
||||
const row = tasks.find((task: any) => task.id === taskId);
|
||||
if (row) setSelectedTask(row);
|
||||
else setSelectedTask(null);
|
||||
}
|
||||
void load(taskId).catch((err) => setError(errorMessage(err, "切换 Codex session 失败")));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void guarded(() => load(), "Codex Queue 加载失败");
|
||||
void guarded(() => load(selectedIdRef.current), "Codex Queue 加载失败");
|
||||
}, [service?.id, service?.runtime?.providerStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!service) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
void load(selectedId).catch((err) => setError(errorMessage(err, "Codex Queue 轮询失败")));
|
||||
void load(selectedIdRef.current).catch((err) => setError(errorMessage(err, "Codex Queue 轮询失败")));
|
||||
}, 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [service?.id, selectedId]);
|
||||
}, [service?.id]);
|
||||
|
||||
const taskListContent = tasks.length === 0 ? h(EmptyState, { title: "队列为空", text: "提交一个任务后,Codex 会串行执行并保存输出。" }) : [
|
||||
h(TaskListSection, {
|
||||
key: "active",
|
||||
title: "运行 / 排队",
|
||||
tasks: queuedTasks,
|
||||
selectedId,
|
||||
emptyText: "当前没有运行或排队任务。",
|
||||
onSelect: selectTask,
|
||||
}),
|
||||
h(TaskListSection, {
|
||||
key: "history",
|
||||
title: "历史 session",
|
||||
tasks: historyTasks,
|
||||
selectedId,
|
||||
emptyText: "最近没有完成、失败或取消的 session。",
|
||||
onSelect: selectTask,
|
||||
}),
|
||||
];
|
||||
|
||||
const sessionPanel = h(Panel, {
|
||||
title: selectedTask ? `Session ${String(selectedTask.id).slice(0, 22)}` : "Session 输出",
|
||||
eyebrow: selectedTask ? `${selectedTask.status} / ${selectedTask.model}` : "Codex CLI-like stream",
|
||||
actions: h("div", { className: "panel-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => setQueueSidebarOpen((value: boolean) => !value), "data-testid": "codex-queue-sidebar-toggle" }, queueSidebarOpen ? "收起队列" : "显示队列"),
|
||||
h("label", { className: "inline-check" }, h("input", { type: "checkbox", checked: autoScroll, onChange: (event: any) => setAutoScroll(Boolean(event.target.checked)) }), "自动滚动"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanInterrupt || busy, onClick: () => void interrupt(), "data-testid": "codex-interrupt-button" }, "打断"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanRetry || busy, onClick: () => void retry() }, "重试"),
|
||||
selectedTask ? h(RawButton, { title: "Codex Task", data: selectedTask, onOpen: onRaw, testId: "raw-codex-task" }) : null,
|
||||
),
|
||||
className: "codex-output-panel",
|
||||
},
|
||||
h("div", { className: `codex-session-shell ${queueSidebarOpen ? "" : "queue-collapsed"}` },
|
||||
queueSidebarOpen ? h("aside", { className: "codex-session-sidebar", "data-testid": "codex-session-sidebar" },
|
||||
h("div", { className: "codex-session-sidebar-head" },
|
||||
h("div", null,
|
||||
h("span", null, "Queue"),
|
||||
h("strong", null, `${tasks.length} sessions`),
|
||||
),
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => setQueueSidebarOpen(false) }, "收起"),
|
||||
),
|
||||
h("div", { className: "codex-task-list codex-task-list-session" }, taskListContent),
|
||||
) : null,
|
||||
h("div", { className: "codex-session-main" },
|
||||
h("div", { className: "codex-output-stack" },
|
||||
h(Transcript, { task: selectedTask, autoScroll, loading: selectedDetailLoading }),
|
||||
h(RawTranscript, { task: selectedTask }),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!service) return h(EmptyState, { title: "Codex Queue 未登记", text: "请在 config.json 的 microservices 中登记 id=codex-queue" });
|
||||
|
||||
@@ -350,7 +706,7 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
h("span", null, "Codex"),
|
||||
h("strong", null, queue?.defaultModel || "gpt-5.4-mini"),
|
||||
h("code", null, "codex app-server --listen stdio://"),
|
||||
h("code", null, `models: ${codexModels.join(" / ")}`),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
h("span", null, "Backend"),
|
||||
@@ -367,50 +723,33 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
|
||||
h(MetricCard, { label: "异常/取消", value: String(Number(counts.failed || 0) + Number(counts.canceled || 0)), hint: "terminal non-success", tone: Number(counts.failed || 0) > 0 ? "fail" : "" }),
|
||||
h(MetricCard, { label: "最近刷新", value: refreshedAt ? fmtClock(refreshedAt) : "--", hint: "1.5s polling" }),
|
||||
),
|
||||
h("div", { className: "codex-session-stage" }, sessionPanel),
|
||||
h("div", { className: "codex-queue-layout" },
|
||||
h("div", { className: "codex-left-rail" },
|
||||
h(Panel, { title: "提交任务", eyebrow: promptParts.length > 1 ? `${promptParts.length} tasks` : "Single or Batch", className: "codex-compose-panel" },
|
||||
h(Panel, { title: "提交任务", eyebrow: enqueueItems.length > 1 ? `${enqueueItems.length} tasks` : "Single or Batch", className: "codex-compose-panel" },
|
||||
h("form", { className: "codex-task-form", onSubmit: enqueue, "data-testid": "codex-queue-task-form" },
|
||||
h("label", null, "Prompt / 多任务用单独一行 --- 分隔",
|
||||
h("textarea", { value: prompt, rows: 8, onChange: (event: any) => setPrompt(event.target.value), placeholder: "写入 Codex 任务;多个任务之间用 --- 分隔。" }),
|
||||
),
|
||||
h("div", { className: "codex-form-grid" },
|
||||
h("label", null, "模型", h("input", { value: model, onChange: (event: any) => setModel(event.target.value), placeholder: "gpt-5.4-mini" })),
|
||||
h("label", null, "工作目录", h("input", { value: cwd, onChange: (event: any) => setCwd(event.target.value), placeholder: "/workspace" })),
|
||||
h("label", null, "模型",
|
||||
h("select", { value: model, onChange: (event: any) => setModel(event.target.value), "data-testid": "codex-model-select" },
|
||||
codexModels.map((name) => h("option", { key: name, value: name }, name)),
|
||||
),
|
||||
),
|
||||
h("label", null, "工作目录", h("input", { value: cwd, onChange: (event: any) => setCwd(event.target.value), placeholder: queue?.defaultWorkdir || "/root/unidesk" })),
|
||||
h("label", null, "最大尝试", h("input", { type: "number", min: 1, max: 10, value: maxAttempts, onChange: (event: any) => setMaxAttempts(Number(event.target.value)) })),
|
||||
h("label", null, "入队份数", h("input", { type: "number", min: 1, max: 50, value: repeatCount, onChange: (event: any) => setRepeatCount(Number(event.target.value)), "data-testid": "codex-repeat-count-input" })),
|
||||
),
|
||||
h("button", { type: "submit", className: "primary-btn", disabled: busy || promptParts.length === 0 }, promptParts.length > 1 ? `批量入队 ${promptParts.length} 个任务` : "入队并运行"),
|
||||
),
|
||||
),
|
||||
h(Panel, { title: "队列", eyebrow: `${tasks.length} visible` },
|
||||
h("div", { className: "codex-task-list" },
|
||||
tasks.length === 0 ? h(EmptyState, { title: "队列为空", text: "提交一个任务后,Codex 会串行执行并保存输出。" }) : tasks.map((task: any) => h(TaskCard, {
|
||||
key: task.id,
|
||||
task,
|
||||
selected: selectedId === task.id,
|
||||
onSelect: () => {
|
||||
setSelectedId(task.id);
|
||||
void load(task.id);
|
||||
},
|
||||
})),
|
||||
h("button", { type: "submit", className: "primary-btn", disabled: busy || enqueueItems.length === 0, "data-testid": "codex-enqueue-button" }, enqueueItems.length > 1 ? `批量入队 ${enqueueItems.length} 个任务` : "入队并运行"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h("div", { className: "codex-main-stage" },
|
||||
h(Panel, {
|
||||
title: selectedTask ? `Session ${String(selectedTask.id).slice(0, 22)}` : "Session 输出",
|
||||
eyebrow: selectedTask ? `${selectedTask.status} / ${selectedTask.model}` : "Codex CLI-like stream",
|
||||
actions: h("div", { className: "panel-actions" },
|
||||
h("label", { className: "inline-check" }, h("input", { type: "checkbox", checked: autoScroll, onChange: (event: any) => setAutoScroll(Boolean(event.target.checked)) }), "自动滚动"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanInterrupt || busy, onClick: () => void interrupt(), "data-testid": "codex-interrupt-button" }, "打断"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanRetry || busy, onClick: () => void retry() }, "重试"),
|
||||
selectedTask ? h(RawButton, { title: "Codex Task", data: selectedTask, onOpen: onRaw, testId: "raw-codex-task" }) : null,
|
||||
),
|
||||
className: "codex-output-panel",
|
||||
},
|
||||
h(Transcript, { task: selectedTask, autoScroll }),
|
||||
),
|
||||
h("div", { className: "codex-detail-grid" },
|
||||
h(Panel, { title: "Prompt 全量", eyebrow: selectedTask ? String(selectedTask.id) : "selected task", className: "codex-prompt-panel" },
|
||||
h(PromptDetail, { task: selectedTask }),
|
||||
),
|
||||
h(Panel, { title: "运行控制", eyebrow: selectedCanSteer ? "Active turn steer" : "Steer when running" },
|
||||
h("form", { className: "codex-steer-form", onSubmit: steer },
|
||||
h("label", null, "追加 prompt",
|
||||
|
||||
@@ -295,6 +295,7 @@ function isStaticAssetPath(pathname: string): boolean {
|
||||
const server = Bun.serve({
|
||||
port: config.port,
|
||||
hostname: "0.0.0.0",
|
||||
idleTimeout: 120,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
logger("debug", "request", { path: url.pathname });
|
||||
|
||||
Reference in New Issue
Block a user