From 236b0d1de088a018368bb06d33258bdb22144876 Mon Sep 17 00:00:00 2001 From: rike Date: Mon, 7 Sep 2026 16:48:24 +0800 Subject: [PATCH 1/4] fix(codex): restore navigation and reliable conversation display for 0.7.1 --- .github/workflows/ci.yml | 2 +- Cargo.lock | 14 +- Cargo.toml | 2 +- README.en.md | 12 +- README.md | 12 +- VERSION | 2 +- crates/farhelm-core/src/lib.rs | 2 +- crates/farhelm-protocol/src/lib.rs | 90 +- .../tests/fixtures/session-display.json | 10 + crates/farhelm-test-support/src/lib.rs | 4 +- deploy/README.en.md | 12 +- deploy/README.md | 12 +- farhelm-agent/src/config.rs | 2 +- farhelm-agent/src/experiment_store.rs | 87 ++ farhelm-agent/src/main.rs | 62 +- farhelm-agent/src/management.rs | 2 +- farhelm-agent/src/resources.rs | 8 + farhelm-console/e2e/codex.spec.ts | 182 +++ farhelm-console/e2e/layout.spec.ts | 7 +- farhelm-console/package.json | 18 +- farhelm-console/pnpm-lock.yaml | 1098 +++++++++++++++++ farhelm-console/src/App.tsx | 7 +- farhelm-console/src/api/events.ts | 8 +- farhelm-console/src/api/features.ts | 27 +- farhelm-console/src/api/reliability.test.ts | 58 +- farhelm-console/src/api/transcript.ts | 31 +- farhelm-console/src/components/CodexPage.tsx | 152 +-- .../src/components/SettingsPage.tsx | 2 +- .../src/components/codex/Conversation.tsx | 173 +++ .../src/components/codex/Dialogs.tsx | 35 + .../src/components/codex/Markdown.tsx | 63 + .../src/components/codex/Transcript.tsx | 36 + .../src/components/codex/highlight.ts | 14 + .../src/components/codex/markdown.worker.ts | 26 + .../src/components/codex/markdownService.ts | 34 + farhelm-console/src/components/codex/math.ts | 4 + .../src/components/codex/presentation.ts | 11 + .../src/components/codex/useOperations.ts | 61 + .../src/components/codex/useSessions.ts | 77 ++ farhelm-console/src/main.tsx | 2 +- farhelm-console/src/styles.css | 173 ++- farhelm-console/vite.config.ts | 12 + farhelm-hub/src/event_store.rs | 41 +- farhelm-hub/src/lib.rs | 64 +- farhelm-hub/src/notification_routes.rs | 2 +- farhelm-hub/src/session_display.rs | 351 ++++++ farhelm-hub/src/session_display_tests.rs | 416 +++++++ farhelm-worker-codex/README.md | 2 +- farhelm-worker-codex/pyproject.toml | 2 +- .../src/farhelm_worker_codex/__init__.py | 2 +- .../src/farhelm_worker_codex/display.py | 62 + .../src/farhelm_worker_codex/history.py | 6 + .../src/farhelm_worker_codex/text.py | 43 + .../src/farhelm_worker_codex/worker.py | 102 +- .../tests/test_display_stream.py | 129 ++ farhelm-worker-codex/tests/test_worker.py | 19 +- farhelm-worker-codex/uv.lock | 2 +- 57 files changed, 3606 insertions(+), 283 deletions(-) create mode 100644 crates/farhelm-protocol/tests/fixtures/session-display.json create mode 100644 farhelm-console/e2e/codex.spec.ts create mode 100644 farhelm-console/src/components/codex/Conversation.tsx create mode 100644 farhelm-console/src/components/codex/Dialogs.tsx create mode 100644 farhelm-console/src/components/codex/Markdown.tsx create mode 100644 farhelm-console/src/components/codex/Transcript.tsx create mode 100644 farhelm-console/src/components/codex/highlight.ts create mode 100644 farhelm-console/src/components/codex/markdown.worker.ts create mode 100644 farhelm-console/src/components/codex/markdownService.ts create mode 100644 farhelm-console/src/components/codex/math.ts create mode 100644 farhelm-console/src/components/codex/presentation.ts create mode 100644 farhelm-console/src/components/codex/useOperations.ts create mode 100644 farhelm-console/src/components/codex/useSessions.ts create mode 100644 farhelm-hub/src/session_display.rs create mode 100644 farhelm-hub/src/session_display_tests.rs create mode 100644 farhelm-worker-codex/src/farhelm_worker_codex/display.py create mode 100644 farhelm-worker-codex/src/farhelm_worker_codex/text.py create mode 100644 farhelm-worker-codex/tests/test_display_stream.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 310e186..4a53d97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,7 @@ jobs: - run: ./tests/deployment-package.sh - uses: actions/upload-artifact@v4 with: - name: farhelm-V0.7.0-linux-x86_64 + name: farhelm-V0.7.1-linux-x86_64 path: | dist/release/farhelm-* dist/release/SHA256SUMS diff --git a/Cargo.lock b/Cargo.lock index 15c24e5..b739331 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -575,7 +575,7 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "farhelm-agent" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "clap", @@ -603,14 +603,14 @@ dependencies = [ [[package]] name = "farhelm-core" -version = "0.7.0" +version = "0.7.1" dependencies = [ "serde", ] [[package]] name = "farhelm-hub" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "argon2", @@ -644,7 +644,7 @@ dependencies = [ [[package]] name = "farhelm-lifecycle" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "tempfile", @@ -652,7 +652,7 @@ dependencies = [ [[package]] name = "farhelm-protocol" -version = "0.7.0" +version = "0.7.1" dependencies = [ "serde", "serde_json", @@ -662,7 +662,7 @@ dependencies = [ [[package]] name = "farhelm-test-support" -version = "0.7.0" +version = "0.7.1" dependencies = [ "farhelm-protocol", "serde_json", @@ -670,7 +670,7 @@ dependencies = [ [[package]] name = "farhelm-updater" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 75154ef..d11f08d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.7.0" +version = "0.7.1" edition = "2024" rust-version = "1.98" license = "Apache-2.0" diff --git a/README.en.md b/README.en.md index 6e78374..bcf4fea 100644 --- a/README.en.md +++ b/README.en.md @@ -4,14 +4,14 @@

A remote control plane for personal research and GPU training environments

See training-host status from your phone and safely extend remote control without exposing inbound ports on training machines.

- V0.7.0 · + V0.7.1 · Deployment guide · 简体中文

> [!IMPORTANT] -> `V0.7.0` adds training-script reports, a unified notification center, and in-page alerts, with improved Codex queues, large-message continuation, and restart recovery. The Agent persists Codex content; Hub only relays it temporarily. Training continues to run locally. +> `V0.7.1` fixes the Codex workspace: global navigation stays visible, projects, sessions, and execution details have clear hierarchy, and Markdown, math, session labels, and search across all imported sessions render correctly. It also fixes composer placement, streamed message identity, and session-switch races. The Agent persists Codex content; Hub only relays it temporarily. ## Quick install @@ -114,6 +114,14 @@ The notification center provides pagination, type/Agent/result filters, synchron Browser commands and schedules are acknowledged after Agent persistence. Failed submissions retain the current-page draft and operation identity for retries. On restart, saved terminal receipts reconcile completed work; running tasks without a terminal receipt become orphaned and are not replayed. Upgrade Hub before Agent; the new content relay requires the Agent's V0.7 capability. +## Codex workspace + +Desktop global navigation and mobile bottom navigation remain visible in Codex. Project groups collapse, and sessions use formal names or a temporary first-user-message summary. Search covers all imported sessions in the selected scope; offline Agents are clearly marked as incomplete results. Summaries and search results never enter Hub databases, logs, or notification titles. + +Assistant replies support Markdown tables, code copying, math, and safe HTTPS links. Consecutive execution details in a turn form a collapsed group with visible failures. The composer stays visible, and reading older content does not jump to the bottom on new messages. Large-message continuation is separate from loading earlier conversations. Each session retains its draft; steer and interrupt target the visible active turn. + +Upgrade Hub before Agent when moving from V0.7.0. SQLite schema stays at 7. Temporary labels and search require a V0.7.1 Agent; older Agents show an upgrade prompt. + ## What is implemented - `farhelm-hub`: Rust control plane with password login, SQLite-backed 30-day sessions, short-code pairing, Secure HttpOnly cookies, CSRF, login throttling, reliable events, SSE replay, and Web Push. diff --git a/README.md b/README.md index f905b0a..37e651c 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@

面向个人科研与 GPU 训练环境的远程控制平面

从手机查看训练服务器状态,并在不开放训练机入站端口的前提下安全扩展远程控制能力。

- V0.7.0 · + V0.7.1 · 部署文档 · English

> [!IMPORTANT] -> `V0.7.0` 加入训练脚本结果上报、统一通知中心和页面提醒,完善 Codex 排队、长消息续读与重启恢复。Codex 正文由 Agent 持久化,Hub 仅短暂中转。训练继续在本地运行。 +> `V0.7.1` 修复 Codex 工作区:保留系统导航,明确项目、会话与执行过程层级;补齐 Markdown、公式、会话名称与全部已导入会话搜索,并修复输入区、流式消息及切换竞态。Codex 正文由 Agent 持久化,Hub 仅短暂中转。 ## 快速安装 @@ -114,6 +114,14 @@ printf '训练失败,请登录查看详情' | farhelm-agent experiment report 网页指令和调度在 Agent 保存后才确认提交;失败时当前页面保留草稿并沿用操作身份重试。运行中的任务在 Agent 重启后按已保存的终态收据恢复;无终态收据时标记 orphaned,不自动重放。升级先 Hub 后 Agent,新正文交付要求 Agent 的 V0.7 能力标识。 +## Codex 工作区 + +进入 Codex 后保留桌面全局侧栏和手机底部导航。项目分组可折叠,会话名称优先使用正式标题,没有标题时临时读取首条用户消息摘要。搜索覆盖筛选范围内全部已导入会话;离线 Agent 会明确提示结果不完整。摘要与搜索结果不写入 Hub 数据库或日志,也不用于通知标题。 + +助手回复支持 Markdown 表格、代码复制、公式及安全 HTTPS 链接。同一轮连续执行过程默认合并折叠,失败可直接辨认。输入区固定可见,阅读旧内容时不会被新消息拉回底部;大消息的“继续加载此消息”与“加载更早对话”分别处理。切换会话保留各自草稿,补充与中断只针对可见的活动轮次。 + +从 V0.7.0 升级先更新 Hub,再更新 Agent。SQLite schema 保持 7,临时会话名称和搜索需要 V0.7.1 Agent;旧 Agent 会显示升级提示。 + ## 当前实现 - `farhelm-hub`:Rust 控制平面、密码登录、SQLite 30 天会话、短码配对、Secure HttpOnly Cookie、CSRF、登录限速、可靠事件、SSE 补发和 Web Push。 diff --git a/VERSION b/VERSION index faef31a..39e898a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.7.1 diff --git a/crates/farhelm-core/src/lib.rs b/crates/farhelm-core/src/lib.rs index 5a222ca..5b06981 100644 --- a/crates/farhelm-core/src/lib.rs +++ b/crates/farhelm-core/src/lib.rs @@ -29,6 +29,6 @@ mod tests { fn build_info_uses_workspace_identity() { let info = BuildInfo::current(); assert_eq!(info.product, "FarHelm"); - assert_eq!(info.version, "0.7.0"); + assert_eq!(info.version, "0.7.1"); } } diff --git a/crates/farhelm-protocol/src/lib.rs b/crates/farhelm-protocol/src/lib.rs index 5ea7594..aa6f104 100644 --- a/crates/farhelm-protocol/src/lib.rs +++ b/crates/farhelm-protocol/src/lib.rs @@ -53,7 +53,11 @@ impl AgentHeartbeat { agent_version: impl Into, ) -> Self { Self { - capabilities: vec!["codex.ephemeral_submit".to_owned()], + capabilities: vec![ + "codex.ephemeral_submit".to_owned(), + "codex.session_display".to_owned(), + "codex.item_offsets".to_owned(), + ], protocol: FARHELM_PROTOCOL.to_owned(), agent_id: agent_id.into(), hostname: hostname.into(), @@ -283,6 +287,25 @@ pub struct CodexSessionListResponse { pub next_cursor: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionDisplayRequest { + pub mode: String, + #[serde(default)] + pub session_ids: Vec, + pub query: Option, + pub agent_id: Option, + pub project_id: Option, + #[serde(default = "display_archive_filter")] + pub archived: String, + pub cursor: Option, + pub limit: Option, +} + +fn display_archive_filter() -> String { + "false".into() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CodexTranscriptItemKind { @@ -298,6 +321,16 @@ pub struct CodexTranscriptItem { pub item_id: String, pub kind: CodexTranscriptItemKind, pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text_offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text_complete: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -313,11 +346,22 @@ pub struct CodexTranscriptTurn { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CodexTranscriptPage { + #[serde(default)] pub protocol: String, pub session_id: String, pub turns: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub next_cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub continuation: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TranscriptContinuation { + pub kind: String, + pub turn_id: String, + pub item_id: String, + pub text_offset: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -487,6 +531,8 @@ pub enum PromptDelivery { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SendCodexMessageRequest { pub prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, #[serde(default = "default_prompt_delivery")] pub delivery: PromptDelivery, } @@ -625,6 +671,19 @@ where } /// Only this metadata crosses into durable Hub events. Transcript content uses the read relay. +#[must_use] +pub fn valid_session_title(title: &str) -> bool { + !matches!( + title.trim().to_lowercase().as_str(), + "" | "codex session" + | "untitled" + | "new conversation" + | "new chat" + | "未命名会话" + | "新会话" + ) +} + #[must_use] pub fn public_event_payload(event_type: &str, payload: &serde_json::Value) -> serde_json::Value { use serde_json::{Map, Value}; @@ -651,6 +710,7 @@ pub fn public_event_payload(event_type: &str, payload: &serde_json::Value) -> se "updated_at_unix", ], "codex.session.updated" => &[ + "update_kind", "session_id", "project_id", "mode", @@ -726,6 +786,11 @@ pub fn public_event_payload(event_type: &str, payload: &serde_json::Value) -> se { safe.insert("delta".to_owned(), delta.clone()); } + if event_type == "codex.message.delta" + && let Some(offset) = data.get("text_offset").and_then(Value::as_u64) + { + safe.insert("text_offset".to_owned(), Value::from(offset)); + } result.insert("data".to_owned(), Value::Object(safe)); } Value::Object(result) @@ -735,6 +800,27 @@ pub fn public_event_payload(event_type: &str, payload: &serde_json::Value) -> se mod tests { use super::*; + #[test] + fn display_and_extended_history_contract_keep_content_out_of_event_fields() { + let fixture: Value = + serde_json::from_str(include_str!("../tests/fixtures/session-display.json")).unwrap(); + let request: SessionDisplayRequest = serde_json::from_value(fixture.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), fixture); + let page: CodexTranscriptPage = serde_json::from_value(serde_json::json!({"session_id":"s","turns":[{"turn_id":"t","status":"completed","cwd":"private","items":[{"item_id":"i","kind":"assistant_message","text":"完整🙂","text_offset":7,"text_complete":false,"raw_output":"private"}]}],"continuation":{"kind":"message","turn_id":"t","item_id":"i","text_offset":10}})).unwrap(); + let safe = serde_json::to_value(page).unwrap(); + assert!(safe["turns"][0].get("cwd").is_none()); + assert!(safe["turns"][0]["items"][0].get("raw_output").is_none()); + assert_eq!(safe["turns"][0]["items"][0]["text_offset"], 7); + let delta = public_event_payload( + "codex.message.delta", + &serde_json::json!({"session_id":"s","data":{"turn_id":"t","item_id":"i","text_offset":7,"delta":"完整🙂","cwd":"private"}}), + ); + assert_eq!(delta["data"]["text_offset"], 7); + assert!(delta["data"].get("cwd").is_none()); + let terminal = public_event_payload("codex.turn.completed", &delta); + assert!(terminal["data"].get("delta").is_none()); + } + #[test] fn health_shape_is_stable() { let value = serde_json::to_value(HealthResponse::hub("0.1.0")).unwrap(); @@ -760,7 +846,7 @@ mod tests { "agent_id": "gpu-a", "hostname": "trainer-a", "agent_version": "0.1.0", - "capabilities": ["codex.ephemeral_submit"] + "capabilities": ["codex.ephemeral_submit", "codex.session_display", "codex.item_offsets"] }) ); } diff --git a/crates/farhelm-protocol/tests/fixtures/session-display.json b/crates/farhelm-protocol/tests/fixtures/session-display.json new file mode 100644 index 0000000..79d5d3f --- /dev/null +++ b/crates/farhelm-protocol/tests/fixtures/session-display.json @@ -0,0 +1,10 @@ +{ + "mode": "search", + "session_ids": [], + "query": "synthetic preview", + "agent_id": "gpu-a", + "project_id": "cc08", + "archived": "all", + "cursor": null, + "limit": 50 +} diff --git a/crates/farhelm-test-support/src/lib.rs b/crates/farhelm-test-support/src/lib.rs index a8a41a7..40bb841 100644 --- a/crates/farhelm-test-support/src/lib.rs +++ b/crates/farhelm-test-support/src/lib.rs @@ -4,7 +4,7 @@ use farhelm_protocol::{HealthResponse, WORKER_PROTOCOL, WorkerResponse}; #[must_use] pub fn health_fixture() -> HealthResponse { - HealthResponse::hub("0.7.0") + HealthResponse::hub("0.7.1") } #[must_use] @@ -16,7 +16,7 @@ pub fn worker_hello_fixture(request_id: impl Into) -> WorkerResponse { ok: true, result: Some(serde_json::json!({ "worker": "farhelm-worker-codex", - "version": "0.7.0", + "version": "0.7.1", "capabilities": ["worker.hello"] })), error: None, diff --git a/deploy/README.en.md b/deploy/README.en.md index 2a23b8e..e22c77c 100644 --- a/deploy/README.en.md +++ b/deploy/README.en.md @@ -1,4 +1,4 @@ -# FarHelm V0.7.0 deployment and lifecycle +# FarHelm V0.7.1 deployment and lifecycle [简体中文](README.md) · [English](README.en.md) @@ -21,7 +21,7 @@ sudo env \ ./farhelm-hub install ``` -V0.5.0 upgrades directly with `update`; SQLite session, pairing, project, and schedule tables are created idempotently on restart. Old TOTP and token fields remain for local rollback, but V0.7.0 does not require TOTP. A backup is still recommended before the first V0.3.0 cross-generation install: +V0.5.0 upgrades directly with `update`; SQLite session, pairing, project, and schedule tables are created idempotently on restart. Old TOTP and token fields remain for local rollback, but V0.7.1 does not require TOTP. A backup is still recommended before the first V0.3.0 cross-generation install: ```bash sudo cp /var/lib/farhelm/farhelm.db /var/lib/farhelm/farhelm.db.v0.3.bak @@ -86,8 +86,8 @@ chmod +x farhelm-agent Agent downloads the independent Python 3.12/Codex runtime from the matching immutable Release and verifies its length and SHA-256. For an offline installation, transfer the matching runtime asset first and provide trusted verification metadata: ```bash -FARHELM_CODEX_RUNTIME_ARCHIVE="$PWD/farhelm-codex-runtime-0.7.0-linux-x86_64.tar.gz" \ -FARHELM_CODEX_RUNTIME_SIZE="$(stat -c '%s' farhelm-codex-runtime-0.7.0-linux-x86_64.tar.gz)" \ +FARHELM_CODEX_RUNTIME_ARCHIVE="$PWD/farhelm-codex-runtime-0.7.1-linux-x86_64.tar.gz" \ +FARHELM_CODEX_RUNTIME_SIZE="$(stat -c '%s' farhelm-codex-runtime-0.7.1-linux-x86_64.tar.gz)" \ FARHELM_CODEX_RUNTIME_SHA256="copy-from-trusted-SHA256SUMS" \ ./farhelm-agent install ``` @@ -150,9 +150,9 @@ farhelm-agent uninstall --keep-data ## Migrating from V0.2.0 -Hosts already on `V0.3.0` through `V0.5.0` can run `farhelm-hub update` or `farhelm-agent update` directly. `V0.2.0` must first upgrade to `V0.3.0` to migrate the old layout, then upgrade to V0.7.0. +Hosts already on `V0.3.0` through `V0.5.0` can run `farhelm-hub update` or `farhelm-agent update` directly. `V0.2.0` must first upgrade to `V0.3.0` to migrate the old layout, then upgrade to V0.7.1. -Lowercase legacy `v0.1.0/v0.2.0` releases are outside the formal update sequence. Remove them with their matching old uninstaller before installing V0.7.0. +Lowercase legacy `v0.1.0/v0.2.0` releases are outside the formal update sequence. Remove them with their matching old uninstaller before installing V0.7.1. ## Security notes diff --git a/deploy/README.md b/deploy/README.md index 44cf651..cd8a28a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,4 +1,4 @@ -# FarHelm V0.7.0 部署与生命周期 +# FarHelm V0.7.1 部署与生命周期 [简体中文](README.md) · [English](README.en.md) @@ -21,7 +21,7 @@ sudo env \ ./farhelm-hub install ``` -V0.5.0 可直接执行 `update` 升级;SQLite 会话、配对、项目和调度表会在重启时幂等创建。旧 TOTP 与 Token 字段保留供本机回滚,但 V0.7.0 不再要求 TOTP。V0.3.0 首次跨代安装前仍建议备份: +V0.5.0 可直接执行 `update` 升级;SQLite 会话、配对、项目和调度表会在重启时幂等创建。旧 TOTP 与 Token 字段保留供本机回滚,但 V0.7.1 不再要求 TOTP。V0.3.0 首次跨代安装前仍建议备份: ```bash sudo cp /var/lib/farhelm/farhelm.db /var/lib/farhelm/farhelm.db.v0.3.bak @@ -86,8 +86,8 @@ chmod +x farhelm-agent Agent 会从同版本不可变 Release 下载独立 Python 3.12/Codex runtime,并校验资产长度和 SHA-256。离线安装时,先传输同版本 runtime 资产,再提供受信任的校验元数据: ```bash -FARHELM_CODEX_RUNTIME_ARCHIVE="$PWD/farhelm-codex-runtime-0.7.0-linux-x86_64.tar.gz" \ -FARHELM_CODEX_RUNTIME_SIZE="$(stat -c '%s' farhelm-codex-runtime-0.7.0-linux-x86_64.tar.gz)" \ +FARHELM_CODEX_RUNTIME_ARCHIVE="$PWD/farhelm-codex-runtime-0.7.1-linux-x86_64.tar.gz" \ +FARHELM_CODEX_RUNTIME_SIZE="$(stat -c '%s' farhelm-codex-runtime-0.7.1-linux-x86_64.tar.gz)" \ FARHELM_CODEX_RUNTIME_SHA256="从受信任的SHA256SUMS复制" \ ./farhelm-agent install ``` @@ -150,9 +150,9 @@ farhelm-agent uninstall --keep-data ## 从 V0.2.0 迁移 -已安装 `V0.3.0` 至 `V0.5.0` 的主机可以直接执行 `farhelm-hub update` 或 `farhelm-agent update`。`V0.2.0` 必须先升级到 `V0.3.0` 完成旧布局迁移,再升级到 V0.7.0。 +已安装 `V0.3.0` 至 `V0.5.0` 的主机可以直接执行 `farhelm-hub update` 或 `farhelm-agent update`。`V0.2.0` 必须先升级到 `V0.3.0` 完成旧布局迁移,再升级到 V0.7.1。 -旧小写 `v0.1.0/v0.2.0` 不属于正式升级序列,仍需先使用对应旧卸载器清理,再安装 V0.7.0。 +旧小写 `v0.1.0/v0.2.0` 不属于正式升级序列,仍需先使用对应旧卸载器清理,再安装 V0.7.1。 ## 安全说明 diff --git a/farhelm-agent/src/config.rs b/farhelm-agent/src/config.rs index 18296ed..866819a 100644 --- a/farhelm-agent/src/config.rs +++ b/farhelm-agent/src/config.rs @@ -353,7 +353,7 @@ mod tests { config: PathBuf::from("/tmp/config/farhelm/agent.toml"), data: PathBuf::from("/tmp/data/farhelm"), database: PathBuf::from("/tmp/data/farhelm/state/agent.db"), - worker: PathBuf::from("/tmp/data/farhelm/runtime/codex-worker/0.7.0"), + worker: PathBuf::from("/tmp/data/farhelm/runtime/codex-worker/0.7.1"), unit: PathBuf::from("/tmp/config/systemd/user/farhelm-agent.service"), legacy_root: PathBuf::from("/tmp/data/farhelm-agent"), }; diff --git a/farhelm-agent/src/experiment_store.rs b/farhelm-agent/src/experiment_store.rs index 1505389..90cba27 100644 --- a/farhelm-agent/src/experiment_store.rs +++ b/farhelm-agent/src/experiment_store.rs @@ -720,6 +720,69 @@ impl ExperimentStore { Ok(()) } + pub fn discover_session( + &self, + session_id: &str, + project_id: &str, + cwd: &Path, + title: &Value, + archived: bool, + updated: u64, + ) -> Result<()> { + let connection = self.lock()?; + let transaction = crate::migrations::write_transaction(&connection)?; + transaction.execute( + "INSERT OR IGNORE INTO codex_session_bindings (session_id,project_id,cwd,mode,updated_at_unix) VALUES (?1,?2,?3,'inspect',?4)", + params![session_id,project_id,cwd.to_string_lossy(),as_i64(updated)?], + )?; + let (bound_project, mode): (String, String) = transaction.query_row( + "SELECT project_id,mode FROM codex_session_bindings WHERE session_id=?1", + [session_id], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + ensure!( + bound_project == project_id, + "discovered session project changed" + ); + let payload = json!({"session_id":session_id,"project_id":project_id,"mode":mode, + "update_kind":"metadata","state":if archived {"archived"}else{"idle"}, + "title":title,"updated_at_unix":updated}); + let fingerprint = Sha256::digest(serde_json::to_vec(&payload)?) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + insert_event( + &transaction, + &format!("session-index:{session_id}:{fingerprint}"), + "codex.session.updated", + &payload, + updated, + )?; + transaction.commit()?; + Ok(()) + } + + pub fn display_bindings(&self, project: Option<&str>, ids: Option<&[String]>) -> Result { + let connection = self.lock()?; + let mut statement = connection.prepare( + "SELECT b.session_id,b.project_id,b.cwd FROM codex_session_bindings b JOIN approved_projects p ON p.project_id=b.project_id WHERE (?1 IS NULL OR b.project_id=?1)")?; + let rows = statement.query_map([project], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + )) + })?; + let mut bindings = serde_json::Map::new(); + for row in rows { + let (id, project_id, cwd) = row?; + if ids.is_none_or(|ids| ids.contains(&id)) { + bindings.insert(id, json!({"project_id":project_id,"cwd":cwd})); + } + } + Ok(Value::Object(bindings)) + } + pub fn link_watch_session(&self, watch_id: &str, session_id: &str, now: u64) -> Result { let connection = self.lock()?; let transaction = crate::migrations::write_transaction(&connection)?; @@ -1642,6 +1705,30 @@ mod tests { store .bind_session("session-a", "project-a", &cwd, "edit", 10) .unwrap(); + store + .discover_session( + "session-a", + "project-a", + &directory.path().join("project"), + &Value::Null, + false, + 11, + ) + .unwrap(); + store + .discover_session( + "session-a", + "project-a", + &directory.path().join("project"), + &Value::Null, + false, + 11, + ) + .unwrap(); + let events = store.pending_events("agent-a", 100).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].payload["mode"], "edit"); + assert_eq!(events[0].payload["update_kind"], "metadata"); drop(store); let binding = ExperimentStore::open(&database) .unwrap() diff --git a/farhelm-agent/src/main.rs b/farhelm-agent/src/main.rs index cf604b3..cdd1643 100644 --- a/farhelm-agent/src/main.rs +++ b/farhelm-agent/src/main.rs @@ -117,7 +117,7 @@ enum CommandKind { /// Only report whether an update is available. #[arg(long)] check: bool, - /// Install one exact formal version, such as V0.7.0. + /// Install one exact formal version, such as V0.7.1. #[arg(long)] version: Option, /// Permit a user-approved first-number version change. @@ -654,8 +654,14 @@ async fn discover_projects(database: &Path, python: &str, worker_root: &Path) -> .get("updated_at_unix") .and_then(serde_json::Value::as_u64) .unwrap_or_else(unix_time); - store.bind_session(session_id, project_id, &path, "inspect", updated)?; - store.enqueue_event(&format!("session-sync:{session_id}:{updated}"),"codex.session.updated",&serde_json::json!({"session_id":session_id,"project_id":project_id,"mode":"inspect","state":if session.get("archived").and_then(serde_json::Value::as_bool).unwrap_or(false){"archived"}else{"idle"},"title":session.get("title"),"active_turn_id":null,"updated_at_unix":updated}),unix_time())?; + store.discover_session( + session_id, + project_id, + &path, + &session["title"], + session["archived"].as_bool().unwrap_or(false), + updated, + )?; } Ok(()) } @@ -707,10 +713,13 @@ async fn sync_project_sessions( .get("updated_at_unix") .and_then(serde_json::Value::as_u64) .unwrap_or_else(unix_time); - store.bind_session(session_id, project_id, project_path, "inspect", updated)?; - store.enqueue_event( - &format!("session-sync:{session_id}:{updated}"),"codex.session.updated", - &serde_json::json!({"session_id":session_id,"project_id":project_id,"mode":"inspect","state":if session.get("archived").and_then(serde_json::Value::as_bool).unwrap_or(false) { "archived" } else { "idle" },"title":session.get("title"),"active_turn_id":null,"updated_at_unix":updated}),unix_time(), + store.discover_session( + session_id, + project_id, + project_path, + &session["title"], + session["archived"].as_bool().unwrap_or(false), + updated, )?; } Ok(()) @@ -1099,6 +1108,30 @@ async fn process_read_once( request.params, ) .await + .and_then(|value| { + let page: farhelm_protocol::CodexTranscriptPage = serde_json::from_value(value)?; + Ok(serde_json::to_value(page)?) + }) + } else if request.method == "codex.session.display" { + let ids: Option> = request + .params + .get("session_ids") + .map(|value| serde_json::from_value(value.clone())) + .transpose()?; + let project = request + .params + .get("project_id") + .and_then(serde_json::Value::as_str); + let bindings = store.display_bindings(project, ids.as_deref())?; + let mut params = request.params; + params["bindings"] = bindings; + params["agent_id"] = serde_json::json!(hub.agent_id); + tokio::time::timeout( + Duration::from_secs(18), + worker_call_once(&worker.python, &worker.root, &request.method, params), + ) + .await + .context("display read timed out")? } else if request.method == "codex.schedule.detail" { let id = request .params @@ -1309,6 +1342,7 @@ async fn execute_remote_command( let (state, data, detail) = match outcome { Ok(data) => (CommandState::Completed, Some(data), None), Err(error) => { + warn!(command_id = %command.command_id, %error, "local Codex operation failed"); let event_type = if error.downcast_ref::().is_some() { "codex.turn.orphaned" } else { @@ -1321,6 +1355,20 @@ async fn execute_remote_command( } else { "failed" }; + if command.action == CommandAction::CodexTurnStart + && let Some(session) = command + .payload + .get("session_id") + .and_then(serde_json::Value::as_str) + && let Some(binding) = store.session_binding(session)? + { + store.enqueue_event( + &format!("{}:session-failed", command.command_id), + "codex.session.updated", + &serde_json::json!({"session_id":session,"project_id":binding.project_id,"mode":binding.mode,"state":status,"title":null,"active_turn_id":null,"updated_at_unix":unix_time()}), + unix_time(), + )?; + } ( CommandState::Failed, Some(serde_json::json!({"status":status})), diff --git a/farhelm-agent/src/management.rs b/farhelm-agent/src/management.rs index 632bc63..e2eb9d8 100644 --- a/farhelm-agent/src/management.rs +++ b/farhelm-agent/src/management.rs @@ -606,7 +606,7 @@ mod tests { config: "/tmp/home/.config/farhelm/agent.toml".into(), data: "/tmp/home/.local/share/farhelm".into(), database: "/tmp/home/.local/share/farhelm/state/agent.db".into(), - worker: "/tmp/home/.local/share/farhelm/runtime/codex-worker/0.7.0".into(), + worker: "/tmp/home/.local/share/farhelm/runtime/codex-worker/0.7.1".into(), unit: "/tmp/home/.config/systemd/user/farhelm-agent.service".into(), legacy_root: "/tmp/home/.local/share/farhelm-agent".into(), }; diff --git a/farhelm-agent/src/resources.rs b/farhelm-agent/src/resources.rs index f42c23e..20ebbfa 100644 --- a/farhelm-agent/src/resources.rs +++ b/farhelm-agent/src/resources.rs @@ -49,6 +49,14 @@ const WORKER_FILES: &[(&str, &[u8])] = &[ "src/farhelm_worker_codex/history.py", include_bytes!("../../farhelm-worker-codex/src/farhelm_worker_codex/history.py"), ), + ( + "src/farhelm_worker_codex/display.py", + include_bytes!("../../farhelm-worker-codex/src/farhelm_worker_codex/display.py"), + ), + ( + "src/farhelm_worker_codex/text.py", + include_bytes!("../../farhelm-worker-codex/src/farhelm_worker_codex/text.py"), + ), ]; pub fn materialize_worker(root: &Path) -> Result<()> { diff --git a/farhelm-console/e2e/codex.spec.ts b/farhelm-console/e2e/codex.spec.ts new file mode 100644 index 0000000..ef133de --- /dev/null +++ b/farhelm-console/e2e/codex.spec.ts @@ -0,0 +1,182 @@ +import AxeBuilder from '@axe-core/playwright' +import { expect, test, type Page } from '@playwright/test' + +const markdown = '## 训练结果\n\n**完整测量**,这里是合成验收内容。\n\n| 阶段 | 时间 |\n| --- | ---: |\n| 配准 | 132 ms |\n\n> 保留原始结论。\n\n- 首帧:0.8 秒\n- 后续:3–5 FPS\n\n[安全文档](https://example.org/paper)\n\n```python\nfor epoch in range(8):\n train(epoch)\n```\n\n公式 $E=mc^2$,以及 \\(a^2+b^2=c^2\\)。\n\n\\[\\sum_{i=1}^{8} x_i\\]\n\n\n\n[危险链接](javascript:alert(1))\n\n' +const longText = markdown + ('这是一段用来验证长对话、中文阅读和独立滚动的合成回复。'.repeat(20) + '\n\n').repeat(8) +const session = (id = 'ses-a') => ({ session_id: id, agent_id: 'gpu-a', project_id: 'cc08', title: null as string | null, mode: 'inspect', state: 'idle', active_turn_id: undefined as string | undefined, updated_at_unix: 2000000000 }) +const turn = (text = longText) => ({ turn_id: 'turn-a', status: 'completed', items: [{ item_id: 'user', kind: 'user_message', text: '请分析训练结果' }, { item_id: 'assistant', kind: 'assistant_message', text }, ...Array.from({ length: 12 }, (_, i) => ({ item_id: `tool-${i}`, kind: 'command_summary', text: `python … · exit ${i === 4 ? 1 : 0}`, status: i === 4 ? 'failed' : 'completed', duration_ms: 100, exit_code: i === 4 ? 1 : 0 }))] }) +async function setup(page: Page) { + const model = { sessions: [session(), session('ses-b')], history: { session_id: 'ses-a', turns: [turn()] }, historyStatus: 200, historyError: 'agent_read_failed', historyReads: 0 } + await page.addInitScript(() => { + const sources: EventTarget[] = [] + class Source extends EventTarget { constructor() { super(); sources.push(this) } close() { sources.splice(sources.indexOf(this), 1) } } + Object.assign(window, { EventSource: Source, emitCodex: (type: string, payload: unknown) => sources.forEach((source) => source.dispatchEvent(new MessageEvent(type, { data: JSON.stringify({ payload }) }))) }) + }) + await page.route('**/api/v1/**', (route) => { + const path = new URL(route.request().url()).pathname + if (path.endsWith('/auth/session')) return route.fulfill({ json: { authenticated: true, user: 'admin', csrf_token: 'test-csrf', expires_at_unix: 2000000000 } }) + if (path.endsWith('/codex/sessions')) return route.fulfill({ json: { protocol: 'farhelm/1', sessions: model.sessions } }) + if (path.endsWith('/session-display')) return route.fulfill({ json: { protocol: 'farhelm/1', sessions: model.sessions.map((s) => ({ ...s, display_label: s.session_id === 'ses-a' ? '训练结果分析' : '另一个会话' })), incomplete_agents: [] } }) + if (path.endsWith('/transcript')) { model.historyReads++; return route.fulfill({ status: model.historyStatus, json: model.historyStatus !== 200 ? { error: model.historyError } : path.includes('ses-b') ? { session_id: 'ses-b', turns: [turn('另一会话的回复')] } : model.history }) } + if (path.includes('/codex/sessions/')) return route.fulfill({ json: model.sessions.find((s) => path.endsWith(s.session_id)) ?? {} }) + if (path.endsWith('/projects')) return route.fulfill({ json: { protocol: 'farhelm/1', projects: [] } }) + if (path.endsWith('/experiments')) return route.fulfill({ json: { protocol: 'farhelm/1', experiments: [] } }) + if (path.includes('/schedules')) return route.fulfill({ json: { protocol: 'farhelm/1', schedules: [] } }) + if (path.endsWith('/notifications/preferences')) return route.fulfill({ json: { experiments: true, codex: true } }) + return route.fulfill({ json: { notifications: [], latest_id: 0, unread_count: 0 } }) + }) + return model +} +async function emit(page: Page, type: string, payload: unknown) { await page.evaluate(({ type, payload }) => (window as unknown as { emitCodex: (type: string, payload: unknown) => void }).emitCodex(type, payload), { type, payload }) } +async function choose(page: Page, name: string) { if (await page.getByRole('button', { name: '打开会话列表' }).isVisible()) await page.getByRole('button', { name: '打开会话列表' }).click(); await page.getByRole('button', { name: new RegExp(name) }).filter({ visible: true }).click() } +async function composerFits(page: Page) { + const bounds = await page.locator('.composer-wrap').boundingBox(); const button = await page.getByRole('button', { name: '发送指令' }).boundingBox() + expect(bounds).not.toBeNull(); expect(bounds!.y).toBeGreaterThanOrEqual(0); expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(page.viewportSize()!.height + 1) + expect(button!.y + button!.height).toBeLessThanOrEqual(page.viewportSize()!.height) + expect(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)).toBe(false) + const navigation = page.getByRole('navigation', { name: '主要导航' }) + if (await navigation.isVisible()) { const nav = await navigation.boundingBox(); expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(nav!.y + 1) } +} + +for (const theme of ['light', 'dark'] as const) for (const size of [{ width: 390, height: 844 }, { width: 1440, height: 900 }, { width: 2550, height: 1233 }]) { + test(`Markdown and fixed workspace ${size.width} ${theme}`, async ({ page }, info) => { + test.skip(info.project.name !== 'desktop', 'Explicit viewport matrix runs once') + await page.setViewportSize(size); await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' }); await setup(page) + await page.goto('/codex?session=ses-a') + await expect(page.locator('.conversation-title')).toContainText('训练结果分析') + if (size.width >= 768) { await expect(page.locator('.app-sider')).toBeVisible(); await expect(page.locator('.app-sider .ant-menu-item-selected')).toContainText('Codex') } + else await expect(page.getByRole('navigation', { name: '主要导航' })).toBeVisible() + await expect(page.getByRole('heading', { name: '最新对话' })).toHaveCount(1) + await expect(page.locator('.markdown-table table')).toHaveCount(1) + await expect(page.locator('.markdown-body strong')).toContainText('完整测量') + await expect(page.locator('.katex')).toHaveCount(3) + await expect(page.locator('.execution-summary')).toHaveCount(1) + await expect(page.getByText('含失败')).toBeVisible() + await composerFits(page) + await expect(page.locator('a[href^="javascript:"]')).toHaveCount(0) + expect(await page.evaluate(() => (window as unknown as { injected?: boolean }).injected)).toBeUndefined() + await expect(page.getByRole('link', { name: '安全文档' })).toHaveAttribute('href', 'https://example.org/paper') + await page.locator('.conversation-scroll').evaluate((node) => { const heading = node.querySelector('h3')!; node.scrollTop += heading.getBoundingClientRect().top - node.getBoundingClientRect().top - 20 }) + await page.screenshot({ path: info.outputPath(`codex-${size.width}-${theme}.png`) }) + const results = await new AxeBuilder({ page }).analyze() + expect(results.violations.filter((v) => ['critical', 'serious'].includes(v.impact ?? ''))).toEqual([]) + await page.getByRole('button', { name: '复制代码' }).scrollIntoViewIfNeeded() + await expect(page.locator('.markdown-code code')).toHaveClass(/language-python/) + await page.locator('.execution-summary').getByRole('button').click() + await expect(page.locator('.tool-items li')).toHaveCount(12) + await composerFits(page) + }) +} + +test('composer survives errors, sidebar collapse and keyboard-sized viewport', async ({ page }) => { + await setup(page); await page.goto('/codex?session=ses-a'); await expect(page.locator('.markdown-table')).toHaveCount(1) + await page.route('**/ses-a/messages', (route) => route.fulfill({ status: 504, json: { error: 'agent_save_unconfirmed' } })) + await page.getByLabel('给 Codex 发送指令').fill('保留这份草稿'); await page.getByRole('button', { name: '发送指令' }).click() + await expect(page.getByText(/尚未确认 Agent 保存/)).toBeVisible(); await composerFits(page) + if (await page.getByRole('button', { name: '折叠会话列表' }).isVisible()) { await page.getByRole('button', { name: '折叠会话列表' }).click(); await composerFits(page) } + await page.setViewportSize({ width: 390, height: 500 }); await composerFits(page) +}) + +test('search reaches unloaded sessions and reports offline Agents', async ({ page }) => { + await setup(page) + let search: unknown + await page.route('**/session-display', (route) => { + const body = route.request().postDataJSON(); if (body.mode === 'search') search = body + return route.fulfill({ json: { sessions: body.mode === 'search' ? [{ ...session('ses-999'), display_label: '第 999 个训练报告' }] : [], incomplete_agents: body.mode === 'search' ? [{ agent_id: 'gpu-b', reason: 'agent_offline' }] : [] } }) + }) + await page.goto('/codex?session=ses-a') + await expect(page.locator('.conversation-title')).toContainText('cc08') + if (await page.getByRole('button', { name: '打开会话列表' }).isVisible()) await page.getByRole('button', { name: '打开会话列表' }).click() + await page.getByLabel('搜索全部会话').filter({ visible: true }).fill('训练报告') + await expect(page.getByRole('button', { name: /第 999 个训练报告/ }).filter({ visible: true })).toBeVisible() + expect(search).toMatchObject({ mode: 'search', query: '训练报告', archived: 'false' }) + await expect(page.getByText(/gpu-b(离线)/).filter({ visible: true })).toBeVisible() +}) + +test('keyboard viewport keeps a multiline draft, errors and active turn controls above navigation', async ({ page }) => { + const model = await setup(page); model.sessions[0].active_turn_id = 'turn-a'; model.sessions[0].state = 'running' + await page.goto('/codex?session=ses-a'); await expect(page.getByText('补充当前对话', { exact: true })).toBeVisible() + await page.route('**/ses-a/messages', (route) => route.fulfill({ status: 504, json: { error: 'agent_save_unconfirmed' } })) + await page.getByLabel('给 Codex 发送指令').fill('多行草稿\n'.repeat(15)); await page.getByRole('button', { name: '发送指令' }).click() + await expect(page.getByText(/尚未确认 Agent 保存/)).toBeVisible() + await page.setViewportSize({ width: 390, height: 440 }) + await expect(page.getByRole('navigation', { name: '主要导航' })).toBeVisible() + await test.info().attach('keyboard-geometry', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => Object.fromEntries(['.app-layout', '.app-header', '.codex-workspace', '.conversation-head', '.conversation-alerts', '.conversation-history', '.composer-wrap', '.composer', '.composer textarea', '.delivery-options', '.composer-actions', '.composer-hint', '.mobile-nav'].map((selector) => { const node = document.querySelector(selector)!; const box = node.getBoundingClientRect(); const style = getComputedStyle(node); return [selector, { y: box.y, height: box.height, minHeight: style.minHeight, maxHeight: style.maxHeight }] }))), null, 2) }) + await composerFits(page) + await expect(page.getByRole('button', { name: '中断', exact: true })).toBeVisible() +}) + +test('history recovers when Agent heartbeat follows Hub restart', async ({ page }) => { + const model = await setup(page); model.historyStatus = 503; model.historyError = 'agent_offline' + await page.goto('/codex?session=ses-a') + await expect(page.getByText('Agent 离线,暂时无法读取历史')).toBeVisible() + model.historyStatus = 200 + await expect(page.locator('.markdown-table')).toHaveCount(1, { timeout: 10000 }) + await expect(page.getByText('Agent 离线,暂时无法读取历史')).toHaveCount(0) + expect(model.historyReads).toBeLessThanOrEqual(4) +}) + +test('failed send belongs to its original session while switching', async ({ page }) => { + await setup(page); let release: () => void = () => {}; const gate = new Promise((r) => { release = r }) + await page.route('**/ses-a/messages', async (route) => { await gate; await route.fulfill({ status: 504, json: { error: 'agent_save_unconfirmed' } }) }) + await page.goto('/codex?session=ses-a'); await expect(page.locator('.conversation-title')).toContainText('训练结果分析') + await page.getByLabel('给 Codex 发送指令').fill('A 会话的草稿'); await page.getByRole('button', { name: '发送指令' }).click() + await expect(page.getByText('你 · 正在提交')).toBeVisible(); await choose(page, '另一个会话'); await expect(page).toHaveURL(/session=ses-b/) + await page.getByLabel('给 Codex 发送指令').fill('B 的草稿'); release() + await expect(page.getByText(/尚未确认 Agent 保存/)).toHaveCount(0); await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('B 的草稿') + await choose(page, '训练结果分析'); await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('A 会话的草稿'); await expect(page.getByText(/尚未确认 Agent 保存/)).toBeVisible() + await page.goBack(); await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('B 的草稿') +}) + +test('interleaved and repeated deltas retain item identity after a failed terminal refresh', async ({ page }) => { + const model = await setup(page); model.history = { session_id: 'ses-a', turns: [] } + await page.goto('/codex?session=ses-a'); await expect(page.getByText(/这个会话还没有对话/)).toBeVisible() + const delta = async (item_id: string, text_offset: number, delta: string) => emit(page, 'codex.message.delta', { session_id: 'ses-a', data: { turn_id: 'stream-turn', item_id, text_offset, delta } }) + await delta('a', 0, '第一条🙂'); await delta('b', 0, '第二条'); await delta('a', 4, '完成'); await delta('a', 4, '完成') + await expect(page.locator('.codex-message.assistant')).toHaveCount(2) + await expect(page.locator('.codex-message.assistant').first()).toContainText('第一条🙂完成') + model.historyStatus = 502 + await emit(page, 'codex.turn.completed', { session_id: 'ses-a', data: { turn_id: 'stream-turn' } }) + await expect(page.getByText('历史刷新失败,已保留当前内容')).toBeVisible(); await expect(page.getByText('第二条', { exact: true })).toBeVisible() + model.historyStatus = 200; model.history = { session_id: 'ses-a', turns: [{ turn_id: 'stream-turn', status: 'completed', items: [{ item_id: 'a', kind: 'assistant_message', text: '第一条🙂完成' }, { item_id: 'b', kind: 'assistant_message', text: '第二条' }] }] } + await page.getByRole('button', { name: '刷新对话' }).click(); await expect(page.getByText('历史刷新失败,已保留当前内容')).toHaveCount(0) + await delta('a', 4, '完成'); await expect(page.locator('.codex-message.assistant')).toHaveCount(2) +}) + +test('reading above the bottom preserves the anchor when new messages arrive', async ({ page }) => { + await setup(page); await page.goto('/codex?session=ses-a'); await expect(page.locator('.markdown-table')).toHaveCount(1) + const scroll = page.locator('.conversation-scroll'); await scroll.evaluate((node) => { node.scrollTop = 400 }) + await expect.poll(() => scroll.evaluate((node) => node.scrollTop)).toBe(400) + await emit(page, 'codex.message.delta', { session_id: 'ses-a', data: { turn_id: 'new-turn', item_id: 'new-item', text_offset: 0, delta: '新的回复内容' } }) + await expect(page.getByRole('button', { name: /有新消息/ })).toBeVisible(); expect(await scroll.evaluate((node) => node.scrollTop)).toBeCloseTo(400, 0) + await page.getByRole('button', { name: /有新消息/ }).click(); await expect(page.getByText('新的回复内容')).toBeVisible() + await expect(page.getByRole('button', { name: /有新消息/ })).toBeHidden() +}) + +test('one MiB message resumes separately from earlier turns without duplicate text', async ({ page }) => { + await setup(page) + const text = '训练🙂结果\n'.repeat(70000) + const pieces = Array.from(text); const offsets = [0, 140000, 280000, pieces.length] + await page.route('**/ses-a/transcript?*', (route) => { + const index = Number(new URL(route.request().url()).searchParams.get('cursor') ?? '0'); const start = offsets[index]; const end = offsets[index + 1] + return route.fulfill({ json: { session_id: 'ses-a', turns: [{ turn_id: 'large', status: 'completed', items: [{ item_id: 'large-message', kind: 'assistant_message', text: pieces.slice(start, end).join(''), text_offset: start, text_complete: index === 2 }] }], next_cursor: index < 2 ? String(index + 1) : null, continuation: index < 2 ? { kind: 'message', turn_id: 'large', item_id: 'large-message', text_offset: end } : null } }) + }) + await page.goto('/codex?session=ses-a') + await expect(page.getByRole('button', { name: '继续加载此消息' })).toBeVisible() + await expect(page.getByRole('button', { name: '加载更早对话' })).toHaveCount(0) + await page.getByLabel('给 Codex 发送指令').fill('大消息解析期间仍可输入') + await page.getByRole('button', { name: '继续加载此消息' }).click() + await expect(page.getByRole('button', { name: '继续加载此消息' })).toBeEnabled() + await page.getByRole('button', { name: '继续加载此消息' }).click() + await expect(page.getByRole('button', { name: '继续加载此消息' })).toHaveCount(0) + await expect(page.locator('.markdown-body [aria-busy="false"]')).toHaveCount(1, { timeout: 20000 }) + await expect(page.locator('.markdown-fallback')).toHaveCount(0) + expect(await page.locator('.long-paragraph-chunk').count()).toBeGreaterThan(0) + // Markdown consumes the closing paragraph newline; every content character + // and interior line break must remain across all three transport fragments. + expect(await page.locator('.markdown-body').textContent()).toBe(text.trimEnd()) + await page.getByRole('button', { name: '刷新对话' }).click() + await expect(page.getByRole('button', { name: '继续加载此消息' })).toHaveCount(0) + expect(await page.locator('.markdown-body').textContent()).toBe(text.trimEnd()) + await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('大消息解析期间仍可输入'); await composerFits(page) +}) diff --git a/farhelm-console/e2e/layout.spec.ts b/farhelm-console/e2e/layout.spec.ts index 372b4a6..390f298 100644 --- a/farhelm-console/e2e/layout.spec.ts +++ b/farhelm-console/e2e/layout.spec.ts @@ -42,6 +42,8 @@ test.beforeEach(async ({ page }) => { contentType: 'application/json', body: JSON.stringify({ protocol: 'farhelm/1', sessions: [{ session_id: 'ses-a', agent_id: 'gpu-a', project_id: 'cc08', mode: 'inspect', state: 'idle', updated_at_unix: 2_000_000_000 }] }), })) + await page.route('**/api/v1/codex/session-display', (route) => route.fulfill({ json: { protocol: 'farhelm/1', sessions: [], incomplete_agents: [] } })) + await page.route(/\/api\/v1\/codex\/sessions\/ses-[ab]$/, (route) => route.fulfill({ json: { session_id: route.request().url().split('/').at(-1), agent_id: 'gpu-a', project_id: 'cc08', mode: 'inspect', state: 'idle', updated_at_unix: 2_000_000_000 } })) await page.route('**/api/v1/codex/sessions/ses-a/transcript?*', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ session_id: 'ses-a', turns: [{ turn_id: 'turn-a', status: 'completed', items: [{ item_id: 'user-a', kind: 'user_message', text: '检查训练结果' }, { item_id: 'agent-a', kind: 'assistant_message', text: '结果正常' }] }] }) })) await page.route('**/api/v1/codex/schedules?*', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ protocol: 'farhelm/1', schedules: [] }) })) await page.route('**/api/v1/events/stream', (route) => route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' })) @@ -111,12 +113,13 @@ test('experiment deep link and Codex manual queue use the mobile-safe workflow', await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) }) await page.goto('/codex?session=ses-a') - await expect(page.getByText('ses-a').first()).toBeVisible() + await expect(page.getByRole('button', { name: '复制会话 ID' })).toBeVisible() await expect(page.getByText('结果正常')).toBeVisible() await page.getByLabel('给 Codex 发送指令').fill('继续分析结果') await page.getByRole('button', { name: '发送指令' }).click() await expect.poll(() => sent).toEqual({ prompt: '继续分析结果', delivery: 'queue' }) - await expect(page.locator('.app-sider')).toHaveCount(0) + if (page.viewportSize()!.width >= 768) await expect(page.locator('.app-sider')).toBeVisible() + else await expect(page.getByRole('navigation', { name: '主要导航' })).toBeVisible() let scheduled: unknown await page.route('**/api/v1/codex/sessions/ses-a/schedules', async (route) => { diff --git a/farhelm-console/package.json b/farhelm-console/package.json index c8316c7..dde6a95 100644 --- a/farhelm-console/package.json +++ b/farhelm-console/package.json @@ -1,7 +1,7 @@ { "name": "farhelm-console", "private": true, - "version": "0.7.0", + "version": "0.7.1", "type": "module", "packageManager": "pnpm@10.17.1", "engines": { @@ -19,11 +19,21 @@ "dependencies": { "@ant-design/icons": "6.3.4", "antd": "6.6.2", + "hast-util-to-jsx-runtime": "2.3.6", + "highlight.js": "11.12.0", + "katex": "0.16.47", "react": "19.2.8", "react-dom": "19.2.8", + "react-markdown": "10.1.0", "react-router-dom": "7.18.3", - "workbox-window": "7.4.1", - "workbox-precaching": "7.4.1" + "rehype-katex": "7.0.1", + "remark-gfm": "4.0.1", + "remark-math": "6.0.0", + "remark-parse": "11.0.0", + "remark-rehype": "11.1.2", + "unified": "11.0.5", + "workbox-precaching": "7.4.1", + "workbox-window": "7.4.1" }, "devDependencies": { "@axe-core/playwright": "4.13.0", @@ -36,10 +46,12 @@ "@types/react": "19.2.18", "@types/react-dom": "19.2.5", "@vitejs/plugin-react": "6.1.1", + "decode-named-character-reference": "1.3.0", "eslint": "10.0.1", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.6", "globals": "17.4.0", + "hast-util-from-html-isomorphic": "2.0.0", "jsdom": "30.0.1", "typescript": "6.0.3", "typescript-eslint": "8.69.0", diff --git a/farhelm-console/pnpm-lock.yaml b/farhelm-console/pnpm-lock.yaml index eb71087..1cf9413 100644 --- a/farhelm-console/pnpm-lock.yaml +++ b/farhelm-console/pnpm-lock.yaml @@ -14,15 +14,45 @@ importers: antd: specifier: 6.6.2 version: 6.6.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + hast-util-to-jsx-runtime: + specifier: 2.3.6 + version: 2.3.6 + highlight.js: + specifier: 11.12.0 + version: 11.12.0 + katex: + specifier: 0.16.47 + version: 0.16.47 react: specifier: 19.2.8 version: 19.2.8 react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) + react-markdown: + specifier: 10.1.0 + version: 10.1.0(@types/react@19.2.18)(react@19.2.8) react-router-dom: specifier: 7.18.3 version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + rehype-katex: + specifier: 7.0.1 + version: 7.0.1 + remark-gfm: + specifier: 4.0.1 + version: 4.0.1 + remark-math: + specifier: 6.0.0 + version: 6.0.0 + remark-parse: + specifier: 11.0.0 + version: 11.0.0 + remark-rehype: + specifier: 11.1.2 + version: 11.1.2 + unified: + specifier: 11.0.5 + version: 11.0.5 workbox-precaching: specifier: 7.4.1 version: 7.4.1 @@ -60,6 +90,9 @@ importers: '@vitejs/plugin-react': specifier: 6.1.1 version: 6.1.1(vite@8.2.2(@types/node@24.12.0)(terser@5.51.2)) + decode-named-character-reference: + specifier: 1.3.0 + version: 1.3.0 eslint: specifier: 10.0.1 version: 10.0.1 @@ -72,6 +105,9 @@ importers: globals: specifier: 17.4.0 version: 17.4.0 + hast-util-from-html-isomorphic: + specifier: 2.0.0 + version: 2.0.0 jsdom: specifier: 30.0.1 version: 30.0.1 @@ -1403,18 +1439,36 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} @@ -1432,6 +1486,12 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.69.0': resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1491,6 +1551,9 @@ packages: resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vitejs/plugin-react@6.1.1': resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1619,6 +1682,9 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1664,17 +1730,39 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + common-tags@1.8.2: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} @@ -1742,6 +1830,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1765,6 +1856,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1783,6 +1877,10 @@ packages: electron-to-chromium@1.5.420: resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -1826,6 +1924,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-plugin-react-hooks@7.1.1: resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} @@ -1875,6 +1977,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -1893,6 +1998,9 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2035,16 +2143,53 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} + engines: {node: '>=12.0.0'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -2064,10 +2209,19 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2100,6 +2254,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-document.all@1.0.0: resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} engines: {node: '>= 0.4'} @@ -2120,6 +2277,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -2142,6 +2302,10 @@ packages: resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} engines: {node: '>=0.10.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -2248,6 +2412,10 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2336,6 +2504,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -2350,13 +2521,151 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -2423,6 +2732,12 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -2485,6 +2800,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2500,6 +2818,12 @@ packages: react-is@19.2.8: resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-router-dom@7.18.3: resolution: {integrity: sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==} engines: {node: '>=20.0.0'} @@ -2551,6 +2875,24 @@ packages: resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} hasBin: true + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2670,6 +3012,9 @@ packages: resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2699,6 +3044,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + stringify-object@3.3.0: resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} engines: {node: '>=4'} @@ -2711,6 +3059,12 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -2768,6 +3122,12 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2837,10 +3197,34 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -2858,6 +3242,15 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-pwa@1.3.0: resolution: {integrity: sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==} engines: {node: '>=16.0.0'} @@ -2958,6 +3351,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -3076,6 +3472,9 @@ packages: zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@adobe/css-tools@4.5.0': {} @@ -4502,14 +4901,34 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/katex@0.16.8': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 @@ -4526,6 +4945,10 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.0.1)(typescript@6.0.3))(eslint@10.0.1)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4617,6 +5040,8 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.4.0': {} + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.12.0)(terser@5.51.2))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -4803,6 +5228,8 @@ snapshots: transitivePeerDependencies: - supports-color + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -4850,12 +5277,26 @@ snapshots: caniuse-lite@1.0.30001810: {} + ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + clsx@2.1.1: {} + comma-separated-tokens@2.0.3: {} + commander@2.20.3: {} + commander@8.3.0: {} + common-tags@1.8.2: {} compute-scroll-into-view@3.1.1: {} @@ -4918,6 +5359,10 @@ snapshots: decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -4938,6 +5383,10 @@ snapshots: detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -4954,6 +5403,8 @@ snapshots: electron-to-chromium@1.5.420: {} + entities@6.0.1: {} + entities@8.0.0: {} es-abstract-get@1.0.0: @@ -5050,6 +5501,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-plugin-react-hooks@7.1.1(eslint@10.0.1): dependencies: '@babel/core': 7.29.7 @@ -5127,6 +5580,8 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -5139,6 +5594,8 @@ snapshots: expect-type@1.4.0: {} + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -5283,18 +5740,102 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.5 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: dependencies: hermes-estree: 0.25.1 + highlight.js@11.12.0: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 transitivePeerDependencies: - '@noble/hashes' + html-url-attributes@3.0.1: {} + idb@7.1.1: {} ignore@5.3.2: {} @@ -5305,12 +5846,21 @@ snapshots: indent-string@4.0.0: {} + inline-style-parser@0.2.7: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 hasown: 2.0.4 side-channel: 1.1.1 + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -5351,6 +5901,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-document.all@1.0.0: dependencies: call-bound: 1.0.4 @@ -5373,6 +5925,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-map@2.0.3: {} is-mobile@5.0.0: {} @@ -5388,6 +5942,8 @@ snapshots: is-obj@1.0.1: {} + is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} is-regex@1.2.1: @@ -5499,6 +6055,10 @@ snapshots: jsonpointer@5.0.1: {} + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5565,6 +6125,8 @@ snapshots: lodash.debounce@4.0.8: {} + longest-streak@3.1.0: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -5577,10 +6139,378 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.47 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + min-indent@1.0.1: {} minimatch@10.2.6: @@ -5642,6 +6572,20 @@ snapshots: package-json-from-dist@1.0.1: {} + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -5691,6 +6635,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + property-information@7.2.0: {} + punycode@2.3.1: {} react-dom@19.2.8(react@19.2.8): @@ -5702,6 +6648,24 @@ snapshots: react-is@19.2.8: {} + react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.8): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 19.2.18 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.8 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-router-dom@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -5764,6 +6728,59 @@ snapshots: dependencies: jsesc: 3.1.0 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.5 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.47 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-from-string@2.0.2: {} resolve@1.22.12: @@ -5936,6 +6953,8 @@ snapshots: source-map@0.8.0: {} + space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} std-env@4.2.0: {} @@ -5987,6 +7006,11 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + stringify-object@3.3.0: dependencies: get-own-enumerable-property-symbols: 3.0.2 @@ -5999,6 +7023,14 @@ snapshots: dependencies: min-indent: 1.0.1 + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + stylis@4.4.0: {} supports-preserve-symlinks-flag@1.0.0: {} @@ -6048,6 +7080,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -6126,10 +7162,53 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} upath@1.2.0: {} @@ -6144,6 +7223,21 @@ snapshots: dependencies: punycode: 2.3.1 + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite-plugin-pwa@1.3.0(vite@8.2.2(@types/node@24.12.0)(terser@5.51.2))(workbox-build@7.4.1)(workbox-window@7.4.1): dependencies: debug: 4.4.3 @@ -6199,6 +7293,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} whatwg-mimetype@5.0.0: {} @@ -6397,3 +7493,5 @@ snapshots: zod: 4.5.4 zod@4.5.4: {} + + zwitch@2.0.4: {} diff --git a/farhelm-console/src/App.tsx b/farhelm-console/src/App.tsx index 3a11535..9dab028 100644 --- a/farhelm-console/src/App.tsx +++ b/farhelm-console/src/App.tsx @@ -97,12 +97,11 @@ export default function App() { if (session === undefined) return
正在恢复安全会话…
if (session === null) return - if (location.pathname === '/codex') return return ( - + {isDesktop && (
@@ -110,7 +109,7 @@ export default function App() {
FarHelm远程训练控制台
go(key)} /> -
V0.7.0 · Codex workspace
+
V0.7.1 · Codex workspace
)} @@ -128,7 +127,7 @@ export default function App() { {!isDesktop && } />)}{sessionCursor && } - - return
{rail}
setRailOpen(false)} width={Math.min(320, window.innerWidth - 32)} closable={false}>{rail}
{error && setError(undefined)} />}
{ if (scroll.current) follow.current = scroll.current.scrollHeight - scroll.current.scrollTop - scroll.current.clientHeight < 80 }}>{cursor && }{historyLoading && turns.length === 0 ? : }
{operation?.command_id && Agent 指令:{operation.state} · {operation.command_id}}
{ setPrompt(event.target.value); if (selectedId) drafts.current.set(selectedId, event.target.value) }} autoSize={{ minRows: 2, maxRows: 8 }} placeholder={selected ? '给 Codex 发送指令…' : '请先选择会话'} disabled={!selected} onPressEnter={(event) => { if (!event.shiftKey && !event.nativeEvent.isComposing && event.keyCode !== 229) { event.preventDefault(); void send() } }} />
{selected?.state === 'running' && setDelivery(event.target.value as 'queue' | 'steer')}>排队补充当前对话}{selected?.state === 'running' && }
- - setCreating(false)} footer={null}>
{ const project = projects.find((item) => item.candidate_id === values.project && item.state === 'approved'); if (!project) return; void createSession(csrf, project.agent_id, project.suggested_project_id, values.mode).then(waitForCommand).then(() => { setCreating(false); void loadSessions() }).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '创建失败')) }}> : } placeholder="搜索全部会话" aria-label="搜索全部会话" value={query} onChange={(e) => setQuery(e.target.value)} /> + { setArchive(value as ArchiveFilter); select(undefined) }} options={[{ label: '当前', value: 'false' }, { label: '归档', value: 'true' }, { label: '全部', value: 'all' }]} /> + {projects.length > 0 && p.state === 'approved').map((p) => ({ value: p.candidate_id, label: `${p.display_name} · ${p.agent_id}` }))} />只读编辑(隔离工作区)
+} + +export function ScheduleDialog({ csrf, target, prompt, onClose }: { csrf: string; target: CodexSession; prompt: string; onClose: () => void }) { + const [error, setError] = useState(); const [busy, setBusy] = useState(false); const [experiments, setExperiments] = useState([]) + useEffect(() => { let active = true; void fetchExperiments().then((rows) => { if (active) setExperiments(rows) }).catch((e: unknown) => { if (active) setError(errorText(e)) }); return () => { active = false } }, []) + return
{ + if (busy) return; setBusy(true); setError(undefined) + const trigger = values.delivery === 'at_time' ? { type: 'at_time' as const, run_at_unix: Math.floor(new Date(values.runAt!).getTime() / 1000) } : { type: 'experiment_succeeded' as const, watch_id: values.watchId! } + try { await waitForCommand(await createSchedule(csrf, target.session_id, values.prompt, trigger)); onClose() } catch (e) { setError(errorText(e)) } finally { setBusy(false) } + }}>{error && }{({ getFieldValue }) => getFieldValue('delivery') === 'at_time' ? : p.state === 'approved').map((p) => ({ value: p.candidate_id, label: `${p.display_name} · ${p.agent_id}` }))} />只读编辑(隔离工作区)
} From cac3c977087027d6524429875ecc0890ee371c60 Mon Sep 17 00:00:00 2001 From: rike Date: Mon, 7 Sep 2026 17:00:46 +0800 Subject: [PATCH 3/4] test(codex): close creation dialog through its keyboard control --- farhelm-console/e2e/codex.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/farhelm-console/e2e/codex.spec.ts b/farhelm-console/e2e/codex.spec.ts index cef0cbb..465e001 100644 --- a/farhelm-console/e2e/codex.spec.ts +++ b/farhelm-console/e2e/codex.spec.ts @@ -128,7 +128,7 @@ for (const cancelled of [false, true]) test(`created session follows its receipt await page.getByRole('button', { name: '新建会话' }).filter({ visible: true }).click() await page.getByLabel('项目', { exact: true }).click(); await page.getByLabel('项目', { exact: true }).press('Enter') await page.getByRole('button', { name: /创\s*建/, exact: true }).click(); await expect.poll(() => reads).toBeGreaterThan(0) - if (cancelled) { await page.keyboard.press('Escape'); await expect(page.getByRole('dialog', { name: '创建 Codex 会话' })).toBeHidden() } + if (cancelled) { const dialog = page.getByRole('dialog', { name: '创建 Codex 会话' }); await dialog.getByRole('button', { name: 'Close', exact: true }).press('Enter'); await expect(dialog).toBeHidden() } model.sessions.push({ ...session('ses-created'), title: '新建验收会话' }); ready = true await expect.poll(() => completed).toBe(true) if (cancelled) { await expect(page).toHaveURL(/session=ses-a/); await expect(page.getByLabel('给 Codex 发送指令')).toHaveValue('原会话草稿') } From 1662be44cf107ffde65303d26da90b12811b9cb0 Mon Sep 17 00:00:00 2001 From: rike Date: Mon, 7 Sep 2026 17:03:58 +0800 Subject: [PATCH 4/4] fix(codex): recover history when session metadata follows creation --- farhelm-console/e2e/codex.spec.ts | 10 ++++++++++ farhelm-console/src/components/codex/Conversation.tsx | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/farhelm-console/e2e/codex.spec.ts b/farhelm-console/e2e/codex.spec.ts index 465e001..4973173 100644 --- a/farhelm-console/e2e/codex.spec.ts +++ b/farhelm-console/e2e/codex.spec.ts @@ -116,6 +116,16 @@ test('history recovers when Agent heartbeat follows Hub restart', async ({ page expect(model.historyReads).toBeLessThanOrEqual(4) }) +test('session metadata arriving after its creation receipt retries initial history', async ({ page }) => { + const model = await setup(page); model.historyStatus = 404; model.historyError = 'session_not_found' + await page.goto('/codex?session=ses-a') + await expect(page.getByText('会话不存在或尚未导入')).toBeVisible() + model.historyStatus = 200 + await emit(page, 'codex.session.updated', { session_id: 'ses-a' }) + await expect(page.locator('.markdown-table')).toHaveCount(1) + await expect(page.getByLabel('给 Codex 发送指令')).toBeEnabled() +}) + for (const cancelled of [false, true]) test(`created session follows its receipt; dialog cancelled=${cancelled}`, async ({ page }) => { const model = await setup(page); let ready = false; let reads = 0; let completed = false await page.route('**/projects', (route) => route.fulfill({ json: { protocol: 'farhelm/1', projects: [{ candidate_id: 'project-a', agent_id: 'gpu-a', suggested_project_id: 'cc08', display_name: '训练项目', state: 'approved' }] } })) diff --git a/farhelm-console/src/components/codex/Conversation.tsx b/farhelm-console/src/components/codex/Conversation.tsx index 79e0fff..3f260f7 100644 --- a/farhelm-console/src/components/codex/Conversation.tsx +++ b/farhelm-console/src/components/codex/Conversation.tsx @@ -18,6 +18,7 @@ export function Conversation({ csrf, id, session: listed, draft, onDraft, onSend const [loading, setLoading] = useState(false) const [failure, setFailure] = useState() const [ready, setReady] = useState(false) + const readyRef = useRef(ready); readyRef.current = ready const [newMessages, setNewMessages] = useState(false) const [away, setAway] = useState(false) const scroll = useRef(null) @@ -126,7 +127,7 @@ export function Conversation({ csrf, id, session: listed, draft, onDraft, onSend flush ??= setTimeout(commit, 80) } else if (event.type.startsWith('codex.turn.') || event.type === 'codex.session.updated') { commit() - const needsHistory = event.type.startsWith('codex.turn.') + const needsHistory = event.type.startsWith('codex.turn.') || !readyRef.current if (needsHistory && !follow.current) setNewMessages(true) scheduleRefresh(needsHistory) }