41fdaba973
Add model selection, batch enqueue controls, dev-ready health checks, transcript pagination, queue watchdog recovery, and MiniMax judge JSON repair for codex-queue.
775 lines
37 KiB
TypeScript
775 lines
37 KiB
TypeScript
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/fail;retry 会在已有 thread 追加继续执行 prompt。" }),
|
||
),
|
||
),
|
||
h(Panel, { title: "Attempts", eyebrow: "terminal vs interruption" }, h(AttemptTable, { task: selectedTask })),
|
||
),
|
||
),
|
||
);
|
||
}
|