Files
pikasTech-unidesk/src/components/frontend/src/codex-queue.tsx
T
Codex 41fdaba973 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.
2026-05-08 03:57:53 +00:00

775 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from "react";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect, useMemo, useRef } = React;
const useState: any = React.useState;
function errorMessage(error: unknown, fallback = "操作失败"): string {
return error instanceof Error ? error.message : String(error || fallback);
}
function fmtDate(value: any): string {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", { hour12: false });
}
function fmtClock(value: Date): string {
return value.toLocaleTimeString("zh-CN", { hour12: false });
}
function shortText(value: any, max = 180): string {
const text = String(value || "").replace(/\s+/gu, " ").trim();
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
const headers = new Headers(options.headers || {});
const body = options.body && typeof options.body !== "string" ? JSON.stringify(options.body) : options.body;
if (body && !headers.has("content-type")) headers.set("content-type", "application/json");
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);
(error as Error & { status?: number }).status = response.status;
throw error;
}
return payload;
}
function StatusBadge({ status, children }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
}
function MetricCard({ label, value, hint, tone }: AnyRecord) {
return h("article", { className: `metric-card ${tone || ""}` },
h("div", { className: "metric-label" }, label),
h("div", { className: "metric-value" }, value),
h("div", { className: "metric-hint" }, hint),
);
}
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h("h2", null, title),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
return h("button", {
type: "button",
className: "ghost-btn",
"data-testid": testId,
onClick: () => onOpen(title, data),
}, "查看原始JSON");
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function microserviceRuntime(service: any): AnyRecord {
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
}
function microserviceBackend(service: any): AnyRecord {
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
}
function microserviceRepository(service: any): AnyRecord {
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
}
function codexApi(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/microservices/codex-queue/proxy${path}`;
}
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 : [];
}
function queueCounts(queue: any): AnyRecord {
return queue?.counts && typeof queue.counts === "object" && !Array.isArray(queue.counts) ? queue.counts : {};
}
function splitPromptTasks(prompt: string): string[] {
return prompt
.split(/^\s*---+\s*$/gmu)
.map((part) => part.trim())
.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",
user: "YOU",
assistant: "GPT",
reasoning: "THINK",
command: "CMD",
diff: "DIFF",
tool: "TOOL",
error: "ERR",
};
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", {
type: "button",
className: `codex-task-card ${selected ? "selected" : ""}`,
onClick: onSelect,
"data-testid": `codex-task-${task?.id || "unknown"}`,
},
h("div", { className: "codex-task-card-head" },
h(StatusBadge, { status: task?.status }, task?.status || "unknown"),
h("span", { className: "mono-text" }, `${task?.currentAttempt || 0}/${task?.maxAttempts || 0}`),
),
h("strong", null, shortText(task?.prompt, 120) || "空任务"),
h("div", { className: "codex-task-meta" },
h("span", null, task?.model || "--"),
h("span", null, fmtDate(task?.updatedAt)),
),
judge?.decision ? h("div", { className: "codex-judge-line" }, `judge=${judge.decision} ${Math.round(Number(judge.confidence || 0) * 100)}%`) : null,
);
}
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 transcript = taskTranscript(task);
useEffect(() => {
if (autoScroll && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [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" },
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 || "")),
)),
),
);
}
function AttemptTable({ task }: AnyRecord) {
const attempts = taskAttempts(task).slice().reverse();
if (attempts.length === 0) return h(EmptyState, { title: "尚无 attempt", text: "任务开始运行后,这里会记录 Codex 终态、传输中断和 stderr tail。" });
return h("div", { className: "table-wrap codex-attempt-table" },
h("table", null,
h("thead", null, h("tr", null,
h("th", null, "#"),
h("th", null, "模式"),
h("th", null, "终态"),
h("th", null, "传输"),
h("th", null, "退出"),
h("th", null, "完成时间"),
)),
h("tbody", null, attempts.map((attempt: any) => h("tr", { key: `${attempt.index}-${attempt.startedAt}` },
h("td", null, attempt.index),
h("td", null, attempt.mode),
h("td", null, h(StatusBadge, { status: attempt.terminalStatus || "unknown" }, attempt.terminalStatus || "unknown")),
h("td", null, attempt.transportClosedBeforeTerminal ? h(StatusBadge, { status: "failed" }, "closed-before-terminal") : h(StatusBadge, { status: "succeeded" }, "normal")),
h("td", null, `code=${attempt.appServerExitCode ?? "--"} signal=${attempt.appServerSignal ?? "--"}`),
h("td", null, fmtDate(attempt.finishedAt)),
))),
),
);
}
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("/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 || "";
const runtime = service ? microserviceRuntime(service) : {};
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 || ""));
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 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 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);
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());
}
async function guarded(action: () => Promise<void>, message: string): Promise<void> {
setBusy(true);
setError("");
try {
await action();
} catch (err) {
setError(errorMessage(err, message));
} finally {
setBusy(false);
}
}
async function enqueue(event: any): Promise<void> {
event.preventDefault();
await guarded(async () => {
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 任务入队失败");
}
async function steer(event: any): Promise<void> {
event.preventDefault();
if (!selectedTask?.id) return;
await guarded(async () => {
await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/steer`), { method: "POST", body: { prompt: steerPrompt } });
setSteerPrompt("");
await load(selectedTask.id);
}, "追加 prompt 失败");
}
async function interrupt(): Promise<void> {
if (!selectedTask?.id) return;
await guarded(async () => {
await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/interrupt`), { method: "POST", body: {} });
await load(selectedTask.id);
}, "打断 Codex session 失败");
}
async function retry(): Promise<void> {
if (!selectedTask?.id) return;
await guarded(async () => {
await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/retry`), { method: "POST", body: {} });
await load(selectedTask.id);
}, "重新入队失败");
}
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(selectedIdRef.current), "Codex Queue 加载失败");
}, [service?.id, service?.runtime?.providerStatus]);
useEffect(() => {
if (!service) return undefined;
const timer = window.setInterval(() => {
void load(selectedIdRef.current).catch((err) => setError(errorMessage(err, "Codex Queue 轮询失败")));
}, 1500);
return () => window.clearInterval(timer);
}, [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" });
return h("div", { className: "codex-queue-page", "data-testid": "codex-queue-page" },
h(Panel, {
title: "Codex Queue 控制台",
eyebrow: "App-Server Task Deck",
actions: h("div", { className: "panel-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: () => void guarded(() => load(selectedId), "刷新失败"), disabled: busy, "data-testid": "codex-refresh-button" }, busy ? "同步中" : "刷新"),
h(RawButton, { title: "Codex Queue Microservice", data: service, onOpen: onRaw, testId: "raw-codex-queue-service" }),
),
},
h("div", { className: "codex-queue-hero" },
h("div", null,
h("div", { className: "node-version-line" },
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
h("span", null, service.providerId),
h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
h("span", null, queue?.judgeConfigured ? `MiniMax ${queue?.minimaxModel || "M2.7"}` : "Fallback judge"),
),
h("p", { className: "muted paragraph" }, service.description),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "Codex"),
h("strong", null, queue?.defaultModel || "gpt-5.4-mini"),
h("code", null, `models: ${codexModels.join(" / ")}`),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "Backend"),
h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
h("code", null, repository.containerName || "codex-queue-backend"),
),
),
error ? h("div", { className: "form-error wide" }, error) : null,
),
h("div", { className: "codex-queue-metrics" },
h(MetricCard, { label: "排队", value: countValue(counts, "queued"), hint: "waiting turns" }),
h(MetricCard, { label: "运行", value: countValue(counts, "running"), hint: activeTaskId ? `active ${String(activeTaskId).slice(0, 16)}` : "idle", tone: activeTaskId ? "warn" : "ok" }),
h(MetricCard, { label: "成功", value: countValue(counts, "succeeded"), hint: "completed tasks", tone: "ok" }),
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: 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("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 || enqueueItems.length === 0, "data-testid": "codex-enqueue-button" }, enqueueItems.length > 1 ? `批量入队 ${enqueueItems.length} 个任务` : "入队并运行"),
),
),
),
h("div", { className: "codex-main-stage" },
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",
h("textarea", { value: steerPrompt, rows: 4, onChange: (event: any) => setSteerPrompt(event.target.value), placeholder: "给正在运行的 Codex session 推入新的指令或纠偏。", disabled: !selectedCanSteer }),
),
h("button", { type: "submit", className: "primary-btn", disabled: !selectedCanSteer || busy || steerPrompt.trim().length === 0, "data-testid": "codex-steer-button" }, "推入运行中 session"),
),
),
h(Panel, { title: "完成判定", eyebrow: selectedTask?.lastJudge ? selectedTask.lastJudge.source : "judge" },
selectedTask?.lastJudge ? h("div", { className: "codex-judge-card" },
h(StatusBadge, { status: selectedTask.lastJudge.decision }, selectedTask.lastJudge.decision),
h("strong", null, `${Math.round(Number(selectedTask.lastJudge.confidence || 0) * 100)}% confidence`),
h("p", null, selectedTask.lastJudge.reason || "--"),
selectedTask.lastJudge.continuePrompt ? h("code", null, shortText(selectedTask.lastJudge.continuePrompt, 220)) : null,
) : h(EmptyState, { title: "尚未判定", text: "Codex turn 结束后会由 MiniMax M2.7 或 fallback judge 判定 complete/retry/failretry 会在已有 thread 追加继续执行 prompt。" }),
),
),
h(Panel, { title: "Attempts", eyebrow: "terminal vs interruption" }, h(AttemptTable, { task: selectedTask })),
),
),
);
}