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:
@@ -95,7 +95,7 @@
|
||||
|
||||
## T23 Main Server Codex Queue Microservice
|
||||
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `codex-queue` 显示为 `providerId=main-server`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、`codex-queue:4222` 后端映射和 `codex-queue-backend` 容器摘要;运行 `bun scripts/cli.ts server rebuild codex-queue` 并用 `bun scripts/cli.ts job status latest` 等待成功,再运行 `bun scripts/cli.ts microservice health codex-queue` 和 `bun scripts/cli.ts microservice proxy codex-queue /api/tasks`,确认链路通过 backend-core、main-server provider-gateway 和 Codex Queue 后端。随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / Codex Queue`,确认页面显示默认模型 `gpt-5.4-mini`、队列指标、任务提交表单、Codex CLI-like 输出、attempt 表、MiniMax/fallback judge 状态、追加 prompt、打断和重试控件;通过页面提交一个小任务,确认任务进入 queued/running/succeeded 或可解释的 failed 状态,并且输出区能看到运行中的 Codex 消息。测试异常中断时可以提交长任务后点击 `打断`,确认任务变为 canceled 或被 judge 标记为非成功终态;自动重试只应在服务端/传输异常、任务正常结束但 execution record 显示未完成、或 judge 判定 retry 时发生;retry 必须复用已有 Codex thread 并 append 继续执行 prompt,只有当前任务 complete 后才推进队列中的下一个任务。Codex provider key 只能通过 `OPENAI_API_KEY`、`CRS_OAI_KEY` 这类运行时环境透传,MiniMax API key 只能通过 `UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY` 这类运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `codex-queue` 显示为 `providerId=main-server`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、`codex-queue:4222` 后端映射和 `codex-queue-backend` 容器摘要;运行 `bun scripts/cli.ts server rebuild codex-queue` 并用 `bun scripts/cli.ts job status latest` 等待成功,再运行 `bun scripts/cli.ts microservice health codex-queue`、`bun scripts/cli.ts microservice proxy codex-queue /api/dev-ready --raw` 和 `bun scripts/cli.ts microservice proxy codex-queue /api/tasks`,确认链路通过 backend-core、main-server provider-gateway 和 Codex Queue 后端,且 `queue.devReady.ok=true`、`devReady.missingTools=[]`、`docker.versionOk=true`、`docker.composeOk=true`,必需工具包含 `docker`、`docker-compose`、`jq`、`ssh`、`rsync`、`pip3` 和 `unzip`;提交会产生较多命令输出的小任务后,`/health` 和 `/api/tasks` 仍必须在常规 CLI 超时内返回,容器内不得堆积无超时 healthcheck 进程。随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `微服务 / Codex Queue`,确认页面显示默认模型 `gpt-5.4-mini`、默认工作目录 `/root/unidesk`、模型下拉菜单包含 `gpt-5.4-mini`/`gpt-5.4`/`gpt-5.5`、入队份数、队列指标、任务提交表单、Codex CLI-like 输出、attempt 表、MiniMax/fallback judge 状态、追加 prompt、打断和重试控件;通过页面提交一个小任务,确认任务进入 queued/running/succeeded 或可解释的 failed 状态,并且输出区能看到运行中的 Codex 消息。批量验收时设置 `入队份数=5` 或用 `---` 分隔 5 段 prompt,一次性入队 5 条任务,确认 5 条任务按顺序运行并全部进入 succeeded 或可解释的非成功终态,不能只运行第一条后停止。测试异常中断时可以提交长任务后点击 `打断`,确认任务变为 canceled 或被 judge 标记为非成功终态;自动重试只应在服务端/传输异常、任务正常结束但 execution record 显示未完成、或 judge 判定 retry 时发生;retry 必须复用已有 Codex thread 并 append 继续执行 prompt,只有当前任务 complete 后才推进队列中的下一个任务。MiniMax judge 必须能处理 Markdown fence/夹杂文本等 JSON 去噪;若去噪后仍失败,必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 修复后重试,日志中应出现 `judge_json_parse_retry`,且 repair 成功时仍以 `source=minimax` 返回。Codex provider key 只能通过 `OPENAI_API_KEY`、`CRS_OAI_KEY` 这类运行时环境透传,MiniMax API key 只能通过 `UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY` 这类运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。
|
||||
|
||||
## T24 MET Nonlinear D601 GPU Microservice
|
||||
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@
|
||||
"/api/"
|
||||
],
|
||||
"healthPath": "/health",
|
||||
"timeoutMs": 30000
|
||||
"timeoutMs": 90000
|
||||
},
|
||||
"development": {
|
||||
"providerId": "D601",
|
||||
|
||||
+6
-2
@@ -105,10 +105,11 @@ services:
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "4222"
|
||||
CODEX_QUEUE_STATE_PATH: "/var/lib/unidesk/codex-queue/state.json"
|
||||
CODEX_QUEUE_WORKDIR: "/workspace"
|
||||
CODEX_QUEUE_WORKDIR: "/root/unidesk"
|
||||
CODEX_QUEUE_CODEX_HOME: "/var/lib/unidesk/codex-queue/codex-home"
|
||||
CODEX_QUEUE_SOURCE_CODEX_CONFIG: "/root/.codex/config.toml"
|
||||
CODEX_QUEUE_DEFAULT_MODEL: "gpt-5.4-mini"
|
||||
CODEX_QUEUE_MODELS: "gpt-5.4-mini,gpt-5.4,gpt-5.5"
|
||||
CODEX_QUEUE_SANDBOX: "danger-full-access"
|
||||
CODEX_QUEUE_APPROVAL_POLICY: "never"
|
||||
CODEX_QUEUE_MAX_ATTEMPTS: "3"
|
||||
@@ -117,9 +118,12 @@ services:
|
||||
MINIMAX_API_KEY: "${UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY:-}"
|
||||
MINIMAX_API_BASE: "${UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE:-https://api.minimaxi.com/v1}"
|
||||
MINIMAX_MODEL: "${UNIDESK_CODEX_QUEUE_MINIMAX_MODEL:-MiniMax-M2.7}"
|
||||
MINIMAX_JUDGE_TIMEOUT_MS: "${UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS:-60000}"
|
||||
MINIMAX_JUDGE_REPAIR_ATTEMPTS: "${UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_REPAIR_ATTEMPTS:-2}"
|
||||
LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_PREFIX}_codex-queue.jsonl"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- .:/root/unidesk
|
||||
- .:/workspace
|
||||
- /root/.codex/config.toml:/root/.codex/config.toml:ro
|
||||
- ${UNIDESK_LOG_DIR}:/var/log/unidesk
|
||||
@@ -127,7 +131,7 @@ services:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:4222/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
test: ["CMD-SHELL", "curl -fsS --max-time 2 http://127.0.0.1:4222/health >/dev/null"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
@@ -58,7 +58,7 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL
|
||||
- `服务目录` 必须显示 service id、Provider、仓库 URL、commit id、业务 Dockerfile/docker-compose 引用、节点后端私有映射、SSH 透传开发入口和运行态容器摘要。
|
||||
- `Todo Note` 子标签必须把主 server `todo-note-backend` 后端渲染为 UniDesk React 控件,包括迁移清单、树形任务、筛选、提醒、拖放/移动、撤销/重做、字号控制和显式原始 JSON 按钮。
|
||||
- `FindJob` 子标签必须把 D601 findjob 后端渲染为 UniDesk React 控件,包括岗位指标、岗位预览、草稿报告和显式原始 JSON 按钮。
|
||||
- `Codex Queue` 子标签必须把主 server `codex-queue-backend` 后端渲染为 UniDesk React 控件,包括串行队列、任务提交/批量提交、默认模型 `gpt-5.4-mini`、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、运行中追加 prompt、打断、手动重试和显式原始 JSON 按钮。
|
||||
- `Codex Queue` 子标签必须把主 server `codex-queue-backend` 后端渲染为 UniDesk React 控件,包括串行队列、任务提交/批量提交、模型下拉、显式入队份数、默认模型 `gpt-5.4-mini`、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、运行中追加 prompt、打断、手动重试和显式原始 JSON 按钮;连续执行同一 prompt 应通过入队份数一次性生成多条任务,避免快速连点造成操作员误判。
|
||||
- 业务 microservice 页面不得 iframe 业务旧前端、Todo Note 原 Vite 前端或 Pipeline 自身 WebUI,不得把 microservice 后端端口暴露为浏览器直连 URL,也不得把业务 API 的 JSON 裸铺在页面上。
|
||||
- `Pipeline` 子标签是 D601 `/home/ubuntu/pipeline` 的 UniDesk host UI。
|
||||
- Pipeline 仓库自带 WebUI 前端已经废弃;UniDesk frontend 是唯一用户可见的 Pipeline UI。
|
||||
|
||||
@@ -56,15 +56,16 @@ Todo Note 数据迁移后必须验证:`microservice proxy todo-note /api/insta
|
||||
- 代码引用:`https://github.com/pikasTech/unidesk` 与配置中的 `repository.commitId`;服务源码位于 `src/components/microservices/codex-queue`,属于 UniDesk 自有控制面组件。
|
||||
- 部署引用:UniDesk 根仓库 `docker-compose.yml` 中的 `codex-queue` service,Dockerfile 为 `src/components/microservices/codex-queue/Dockerfile`,容器名为 `codex-queue-backend`。
|
||||
- Codex 认证:容器只从主 server 的 `/root/.codex/config.toml` 同步 Codex provider 配置到 `.state/codex-queue/codex-home`,并通过运行时环境透传 `OPENAI_API_KEY`、`CRS_OAI_KEY` 等 provider 所需变量;新增 provider 的 `env_key` 时必须增加同类运行时透传,禁止把 Codex 或 MiniMax 密钥写入仓库文件。
|
||||
- Develop-ready 镜像:Codex Queue 镜像必须在启动前预装 UniDesk/Pipeline 调试所需工具,至少包含 `codex`、`bun`、`node`、`npm`/`npx`、`git`、`rg`、`curl`、`python3`/`pip3`、`docker`、`docker compose`、`docker-compose`、`jq`、`ssh`、`rsync`、`make`、`gcc`/`g++`、`tar`、`gzip` 和 `unzip`;不得依赖 Codex 任务运行时再 `apt-get install` 这些基础环境。
|
||||
- Codex 控制:服务内部启动 `codex app-server --listen stdio://`,用 JSON-RPC 调用 `thread/start`、`turn/start`、`turn/steer` 和 `turn/interrupt`,并监听 `turn/completed`、assistant delta、reasoning delta、command output delta、file diff delta 等通知生成前端可轮询的 transcript。
|
||||
- 队列语义:`POST /api/tasks` 或 `/api/tasks/batch` 入队,服务始终只运行一个 Codex turn;当前任务真正终止后才推进下一个任务。`GET /api/tasks` 与 `GET /api/tasks/{id}` 返回队列、attempt、judge 和输出;`POST /api/tasks/{id}/steer` 向运行中 turn 推入 prompt;`POST /api/tasks/{id}/interrupt` 或 `DELETE /api/tasks/{id}` 打断/取消;`POST /api/tasks/{id}/retry` 手动重试。
|
||||
- 完成判定:app-server `turn/completed` 的 `turn.status=completed|interrupted|failed` 只代表 Codex turn 已结束;即使 `completed` 也必须把原始任务、assistant 最终回复、command/file-change 事件、stderr tail 和 recent events 组成 execution record 交给 judge 判断是否真的完成。配置了 `UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY` 时使用 MiniMax `MiniMax-M2.7` 判定 `complete|retry|fail`,否则使用 fallback 规则。
|
||||
- 队列语义:`POST /api/tasks` 或 `/api/tasks/batch` 入队,服务始终只运行一个 Codex turn;当前任务真正终止后才推进下一个任务。`GET /api/tasks` 与 `GET /api/tasks/{id}` 返回队列、attempt、judge 和输出;`POST /api/tasks/{id}/steer` 向运行中 turn 推入 prompt;`POST /api/tasks/{id}/interrupt` 或 `DELETE /api/tasks/{id}` 打断/取消;`POST /api/tasks/{id}/retry` 手动重试。队列 worker 必须隔离单个 task 的异常,不能因为某个 app-server 或 judge 异常让后续 queued 任务停止;当存在 queued/retry_wait 且 worker 空闲时,watchdog 必须自动重新调度。
|
||||
- 完成判定:app-server `turn/completed` 的 `turn.status=completed|interrupted|failed` 只代表 Codex turn 已结束;即使 `completed` 也必须把原始任务、assistant 最终回复、command/file-change 事件、stderr tail 和 recent events 组成 execution record 交给 judge 判断是否真的完成。配置了 `UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY` 时使用 MiniMax `MiniMax-M2.7` 判定 `complete|retry|fail`,否则使用 fallback 规则。MiniMax 返回必须先做 JSON 去噪,支持去除 Markdown fence、`json` 标签和从夹杂文本中提取平衡 JSON object;如果去噪后仍无法解析,服务必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 做 JSON repair 重试,重试次数由 `UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_REPAIR_ATTEMPTS` 控制,默认 `2`,耗尽后才进入 fallback,并在 fallback 原因中保留 MiniMax 失败信息。
|
||||
- Retry/推进语义:`retry` 不是新开一个独立任务或完全新 session;只要已有 `codexThreadId`,服务必须 `thread/resume` 原 thread 并 append 一个继续执行 prompt。只有 judge 判定 `complete` 后,队列 worker 才把当前任务标为成功并推进下一个 queued/retry_wait 任务。
|
||||
- Judge 探针:`GET|POST /api/judge/probe` 使用同一套 judge 逻辑跑内置 synthetic execution records,覆盖正常完成、正常结束但只给计划、传输中断和用户打断四类样本,返回 `hits`、`total`、`hitRate`、每例 `expected` 与 `decision`;该接口不得回显 MiniMax API key。
|
||||
- 模型选择:默认 Codex 模型是 `gpt-5.4-mini`,每个入队任务可覆盖 `model`、`cwd`、`reasoningEffort` 和 `maxAttempts`。
|
||||
- 状态与日志:队列状态保存在 `.state/codex-queue/state.json` 对应的容器挂载路径,日志写入 UniDesk `logs/{YYYYMMDD}/..._codex-queue.jsonl`,`/logs` 端点返回最近结构化日志。
|
||||
- 模型选择:默认 Codex 模型是 `gpt-5.4-mini`,内置模型队列包含 `gpt-5.4-mini`、`gpt-5.4`、`gpt-5.5`;每个入队任务可通过前端模型下拉菜单或 API 覆盖 `model`、`cwd`、`reasoningEffort` 和 `maxAttempts`。
|
||||
- 状态与日志:默认工作目录为容器内 `/root/unidesk`,该路径映射主 server 的 `~/unidesk`;同时保留 `/workspace` 映射以兼容历史任务。队列状态保存在 `.state/codex-queue/state.json` 对应的容器挂载路径,日志写入 UniDesk `logs/{YYYYMMDD}/..._codex-queue.jsonl`,`/logs` 端点返回最近结构化日志。`/health` 的 `queue.devReady` 和 `/api/dev-ready` 必须暴露 develop-ready 自检,包括必需工具、Docker socket、`docker compose`、默认工作目录和 Codex config 状态。Codex CLI-like 输出可能很大,服务必须对输出条数设上限并节流状态持久化,禁止对每个 output delta 同步重写完整 state 导致 `/health` 和控制 API 卡死;容器 healthcheck 必须使用带超时的 HTTP 探针,不能留下堆积的无超时探针进程。
|
||||
- 代理路径:只允许 `/health`、`/logs` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`DELETE`。Codex Queue 只在 Compose 内网暴露 `4222/tcp`,不得映射或开放到公网。
|
||||
- UniDesk 前端:`微服务 / Codex Queue` React 页面负责展示队列卡片、默认模型、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、追加 prompt、打断和手动重试控件;原始任务 JSON 只能通过显式 `查看原始JSON` 打开。
|
||||
- UniDesk 前端:`微服务 / Codex Queue` React 页面负责展示队列卡片、默认模型、模型下拉、显式入队份数、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、追加 prompt、打断和手动重试控件;连续执行同一 prompt 应使用 `入队份数` 一次性生成多条队列任务,而不是依赖快速连点按钮;原始任务 JSON 只能通过显式 `查看原始JSON` 打开。
|
||||
|
||||
## D601 Microservices
|
||||
|
||||
@@ -157,7 +158,7 @@ microservice 交付必须同时通过后端、CLI 和公网 frontend 验证:
|
||||
- 运行 `bun scripts/cli.ts microservice health pipeline` 与 `bun scripts/cli.ts microservice proxy pipeline '/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3'`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 Pipeline 后端,且 run/procedure 摘要包含甘特图所需时间字段。
|
||||
- 运行 `bun scripts/cli.ts microservice health met-nonlinear`、`bun scripts/cli.ts microservice proxy met-nonlinear /api/queue`、`bun scripts/cli.ts microservice proxy met-nonlinear '/api/projects?root=projects&limit=20'` 和 `bun scripts/cli.ts microservice proxy met-nonlinear /api/images`,确认真实链路经过 backend-core、WebSocket、D601 provider-gateway 和 D601 本机 MET Nonlinear TS 后端。
|
||||
- 运行 `bun scripts/cli.ts microservice health todo-note` 与 `bun scripts/cli.ts microservice proxy todo-note /api/instances`,确认真实链路经过 backend-core、WebSocket、main-server provider-gateway 和主 server `todo-note-backend` 后端;输出中必须包含五个迁移清单和 PostgreSQL 存储健康状态。
|
||||
- 运行 `bun scripts/cli.ts microservice health codex-queue` 与 `bun scripts/cli.ts microservice proxy codex-queue /api/tasks`,确认真实链路经过 backend-core、WebSocket、main-server provider-gateway 和主 server `codex-queue-backend` 后端;再通过 frontend 提交一个 `gpt-5.4-mini` 小任务,确认队列串行推进、输出实时更新、结束后有 judge 判定,且运行中可追加 prompt 或打断。
|
||||
- 运行 `bun scripts/cli.ts microservice health codex-queue` 与 `bun scripts/cli.ts microservice proxy codex-queue /api/tasks`,确认真实链路经过 backend-core、WebSocket、main-server provider-gateway 和主 server `codex-queue-backend` 后端;再通过公网 frontend 提交一个 `gpt-5.4-mini` 小任务,确认队列串行推进、输出实时更新、结束后有 judge 判定,且运行中可追加 prompt 或打断。批量验收必须通过公网 frontend 设置 `入队份数=5` 或使用多段 prompt 分隔,一次性入队 5 条任务,并确认 5 条任务按顺序进入 running/judging/succeeded,而不是只运行第一条。
|
||||
- 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试业务仓库和容器,确认 `curl http://127.0.0.1:3254/api/health` 可用;不要把调试服务部署到主 server。
|
||||
- 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试业务仓库和容器,确认 `curl http://127.0.0.1:18082/health` 和 `curl http://127.0.0.1:18082/api/snapshot` 可用;不要把 Pipeline 调试服务部署到主 server。
|
||||
- 在 D601 上用 `bun scripts/cli.ts ssh D601 ...` 调试 `~/met_nonlinear`,确认 `curl http://127.0.0.1:3288/health` 可用;最终验收必须回到公网 UniDesk frontend,通过项目库选择、Fork、加入待启动队列和启动队列完成,不要把 MET Nonlinear 后端、Docker build 或训练任务部署到主 server。
|
||||
|
||||
@@ -113,7 +113,8 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
UNIDESK_HOST_SSH_USER: config.sshForwarding.user,
|
||||
UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY"),
|
||||
UNIDESK_CODEX_QUEUE_MINIMAX_MODEL: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_MODEL") || "MiniMax-M2.7",
|
||||
UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE") || "https://api.minimax.io/v1",
|
||||
UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE") || "https://api.minimaxi.com/v1",
|
||||
UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS") || "60000",
|
||||
};
|
||||
writeFileSync(envFile, Object.entries(lines).map(([key, value]) => `${key}=${envValue(value)}`).join("\n") + "\n", "utf8");
|
||||
return { envFile, logDir, logPrefix };
|
||||
|
||||
+4
-1
@@ -1113,8 +1113,11 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
const lower = text.toLowerCase();
|
||||
return lower.includes("codex queue 控制台")
|
||||
&& text.includes("gpt-5.4-mini")
|
||||
&& text.includes("gpt-5.4")
|
||||
&& text.includes("gpt-5.5")
|
||||
&& text.includes("仅 UniDesk frontend 代理访问")
|
||||
&& text.includes("提交任务")
|
||||
&& text.includes("入队份数")
|
||||
&& text.includes("追加 prompt")
|
||||
&& text.includes("打断")
|
||||
&& lower.includes("attempts");
|
||||
@@ -1412,7 +1415,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
addSelectedCheck(checks, options, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogTextLower.includes("codex queue") && microserviceCatalogText.includes("D601") && microserviceCatalogText.includes(config.providerGateway.id) && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline") && microserviceCatalogText.includes("https://github.com/pikasTech/met_nonlinear") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note") && microserviceCatalogText.includes("https://github.com/pikasTech/unidesk"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1800) });
|
||||
addSelectedCheck(checks, options, "frontend:todo-note-integrated-visible", todoNoteTextLower.includes("todo note 工作台") && todoNoteText.includes("CONSTAR") && todoNoteText.includes("大论文") && todoNoteText.includes("UI E2E smoke task") && todoNoteText.includes("撤销") && todoNoteText.includes("重做") && todoNoteText.includes("全部展开") && todoNoteText.includes("仅 UniDesk frontend 代理访问"), { todoNoteTextPreview: todoNoteText.slice(0, 1400) });
|
||||
addSelectedCheck(checks, options, "frontend:findjob-integrated-visible", findjobTextLower.includes("findjob 工作台".toLowerCase()) && findjobText.includes("岗位总量") && findjobText.includes("D601") && findjobText.includes("近期岗位") && findjobText.includes("仅 UniDesk frontend 代理访问") && /岗位总量\s+\d+/.test(findjobText) && /health\s+ok/i.test(findjobText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(findjobText), { findjobTextPreview: findjobText.slice(0, 1200) });
|
||||
addSelectedCheck(checks, options, "frontend:codex-queue-integrated-visible", codexQueueTextLower.includes("codex queue 控制台".toLowerCase()) && codexQueueText.includes("gpt-5.4-mini") && codexQueueText.includes("提交任务") && codexQueueText.includes("追加 prompt") && codexQueueText.includes("打断") && codexQueueTextLower.includes("attempts") && codexQueueText.includes("仅 UniDesk frontend 代理访问"), { codexQueueTextPreview: codexQueueText.slice(0, 1400) });
|
||||
addSelectedCheck(checks, options, "frontend:codex-queue-integrated-visible", codexQueueTextLower.includes("codex queue 控制台".toLowerCase()) && codexQueueText.includes("gpt-5.4-mini") && codexQueueText.includes("gpt-5.4") && codexQueueText.includes("gpt-5.5") && codexQueueText.includes("提交任务") && codexQueueText.includes("入队份数") && codexQueueText.includes("追加 prompt") && codexQueueText.includes("打断") && codexQueueTextLower.includes("attempts") && codexQueueText.includes("仅 UniDesk frontend 代理访问"), { codexQueueTextPreview: codexQueueText.slice(0, 1400) });
|
||||
addSelectedCheck(checks, options, "frontend:url-route-deeplink", routeInitialPath === "/app/pipeline/" && routeDockerPath === "/nodes/docker/" && routeBackPath === "/app/pipeline/" && routeOverviewPath === "/ops/status/" && routeDeepLinkText.toLowerCase().includes("pipeline v2 工作台".toLowerCase()) && routeOverviewText.includes("核心指标"), { routeInitialPath, routeDockerPath, routeBackIntermediatePath, routeBackPath, routeOverviewPath, routeDeepLinkPreview: routeDeepLinkText.slice(0, 1200), routeOverviewPreview: routeOverviewText.slice(0, 800) });
|
||||
addSelectedCheck(checks, options, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && pipelineText.includes("评分器") && /epoch\s+甘特图/i.test(pipelineText) && pipelineText.includes("运行材料索引") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) });
|
||||
addSelectedCheck(checks, options, "frontend:pipeline-react-flow-visible", pipelineFlowNodeCount > 0 && pipelineFlowEdgeCount > 0, { pipelineFlowNodeCount, pipelineFlowEdgeCount });
|
||||
|
||||
@@ -1252,6 +1252,7 @@ markStaleTasksFailed().catch((error) => logger("error", "task_timeout_sweep_fail
|
||||
const apiServer = Bun.serve<WsData>({
|
||||
port: config.port,
|
||||
hostname: "0.0.0.0",
|
||||
idleTimeout: 120,
|
||||
fetch: route,
|
||||
websocket: {
|
||||
open(ws) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
FROM oven/bun:1-debian
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git bash ripgrep procps python3 make g++ bubblewrap docker.io npm \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
bubblewrap \
|
||||
ca-certificates \
|
||||
curl \
|
||||
docker-cli \
|
||||
docker-compose \
|
||||
file \
|
||||
g++ \
|
||||
gcc \
|
||||
git \
|
||||
gzip \
|
||||
jq \
|
||||
make \
|
||||
npm \
|
||||
openssh-client \
|
||||
patch \
|
||||
pkg-config \
|
||||
procps \
|
||||
python3 \
|
||||
python3-pip \
|
||||
ripgrep \
|
||||
rsync \
|
||||
tar \
|
||||
tini \
|
||||
unzip \
|
||||
xz-utils \
|
||||
&& mkdir -p /usr/local/lib/docker/cli-plugins /root/.docker/cli-plugins \
|
||||
&& ln -sf /usr/bin/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose \
|
||||
&& ln -sf /usr/bin/docker-compose /root/.docker/cli-plugins/docker-compose \
|
||||
&& npm install -g @openai/codex@0.128.0 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -12,4 +41,5 @@ COPY src/components/microservices/codex-queue/tsconfig.json ./tsconfig.json
|
||||
COPY src/components/microservices/codex-queue/src ./src
|
||||
|
||||
EXPOSE 4222
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user