commit 0ba53cc7e3a842683e4b6212c37a2bba1bbfe90d Author: sorrow404null Date: Thu Sep 17 16:30:02 2026 +0800 init repo diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..276a5ce --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,66 @@ +# 仓库协作规则 + +haocode 是面向 Windows 和 Linux 的 PyQt6 桌面 AI Agent 客户端。Windows 首选 WebView2,失败时回退 QtWebEngine;Linux 只使用 QtWebEngine。`core/agent/` 保持无 GUI,并继续与 pi 对齐。 + +## 权威交接入口 + +处理修复、跨平台、测试、项目结构或交接任务前,必须先读 `docs/agent-handoff/README.md`,再按其中的触发条件读取对应文档。`readme.md`、`Frame.md` 和 `ARCHITECTURE.md` 只作历史背景,不能未经核验就当作事实源。 + +`data/config.json` 是不透明的本机密钥文件。Agent 不得打开、读取、搜索、打印、复制、编辑它,也不得让宽范围内容搜索包含它。测试必须使用临时配置。 + +## 项目结构 + +- `main.py`:入口、UTF-8 标准输出保护、Chromium 参数和 `MainWindow` 创建。 +- `core/`:无 GUI 后端,包括 SQLite 会话树、Qt 工作线程、WebView2 后端和日志。 +- `core/agent/`:与 pi 对齐的循环、流式处理、重试/压缩恢复,以及 `read`/`bash`/`write`/`edit` 工具;可直接进行纯 Python 测试。 +- `ui/views/`:PyQt6 窗口与桥接。`main_window.py`(约 6000 行)是当前中心,还包括 `bash_panel.py`、`chat_bridge.py`、`wv2_view.py`、`custom_web_page.py`、`debug_window.py` 和 `system_tools/`。 +- `ui/web/`:离线网页层,包括 `index.html`、`app.js`、`style.css` 及 vendored marked/DOMPurify/KaTeX/highlight.js。 +- `data/`:源码运行时数据,包括不透明的 `config.json`、自动创建的 `chat_history.db` 和 `attachments/`。 +- `tools/builtin_tools/pdf_reader.py`、`svg/`(图标)、`SYSTEM_PROMPT.md`(每次请求重新读取,修改后无需重启)。 +- `vendor/webview2/` 和根目录 `WebView2Loader.dll`:Windows 运行依赖,**不得删除**。 +- `tests/`:测试、smoke、诊断和人工验证脚本。 + +## 常用命令 + +- `pip install -r requirements.txt`:安装当前依赖;Linux 依赖规范化见交接任务清单。 +- `python main.py`:从源码运行。Windows 可从 WebView2 回退 QtWebEngine;Linux 使用 QtWebEngine。 +- `python tests/run_tests.py`:只运行当前 agent-core 聚合,不会发现整个测试目录。 +- `node tests/test_math_extract.js`:运行前端公式提取测试。 +- `python tests/check_db_migration.py `:只读检查数据库迁移完整性。 + +## 编码规范 + +- 以 Python 3.10 语法为基线,使用 4 空格缩进、`snake_case` 函数/文件名和 `PascalCase` 类名;目标运行矩阵为 Python 3.10–3.12。仓库未配置 formatter/linter,修改时匹配周边风格。 +- 注释和文档使用中文;路径、命令和 API 标识符保持原文。 +- `requirements.txt` 只列源码确实导入的包;依赖变更必须有意为之。 +- `core/agent/` 必须保持无 GUI,确保可离屏测试。 + +## 测试规则 + +仓库不使用 pytest;测试是可直接运行的 Python/Node 脚本。`tests/run_tests.py` 当前只加载 agent-core 套件。`test_*.py` 表示聚焦自动化测试,`smoke_*.py` 表示离屏集成测试,`diag_*`/`verify_*`/`tune_*` 默认表示诊断或人工脚本,除非文件自身另有说明。 + +```bat +set PYTHONIOENCODING=utf-8 +set QT_QPA_PLATFORM=offscreen :: only for smoke_* GUI tests +python tests/test_tool_params.py +``` + +跨平台聚合入口(不改变任何独立命令):`python tests/run_all.py --group logic|offscreen|all`。每个条目独立子进程 + 临时目录 + 显式超时;依赖/平台不适用项打印明确 SKIP 理由;任一失败则退出码非零。默认不跑 `diag_*`/`verify_*`/`tune_*`/真实 API/真实桌面脚本。 + +导入 `MainWindow` 前,必须把 `core.db_manager._DEFAULT_DB` 指向临时数据库,并把 `HAOCODE_CONFIG_FILE` 指向临时配置。当前仍有代码绕过该变量;对应任务完成前,测试还必须 patch 相关模块缓存的路径,并证明真实运行时文件未被访问或修改。 + +## 调试铁律 + +- **所有调试命令、测试与诊断脚本执行都必须设置显式的最长耗时预算(timeout)**。 +- 若中途因超时跳出,先定位卡点,再允许延长预算重跑;**严禁不设超时让它无限卡死**。 + +## 变更粒度 + +当前快照没有 Git 历史。不得初始化 Git 或伪造提交;每批修改仍须保持可独立提交。未来提交信息使用简短祈使句并注明区域,例如 `core/agent: fix compaction cut-point`。 + +## Agent 注意事项 + +- 运行时可能在根目录写出 `compaction_diag.log`、`stream_diag.log`、`diag_shot_*.png` 等诊断产物。除非当前任务明确负责清理,否则不得删除。 +- shell 行为尚未规范化。目标契约是 Windows `cmd.exe`、Linux `/bin/bash -lc`;修改前先读 `docs/agent-handoff/PLATFORM_PLAN.md`。 +- 本阶段源码运行继续把数据放在项目 `data/` 下;打包路径和 AppData/XDG 迁移延后。 +- 不得删除 `vendor/webview2/` 或根目录 `WebView2Loader.dll`。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..cf39878 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,238 @@ +# 项目文件架构 + +> [!WARNING] +> **历史资料,不是当前事实源。** 本文仅保留旧架构与故障记录。处理修复、跨平台、测试、项目结构或交接任务时,先读 [`docs/agent-handoff/README.md`](docs/agent-handoff/README.md)。 + +> 仅描述目录与文件的基础组织,不涉及具体实现细节。 + +``` +GeekAgent-Studio/ +│ +├── main.py # 程序入口 +├── requirements.txt # 依赖清单 +├── untitled.ui # Qt Designer 界面文件 +│ +├── ui/ # 表现层(桌面窗口 + 本地 Web 渲染) +│ ├── views/ # PyQt6 窗口与组件逻辑 +│ │ └── system_tools/ # 系统级工具(全局热键、截屏等) +│ ├── web/ # 本地 HTML/JS/CSS 渲染层 +│ │ └── highlight/ # 本地代码高亮库 +│ └── assets/ # 静态资源(样式表、图标) +│ └── icons/ +│ +├── core/ # 控制与大脑层(后端核心) +│ +├── agents/ # Agent 逻辑与执行器 +│ +├── workspace/ # 智能文件系统与代码编辑 +│ +├── tools/ # 动态 Skill / Tool 生态 +│ ├── builtin_tools/ # 内置基础工具 +│ └── dynamic_skills/ # 动态生成的工具脚本 +│ +├── data/ # 本地数据与缓存 +│ ├── attachments/ # 聊天附件 +│ └── .agent_history/ # 文件修改备份 +│ +├── svg/ # 界面图标资源 +│ +└── tests/ # 测试用例 +``` + +--- + +## 流式显示诊断体系(2026-07 新增,重要) + +**历史教训**:`dist/GeekAgent-Studio/` 是 PyInstaller 打包产物,`ui/web` 被冻结在 +`_internal/ui/web/` 里——**改源码不影响 exe**。修 UI 问题后必须重新打包 +(`python -m PyInstaller geekagent.spec --noconfirm`),否则用户跑 exe 永远看不到修复。 + +**链路诊断日志**(`stream_diag.log`,应用根目录,每次启动清空): +- Python 侧:`APP_START` / `FRONTEND_VER <前端版本戳>` / `CHUNK`(前 3 条 + 每 50 条 + + 会话不匹配时全记)/ `THINK` / `RESTORE` / `JS_DIAG`(回复完成时抓 JS 事件环) +- JS 侧:`window.__APP_VER` 版本戳 + `__diag` 事件环(createMessage / appendToken + 采样 / tokenDOM 缓冲长度 vs DOM 长度 / NO_WRAPPER 告警 / restore / history / finish) +- 读日志即可回答:token 到了吗?wrapper 在吗?缓冲有内容吗?DOM 写进去了吗? + +**助手正文透明气泡**(用户方案):`.message.assistant .md-segment` 浅灰半透明底 + +圆角边框,流式中/完成后视觉一致;正文段元素在首个 token 时同步创建,气泡即时出现。 + +--- + +## Agent 核心(core/agent)— pi 1:1 移植 + +``` +core/agent/ +├── types.py # AgentMessage(kind 标记压缩摘要) / AgentConfig / 事件 / ToolCall / ToolResult +├── context.py # token 估算(usage 锚定 + CJK 感知)/ 输出钳制 / 压缩触发公式 +├── stream_fn.py # openai_stream:流式 + tools 序列化 + OpenAI⇄pi 消息转换 +├── tools.py # bash(shell=True) / read / write / edit + 文本兜底解析器 +├── loop.py # run_loop:系统提示注入 → 流式 → 工具批执行 → 截断保护 +├── agent.py # Agent:事件订阅/发布(subscriber 异常静默,pi 语义) +├── compaction.py # pi harness 压缩算法 1:1(切点/断轮/迭代摘要/文件附录) +└── recovery.py # 429/上下文溢出/截断 重试与恢复(pi 精确语义) +``` + +- 每次发送 → 新 `AgentWorker`(core/llm_engine.py,QThread 胶水层); + 多轮工具循环在一次发送内部闭环,UI 线程只收 Qt 信号。 +- `stream_fn` 协议:`stream_fn(context, model, signal, max_tokens, tools=None)`; + `tools` 必须序列化进 OpenAI 请求体(否则模型只能"文字扮演"工具调用)。 +- `SYSTEM_PROMPT.md`(项目根):worker 模式每次请求头部注入,不入历史、不受压缩影响。 + +### 上下文管理与压缩(2026-07-21 升级为 pi harness 原版算法 1:1) + +对照 `packages/agent/src/harness/compaction/compaction.ts` + `utils.ts` 逐函数移植: + +- **触发公式**:`shouldCompact: tokens > contextWindow − reserveTokens`, + `reserveTokens=16384`(`AgentConfig.compaction_reserve`),替换旧 40% 启发式。 +- **token 估算**:usage 锚定 1:1——最后一条有效 assistant(非 aborted/error 且 + totalTokens>0)的精确值 + 其后消息逐条估算;无 usage 则全部逐条。 + 逐条估算用 CJK 感知版(唯一已声明偏差:pi 是 chars/4)。 +- **切分点** `find_cut_point`:从尾部回扫累计 token 至 `keepRecentTokens=20000`, + 取该位置起第一个有效切点(user/assistant;toolResult 不可切); + 切点非 user → **断轮**:轮起点前的历史与轮前缀**分两次 LLM 摘要**, + 拼接 `{history}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix}`。 +- **迭代式更新**:上次压缩摘要(`kind="compaction_summary"` 消息)不重摘, + 作为 `` 走 `UPDATE_SUMMARIZATION_PROMPT`(pi 四套提示词逐字移植)。 +- **文件操作附录**:摘要范围内 read/write/edit 工具的 path 提取, + 摘要尾部追加 `/`(1:1 utils.ts)。 +- **摘要预算**:`maxTokens = min(0.8×reserve, model.maxTokens)`;轮前缀 `0.5×reserve`。 +- **对话序列化**:`[User]:` / `[Assistant thinking]:` / `[Assistant]:` / + `[Assistant tool calls]:` / `[Tool result]:`(超 2000 字符截断)逐字一致。 +- **唯一已声明偏差**:摘要 LLM 调用失败时降级为机械摘录(pi 返回错误), + 桌面应用优先不丢上下文。 +- 架构级差异:会话重启后 agent 内存态不持久化(DB 只存时间线), + 压缩摘要随之丢失、上下文从零重建——与 pi 的 compaction 条目落盘不同(见桌面文档)。 + +## 时间线持久化(2026-07 新增) + +agent 模式的完整事件时间线按序落库,切会话/重载后 1:1 还原: + +``` +messages.timeline (JSON, 可空) +[ + {"t":"think", "text":"…"}, + {"t":"text", "text":"…"}, + {"t":"tool", "id":"call_x", "name":"bash", "args":"{…}", + "ok":true, "result":"…"} # ok:null = 仍在执行 +] +``` + +- **累积**:`MainWindow._active_streams[session]["timeline"]` 从 + reasoning/chunk/tool 三组 Qt 信号按到达顺序累积。 +- **入库**:正常结束 / 中止 两条路径都带 `timeline` 写 `db.add_message`。 +- **重载**:`load_messages_to_web` 对带 timeline 的 assistant 行调 + `renderTimelineHistory`(静态时间线:思考折叠、chip 定格、文本完整渲染)。 +- **切回进行中会话**:`restoreStreamingTimeline` 按流式状态恢复, + 后续 token 无缝续流(续接紧邻的进行中块,否则新开一段)。 +- **API 上下文重建**:`build_api_context` 对带 timeline 的行重建完整链 + (assistant+tool_calls → tool 消息),模型跨轮次工具记忆不丢。 +- 旧消息(无 timeline 列值)走原有聚合渲染,向后兼容。 + +## 流式渲染鲁棒性 + agent 运行期 UX(本轮新增) + +### 1. 流式内容可见性 —— 不依赖 rAF / 页面定时器 + +根因:正文/思考内容原来只在 `requestAnimationFrame` 回调里写入 DOM; +rAF 依赖合成器 BeginFrame,在 GPU 上下文丢失(本机 AMD 核显实测会 +周期性 context lost)/窗口隐藏/页面被判定后台(rAF 停发 + timer +钳制 1Hz)等环境下,内容永久空白(思考块/工具 chip 是同步插入 DOM +的所以看得见)。 + +对策(三层,均不依赖页面帧/定时器): +1. **同步渲染通道** `syncRenderThrottled`:`appendToken`/`appendReasoning` + 在同一 JS 任务内 30ms 节流直接写 DOM(token 由 Python runJavaScript + 送达,必被执行); +2. **恢复路径同步上屏**:`renderTimelineEntries(liveMode)` 结束即 + `syncRenderThrottled(msgId, 0)` —— 中途切回会话,恢复的正文/思考 + 不等任何帧立即显示; +3. **Qt 看门狗**(`MainWindow._render_watchdog`,200ms):对当前会话 + 活跃流 `forceRenderNow(msgId)`(幂等,尾部无变化时近零开销)。 + +另有:`doStreamingRender` 逐段 try/catch(解析异常 → 全量重解析); +rAF 通道保留作平滑优化 + 40ms setTimeout 兜底;收尾取消全部定时器。 + +回归测试:`smoke_live_guard.py` Phase 0(rAF+全部页面定时器 kill → +burst 后 400ms 内容必现)+ Phase A(仅 rAF 卡死 → 40ms 兜底); +`smoke_midswitch.py`(流式中切走→切回:块顺序/正文/思考/DB 完整 + +切回后立即同步可见)。 + +### 2. 深度思考:默认收起 + 进行中蓝色动画 + +- 流式/恢复/历史三条路径的思考块一律 `open=false`; +- 进行中块带 `.streaming-think`:标签蓝色呼吸 + 省略号动画(CSS + `think-breathe` / `think-dots`); +- 收尾移除动画类,标签还原「已完成深度思考」。 + +### 3. bash chip:耗时 / 命中超时徽章 + +- bash 工具结果自带 `[exit N] (X.Xs)` 与 `命令超时(>Ns)已终止`; +- `_parseToolTiming` 解析后在摘要行插入 `⏱ X.Xs`(灰)与 + `⏱ 超时 Ns`(红)徽章 —— 收起状态也能看到; +- 实时(toolExecutionFinished)与恢复/历史(buildToolChip)两条路径同逻辑。 + +### 4. 工具结果:展开必显示 + 长结果尾部预览 + 展开按钮 + +- 修复旧 bug:`toolExecutionFinished` 原来只在 `chip.open` 时写结果, + 收起状态下执行完 → 事后展开为空;现在无条件写入; +- 结果 > 4000 字:默认只显示尾部 4000 字(前缀 `…`),正文上方出现 + 「⬆ 展开完整输出(共 N 字)」按钮,再点收起;`chip.__fullResult` 存全文; +- 信号链路扩容:`tool_execution_finished` 携带 `text[:20000]`(原 800); +- API 回灌限制:`build_api_context` 重建 tool 消息时截断 4000 字, + 防止长输出撑爆模型上下文。 + +--- + +## 流式正文不显示:深度调试与根因(2026-07) + +### 现象链 +旧版(逐 token 全量 innerHTML 同步重绘)正文可见但乱序 → 时间线重构后(缓冲+rAF 增量渲染)正文流式期不显示,结束后/切回后可见。 + +### 诊断数据(用户环境 stream_diag.log,前端 v2) +- `FRONTEND_VER 20260721-v2` ✓ 最新前端在跑 +- CHUNK/THINK 逐 token 到达 UI 线程,会话匹配 ✓ +- JS_DIAG 完成时刻环形缓冲 = 思考事件以 ~95/s 流入 → **JS 线程活着,token 在被处理** +- 结论:不是 JS 没跑,是**屏幕没有把 DOM 变化画出来** + +### 根因(本机复现) +AMD 核显 GPU 上下文周期性丢失: +- 带 `--enable-gpu-rasterization --ignore-gpu-blocklist`(旧默认)离屏实测: + 连续 `SharedImageStub: context already lost` 错误 → **页面 JS 处理/Qt 定时器全部停摆** +- 加 `--disable-gpu` 后同样代码 100% 正常(真实消息内容回放探针 b/d 双满) +- 用户屏幕上的部分停摆:DOM 已写入,但 GPU 光栅化产物无法合成上屏 → 正文不可见 + +### 修复 +1. **main.py 默认改为 `--disable-gpu`**(全 CPU 软渲染,彻底绕开 GPU 上下文丢失; + 旧 GPU 模式保留为 `HAOCODE_RENDER=gpu`) +2. 保留双保险渲染通道(同步节流渲染 + 200ms Qt 看门狗 forceRenderNow) +3. v3 探针:`probeStream()` 每 2s 写入 `PROBE` 日志(缓冲长度/DOM 长度/offsetHeight/opacity/display), + `dumpDiag` 改为按类型摘要 —— 若再异常可一次性定位断点 + +### 最终根因(2026-07-21 v7,推翻上述 GPU 主因结论) +人肉 debug(token 级日志 + 屏幕截图 + 逐层盒模型探针 + 页面内对照/克隆实验)锁定: + +**Chromium 布局失效 bug:空 `.md-segment` 先入文档 → 匹配 `:empty{display:none}` +→ 30ms 后写入内容 → 引擎未重新触发布局 → 盒永久 0x0(正文不可见)。** + +证据链: +1. token 日志:正文 `段buf` 与 `dom`(textContent)同步增长(71c 全在 DOM),但 `h=0` +2. 像素分析 `diag_shot_*.png`:完成时刻屏幕上也无正文(切会话才可见 = DB 重渲路径) +3. 逐层探针:seg 的祖先链全部存活(reply-content w=119/h=75),唯 seg 子树 w=0/h=0 +4. 对照实验:同容器新建的 `.md-segment`(带内容)h=24 正常;原始流式段 h=0 +5. 克隆实验:原节点的**完整克隆 h=24**;把原节点**重新 appendChild 一次即恢复 h=24** + —— 纯布局状态腐坏,与 CSS 规则无关 + +为什么切会话重渲正常:`renderTimelineHistory` 创建节点时**先填充 innerHTML 再插入**, +从不以空节点进文档,`:empty` 从未匹配。 + +### v7 修复(真正根因) +1. **appendToken / renderTimelineEntries(live)**:新正文段先 `mdStateOf + renderMarkdownStreaming` + 同步渲染、**带内容再 insertBefore**(永不空节点入文档) +2. `.md-stable/.md-tail`:`display:contents` → `block`(规避 contents 布局风险,加内层边距补偿) +3. 周期性流式截图改为 `HAOCODE_SHOT=1` 可选 —— 实测 `QWidget.grab()` 强制出帧会 + 阻塞渲染器主线程 1-2s,会干扰正文 token 处理(诊断干扰项) +4. 保留 `--disable-gpu` 默认(对离屏测试环境仍有必要)与双通道渲染 + 看门狗 +5. 新增 `tests/verify_onscreen.py`:**真实窗口真实布局**验证(offscreen 无布局, + 此类 bug 离屏测不出来;本 bug 即由此漏检 3 轮) +6. v6 起 JS 端带渲染器主线程心跳(dt>1.5s 告警)+ finish 后正文段体检日志 + diff --git a/Frame.md b/Frame.md new file mode 100644 index 0000000..d48e4a7 --- /dev/null +++ b/Frame.md @@ -0,0 +1,288 @@ +# Frame —— haocode 逐目录 / 逐文件说明 + +> 本文件是 `readme.md` 的下钻版:**每个文件夹、每个文件到底负责什么**, +> 以及关键的类 / 函数名(方便直接搜索定位)。 +> 阅读建议:先看 `readme.md` 的架构总览,再回来看这里的目录细节。 + +--- + +## 0. 一眼看清分层 + +| 层 | 目录 | 职责 | 能否单独跑 | +|---|---|---|---| +| 入口 | `main.py` | 环境准备 → QApplication → MainWindow | — | +| 表现层 | `ui/views/` | PyQt6 窗口、控件、事件编排 | 需 GUI | +| 渲染层 | `ui/web/` | HTML/JS/CSS(离线三库:marked / dompurify / KaTeX + highlight.js) | 浏览器可单独开 | +| 引擎层 | `core/llm_engine.py` | 把 agent 核心包成 Qt 线程,发信号给 UI | 需 PyQt6 | +| Agent 核心 | `core/agent/` | 与 pi 1:1 的循环 / 流式 / 重试 / 压缩 / 工具 | ✅ 纯 Python 可测 | +| 存储层 | `core/db_manager.py` | SQLite:会话树、消息、压缩标记、附件 | ✅ 纯 Python 可测 | +| 外部集成 | `core/webview2.py`、`vendor/webview2/` | WebView2 原生子窗口 + .NET SDK | 需 Windows | +| 资源 | `svg/`、`ui/web/katex|highlight`、`data/` | 图标 / 前端库 / 配置 | — | + +--- + +## 1. 根目录文件 + +| 文件 | 说明 | +|---|---| +| `main.py` (111 行) | **程序入口**。① 顶部先做 stdout/stderr UTF-8 保护(打包成 exe 后 GBK 环境遇 emoji 会崩,见 readme 第六节);② 设置 `QTWEBENGINE_CHROMIUM_FLAGS`(默认 `--disable-gpu` 全 CPU 软渲染,规避 AMD 核显 GPU 上下文丢失导致"DOM 变了但屏幕不刷新");③ `QApplication` + 字体的 DPI/pointSize 防御;④ `MainWindow()` + `show()` + `app.exec()`。 | +| `SYSTEM_PROMPT.md` (~3400 tokens) | **agent 系统提示词**。0 身份 / 1 运行环境(含 §1.1 cmd.exe 陷阱表)/ 2 可用工具 / 3 工具参数与执行语义(read、bash、write、edit 四条铁律、执行顺序与并发)/ 4 调用方式 / 5 失败与异常处理(错误串→动作对照表)/ 6 工作方式 / 7 安全红线 / 8 会话与上下文。**每次请求由 `load_system_prompt()` 重新读取,改完不用重启**;只在 `enable_tools=True`(worker 模式)时注入,且**不进历史、不参与压缩**。 | +| `requirements.txt` | 依赖清单(只列代码真实 import 的包,AST 扫描核对过)。 | +| `haocode.spec` | **PyInstaller 打包配置**(onedir)。关键点:把 `ui/web`、`svg`、`vendor/webview2`、`SYSTEM_PROMPT.md`、`data/config.json`、`WebView2Loader.dll` 收集进 `_internal/`(因为代码里普遍用 `dirname(__file__)` 上溯定位项目根,冻结后根 = `_internal`);**刻意不含** `data/chat_history.db` 与 `data/attachments/`;`console=False`;带 `runtime_hooks`。 | +| `pyi_rth_trace.py` | 打包**运行时钩子**:把冻结版 stdout/stderr 重定向到 exe 同级 `haocode.log`(GUI 版没有控制台,没有它启动异常会完全静默)。 | +| `WebView2Loader.dll` | WebView2 加载器,供"以工作目录为基准"的兜底加载路径使用(首选路径是 `vendor/webview2/webview2loader_x64.dll`)。 | +| `ARCHITECTURE.md` / `readme_our.md` / `黑边两现象分析报告.md` | **历史文档**(早期架构、早期设计稿、窗口缩放黑边问题分析)。其中提到的 `agents/`、`workspace/`、`ui/assets/`、`untitled.ui` 等目录/文件**已在交接版移除**,最新结构以 `readme.md` + 本文件为准。 | +| `_out.txt` / `_t0.txt` | 文本示例文件(按交接要求保留)。 | + +--- + +## 2. `core/` —— 后端核心 + +### 2.1 `core/db_manager.py` (715 行) —— SQLite 存储层 + +**表结构** + +```sql +sessions(id, title, created_at, updated_at, has_messages, sort_order, is_starred, + current_leaf_msg_id, mode) -- mode: 'chat' | 'worker' | NULL(未发过消息) +messages(id, session_id, role, content, reasoning, is_ignored, created_at, + attachment_metadata, parent_id, timeline, usage, + stop_reason, error_message) +``` + +- **会话是链表树**:`messages.parent_id` 指向上一条,`sessions.current_leaf_msg_id` 记录当前叶子。 + `get_message_chain()` 从叶子向上回溯再反转 → 得到"当前激活时间线"(绕开所有废弃分支)。 +- `role` 取值:`user` / `assistant` / `system` / **`compaction`**(压缩标记行,`content` 是摘要)。 +- `timeline`:assistant 行的**事件时间线 JSON**(`{"t":"think"|"text"|"tool", ...}`), + 工具条目形如 `{"t":"tool","id","name","args","ok","result"}`;`ok=None` 表示开始了但没拿到结果。 +- `usage`:本轮精确 token 用量(`{"input","output","cacheRead",...}`),供显示与压缩估算做**锚点**。 +- `stop_reason` / `error_message`:失败轮次标记(`'error'`),NULL = 正常行。 + +**关键方法** + +| 方法 | 作用 | +|---|---| +| `add_message(...)` | 插一条消息并把会话叶子前推(`stop_reason`/`error_message` 为可选新参数) | +| `get_message_chain(session_id)` | 取当前激活链(含环检测保护) | +| `get_branch_info(parent_id)` | 某父节点下的所有子分支(UI 的 `1/3` 分支切换) | +| `update_session_leaf(session_id, leaf)` | 手动改叶子(分支切换、错误回退用) | +| `insert_compaction_mark(session_id, summary, cut_before_id, first_retained_id, meta)` | 插压缩标记行 | +| `copy_session(...)` | 整会话深拷贝:消息 id 重映射、parent 重连、**附件物理复制改名**、标题 `(副本 N)` 递增 | +| `_upgrade_schema(cursor)` | 旧库自动补列。⚠️ **注意**:旧的 `upgraded=True` 分支会触发"链表化重构"(把树按时间拍平成线性链)→ 新增列**绝不能**置这个标志(源码里有警告注释) | +| 冻结态库路径(文件头) | `sys.frozen` 时优先 `exe/../../data/chat_history.db`(存在才共用),否则 `exe 同级/data/chat_history.db` | + +### 2.2 `core/llm_engine.py` (446 行) —— 引擎层(Qt 线程桥) + +| 名字 | 说明 | +|---|---| +| `load_system_prompt()` / `_load_config()` / `_provider_info()` | 读 `SYSTEM_PROMPT.md`、`data/config.json`(缺失返回兜底短提示词 / 空配置) | +| `AgentWorker(QThread)` | **worker 模式**:组装 `AgentConfig`(model / system_prompt / tools / retry=3 次 2s×2)→ `agent.set_stream_fn(openai_stream)` → `AgentRunner(agent, summarize_fn=..., 回调...)` → `runner.run(last_user)`。信号:`chunk_received` / `reasoning_received` / `tool_execution_started|updated|timed|finished` / `context_compacted` / `compaction_started` / `retry_scheduled` / `retry_finished` / `usage_updated` / `error_occurred` / `finished` | +| `_bridge(event)` | `AgentEvent` → Qt 信号(在工作线程内 emit,跨线程 queued 投递) | +| `_make_summarize_fn(model)` | 压缩用的**非流式** LLM 调用(`prompt_text, system_prompt, max_tokens → str`) | +| `ChatWorker(QThread)` | **chat 模式**:单次流式,无工具、无重试、无压缩(快速问答) | +| `TitleWorker(QThread)` | 会话自动命名 | +| `abort()` / `cancel()` | 中断:置 `AbortSignal` → agent 循环在 chunk 边界收尾为 `stop_reason="aborted"` | + +### 2.3 `core/webview2.py` (406 行) —— WebView2 集成 + +| 名字 | 说明 | +|---|---| +| `get_environment(app)` | 返回 WebView2 环境或 `None`(None → UI 侧回落 QtWebEngine)。**两道守卫**:① `_wv2_allowed_here()` 无头/测试环境直接不启用;② `acquire_instance_lock()` 抢不到单实例锁(已有实例在跑)就跳过,**并且跳过 `taskkill`**(否则会把正在运行的兄弟实例的浏览器进程杀掉 → 它的 controller 变 disposed → **DOM 正常但视觉层永久空白**,这就是历史 T0 事故) | +| `acquire_instance_lock()` | `msvcrt.locking` 锁 `data/app_instance.lock`(`HAOCODE_INSTANCE_LOCK_FILE` 可覆盖路径,供测试隔离);返回 True=唯一实例 / False=已有实例 / None=平台不支持 | +| `Wv2Session` | 一个 WebView2 实例:创建 controller、找子窗口 hwnd(`find_child`)、`set_bounds`/`set_visible`/`navigate`/`execute_js`/`close` | +| `_pump_wait(op, app, timeout)` | 等待异步 COM 操作时泵 Qt 事件(避免 UI 卡死) | +| 环境变量 | `HAOCODE_FORCE_QTWEBENGINE=1` 强制回落;`QT_QPA_PLATFORM=offscreen` 自动不启用 WebView2 | + +### 2.4 `core/debug_log.py` (78 行) —— 调试事件总线 + +`debug_log(msg, tag)` 写内存环形缓冲 + 落盘;`poll_debug_cmd()` 取调试窗口输入的命令; +`autostart_debug_window(cfg)` 按 `config.json: debug_window_autostart` 决定是否开调试窗口。 + +### 2.5 `core/agent/` —— pi 1:1 agent 核心(9 文件) + +| 文件 | 行数 | 关键名字 | 说明 | +|---|---|---|---| +| `types.py` | 331 | `AgentMessage` `ToolCall` `AgentConfig` `ModelConfig` `RetryConfig` `AgentEvent` `AbortSignal` `new_id` | 全部数据结构(dataclass)。`AgentMessage` 含 `content/reasoning/tool_calls/stop_reason/error_message/usage/timestamp` | +| `agent.py` | 204 | `Agent`(`subscribe`/`prompt`/`continue_`/`steer`/`follow_up`/`abort`/`set_stream_fn`) | agent 对象:状态机 + 事件订阅 + 输入队列(steering=轮中插话,followUp=队列尾续跑) | +| `loop.py` | 482 | `run_loop` `_stream_turn` `execute_tool_calls` `_execute_parallel` `_execute_sequential` `_should_terminate_batch` | **主循环**:注入 system prompt → 输出预算钳制 → 流式一轮 → 工具批(含 length 截断保护:参数可能残缺则一律不执行)→ 轮末钩子(`prepare_next_turn` / `should_stop_after_turn`)→ 停止判定 | +| `stream_fn.py` | 438 | `openai_stream` `to_openai_messages` `from_openai_messages` `classify_error` `_parse_tool_call` `_pick_reasoning` `_pick_usage` | OpenAI 兼容流式调用(180s 超时、SDK 层 `max_retries=0`——重试统一交给 recovery 层)+ 异常分类(rate_limit/timeout/connection/server_error/overload/auth/overflow)+ 工具调用增量拼装(JSON 解析失败兜底 `{}` + 保留 raw) | +| `recovery.py` | 519 | `AgentRunner`(`run` `pre_prompt_compaction` `compact_if_needed` `_handle_post_agent_run` `_prepare_retry` `_remove_last_bad_assistant` `_do_compaction`)、`is_context_overflow` `is_retryable_assistant_error` `is_recoverable_length` `compute_retry_delay_ms` `compact_diag_log` | **三路决策**:① 上下文溢出 → 压缩恢复(只试一次)② 可重试错误(429/5xx/超时/断连…且非配额耗尽)→ 移除坏消息 + 退避(2s→4s→8s,最多 3 次)③ 否则停。另含**轮中主动压缩** `compact_if_needed`(单条巨型工具输出不再依赖"失败一次"再兜底;同 run 连败 2 次即止损) | +| `compaction.py` | 637 | `compact_context` `should_compact` `CompactionSettings` `find_cut_point(s)` `serialize_conversation` `FileOperations` | 上下文压缩:按 pi harness 原版算法找**有效切点**(不切开工具调用对)→ 摘要化前半段 → 保留尾巴 → 产出切点 id(供 UI 落库成 `compaction` 标记行) | +| `context.py` | 341 | `estimate_context_tokens` `estimate_message_tokens` `calculate_context_tokens` `_find_last_usage` `clamp_max_tokens_to_context` `clamp_outputs_to_context` | token 计量与预算钳制:优先用**最近一次真实 usage 做锚点**,无锚点才按字符估算(含图片按比例计) | +| `tools.py` | 873 | `tool_read` `tool_bash` `tool_write` `tool_edit` `default_tools` `prepare_tool_call` `parse_text_tool_calls` | 四个工具的实现与参数校验。`tool_bash` 用「Popen + 读线程 + 队列」实现**实时输出**(`on_update`)、每秒读秒(`on_timer`)、超时 `taskkill /F /T` 杀进程树、50KB 截断;`tool_edit` 按「原文件快照定位 + 唯一匹配 + 非重叠 + 全有或全无」改文件 | + +--- + +## 3. `ui/` —— 表现层 + +### 3.1 `ui/views/main_window.py` (6019 行) —— 主窗口(全项目核心) + +单文件承载了绝大部分 UI 与编排逻辑,按区块读: + +| 区块(搜关键字) | 说明 | +|---|---| +| `MainWindow.__init__` | 组装:DBManager、左侧栏、聊天区、工具栏、输入区、右侧任务面板、调试窗口 autostart、全局热键 | +| `setup_ui()` | 三栏布局 `main_layout = [sidebar | chat_area | bash_panel]`;顶部工具栏(`历史` 按钮) | +| `setup_stylesheet()` | 全量 QSS(`#sidebar` / `#right_sidebar` / `#bl_*` / `#top_tool_btn` / 气泡…) | +| `get_svg_path()` / 侧边栏折叠动画 | `sidebarWidth` 属性 + `QPropertyAnimation`;窗口过窄时自动折叠 | +| 输入区与附件 | `_on_image_pasted` / `_on_files_dropped` / `_on_long_text_pasted` / `AttachmentPreviewOverlay`(独立顶层窗口,避免被 WebView 遮挡) | +| `send_message()` | ① 若本会话在生成 → 走**中断**分支;② 建 `_active_streams[session_id]` 流状态;③ 落库用户消息;④ 起 `AgentWorker`/`ChatWorker` 并接线全部信号 | +| `_active_streams` | 每会话流式状态:`msg_id / parent_id / previous_leaf_id / content / reasoning / timeline / tl_kind / usage / worker` | +| `on_chunk_received` / `on_reasoning_received` | 累积正文/思考 + 维护时间线(`t:text` / `t:think` 分段)+ 定时刷新上下文标签 | +| `_on_tool_started/updated/timed/finished` | 工具事件 → 时间线条目 + 前端 chip + 右侧任务面板转发 | +| `_on_context_compacted` | 压缩完成 → 同一气泡原地定格(摘要全文)→ **插 `compaction` 标记行入库** | +| `on_reply_finished` | 正常收尾:三维全空则不入库;否则落库 assistant 行(正文/思考/时间线/usage),叶子前推 | +| `on_error` → `_persist_failed_stream` | **失败轮次也入库**(对照 pi:`message_end` 无条件持久化)——本轮已完成的工具结果/正文/思考全部保留,尾部追加 `> ⚠️ [本轮中断] …`(并额外作为一条 timeline 文本条目,因为带 timeline 的行回放不读 content);完全空的一轮才 `is_ignored=1` 只留痕不回放 | +| `_persist_interrupted_stream` | 用户主动中断:有内容则入库,全空则回退叶子 | +| `build_api_context(session_id)` | **DB → API 上下文**:处理压缩标记(标记之前只发摘要)、附件转 base64(图片走视觉接口)、回放 assistant 的 timeline(重建 `tool_calls` + `tool` 结果,结果截断 4000 字)、跳过 `is_ignored` 与全空行、孤儿工具补合成结果、错误行照常回放 | +| `load_messages_to_web(session_id)` | 切会话:清空前端 → 按链渲染历史(含时间线回放)→ 刷新右侧面板 | +| 模式切换 | `mode_switch` 配置 + `chat`/`worker` 两种 worker;`sessions.mode` 锁定已发过消息的会话模式 | +| 右侧面板接线 | `bash_panel.set_session(...)` + 四个 tool 事件转发(仅当前会话) | +| 关闭清理 | `closeEvent`:中断所有 worker、持久化进行中的回复、清理后台任务 | + +### 3.2 `ui/views/bash_panel.py` (944 行) —— 右侧任务面板 + +| 名字 | 说明 | +|---|---| +| `BashPanel(QWidget)` | 外壳:宽 260 / 收起 52(`panelWidth` 属性 + 260ms InOutCubic 动画);**开关按钮在栏内**(收起态=栏正中 34×34;展开态=标题行右上角 28×28,两按钮分居 QStackedWidget 两页,任何时刻只有一个可见) | +| `_ResizeHandle` | 左边缘 4px 拖拽调宽(最小 200px),松手把宽度写入 `config.json: bash_panel_width`,下次展开自动恢复 | +| `_Section` | 一栏 = 头部行(24px **硬固定**)+ `body`(滚动区 + 提示语);折叠只收 `body` 的高度 | +| `_layout_targets()` | 两栏折叠/展开的统一布局策略:**层从顶部堆叠,余量只由"展开着的已完成"吸收;已完成收起时余量进 `spacer` 空白占位**(避免头部被顶到面板底部) | +| `BashLayer` | 一层 = 一次 bash 执行:头部(状态点/名称/耗时/状态标签/命令预览/箭头)+ 展开后「参数」「输出」两块;运行中实时输出(200KB 上限,超出标注)、完成后显示**进入上下文的原文**、压缩切点之前的层标「已出上下文」 | +| 常量 | `PANEL_W_DEFAULT/MIN/MAX`、`FOLD_MS=200`、`FOLD_STEP=16`、`LAYER_LIMIT=30`、`LIVE_BUF_CAP=200KB`、`_SPLIT_ORIENTATION`(Vertical,改 Horizontal 即左右并排) | + +### 3.3 `ui/views/` 其余文件 + +| 文件 | 行数 | 说明 | +|---|---|---| +| `chat_bridge.py` | 188 | `ChatBridge(QObject)`:**Python → JS 单向桥**(QWebChannel)。每个方法 = 一个前端函数:`create_message` / `append_token` / `append_reasoning` / `finish_message` / `tool_execution_started|updated|timed|finished` / `restore_streaming_timeline` / `render_timeline_history` / `show_note` / `show_error` / `compaction_started` / `compaction_finished`。用 `json.dumps` 转义全部文本,杜绝注入 | +| `wv2_view.py` | 217 | `WebView2View(QWidget)`:把 WebView2 原生子窗口包成"看起来像 QWebEngineView"的控件——提供 `page()`(`_PageShim.runJavaScript`)、`setUrl`、`grab`、`attach_bridge`、`sync_bounds`(父窗口移动/缩放时同步原生子窗口位置),从而让上层渲染代码**两条渲染路径共用一套调用** | +| `custom_web_page.py` | 65 | `CustomWebPage(QWebEnginePage)`:QtWebEngine 回落路径,`acceptNavigationRequest` 限制只允许本地 file:// | +| `debug_window.py` | 214 | `DebugWindow(QWidget)`:两个页签「对话消息 / 应用日志」+ 命令输入框;`_TailReader` 增量读日志文件 | + +### 3.4 `ui/views/system_tools/` + +| 文件 | 说明 | +|---|---| +| `file_reader.py` | `read_text_file(path, max_bytes)`:文本/代码文件读取;带**二进制黑名单**(Word/Excel/PPT/压缩包/可执行/媒体),PDF 不在黑名单里(交给 `tools/builtin_tools/pdf_reader.py`) | +| `global_hotkey.py` | `GlobalHotkeyThread(QThread)`:`RegisterHotKey` 注册全局热键(默认 Ctrl+Shift+S 截屏),失败只打印不影响主流程 | +| `screen_capture.py` | `ScreenCaptureOverlay(QWidget)`:全屏遮罩框选区域 → 截屏 → 转成图片附件(`screenshot_captured` 信号) | + +### 3.5 `ui/web/` —— 本地渲染层(完全离线) + +| 文件 | 行数 | 说明 | +|---|---|---| +| `index.html` | 82 | 页面骨架 + 引入本地 `marked` / `dompurify` / `highlight` / `katex`(无任何 CDN) | +| `app.js` | 2111 | **前端全部逻辑**:`createMessage` / `appendToken`(稳定前缀增量渲染 + rAF 批量)/ `updateMessage` / `finishMessage` / 时间线渲染(思考块、工具 chip、压缩气泡)/ `marked.parse` 统一拦截点(先抽公式占位符再渲染,最后 KaTeX 回填)/ 自动滚动与"贴底"判定 / 分支切换 `1/3` | +| `style.css` | 902 | 全部样式:气泡(assistant 85% 宽、透明正文)、思考块、工具 chip、紧凑模式、公式块、错误提示条 | +| `marked.min.js` / `dompurify.min.js` | — | Markdown 渲染 + HTML 消毒 | +| `highlight/` | — | highlight.js + atom-one-dark 主题 | +| `katex/` | — | KaTeX 0.16.11(js + css + woff2 字体) | + +--- + +## 4. `tools/` + +| 文件 | 说明 | +|---|---| +| `builtin_tools/pdf_reader.py` | **PDF 专用解析**(`extract_pdf_text` / `extract_pdf_images`),基于 PyMuPDF。被 `ui/views/main_window.py` 直接 import(文本模式抽文字、图片模式抽内嵌图片为 PNG 再送视觉接口),并有专门的 `PDFExtractWorker` 线程避免大文件卡 UI | +| `__init__.py` / `builtin_tools/__init__.py` | 包结构标记(**必须保留**,主窗口按 `tools.builtin_tools.pdf_reader` 路径 import) | + +> 交接版已删除该目录下**全空且零引用**的 `registry.py` / `conda_env.py` / `file_ops.py` / `web_search.py`。 + +--- + +## 5. `svg/` —— 图标(18 个) + +`panel.svg`(左侧栏开关)/ `panel_right.svg`(右侧任务面板开关)/ `chevron_down|right.svg`(弹窗箭头)/ +`mode_chat.svg` / `mode_worker.svg`(模式)/ `model.svg` / `provider*.svg`(模型弹窗)/ `send*.svg` / `stop*.svg` / +`upload.svg` / `cross.svg` / `check.svg` / `main.svg`。全部经 `MainWindow.get_svg_path()` 按项目根解析。 + +--- + +## 6. `data/` —— 运行时数据 + +| 文件 | 说明 | +|---|---| +| `config.json` | 供应商/模型/开关(**交接版已清空 api_key**)。关键键:`providers`、`default_provider/model`、`temperature`、`debug_window_autostart`、`mode_switch`、`webview_backend`,运行中还会被写入 `bash_panel_width`(面板宽度记忆) | +| `chat_history.db` | 聊天主库(**交接版不含**,首次运行自动创建空库 + 初始对话) | +| `attachments/` | 附件目录(图片/PDF,**交接版不含**;运行时按 `<项目根>/data/attachments/` 存放) | + +--- + +## 7. `vendor/webview2/` + +WebView2 的 .NET SDK(随仓库提供,不走 pip): +`net462_Microsoft.Web.WebView2.Core.dll`(`clr.AddReference` 加载)、`webview2loader_x64.dll`(loader)、`sdk.nupkg`(原始包)。 + +--- + +## 8. `tests/` —— 测试与调试工具 + +### 8.1 正式测试套件(交接后应保持全绿) + +| 文件 | 内容 | +|---|---| +| `run_tests.py` | **离线 harness**(自带用例收集 + pytest stub),跑 `test_agent_core.py` | +| `test_agent_core.py` | agent 核心:流式增量、工具批、重试、压缩、读秒、超时杀树 | +| `test_tool_params.py` | 四个工具的参数校验与错误串(35 项) | +| `test_compaction_persist.py` | 压缩标记入库 + 上下文截断行为 | +| `test_copy_session.py` | 会话复制(消息/分支/附件深拷贝/标题递增/叶子)(54 项) | +| `test_bash_stream.py` | bash 实时输出、每秒读秒、超时杀进程树、50KB 截断、非零退出 | +| `test_error_persist.py` | **失败轮次入库 + 回放取舍**(T1–T9,含旧库迁移不得破坏分支的安全用例) | +| `test_wv2_guard.py` | WebView2 双守卫(无头环境 / 跨进程单实例锁) | +| `test_debug_window.py` | 调试窗口事件与日志 | +| `test_think_code_neutral.py` | 思考块/代码块内容中性化 | +| `test_file_attach.py` / `test_pdf_reader.py` | 附件类型判定 / PDF 文本与图片解析 | +| `test_math_extract.js` | 前端公式提取逻辑(Node 运行,39 项) | +| `smoke_offscreen.py` | 主窗口离屏冒烟(8 项) | +| `smoke_mode.py` | chat ↔ worker 模式切换(16 项) | +| `smoke_copy_session.py` | 复制会话 UI 全链路 | +| `smoke_bash_panel.py` | 右侧任务面板全链路(含折叠动画/布局策略/拖拽调宽,116 项) | +| `check_db_migration.py` | 只读工具:校验库迁移后旧数据 100% 原样(消息数/父指针/叶子/分支点逐项比对) | + +### 8.2 真机 / 真 API 工具(按需使用,不属于回归) + +`smoke_live_guard.py`、`smoke_midswitch.py`、`smoke_persist.py`、`smoke_probe.py`、`smoke_repro_real.py`、 +`smoke_timeline.py`、`smoke_manual.py`、`diag_live_agent.py`、`diag_live_onscreen.py`、`diag_live_text.py`、 +`verify_onscreen.py`、`verify_math_render.py`、`inject_math_demo.py`、`debug_inject.py`、 +`tune_mode_popup.py`、`tune_model_popup.py`(两个弹窗调参工具)。 + +### 8.3 写测试的两条铁律 + +1. 不得污染真实库:测试开始时把 `core.db_manager._DEFAULT_DB` 指向临时文件(**必须在 import MainWindow 之前**) +2. 不得污染真实配置:把 `HAOCODE_CONFIG_FILE` 指向临时文件(会写 `config.json` 的功能) + +> 交接版已清除 81 个开发期临时物(`_tmp*` 脚本 / `_tmp_resize_vis/` 截图 / `_tmp_*.log`)。 + +--- + +## 9. 一次提问的完整生命周期(把上面所有文件串起来) + +``` +① 用户输入(ui/views/main_window.py: send_message) + └─ 落库 user 行(core/db_manager.add_message)→ 建 _active_streams[sid] 流状态 + +② 组装上下文(main_window.build_api_context) + └─ 取当前激活链 → 压缩标记截断 → 附件转 base64 → timeline 回放成 tool_calls/tool 消息 + +③ 起引擎(core/llm_engine.AgentWorker.run) + └─ AgentConfig(model, system_prompt=SYSTEM_PROMPT.md, tools=default_tools(), retry=3×2s) + + AgentRunner(summarize_fn=..., on_retry_* / on_compaction_* 回调) + +④ agent 核心(core/agent/loop.run_loop) + ├─ 轮首/轮中压缩检查(recovery.compact_if_needed → compaction.compact_context) + ├─ 流式一轮(stream_fn.openai_stream:text/reasoning/toolcall 增量 + usage + stop_reason) + ├─ 出错 → recovery._handle_post_agent_run:溢出→压缩重试 / 可重试→退避重试 / 否则停 + └─ 有 tool_calls → execute_tool_calls(tools.py 的四个工具,并行/串行按批次规则) + +⑤ 事件回流 UI(llm_engine._bridge → Qt 信号 → main_window 的 on_* 处理器) + └─ chat_bridge 调前端 app.js:增量渲染 / 时间线 chip / 思考块 / 读秒 / 压缩气泡 + └─ 同时转发给 bash_panel(右侧任务面板实时更新) + +⑥ 收尾落库(main_window.on_reply_finished 或 on_error→_persist_failed_stream) + └─ assistant 行 = 正文 + 思考 + timeline(工具) + usage(+stop_reason),叶子前推 + └─ 压缩发生 → 另插 role='compaction' 标记行 + +⑦ 下一次提问回到 ②,此时模型能看到上一轮的正文、工具调用与结果(失败轮次亦然) +``` diff --git a/SYSTEM_PROMPT.md b/SYSTEM_PROMPT.md new file mode 100644 index 0000000..7bdba14 --- /dev/null +++ b/SYSTEM_PROMPT.md @@ -0,0 +1,157 @@ +# haocode Agent 系统提示词 + +## 0. 身份 + +你是 **haocode**(worker 模式)的本地智能体,运行在用户本机的 PyQt6 桌面应用内, +通过内嵌浏览器界面与用户对话。你具备代码理解与修改能力,并能调用工具直接操作 +本机文件与 shell。你的目标是把用户交代的任务**真正做完**(改文件、跑命令、验证结果), +而不是只给出建议或描述该怎么做。 + +- 回答语言:**简体中文**;代码、命令、路径、标识符保持原文。 +- 你在一个多轮循环里工作:可以连续调用工具、观察结果、再决定下一步, + 直到任务完成或确实需要用户决策为止。 + +## 1. 运行环境 + +- 当前工作目录:**haocode 项目根目录**。所有相对路径都相对它解析; + 每条 bash 命令都以它作为工作目录启动。 +- Python:直接用 `python`(3.10 环境,PyQt6、openai 已装)。 +- 前端是本地网页(`ui/web/`),改动前端文件后需重启应用才生效。 + +{{SHELL_PLATFORM_SECTION}} + +## 2. 可用工具 + +| 工具 | 用途 | 关键约束 | +|---|---|---| +| `read` | 读取**文本**文件(带行号) | 单次 ≤2000 行 / 50KB;大文件用 `offset`/`limit` 分页;**不要用于图片或二进制** | +| `bash` | 执行 shell 命令 | 默认 120 秒超时(上限 600);输出 50KB 截断;**同批有它则整批串行** | +| `write` | 新建或**完整覆盖**文件 | 自动创建父目录;原子写入;只用于新建或整体重写 | +| `edit` | 精确文本替换 | `oldText` 必须与**原文件**逐字符一致且唯一;各条区间不得重叠;**整批全有或全无** | + +看文件优先用 `read`(不要用 `cat`);探查目录、搜索内容用 `bash`(`ls`、`grep`)。 + +## 3. 工具参数与执行语义 + +### 3.1 read + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `path` | string | ✅ | — | 文件路径(相对路径基于项目根) | +| `offset` | integer | | 1 | 起始行号,**从 1 开始** | +| `limit` | integer | | 2000 | 最多读取行数,**上限 2000**(传更大按 2000) | + +输出格式:每行 `%6d\t原文`,即「6 位右对齐行号 + TAB + 原始内容」,末尾附 +`[已显示 a–b 行,共 N 行](还有 M 行未显示,用 offset=X 继续)`。 + +- 只读文本(UTF-8,非法字节被替换为 �)。**读图片或二进制只会得到乱码,不会报错**。 +- 文件可能超过 2000 行时:先用小 `limit` 看结构,再用 `offset` 分页读到目标位置。 +- ⚠️ **把读到的内容用作 `edit` 的 `oldText` 时,必须去掉行号与 TAB 前缀**,只保留文件原文。 + +### 3.2 bash + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `command` | string | ✅ | — | 要执行的命令(平台 shell 语法见第 1.1 节) | +| `timeout` | number | | 120 | 超时秒数,**上限 600**(传更大按 600) | + +- 返回值:`$ 命令` + stdout + `[stderr]` + `[exit N] (耗时)`;**退出码非 0 时结果视为失败**。 +- 超时:到期会**杀掉整棵进程树**并返回 `命令超时(>Ns)已终止`。 + 长任务(全量测试、构建、下载)请显式传 `timeout`;短查询不必传。 +- 输出超过 50KB 会被截断 → 用 `-n` / `head` 或更精确的命令收窄输出后再逐步放宽。 +- 需要等待的场景直接跑命令并设好 `timeout`,不要用反复 `sleep` 试探。 + +### 3.3 write + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `path` | string | ✅ | 目标文件路径;父目录自动创建 | +| `content` | string | ✅ | 完整文件内容(覆盖写,**不是追加**) | + +- 只用于**新建文件**或**整体重写**;局部改动一律用 `edit`(避免丢掉未读到的内容)。 +- 写入是原子的(临时文件 + 替换),不会留下半截文件。 +- 覆盖已存在文件前,先确认你确实要丢弃它原有的其余内容。 + +### 3.4 edit + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `path` | string | ✅ | 目标文件路径 | +| `edits` | array | ✅ | 替换列表,每项 `{oldText, newText}`;`oldText` 必填,`newText` 省略视为删除 | + +四条铁律: + +1. `oldText` 必须与文件原文**逐字符完全一致**(含空格、缩进、换行),且在**原文件**中**唯一**。 +2. 每条 edit 都对照**原始文件**定位,**不是**「上一条改完之后」的结果 → **不要**写重叠或嵌套的 + edit;同一处或相邻的多处改动请**合并进同一条** edit。 +3. `oldText` 尽量**短但保证唯一**;**不要**用一大段未改动的内容去「连接」两处相距很远的改动。 +4. 一次调用传入全部 edit(`edits[]`),**不要**为同一个文件连续发多次 `edit`。 + +- 整批是**全有或全无**:任何一条未命中、不唯一或区间重叠 → 文件一个字节都不会被修改。 +- 删除文本就把 `newText` 写成 `""`;要改多个文件就分多次调用(不同 `path`)。 + +### 3.5 执行顺序与并发 + +- 同一批里只要有一个 `bash`,**整批串行**执行(bash 是 sequential 工具)。 +- `read` / `write` / `edit` 会**并发**执行 → **同一批次内不要对同一个文件发多个 `edit`/`write`** + (先后顺序不保证)。 +- 无依赖的多个动作尽量放在同一批连续调用(减少往返);有依赖关系时再分轮次。 + +## 4. 工具调用方式 + +- 通过 **function calling**(请求里的 `tools` 参数)调用工具。**只有 function call 会被执行。** +- **严禁**在正文里写 `...`、`...` 之类的伪工具调用—— + 那些只是文本,`write`/`edit` 绝不会被执行。 +- 参数必须是**正确的 JSON 类型**:`offset`/`limit`/`timeout` 传数字(不要传 `"10"`); + `edits` 传数组(不要传 JSON 字符串);不要给用不到的参数传 `null`。 +- 一次需要多个动作就连续发多次 function call,**全部完成后再**统一总结。 + +## 5. 工具失败与异常处理 + +| 工具返回 | 含义与你的动作 | +|---|---| +| `文件不存在: <绝对路径>` | 路径写错了。用 `bash` 的 `ls` 确认真实路径,**不要猜** | +| `path 不能为空` / `command 不能为空` | 参数缺失,补齐后重试 | +| `[起始行 offset=N 超出文件范围,该文件共 M 行]` | 用 M 以内的 offset 重读 | +| `[文件为空(0 行)]` | 文件确实为空 → 用 `write` | +| `命令超时(>Ns)已终止` | 收窄命令范围,或显式传更大的 `timeout`(≤600)后重试 | +| `参数校验失败: …;收到的参数: …` | 按提示修正类型/必填项(注意别传数字字符串) | +| `第 i 条 edit 匹配到 N 处(要求唯一)` | 加长 `oldText` 上下文使其唯一 | +| `第 i 条 edit 未找到匹配文本` | 先 `read` 确认原文;检查是否把行号+TAB 前缀带进了 `oldText` | +| `edits[i] 与 edits[j] 区域重叠` | 合并成一条 edit,或改成互不相交的改动 | +| `工具调用 X 未执行(length):…` | 上一轮回复被输出长度截断 → 缩短回答或拆分步骤重试 | +| `操作已中止 (Operation aborted)` | 用户主动中止 → 停止动作,简要说明当前进度 | + +连续两次同样的失败就**停下来向用户说明卡点**,不要用相似命令反复硬试。 + +## 6. 工作方式 + +1. **先看清再动手**:改代码前先 `read` / `bash` 确认现状;不凭空猜路径、函数名、行号。 +2. **小步快跑**:一次做一个明确改动;改完立刻用 `bash` 验证(编译、测试、脚本)。 +3. **验证要真实**:说「已完成」之前必须有工具输出作证据(命令结果 / 测试结果)。 +4. **推荐流程**:定位(`grep` / `ls`)→ 精读(`read`)→ 改动(`edit` / `write`)→ 验证(`bash`)。 +5. **范围克制**:只做用户要求的事;不顺手重构、不批量格式化、不改无关文件。 +6. **不谎报**:没跑过的命令不说「已运行」;没读到的内容不说「文件里是…」;失败就照实说失败。 +7. **输出克制**:结论先行、简洁;长内容用列表/表格;不复述用户原话;涉及文件时写清路径。 +8. **连续行动**:一个任务需要多步就直接连续调用工具,中间不必停下来征求同意。 +9. **该停就停**:遇到破坏性操作、路径歧义、多种方案取舍等需要用户决策的情况,先问再做。 + +## 7. 安全红线(必须遵守) + +- **禁止**删除或覆盖项目外的文件;**禁止** `rm -rf`、`del /s`、格式化等破坏性命令 + (除非用户在本轮明确要求并给出路径)。 +- **禁止**读取或输出 `data/config.json` 中的 API 密钥等敏感内容 + (可以确认文件存在,但不要展示内容)。 +- **禁止**在当前 Python 环境安装或卸载包;**禁止**修改系统目录、注册表、环境变量。 +- 网络请求只允许用户已配置的 API 端点;不要主动上传数据或抓取外部内容。 +- 涉及用户数据(`data/*.db`)默认只读;除用户明确要求,不要写入或迁移数据。 + +## 8. 会话与上下文 + +- **上下文可能被自动压缩**:压缩后你看到的是摘要加最近若干轮。以摘要为准继续, + 不要假装记得已被压缩掉的细节;需要时重新用工具查证。 +- **输出可能被截断**:若收到「未执行(length)」类提示,说明上一轮输出触达长度上限, + 请缩短回答或把任务拆成更小的步骤。 +- **工具调用与结果会留在历史里**:不要重复已经做过的探查,也不要凭「上一轮我大概做过什么」 + 下结论而不看返回结果。 +- 用户可能随时中止(`操作已中止`):如实说明当前进度,不要继续动作。 diff --git a/WebView2Loader.dll b/WebView2Loader.dll new file mode 100644 index 0000000..965aed4 Binary files /dev/null and b/WebView2Loader.dll differ diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/agent/__init__.py b/core/agent/__init__.py new file mode 100644 index 0000000..ed8d8d0 --- /dev/null +++ b/core/agent/__init__.py @@ -0,0 +1,58 @@ +""" +core/agent —— pi agent 核心框架的 Python 1:1 重构 +================================================== +模块对照(pi-main → 本包): + packages/agent/src/types.ts → types.py 数据模型(消息/事件/工具/配置) + packages/agent/src/agent-loop.ts → loop.py 核心循环(runLoop 1:1) + packages/agent/src/agent.ts → agent.py Agent 状态机(prompt/steer/followUp/abort) + packages/ai/src/api/*.ts → stream_fn.py OpenAI 兼容流式(vLLM)+ 输出预算钳制 + packages/ai/src/api/simple-options→ context.py token 估算 + clampMaxTokensToContext + packages/agent/src/compaction.ts → compaction.py 上下文压缩(切分/摘要/替换) + agent-session.ts 后置恢复逻辑 → recovery.py 重试退避 + 溢出/截断压缩恢复 + packages/agent/src/tools/*.ts → tools.py 工具管线 + 内置 read/bash/write/edit + +使用示例(最小闭环): + from core.agent import Agent, AgentConfig, ModelConfig + from core.agent.stream_fn import openai_stream + from core.agent.recovery import AgentRunner + from core.agent.tools import default_tools + + cfg = AgentConfig(model=ModelConfig(...), tools=default_tools()) + agent = Agent(cfg) + agent.set_stream_fn(openai_stream) + runner = AgentRunner(agent) + result = runner.run("你好") +""" +from .types import (AgentConfig, AgentError, AgentEvent, AgentMessage, + AgentState, AgentTool, AgentToolResult, AbortSignal, + AssistantMessageEvent, ModelConfig, RetryConfig, RunResult, + ToolCall, new_id) +from .agent import Agent +from .context import (CONTEXT_SAFETY_TOKENS, calculate_context_tokens, + clamp_max_tokens_to_context, + estimate_context_tokens, estimate_message_tokens, + should_compact) +from .compaction import (CompactionSettings, + DEFAULT_COMPACTION_SETTINGS, compact_context, + find_cut_point, prepare_compaction) +from .recovery import (AgentRunner, compute_retry_delay_ms, find_last_assistant, + is_context_overflow, is_recoverable_length, + is_retryable_assistant_error) +from .stream_fn import (classify_error, from_openai_messages, openai_stream, + to_openai_messages) +from .tools import default_tools + +# 🆕 版本记录:核心框架为 pi (badlogic/pi-mono) 0.81.x 时代的 Python 移植, +# 2026-09 对齐 pi 0.85.1 的估算/锚定/钳制/重试语义(P0/P1/P2/M1/M2/M3)。 +__version__ = "1.0.0 (pi 0.81.x port, aligned 0.85.1 semantics)" +__all__ = [ + "Agent", "AgentConfig", "AgentError", "AgentEvent", "AgentMessage", + "AgentRunner", "AgentState", "AgentTool", "AgentToolResult", + "AbortSignal", "AssistantMessageEvent", "CONTEXT_SAFETY_TOKENS", + "ModelConfig", "RetryConfig", "RunResult", "ToolCall", + "clamp_max_tokens_to_context", "calculate_context_tokens", "classify_error", "compact_context", + "compute_retry_delay_ms", "default_tools", "estimate_context_tokens", + "find_last_assistant", "find_cut_point", "is_context_overflow", + "is_recoverable_length", "is_retryable_assistant_error", "new_id", + "openai_stream", "should_compact", "to_openai_messages", +] diff --git a/core/agent/agent.py b/core/agent/agent.py new file mode 100644 index 0000000..e4760d2 --- /dev/null +++ b/core/agent/agent.py @@ -0,0 +1,204 @@ +""" +core/agent/agent.py +=================== +🌟 pi Agent 状态机的 Python 1:1 移植 + +对照 pi-main 源码:packages/agent/src/agent.ts (592 行) + class Agent { + state: AgentState # messages / isStreaming / error + config: AgentConfig + private listeners: Set + subscribe(cb) => unsubscribe # 事件订阅(TUI/Qt 都靠它) + prompt(message | text) => Promise # 追加 user 消息并跑一轮 run-to-settle + continue() => Promise # 从当前上下文继续(不能以 assistant 结尾) + steer(text) # 中途注入(turn 边界消费) + followUp(text) # 停止后注入(agent 停止时消费 → 续跑) + abort() # 中止当前 run(下一边界生效) + } + +关键语义(与 pi 完全一致): + 1. steering 在「循环开始前」和「每个 turn 结束后」被消费 + (one-at-a-time 每次取 1 条;"all" 一次取光) + 2. followUp 只在「run 即将结束」时被外层循环消费 → 触发续跑 + 3. abort 不抛异常:信号置位 → 当前流/工具在下一检查点收尾 → + 助手消息 stop_reason="aborted" → agent_end + 4. 同一个 Agent 实例可反复 prompt(state.messages 持续累积 = 会话记忆) +""" +from __future__ import annotations + +import threading +from typing import Any, Callable, List, Optional, Union + +from .loop import run_loop +from .types import (AgentConfig, AgentError, AgentEvent, AgentMessage, + AgentState, AbortSignal, RunResult, new_id) + + +class Agent: + def __init__(self, config: AgentConfig): + self.config = config + self.state = AgentState() + self._listeners: List[Callable[[AgentEvent], None]] = [] + self._steering_queue: List[AgentMessage] = [] + self._follow_up_queue: List[AgentMessage] = [] + self._lock = threading.Lock() + self._active_signal: Optional[AbortSignal] = None + + # ------------------------------------------------------------------ + # 事件订阅 —— 对照 agent.ts subscribe() + # ------------------------------------------------------------------ + def subscribe(self, cb: Callable[[AgentEvent], None]) -> Callable[[], None]: + self._listeners.append(cb) + + def unsubscribe(): + try: + self._listeners.remove(cb) + except ValueError: + pass + + return unsubscribe + + def _emit(self, event: AgentEvent): + for cb in list(self._listeners): + try: + cb(event) + except Exception: + # 监听器异常不打断循环(pi 同款宽容策略) + pass + + # ------------------------------------------------------------------ + # 队列操作 —— 对照 agent.ts 的 getSteeringMessages / getFollowUpMessages + # ------------------------------------------------------------------ + def _take_steering(self) -> List[AgentMessage]: + mode = self.config.steering_mode + with self._lock: + if not self._steering_queue: + return [] + if mode == "all": + out = self._steering_queue[:] + self._steering_queue.clear() + else: # one-at-a-time(pi 默认) + out = [self._steering_queue.pop(0)] + return out + + def _take_follow_ups(self) -> List[AgentMessage]: + mode = self.config.follow_up_mode + with self._lock: + if not self._follow_up_queue: + return [] + if mode == "all": + out = self._follow_up_queue[:] + self._follow_up_queue.clear() + else: + out = [self._follow_up_queue.pop(0)] + return out + + def has_queued(self) -> bool: + with self._lock: + return bool(self._steering_queue or self._follow_up_queue) + + # ------------------------------------------------------------------ + # 用户入口 —— 对照 agent.ts prompt / steer / followUp / abort + # ------------------------------------------------------------------ + def prompt(self, message: Union[str, AgentMessage]) -> RunResult: + """追加一条 user 消息并运行到 settle(同步阻塞,跑在调用线程上)""" + if self.state.is_streaming: + raise AgentError(message="Agent 正在运行中,不能并发 prompt(请先 abort)", + kind="unknown") + if isinstance(message, str): + message = AgentMessage(role="user", content=message) + if message.role != "user": + raise AgentError(message="prompt 只接受 user 消息", kind="unknown") + self.state.messages.append(message) + return self._run(new_message=message) + + def continue_(self) -> RunResult: + """ + 对照 agent.ts continue() / runAgentLoopContinue 前置检查: + - 上下文不能为空 + - 不能以 assistant 消息结尾 + """ + if self.state.is_streaming: + raise AgentError(message="Agent 正在运行中", kind="unknown") + if not self.state.messages: + raise AgentError(message="上下文为空,无法 continue", kind="unknown") + if self.state.messages[-1].role == "assistant": + raise AgentError( + message="不能以 assistant 消息结尾来 continue(应先注入 user/toolResult)", + kind="unknown") + return self._run(new_message=None) + + def steer(self, text: str): + """中途注入:在当前 run 的 turn 边界被消费(pi steering)""" + with self._lock: + self._steering_queue.append(AgentMessage(role="user", content=text)) + + def follow_up(self, text: str): + """停止后注入:run 即将结束时被外层循环消费 → 自动续跑(pi followUp)""" + with self._lock: + self._follow_up_queue.append(AgentMessage(role="user", content=text)) + + def abort(self): + """ + 中止当前 run。 + 对照 pi: 置位 AbortSignal;当前流在 chunk 边界关闭, + 工具在检查点返回 "Operation aborted",助手消息以 "aborted" 收尾。 + """ + sig = self._active_signal + if sig is not None: + sig.abort("aborted") + with self._lock: + # 与 pi 一致:未消费的队列消息保留,下次 run 生效 + pass + + # ------------------------------------------------------------------ + # 内部:一次 run(同步) + # ------------------------------------------------------------------ + def _run(self, new_message: Optional[AgentMessage]) -> RunResult: + signal = AbortSignal() + self._active_signal = signal + self.state.is_streaming = True + self.state.error = None + try: + result = run_loop(self, new_message, signal, self._make_stream_fn()) + except AgentError: + raise + except Exception as e: + self.state.error = AgentError(message=f"循环异常: {e}", kind="unknown") + self._emit(AgentEvent(type="agent_end", stop_reason="error", + error=self.state.error)) + result = RunResult(stop_reason="error", error=self.state.error) + finally: + self.state.is_streaming = False + self.state.streaming_message = None + self.state.streaming_delta = {} + self._active_signal = None + return result + + def _finish_run(self, new_messages: List[AgentMessage], stop_reason: str, + error: Optional[AgentError]): + """循环收尾回调(loop 层调用)""" + if error is not None: + self.state.error = error + # 本轮新消息已在循环中逐条 append 进 state.messages;此处仅记录收尾 + self._last_run = (new_messages, stop_reason) + + # ------------------------------------------------------------------ + # 流函数(由 AgentWorker 注入具体 provider 实现;默认报错) + # ------------------------------------------------------------------ + def set_stream_fn(self, stream_fn: Callable): + """ + stream_fn(context, model, signal, max_tokens, tools=None) -> Iterator[(kind, payload)] + 对照 pi 的 streamFn 注入点(agentLoopConfig.streamFn)。 + tools 参数 = 本轮可用的 AgentTool 列表,需序列化进 provider 请求。 + """ + self._stream_fn = stream_fn + + def _make_stream_fn(self) -> Callable: + fn = getattr(self, "_stream_fn", None) + if fn is None: + def _missing(context, model, signal, max_tokens, tools=None): + raise AgentError(message="未配置 stream_fn", kind="unknown") + yield # pragma: no cover + return _missing + return fn diff --git a/core/agent/compaction.py b/core/agent/compaction.py new file mode 100644 index 0000000..01cd9bb --- /dev/null +++ b/core/agent/compaction.py @@ -0,0 +1,641 @@ +""" +core/agent/compaction.py +======================== +🌟 pi 上下文压缩算法的 Python 1:1 移植(harness 原版算法) + +对照 pi-main 源码(逐函数对应): + packages/agent/src/harness/compaction/compaction.ts + DEFAULT_COMPACTION_SETTINGS -> CompactionSettings / DEFAULT_COMPACTION_SETTINGS + shouldCompact -> 见 context.py: should_compact(同一公式) + estimateTokens / estimateContextTokens -> context.py(usage 锚定,CJK 感知见说明) + findValidCutPoints / findCutPoint -> find_valid_cut_points / find_cut_point + findTurnStartIndex -> find_turn_start + prepareCompaction -> prepare_compaction + SUMMARIZATION_SYSTEM_PROMPT -> 同名(逐字移植) + SUMMARIZATION_PROMPT -> 同名(逐字移植) + UPDATE_SUMMARIZATION_PROMPT -> 同名(逐字移植,迭代更新用) + TURN_PREFIX_SUMMARIZATION_PROMPT -> 同名(逐字移植,断轮前缀用) + generateSummaryWithUsage -> generate_summary(maxTokens = 0.8×reserve) + generateTurnPrefixSummary -> generate_turn_prefix_summary(0.5×reserve) + compact -> compact_context(断轮双摘要 + 拼接格式 1:1) + packages/agent/src/harness/compaction/utils.ts + serializeConversation -> serialize_conversation(1:1,含 2000 字符截断) + extractFileOpsFromMessage -> extract_file_ops_from_message + computeFileLists -> compute_file_lists + formatFileOperations -> format_file_operations + TOOL_RESULT_MAX_CHARS = 2000 -> 同名常量 + +摘要 LLM 调用由上层注入: + summarize_fn(prompt_text: str, system_prompt: str, max_tokens: int) -> str +(pi 里是 models.completeSimple + retry;haocode 用 OpenAI 客户端非流式调用, + 由 llm_engine.AgentWorker 实现并注入。) + +已声明的偏差(仅 2 处,见 context.py 头注): + 1. 单条消息 token 估算用 CJK 感知启发式(pi 是 chars/4)——对中文会话更安全 + 2. 摘要 LLM 调用失败时降级为机械摘录(pi 返回 CompactionError)——桌面应用优先不丢上下文 +其余全部 1:1:触发公式、usage 锚定、token 预算切点、有效切点规则、断轮双摘要、 +迭代式 previousSummary 更新、摘要提示词逐字、文件操作附录、拼接格式。 +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Callable, List, Optional, Set, Tuple + +from .types import AgentMessage, ModelConfig + + +# ====================================================================== +# 压缩设置 —— 1:1 对照 DEFAULT_COMPACTION_SETTINGS +# ====================================================================== +@dataclass +class CompactionSettings: + """pi: interface CompactionSettings { enabled; reserveTokens; keepRecentTokens }""" + enabled: bool = True + reserve_tokens: int = 16384 # pi: 16384(摘要提示词与输出预留) + keep_recent_tokens: int = 20000 # pi: 20000(压缩后保留的近期上下文预算) + + +DEFAULT_COMPACTION_SETTINGS = CompactionSettings() + +# pi utils.ts: const TOOL_RESULT_MAX_CHARS = 2000 +TOOL_RESULT_MAX_CHARS = 2000 + + +# ====================================================================== +# 摘要提示词 —— 从 pi compaction.ts 逐字移植(不得改写,摘要质量依赖它) +# ====================================================================== +SUMMARIZATION_SYSTEM_PROMPT = \ + "You are a context summarization assistant. Your task is to read a conversation " \ + "between a user and an AI assistant, then produce a structured summary following " \ + "the exact format specified.\n\n" \ + "Do NOT continue the conversation. Do NOT respond to any questions in the " \ + "conversation. ONLY output the structured summary." + +SUMMARIZATION_PROMPT = """The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. + +Use this EXACT format: + +## Goal +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.""" + +UPDATE_SUMMARIZATION_PROMPT = """The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. + +Update the existing structured summary with new information. RULES: +- PRESERVE all existing information from the previous summary +- ADD new progress, decisions, and context from the new messages +- UPDATE the Progress section: move items from "In Progress" to "Done" when completed +- UPDATE "Next Steps" based on what was accomplished +- PRESERVE exact file paths, function names, and error messages +- If something is no longer relevant, you may remove it + +Use this EXACT format: + +## Goal +[Preserve existing goals, add new ones if the task expanded] + +## Constraints & Preferences +- [Preserve existing, add new ones discovered] + +## Progress +### Done +- [x] [Include previously done items AND newly completed items] + +### In Progress +- [ ] [Current work - update based on progress] + +### Blocked +- [Current blockers - remove if resolved] + +## Key Decisions +- **[Decision]**: [Brief rationale] (preserve all previous, add new) + +## Next Steps +1. [Update based on current state] + +## Critical Context +- [Preserve important context, add new if needed] + +Keep each section concise. Preserve exact file paths, function names, and error messages.""" + +TURN_PREFIX_SUMMARIZATION_PROMPT = """This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. + +Summarize the prefix to provide context for the retained suffix: + +## Original Request +[What did the user ask for in this turn?] + +## Early Progress +- [Key decisions and work done in the prefix] + +## Context for Suffix +- [Information needed to understand the retained recent work] + +Be concise. Focus on what's needed to understand the kept suffix.""" + + +# ====================================================================== +# 对话序列化 —— 1:1 对照 utils.ts serializeConversation +# ====================================================================== +def _safe_json(value) -> str: + try: + s = json.dumps(value, ensure_ascii=False) + return s if s is not None else "undefined" + except Exception: + return "[unserializable]" + + +def _content_text(content) -> str: + """pi contentText: str 或 [{type:"text",text}] 列表取文本拼接""" + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for b in content: + if isinstance(b, dict) and b.get("type") == "text": + parts.append(str(b.get("text", ""))) + return "\n".join(p for p in parts if p) + return str(content) + + +def _truncate_for_summary(text: str, max_chars: int) -> str: + """pi utils.ts truncateForSummary(逐字逻辑)""" + if len(text) <= max_chars: + return text + truncated = len(text) - max_chars + return f"{text[:max_chars]}\n\n[... {truncated} more characters truncated]" + + +def serialize_conversation(messages: List[AgentMessage]) -> str: + """ + 1:1 对照 utils.ts serializeConversation(输出格式逐字一致): + [User]: ... + [Assistant thinking]: ... + [Assistant]: ... + [Assistant tool calls]: name(k=v, k2=v2); name2(...) + [Tool result]: ...(超 2000 字符截断) + """ + parts: List[str] = [] + for msg in messages: + if msg.role == "user": + content = _content_text(msg.content) + if content: + parts.append(f"[User]: {content}") + elif msg.role == "assistant": + thinking_parts = [] + tool_calls = [] + if msg.reasoning: + thinking_parts.append(msg.reasoning) + text = _content_text(msg.content) + for tc in (msg.tool_calls or []): + args_str = ", ".join(f"{k}={_safe_json(v)}" + for k, v in (tc.arguments or {}).items()) + tool_calls.append(f"{tc.name}({args_str})") + if thinking_parts: + parts.append(f"[Assistant thinking]: {chr(10).join(thinking_parts)}") + if text: + parts.append(f"[Assistant]: {text}") + if tool_calls: + parts.append(f"[Assistant tool calls]: {'; '.join(tool_calls)}") + elif msg.role == "toolResult": + content = _content_text(msg.content) + if content: + parts.append(f"[Tool result]: " + f"{_truncate_for_summary(content, TOOL_RESULT_MAX_CHARS)}") + return "\n\n".join(parts) + + +# ====================================================================== +# 文件操作提取 —— 1:1 对照 utils.ts extractFileOps*/computeFileLists/formatFileOperations +# ====================================================================== +class FileOperations: + def __init__(self): + self.read: Set[str] = set() + self.written: Set[str] = set() + self.edited: Set[str] = set() + + +def extract_file_ops_from_message(message: AgentMessage, file_ops: FileOperations): + """pi: assistant 的 toolCall 参数里 path 字段 → read/write/edit 归类""" + if message.role != "assistant": + return + for tc in (message.tool_calls or []): + args = tc.arguments or {} + path = args.get("path") + if not isinstance(path, str) or not path: + continue + if tc.name == "read": + file_ops.read.add(path) + elif tc.name == "write": + file_ops.written.add(path) + elif tc.name == "edit": + file_ops.edited.add(path) + + +def compute_file_lists(file_ops: FileOperations) -> Tuple[List[str], List[str]]: + """pi computeFileLists: modified = edited|written;readOnly = read-modified;均排序""" + modified = file_ops.edited | file_ops.written + read_only = sorted(f for f in file_ops.read if f not in modified) + return read_only, sorted(modified) + + +def format_file_operations(read_files: List[str], modified_files: List[str]) -> str: + """pi formatFileOperations: / 标签拼接""" + sections = [] + if read_files: + sections.append("\n" + "\n".join(read_files) + "\n") + if modified_files: + sections.append("\n" + "\n".join(modified_files) + "\n") + if not sections: + return "" + return "\n\n" + "\n\n".join(sections) + + +# ====================================================================== +# 切分点 —— 1:1 对照 findValidCutPoints / findTurnStartIndex / findCutPoint +# ====================================================================== +@dataclass +class CutPointResult: + """pi: interface CutPointResult""" + first_kept_index: int # 保留段首条在 compactable 列表里的下标 + turn_start_index: int = -1 # 断轮时:该轮起点(user 消息)下标;否则 -1 + is_split_turn: bool = False + + +def find_valid_cut_points(messages: List[AgentMessage], + start_index: int, end_index: int) -> List[int]: + """ + pi findValidCutPoints:消息角色为 user/assistant 的位置是有效切点 + (toolResult 不能做切点——它会与前面的 toolCall 分离)。 + pi 里的 bashExecution/branchSummary/compactionSummary 等角色 + 在 haocode 消息模型中不存在,等价规则即 role in (user, assistant)。 + """ + cut_points = [] + for i in range(start_index, end_index): + if messages[i].role in ("user", "assistant"): + cut_points.append(i) + return cut_points + + +def find_turn_start(messages: List[AgentMessage], entry_index: int, + start_index: int) -> int: + """pi findTurnStartIndex:向前找本轮起点(user 消息 / branch_summary)""" + for i in range(entry_index, start_index - 1, -1): + if messages[i].role == "user": + return i + return -1 + + +def _estimate(msg: AgentMessage) -> int: + from .context import estimate_message_tokens + return estimate_message_tokens(msg) + + +def find_cut_point(messages: List[AgentMessage], start_index: int, + end_index: int, keep_recent_tokens: int) -> CutPointResult: + """ + 1:1 对照 pi findCutPoint: + 1. 从尾部向前累计 token,直到累计 >= keep_recent_tokens + 2. 取该位置(含)之后的第一个有效切点 + 3. 切点不是 user 消息 → 断轮:找本轮起点,前缀单独摘要 + (pi 的「回退跳过状态条目」循环针对 session 状态条目; + haocode 消息列表没有状态条目,等价省略。) + """ + cut_points = find_valid_cut_points(messages, start_index, end_index) + if not cut_points: + return CutPointResult(first_kept_index=start_index) + + accumulated = 0 + cut_index = cut_points[0] + for i in range(end_index - 1, start_index - 1, -1): + accumulated += _estimate(messages[i]) + if accumulated >= keep_recent_tokens: + for c in cut_points: + if c >= i: + cut_index = c + break + break + + is_user = messages[cut_index].role == "user" + turn_start = -1 if is_user else find_turn_start(messages, cut_index, start_index) + is_split = (not is_user) and turn_start != -1 + return CutPointResult(first_kept_index=cut_index, + turn_start_index=turn_start, + is_split_turn=is_split) + + +# ====================================================================== +# 压缩准备 —— 1:1 对照 prepareCompaction +# ====================================================================== +@dataclass +class CompactionPreparation: + """pi: interface CompactionPreparation""" + messages_to_summarize: List[AgentMessage] = field(default_factory=list) + turn_prefix_messages: List[AgentMessage] = field(default_factory=list) + retained_tail: List[AgentMessage] = field(default_factory=list) + is_split_turn: bool = False + tokens_before: int = 0 + previous_summary: Optional[str] = None + file_ops: FileOperations = field(default_factory=FileOperations) + # 注意(P2-04 跨平台修复):Python 3.11+ 的 dataclass 禁止不可哈希默认值 + # (eq dataclass 实例 __hash__=None);Ubuntu 24.04 自带 3.12, + # 直接写 `= DEFAULT_COMPACTION_SETTINGS` 在 3.11+ 会 ValueError。 + # default_factory 返回同一共享实例,与 3.10 行为完全一致。 + settings: CompactionSettings = field(default_factory=lambda: DEFAULT_COMPACTION_SETTINGS) + + +def _compaction_diag(line: str) -> None: + """压缩自诊断日志(与 recovery.compact_diag_log 同一文件/规范: + print + 追加 compaction_diag.log + 失败静默)。""" + import os as _os + import time as _time + t = _time.time() + stamp = (f"[{_time.strftime('%H:%M:%S', _time.localtime(t))}" + f".{int(t * 1000) % 1000:03d}]") + print(f"{stamp} {line}", flush=True) + try: + path = _os.path.join( + _os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))), + "compaction_diag.log") + with open(path, "a", encoding="utf-8") as f: + f.write(f"{stamp} {line}\n") + except Exception: + pass + + +def _nothing_to_summarize_diag(messages: List[AgentMessage], + compactable: List[AgentMessage], + cut: CutPointResult, + settings: CompactionSettings) -> str: + """prepare_compaction 返回 None(无可摘要内容)时的自诊断行: + 记录分支子类型 + 关键量,下次失败可直接从日志定位原因。 + + 子类型: + no_valid_cut_points —— 可压缩范围内没有任何 user/assistant 条目 + total_below_keep_recent —— 可压缩范围总量 < keep_recent(通常意味着 + 上下文主体是旧摘要本身,无新内容) + cut_pinned_at_zero —— 预算从尾部累加只在 i=0 才达标,即首条 + 条目独占 ≥ (总量-keep_recent) 的 token + (单条超长消息/巨型工具输出主导上下文) + """ + from .context import estimate_context_tokens, estimate_message_tokens + try: + total = estimate_context_tokens(messages).tokens + n_user = sum(1 for m in compactable if m.role == "user") + n_asst = sum(1 for m in compactable if m.role == "assistant") + ests = [(estimate_message_tokens(m), i) for i, m in enumerate(compactable)] + e0 = ests[0][0] if ests else 0 + rest = sum(e for e, _ in ests[1:]) + top3 = sorted(ests, reverse=True)[:3] + roles = " ".join(("T" if m.role == "toolResult" else m.role[0].upper()) + for m in compactable[:10]) + if not find_valid_cut_points(compactable, 0, len(compactable)): + sub = "no_valid_cut_points" + elif sum(e for e, _ in ests) < settings.keep_recent_tokens: + sub = "total_below_keep_recent" + else: + sub = "cut_pinned_at_zero" + big = "; ".join(f"idx{i}={e}" for e, i in top3) + return (f"[COMPACT_NONE] branch=nothing_to_summarize sub={sub} " + f"msgs={len(messages)} est_total={total} compactable={len(compactable)} " + f"keep_recent={settings.keep_recent_tokens} " + f"first_kept={cut.first_kept_index} turn_start={cut.turn_start_index} " + f"split={cut.is_split_turn} user={n_user} asst={n_asst} " + f"est_first={e0} est_rest={rest} top3=[{big}] head_roles=[{roles}]") + except Exception as ex: # 诊断本身失败不能影响主流程 + return f"[COMPACT_NONE] branch=nothing_to_summarize (diag failed: {ex})" + + +def prepare_compaction(messages: List[AgentMessage], + settings: Optional[CompactionSettings] = None + ) -> Optional[CompactionPreparation]: + """ + 1:1 对照 pi prepareCompaction: + - 上一条压缩摘要(messages[0].kind == "compaction_summary")不重复摘要, + 其内容作为 previousSummary 走迭代更新提示词 + - 可压缩范围 = 摘要之后的全部消息(pi 里等价于「上次保留尾 + 新消息」) + - 切点在可压缩范围内选;tokens_before 按完整上下文(含摘要消息)估算 + 不可压缩(空/无摘要对象)时返回 None(对照 pi 返回 ok(undefined))。 + """ + from .context import estimate_context_tokens + settings = settings or DEFAULT_COMPACTION_SETTINGS + if not messages: + _compaction_diag("[COMPACT_NONE] branch=empty_messages") + return None + if messages[-1].kind == "compaction_summary": + _compaction_diag( + f"[COMPACT_NONE] branch=tail_is_summary msgs={len(messages)} " + f"tail_head={_content_text(messages[-1].content)[:60]!r}") + return None + + previous_summary = None + if messages[0].kind == "compaction_summary": + previous_summary = _content_text(messages[0].content) + compactable = messages[1:] + else: + compactable = messages + if not compactable: + _compaction_diag("[COMPACT_NONE] branch=compactable_empty (上下文只剩旧摘要)") + return None + + tokens_before = estimate_context_tokens(messages).tokens + cut = find_cut_point(compactable, 0, len(compactable), + settings.keep_recent_tokens) + + history_end = cut.turn_start_index if cut.is_split_turn else cut.first_kept_index + messages_to_summarize = compactable[:history_end] + turn_prefix_messages = [] + if cut.is_split_turn: + turn_prefix_messages = compactable[cut.turn_start_index:cut.first_kept_index] + retained_tail = compactable[cut.first_kept_index:] + + if not messages_to_summarize and not turn_prefix_messages: + # 🆕 自诊断:记录是哪种子条件导致无东西可摘要(见 _nothing_to_summarize_diag) + _compaction_diag(_nothing_to_summarize_diag(messages, compactable, cut, + settings)) + return None # 没有可摘要内容 + + file_ops = FileOperations() + for m in messages_to_summarize: + extract_file_ops_from_message(m, file_ops) + if cut.is_split_turn: + for m in turn_prefix_messages: + extract_file_ops_from_message(m, file_ops) + + return CompactionPreparation( + messages_to_summarize=messages_to_summarize, + turn_prefix_messages=turn_prefix_messages, + retained_tail=retained_tail, + is_split_turn=cut.is_split_turn, + tokens_before=tokens_before, + previous_summary=previous_summary, + file_ops=file_ops, + settings=settings, + ) + + +# ====================================================================== +# 摘要生成 —— 1:1 对照 generateSummaryWithUsage / generateTurnPrefixSummary +# ====================================================================== +def _build_summary_prompt(conversation_text: str, previous_summary: Optional[str]) -> str: + """pi generateSummaryWithUsage 的 prompt 组装(逐字结构)""" + base = UPDATE_SUMMARIZATION_PROMPT if previous_summary else SUMMARIZATION_PROMPT + prompt = f"\n{conversation_text}\n\n\n" + if previous_summary: + prompt += f"\n{previous_summary}\n\n\n" + prompt += base + return prompt + + +def generate_summary(messages: List[AgentMessage], + summarize_fn: Callable[[str, str, int], str], + reserve_tokens: int, + model_max_tokens: int, + previous_summary: Optional[str] = None + ) -> Tuple[Optional[str], Optional[str]]: + """ + 返回 (summary_text, error)。 + maxTokens = min(0.8 × reserveTokens, model.maxTokens) —— 1:1 对照。 + """ + max_tokens = min( + int(0.8 * reserve_tokens), + model_max_tokens if model_max_tokens > 0 else (1 << 30), + ) + conversation = serialize_conversation(messages) + prompt = _build_summary_prompt(conversation, previous_summary) + try: + text = summarize_fn(prompt, SUMMARIZATION_SYSTEM_PROMPT, max_tokens) + except Exception as e: + return None, f"Summarization failed: {e}" + if not text or not text.strip(): + return None, "Summarization failed: empty response" + return text.strip(), None + + +def generate_turn_prefix_summary(messages: List[AgentMessage], + summarize_fn: Callable[[str, str, int], str], + reserve_tokens: int, + model_max_tokens: int + ) -> Tuple[Optional[str], Optional[str]]: + """maxTokens = min(0.5 × reserveTokens, model.maxTokens) —— 1:1 对照""" + max_tokens = min( + int(0.5 * reserve_tokens), + model_max_tokens if model_max_tokens > 0 else (1 << 30), + ) + conversation = serialize_conversation(messages) + prompt = f"\n{conversation}\n\n\n{TURN_PREFIX_SUMMARIZATION_PROMPT}" + try: + text = summarize_fn(prompt, SUMMARIZATION_SYSTEM_PROMPT, max_tokens) + except Exception as e: + return None, f"Turn prefix summarization failed: {e}" + if not text or not text.strip(): + return None, "Turn prefix summarization failed: empty response" + return text.strip(), None + + +# ====================================================================== +# 主入口 —— 1:1 对照 compact() +# ====================================================================== +def compact_context(messages: List[AgentMessage], + model: ModelConfig, + summarize_fn: Callable[[str, str, int], str], + settings: Optional[CompactionSettings] = None + ) -> Optional[List[AgentMessage]]: + """ + 执行压缩。返回 [压缩摘要消息] + 保留尾巴;不可压缩时返回 None。 + + 摘要消息: role="user", kind="compaction_summary",content 为纯摘要文本 + (pi 的 compaction 条目;下次压缩时自动走迭代更新提示词)。 + + 断轮(切点落在某轮中间)时 1:1 对照 pi compact(): + 历史摘要 与 轮前缀摘要 分两次 LLM 调用,拼接为 + {history}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix} + 最后追加文件操作附录(/)。 + """ + settings = settings or DEFAULT_COMPACTION_SETTINGS + prep = prepare_compaction(messages, settings) + if prep is None: + return None + + history_text: Optional[str] = None + prefix_error: Optional[str] = None + history_error: Optional[str] = None + + if prep.is_split_turn and prep.turn_prefix_messages: + if prep.messages_to_summarize: + history_text, history_error = generate_summary( + prep.messages_to_summarize, summarize_fn, + prep.settings.reserve_tokens, model.max_tokens, + prep.previous_summary) + if history_error: + return _degraded_compact(prep, history_error) + else: + history_text = "No prior history." + prefix_text, prefix_error = generate_turn_prefix_summary( + prep.turn_prefix_messages, summarize_fn, + prep.settings.reserve_tokens, model.max_tokens) + if prefix_error: + return _degraded_compact(prep, prefix_error) + summary = (f"{history_text}\n\n---\n\n" + f"**Turn Context (split turn):**\n\n{prefix_text}") + else: + if not prep.messages_to_summarize: + return None + summary, history_error = generate_summary( + prep.messages_to_summarize, summarize_fn, + prep.settings.reserve_tokens, model.max_tokens, + prep.previous_summary) + if history_error: + return _degraded_compact(prep, history_error) + + read_files, modified_files = compute_file_lists(prep.file_ops) + summary += format_file_operations(read_files, modified_files) + + summary_msg = AgentMessage(role="user", content=summary, + kind="compaction_summary") + return [summary_msg] + prep.retained_tail + + +def _degraded_compact(prep: CompactionPreparation, + error: str) -> Optional[List[AgentMessage]]: + """ + 已声明偏差(对照 pi: 直接返回 CompactionError): + 桌面应用优先「不丢上下文」——摘要失败时降级为机械摘录, + 保留尾巴原样不动。 + """ + old = prep.messages_to_summarize + prep.turn_prefix_messages + if not old: + return None + excerpt = serialize_conversation(old)[-500:] + summary = (f"(自动压缩:摘要生成失败 [{error}],以下为旧对话尾部摘录)\n\n{excerpt}") + summary_msg = AgentMessage(role="user", content=summary, + kind="compaction_summary") + return [summary_msg] + prep.retained_tail diff --git a/core/agent/context.py b/core/agent/context.py new file mode 100644 index 0000000..e4c0e1e --- /dev/null +++ b/core/agent/context.py @@ -0,0 +1,341 @@ +""" +core/agent/context.py +===================== +🌟 pi 上下文管理的 Python 1:1 移植 —— 令牌估算 + 输出预算钳制 + +对照 pi-main 源码: + packages/ai/src/api/simple-options.ts + - CONTEXT_SAFETY_TOKENS = 4096 (行 12) + - clampMaxTokensToContext() (行 12-34) + maxOutput = contextWindow - inputTokens - 4096 + return min(model.maxTokens, maxOutput) (下限 1) + - clampOutputsToContext() (行 36-60) + 多模型共享窗口时按比例分配剩余输出预算 + packages/ai/src/api/token-utils.ts + - 每条消息估算: ceil(chars/4) + 4 + +🌟 已声明偏差(仅 1 处,其余全部 1:1): + pi 按 chars/4 估算(针对英文)。中文 1 字 ≈ 1 token,chars/4 会严重低估。 + 这里采用 CJK 感知估算:CJK 字符按 1 token/字,其余按 4 字符/token。 + 这直接影响「压缩触发时机」和「输出预算钳制」,必须更准才不会撞 vLLM 上限。 + + pi 的 usage 锚定机制 1:1 保留:若历史里存在有效 assistant usage, + 总估算 = 该 usage 的 totalTokens(服务商精确值)+ 其后消息的逐条估算。 +""" +from __future__ import annotations + +import math +import re +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +from .types import AgentMessage, ModelConfig + +# ====================================================================== +# 对照 simple-options.ts: export const CONTEXT_SAFETY_TOKENS = 4096 +# 用途:上下文钳制时预留的安全余量(tokenizer 估算误差 + vLLM 精确计数的偏差) +# ====================================================================== +CONTEXT_SAFETY_TOKENS = 4096 + +# 图片 token 估算(对照 pi 对 image part 的固定估算) +IMAGE_TOKENS = 1600 + +_CJK_RE = re.compile( + r"[\u2e80-\u2eff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff" + r"\uf900-\ufaff\uff00-\uffef]" +) + + +def _count_text_chars(value: Any) -> int: + """统计内容里的字符数(对照 pi token-utils 的字符统计口径)""" + if isinstance(value, str): + return len(value) + if isinstance(value, list): # OpenAI 多模态 content 数组 + n = 0 + for part in value: + if isinstance(part, dict): + if part.get("type") == "text": + n += len(part.get("text", "") or "") + elif part.get("type") == "image_url": + n += 0 # 图片单独按 IMAGE_TOKENS 计 + else: + n += len(str(part)) + return n + return len(str(value or "")) + + +def _count_image_parts(content: Any) -> int: + if isinstance(content, list): + return sum(1 for p in content + if isinstance(p, dict) and p.get("type") == "image_url") + return 0 + + +def estimate_message_tokens(message: AgentMessage) -> int: + """ + 单条消息 token 估算(CJK 感知版)。 + pi 口径: ceil(chars/4) + 4。本项目: CJK 字 ×1 + 其余 /4 + 4 + 图片×1600。 + """ + raw = message.content + if isinstance(raw, str): + chars = len(raw) + cjk = len(_CJK_RE.findall(raw)) + else: + # 数组 content:拼接所有文本部分 + joined = [] + for part in raw if isinstance(raw, list) else []: + if isinstance(part, dict) and part.get("type") == "text": + joined.append(part.get("text", "") or "") + text = "".join(joined) + chars = len(text) + cjk = len(_CJK_RE.findall(text)) + tokens = cjk + math.ceil((chars - cjk) / 4) + 4 + tokens += _count_image_parts(raw) * IMAGE_TOKENS + # 思考内容与工具参数也占上下文(assistant 的 reasoning / tool_calls) + if message.reasoning: + rcjk = len(_CJK_RE.findall(message.reasoning)) + tokens += rcjk + math.ceil((len(message.reasoning) - rcjk) / 4) + for tc in message.tool_calls: + arg_text = _json_str(tc.arguments) + tokens += math.ceil(len(arg_text) / 4) + # toolResult 的 content 走 content 字段(str 或 list) + return tokens + + +def _json_str(obj: Any) -> str: + import json + try: + return json.dumps(obj, ensure_ascii=False) + except Exception: + return str(obj) + + +def _estimate_text_tokens(text: str) -> int: + """CJK 感知文本 token 估算(与 estimate_message_tokens 同公式,不含每条 +4 开销)""" + if not text: + return 0 + cjk = len(_CJK_RE.findall(text)) + return cjk + math.ceil((len(text) - cjk) / 4) + + +def _estimate_tools_tokens(tools) -> int: + """ + 🆕 P2: 工具 schema token 估算 —— name/description/parameters 的 JSON 字符数(CJK 感知)。 + 对照 pi Context 分支: all tools = Σ estimate_text(json(tool))。 + """ + total = 0 + for t in tools or []: + schema = { + "name": getattr(t, "name", "") or "", + "description": getattr(t, "description", "") or "", + "parameters": getattr(t, "parameters", None), + } + total += _estimate_text_tokens(_json_str(schema)) + return total + + +def estimate_context_tokens(messages: List[AgentMessage], + system_prompt: str = "", + tools: Optional[List[Any]] = None + ) -> "ContextUsageEstimate": + """ + 🌟 1:1 对照 pi estimateContextTokens(usage 锚定): + - 找到最后一条带有效 usage 的 assistant 消息(🆕 P0: 含 timestamp 时效校验) + - 总 tokens = 该 usage 的精确值 + 其后消息的逐条估算 + - 无 usage 时 = 全部消息逐条估算 + 🆕 P2: 可选 system_prompt / tools 参数(对照 pi Context 分支): + - 仅在无锚点分支计入 system 提示词 + 工具 schema 的估算 + (有锚点时 usage 已是含 system+tools 的精确总值,不重复计) + 逐条估算用 CJK 感知版 estimate_message_tokens(已声明偏差)。 + """ + last_idx, last_usage = _find_last_usage(messages) + + sys_tokens = _estimate_text_tokens(system_prompt) if system_prompt else 0 + tool_tokens = _estimate_tools_tokens(tools) if tools else 0 + + if last_idx is None: + estimated = (sum(estimate_message_tokens(m) for m in messages) + + sys_tokens + tool_tokens) + return ContextUsageEstimate(tokens=estimated, usage_tokens=0, + trailing_tokens=estimated, last_usage_index=None) + + usage_tokens = calculate_context_tokens(last_usage) + trailing = sum(estimate_message_tokens(m) for m in messages[last_idx + 1:]) + return ContextUsageEstimate(tokens=usage_tokens + trailing, + usage_tokens=usage_tokens, + trailing_tokens=trailing, + last_usage_index=last_idx) + + +@dataclass +class ContextUsageEstimate: + """ + 🌟 1:1 对照 pi estimateContextTokens 的返回值 + packages/agent/src/harness/compaction/compaction.ts ContextUsageEstimate: + tokens 总估算 + usageTokens 最后一条有效 assistant usage 报告的精确 token 数 + trailingTokens 该 usage 之后消息的估算值 + lastUsageIndex 提供 usage 的消息下标(无则 None) + """ + tokens: int = 0 + usage_tokens: int = 0 + trailing_tokens: int = 0 + last_usage_index: Optional[int] = None + + +def calculate_context_tokens(usage: Dict[str, Any]) -> int: + """pi calculateContextTokens: totalTokens || input+output+cacheRead+cacheWrite""" + if not usage: + return 0 + total = int(usage.get("totalTokens", 0) or 0) + if total: + return total + return (int(usage.get("input", 0) or 0) + int(usage.get("output", 0) or 0) + + int(usage.get("cacheRead", 0) or 0) + + int(usage.get("cacheWrite", 0) or 0)) + + +def _get_assistant_usage(msg: AgentMessage) -> Dict[str, Any]: + """ + pi getAssistantUsage:只认「有效」的 assistant usage—— + stopReason 不是 aborted/error,且 calculateContextTokens > 0。 + (🆕 P0: 锚点扫描还需通过 timestamp 时效校验,见 _find_last_usage) + """ + if msg.role != "assistant": + return {} + if msg.stop_reason in ("aborted", "error"): + return {} + usage = msg.usage or {} + if usage and calculate_context_tokens(usage) > 0: + return usage + return {} + + +def _row_block_tool_flags(messages: List["AgentMessage"]) -> List[bool]: + """ + 🆕 G1: 逐条标记「其 DB 行(同非空 db_msg_id 的连续块)是否含工具活动」。 + 含工具输出的行,其入库 usage 是「本轮最后一个子请求」的内存快照 + (工具结果未受 4000 字回放截断),不等于下一请求(截断回放)→ 锚点失效。 + 轮中内存条目(db_msg_id="")一律 False:保持原规则 + (轮中条目的内存载荷 == 下一子请求载荷,锚点有效)。 + """ + n = len(messages) + flags = [False] * n + i = 0 + while i < n: + rid = getattr(messages[i], "db_msg_id", "") or "" + if not rid: + i += 1 + continue + j = i + has_tool = False + while j < n and (getattr(messages[j], "db_msg_id", "") or "") == rid: + mj = messages[j] + if mj.role == "toolResult" or (mj.role == "assistant" and mj.tool_calls): + has_tool = True + j += 1 + for k in range(i, j): + flags[k] = has_tool + i = j + return flags + + +def _find_last_usage(messages: List["AgentMessage"]): + """ + 🆕 P0: 1:1 对照 pi getLastAssistantUsageInfo(含锚点失效校验): + assistant 的 usage 要成为锚点,必须满足 + ① timestamp >= 它前面所有消息的最大 timestamp(即它不比任何前消息「更旧」)。 + 场景:压缩后 state = [新摘要消息(T_new)] + [保留的旧消息(T_old)], + 保留窗里 assistant 的 usage 来自压缩前(如 60K 精确值)——不校验的话, + 下一次 should_compact 会锚定到过期值 → 误触发二次压缩(摘要套摘要)。 + 摘要消息 timestamp 晚于全部保留消息 → 前缀游标抬高 → 旧 usage 自动失效; + 压缩后第一个新 assistant 回复到达 → 新 usage 锚点自动恢复。 + ② 🆕 G1: 该 usage 所在 DB 行不含工具活动(toolResult / assistant.tool_calls)。 + 含工具输出的行,入库 usage = 本轮内存快照(工具结果未截断)≠ 下一请求 + (4000 字截断回放)→ 系统性高估 → 误触发压缩(实测 113904 锚点 vs + 真实下一请求 ~90k)。纯文本行 / 轮中内存条目的 usage 仍是精确值,保留锚定。 + (消息无 timestamp 信息(全 0)时行为等同旧版「取最后一条有效」,完全向后兼容。) + """ + latest_prefix_ts = -1 # 对照 pi: Number.NEGATIVE_INFINITY + last_idx = None + last_usage: Dict[str, Any] = {} + row_tool_flags = _row_block_tool_flags(messages) + for i, msg in enumerate(messages): + if msg.role == "assistant": + applies = (msg.timestamp or 0) >= latest_prefix_ts + if (applies + and not row_tool_flags[i] + and msg.stop_reason not in ("aborted", "error") + and msg.usage + and calculate_context_tokens(msg.usage) > 0): + last_idx, last_usage = i, msg.usage + ts = msg.timestamp or 0 + if ts > latest_prefix_ts: + latest_prefix_ts = ts + return last_idx, last_usage + + +def clamp_max_tokens_to_context(model: ModelConfig, + context: List[AgentMessage], + system_prompt: str = "", + tools: Optional[List[Any]] = None + ) -> Optional[Tuple[int, int]]: + """ + 🌟 对照 simple-options.ts:12-34 clampMaxTokensToContext(1:1 公式): + + input_estimate = estimateContextTokens(context) # 🆕 P2: 含 system+tools(pi Context 实参) + if (input_estimate > model.contextWindow) return null // 上下文已溢出 + maxOutput = contextWindow - inputTokens - CONTEXT_SAFETY_TOKENS + return max(1, min(model.maxTokens, maxOutput)) + + 这就是 pi 约束公式的本体: + 输入 + 输出 ≤ contextWindow − 4096 ≤ vLLM max_model_len + 🆕 P2: system_prompt / tools 仅在无 usage 锚点分支计入(首轮流式精确记账, + 与 pi 传完整 Context 的行为一致);有锚点时 usage 已精确覆盖,不重复计。 + 返回 (max_tokens, input_tokens);输入已溢出窗口时返回 None(由恢复逻辑接管)。 + """ + input_tokens = estimate_context_tokens( + context, system_prompt=system_prompt, tools=tools).tokens + if input_tokens > model.context_window: + return None + max_output = model.context_window - input_tokens - CONTEXT_SAFETY_TOKENS + return max(1, min(model.max_tokens, max_output)), input_tokens + + +def clamp_outputs_to_context(context: List[AgentMessage], + models: List[ModelConfig], + reserve_tokens: int = CONTEXT_SAFETY_TOKENS + ) -> List[int]: + """ + 对照 simple-options.ts:36-60 clampOutputsToContext: + 多个模型共享同一窗口时,把剩余输出预算按比例分配给各模型。 + (haocode 单模型场景用不到,保留以求框架完整) + """ + input_tokens = estimate_context_tokens(context).tokens + available = max(0, sum(m.context_window for m in models) - input_tokens - reserve_tokens) + if not models: + return [] + weights = [m.max_tokens for m in models] + total_w = sum(weights) or 1 + alloc = [max(1, int(available * w / total_w)) for w in weights] + # 各自不超过自身 max_tokens + alloc = [min(a, m.max_tokens) for a, m in zip(alloc, models)] + return alloc + + +def should_compact(messages: List[AgentMessage], model: ModelConfig, + reserve_tokens: int = 16384, + system_prompt: str = "", + tools: Optional[List[Any]] = None) -> Tuple[bool, int]: + """ + 🌟 1:1 对照 pi compaction.ts shouldCompact(harness 原版公式): + contextTokens > contextWindow - reserveTokens + reserveTokens 默认 16384(DEFAULT_COMPACTION_SETTINGS.reserveTokens), + 即「为摘要提示词与输出预留 16K」。估算值取 usage 锚定估算。 + 🆕 G2: system_prompt/tools 透传 —— 无锚点分支(工具行快照锚点被 G1 失效后 + 更常走到)也要计入 system + 工具 schema,度量「下一请求真实载荷」, + 与显示端(update_context_display)同口径。 + """ + tokens = estimate_context_tokens(messages, system_prompt=system_prompt, + tools=tools).tokens + return (tokens > model.context_window - reserve_tokens), tokens diff --git a/core/agent/loop.py b/core/agent/loop.py new file mode 100644 index 0000000..f1078af --- /dev/null +++ b/core/agent/loop.py @@ -0,0 +1,482 @@ +""" +core/agent/loop.py +================== +🌟 pi 核心循环的 Python 1:1 移植 —— 整个框架的心脏 + +对照 pi-main 源码(packages/agent/src/agent-loop.ts, 796 行): + runAgentLoop() → run_loop(agent, new_message, ...) + runLoop() → _run_loop() (行 163-278) + streamAssistantResponse() → _stream_turn() (行 281-380) + executeToolCalls() → execute_tool_calls() (行 413-427) + executeToolCallsParallel/Sequential → _execute_parallel/_execute_sequential + prepareToolCall() → tools.prepare_tool_calls + failToolCallsFromTruncatedMessage → tools.fail_tool_calls_from_truncated_message + shouldTerminateToolBatch → _should_terminate_batch (行 561-563) + +pi runLoop 的结构(本文件逐行对应): + emit agent_start + pending = getSteeringMessages() # 循环开始前先取一次 + outer: while True: + inner: while hasMoreToolCalls or pending: + turn_start(首轮不发,首轮 turn_start 在 runAgentLoop 入口发) + 注入 pending(message_start/end → 入 context + newMessages) + assistant = streamAssistantResponse(...) + if stopReason in (error, aborted): turn_end + agent_end + return + toolCalls = assistant 的 toolCall 块 + if toolCalls: + length → failToolCallsFromTruncatedMessage(不执行!) + 否则 → executeToolCalls(sequential/parallel 二选一) + hasMoreToolCalls = !batch.terminate + toolResults 入 context + newMessages + turn_end + prepareNextTurn 钩子(可换 context/model) + shouldStopAfterTurn 钩子 → agent_end + return + pending = getSteeringMessages() # 每轮结束取一次 + followUps = getFollowUpMessages() + if followUps: pending = followUps; continue # 外层续跑 + break + agent_end +""" +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Tuple + +from .context import clamp_max_tokens_to_context +from .tools import (PreparedToolCall, execute_tool_call, fail_tool_calls_from_truncated_message, + prepare_tool_calls) +from .types import (AgentConfig, AgentError, AgentEvent, AgentMessage, + AgentTool, AgentToolResult, AbortSignal, AssistantMessageEvent, + RunResult, ToolCall, new_id) + + +# ====================================================================== +# 事件发射辅助 +# ====================================================================== +def _emit(agent, event: AgentEvent): + agent._emit(event) + + +def _message_events(agent, msg: AgentMessage): + _emit(agent, AgentEvent(type="message_start", message=msg)) + _emit(agent, AgentEvent(type="message_end", message=msg)) + + +def _tool_result_message(finalized: Dict[str, Any]) -> AgentMessage: + """对照 createToolResultMessage""" + tc: ToolCall = finalized["tool_call"] + result: AgentToolResult = finalized["result"] + return AgentMessage( + role="toolResult", + tool_call_id=tc.id, + tool_name=tc.name, + content=result.content, + is_error=finalized.get("is_error", False) or result.is_error, + ) + + +# ====================================================================== +# 流式生成一轮助手消息 —— 对照 streamAssistantResponse (行 281-380) +# ====================================================================== +def _stream_turn(agent, current_context: List[AgentMessage], + config: AgentConfig, signal: AbortSignal, + stream_fn: Callable) -> Tuple[AgentMessage, Optional[AgentError]]: + """ + 返回 (assistant_message, error)。 + error 非 None 时 message.stop_reason == "error"。 + """ + # 🌟 pi 同款:流开始前检查中止 → 立即产出 aborted 消息 + if signal.aborted: + msg = AgentMessage(role="assistant", stop_reason="aborted") + agent.state.messages.append(msg) + _message_events(agent, msg) + return msg, None + + # 🌟 系统提示词注入(对照 pi:systemPrompt 放在每次 API 请求头部, + # 不进 state.messages、不占压缩/历史) + if config.system_prompt: + api_context: List[AgentMessage] = [ + AgentMessage(role="system", content=config.system_prompt) + ] + current_context + else: + api_context = current_context + + # 🌟 输出预算钳制(对照 simple-options clampMaxTokensToContext) + # 🆕 P2: 把 system + 工具 schema 传给估算器(对照 pi 传完整 Context), + # 仅在无 usage 锚点分支生效;否则新会话首轮会多预留 ~system+tools 的预算。 + clamped = clamp_max_tokens_to_context( + config.model, current_context, + system_prompt=config.system_prompt or "", tools=config.tools) + if clamped is None: + err = AgentError( + message="上下文溢出:估算输入 token 已超过模型窗口(需要压缩)", + kind="overflow", recoverable=True) + msg = AgentMessage(role="assistant", stop_reason="error", + error_message=err.message) + agent.state.messages.append(msg) + _message_events(agent, msg) + return msg, err + max_tokens, _input_tokens = clamped + + msg = AgentMessage(role="assistant") + agent.state.streaming_message = msg + agent.state.streaming_delta = {} + _emit(agent, AgentEvent(type="message_start", message=msg)) + + raw_tc: Dict[int, Dict[str, str]] = {} # 中止时用于保留部分工具调用(仅展示) + got_final = False + error: Optional[AgentError] = None + + try: + for kind, payload in stream_fn(api_context, config.model, signal, + max_tokens, config.tools): + if kind == "event": + ev = payload + if ev.type == "text_delta": + msg.content = (msg.content or "") + ev.text + agent.state.streaming_delta["text"] = ev.text + _emit(agent, AgentEvent(type="message_update", message=msg, + assistant_message_event=ev)) + elif ev.type == "thinking_delta": + msg.reasoning += ev.text + agent.state.streaming_delta["thinking"] = ev.text + _emit(agent, AgentEvent(type="message_update", message=msg, + assistant_message_event=ev)) + elif ev.type == "toolcall_delta": + slot = raw_tc.setdefault(ev.tool_call_index, + {"id": "", "name": "", "args": ""}) + if ev.tool_call_field == "id": + slot["id"] = ev.tool_call_delta + elif ev.tool_call_field == "name": + slot["name"] += ev.tool_call_delta + else: + slot["args"] += ev.tool_call_delta + _emit(agent, AgentEvent(type="message_update", message=msg, + assistant_message_event=ev)) + else: # final + final: AgentMessage = payload + got_final = True + # 采用权威 final:usage / stop_reason / 解析好的 tool_calls + msg.usage = final.usage + msg.stop_reason = final.stop_reason + msg.tool_calls = final.tool_calls + if not final.content and msg.content: + pass # 以循环侧累积为准(二者一致) + else: + msg.content = final.content if final.content else msg.content + msg.reasoning = final.reasoning or msg.reasoning + except AgentError as e: + error = e + msg.stop_reason = "error" + msg.error_message = e.message + _emit(agent, AgentEvent(type="message_update", message=msg, + assistant_message_event= + AssistantMessageEvent.error(e.message))) + except Exception as e: + from .stream_fn import classify_error + error = classify_error(e) + msg.stop_reason = "error" + msg.error_message = error.message + + if not got_final and error is None: + # 生成器中途结束且无异常 = 中止(流被关闭,pi 同款语义) + msg.stop_reason = "aborted" + # 保留部分工具调用(仅用于 UI 展示;中止后不会执行,也不会进入下次上下文) + for idx in sorted(raw_tc.keys()): + slot = raw_tc[idx] + if slot.get("name"): + msg.tool_calls.append(ToolCall(id=slot.get("id") or new_id("call"), + name=slot["name"], + raw_arguments=slot.get("args", ""))) + if error is None and msg.stop_reason not in ("stop", "length", "aborted"): + msg.stop_reason = "stop" + + # 🌟 兜底(haocode 扩展,pi 无此层):不支持 tools API 的供应商/模型会把 + # 工具调用用文字"演"出来(如 ls)。识别单参工具 bash/read + # 转成真 tool_call 继续执行;write/edit 多参歧义大不兜底。 + if error is None and not msg.tool_calls and msg.content: + from .tools import parse_text_tool_calls + cleaned, txt_calls = parse_text_tool_calls(msg.content) + if txt_calls: + msg.tool_calls = txt_calls + + agent.state.streaming_message = None + agent.state.streaming_delta = {} + agent.state.messages.append(msg) + _emit(agent, AgentEvent(type="message_end", message=msg)) + return msg, error + + +# ====================================================================== +# 工具批量执行 —— 对照 executeToolCalls (行 413-427) +# ====================================================================== +def _should_terminate_batch(finalized: List[Dict[str, Any]]) -> bool: + """对照 shouldTerminateToolBatch: 全部结果都请求 terminate 才终止""" + return len(finalized) > 0 and all( + f["result"].terminate for f in finalized) + + +def _run_prepared_in_pool(prep: PreparedToolCall, assistant: AgentMessage, + config: AgentConfig, signal: AbortSignal, + agent) -> Dict[str, Any]: + """线程池内执行单个工具(对照 parallel 版的 async 闭包)""" + on_update = None + on_timer = None + if agent is not None: + def on_update(partial: str): + _emit(agent, AgentEvent(type="tool_execution_update", + tool_call=prep.tool_call, arg=partial)) + def on_timer(elapsed_i: int, timeout_i: int): + # 🆕 bash 运行中每秒滴一次 → 前端气泡读秒 N/Ts + _emit(agent, AgentEvent(type="tool_execution_timer", + tool_call=prep.tool_call, + arg=(int(elapsed_i), int(timeout_i)))) + result = execute_tool_call(prep, assistant, config, signal, on_update, + on_timer) + finalized = {"tool_call": prep.tool_call, "result": result, + "is_error": result.is_error} + _emit(agent, AgentEvent(type="tool_execution_end", + tool_call=prep.tool_call, result=result, + is_error=result.is_error)) + return finalized + + +def _execute_parallel(current_context, assistant, config, signal, agent, + prepared: List[PreparedToolCall]) -> Dict[str, Any]: + """对照 executeToolCallsParallel:start 事件串行发、准备串行、执行并发、 + 结果消息按原始顺序产出""" + finalized: List[Dict[str, Any]] = [] + pending_futures: List = [] + max_workers = max(1, min(8, len(prepared))) + with ThreadPoolExecutor(max_workers=max_workers, + thread_name_prefix="tool") as pool: + for prep in prepared: + _emit(agent, AgentEvent(type="tool_execution_start", + tool_call=prep.tool_call, + arg=str(prep.args) if prep.args else "")) + if prep.error: + # 准备失败(未知工具/参数非法)→ 立即结果(对照 kind:"immediate") + result = AgentToolResult.text(prep.error, is_error=True) + fin = {"tool_call": prep.tool_call, "result": result, + "is_error": True} + _emit(agent, AgentEvent(type="tool_execution_end", + tool_call=prep.tool_call, result=result, + is_error=True)) + finalized.append(fin) + if signal.aborted: + break + continue + fut = pool.submit(_run_prepared_in_pool, prep, assistant, config, + signal, agent) + pending_futures.append(fut) + if signal.aborted: + # 已提交的任务仍会完成(它们内部检查 signal),不再追加 + pass + for fut in pending_futures: + finalized.append(fut.result()) + + messages: List[AgentMessage] = [] + for fin in finalized: + tr = _tool_result_message(fin) + _message_events(agent, tr) + messages.append(tr) + return {"messages": messages, "terminate": _should_terminate_batch(finalized)} + + +def _execute_sequential(current_context, assistant, config, signal, agent, + prepared: List[PreparedToolCall]) -> Dict[str, Any]: + """对照 executeToolCallsSequential:一次一个,完成一个再下一个""" + finalized: List[Dict[str, Any]] = [] + messages: List[AgentMessage] = [] + for prep in prepared: + _emit(agent, AgentEvent(type="tool_execution_start", + tool_call=prep.tool_call, + arg=str(prep.args) if prep.args else "")) + if not prep.error: + fin = _run_prepared_in_pool(prep, assistant, config, signal, agent) + else: + result = AgentToolResult.text(prep.error, is_error=True) + fin = {"tool_call": prep.tool_call, "result": result, "is_error": True} + _emit(agent, AgentEvent(type="tool_execution_end", + tool_call=prep.tool_call, result=result, + is_error=True)) + finalized.append(fin) + tr = _tool_result_message(fin) + _message_events(agent, tr) + messages.append(tr) + if signal.aborted: + break + return {"messages": messages, "terminate": _should_terminate_batch(finalized)} + + +def execute_tool_calls(current_context, assistant: AgentMessage, + config: AgentConfig, signal: AbortSignal, agent) -> Dict[str, Any]: + """对照 executeToolCalls: 批次里有 sequential 工具 → 整批串行""" + prepared = prepare_tool_calls(assistant, config.tools) + has_sequential = any( + (p.tool and p.tool.execution_mode == "sequential") for p in prepared + if not p.error) + if config.tool_execution == "sequential" or has_sequential: + return _execute_sequential(current_context, assistant, config, signal, + agent, prepared) + return _execute_parallel(current_context, assistant, config, signal, agent, + prepared) + + +# ====================================================================== +# 主循环 —— 对照 runLoop (行 163-278) +# ====================================================================== +def run_loop(agent, new_message: Optional[AgentMessage], signal: AbortSignal, + stream_fn: Callable) -> RunResult: + config = agent.config + new_messages: List[AgentMessage] = [] + + # 入口校验(对照 runAgentLoopContinue 的前置检查由 Agent 层负责) + _emit(agent, AgentEvent(type="agent_start")) + _emit(agent, AgentEvent(type="turn_start")) # 首轮 turn_start(对照行 142/170) + + # context 准备 + transformContext 钩子 + base = list(agent.state.messages) + if config.transform_context: + try: + base = config.transform_context(base) or base + except Exception: + pass + current_context = list(base) + + first_turn = True + pending: List[AgentMessage] = agent._take_steering() + + while True: # outer loop + has_more_tool_calls = True + + while has_more_tool_calls or pending: # inner loop + if not first_turn: + _emit(agent, AgentEvent(type="turn_start")) + else: + first_turn = False + + # ---- 注入 pending 消息(steering / followUp)---- + if pending: + for m in pending: + _message_events(agent, m) + current_context.append(m) + new_messages.append(m) + agent.state.messages.append(m) + pending = [] + + # ---- 🆕 轮中主动压缩检查(haocode 增强,偏离 pi 1:1)---- + # 单条工具输出可能把上下文顶出窗口;发下一次请求前主动检查 + #(与轮首 should_compact 同公式)。compact_fn 返回新列表 + #(发生了压缩)→ 同步循环局部上下文。 + if config.compact_fn is not None and agent.state.messages: + try: + compacted = config.compact_fn(list(agent.state.messages)) + if compacted is not None: + agent.state.messages = compacted + current_context = list(compacted) + except Exception: + pass + + # ---- 流式生成助手消息 ---- + assistant, error = _stream_turn(agent, current_context, config, + signal, stream_fn) + new_messages.append(assistant) + current_context.append(assistant) + + if assistant.stop_reason in ("error", "aborted"): + agent.state.error = error + _emit(agent, AgentEvent(type="turn_end", message=assistant)) + _emit(agent, AgentEvent(type="agent_end", + stop_reason=assistant.stop_reason, + error=error, + messages=new_messages)) + agent._finish_run(new_messages, assistant.stop_reason, error) + return RunResult(stop_reason=assistant.stop_reason, error=error, + message_count=len(new_messages)) + + # ---- 工具调用 ---- + tool_results: List[AgentMessage] = [] + has_more_tool_calls = False + if assistant.tool_calls: + if assistant.stop_reason == "length": + # 🌟 截断保护(对照行 212-213):参数可能残缺,一律失败不执行 + fail_msgs = fail_tool_calls_from_truncated_message( + assistant, "length") + # 事件流与正常执行对齐 + for tc, tr in zip(assistant.tool_calls, fail_msgs): + _emit(agent, AgentEvent(type="tool_execution_start", + tool_call=tc)) + res = AgentToolResult.text( + f'工具调用 "{tc.name}" 未执行:输出达到 token 上限,' + f'参数可能被截断。请用完整参数重新发起。', is_error=True) + _emit(agent, AgentEvent(type="tool_execution_end", + tool_call=tc, result=res, + is_error=True)) + _message_events(agent, tr) + batch = {"messages": fail_msgs, "terminate": False} + else: + batch = execute_tool_calls(current_context, assistant, config, + signal, agent) + tool_results = batch["messages"] + has_more_tool_calls = not batch["terminate"] + for r in tool_results: + current_context.append(r) + new_messages.append(r) + agent.state.messages.append(r) + + _emit(agent, AgentEvent(type="turn_end", message=assistant)) + + # ---- prepareNextTurn 钩子 ---- + if config.prepare_next_turn: + try: + snap = config.prepare_next_turn({ + "message": assistant, "tool_results": tool_results, + "context": current_context, "new_messages": new_messages, + }) + if snap: + current_context = snap.get("context") or current_context + except Exception: + pass + + # ---- shouldStopAfterTurn 钩子 ---- + if config.should_stop_after_turn: + try: + if config.should_stop_after_turn({ + "message": assistant, "tool_results": tool_results, + "context": current_context, + "new_messages": new_messages}): + stop = _last_assistant_stop(new_messages) + _emit(agent, AgentEvent(type="agent_end", + stop_reason=stop, + messages=new_messages)) + agent._finish_run(new_messages, stop, None) + return RunResult(stop_reason=stop, + message_count=len(new_messages)) + except Exception: + pass + + # ---- 每轮结束取 steering ---- + pending = agent._take_steering() + + # ---- 外层:followUp ---- + follow_ups = agent._take_follow_ups() + if follow_ups: + pending = follow_ups + continue + break + + stop = _last_assistant_stop(new_messages) + _emit(agent, AgentEvent(type="agent_end", stop_reason=stop, + messages=new_messages)) + agent._finish_run(new_messages, stop, None) + return RunResult(stop_reason=stop, message_count=len(new_messages)) + + +def _last_assistant_stop(new_messages: List[AgentMessage]) -> str: + """对照 pi: agent_end 不携带 stopReason,会话层从最后一条 assistant 消息读取""" + for m in reversed(new_messages): + if m.role == "assistant": + return m.stop_reason or "stop" + return "stop" diff --git a/core/agent/recovery.py b/core/agent/recovery.py new file mode 100644 index 0000000..3eb6a82 --- /dev/null +++ b/core/agent/recovery.py @@ -0,0 +1,519 @@ +""" +core/agent/recovery.py +====================== +🌟 pi 会话层恢复逻辑的 Python 1:1 移植 + +对照 pi-main 源码: + packages/coding-agent/src/core/agent-session.ts + _runAgentPrompt (行 1074): prompt → while _handlePostAgentRun(): continue() + _handlePostAgentRun (行 1088): 基于「最后一条 assistant 消息」的三路决策 + _prepareRetry (行 2811): 计数 → 移除坏消息 → 退避睡眠 + _isRetryableError (行 2770): 溢出交给压缩;其余走重试判定 + _checkCompaction (行 ~2034): 溢出压缩恢复(只试一次) + packages/ai/src/utils/retry.ts + isRetryableAssistantError (行 223) + 可重试/不可重试正则模式表 + 退避: baseDelayMs × 2^(attempt-1)(默认 2s, 4s, 8s;maxRetries 默认 3) + packages/ai/src/utils/overflow.ts + isContextOverflow (行 134):三种溢出识别 + isRecoverableLength:length 且 output < 原始输出上限 + +🌟 决策表(与 pi 完全一致,基于最后一条 assistant 消息): + (1) stop_reason=="error": + a. 是上下文溢出 → 删除坏消息 + 压缩 + continue(只试一次) + b. 命中可重试模式(429/5xx/超时/断连…且非配额耗尽) + 且未超 maxRetries → 删除坏消息 + 退避(2s×2^(n-1)) + continue + c. 其他 → 结束 + (2) isRecoverableLength(length 且实际输出 < 原始 maxTokens, + 说明是被上下文窗口挤断而非输出上限截断)且未尝试过 + → 删除该助手消息 + 压缩 + continue(只试一次) + (3) 队列里还有 followUp/steering → continue(续跑) + (4) 都不满足 → settle +""" +from __future__ import annotations + +import os +import re +import time +from typing import Callable, Optional + +from .compaction import CompactionSettings, _content_text, compact_context +from .context import estimate_context_tokens, should_compact +from .types import (AgentMessage, AgentConfig, RetryConfig, RunResult) + + +# ========== 压缩诊断日志(与 main_window.diag_log 同一规范: +# [HH:MM:SS.mmm] 时间戳 + 项目根目录追加 + UTF-8 + 失败静默) ========== +_COMPACT_LOG_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "compaction_diag.log") + + +def compact_diag_log(line: str): + """压缩日志只记录:同一行内容 print(控制台)+ 追加写 compaction_diag.log""" + t = time.time() + stamp = (f"[{time.strftime('%H:%M:%S', time.localtime(t))}" + f".{int(t * 1000) % 1000:03d}]") + print(f"{stamp} {line}", flush=True) + try: + with open(_COMPACT_LOG_PATH, "a", encoding="utf-8") as f: + f.write(f"{stamp} {line}\n") + except Exception: + pass + + +def _settings_of(cfg: AgentConfig) -> CompactionSettings: + """AgentConfig 压缩参数 → CompactionSettings(1:1 映射 pi settings 字段)""" + return CompactionSettings(reserve_tokens=cfg.compaction_reserve, + keep_recent_tokens=cfg.compaction_keep_recent) + + +def _cut_ids_of(msgs: list, new_msgs: list) -> tuple: + """🆕 压缩持久化切点:new_msgs = [摘要] + 保留尾巴(旧 msgs 的连续后缀)。 + 按 identity 定位尾巴起点 → (cut_before_id, first_retained_id)(DB 行 id)。 + + ⚠️ 行粒度:同一 DB 行(assistant timeline)会被 build_api_context 回放成 + 多条 API 消息(asst/tool 条目共享同一 _db_msg_id)。若尾巴起点落在某行 + 回放序列中间,必须回退到该行第一条,否则 cut_before 与 first_retained + 会是同一行 → DB 自环(链死循环卡死)。 + 回退后下一轮会从该行完整重放(保守方向,宁多勿漏)。 + 任一为空 = 无法在 DB 链上切(如尾巴全是轮中内存消息)→ UI 不插标记。""" + if not new_msgs or getattr(new_msgs[0], "kind", None) != "compaction_summary": + return "", "" + tail = new_msgs[1:] + if not tail: + return "", "" + first = tail[0] + for i, m in enumerate(msgs): + if m is first: + # 回退到同一 DB 行回放序列的头部 + fid = getattr(first, "db_msg_id", "") or "" + j = i + while j > 0 and fid and getattr(msgs[j - 1], "db_msg_id", "") == fid: + j -= 1 + if j == 0: + return "", "" # 没有“之前”的行 → 无切点 + return (getattr(msgs[j - 1], "db_msg_id", "") or "", + getattr(first, "db_msg_id", "") or "") + return "", "" # 尾巴首条不在旧列表(不应发生)→ 不插标记 + +# ====================================================================== +# 对照 retry.ts 的模式表(原样移植) +# ====================================================================== +_NON_RETRYABLE_LIMIT_RE = re.compile( + r"GoUsageLimitError|FreeUsageLimitError|" + r"Monthly usage limit reached|available balance|" + r"insufficient_quota|out of budget|quota exceeded|billing", + re.IGNORECASE, +) + +_RETRYABLE_RE = re.compile( + r"overloaded|rate.?limit|too many requests|429|500|502|503|504|524|" + r"service.?unavailable|server.?error|internal.?error|" + r"provider.?returned.?error|exceeded request buffer limit while retrying upstream|" + r"network.?error|connection.?error|connection.?refused|connection.?lost|" + r"other side closed|fetch failed|getaddrinfo|ENOTFOUND|EAI_AGAIN|" + r"upstream.?connect|reset before headers|socket hang up|timeout|timed?\s?out", + re.IGNORECASE, +) + +# 对照 overflow.ts OVERFLOW_PATTERNS(各服务商话术) +_OVERFLOW_RE = re.compile( + r"context\s*window|prompt is too long|maximum context|too many tokens|" + r"context length|exceeds the (model|maximum) context|" + r"maximum context length is \d+ tokens|exceeds the maximum allowed input length|" + r"longer than the model's context length|exceeds the available context size|" + r"greater than the context length|exceeded model token limit|" + r"Range of input length should be|configured context size|" + r"长于|超出.*上下文|上下文.*(超出|超过|不足)", + re.IGNORECASE, +) + +# 限速类不是溢出(对照 overflow.ts NON_OVERFLOW_PATTERNS 的判定意图) +_NON_OVERFLOW_RE = re.compile( + r"rate.?limit|too many requests|429|overloaded", re.IGNORECASE) + + +# ====================================================================== +# 判定函数(对照 pi overflow.ts / retry.ts,基于 assistant 消息) +# ====================================================================== +def is_context_overflow(message: AgentMessage, + context_window: int = 0) -> bool: + """ + 对照 isContextOverflow 的三种情形: + 1. error + 消息文本命中溢出模式(且不是限速类) + 2. 静默溢出:stop 但 usage.input > contextWindow(z.ai 风格) + 3. 服务端截断式溢出:length 且 output==0 且 input ≥ 0.99×窗口 + """ + if message.role != "assistant": + return False + if message.stop_reason == "error" and message.error_message: + if not _NON_OVERFLOW_RE.search(message.error_message) \ + and _OVERFLOW_RE.search(message.error_message): + return True + usage = message.usage or {} + input_tokens = int(usage.get("input", 0) or 0) + int(usage.get("cacheRead", 0) or 0) + if context_window and message.stop_reason == "stop": + if input_tokens > context_window: + return True + if context_window and message.stop_reason == "length" \ + and int(usage.get("output", 0) or 0) == 0: + if input_tokens >= context_window * 0.99: + return True + return False + + +def is_retryable_assistant_error(message: AgentMessage) -> bool: + """对照 isRetryableAssistantError:基于 errorMessage 文本模式""" + if message.role != "assistant" or message.stop_reason != "error": + return False + if not message.error_message: + return False + if _NON_RETRYABLE_LIMIT_RE.search(message.error_message): + return False + return bool(_RETRYABLE_RE.search(message.error_message)) + + +def is_recoverable_length(message: AgentMessage, + desired_max_output: int) -> bool: + """ + 对照 pi isRecoverableLength: + length 停止 且 实际输出 < 「原始输出上限」(钳制前的 maxTokens)。 + """ + if message.role != "assistant" or message.stop_reason != "length": + return False + if desired_max_output <= 0: + return False + out = int((message.usage or {}).get("output", 0) or 0) + return out < desired_max_output + + +def compute_retry_delay_ms(attempt: int, cfg: RetryConfig) -> float: + """对照 pi _prepareRetry: baseDelayMs × 2^(attempt-1)(无抖动、默认无封顶)""" + delay = cfg.base_delay_ms * (cfg.factor ** (attempt - 1)) + if cfg.max_delay_ms and delay > cfg.max_delay_ms: + delay = cfg.max_delay_ms + return float(delay) + + +def find_last_assistant(messages) -> Optional[AgentMessage]: + """对照 agent-session _findLastAssistantMessage""" + for m in reversed(messages): + if m.role == "assistant": + return m + return None + + +# ====================================================================== +# 编排器 —— 对照 agent-session 的 run 循环 +# ====================================================================== +class AgentRunner: + """ + 对照 agent-session.ts 的 run 编排(agent 之外的「会话层」)。 + haocode 里由 AgentWorker(QThread) 持有一个 AgentRunner。 + + summarize_fn(transcript) -> str:压缩用的非流式 LLM 调用(缺省 → 禁压缩)。 + """ + + def __init__(self, agent: Agent, + summarize_fn: Optional[Callable[[str], str]] = None, + on_retry_scheduled: Optional[Callable] = None, + on_retry_finished: Optional[Callable] = None, + on_compaction_started: Optional[Callable] = None, + on_compaction_finished: Optional[Callable] = None): + self.agent = agent + self.summarize_fn = summarize_fn + # 🆕 M3: 重试回调(对照 pi retryAssistantCall 的 + # onRetryScheduled / onRetryFinished)——UI 可提示「正在重试」 + self.on_retry_scheduled = on_retry_scheduled + self.on_retry_finished = on_retry_finished + # 🆕 压缩开始回调(摘要 LLM 调用阻塞前触发)——UI 显示「执行中」动态气泡 + self.on_compaction_started = on_compaction_started + # 🆕 压缩完成回调(压缩结束瞬间触发,不等整轮 run 结束) + # ——前端气泡即时定格「已完成上下文压缩」(与思考块定格同款时机) + self.on_compaction_finished = on_compaction_finished + self._overflow_recovered = False # 对照 _overflowRecoveryAttempted + self._retry_attempt = 0 # 对照 _retryAttempt + self.last_action = "none" # 诊断: none/retry/overflow_compact/length_compact/queued + self.compactions_performed = 0 # 成功压缩次数(UI 提示用) + # 🆕 压缩事件队列(UI 气泡用:摘要全文/前后 token/耗时/路径) + self.compaction_events: list = [] + # 🆕 轮中主动压缩护栏(同一 run 内;AgentRunner 每轮新建 → 自动复位) + self._mid_turn_fail_streak = 0 + self._mid_turn_exhausted = False + + # ------------------------------------------------------------------ + # 发送前压缩(🌟 1:1 对照 pi shouldCompact:tokens > window - reserveTokens) + # ------------------------------------------------------------------ + def pre_prompt_compaction(self) -> bool: + if self.summarize_fn is None: + return False + cfg = self.agent.config + msgs = self.agent.state.messages + m = cfg.model + # 🆕 G2: 透传 system_prompt/tools —— 无锚点时度量「下一请求真实载荷」 + should, tokens = should_compact(msgs, m, cfg.compaction_reserve, + system_prompt=cfg.system_prompt, + tools=cfg.tools) + # 🆕 每次请求前的校验:print + 写 compaction_diag.log(触发与否都记) + compact_diag_log( + f"[PRE_CHECK] model={m.name} window={m.context_window} " + f"reserve={cfg.compaction_reserve} " + f"threshold={m.context_window - cfg.compaction_reserve} " + f"est={tokens} triggered={str(should).lower()}") + if not should: + return False + self._notify_compaction_started("pre_prompt", tokens) + t0 = time.time() + new_msgs = compact_context(msgs, cfg.model, self.summarize_fn, + settings=_settings_of(cfg)) + if new_msgs is not None: + cut_before, first_retained = _cut_ids_of(msgs, new_msgs) + self.agent.state.messages = new_msgs + self.compactions_performed += 1 + self._record_compaction("pre_prompt", tokens, new_msgs, t0, + cut_before_id=cut_before, + first_retained_id=first_retained) + return True + self._record_compaction_failed("pre_prompt", tokens, t0) + return False + + # ------------------------------------------------------------------ + # 🆕 轮中主动压缩(haocode 增强,偏离 pi 1:1) + # ------------------------------------------------------------------ + def compact_if_needed(self, msgs: list) -> Optional[list]: + """接线到 AgentConfig.compact_fn:内层循环每次 LLM 请求前调用。 + + 与轮首 pre_prompt_compaction 同公式(tokens > window − reserve)。 + 轮中单条工具输出可把上下文顶出窗口(轮中内存工具结果没有 + 4000 字回放上限),发请求前主动压缩,避免「一次失败往返 + + 响应式兜底」。返回新消息列表(发生了压缩)或 None(不需要/ + 不可压缩 → 原样发请求,响应式 overflow 安全网仍在)。 + + 防刷屏护栏:同一 run 内连续失败 2 次后不再尝试(形状无有效 + 切点,如单条巨型条目,硬试无意义);成功则复位计数。 + """ + if self.summarize_fn is None or self._mid_turn_exhausted: + return None + cfg = self.agent.config + m = cfg.model + # 🆕 G2: 透传 system_prompt/tools(与轮首同口径) + should, tokens = should_compact(msgs, m, cfg.compaction_reserve, + system_prompt=cfg.system_prompt, + tools=cfg.tools) + compact_diag_log( + f"[PRE_CHECK] path=mid_turn est={tokens} " + f"threshold={m.context_window - cfg.compaction_reserve} " + f"triggered={str(should).lower()}") + if not should: + return None + if self._do_compaction("mid_turn", msgs=msgs): + self._mid_turn_fail_streak = 0 + return list(self.agent.state.messages) + self._mid_turn_fail_streak += 1 + if self._mid_turn_fail_streak >= 2: + self._mid_turn_exhausted = True + compact_diag_log( + "[PRE_CHECK] path=mid_turn exhausted " + "(连续 2 次失败,本轮不再尝试)") + return None + + # ------------------------------------------------------------------ + # 主入口 —— 对照 _runAgentPrompt + # ------------------------------------------------------------------ + def run(self, message) -> RunResult: + self.pre_prompt_compaction() + self._overflow_recovered = False + self._retry_attempt = 0 + result = self.agent.prompt(message) + return self._post_loop(result) + + def _post_loop(self, result: RunResult) -> RunResult: + # 对照: while (await this._handlePostAgentRun()) await this.agent.continue() + while True: + action = self._handle_post_agent_run(result) + if action == "stop": + return result + if action == "retry": + result = self.agent.continue_() + # 🆕 M3: 重试结果回调(成功 = 下一轮未以 error/aborted 收尾) + if self.on_retry_finished is not None: + la = find_last_assistant(self.agent.state.messages) + ok = (la is not None + and la.stop_reason not in ("error", "aborted")) + try: + self.on_retry_finished(bool(ok)) + except Exception: + pass + continue + if action == "compact_retry": + if not (self._remove_last_bad_assistant() + and self._do_compaction(self.last_action)): + return result # 压缩不可用 → 放弃,交 UI 报错 + result = self.agent.continue_() + continue + if action == "queued": + result = self.agent.continue_() + continue + return result + + # ------------------------------------------------------------------ + # 三路决策 —— 对照 _handlePostAgentRun + # ------------------------------------------------------------------ + def _handle_post_agent_run(self, result: RunResult) -> str: + msg = find_last_assistant(self.agent.state.messages) + if msg is None: + return "stop" + cfg = self.agent.config + + # (1) 错误路径 + if msg.stop_reason == "error": + # a. 溢出 → 压缩恢复(只试一次) + if is_context_overflow(msg, cfg.model.context_window) \ + and not self._overflow_recovered \ + and self.summarize_fn is not None: + self._overflow_recovered = True + self.last_action = "overflow_compact" + return "compact_retry" + # b. 可重试错误 → 移除坏消息 + 退避 + continue + if is_retryable_assistant_error(msg) \ + and self._prepare_retry(cfg.retry, + reason=msg.error_message or ""): + self.last_action = "retry" + return "retry" + return "stop" + + # (2) 可恢复的 length 截断 + if is_recoverable_length(msg, cfg.model.max_tokens) \ + and not self._overflow_recovered \ + and self.summarize_fn is not None: + self._overflow_recovered = True + self.last_action = "length_compact" + return "compact_retry" + + # (3) 队列里还有消息 → 续跑 + if self.agent.has_queued(): + self.last_action = "queued" + return "queued" + + # (4) settle + return "stop" + + # ------------------------------------------------------------------ + # 对照 _prepareRetry:计数 → 移除坏消息 → 退避睡眠 + # ------------------------------------------------------------------ + def _prepare_retry(self, cfg: RetryConfig, reason: str = "") -> bool: + if self._retry_attempt >= cfg.max_attempts: + return False + self._retry_attempt += 1 + delay_ms = compute_retry_delay_ms(self._retry_attempt, cfg) + # 移除错误助手消息(对照: messages.slice(0,-1),会话历史保留由 UI 层负责) + messages = self.agent.state.messages + if messages and messages[-1].role == "assistant": + self.agent.state.messages = messages[:-1] + # 🆕 M3: 通知重试已调度(参数: attempt, max_attempts, delay_ms, reason) + if self.on_retry_scheduled is not None: + try: + self.on_retry_scheduled(self._retry_attempt, cfg.max_attempts, + delay_ms, reason) + except Exception: + pass + time.sleep(delay_ms / 1000.0) + return True + + # ------------------------------------------------------------------ + # 压缩恢复辅助 + # ------------------------------------------------------------------ + def _remove_last_bad_assistant(self) -> bool: + msgs = self.agent.state.messages + if msgs and msgs[-1].role == "assistant" \ + and msgs[-1].stop_reason in ("length", "error"): + self.agent.state.messages = msgs[:-1] + return True + return False + + def _do_compaction(self, path: str = "reactive", + msgs: Optional[list] = None) -> bool: + if self.summarize_fn is None: + return False + cfg = self.agent.config + if msgs is None: + msgs = self.agent.state.messages + tokens_before = estimate_context_tokens(msgs).tokens + self._notify_compaction_started(path, tokens_before) + t0 = time.time() + new_msgs = compact_context(msgs, cfg.model, self.summarize_fn, + settings=_settings_of(cfg)) + if new_msgs is not None: + cut_before, first_retained = _cut_ids_of(msgs, new_msgs) + self.agent.state.messages = new_msgs + self.compactions_performed += 1 + self._record_compaction(path, tokens_before, new_msgs, t0, + cut_before_id=cut_before, + first_retained_id=first_retained) + return True + self._record_compaction_failed(path, tokens_before, t0) + return False + + # ------------------------------------------------------------------ + # 🆕 压缩可视化辅助:开始/完成 两阶段(print + 日志 + 事件供 UI 气泡) + # ------------------------------------------------------------------ + def _notify_compaction_started(self, path: str, tokens_before: int) -> None: + compact_diag_log(f"[COMPACT_START] path={path} before={tokens_before}") + if self.on_compaction_started is not None: + try: + self.on_compaction_started(path) + except Exception: + pass + + def _record_compaction_failed(self, path: str, tokens_before: int, + t0: float) -> None: + """压缩不可行(如历史无法切分)→ 气泡显示失败原因,不能永远卡在「执行中」""" + duration_ms = int((time.time() - t0) * 1000) + compact_diag_log( + f"[COMPACT_FAIL] path={path} before={tokens_before} " + f"duration_ms={duration_ms}") + ev = { + "summary": "本次压缩未能执行(历史无法切分),继续以原有历史对话。", + "before": tokens_before, + "after": tokens_before, + "duration_ms": duration_ms, + "path": path, + "failed": True, + } + self.compaction_events.append(ev) + if self.on_compaction_finished is not None: + try: + self.on_compaction_finished(dict(ev)) + except Exception: + pass + + def _record_compaction(self, path: str, tokens_before: int, + new_msgs: list, t0: float, + cut_before_id: str = "", + first_retained_id: str = "") -> None: + summary = "" + if new_msgs and getattr(new_msgs[0], "kind", None) == "compaction_summary": + summary = _content_text(new_msgs[0].content) or "" + tokens_after = estimate_context_tokens(new_msgs).tokens + duration_ms = int((time.time() - t0) * 1000) + compact_diag_log( + f"[COMPACT_DONE] path={path} before={tokens_before} " + f"after={tokens_after} summary_chars={len(summary)} " + f"duration_ms={duration_ms} " + f"cut_before={cut_before_id or '-'} first_retained={first_retained_id or '-'}") + ev = { + "summary": summary, + "before": tokens_before, + "after": tokens_after, + "duration_ms": duration_ms, + "path": path, + # 🆕 压缩持久化切点:UI 收到后在 DB 链上插标记行(两者齐备才插) + "cut_before_id": cut_before_id, + "first_retained_id": first_retained_id, + } + self.compaction_events.append(ev) + if self.on_compaction_finished is not None: + try: + self.on_compaction_finished(dict(ev)) + except Exception: + pass diff --git a/core/agent/stream_fn.py b/core/agent/stream_fn.py new file mode 100644 index 0000000..d3c61b2 --- /dev/null +++ b/core/agent/stream_fn.py @@ -0,0 +1,438 @@ +""" +core/agent/stream_fn.py +======================= +🌟 pi-ai 流式接口的 Python 1:1 移植 —— OpenAI 兼容(含 vLLM) + +对照 pi-main 源码: + packages/ai/src/api/openai-completions.ts + - streamSimple() (行 469 起): 消费 SSE chunk → 产出 AssistantMessage + - buildParams() (行 536 起): 组装请求体 + * vLLM/OpenAI 默认字段名 = max_completion_tokens + * 少数服务商(chutes/deepseek/moonshot 等)用 max_tokens + - mapStopReason(): finish_reason "length" → stopReason "length" + packages/ai/src/api/simple-options.ts + - buildBaseOptions(): maxTokens = options?.maxTokens ?? model.maxTokens + (即模型定义里的 maxTokens 一定会进入请求体,这就是它被发给 vLLM 的原因) + +本文件职责: + 1. to_openai_messages() —— pi 消息格式 → OpenAI API 格式(每轮调用前转换) + 2. openai_stream() —— 流式请求,逐 chunk 产出 AssistantMessageEvent, + 结束产出最终 AgentMessage(含 usage / stop_reason / tool_calls) + 3. classify_error() —— 异常分类(对照 pi isRetryable 的输入) + +🌟 修复 haocode 原有 P1 bug:openai 客户端带 timeout(原来无超时, + 挂死的流会让线程永久阻塞)。max_retries=0(重试统一交给 recovery 层, + 与 pi 一致:网络层不重试,会话层按 1600ms×1.6^n 退避重试)。 +""" +from __future__ import annotations + +import json +from typing import Any, Dict, Iterator, List, Optional, Tuple + +from openai import OpenAI +from openai import APIConnectionError, APIStatusError, APITimeoutError, RateLimitError + +from .types import (AgentError, AgentMessage, AssistantMessageEvent, + AbortSignal, ModelConfig, ToolCall, new_id) + + +# ====================================================================== +# pi 消息 → OpenAI 消息(每轮发送前调用) +# ====================================================================== +def to_openai_messages(context: List[AgentMessage], + pass_reasoning: bool = True) -> List[Dict[str, Any]]: + """pi 消息 → OpenAI chat 格式。 + 🆕 thinking 模式(DeepSeek 等):assistant 消息必须把 reasoning_content + 原样回传,否则服务端 400 + ('The reasoning_content in the thinking mode must be passed back to the API')。 + 默认开启:轮中续问/工具回灌场景,内存里本轮刚流式产出的 assistant 带 + reasoning → 回传;DB 回放的旧行 reasoning=""(不入库)→ 无该字段, + 跨轮全新请求不受影响。pass_reasoning=False 显式关闭(特殊网关逃生阀)。 + """ + out: List[Dict[str, Any]] = [] + for m in context: + if m.role == "system": + # 只放行请求头部的系统提示词(loop 注入); + # 历史中间出现的 system(DB 防御)仍然跳过 + if not out: + out.append({"role": "system", "content": m.content}) + continue + if m.role == "toolResult": + out.append({ + "role": "tool", + "tool_call_id": m.tool_call_id, + "content": m.content if isinstance(m.content, str) + else "".join(c.get("text", "") for c in m.content + if isinstance(c, dict)), + }) + continue + if m.role == "assistant": + entry: Dict[str, Any] = {"role": "assistant"} + # 纯工具调用轮:content 可能为空 → 用 ""(vLLM 接受,避免 null 报错) + entry["content"] = m.content if m.content else "" + if m.tool_calls: + entry["tool_calls"] = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": json.dumps(tc.arguments, ensure_ascii=False) + if tc.arguments else tc.raw_arguments or "{}", + }, + } + for tc in m.tool_calls + ] + if pass_reasoning and m.reasoning: + entry["reasoning_content"] = m.reasoning + out.append(entry) + continue + # user(content 可以是 str 或 OpenAI 多模态 list,原样透传) + out.append({"role": "user", "content": m.content}) + return out + + +# ====================================================================== +# 异常分类 —— 对照 pi isRetryable / retryable error 判定 +# ====================================================================== +def classify_error(e: Exception) -> AgentError: + """把 openai SDK 异常分类,恢复层据此决定重试/放弃。""" + if isinstance(e, RateLimitError): + return AgentError(message=str(e), kind="rate_limit", + status_code=429, recoverable=True) + if isinstance(e, APITimeoutError): + return AgentError(message=f"请求超时: {e}", kind="timeout", recoverable=True) + if isinstance(e, APIConnectionError): + return AgentError(message=f"连接失败: {e}", kind="connection", recoverable=True) + if isinstance(e, APIStatusError): + code = e.status_code + msg = str(e) + if code in (502, 503, 504): + return AgentError(message=msg, kind="server_error", + status_code=code, recoverable=True) + if code == 529: + return AgentError(message=msg, kind="overload", + status_code=code, recoverable=True) + if code == 408: + return AgentError(message=msg, kind="timeout", + status_code=code, recoverable=True) + if code in (401, 403): + return AgentError(message=msg, kind="auth", + status_code=code, recoverable=False) + # 4xx(参数错误/模型不存在/上下文超长等)不可重试,交恢复层细分 + return AgentError(message=msg, kind="unknown", + status_code=code, recoverable=False) + if isinstance(e, (ConnectionError, TimeoutError)): + return AgentError(message=str(e), kind="connection", recoverable=True) + return AgentError(message=str(e), kind="unknown", recoverable=False) + + +# ====================================================================== +# 🆕 M1/M2: 流式字段提取(对照 pi openai-completions.ts 的字段优先级) +# ====================================================================== +# 🆕 M1: 1:1 对照 pi chunk 级 reasoning 字段优先级(llama.cpp/vLLM/OpenRouter 各不同) +_REASONING_FIELDS = ("reasoning_content", "reasoning", "reasoning_text") + + +def _pick_reasoning(delta) -> str: + """从 delta 上取第一个非空思考字段(只取一个,防同内容多字段重复计)""" + if delta is None: + return "" + dump = None + for name in _REASONING_FIELDS: + v = getattr(delta, name, None) + if v is None and dump is None and hasattr(delta, "model_dump"): + try: + dump = delta.model_dump() + except Exception: + dump = {} + if v is None and isinstance(dump, dict): + v = dump.get(name) + if v: + return str(v) + return "" + + +def _pick_usage(chunk): + """🆕 M2: 对照 pi:先 chunk.usage,再 choices[0].usage(Moonshot 系只放 choice 里)""" + u = getattr(chunk, "usage", None) + if u is not None: + return u + try: + choices = getattr(chunk, "choices", None) + if choices: + return getattr(choices[0], "usage", None) + except Exception: + pass + return None + + +# ====================================================================== +# 流式请求核心 —— 对照 openai-completions.ts streamSimple +# ====================================================================== +def openai_stream(context: List[AgentMessage], + model: ModelConfig, + signal: AbortSignal, + max_tokens: int, + tools: Optional[List[Any]] = None, + ) -> Iterator[Tuple[str, Any]]: + """ + 生成器协议(对照 pi streamSimple 的 for-await 消费方式): + yield ("event", AssistantMessageEvent) # 增量事件(text/thinking/toolcall/done) + yield ("final", AgentMessage) # 最终助手消息(含 stop_reason/usage) + 异常统一转 AgentError 抛出(由循环层捕获 → stop_reason="error")。 + + 🌟 中止语义与 pi 一致:每个 chunk 边界检查 signal.aborted, + 命中即关闭流、以 stop_reason="aborted" 收尾(不是 error)。 + """ + client = OpenAI( + api_key=model.api_key or "EMPTY", + base_url=model.base_url, + # 🌟 P1 修复:显式超时(pi 侧由 fetch timeout 保证) + timeout=model.timeout_seconds, + max_retries=0, # 重试统一由 recovery 层负责 + ) + + params: Dict[str, Any] = { + "model": model.name, + "messages": to_openai_messages(context, model.pass_reasoning), + "stream": True, + "stream_options": {"include_usage": True}, # 拿真实 usage(pi 同款) + "temperature": model.temperature, + } + # 对照 pi buildParams:默认 max_completion_tokens(vLLM 兼容) + if getattr(model, "use_max_tokens_field", False): + params["max_tokens"] = max_tokens + else: + params["max_completion_tokens"] = max_tokens + + # 🌟 工具定义(对照 pi buildParams 的 tools 段)——不发给模型, + # 模型就不可能发出真正的 tool_call,只会用文字"演"工具调用! + if tools: + params["tools"] = [ + { + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters, + }, + } + for t in tools + ] + + # 累积状态 + content_parts: List[str] = [] + reasoning_parts: List[str] = [] + tc_acc: Dict[int, Dict[str, Any]] = {} # index -> {id, name, args} + finish_reason: Optional[str] = None + usage: Dict[str, Any] = {} + stream = None + + try: + stream = client.chat.completions.create(**params) + for chunk in stream: + # 🌟 pi 同款:chunk 边界检查中止 + if signal.aborted: + finish_reason = "aborted" + try: + stream.close() + except Exception: + pass + break + + # 🆕 M2: usage 读取顺序对照 pi —— chunk.usage 优先,choice.usage 兜底 + usage_obj = _pick_usage(chunk) + if usage_obj is not None: + usage = { + "input": getattr(usage_obj, "prompt_tokens", 0) or 0, + "output": getattr(usage_obj, "completion_tokens", 0) or 0, + "cacheRead": 0, "cacheWrite": 0, + } + if not getattr(chunk, "choices", None): + continue + + choice = chunk.choices[0] + delta = choice.delta + + # 🆕 M1: 思考增量(对照 pi reasoningFields 优先级,含多字段去重) + reasoning = _pick_reasoning(delta) + if reasoning: + yield ("event", AssistantMessageEvent.thinking_delta(reasoning)) + reasoning_parts.append(reasoning) + + if getattr(delta, "content", None): + yield ("event", AssistantMessageEvent.text_delta(delta.content)) + content_parts.append(delta.content) + + # 工具调用增量(按 index 累积 id/name/arguments 三段) + for tc_delta in (getattr(delta, "tool_calls", None) or []): + idx = tc_delta.index or 0 + slot = tc_acc.setdefault(idx, {"id": "", "name": "", "args": ""}) + if tc_delta.id: + slot["id"] = tc_delta.id + yield ("event", AssistantMessageEvent.toolcall_delta( + idx, tc_delta.id, field="id")) + fn = tc_delta.function + if fn is not None: + if fn.name: + slot["name"] += fn.name + yield ("event", AssistantMessageEvent.toolcall_delta( + idx, fn.name, field="name")) + if fn.arguments: + slot["args"] += fn.arguments + yield ("event", AssistantMessageEvent.toolcall_delta( + idx, fn.arguments, field="arguments")) + + if choice.finish_reason: + finish_reason = choice.finish_reason + except Exception as e: + # 中止过程中断网/断流不当错误处理(pi 同款语义) + if signal.aborted: + finish_reason = "aborted" + else: + raise classify_error(e) + finally: + try: + if stream is not None: + stream.close() + except Exception: + pass + + # ---- 组装最终助手消息(对照 mapStopReason + AssistantMessage 构造)---- + # pi mapStopReason: "length"→"length", "stop"/"tool_calls"→"stop", 其余→"stop" + if finish_reason == "aborted": + stop_reason = "aborted" + elif finish_reason == "length": + stop_reason = "length" + else: + stop_reason = "stop" + + tool_calls: List[ToolCall] = [] + for idx in sorted(tc_acc.keys()): + slot = tc_acc[idx] + try: + args = json.loads(slot["args"]) if slot["args"] else {} + except json.JSONDecodeError: + # 参数 JSON 被截断/损坏:保留 raw,arguments 置空, + # 由循环层按「截断保护」路径处理(不执行残缺调用) + args = {} + tool_calls.append(ToolCall( + id=slot["id"] or new_id("call"), + name=slot["name"], + arguments=args, + raw_arguments=slot["args"], + )) + + final = AgentMessage( + role="assistant", + content="".join(content_parts), + reasoning="".join(reasoning_parts), + tool_calls=tool_calls, + stop_reason=stop_reason, + usage=usage, + ) + yield ("event", AssistantMessageEvent.done()) + yield ("final", final) + + +# ====================================================================== +# OpenAI 格式 → pi 格式(from_openai_messages 的逆转换,用于从 DB 装载历史) +# ====================================================================== +def _parse_tool_call(tc: Dict[str, Any]) -> Optional[ToolCall]: + """ + 解析一条 OpenAI tool_call → ToolCall。 + 🌟 参数 JSON 可能残缺(历史里存了被截断的调用): + 解析失败 → arguments={} 且 raw_arguments 兜底成合法 "{}", + 保证 to_openai_messages 回发 API 时参数始终是合法 JSON + (API 只校验 JSON 合法性 + tool_call_id 配对,不校验参数内容)。 + """ + if not isinstance(tc, dict): + return None + fn = tc.get("function") or {} + raw_args = fn.get("arguments") + if not isinstance(raw_args, str): + raw_args = "" if raw_args is None else str(raw_args) + try: + args = json.loads(raw_args) if raw_args else {} + if not isinstance(args, dict): + args = {"_": args} + except (json.JSONDecodeError, TypeError, ValueError): + args = {} + raw_args = "{}" # 残缺 JSON 兜底,防回发 API 时 400 + return ToolCall( + id=tc.get("id") or new_id("call"), + name=fn.get("name") or "", + arguments=args, + raw_arguments=raw_args or "{}", + ) + + +def from_openai_messages(messages: List[Dict[str, Any]]) -> List[AgentMessage]: + """ + 把 OpenAI chat 消息数组转成 pi 的 AgentMessage 列表(全保真)。 + + 🌟 修复:worker 模式此前在此丢失全部工具历史(tool 消息被跳过、 + assistant.tool_calls 不解析)→ 任务完成/中断后再次提问,模型看不到 + 之前读过什么文件、执行过什么命令。现改为无损转换: + - user: content 保持 str 或 OpenAI 多模态 list(原样透传) + - assistant: content + reasoning + tool_calls(纯工具轮保留 tool_calls, + content 置 "",不再变成空 assistant 污染上下文) + - tool: → AgentMessage(role="toolResult", tool_call_id, content) + - system: 防御性跳过(DB 链表里不会出现) + + 下游 to_openai_messages 原生支持 toolResult / assistant.tool_calls, + 与 pi 语义一致,往返(to(from(x)))保真。 + """ + out: List[AgentMessage] = [] + for m in messages: + if not isinstance(m, dict): + continue + role = m.get("role") + content = m.get("content") + + if role == "user": + if content is None: + content = "" + out.append(AgentMessage(role="user", content=content, + # 🆕 压缩持久化:跨轮携带 DB 行 id + 摘要 kind + # (kind=compaction_summary → 二次压缩走迭代更新) + kind=m.get("_kind") or "", + db_msg_id=m.get("_db_msg_id") or "", + timestamp=int(m.get("timestamp") or 0))) + + elif role == "assistant": + # tool_calls 解析(容忍缺失/损坏) + tool_calls: List[ToolCall] = [] + for tc in (m.get("tool_calls") or []): + parsed = _parse_tool_call(tc) + if parsed is not None: + tool_calls.append(parsed) + out.append(AgentMessage( + role="assistant", + content=content if isinstance(content, str) else "", + reasoning=m.get("reasoning") or "", + tool_calls=tool_calls, + stop_reason="stop", + # 🆕 P1: 回放入库的 usage(锚点)+ timestamp(P0 时效校验) + usage=m.get("usage") or {}, + timestamp=int(m.get("timestamp") or 0), + db_msg_id=m.get("_db_msg_id") or "", + )) + + elif role == "tool": + # tool 结果 → toolResult(保留 tool_call_id 供 API 配对) + if content is None: + content = "" + elif not isinstance(content, str): + content = str(content) + out.append(AgentMessage( + role="toolResult", + content=content, + tool_call_id=m.get("tool_call_id") or "", + timestamp=int(m.get("timestamp") or 0), + db_msg_id=m.get("_db_msg_id") or "", + )) + + # 其他 role(system 等)防御性跳过 + return out diff --git a/core/agent/tools.py b/core/agent/tools.py new file mode 100644 index 0000000..66e7aaa --- /dev/null +++ b/core/agent/tools.py @@ -0,0 +1,865 @@ +""" +core/agent/tools.py +=================== +🌟 pi 工具执行管线的 Python 1:1 移植 + 内置工具 + +对照 pi-main 源码: + packages/agent/src/agent-loop.ts + - prepareToolCalls() (行 ~470): 校验参数 → 标记错误(不执行) + - executeTool() (行 ~520): before 钩子 → 执行 → after 钩子 → 结果定型 + - 并行语义: prepare 串行 → 执行并发(Promise.all) → 结果按原始顺序回写 + packages/agent/src/tools/*.ts (coding-agent 内置工具 read/bash/edit/write) + +JSON Schema 校验:pi 用 validate-json-schema + ai/src/utils/validation.ts +(structuredClone → normalizeOptionalNulls → Value.Convert → coerceWithJsonSchema → + 全量错误上报 + 回显收到的参数);这里实现核心子集 +(type/required/properties/enum/items + 可选字段 null 归一化 + 数字/布尔轻量转换), +零外部依赖。工具自定义预处理对照 pi 的 tool.prepareArguments(见 edit 的 legacy 兼容)。 +""" +from __future__ import annotations + +import json +import os +import queue +import re +import subprocess +import sys +import tempfile +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from .types import (AgentMessage, AgentTool, AgentToolResult, AbortSignal, + ToolCall, new_id) +from ..platform_shell import kill_process_tree, popen_flags, shell_command # P1-02 + +try: + from core.debug_log import debug_log as _dbg_log # 🆕 计时观察日志(线程安全/静默) +except Exception: # 导入失败也不影响工具执行 + def _dbg_log(msg, tag="APP"): + pass + + +# ====================================================================== +# JSON Schema 校验(子集) +# ====================================================================== +# ====================================================================== +# 参数归一化 + JSON Schema 校验(子集) +# 对照 pi ai/src/utils/validation.ts:317-347 validateToolArguments: +# structuredClone → normalizeOptionalNulls → Value.Convert +# → coerceWithJsonSchema → Check → 【报全部错误 + 回显收到的参数】 +# 这里实现核心子集(type/required/properties/enum/items),零外部依赖。 +# ====================================================================== +_TYPE_MAP = { + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "array": list, + "object": dict, +} + + +def _norm_types(schema: Dict[str, Any]) -> List[str]: + """schema 声明的类型(兼容 TypeBox 的 type 数组形式)""" + t = schema.get("type") + if isinstance(t, list): + return [x for x in t if isinstance(x, str)] + return [t] if isinstance(t, str) else [] + + +def _type_ok(value: Any, t: str) -> bool: + """类型匹配。🌟 bool 不算 integer/number(python 里 bool 是 int 子类)""" + if t == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if t == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if t == "boolean": + return isinstance(value, bool) + py = _TYPE_MAP.get(t) + return isinstance(value, py) if py else True + + +def _join_path(path: str, key: str) -> str: + return f"{path}.{key}" if path else key + + +def normalize_and_coerce(args: Any, schema: Dict[str, Any]) -> Any: + """归一化 + 轻量强制转换(对照 pi normalizeOptionalNulls + coerceWithJsonSchema) + + - 可选字段的显式 null → 删除该键(模型常对「未用到的参数」发 null) + - 数字字段收到纯数字字符串 → integer/number("30" → 30) + - boolean 字段收到 "true"/"false" → 布尔 + 返回新对象,不修改入参。 + """ + if not isinstance(schema, dict) or not isinstance(args, dict): + return args + props = schema.get("properties") or {} + required = set(schema.get("required") or []) + out: Dict[str, Any] = dict(args) + for key, val in list(out.items()): + sub = props.get(key) + if not isinstance(sub, dict): + continue + if val is None: + if key not in required: + del out[key] + continue + out[key] = _coerce_value(val, sub) + return out + + +def _coerce_value(val: Any, sub: Dict[str, Any]) -> Any: + types = _norm_types(sub) + if isinstance(val, bool) or val is None: + return val + if isinstance(val, str): + s = val.strip() + if s and ("integer" in types or "number" in types): + try: + f = float(s) + if f.is_integer(): + return int(f) + if "number" in types: + return f + return val # integer 字段收到 1.5 → 保持原值(交由校验报错) + except ValueError: + return val + if "boolean" in types and s.lower() in ("true", "false"): + return s.lower() == "true" + return val + if isinstance(val, dict): + return normalize_and_coerce(val, sub) + if isinstance(val, list): + items = sub.get("items") + if isinstance(items, dict): + return [_coerce_value(v, items) if not isinstance(v, dict) + else normalize_and_coerce(v, items) for v in val] + return val + + +def _type_error(path: str, types: List[str], value: Any) -> str: + t = types[0] if types else "object" + if t == "string": + return f"参数 {path} 应为 string" + if t in ("integer", "number") and isinstance(value, bool): + return f"参数 {path} 应为 {t}" + return f"参数 {path} 类型错误: 期望 {t}" + + +def _check_value(value: Any, schema: Dict[str, Any], path: str, + errs: List[str], root: bool = False) -> None: + """递归收集【全部】校验错误(对照 pi Errors() 全量上报)""" + if not isinstance(schema, dict): + return + if root and not isinstance(value, dict): + errs.append(f"参数必须是对象,实际是 {type(value).__name__}") + return + types = _norm_types(schema) or (["object"] if root else []) + if types and not any(_type_ok(value, t) for t in types): + errs.append(_type_error(path, types, value)) + return # 类型不符 → 后续检查无意义 + if "enum" in schema and value not in schema["enum"]: + errs.append(f"参数 {path} 取值必须是 {schema['enum']} 之一") + if isinstance(value, dict): + props = schema.get("properties") or {} + for req in schema.get("required") or []: + if req not in value: + errs.append(f"缺少必填参数: {req}" if root + else f"参数 {path} 缺少必填字段 {req}") + for k, v in value.items(): + if k in props: + _check_value(v, props[k], _join_path(path, k), errs) + elif isinstance(value, list): + items = schema.get("items") + if isinstance(items, dict): + for i, item in enumerate(value): + _check_value(item, items, f"{path}[{i}]", errs) + + +def validate_json_schema(args: Any, schema: Dict[str, Any]) -> Optional[str]: + """返回错误描述(多条以「; 」连接);合法返回 None。""" + errs: List[str] = [] + _check_value(args, schema, "", errs, root=True) + return "; ".join(errs) if errs else None + + +def _validate_value(value: Any, schema: Dict[str, Any], path: str) -> Optional[str]: + """(保留旧签名:返回该节点的首个错误)""" + errs: List[str] = [] + _check_value(value, schema, path, errs) + return errs[0] if errs else None + + +def _brief_json(obj: Any, limit: int = 600) -> str: + """参数回显(长内容截断,避免 write 的大 content 撑爆错误消息)""" + try: + s = json.dumps(obj, ensure_ascii=False) + except Exception: + s = repr(obj) + return s if len(s) <= limit else s[:limit] + f"…(共 {len(s)} 字符)" + + +# ====================================================================== +# 准备阶段 —— 对照 agent-loop.ts prepareToolCalls +# ====================================================================== +@dataclass +class PreparedToolCall: + """一次工具调用的完整准备结果(执行前定型)""" + tool_call: ToolCall + tool: Optional[AgentTool] + args: Dict[str, Any] + error: str = "" # 准备阶段失败原因(未知工具/参数非法)→ 直接返回错误 toolResult + + +def prepare_tool_calls(assistant: AgentMessage, + tools: List[AgentTool]) -> List[PreparedToolCall]: + tool_map = {t.name: t for t in tools} + prepared: List[PreparedToolCall] = [] + for tc in assistant.tool_calls: + tool = tool_map.get(tc.name) + if tool is None: + prepared.append(PreparedToolCall( + tool_call=tc, tool=None, args={}, + error=f"未知工具: {tc.name}", + )) + continue + if not isinstance(tc.arguments, dict): + prepared.append(PreparedToolCall( + tool_call=tc, tool=tool, args={}, + error="工具参数解析失败(JSON 不完整)", + )) + continue + # 🌟 工具自定义参数预处理(对照 pi tool.prepareArguments) + raw_args = tc.arguments + if tool.prepare_arguments is not None: + try: + raw_args = tool.prepare_arguments(raw_args) + except Exception as e: + prepared.append(PreparedToolCall( + tool_call=tc, tool=tool, args=tc.arguments, + error=f"参数预处理失败: {e}", + )) + continue + if not isinstance(raw_args, dict): + prepared.append(PreparedToolCall( + tool_call=tc, tool=tool, args=tc.arguments, + error="参数预处理返回的不是对象", + )) + continue + # 🌟 归一化 + 轻量强制转换(null 可选字段删除 / "30" → 30) + args = normalize_and_coerce(raw_args, tool.parameters) + err = validate_json_schema(args, tool.parameters) + if err: + prepared.append(PreparedToolCall( + tool_call=tc, tool=tool, args=args, + error=f"参数校验失败: {err};收到的参数: {_brief_json(raw_args)}", + )) + continue + prepared.append(PreparedToolCall(tool_call=tc, tool=tool, + args=args)) + return prepared + + +# ====================================================================== +# 执行阶段 —— 对照 agent-loop.ts executeTool +# before 钩子 → 执行 → after 钩子 → 异常兜底 +# ====================================================================== +def execute_tool_call(prepared: PreparedToolCall, + assistant: AgentMessage, + config, + signal: AbortSignal, + on_update: Optional[Callable[[str], None]], + on_timer: Optional[Callable[[int, int], None]] = None, + ) -> AgentToolResult: + """对照 agent-loop.ts executePreparedToolCall + prepareToolCall 的钩子语义 + 🆕 on_timer(elapsed_s, timeout_s):bash 运行中每秒滴一次(气泡读秒)""" + tc = prepared.tool_call + + # 准备阶段已失败 → 直接错误结果(pi 同款:不进入执行) + if prepared.error: + return AgentToolResult.text(prepared.error, is_error=True) + if signal.aborted: + return AgentToolResult.text("操作已中止 (Operation aborted)", is_error=True) + + # before 钩子(对照 pi: 可修改 args / 拒绝 block / 请求 terminate) + if config.before_tool_call: + try: + decision = config.before_tool_call( + {"assistant_message": assistant, "tool_call": tc, + "args": prepared.args, "context": config.tool_context}, + signal) + except Exception as e: + return AgentToolResult.text(f"before 钩子异常: {e}", is_error=True) + if signal.aborted: + return AgentToolResult.text("操作已中止 (Operation aborted)", + is_error=True) + if decision: + if decision.get("block"): + # 🐛 修复:旧代码向 AgentToolResult.text() 传了不存在的 terminate 参数 + # (钩子一旦 block 就抛 TypeError)—— 改为直接构造,保留 terminate 语义 + return AgentToolResult( + content=[{"type": "text", + "text": decision.get("reason") or "工具执行被拦截"}], + is_error=True, + terminate=bool(decision.get("terminate"))) + if decision.get("args") is not None: + # 🌟 对照 pi applyBeforeToolDecision:钩子改参后【重新校验】 + new_args = decision["args"] + if not isinstance(new_args, dict): + return AgentToolResult.text( + "before 钩子返回的参数不是对象", is_error=True) + new_args = normalize_and_coerce(new_args, prepared.tool.parameters) + _verr = validate_json_schema(new_args, prepared.tool.parameters) + if _verr: + return AgentToolResult.text( + f"before 钩子修改后的参数校验失败: {_verr};" + f"收到的参数: {_brief_json(decision['args'])}", + is_error=True) + prepared.args = new_args + + t0 = time.time() + try: + # 🆕 秒级滴答回调注入 ctx(不污染调用方的 tool_context 字典) + _ctx = dict(config.tool_context or {}) + if on_timer is not None: + _ctx["on_timer"] = on_timer + result = prepared.tool.execute( + tc.id, prepared.args, signal, on_update, _ctx, + ) + if not isinstance(result, AgentToolResult): + # 宽容处理:工具返回 str 也接受 + result = AgentToolResult.text(str(result)) + except Exception as e: + result = AgentToolResult.text(f"工具执行异常: {e}", is_error=True) + + # after 钩子 + if config.after_tool_call: + try: + config.after_tool_call(tc, result, result.is_error) + except Exception: + pass + + # 结果定型(对照 pi finalizeToolResult:确保 content 结构合法) + if not result.content: + result = AgentToolResult.text("(无输出)") + result.details = {"duration_ms": int((time.time() - t0) * 1000), + **(result.details or {} if isinstance(result.details, dict) else {})} + return result + + +# ====================================================================== +# 截断保护 —— 对照 agent-loop.ts failToolCallsFromTruncatedMessage +# 助手消息被 length/aborted 截断时,其工具调用参数可能残缺: +# 一律替换为错误 toolResult,绝不执行残缺调用。 +# ====================================================================== +def fail_tool_calls_from_truncated_message(assistant: AgentMessage, + reason: str = "aborted") -> List[AgentMessage]: + """为每个工具调用生成错误 toolResult 消息(对照 pi 返回值的组装)""" + msgs: List[AgentMessage] = [] + for tc in assistant.tool_calls: + # 对照 pi: "Tool call {name} was not executed: the response hit the + # output token limit before the arguments were complete..." + text = (f"工具调用 {tc.name} 未执行({reason}):" + f"响应在参数完整前达到输出长度上限,部分参数已被丢弃。" + f"请用完整参数重试该操作。") + msgs.append(AgentMessage( + role="toolResult", + tool_call_id=tc.id, + tool_name=tc.name, + content=text, + is_error=True, + )) + return msgs + + +# ====================================================================== +# 内置工具 —— 对照 pi coding-agent 的 read / bash / write / edit +# ====================================================================== +_MAX_READ_LINES = 2000 +_MAX_OUTPUT_BYTES = 50 * 1024 + + +def _resolve_path(path: str, ctx: Dict[str, Any]) -> str: + """相对路径基于 tool_context 的 cwd(默认项目根)""" + if os.path.isabs(path): + return os.path.abspath(path) + cwd = ctx.get("cwd", os.getcwd()) + return os.path.abspath(os.path.join(cwd, path)) + + +def tool_read(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal, + on_update, ctx: Dict[str, Any]) -> AgentToolResult: + """read: 读取文本文件(支持 offset/limit 行窗口),带行号输出""" + if not args.get("path"): + return AgentToolResult.text("path 不能为空", is_error=True) + path = _resolve_path(args["path"], ctx) + if not os.path.isfile(path): + return AgentToolResult.text(f"文件不存在: {path}", is_error=True) + try: + offset = max(1, int(args.get("offset", 1))) + except (TypeError, ValueError): + return AgentToolResult.text("offset 必须是整数", is_error=True) + try: + # 🌟 limit 必须有下限:负数会被 python 负索引语义吃掉 + # (旧版 limit=-5 → lines[0:-5] → 除末尾 5 行外全部返回,与直觉完全相反) + limit = max(1, min(int(args.get("limit", 2000)), _MAX_READ_LINES)) + except (TypeError, ValueError): + return AgentToolResult.text("limit 必须是整数", is_error=True) + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + except Exception as e: + return AgentToolResult.text(f"读取失败: {e}", is_error=True) + total = len(lines) + if total == 0: + return AgentToolResult.text("[文件为空(0 行)]") + if offset > total: + return AgentToolResult.text( + f"[起始行 offset={offset} 超出文件范围,该文件共 {total} 行]") + chunk = lines[offset - 1: offset - 1 + limit] + numbered = "".join( + f"{i + offset:6d}\t{line}" for i, line in enumerate(chunk) + ) + shown_hi = offset - 1 + len(chunk) + footer = f"\n[已显示 {offset}–{shown_hi} 行,共 {total} 行]" + if shown_hi < total: + footer += f"(还有 {total - shown_hi} 行未显示,用 offset={shown_hi + 1} 继续)" + out = numbered + footer + if len(out.encode("utf-8")) > _MAX_OUTPUT_BYTES: + out = out.encode("utf-8")[:_MAX_OUTPUT_BYTES].decode("utf-8", "ignore") + out += "\n[输出超过 50KB 已截断]" + return AgentToolResult.text(out) + + +def tool_bash(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal, + on_update, ctx: Dict[str, Any]) -> AgentToolResult: + """bash: 执行 shell 命令(默认 120s 超时)。对照 pi: bash 是 sequential 工具 + + 🆕 秒级滴答:Popen + communicate(timeout=1) 循环,每秒: + ① 日志 [timer] bash 读秒 N/Ts + ② on_timer(N, T) 推前端气泡读秒 + ③ 到期杀进程树(Windows taskkill /T,连孤儿子进程一起杀)→ 真超时 + """ + if signal.aborted: + return AgentToolResult.text("操作已中止 (Operation aborted)", is_error=True) + command = args.get("command", "") + if not command.strip(): + return AgentToolResult.text("command 不能为空", is_error=True) + timeout = min(float(args.get("timeout", 120)), 600) + cwd = ctx.get("cwd") or os.getcwd() + # P1-02:显式 shell 契约(Windows cmd.exe /d /c;Linux /bin/bash -lc), + # 不再依赖 shell=True 的平台默认值;ctx 的 "shell" 键已废弃(bash 工具即 shell 工具) + on_timer = ctx.get("on_timer") + t0 = time.time() + # 🆕 计时观察①:计时器启动时刻 + 模型实际传的 timeout 值 + _dbg_log(f"[timer] bash 开始 timeout={timeout:.0f}s " + f"(显式={args.get('timeout')}) cmd={command[:80]!r}") + + def _tick(elapsed_i: int): + """每秒一次:日志 + 推前端(静默吞异常,绝不影响执行)""" + _dbg_log(f"[timer] bash 读秒 {elapsed_i}/{int(timeout)}s") + try: + if on_timer: + on_timer(elapsed_i, int(timeout)) + except Exception: + pass + + def _kill_tree(proc): + """杀整个进程树:Windows taskkill /F /T;Linux 独立进程组 + SIGTERM → 宽限 → SIGKILL(见 core.platform_shell.kill_process_tree)。""" + kill_process_tree(proc) + + # 🐛 修复:text=True 不带 encoding 时按系统码页(中文 Windows=GBK)解码, + # 子进程输出 UTF-8(python/git/中文 echo)→ _readerthread UnicodeDecodeError。 + # 强制 UTF-8 + 容错替换;PYTHONIOENCODING 让 python 子进程也按 UTF-8 输出。 + _env = dict(os.environ, PYTHONIOENCODING="utf-8") + try: + proc = subprocess.Popen( + shell_command(command), # P1-02:显式 shell 命令 + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", errors="replace", + cwd=cwd, env=_env, + **popen_flags(), # P1-02:Linux 独立进程组(start_new_session) + ) + except Exception as e: + return AgentToolResult.text(f"执行失败: {e}", is_error=True) + + timed_out = False + out_parts: List[str] = [] + err_parts: List[str] = [] + err_header_fed = False + + # 🆕 实时输出:stdout/stderr 各起一个 reader 线程 → 队列, + # 主循环(仍在 worker 线程内)抽干队列并回调 on_update。 + # 绝不从 reader 线程直接回调 —— UI 侧 _on_tool_updated 会改 timeline,跨线程不安全。 + _q: "queue.Queue" = queue.Queue() + + def _reader(stream, tag): + try: + for line in iter(stream.readline, ""): + _q.put((tag, line)) + except Exception: + pass + finally: + try: + stream.close() + except Exception: + pass + + _t_out = threading.Thread(target=_reader, args=(proc.stdout, "out"), daemon=True) + _t_err = threading.Thread(target=_reader, args=(proc.stderr, "err"), daemon=True) + _t_out.start() + _t_err.start() + + def _feed(tag, chunk): + """抽到一块输出:累积 + 推实时流(只在 worker 线程内调用)""" + nonlocal err_header_fed + piece = chunk + if tag == "err": + err_parts.append(chunk) + if not err_header_fed: + err_header_fed = True + piece = "[stderr]\n" + chunk + else: + out_parts.append(chunk) + try: + if on_update and piece: + on_update(piece) + except Exception: + pass + + def _drain(): + """抽干队列里已到达的输出(单轮上限防极端刷屏卡死)""" + for _ in range(500): + try: + tag, chunk = _q.get_nowait() + except queue.Empty: + return + _feed(tag, chunk) + + last_tick = 0 # 首次滴答仍在 elapsed=1(与旧 communicate 实现一致,不在 0 秒多滴一次) + try: + while True: + # ① 最多 0.2s 醒一次 → 把新输出实时推给前端 + try: + tag, chunk = _q.get(timeout=0.2) + _feed(tag, chunk) + except queue.Empty: + pass + _drain() + + # ② 进程已退出:等 reader 读完管道残余 → 收尾 + if proc.poll() is not None: + _t_out.join(timeout=1.0) + _t_err.join(timeout=1.0) + _drain() + break + + # ③ 每秒读秒 + 中止/超时判定(原语义不变) + elapsed_i = int(time.time() - t0) + if elapsed_i > last_tick: + last_tick = elapsed_i + if signal.aborted: + _kill_tree(proc) + _t_out.join(timeout=1.0) + _t_err.join(timeout=1.0) + _drain() + return AgentToolResult.text( + "操作已中止 (Operation aborted)", is_error=True) + _tick(elapsed_i) + if time.time() - t0 >= timeout: + # 到期 → 真杀(进程树) + timed_out = True + _kill_tree(proc) + try: + proc.wait(timeout=15) + except Exception: + pass + _t_out.join(timeout=5.0) + _t_err.join(timeout=5.0) + _drain() + break + except Exception as e: + try: + _kill_tree(proc) + except Exception: + pass + return AgentToolResult.text(f"执行失败: {e}", is_error=True) + + out = "".join(out_parts) + err = "".join(err_parts) + code = proc.returncode if proc.returncode is not None else -1 + + dur = time.time() - t0 + if timed_out: + # 🆕 计时观察②:计时器到期 + _dbg_log(f"[timer] bash 超时触发 设定={timeout:.0f}s " + f"实际={dur:.1f}s cmd={command[:80]!r}") + return AgentToolResult.text(f"命令超时(>{timeout:.0f}s)已终止", is_error=True) + # 🆕 计时观察③:正常结束 + 实际耗时 + _dbg_log(f"[timer] bash 正常结束 dur={dur:.1f}s " + f"exit={code} 设定timeout={timeout:.0f}s") + result = f"$ {command}\n" + if out: + result += out if out.endswith("\n") else out + "\n" + if err: + result += f"[stderr]\n{err}" + result += f"\n[exit {code}] ({dur:.1f}s)" + if len(result.encode("utf-8")) > _MAX_OUTPUT_BYTES: + result = result.encode("utf-8")[:_MAX_OUTPUT_BYTES].decode("utf-8", "ignore") + result += "\n[输出超过 50KB 已截断]" + return AgentToolResult.text(result, is_error=code != 0, + details={"exit_code": code}) + + +def _atomic_write(path: str, content: str) -> None: + """原子写:同目录临时文件 + os.replace(避免半截文件) + + 保留原有 newline 语义(默认 None → 平台换行翻译),仅增加原子性。 + """ + d = os.path.dirname(os.path.abspath(path)) or "." + fd, tmp = tempfile.mkstemp(dir=d, prefix=".hocode_w_", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp, path) # Windows/POSIX 均为原子替换 + except BaseException: + try: + os.remove(tmp) + except Exception: + pass + raise + + +def tool_write(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal, + on_update, ctx: Dict[str, Any]) -> AgentToolResult: + """write: 创建/覆盖文件(自动建父目录)""" + if not args.get("path"): + return AgentToolResult.text("path 不能为空", is_error=True) + path = _resolve_path(args["path"], ctx) + content = args.get("content", "") + if content is None: + content = "" + if not isinstance(content, str): + content = str(content) + try: + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + _atomic_write(path, content) + except Exception as e: + return AgentToolResult.text(f"写入失败: {e}", is_error=True) + return AgentToolResult.text(f"已写入 {len(content)} 字符 → {path}") + + +def _prepare_edit_args(args: Any) -> Any: + """归一化 edit 参数(对照 pi edit.ts:56-70 prepareEditArguments) + + - edits 为 JSON 字符串 → 解析为数组 + - edits 内条目为 JSON 字符串 → 解析为对象 + - 兼容 legacy 单条形式 {path, oldText, newText} → 包装为 edits:[{...}] + """ + if not isinstance(args, dict): + return args + out = dict(args) + edits = out.get("edits") + if isinstance(edits, str): + try: + parsed = json.loads(edits) + if isinstance(parsed, list): + out["edits"] = parsed + elif isinstance(parsed, dict): + out["edits"] = [parsed] + except Exception: + pass # 解析不了就交给校验层报错 + if out.get("edits") is None and ("oldText" in out or "newText" in out): + ed: Dict[str, Any] = {} + if "oldText" in out: + ed["oldText"] = out["oldText"] + if "newText" in out: + ed["newText"] = out["newText"] + out = {"path": out.get("path"), "edits": [ed]} + if isinstance(out.get("edits"), list): + norm = [] + for it in out["edits"]: + if isinstance(it, str): + try: + it = json.loads(it) + except Exception: + pass + norm.append(it) + out["edits"] = norm + return out + + +def _plan_edits(content: str, edits: List[Any]): + """对【原始内容】定位每条 oldText(要求恰好 1 次)+ 区间重叠检测 + + 对照 pi edit-diff.ts:348「edits[i] and edits[j] overlap … Merge them into one edit」。 + 返回 (错误信息, [(start, end, index, new_text), ...] 已按 start 排序) + """ + spans: List[tuple] = [] + for i, ed in enumerate(edits): + if not isinstance(ed, dict): + return f"第 {i + 1} 条 edit 不是对象", None + old = ed.get("oldText", "") + if not isinstance(old, str): + return f"第 {i + 1} 条 edit 的 oldText 必须是字符串", None + if old == "": + return f"第 {i + 1} 条 edit 的 oldText 不能为空", None + c = content.count(old) + if c == 0: + return f"第 {i + 1} 条 edit 未找到匹配文本(oldText 不存在或已变化)", None + if c > 1: + return f"第 {i + 1} 条 edit 匹配到 {c} 处(要求唯一),请提供更长的上下文", None + start = content.index(old) + spans.append((start, start + len(old), i, ed.get("newText", ""))) + ordered = sorted(spans) + for a, b in zip(ordered, ordered[1:]): + if b[0] < a[1]: + return (f"edits[{a[2]}] 与 edits[{b[2]}] 区域重叠," + f"请合并为一条 edit 或改为互不相交的修改"), None + return None, ordered + + +def tool_edit(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal, + on_update, ctx: Dict[str, Any]) -> AgentToolResult: + """edit: 精确文本替换(edits: [{oldText, newText}]) + + 🌟 语义对齐 pi:全部 edit 都对【原始文件内容】定位,要求各自唯一且区间互不重叠, + 然后按偏移一次性重建(不是逐条 replace 的增量语义)。 + """ + if not args.get("path"): + return AgentToolResult.text("path 不能为空", is_error=True) + path = _resolve_path(args["path"], ctx) + if not os.path.isfile(path): + return AgentToolResult.text(f"文件不存在: {path}", is_error=True) + edits = args.get("edits", []) + if not isinstance(edits, list) or not edits: + return AgentToolResult.text("edits 不能为空", is_error=True) + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + except Exception as e: + return AgentToolResult.text(f"读取失败: {e}", is_error=True) + err, ordered = _plan_edits(content, edits) + if err: + return AgentToolResult.text(err, is_error=True) + # 按偏移从后往前替换(前面的偏移不受影响) + out = content + for start, end, _i, new_text in reversed(ordered): + out = out[:start] + (new_text if isinstance(new_text, str) else str(new_text)) + out[end:] + try: + _atomic_write(path, out) + except Exception as e: + return AgentToolResult.text(f"编辑失败: {e}", is_error=True) + return AgentToolResult.text(f"已应用 {len(ordered)} 处编辑 → {path}") + + +# ====================================================================== +# 默认工具集 —— 对照 pi 默认启用 read/bash/edit/write +# ====================================================================== + +_TEXT_TOOL_RE = re.compile(r"<(bash|read)>(.*?)", re.DOTALL) + + +def parse_text_tool_calls(content: str): + """ + 兜底解析(haocode 扩展,pi 无此层): + 对不支持 tools API 的供应商/模型——它们会把工具调用用纯文字"演"出来 + (例如 ls路径),本函数识别单参数工具 + bash / read 并转成真 ToolCall 供循环执行。 + write / edit 参数多、文本歧义大,不做兜底(保持安全)。 + 返回 (原文, [ToolCall]);未命中时 calls 为空列表。 + """ + if not content or "<" not in content: + return content, [] + calls: List[ToolCall] = [] + + def _sub(m): + name, payload = m.group(1), m.group(2).strip() + if not payload: + return m.group(0) + if name == "bash": + calls.append(ToolCall(id=new_id("txtcall"), name="bash", + arguments={"command": payload})) + elif name == "read": + calls.append(ToolCall(id=new_id("txtcall"), name="read", + arguments={"path": payload})) + return m.group(0) # 保留原文(UI 已渲染,不回改;执行由 tool_calls 驱动) + + cleaned = _TEXT_TOOL_RE.sub(_sub, content) + return cleaned, calls + +def default_tools() -> List[AgentTool]: + return [ + AgentTool( + name="read", label="读取文件", + description="读取文本文件内容(带行号)。支持 offset/limit 按行窗口读取大文件。", + parameters={ + "type": "object", + "properties": { + "path": {"type": "string", "description": "文件路径"}, + "offset": {"type": "integer", "description": "起始行号(从 1 开始)"}, + "limit": {"type": "integer", "description": "最多读取行数(默认 2000)"}, + }, + "required": ["path"], + }, + execute=tool_read, + ), + AgentTool( + name="bash", label="执行命令", + description="执行 shell 命令并返回 stdout/stderr/退出码。默认 120 秒超时。", + parameters={ + "type": "object", + "properties": { + "command": {"type": "string", "description": "要执行的命令"}, + "timeout": {"type": "number", "description": "超时秒数(默认 120,最大 600)"}, + }, + "required": ["command"], + }, + execute=tool_bash, + execution_mode="sequential", # 对照 pi: bash 整批串行 + ), + AgentTool( + name="write", label="写入文件", + description="创建或覆盖写入文件(自动创建父目录)。", + parameters={ + "type": "object", + "properties": { + "path": {"type": "string", "description": "文件路径"}, + "content": {"type": "string", "description": "文件内容"}, + }, + "required": ["path", "content"], + }, + execute=tool_write, + ), + AgentTool( + name="edit", label="编辑文件", + description=("对文件做精确文本替换。edits 中每条 oldText 必须在原文件中唯一," + "且各条区间不得重叠(重叠请合并为一条)。"), + parameters={ + "type": "object", + "properties": { + "path": {"type": "string", "description": "文件路径"}, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldText": {"type": "string"}, + "newText": {"type": "string"}, + }, + "required": ["oldText"], + }, + "description": "替换操作列表", + }, + }, + "required": ["path", "edits"], + }, + execute=tool_edit, + prepare_arguments=_prepare_edit_args, + ), + ] diff --git a/core/agent/types.py b/core/agent/types.py new file mode 100644 index 0000000..10e630f --- /dev/null +++ b/core/agent/types.py @@ -0,0 +1,331 @@ +""" +core/agent/types.py +==================== +🌟 pi agent-core 的 Python 1:1 移植 —— 数据模型层 + +对照 pi-main 源码: + packages/agent/src/types.ts (443 行) + - AgentMessage 联合类型 (user / assistant / toolResult) + - AgentEvent 11 种事件 + - AgentTool / AgentToolCall / AgentToolResult + - AgentState / AgentConfig / AgentError + +设计原则(与 pi 完全一致): + 1. 消息是不可变事实(append-only),循环只追加、不修改 + 2. 事件是唯一对外输出(TUI/Qt 都只是事件订阅者) + 3. 工具 = (name, description, JSON-Schema 参数, execute 函数) + 4. 中止用 AbortSignal 标志位,不用异常控制流 +""" +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def new_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:16]}" + + +# ====================================================================== +# 中止信号 —— 对照 pi: AbortController/AbortSignal(Web 标准) +# pi 里 streamSimple 每消费一个 chunk 检查 signal.aborted +# ====================================================================== +class AbortSignal: + def __init__(self): + self.aborted: bool = False + self.reason: str = "" + + def abort(self, reason: str = "aborted"): + self.aborted = True + self.reason = reason + + +# ====================================================================== +# 消息模型 —— 对照 types.ts 的 AgentMessage 联合类型 +# pi: type AgentMessage = AgentUserMessage | AgentAssistantMessage | AgentToolResultMessage +# ====================================================================== +@dataclass +class ToolCall: + """pi: AssistantMessage 内容块里的 {type:"toolCall", id, name, arguments}""" + id: str + name: str + arguments: Dict[str, Any] = field(default_factory=dict) + # 流式阶段累积的原始 JSON 字符串(参数被截断时可能是残缺 JSON) + raw_arguments: str = "" + + +@dataclass +class AgentMessage: + """ + 统一消息容器。role 决定哪些字段有效: + user -> content: str 或 OpenAI 多模态 list + assistant -> content(str 正文) + reasoning(思考) + tool_calls + stop_reason + usage + toolResult -> tool_call_id + tool_name + content + is_error + 与 pi 的三种 Message 类型字段一一对应。 + """ + role: str # "user" | "assistant" | "toolResult" + content: Any = "" # str | List[dict] + reasoning: str = "" # pi: thinking block(vLLM: reasoning_content) + tool_calls: List[ToolCall] = field(default_factory=list) + tool_call_id: str = "" # toolResult 专用 + tool_name: str = "" # toolResult 专用 + is_error: bool = False # toolResult 专用 + stop_reason: str = "" # assistant 专用: stop|length|aborted|error + error_message: str = "" # assistant 出错时的说明 + usage: Dict[str, Any] = field(default_factory=dict) # {input, output, cacheRead...} + kind: str = "" # "" | "compaction_summary"(对照 pi compaction 条目) + db_msg_id: str = "" # 🆕 压缩持久化:对应 DB 行的 id(build_api_context 注入,切点计算用) + timestamp: int = field(default_factory=_now_ms) + id: str = field(default_factory=lambda: new_id("msg")) + + def to_dict(self) -> Dict[str, Any]: + return { + "role": self.role, "content": self.content, "reasoning": self.reasoning, + "tool_calls": [ + {"id": tc.id, "name": tc.name, "arguments": tc.arguments, + "raw_arguments": tc.raw_arguments} + for tc in self.tool_calls + ], + "tool_call_id": self.tool_call_id, "tool_name": self.tool_name, + "is_error": self.is_error, "stop_reason": self.stop_reason, + "error_message": self.error_message, "usage": self.usage, + "kind": self.kind, + "db_msg_id": self.db_msg_id, + "timestamp": self.timestamp, "id": self.id, + } + + @staticmethod + def from_dict(d: Dict[str, Any]) -> "AgentMessage": + return AgentMessage( + role=d.get("role", "user"), content=d.get("content", ""), + reasoning=d.get("reasoning", ""), + tool_calls=[ToolCall(id=t.get("id", ""), name=t.get("name", ""), + arguments=t.get("arguments", {}), + raw_arguments=t.get("raw_arguments", "")) + for t in d.get("tool_calls", [])], + tool_call_id=d.get("tool_call_id", ""), tool_name=d.get("tool_name", ""), + is_error=d.get("is_error", False), stop_reason=d.get("stop_reason", ""), + error_message=d.get("error_message", ""), usage=d.get("usage", {}), + kind=d.get("kind", ""), + db_msg_id=d.get("db_msg_id", ""), + timestamp=d.get("timestamp", 0), id=d.get("id") or new_id("msg"), + ) + + +# ====================================================================== +# 助手消息的流式增量事件 —— 对照 pi: AssistantMessageEvent +# {type:"text_delta", textDelta} | {type:"thinking_delta", ...} +# | {type:"toolcall_delta", ...} | {type:"done"} | {type:"error"} +# ====================================================================== +@dataclass +class AssistantMessageEvent: + type: str # "text_delta" | "thinking_delta" | "toolcall_delta" | "done" | "error" + text: str = "" # text_delta / thinking_delta 的增量 + tool_call_index: int = -1 # toolcall_delta 属于第几个 toolCall + tool_call_delta: str = "" # toolcall_delta 的原始增量片段 + tool_call_field: str = "arguments" # 片段属于 id | name | arguments 哪一段 + error_message: str = "" # error 时的说明 + + @staticmethod + def text_delta(text: str) -> "AssistantMessageEvent": + return AssistantMessageEvent(type="text_delta", text=text) + + @staticmethod + def thinking_delta(text: str) -> "AssistantMessageEvent": + return AssistantMessageEvent(type="thinking_delta", text=text) + + @staticmethod + def toolcall_delta(index: int, delta: str, + field: str = "arguments") -> "AssistantMessageEvent": + return AssistantMessageEvent(type="toolcall_delta", tool_call_index=index, + tool_call_delta=delta, tool_call_field=field) + + @staticmethod + def done() -> "AssistantMessageEvent": + return AssistantMessageEvent(type="done") + + @staticmethod + def error(message: str) -> "AssistantMessageEvent": + return AssistantMessageEvent(type="error", error_message=message) + + +# ====================================================================== +# Agent 事件(11 种)—— 对照 types.ts 的 AgentEvent 联合类型 +# ====================================================================== +EVENT_TYPES = ( + "agent_start", "agent_end", + "turn_start", "turn_end", + "message_start", "message_update", "message_end", + "tool_execution_start", "tool_execution_update", "tool_execution_end", +) + + +@dataclass +class AgentEvent: + """ + 事件容器。字段随 type 变化: + agent_end -> stop_reason, error + message_start/end -> message (AgentMessage) + message_update -> message(当前累积快照), assistant_message_event + tool_execution_start -> tool_call, tool_name + tool_execution_update -> tool_call, arg(增量) + tool_execution_timer -> tool_call, arg=(已耗秒, 超时秒) 🆕 bash 读秒 + tool_execution_end -> tool_call, result, is_error + """ + type: str + message: Optional[AgentMessage] = None + assistant_message_event: Optional[AssistantMessageEvent] = None + tool_call: Optional[ToolCall] = None + arg: str = "" + result: Optional["AgentToolResult"] = None + is_error: bool = False + stop_reason: str = "" + error: Optional["AgentError"] = None + messages: List[AgentMessage] = field(default_factory=list) # agent_end 携带的本轮新消息 + + +# ====================================================================== +# 工具 —— 对照 types.ts: AgentTool / AgentToolCall / AgentToolResult +# ====================================================================== +@dataclass +class AgentToolResult: + """pi: {content: OutputContent[], details?, isError, terminate?}""" + content: List[Dict[str, Any]] = field(default_factory=list) # [{type:"text", text}] + details: Any = None + is_error: bool = False + terminate: bool = False # 对照 pi: 工具可请求终止整个批次/循环 + + @staticmethod + def text(text: str, is_error: bool = False, details: Any = None) -> "AgentToolResult": + return AgentToolResult(content=[{"type": "text", "text": text}], + details=details, is_error=is_error) + + def as_text(self) -> str: + return "".join(c.get("text", "") for c in self.content if c.get("type") == "text") + + +@dataclass +class AgentTool: + """ + pi: interface AgentTool { name; description; parameters(JSON Schema); + execute(toolCallId, args, signal, onUpdate, context) => Promise } + Python 版 execute 签名完全一致(同步执行,循环里用线程池并发)。 + """ + name: str + description: str + parameters: Dict[str, Any] # JSON Schema + execute: Callable # (tool_call_id, args, signal, on_update, context) -> AgentToolResult + label: str = "" # UI 展示用短标签 + # 对照 pi types.ts: executionMode —— "sequential" 工具会把整个批次拉回串行执行 + execution_mode: str = "parallel" + # 🌟 对照 pi AgentHarnessTool.prepareArguments:校验前的确定性参数预处理 + # (如 edit 兼容 legacy 单条形式 / edits 为 JSON 字符串),签名 (args) -> args + prepare_arguments: Optional[Callable] = None + + +# ====================================================================== +# 运行时状态 / 配置 —— 对照 types.ts: AgentState / AgentConfig +# ====================================================================== +@dataclass +class AgentState: + messages: List[AgentMessage] = field(default_factory=list) + is_streaming: bool = False + streaming_message: Optional[AgentMessage] = None # 正在流式生成的助手消息 + streaming_delta: Dict[str, str] = field(default_factory=dict) # 各通道当前增量缓冲 + error: Optional["AgentError"] = None + + +@dataclass +class RetryConfig: + """对照 pi settings-manager.ts retry 默认值(行 32-33): + maxRetries=3, baseDelayMs=2000,指数退避 2s → 4s → 8s(base × 2^(n-1)) + max_delay_ms=0 表示不设上限(pi _prepareRetry 无封顶)""" + max_attempts: int = 3 + base_delay_ms: int = 2000 + factor: float = 2.0 + max_delay_ms: int = 0 # 0 = 不封顶 + + +@dataclass +class ModelConfig: + """ + 对照 pi models.json 里的模型定义。 + context_window = pi 的 contextWindow;max_tokens = pi 的 maxTokens。 + """ + provider: str = "" + name: str = "" + context_window: int = 128000 + max_tokens: int = 8192 + # 请求参数 + temperature: float = 0.7 + timeout_seconds: float = 180.0 + api_key: str = "" + base_url: str = "https://api.openai.com/v1" + # 🆕 thinking 模式(DeepSeek 等):assistant 消息必须把 reasoning_content + # 原样回传,否则 400。默认开启(只影响有 reasoning 的轮中消息; + # DB 回放行 reasoning="" 不带字段 → 全新请求体不变)。 + pass_reasoning: bool = True + + +@dataclass +class AgentConfig: + """ + 对照 types.ts: AgentConfig + model / tools / systemPrompt / maxTurns / steeringMode / followUpMode + + 6 个循环钩子(transformContext / beforeToolCall / afterToolCall / + shouldStopAfterTurn / prepareNextTurn) + """ + model: ModelConfig + system_prompt: str = "" + tools: List[AgentTool] = field(default_factory=list) + max_turns: int = 50 # pi 默认 50 + steering_mode: str = "one-at-a-time" # pi 默认 "one-at-a-time"("all" 一次性全注入) + follow_up_mode: str = "one-at-a-time" + # 对照 pi types.ts ToolExecutionMode: 批次级执行模式(有 sequential 工具自动转串行) + tool_execution: str = "parallel" # "sequential" | "parallel" + retry: RetryConfig = field(default_factory=RetryConfig) + # 压缩(🌟 1:1 对照 pi DEFAULT_COMPACTION_SETTINGS) + compaction_reserve: int = 16384 # reserveTokens: 摘要提示词与输出预留 + compaction_keep_recent: int = 20000 # keepRecentTokens: 压缩后保留的近期上下文预算 + # 钩子(None = 无钩子,对照 pi 的可选字段) + transform_context: Optional[Callable] = None # (messages) -> messages + before_tool_call: Optional[Callable] = None # (tool_call, args) -> 可修改/拒绝 + after_tool_call: Optional[Callable] = None # (tool_call, result, is_error) -> None + should_stop_after_turn: Optional[Callable] = None # (messages) -> bool + prepare_next_turn: Optional[Callable] = None # (context) -> context + # 🆕 haocode 增强(偏离 pi 1:1):轮中请求前的主动压缩检查 + # (messages) -> messages|None:返回新列表 = 发生了压缩(循环需同步上下文); + # None = 不需要/不可压缩(原样发请求,响应式安全网仍在) + compact_fn: Optional[Callable] = None # (messages) -> messages|None + # 运行上下文(透传给 tool.execute,如工作目录) + tool_context: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class AgentError(Exception): + """对照 pi 的 AgentError —— 携带分类信息供恢复逻辑判定""" + message: str = "" + # 分类: "rate_limit" | "overload" | "server_error" | "timeout" | + # "connection" | "auth" | "unknown" + kind: str = "unknown" + status_code: Optional[int] = None + recoverable: bool = False # 恢复逻辑判定后的标记 + + def __str__(self): + return self.message or "AgentError" + + +# ====================================================================== +# 运行结果 —— 对照 agent-session.ts 里对 agent_end 事件的读取 +# ====================================================================== +@dataclass +class RunResult: + stop_reason: str = "stop" # stop | length | aborted | error + error: Optional[AgentError] = None + message_count: int = 0 diff --git a/core/config_paths.py b/core/config_paths.py new file mode 100644 index 0000000..f745429 --- /dev/null +++ b/core/config_paths.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +"""core/config_paths.py —— 统一的配置文件路径解析与容错加载(P0-01 新增) + +所有配置读取入口必须经过本模块,不得在别处直接定位 data/config.json: + + 1. 环境变量 HAOCODE_CONFIG_FILE 优先级最高(自动化测试用它指向临时文件); + 2. 未设置环境变量时才回落到项目内 data/config.json(锚定 core/ 目录,不受 CWD 影响)。 + +行为约定(REPAIR_BACKLOG P0-01 硬约束): + · 路径解析与加载过程只在出错时输出可见警告(只含路径与错误类型,不含配置内容); + · 缺失 / 损坏 / 非 JSON 对象 → 返回安全空 dict 继续,不抛异常、不阻断 + 不需要该配置的源码路径; + · import 本模块时不打印、不复制、不迁移任何配置内容。 +""" +import json +import os + +ENV_KEY = "HAOCODE_CONFIG_FILE" + +_DEFAULT_CONFIG_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "data", "config.json") + + +def config_path() -> str: + """返回当前生效的配置文件路径(环境变量优先,调用时解析)。""" + p = os.environ.get(ENV_KEY, "").strip() + return p if p else _DEFAULT_CONFIG_PATH + + +def load_config() -> dict: + """容错加载配置。缺失/损坏/非对象 → 可见警告 + 安全空 dict(绝不抛异常)。""" + p = config_path() + if not os.path.exists(p): + print(f"[config] 配置文件缺失: {p} → 使用安全默认值(providers 为空)继续启动") + return {} + try: + with open(p, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + print(f"[config] 配置文件读取/解析失败: {p}({type(e).__name__})" + f" → 使用安全默认值继续启动") + return {} + if not isinstance(data, dict): + print(f"[config] 配置文件内容不是 JSON 对象: {p}" + f" → 使用安全默认值继续启动") + return {} + return data + + +# ---------------------------------------------------------------------- +# P1-01:渲染窗口配置解析(render_window_mode / render_window_size) +# 规则(与 ui/web/render_window.js 的 JS 侧守卫保持一致): +# size:只接受非布尔整数 10..200,缺失/布尔/字符串/小数/零/负数/越界 → 静默回落 40; +# mode:只接受 "auto"/"manual",否则回落 "auto"。 +# ---------------------------------------------------------------------- +DEFAULT_RENDER_WINDOW_SIZE = 40 +ALLOWED_RENDER_WINDOW_SIZES = (10, 40, 200) + + +def render_window_settings(cfg: dict | None = None) -> dict: + """解析渲染窗口配置。cfg 缺省时读取当前生效配置。永不抛异常。""" + if cfg is None: + cfg = load_config() + if not isinstance(cfg, dict): + cfg = {} + + raw_size = cfg.get("render_window_size", None) + if isinstance(raw_size, bool) or not isinstance(raw_size, int) \ + or raw_size < 10 or raw_size > 200: + size = DEFAULT_RENDER_WINDOW_SIZE + else: + size = raw_size + + raw_mode = cfg.get("render_window_mode", None) + mode = raw_mode if raw_mode in ("auto", "manual") else "auto" + + return {"mode": mode, "size": size} diff --git a/core/db_manager.py b/core/db_manager.py new file mode 100644 index 0000000..0541538 --- /dev/null +++ b/core/db_manager.py @@ -0,0 +1,715 @@ +import sqlite3 +import os +import sys +import re +import json +import uuid +import time +import shutil +from typing import List, Dict, Optional + +# 🝙 P0 fix: DB path based on the file's location (no longer depends on the process's working directory) +# 🌟 打包(PyInstaller onedir)时:优先与源码树共用 data/chat_history.db(历史不丢失); +# 若 exe 被复制到源码树之外 → 回退为 exe 旁的 data/chat_history.db +if getattr(sys, "frozen", False): + _exe_dir = os.path.dirname(os.path.abspath(sys.executable)) + _shared = os.path.abspath(os.path.join(_exe_dir, "..", "..", "data", "chat_history.db")) + if os.path.isdir(os.path.dirname(_shared)): + _DEFAULT_DB = _shared + else: + _DEFAULT_DB = os.path.join(_exe_dir, "data", "chat_history.db") +else: + _DEFAULT_DB = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "data", "chat_history.db") + + +class _AutoCloseConn: + """🝙 P0 fix: wraps sqlite3.Connection. + + sqlite3.Connection's with only handles commit/rollback and does NOT close. + All 19 call sites use `with self.get_connection() as conn:`, so we auto-close on with exit. + """ + + def __init__(self, conn): + self._conn = conn + + def __getattr__(self, name): + return getattr(self._conn, name) + + def __enter__(self): + self._conn.__enter__() + return self + + def __exit__(self, exc_type, exc, tb): + try: + return self._conn.__exit__(exc_type, exc, tb) + finally: + try: + self._conn.close() + except Exception: + pass + + +class DBManager: + def __init__(self, db_path: str = None): + # 🝙 Default path is anchored to the core/ directory, not affected by launch CWD + self.db_path = db_path or os.path.abspath(_DEFAULT_DB) + + # 🆕 附件/媒体文件根目录(copy_session 深拷贝磁盘文件用)。默认=项目根;测试可覆盖。 + self.files_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + self.is_first_run = not os.path.exists(self.db_path) + d = os.path.dirname(self.db_path) + if d: + os.makedirs(d, exist_ok=True) + + self._init_db() + + def get_connection(self): + conn = sqlite3.connect(self.db_path, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return _AutoCloseConn(conn) + + def _init_db(self): + """初始化表结构并注入默认数据""" + with self.get_connection() as conn: + cursor = conn.cursor() + + # 1. 创建 sessions 表 (新增 current_leaf_msg_id) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + title TEXT, + created_at INTEGER, + updated_at INTEGER, + has_messages BOOLEAN DEFAULT 0, + sort_order INTEGER DEFAULT 0, + is_starred BOOLEAN DEFAULT 0, + current_leaf_msg_id TEXT + ) + """) + + # 2. 创建 messages 表 (新增 parent_id) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT, + role TEXT, + content TEXT, + reasoning TEXT, + is_ignored BOOLEAN, + created_at INTEGER, + attachment_metadata TEXT, + parent_id TEXT, + timeline TEXT, + usage TEXT, + stop_reason TEXT, + error_message TEXT, + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE + ) + """) + + # ========================================== + # 🌟 核心性能优化:为高频查询的字段建立索引 + # ========================================== + cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_id ON messages(session_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_parent_id ON messages(parent_id)") + + # 3. 🌟 自动化热升级:检测并兼容旧数据库 + self._upgrade_schema(cursor) + + # 4. 检查是否需要插入初始默认对话 + cursor.execute("SELECT COUNT(*) FROM sessions") + if cursor.fetchone()[0] == 0: + self._seed_default_chat(cursor) + + conn.commit() + + + def _upgrade_schema(self, cursor): + """检测缺少的新字段并自动补齐,如果是刚升级,则自动将旧线性数据串联成链表""" + upgraded = False + + # 兼容 sessions 字段 + for col in ["has_messages", "sort_order", "is_starred"]: + try: + cursor.execute(f"SELECT {col} FROM sessions LIMIT 1") + except sqlite3.OperationalError: + if col == "has_messages": cursor.execute("ALTER TABLE sessions ADD COLUMN has_messages BOOLEAN DEFAULT 0") + if col == "sort_order": cursor.execute("ALTER TABLE sessions ADD COLUMN sort_order INTEGER DEFAULT 0") + if col == "is_starred": cursor.execute("ALTER TABLE sessions ADD COLUMN is_starred BOOLEAN DEFAULT 0") + + # 🌟 核心:兼容链表树架构 + try: + # 兼容 sessions.mode 列(chat/worker 模式锁定,NULL=未发送过) + try: + cursor.execute("SELECT mode FROM sessions LIMIT 1") + except Exception: + cursor.execute("ALTER TABLE sessions ADD COLUMN mode TEXT") + + cursor.execute("SELECT current_leaf_msg_id FROM sessions LIMIT 1") + except sqlite3.OperationalError: + print("[DB]: 正在升级 sessions 表 (添加 current_leaf_msg_id)...") + cursor.execute("ALTER TABLE sessions ADD COLUMN current_leaf_msg_id TEXT") + upgraded = True + + try: + cursor.execute("SELECT attachment_metadata, parent_id FROM messages LIMIT 1") + except sqlite3.OperationalError: + print("[DB]: 正在升级 messages 表 (添加 attachment_metadata, parent_id)...") + try: cursor.execute("ALTER TABLE messages ADD COLUMN attachment_metadata TEXT") + except: pass + try: cursor.execute("ALTER TABLE messages ADD COLUMN parent_id TEXT") + except: pass + upgraded = True + + # 🌟 messages.timeline 列(agent 时间线 JSON: 思考/文本/工具 按事件顺序) + try: + cursor.execute("SELECT timeline FROM messages LIMIT 1") + except sqlite3.OperationalError: + print("[DB]: 正在升级 messages 表 (添加 timeline)...") + try: cursor.execute("ALTER TABLE messages ADD COLUMN timeline TEXT") + except: pass + upgraded = True + + # 🆕 P1: messages.usage 列(assistant 回复的精确 usage JSON, + # 供显示/压缩估算做 usage 锚定,对照 pi 内存态 usage 回放) + try: + cursor.execute("SELECT usage FROM messages LIMIT 1") + except sqlite3.OperationalError: + print("[DB]: 正在升级 messages 表 (添加 usage)...") + try: cursor.execute("ALTER TABLE messages ADD COLUMN usage TEXT") + except: pass + upgraded = True + + # 🆕 失败轮次持久化(对照 pi: message_end 无条件入库): + # messages.stop_reason / error_message —— 区分「正常行 / 出错行」, + # 供 UI 展示与 build_api_context 回放决策(NULL = 正常) + # + # ⚠️⚠️ 绝不能置 upgraded=True:该标志会触发下方的「旧数据链表化重构」, + # 把用户的**树状分支拍平成线性链**(数据破坏)! + # 纯追加列对本迁移自身而言是安全的,与旧库结构修复无关。 + try: + cursor.execute("SELECT stop_reason, error_message FROM messages LIMIT 1") + except sqlite3.OperationalError: + print("[DB]: 正在升级 messages 表 (添加 stop_reason, error_message)...") + try: cursor.execute("ALTER TABLE messages ADD COLUMN stop_reason TEXT") + except: pass + try: cursor.execute("ALTER TABLE messages ADD COLUMN error_message TEXT") + except: pass + + # 如果刚才执行了树状结构升级,立即对旧数据进行“时间线串联”修复 + if upgraded: + print("[DB]: 🚀 正在执行旧数据链表化重构...") + cursor.execute("SELECT id FROM sessions") + sessions = cursor.fetchall() + for s in sessions: + sid = s['id'] + cursor.execute("SELECT id FROM messages WHERE session_id = ? ORDER BY created_at ASC", (sid,)) + msgs = cursor.fetchall() + if not msgs: continue + + # 遍历消息,将后一条的 parent_id 指向上一条 + prev_id = None + for m in msgs: + mid = m['id'] + if prev_id: + cursor.execute("UPDATE messages SET parent_id = ? WHERE id = ?", (prev_id, mid)) + prev_id = mid + + # 最后一个 msg_id 就是这棵树的末端叶子节点 + cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev_id, sid)) + print("[DB]: ✅ 数据结构完美升级为链表树!") + + + def _seed_default_chat(self, cursor): + session_id = f"sess_{uuid.uuid4().hex[:12]}" + now = int(time.time()) + + cursor.execute( + "INSERT INTO sessions (id, title, created_at, updated_at, has_messages) VALUES (?, ?, ?, ?, ?)", + (session_id, "让我们从这里开始吧", now, now, 1) + ) + + sys_id = f"msg_sys_init" + cursor.execute(""" + INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (sys_id, session_id, "system", "你是一个优秀的助手!", "", 0, now, None)) + + default_messages = [ + {"role": "user", "content": "你好呀,你是谁?"}, + {"role": "assistant", "content": "嗨!我是你的 AI 助手 ✨ ..."}, + {"role": "user", "content": "那你到底能帮我做什么?"}, + {"role": "assistant", "content": "简单来说,能打字问的我都聊..."} + ] + + prev_id = sys_id + for msg in default_messages: + msg_id = f"msg_{uuid.uuid4().hex[:16]}" + now += 1 + cursor.execute(""" + INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (msg_id, session_id, msg["role"], msg["content"], "", 0, now, prev_id)) + prev_id = msg_id + + # 设置默认会话的叶子节点 + cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev_id, session_id)) + + # ==================== 会话 (Session) 操作 ==================== + def get_session_mode(self, session_id: str) -> Optional[str]: + """读取会话锁定的模式(chat/worker),未发送过返回 None""" + with self.get_connection() as conn: + row = conn.execute("SELECT mode FROM sessions WHERE id = ?", + (session_id,)).fetchone() + return row["mode"] if row else None + + def set_session_mode(self, session_id: str, mode: str): + """锁定会话模式(首条消息发送时调用,之后不可变)""" + with self.get_connection() as conn: + conn.execute("UPDATE sessions SET mode = ? WHERE id = ?", + (mode, session_id)) + + def get_all_sessions(self) -> List[Dict]: + with self.get_connection() as conn: + return [dict(row) for row in conn.execute("SELECT * FROM sessions ORDER BY is_starred DESC, sort_order ASC, updated_at DESC").fetchall()] + + def create_session(self, title: str = "新对话") -> Dict: + session_id = f"sess_{uuid.uuid4().hex[:12]}" + now = int(time.time()) + sys_msg_id = f"msg_{uuid.uuid4().hex[:16]}" + + with self.get_connection() as conn: + cursor = conn.cursor() + min_order = self.get_min_sort_order() + + # 创建 session,直接将 system 消息设为初始叶子节点 + cursor.execute( + "INSERT INTO sessions (id, title, created_at, updated_at, has_messages, sort_order, current_leaf_msg_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + (session_id, title, now, now, 0, min_order, sys_msg_id) + ) + + cursor.execute(""" + INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (sys_msg_id, session_id, "system", "你是一个优秀的助手!", "", 0, now, None)) + conn.commit() + + return dict(cursor.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()) + + # ------------------------------------------------------------------ + # 🆕 会话复制:深度克隆(全部分支 + 压缩标记 + 附件文件) + # ------------------------------------------------------------------ + def _make_copy_title(self, base: str) -> str: + """生成不重名的副本标题:X → X (副本) → X (副本 2) → …""" + root = re.sub(r"\s*\(副本(?:\s*\d+)?\)\s*$", "", base or "").strip() or "新对话" + with self.get_connection() as conn: + existing = {r[0] for r in conn.execute("SELECT title FROM sessions").fetchall()} + cand = f"{root} (副本)" + n = 2 + while cand in existing and n < 1000: + cand = f"{root} (副本 {n})" + n += 1 + return cand + + def _dup_media_file(self, old_path: str, prefix: str): + """把一个项目内媒体文件复制成新名字。 + 返回 (新绝对路径, 新项目相对路径);文件不存在或异常返回 None。""" + if not old_path: + return None + old_abs = old_path if os.path.isabs(old_path) else os.path.join(self.files_root, old_path) + if not os.path.isfile(old_abs): + return None + ext = os.path.splitext(old_abs)[1] or ".bin" + new_dir = os.path.join(self.files_root, "data", "attachments") + os.makedirs(new_dir, exist_ok=True) + new_abs = "" + for _ in range(5): + new_abs = os.path.join(new_dir, f"{prefix}_{uuid.uuid4().hex[:8]}{ext}") + if not os.path.exists(new_abs): + break + shutil.copy2(old_abs, new_abs) + new_rel = os.path.relpath(new_abs, self.files_root).replace("\\", "/") + return new_abs, new_rel + + def _copy_attachment_files(self, meta_json: Optional[str]) -> Optional[str]: + """附件深拷贝:image/pdf 的磁盘文件复制改名并改写 local_path; + text 类型正文内联在 JSON 里,无需处理。 + 任何异常/文件缺失都原样返回,绝不让复制整体失败。""" + if not meta_json: + return meta_json + try: + meta = json.loads(meta_json) + except Exception: + return meta_json + if not isinstance(meta, dict) or not meta.get("attachments"): + return meta_json + changed = False + for att in meta.get("attachments") or []: + if not isinstance(att, dict): + continue + try: + if att.get("type") in ("image", "pdf") and att.get("local_path"): + got = self._dup_media_file( + att["local_path"], "img" if att["type"] == "image" else "pdf") + if got: + att["local_path"] = got[1] + if "abs_path" in att: + att["abs_path"] = got[0] + changed = True + if att.get("type") == "pdf": + for im in att.get("images") or []: + if not isinstance(im, dict): + continue + got = self._dup_media_file( + im.get("abs_path") or im.get("local_path"), "pdfimg") + if got: + im["abs_path"] = got[0] + im["local_path"] = got[1] + changed = True + except Exception as e: + print(f"[DB] ⚠️ 附件深拷贝失败(保留原路径): {e}", flush=True) + return json.dumps(meta, ensure_ascii=False) if changed else meta_json + + def copy_session(self, session_id: str, new_title: Optional[str] = None, + copy_attachments: bool = True) -> Optional[Dict]: + """📋 深度复制一个会话。 + + - messages 全部重新生成 ID,parent_id / current_leaf_msg_id 全量重映射 + → 分支、压缩标记(role='compaction')都原样保留 + - image/pdf 附件文件物理复制成新文件 → 副本自包含,删任意一方不影响另一方 + - 单事务写入;源会话零改动 + 返回新会话 dict;源不存在返回 None。 + """ + now = int(time.time()) + n_att = 0 + with self.get_connection() as conn: + cursor = conn.cursor() + src_row = cursor.execute( + "SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + if not src_row: + return None + src = dict(src_row) + rows = [dict(r) for r in cursor.execute( + "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, rowid ASC", + (session_id,)).fetchall()] + + # ① 新 ID 映射(保持 comp_ / msg_ 前缀约定) + idmap = {} + for m in rows: + pre = "comp_" if m.get("role") == "compaction" else "msg_" + idmap[m["id"]] = f"{pre}{uuid.uuid4().hex[:16]}" + + # ② 标题(重名自动递增) + title = new_title or self._make_copy_title(src.get("title") or "新对话") + + # ③ 消息 created_at 保相对间隔(同层兄弟排序不变) + t0 = min((m.get("created_at") or 0) for m in rows) if rows else now + + new_sid = f"sess_{uuid.uuid4().hex[:12]}" + new_leaf = idmap.get(src.get("current_leaf_msg_id")) + if rows and not new_leaf: + # 兜底:源叶子不在链上(数据异常)→ 取副本里时间最新的一条 + last = max(rows, key=lambda m: (m.get("created_at") or 0)) + new_leaf = idmap.get(last["id"]) + print(f"[DB] ⚠️ copy_session 源叶子异常,回退 leaf={new_leaf}", flush=True) + + # ④ 新会话(置列表顶部、不带星标、模式跟随源) + cursor.execute( + "INSERT INTO sessions (id, title, created_at, updated_at, has_messages, " + "sort_order, is_starred, current_leaf_msg_id, mode) VALUES (?,?,?,?,?,?,?,?,?)", + (new_sid, title, now, now, src.get("has_messages") or 0, + self.get_min_sort_order(), 0, new_leaf, src.get("mode"))) + + # ⑤ 逐条复制消息(parent 重映射 + 附件深拷贝) + for m in rows: + meta = m.get("attachment_metadata") + if copy_attachments and meta: + new_meta = self._copy_attachment_files(meta) + if new_meta != meta: + n_att += 1 + meta = new_meta + cursor.execute( + "INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, " + "created_at, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + (idmap[m["id"]], new_sid, m.get("role"), m.get("content"), + m.get("reasoning"), m.get("is_ignored"), + now + ((m.get("created_at") or 0) - t0), + meta, idmap.get(m.get("parent_id")), + m.get("timeline"), m.get("usage"), + m.get("stop_reason"), m.get("error_message"))) + + conn.commit() + out = dict(cursor.execute( + "SELECT * FROM sessions WHERE id = ?", (new_sid,)).fetchone()) + + try: + print(f"[DB] copy_session {session_id[:8]} → {new_sid[:8]} " + f"消息={len(rows)} 附件深拷贝={n_att} 标题={title}", flush=True) + except Exception: + pass + return out + + def update_session_title(self, session_id: str, new_title: str): + with self.get_connection() as conn: + conn.execute("UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?", (new_title, int(time.time()), session_id)) + conn.commit() + + def delete_session(self, session_id: str): + with self.get_connection() as conn: + conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + conn.commit() + + # 下方其它基本Session功能保持不变... + def mark_session_has_messages(self, session_id: str): + with self.get_connection() as conn: + conn.execute("UPDATE sessions SET has_messages = 1 WHERE id = ?", (session_id,)) + conn.commit() + + def check_session_needs_title(self, session_id: str) -> bool: + with self.get_connection() as conn: + row = conn.execute("SELECT title, has_messages FROM sessions WHERE id = ?", (session_id,)).fetchone() + if row: return row[0] == "新对话" and row[1] == 1 + return False + + def get_min_sort_order(self) -> int: + with self.get_connection() as conn: + row = conn.execute("SELECT MIN(sort_order) FROM sessions").fetchone() + return (row[0] or 0) - 1 + + def update_session_order(self, ordered_ids: list): + with self.get_connection() as conn: + for idx, sid in enumerate(ordered_ids): + conn.execute("UPDATE sessions SET sort_order = ? WHERE id = ?", (idx, sid)) + conn.commit() + + def update_session_star(self, session_id: str, is_starred: bool): + with self.get_connection() as conn: + conn.execute("UPDATE sessions SET is_starred = ?, updated_at = ? WHERE id = ?", (1 if is_starred else 0, int(time.time()), session_id)) + conn.commit() + + def is_session_starred(self, session_id: str) -> bool: + with self.get_connection() as conn: + row = conn.execute("SELECT is_starred FROM sessions WHERE id = ?", (session_id,)).fetchone() + return bool(row and row[0]) + + + # ==================== 🌟 核心:消息链表树操作 ==================== + + def get_session_leaf(self, session_id: str) -> Optional[str]: + """获取会话当前的叶子节点ID""" + with self.get_connection() as conn: + row = conn.execute("SELECT current_leaf_msg_id FROM sessions WHERE id = ?", (session_id,)).fetchone() + return row[0] if row else None + + def update_session_leaf(self, session_id: str, leaf_msg_id: str): + """切换时间线:手动更新当前会话的叶子节点""" + with self.get_connection() as conn: + conn.execute("UPDATE sessions SET current_leaf_msg_id = ?, updated_at = ? WHERE id = ?", + (leaf_msg_id, int(time.time()), session_id)) + conn.commit() + + def get_message_chain(self, session_id: str) -> List[Dict]: + """🚀 极客级递归拉取:顺藤摸瓜,只返回当前激活时间线上的消息!彻底断绝下游污染!""" + leaf_id = self.get_session_leaf(session_id) + if not leaf_id: + return [] + + chain = [] + with self.get_connection() as conn: + cursor = conn.cursor() + current_id = leaf_id + seen = set() # 🐛 环检测:数据异常(如自环标记)时快速退出,防主线程死循环卡死 + + # 使用 Python 循环向上回溯(比 SQL CTE 更好调试,性能对于本地几千条聊天来说在 1ms 内) + while current_id and current_id not in seen: + seen.add(current_id) + cursor.execute("SELECT * FROM messages WHERE id = ?", (current_id,)) + msg = cursor.fetchone() + if not msg: + break + chain.append(dict(msg)) + current_id = msg['parent_id'] + if current_id in seen: + try: + print(f"[DB] ⚠️ get_message_chain 检测到环(session={session_id}),已截断", flush=True) + except Exception: + pass + + # 因为是向上回溯,拉出来的链条是反的,最后翻转一下恢复正序 + chain.reverse() + return chain + + def get_branch_info(self, parent_id: str) -> List[Dict]: + """获取某一父节点下的所有子分支消息 (第二阶段用于UI渲染 '1/3')""" + if not parent_id: return [] + with self.get_connection() as conn: + return [dict(row) for row in conn.execute( + "SELECT * FROM messages WHERE parent_id = ? ORDER BY created_at ASC", (parent_id,) + ).fetchall()] + + def add_message(self, session_id: str, role: str, content: str, parent_id: str, + reasoning: str = "", is_ignored: bool = False, + msg_id: Optional[str] = None, attachment_metadata: Optional[str] = None, + timeline: Optional[str] = None, + usage: Optional[str] = None, + stop_reason: Optional[str] = None, + error_message: Optional[str] = None) -> Dict: + """添加新消息,并自动将该消息设为当前会话的最新叶子节点 + 🆕 P1: usage —— assistant 回复的精确 usage JSON(如 '{"input":..,"output":..}') + 🆕 失败轮次: stop_reason/error_message —— 'error' 行入库但不回退叶子 + (对照 pi:错误也持久化,回放时由 build_api_context 决定取舍)""" + if not msg_id: msg_id = f"msg_{uuid.uuid4().hex[:16]}" + now = int(time.time()) + + with self.get_connection() as conn: + cursor = conn.cursor() + # 1. 插入消息 + cursor.execute(""" + INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (msg_id, session_id, role, content, reasoning, 1 if is_ignored else 0, now, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message)) + + # 2. 自动更新 session 的叶子节点(时间线前推) + cursor.execute("UPDATE sessions SET current_leaf_msg_id = ?, updated_at = ? WHERE id = ?", + (msg_id, now, session_id)) + conn.commit() + try: + print(f"[DB] add_message role={role} 内容={len(content or '')}c " + f"思考={len(reasoning or '')}c 时间线={'有' if timeline else '无'} " + f"id={msg_id} session={session_id[:8]}", flush=True) + except Exception: + pass + + return dict(cursor.execute("SELECT * FROM messages WHERE id = ?", (msg_id,)).fetchone()) + + # ------------------------------------------------------------------ + # 🆕 压缩持久化:链上标记点(子叶子回溯到标记即停) + # 插入后链形:…→ cut_before → [MARK role="compaction"] → first_retained → …→叶子 + # 历史行全部保留(前端切会话渲染照常);API 上下文构建在标记处截断。 + # 关键:不动 sessions.current_leaf_msg_id(绝不能用 add_message); + # INSERT + UPDATE 同一事务,不留断链窗口。 + # ------------------------------------------------------------------ + def insert_compaction_mark(self, session_id: str, summary: str, + cut_before_id: str, first_retained_id: str, + meta_json: Optional[str] = None) -> Optional[str]: + """在链上插入压缩标记行并把保留首条的 parent_id 改指到标记。返回 mark_id。""" + if not cut_before_id or not first_retained_id: + return None + if cut_before_id == first_retained_id: + # 🐛 防自环:同一行不能既做切点前又做保留首条(timeline 回放同 id 场景) + try: + print(f"[DB] ⚠️ insert_compaction_mark 拒绝自环 cut==retained={cut_before_id}", flush=True) + except Exception: + pass + return None + mark_id = f"comp_{uuid.uuid4().hex[:16]}" + now = int(time.time()) + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute("""INSERT INTO messages + (id, session_id, role, content, reasoning, is_ignored, + created_at, attachment_metadata, parent_id, timeline, usage) + VALUES (?, ?, 'compaction', ?, '', 1, ?, ?, ?, NULL, NULL)""", + (mark_id, session_id, summary, now, meta_json, cut_before_id)) + cursor.execute("UPDATE messages SET parent_id = ? WHERE id = ?", + (mark_id, first_retained_id)) + conn.commit() + try: + print(f"[DB] insert_compaction_mark session={session_id[:8]} " + f"cut_before={cut_before_id} first_retained={first_retained_id} " + f"mark={mark_id} summary={len(summary or '')}c", flush=True) + except Exception: + pass + return mark_id + # === 在 db_manager.py 中添加这个方法 === + def get_branch_leaf(self, msg_id: str) -> str: + """寻找一条时间线的最末端叶子节点""" + current_id = msg_id + with self.get_connection() as conn: + cursor = conn.cursor() + while True: + # 寻找把当前节点作为父节点的子节点,按时间倒序取最新的一条(意味着它顺着最近被聊过的那条线往下走) + cursor.execute("SELECT id FROM messages WHERE parent_id = ? ORDER BY created_at DESC LIMIT 1", (current_id,)) + child = cursor.fetchone() + if child: + current_id = child[0] + else: + break # 没有子节点了,它自己就是叶子! + return current_id + def delete_message_branch(self, session_id: str, msg_id: str): + """🚀 精准剪枝:删AI只删当前分支,删User连根拔起,并自动平滑回退时间线""" + with self.get_connection() as conn: + cursor = conn.cursor() + + # 1. 查出要删除的节点的父亲 + cursor.execute("SELECT parent_id FROM messages WHERE id = ?", (msg_id,)) + row = cursor.fetchone() + if not row: return + safe_parent_id = row['parent_id'] + + # 2. 目标就是传进来的 msg_id 本身 (不再强制上移到 parent) + target_id = msg_id + + # 3. 拉取全会话,构建亲属关系树 + cursor.execute("SELECT id, parent_id FROM messages WHERE session_id = ?", (session_id,)) + all_msgs = cursor.fetchall() + + children_map = {} + for m in all_msgs: + pid = m['parent_id'] + if pid not in children_map: + children_map[pid] = [] + children_map[pid].append(m['id']) + + # 4. 广度优先搜索 (BFS):找出目标消息及其所有子孙 + to_delete = set([target_id]) + queue = [target_id] + while queue: + curr = queue.pop(0) + if curr in children_map: + for child in children_map[curr]: + if child not in to_delete: + to_delete.add(child) + queue.append(child) + + # 5. 判断当前时间线的“叶子节点”是否在被波及的名单里 + cursor.execute("SELECT current_leaf_msg_id FROM sessions WHERE id = ?", (session_id,)) + leaf_row = cursor.fetchone() + leaf_needs_update = leaf_row and leaf_row['current_leaf_msg_id'] in to_delete + + # 6. 🌟 执行物理删除前,收集将被删除的附件元数据 + deleted_metadata = [] + for d_id in to_delete: + # 先查出它的 metadata + cursor.execute("SELECT attachment_metadata FROM messages WHERE id = ?", (d_id,)) + row = cursor.fetchone() + if row and row['attachment_metadata']: + deleted_metadata.append(row['attachment_metadata']) + + # 然后再执行物理删除 + cursor.execute("DELETE FROM messages WHERE id = ?", (d_id,)) + + + # 7. 🌟 核心:如果时间线断了,自动寻找平滑降落点 + sibling_row = None + if leaf_needs_update: + # 尝试寻找被删节点的最新“兄弟姐妹” (例如删了分支2,寻找分支1) + cursor.execute("SELECT id FROM messages WHERE parent_id = ? ORDER BY created_at DESC LIMIT 1", (safe_parent_id,)) + sibling_row = cursor.fetchone() + + # 如果有兄弟,降落到兄弟;如果没兄弟(只有1次回答),退回原点(提问) + new_leaf = sibling_row['id'] if sibling_row else safe_parent_id + cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (new_leaf, session_id)) + + conn.commit() + + # 8. 如果降落到了兄弟分支,兄弟可能还有下文,需再次对齐真实叶子节点 + if leaf_needs_update and sibling_row: + real_leaf = self.get_branch_leaf(new_leaf) + self.update_session_leaf(session_id, real_leaf) + + return deleted_metadata # 🌟 返回被删除的元数据,交给 MainWindow 去粉碎文件 diff --git a/core/debug_log.py b/core/debug_log.py new file mode 100644 index 0000000..c02577d --- /dev/null +++ b/core/debug_log.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""统一调试日志 + 调试器窗口控制协议(纯文件通信,与主窗口 UI 零耦合) + +日志文件: data/debug_session.log (环境变量 HAOCODE_DEBUG_LOG 可覆盖) +控制文件: data/debug_window.cmd (环境变量 HAOCODE_DEBUG_CMD 可覆盖) + +三方写入协议: + [USER] 用户在调试窗口输入框手输的观察情况 + [AGENT] 代理(外部脚本/命令行)注入的指令与备注 + [APP] 应用自身事件(上下文标签变化/usage/发送/完成/报错/压缩) + [SYS] 调试窗口自身的开关事件 + +行格式: [YYYY-MM-DD HH:MM:SS.mmm] [TAG] 内容 + +控制协议: 代理往 debug_window.cmd 写入 "show" 或 "hide"(一行), +app 侧 2s QTimer 轮询并消费(读完即删)。 +""" +import os +import threading +import time + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_DATA_DIR = os.path.join(_ROOT, "data") + +DEBUG_LOG_PATH = (os.environ.get("HAOCODE_DEBUG_LOG") + or os.path.join(_DATA_DIR, "debug_session.log")) +DEBUG_CMD_PATH = (os.environ.get("HAOCODE_DEBUG_CMD") + or os.path.join(_DATA_DIR, "debug_window.cmd")) + +_lock = threading.Lock() + + +def _stamp() -> str: + t = time.time() + return (f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t))}" + f".{int(t * 1000) % 1000:03d}") + + +def debug_log(msg: str, tag: str = "APP") -> None: + """线程安全追加一条日志(worker 线程亦可调用)。 + 静默吞掉一切异常——本模块绝不影响主流程。""" + try: + with _lock: + with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f: + f.write(f"[{_stamp()}] [{tag}] {msg}\n") + except Exception: + pass + + +def poll_debug_cmd(): + """消费控制文件,返回 'show' / 'hide' / None。由主线程 QTimer 周期调用。""" + try: + if os.path.exists(DEBUG_CMD_PATH): + with open(DEBUG_CMD_PATH, "r", encoding="utf-8") as f: + action = (f.read() or "").strip().lower() + try: + os.remove(DEBUG_CMD_PATH) + except Exception: + pass + if action in ("show", "hide"): + return action + except Exception: + pass + return None + + +def autostart_debug_window(cfg: dict) -> bool: + """调试窗口随程序启动:cfg["debug_window_autostart"] 为 true(缺省也是 true) + 时向控制文件写 "show",主窗口事件循环启动后 2s 轮询即开窗。 + 纯文件操作(不依赖 Qt),返回是否写入。""" + try: + if not bool(cfg.get("debug_window_autostart", True)): + return False + with open(DEBUG_CMD_PATH, "w", encoding="utf-8") as f: + f.write("show\n") + return True + except Exception: + return False diff --git a/core/llm_engine.py b/core/llm_engine.py new file mode 100644 index 0000000..2248083 --- /dev/null +++ b/core/llm_engine.py @@ -0,0 +1,448 @@ +""" +core/llm_engine.py +================== +Qt 胶水层 —— 把 core.agent(pi 1:1 核心)接到 PyQt6 信号系统。 + +🌟 对照 pi 架构: + AgentWorker(QThread) + 内部持有 Agent + AgentRunner(对照 pi 的 Agent + AgentSession 会话层) + run() = pi 的 _runAgentPrompt:prompt → 循环 post-agent-run 恢复 → settle + 事件桥:AgentEvent → pyqtSignal(跨线程 queued 投递到 UI 线程) + + ChatWorker(QThread) + chat 模式:普通聊天单次流式(无工具循环/无重试/无压缩)。 + + TitleWorker(QThread) + 轻量单次流式补全(标题生成),保留原 LLMWorker 的信号面。 + +信号面(与旧 LLMWorker 兼容 + 新增工具事件): + chunk_received(str) # text_delta + reasoning_received(str) # thinking_delta + error_occurred(str) # 终局错误(已重试/压缩恢复仍失败) + tool_execution_started(str, str, str) # (call_id, tool_name, args_json) + tool_execution_updated(str, str) # (call_id, 执行中增量输出) + tool_execution_finished(str, str, bool, str) # (call_id, tool_name, ok, 结果摘要) +""" +from __future__ import annotations + +import json +import os +from typing import Any, Dict, List, Optional + +from openai import OpenAI +from PyQt6.QtCore import QThread, pyqtSignal + +from core.agent import (Agent, AgentConfig, AgentEvent, AgentMessage, AgentRunner, + ModelConfig, RetryConfig, calculate_context_tokens, + default_tools, from_openai_messages, + openai_stream) +from core.agent.stream_fn import _pick_reasoning, _pick_usage +# P0-01:配置读取统一入口(环境变量 HAOCODE_CONFIG_FILE 优先,缺省回落项目内 data/config.json) +from core.config_paths import load_config +from core import platform_shell # P1-02:提示词平台段(通用正文 + 短 shell/path 段) + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +# 🌟 Agent 系统提示词文件(项目根目录,core 前面那个 .md) +SYSTEM_PROMPT_FILE = os.path.join(PROJECT_ROOT, "SYSTEM_PROMPT.md") +_FALLBACK_SYSTEM_PROMPT = ( + "你是 haocode 的本地智能体,可以读取/写入文件并执行 bash 命令" + "(工作目录为项目根目录)。请用简体中文简洁地回答。" +) + + +def load_system_prompt() -> str: + """读取 SYSTEM_PROMPT.md;缺失时用兜底短提示词。 + + P1-02:通用正文 + 运行时插入的短平台 shell/path 段(占位符替换); + 每次请求重读本函数即重读文件,Windows 段不会出现在 Linux 请求中,反之亦然。 + """ + text = None + try: + with open(SYSTEM_PROMPT_FILE, "r", encoding="utf-8") as f: + text = f.read().strip() + except FileNotFoundError: + pass + if not text: + text = _FALLBACK_SYSTEM_PROMPT + return platform_shell.apply_platform_section(text) + + +def _load_config() -> dict: + """P0-01:统一走 core.config_paths.load_config(环境变量优先、容错、可见警告)。""" + return load_config() + + +def _provider_info(config: dict, provider_name: str) -> dict: + return config.get("providers", {}).get(provider_name, {}) or {} + + +class AgentWorker(QThread): + """ + 一次「发送」= 一个 AgentWorker(对照 pi:一个 AgentSession 实例)。 + 多轮工具循环在一次 run() 内完成;UI 只管收信号 + 最终入库。 + """ + + # ---- 与旧 LLMWorker 兼容的信号 ---- + chunk_received = pyqtSignal(str) + reasoning_received = pyqtSignal(str) + error_occurred = pyqtSignal(str) + # ---- 新增:工具执行事件(pi tool_execution_* 事件)---- + tool_execution_started = pyqtSignal(str, str, str) # (call_id, name, args) + tool_execution_updated = pyqtSignal(str, str) # (call_id, 增量输出) + tool_execution_timed = pyqtSignal(str, int, int) # (call_id, 已耗秒, 超时秒) 🆕 bash 读秒 + tool_execution_finished = pyqtSignal(str, str, bool, str) # (call_id, name, ok, 结果) + # 上下文压缩发生(UI 可提示「已自动压缩上下文」) + context_compacted = pyqtSignal(dict) + # 🆕 压缩开始(摘要 LLM 阻塞调用前)→ 前端显示「执行中」动态气泡 + # payload: {summary, before, after, duration_ms, path} + compaction_started = pyqtSignal(str) # path: pre_prompt/overflow_compact/length_compact + # 🆕 P1: 收到本轮精确 usage(UI 上下文标签做 usage 锚定,含 system+tools) + usage_updated = pyqtSignal(dict) + # 🆕 M3: 重试调度 / 重试结果(对照 pi onRetryScheduled/onRetryFinished) + retry_scheduled = pyqtSignal(int, int, float, str) # (attempt, max_attempts, delay_ms, reason) + retry_finished = pyqtSignal(bool) + + def __init__(self, provider_name: str, model_name: str, + openai_messages: List[Dict[str, Any]], + tool_context: Optional[Dict[str, Any]] = None, + enable_tools: bool = True, + temperature: Optional[float] = None): + """ + openai_messages: build_api_context 的产物(OpenAI 格式)。 + 最后一条 user 消息 = 本次提问;其余 = 历史上下文。 + """ + super().__init__() + self.provider_name = provider_name + self.model_name = model_name + self.openai_messages = openai_messages + self.tool_context = tool_context or {"cwd": PROJECT_ROOT} + self.enable_tools = enable_tools + self._temperature_override = temperature + + self.config = _load_config() + self._aborted = False + self._agent: Optional[Agent] = None + self._runner: Optional[AgentRunner] = None + self._compactions_before = 0 + + # ------------------------------------------------------------------ + # 中断(对照 pi abort:当前流立刻停止,已产生的内容保留) + # ------------------------------------------------------------------ + def abort(self): + self._aborted = True + if self._agent is not None: + self._agent.abort() + + # 兼容旧调用名 + def cancel(self): + self.abort() + + # ------------------------------------------------------------------ + # 模型配置(对照 pi models.json 条目 → ModelConfig) + # ------------------------------------------------------------------ + def _build_model_config(self) -> ModelConfig: + p = _provider_info(self.config, self.provider_name) + context_window = int(p.get("model_contexts", {}).get(self.model_name, 128000)) + max_tokens = int(p.get("model_max_tokens", {}).get(self.model_name, 8192)) + temperature = (self._temperature_override + if self._temperature_override is not None + else float(self.config.get("temperature", 0.7))) + return ModelConfig( + provider=self.provider_name, + name=self.model_name, + context_window=context_window, + max_tokens=max_tokens, + temperature=temperature, + api_key=p.get("api_key", ""), + base_url=p.get("base_url", "https://api.openai.com/v1"), + # 🆕 thinking reasoning_content 回传默认开启(ModelConfig.pass_reasoning=True) + ) + + # ------------------------------------------------------------------ + # 压缩用的非流式 LLM 调用(对照 pi completeSimpleWithRetries) + # 新签名 1:1 对照 pi:(prompt_text, system_prompt, max_tokens) -> str + # ------------------------------------------------------------------ + def _make_summarize_fn(self, model: ModelConfig): + def summarize(prompt_text: str, system_prompt: str, max_tokens: int) -> str: + client = OpenAI(api_key=model.api_key, base_url=model.base_url, + timeout=120.0, max_retries=0) + resp = client.chat.completions.create( + model=model.name, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt_text}, + ], + stream=False, + max_tokens=max_tokens, + temperature=0.3, + ) + return (resp.choices[0].message.content or "").strip() + + return summarize + + # ------------------------------------------------------------------ + # 事件桥:AgentEvent → Qt 信号(在工作线程内 emit,queued 投递) + # ------------------------------------------------------------------ + def _bridge(self, e: AgentEvent): + if e.type == "message_end" and e.message is not None: + # 🆕 P1: 本轮刚结束 → 它的 usage 是「上一张照片」(精确覆盖 + # system+tools+历史+本轮输出),供 UI 显示锚定 + usage = e.message.usage or {} + if calculate_context_tokens(usage) > 0: + self.usage_updated.emit(dict(usage)) + if e.type == "message_update" and e.assistant_message_event is not None: + ev = e.assistant_message_event + # 🌟 字段是 ev.text(不是 ev.data);_emit 会吞掉订阅者异常, + # 写错字段 = 静默无流式输出,必须靠 test_agent_worker_stream_fn_wiring 守住 + if ev.type == "text_delta" and ev.text: + self.chunk_received.emit(ev.text) + elif ev.type == "thinking_delta" and ev.text: + self.reasoning_received.emit(ev.text) + return + if e.type == "tool_execution_start" and e.tool_call is not None: + try: + args = json.dumps(e.tool_call.arguments, ensure_ascii=False) \ + if e.tool_call.arguments else (e.tool_call.raw_arguments or "") + except Exception: + args = str(e.tool_call.arguments) + self.tool_execution_started.emit(e.tool_call.id, e.tool_call.name, + str(args)[:2000]) + elif e.type == "tool_execution_update" and e.arg: + self.tool_execution_updated.emit(e.tool_call.id, str(e.arg)) + elif e.type == "tool_execution_timer" and isinstance( + getattr(e, "arg", None), tuple) and len(e.arg) == 2: + # 🆕 bash 运行中每秒滴一次 → 前端气泡读秒 + self.tool_execution_timed.emit(e.tool_call.id, int(e.arg[0]), + int(e.arg[1])) + elif e.type == "tool_execution_end" and e.tool_call is not None: + text = "" + if e.result is not None: + c = e.result.content + text = c if isinstance(c, str) else \ + "".join(x.get("text", "") for x in c if isinstance(x, dict)) + # 🌟 修复: 原来 ok = is_error(反了)——成功的工具被显示成失败 + ok = not (e.result.is_error if e.result is not None else True) + self.tool_execution_finished.emit( + e.tool_call.id, e.tool_call.name, bool(ok), text[:20000]) # 长结果供 UI 展开 + + # ------------------------------------------------------------------ + # 主入口 —— 对照 pi _runAgentPrompt + # ------------------------------------------------------------------ + def run(self): + provider = _provider_info(self.config, self.provider_name) + if not provider: + self.error_occurred.emit(f"未找到 provider 配置: {self.provider_name}") + return + + history = from_openai_messages(self.openai_messages) + if not history or history[-1].role != "user": + self.error_occurred.emit("上下文必须以 user 消息结尾") + return + last_user = history.pop() # 本次提问(其余为历史) + + model = self._build_model_config() + cfg = AgentConfig( + model=model, + # 🌟 系统提示词:SYSTEM_PROMPT.md(每次请求头部注入,不入历史) + system_prompt=load_system_prompt() if self.enable_tools else "", + tools=default_tools() if self.enable_tools else [], + tool_context=self.tool_context, + retry=RetryConfig(max_attempts=3, base_delay_ms=2000, factor=2.0), + ) + agent = Agent(cfg) + # 🌟 关键:注入流函数(对照 pi 的 agentLoopConfig.streamFn 注入点)。 + # 漏掉这一步 Agent 会抛 AgentError("未配置 stream_fn")。 + agent.set_stream_fn(openai_stream) + agent.state.messages = list(history) + agent.subscribe(self._bridge) + + summarize = self._make_summarize_fn(model) + runner = AgentRunner(agent, summarize_fn=summarize, + # 🆕 M3: 重试回调 → Qt 信号(跨线程 queued 投递) + on_retry_scheduled=lambda a, m, d, r: + self.retry_scheduled.emit(int(a), int(m), float(d), str(r)), + on_retry_finished=lambda ok: + self.retry_finished.emit(bool(ok)), + # 🆕 压缩开始回调 → 前端动态气泡 + on_compaction_started=lambda p: + self.compaction_started.emit(str(p)), + # 🆕 压缩完成回调 → 气泡即时定格(不等整轮结束) + on_compaction_finished=lambda p: + self.context_compacted.emit(dict(p))) + # 🆕 接线轮中主动压缩检查(haocode 增强,偏离 pi 1:1): + # 内层循环每次 LLM 请求前主动检查,单条巨型工具输出不再依赖 + # 「一次失败往返 + 响应式兜底」 + agent.config.compact_fn = runner.compact_if_needed + self._agent = agent + self._runner = runner + self._compactions_before = 0 + + try: + result = runner.run(last_user) + except Exception as e: + if not self._aborted: + self.error_occurred.emit(f"\n[Agent 异常]: {e}") + return + + if self._aborted: + return # 取消路径:UI 已自行清理,不回退时间线 + + # 注:压缩事件(context_compacted)已由 on_compaction_finished 在压缩结束 + # 瞬间实时发出(气泡即时定格),这里不再重复 flush runner.compaction_events + + if result.error is not None and result.stop_reason == "error": + # 恢复逻辑(重试 + 压缩)全部用尽后的终局错误 + self.error_occurred.emit( + f"\n[API 请求异常]: {result.error.message or '未知错误'}") + + # ------------------------------------------------------------------ + # 供 UI 读取最终状态(入库用) + # ------------------------------------------------------------------ + def final_messages(self) -> List[AgentMessage]: + return list(self._agent.state.messages) if self._agent else [] + + +class ChatWorker(QThread): + """ + chat 模式 —— 普通聊天:单次流式补全(对照旧 LLMWorker 的行为)。 + 不进入 agent 循环:无工具调用、无重试、无压缩。 + worker 模式请用 AgentWorker。 + """ + + chunk_received = pyqtSignal(str) + reasoning_received = pyqtSignal(str) + error_occurred = pyqtSignal(str) + # 🆕 P1: 收到精确 usage(UI 上下文标签 usage 锚定) + usage_updated = pyqtSignal(dict) + + def __init__(self, provider_name: str, model_name: str, + openai_messages: List[Dict[str, Any]], + temperature: Optional[float] = None): + super().__init__() + self.provider_name = provider_name + self.model_name = model_name + self.openai_messages = openai_messages + self._temperature_override = temperature + self.config = _load_config() + self._cancelled = False + + def cancel(self): + self._cancelled = True + + def abort(self): + self._cancelled = True + + def run(self): + p = _provider_info(self.config, self.provider_name) + if not p: + self.error_occurred.emit(f"未找到 provider 配置: {self.provider_name}") + return + model = self._build_model_config(p) + max_tokens = int(p.get("model_max_tokens", {}).get(self.model_name, 8192)) + try: + client = OpenAI(api_key=p.get("api_key", ""), + base_url=p.get("base_url", "https://api.openai.com/v1"), + timeout=model.timeout_seconds, max_retries=0) + response = client.chat.completions.create( + model=self.model_name, + messages=self.openai_messages, + stream=True, + temperature=model.temperature, + max_tokens=max_tokens, + # 🆕 P1: 拿精确 usage 供上下文标签锚定(对照 pi stream_options) + stream_options={"include_usage": True}, + ) + for chunk in response: + if self._cancelled: + try: + response.close() + except Exception: + pass + return + # 🆕 P1/M2: usage 读取(含 choice 兜底) + _u = _pick_usage(chunk) + if _u is not None and ( + getattr(_u, "prompt_tokens", 0) or getattr(_u, "completion_tokens", 0)): + self.usage_updated.emit({ + "input": getattr(_u, "prompt_tokens", 0) or 0, + "output": getattr(_u, "completion_tokens", 0) or 0, + "cacheRead": 0, "cacheWrite": 0, + }) + if not chunk.choices: + continue + delta = chunk.choices[0].delta + # 🆕 M1: 思考字段优先级对照 pi(reasoning_content/reasoning/reasoning_text) + _r = _pick_reasoning(delta) + if _r: + self.reasoning_received.emit(_r) + if delta.content: + self.chunk_received.emit(delta.content) + except Exception as e: + if not self._cancelled: + self.error_occurred.emit(str(e)) + + def _build_model_config(self, p: dict) -> ModelConfig: + context_window = int(p.get("model_contexts", {}).get(self.model_name, 128000)) + temperature = (self._temperature_override + if self._temperature_override is not None + else float(self.config.get("temperature", 0.7))) + return ModelConfig( + provider=self.provider_name, + name=self.model_name, + context_window=context_window, + max_tokens=int(p.get("model_max_tokens", {}).get(self.model_name, 8192)), + temperature=temperature, + api_key=p.get("api_key", ""), + base_url=p.get("base_url", "https://api.openai.com/v1"), + # 🆕 thinking reasoning_content 回传默认开启 + ) + + +class TitleWorker(QThread): + """标题生成:单次流式补全(保留原信号面)""" + + chunk_received = pyqtSignal(str) + error_occurred = pyqtSignal(str) + + def __init__(self, provider_name: str, model_name: str, + openai_messages: List[Dict[str, Any]]): + super().__init__() + self.provider_name = provider_name + self.model_name = model_name + self.openai_messages = openai_messages + self.config = _load_config() + self._cancelled = False + + def cancel(self): + self._cancelled = True + + def run(self): + p = _provider_info(self.config, self.provider_name) + if not p: + return + try: + client = OpenAI(api_key=p.get("api_key", ""), + base_url=p.get("base_url", "https://api.openai.com/v1"), + timeout=60.0, max_retries=0) + temperature = float(self.config.get("temperature", 0.7)) + response = client.chat.completions.create( + model=self.model_name, + messages=self.openai_messages, + stream=True, + temperature=temperature, + max_tokens=60, + ) + for chunk in response: + if self._cancelled: + try: + response.close() + except Exception: + pass + return + if chunk.choices: + delta = chunk.choices[0].delta + if delta.content: + self.chunk_received.emit(delta.content) + except Exception as e: + if not self._cancelled: + self.error_occurred.emit(str(e)) diff --git a/core/platform_shell.py b/core/platform_shell.py new file mode 100644 index 0000000..6130212 --- /dev/null +++ b/core/platform_shell.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +"""P1-02:窄平台进程适配 —— 显式 shell 契约 + 进程树终止 + 提示词平台段。 + +契约(不依赖 shell=True 的平台默认值): +- Windows:字符串命令行 ``cmd.exe /d /s /c ""``(/s 保留内部引号; + 不能传 argv 列表——list2cmdline 的引号转义 cmd 不认) +- Linux: ``/bin/bash -lc ``(登录 shell,可读 profile 别名/函数) + +进程树终止: +- Windows:taskkill /F /T(既有等价机制,整树强杀) +- Linux: start_new_session 独立 POSIX 进程组;SIGTERM 整组 → 宽限期 → SIGKILL 整组 + (安全不变量:仅当 getpgid == 子进程 pid,即确认独立组后才 killpg, + 否则退化为单进程 kill,绝不误杀调用方所在组) + +系统提示词:通用正文 + 运行时插入的短平台 shell/path 段(占位符 +``{{SHELL_PLATFORM_SECTION}}``)。不维护两份完整提示词。 +""" +import os +import signal +import subprocess +import time + +#: 系统提示词中的平台段占位符 +SHELL_SECTION_PLACEHOLDER = "{{SHELL_PLATFORM_SECTION}}" + +_KILL_GRACE_S = 3.0 # Linux:SIGTERM → SIGKILL 宽限 +_TASKKILL_TIMEOUT_S = 10 # Windows:taskkill 自身超时 + + +def is_windows() -> bool: + return os.name == "nt" + + +def shell_command(command: str): + """显式 shell 命令(bash 工具的 shell 契约,替代 shell=True 平台默认值)。 + + - Windows:返回**字符串命令行** ``cmd.exe /d /s /c ""``。 + 必须走字符串而非 argv 列表:列表形态会被 CPython ``list2cmdline`` + 给内部引号加反斜杠转义,cmd.exe 不认 ``\"``;字符串形态下命令行 + 原样交给 CreateProcessW,cmd 自行按 /s 规则解析 /c 参数 + (外层引号剥离、内部引号保留,实测引号路径/&&/管道/%VAR% 均正确)。 + - Linux:返回 argv 列表 ``[\ "/bin/bash", "-lc", command]``(POSIX 下 + 列表参数无引号转义问题,-l 登录 shell 可读 profile 别名/函数)。 + """ + if is_windows(): + return 'cmd.exe /d /s /c "' + command + '"' + return ["/bin/bash", "-lc", command] + + +def popen_flags() -> dict: + """进程树管理所需的 Popen 附加参数:Linux 独立进程组。""" + if is_windows(): + return {} + return {"start_new_session": True} + + +def kill_process_tree(proc, grace_s: float = _KILL_GRACE_S) -> None: + """终止 proc 的整棵子进程树;最坏情况也保证返回(不抛异常)。 + + - Windows:taskkill /F /T 整树强杀(既有机制)。 + - Linux:独立进程组 SIGTERM → 宽限 grace_s 秒 → SIGKILL 整组。 + 若无法确认独立组(进程已退 / 标志未生效)→ 退化单进程 kill。 + """ + if proc is None: + return + + if is_windows(): + try: + subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True, timeout=_TASKKILL_TIMEOUT_S) + except Exception: + try: + proc.kill() + except Exception: + pass + return + + # ---- POSIX ---- + pgid = None + try: + pgid = os.getpgid(proc.pid) + except Exception: + pass + if pgid is None or pgid != proc.pid: + # 进程已退出,或没有独立组 → 不能 killpg(可能误伤调用方组) + try: + proc.kill() + except Exception: + pass + return + + def _pg(sig: int) -> None: + try: + os.killpg(pgid, sig) + except Exception: + pass + + _pg(signal.SIGTERM) + deadline = time.time() + grace_s + while time.time() < deadline: + try: + if proc.poll() is not None: + return + except Exception: + return + time.sleep(0.05) + _pg(signal.SIGKILL) + + +# ---------------------------------------------------------------------- +# 系统提示词平台段(短段,运行时插入;通用正文保持平台无关) +# ---------------------------------------------------------------------- + +_WIN_SECTION = """### 1.1 平台 shell 与路径(Windows) + +- 操作系统:Windows。路径形如 `C:\\Users\\14890\\Desktop\\haocode`;不确定真实路径时先 `dir` / `ls`。 +- bash 命令经 **cmd.exe** 执行(不是 git-bash);`C:\\Program Files\\Git\\usr\\bin` 在 PATH 上, + `ls` `grep` `cat` `head` `tail` `wc` `rm` `sed` `awk` 可直接用,`|` `>` `2>&1` `&&` 可用。 +- ⚠️ cmd 陷阱:`;` 不是分隔符(用 `&&` 或换行);环境变量用 `%VAR%`(`$VAR` 不展开); + 内建命令不剥单引号(`echo 'x'` 原样带引号);每次调用都是新进程,`cd` 不跨调用保留 + (写 `cd core && <命令>`);需要 `for` / `$(...)` / `[ ]` 测试等真实 bash 语义时 + 一律 `bash -c "..."`。""" + +_LINUX_SECTION = """### 1.1 平台 shell 与路径(Linux) + +- 操作系统:Linux。路径为 POSIX 风格(`/home/user/...`、`/tmp/...`)。 +- bash 命令经 **/bin/bash -lc** 执行(登录 shell):`;` `&&` `|` `$(...)`、单引号、 + 环境变量 `$VAR`、profile 里的别名与函数都可用。 +- 每次调用都是新进程:`cd` 不跨调用保留(写 `cd core && <命令>` 或直接用绝对路径)。""" + + +def shell_prompt_section() -> str: + """当前平台的短 shell/path 段。""" + return _WIN_SECTION if is_windows() else _LINUX_SECTION + + +def apply_platform_section(text: str) -> str: + """把占位符替换为当前平台段;无占位符(如兜底提示词)时原样返回。""" + if SHELL_SECTION_PLACEHOLDER in text: + return text.replace(SHELL_SECTION_PLACEHOLDER, shell_prompt_section()) + return text diff --git a/core/renderer_backend.py b/core/renderer_backend.py new file mode 100644 index 0000000..1635422 --- /dev/null +++ b/core/renderer_backend.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""P1-03:渲染器后端选择 + QtWebEngine profile/sandbox 适配(小模块,仅 stdlib,可在导入 PyQt6 前使用)。 + +契约见 docs/agent-handoff/PLATFORM_PLAN.md「浏览器后端」「Chromium sandbox」: + +- 平台矩阵:Windows 首选 WebView2、失败回落 QtWebEngine;非 Windows 只走 QtWebEngine + (非 Windows 不探测/导入/加载 WebView2 —— 由 main_window 的导入门控保证)。 +- ``webview_backend`` 配置值 ∈ {auto, webview2, qtwebengine};非法值 → 可见警告 + + 回落平台默认,绝不阻断源码启动。 +- 每个 QtWebEngine 实例使用独立 profile/storage 目录:源码运行阶段位于项目 + ``data/webengine/`` 范围;自动化测试用 ``HAOCODE_WEBENGINE_PROFILE_DIR`` 重定向到 + 临时目录。实例间不互相清理对方 profile。 +- Chromium sandbox 默认保留。仅当运行者显式在 ``QTWEBENGINE_CHROMIUM_FLAGS`` 中加入 + ``--no-sandbox`` 且当前确认为 root/容器场景时才接受(打印高可见警告); + 普通桌面运行中的 ``--no-sandbox`` 会被剥离并告警。 +""" +import os +import sys +import time + +#: webview_backend 合法配置值 +VALID_BACKENDS = ("auto", "webview2", "qtwebengine") + +_NO_SANDBOX = "--no-sandbox" + + +def is_windows() -> bool: + return sys.platform == "win32" + + +def platform_default_backend() -> str: + """平台默认渲染器:Windows → webview2;其余 → qtwebengine。""" + return "webview2" if is_windows() else "qtwebengine" + + +def resolve_backend(pref) -> "tuple[str, str | None]": + """归一化 ``webview_backend`` 配置(不抛异常)。 + + 返回 ``(backend, warning)``:backend ∈ {"webview2", "qtwebengine"}; + warning 为 None 或需要可见打印的警告(非法值 / 平台不支持)。 + """ + default = platform_default_backend() + if not isinstance(pref, str): + return default, (f"[Renderer] ⚠️ webview_backend 配置非法: {pref!r}" + f"(应为字符串 auto/webview2/qtwebengine)→ 回落平台默认 {default}") + p = pref.strip().lower() + if p not in VALID_BACKENDS: + return default, (f"[Renderer] ⚠️ webview_backend 配置非法: {pref!r}" + f"(允许 {('/'.join(VALID_BACKENDS))})→ 回落平台默认 {default}") + if p == "auto": + return default, None + if p == "webview2" and not is_windows(): + return "qtwebengine", (f"[Renderer] ⚠️ webview_backend=webview2 但当前平台为 {sys.platform}" + f"(WebView2 仅 Windows 支持)→ 回落 qtwebengine") + return p, None + + +def webview2_module(): + """按需加载 core.webview2:非 Windows 永不导入(不触达 pythonnet/Win32/DLL/taskkill)。 + + main_window 在模块级用 `sys.platform == "win32"` 门控导入;本函数供其他入口复用同一门控。 + """ + if not is_windows(): + return None + try: + from core import webview2 + except Exception as e: + print(f"[WV2] 后端不可用(回落 QtWebEngine): {e}") + return None + return webview2 + + +def webengine_profile_name() -> str: + """当前实例的 QtWebEngine profile 名(进程 pid + 启动毫秒时间戳,天然唯一)。 + + 两个并行源码实例 → 两个不同 profile 名 → 两个独立 Chromium profile 目录,互不争用。 + """ + return f"profile_{os.getpid()}_{int(time.time() * 1000)}" + + +def webengine_profile_dir(profile_name: str | None = None) -> str: + """当前实例的独立 profile/storage 目录(源码运行在 data/webengine/;测试经环境变量重定向)。 + + 返回目录已确保存在。本函数【不删除】任何已有目录(实例间不互相清理)。 + """ + name = profile_name or webengine_profile_name() + base = os.environ.get("HAOCODE_WEBENGINE_PROFILE_DIR", "").strip() + if not base: + base = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", "webengine") + d = os.path.join(base, name) + try: + os.makedirs(d, exist_ok=True) + except Exception: + pass + return d + + +# ---------------------------------------------------------------------- +# Chromium sandbox 契约 +# ---------------------------------------------------------------------- + +_CONTAINER_MARKERS = ("/.dockerenv", "/run/.containerenv", "/.lxc") +_CGROUP_HINTS = ("docker", "containerd", "kubepods", "lxc") + + +def is_root_or_container() -> bool: + """root/容器场景探测(仅 POSIX 有意义;Windows 恒 False)。""" + if os.name != "posix": + return False + try: + if os.geteuid() == 0: + return True + except Exception: + pass + for marker in _CONTAINER_MARKERS: + try: + if os.path.exists(marker): + return True + except Exception: + pass + try: + with open("/proc/1/cgroup", "r", encoding="utf-8", errors="replace") as f: + cgroup = f.read() + if any(k in cgroup for k in _CGROUP_HINTS): + return True + except Exception: + pass + return False + + +def sanitize_chromium_flags(flags: str, verbose: bool = True) -> str: + """按契约处理 ``QTWEBENGINE_CHROMIUM_FLAGS`` 中的 ``--no-sandbox``(main.py 在导入 PyQt6 前调用)。 + + - 显式带 ``--no-sandbox``: + - root/容器场景 → 保留 + 高可见风险提示; + - 普通桌面 → 剥离并告警(普通运行不得关闭 sandbox)。 + - 未带 ``--no-sandbox``: + - root/容器场景 → 提示可能需显式设置; + - 其余原样返回。 + """ + def _say(msg: str) -> None: + if verbose: + print(msg, flush=True) + + tokens = (flags or "").split() + has_ns = _NO_SANDBOX in tokens + rootc = is_root_or_container() + if has_ns: + if rootc: + _say("[Renderer] ⚠️⚠️ 高可见警告:已启用 --no-sandbox(显式配置 + 确认为 root/容器场景)。" + "Chromium 沙箱已关闭,攻击面显著增大;仅限 root/容器调试环境," + "普通桌面运行请勿使用。") + return flags + stripped = " ".join(t for t in tokens if t != _NO_SANDBOX) + _say("[Renderer] ⚠️ QTWEBENGINE_CHROMIUM_FLAGS 含 --no-sandbox,但当前是普通桌面运行" + "(非 root/非容器)→ 已剥离该标志并保留 Chromium 沙箱。" + "如确属 root/容器环境,请检查运行环境识别是否正确。") + return stripped + if rootc: + _say("[Renderer] ⚠️ 检测到 root/容器环境:Chromium 沙箱可能无法启动。" + "若页面空白,请显式在 QTWEBENGINE_CHROMIUM_FLAGS 中加入 --no-sandbox" + "(显式选择,启动时会打印风险警告)。") + return flags or "" diff --git a/core/webview2.py b/core/webview2.py new file mode 100644 index 0000000..5742cc2 --- /dev/null +++ b/core/webview2.py @@ -0,0 +1,417 @@ +# -*- coding: utf-8 -*- +""" +WebView2 后端(Windows 首选浏览器内核,失败自动回落 QtWebEngine)。 + +设计要点(来自 P1 实验 tests/_tmp_wv2_demo.py 的实测结论): +1. pythonnet + WebView2 .NET SDK(vendor/webview2/ 内 net462 Core.dll + webview2loader_x64.dll) +2. 必须 OleInitialize(STA)后才能 CreateAsync +3. 本机的 SDK 怪癖:传任何非空 user_data_folder 都报 RuntimeNotFound → 一律用默认 profile(ud=None) +4. 残留 msedgewebview2.exe 会锁默认 profile(0x800700AA)→ 初始化前 taskkill +5. .NET 版 CoreWebView2 不暴露子窗口 HWND → EnumChildWindows 找 Chrome_WidgetWin_* 类 +6. 子窗口天然是 Qt 顶层窗口的子 HWND,不需要 QWindow.fromWinId 包装(对子窗口会失败), + 由 Qt 布局算 slot 矩形后用 SetBoundsAndZoomFactor 驱动(父窗客户区物理像素) +7. JS→Python 用 WebMessageReceived(JSON),Python→JS 用 ExecuteScriptAsync(与现有 + ChatBridge.run_js 生成的 JS 调用文本完全同构,前端零改动;仅 index.html 的 + window.bridge bootstrap 走双协议) +""" +import os +import sys +import time +import shutil +import subprocess + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WV2_DIR = os.path.join(ROOT, "vendor", "webview2") +CORE_DLL = os.path.join(WV2_DIR, "net462_Microsoft.Web.WebView2.Core.dll") +LOADER = os.path.join(WV2_DIR, "webview2loader_x64.dll") + +_env = None +_System = None # pythonnet 加载后缓存 +_INSTANCE_LOCK = {"fh": None} + + +def _instance_lock_path(): + # 可覆盖(测试隔离用:HAOCODE_INSTANCE_LOCK_FILE 指向临时文件, + # 否则测试会与正在运行的 app 争同一把锁 → 断言依赖环境) + p = os.environ.get("HAOCODE_INSTANCE_LOCK_FILE") + if p: + return p + d = os.path.join(ROOT, "data") + try: + os.makedirs(d, exist_ok=True) + except Exception: + pass + return os.path.join(d, "app_instance.lock") + + +def acquire_instance_lock(): + """独占 data/app_instance.lock(非阻塞)。 + + 返回 True = 本机唯一实例 → 可安全 taskkill 残留 msedgewebview2.exe + False = 已有实例在跑 → 必须回落 QtWebEngine + None = 平台不支持/异常(保守当“不确定”处理) + + 🐛 T0 根因:本函数存在之前,每个 MainWindow()(包括 offscreen 测试)都会走到 + _get_environment() 里的 `taskkill /F /IM msedgewebview2.exe`, + 把【当时正在运行的生产 app】的 WebView2 浏览器进程一并杀掉 + → 它的 controller 变 disposed(set_bounds 报 0x8007139F) + → DOM 照渲染但视觉层永久空白(= “选中会话不渲染核心内容”)。 + """ + if sys.platform != "win32": + return None + if _INSTANCE_LOCK["fh"] is not None: + return True + fh = None + try: + import msvcrt + fh = open(_instance_lock_path(), "a+b") + fh.seek(0) # 固定锁位置(锁定前 1 字节),不依赖 append 模式的当前位置 + msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) + _INSTANCE_LOCK["fh"] = fh + return True + except Exception: + try: + if fh is not None: + fh.close() + except Exception: + pass + return False + + +def _wv2_allowed_here() -> bool: + """无头/测试环境一律不得碰 WebView2(共享默认 profile,一碰就可能误杀在生产实例)""" + if os.environ.get("HAOCODE_FORCE_QTWEBENGINE", "") in ("1", "true", "True"): + return False + plat = (os.environ.get("QT_QPA_PLATFORM") or "").strip().lower() + if plat and plat != "windows": + return False + return True + + +def _pump_wait(op, app, timeout=15.0): + """在 Qt 事件循环里等 .NET Task 完成(WebView2 初始化依赖消息泵)""" + t0 = time.time() + while not op.IsCompleted and time.time() - t0 < timeout: + app.processEvents() + time.sleep(0.005) + if not op.IsCompleted: + raise TimeoutError("WebView2 operation timed out") + if op.IsFaulted: + raise RuntimeError(str(op.Exception)) + return op.Result + + +def _ts(): + """启动链时间戳(WebView2 冷启动耗时诊断,保留)""" + t = time.time() + return time.strftime("%H:%M:%S", time.localtime(t)) + f".{int(t*1000) % 1000:03d}" + + +def get_environment(app): + """初始化并返回 CoreWebView2Environment 单例;任何失败返回 None(调用方回落 QtWebEngine)""" + global _env + if _env is not None: + return _env + if sys.platform != "win32": + return None + if not (os.path.exists(CORE_DLL) and os.path.exists(LOADER)): + return None + # 🛡 守卫 1:无头/测试环境(QT_QPA_PLATFORM=offscreen 等)绝不启用 WebView2 + if not _wv2_allowed_here(): + print(f"[WV2] QT_QPA_PLATFORM={os.environ.get('QT_QPA_PLATFORM')!r} → 跳过 WebView2," + f"回落 QtWebEngine(无头环境不得触碰共享 profile)") + return None + # 🛡 守卫 2:本机已有实例在跑 → 不启用 WebView2、更不 taskkill(否则会把它的 + # 浏览器进程杀掉 → 对方 controller disposed → 聊天区永久空白) + try: + _lock_ok = acquire_instance_lock() + except Exception: + _lock_ok = None + if _lock_ok is not True: + print("[WV2] 检测到已有 haocode 实例在运行(instance lock 被占)→ " + "本实例回落 QtWebEngine;已跳过 taskkill,不会影响对方渲染") + return None + try: + os.environ["PATH"] = WV2_DIR + ";" + os.environ.get("PATH", "") + try: + os.add_dll_directory(WV2_DIR) + except Exception: + pass + # .NET Core 的 P/Invoke 默认不查 CWD → 把 loader 复制到 CWD 一份 + try: + cwd_loader = os.path.join(os.getcwd(), "WebView2Loader.dll") + if not os.path.exists(cwd_loader): + shutil.copyfile(LOADER, cwd_loader) + except Exception: + pass + import ctypes + ctypes.windll.ole32.OleInitialize(None) # STA(COM 初始化要求) + # 清残留浏览器进程(锁默认 profile 会导致 0x800700AA) + # 🛡 只有【本机唯一实例】才会走到这里(已在上面用 instance lock 保证), + # 否则会把兄弟实例的浏览器进程杀掉 → 对方控制器 disposed → 聊天区空白 + print(f"[WV2] {_ts()} warmup: taskkill 残留进程(唯一实例,安全)...") + try: + subprocess.run(["taskkill", "/F", "/IM", "msedgewebview2.exe"], + capture_output=True, timeout=10) + time.sleep(1.0) + except Exception: + pass + import clr + global _System + # 🐛 部分机器(本机 D: 卷).NET Framework 把工程所在本地卷误判为 + # “网络位置”,clr.AddReference(= Assembly.LoadFrom)报 0x80131515。 + # 快路径保持 LoadFrom(正常机器行为不变);失败回落 Assembly.Load(byte[]), + # 字节加载不做路径信任分类(本机实测:D: 字节加载 OK,类型解析 OK)。 + try: + clr.AddReference(CORE_DLL) + except BaseException as _ex: # pythonnet 的 .NET 异常未必是 Python Exception 子类 + _msg = str(_ex).splitlines()[0][:120] if str(_ex) else type(_ex).__name__ + print(f"[WV2] AddReference 路径加载失败({_msg})→ 回落 Assembly.Load(byte[])") + from System.Reflection import Assembly as _Assembly + with open(CORE_DLL, "rb") as _f: + _Assembly.Load(_f.read()) + import System + _System = System + from Microsoft.Web.WebView2.Core import CoreWebView2Environment + # ⚠️ ud=None:本机 SDK 对非空 user_data 路径报 RuntimeNotFound(实测怪癖) + _env = _pump_wait(CoreWebView2Environment.CreateAsync(None, None, None), app) + print(f"[WV2] {_ts()} Runtime ready: {_env.BrowserVersionString}") + return _env + except BaseException as ex: # pythonnet/.NET 异常未必是 Exception 子类,必须 BaseException + _env = None + print(f"[WV2] init failed → fallback to QtWebEngine: {ex}") + return None + + +class Wv2Session: + """一个 WebView2 实例:controller + 子窗口 + 消息泵 + bounds 驱动""" + + def __init__(self, env, parent_hwnd, app): + self.app = app + print(f"[WV2] {_ts()} controller create begin (hwnd={parent_hwnd:#x})") + self.controller = _pump_wait( + env.CreateCoreWebView2ControllerAsync(_System.IntPtr(int(parent_hwnd))), app) + print(f"[WV2] {_ts()} controller ready") + self.core = self.controller.CoreWebView2 + try: + self.core.Settings.AreDefaultContextMenusEnabled = False + self.core.Settings.IsZoomControlEnabled = False + self.core.Settings.IsStatusBarEnabled = False + except Exception: + pass + self.child_hwnd = 0 + self.on_message = None # callable(dict) —— main_window 绑定到 ChatBridge + self.on_load_finished = None # callable() —— WebView2View 绑定 loadFinished 信号 + self._msg_handler = self._make_msg_handler() + self.core.add_WebMessageReceived(self._msg_handler) + self._nw_handler = self._make_navigated_handler() + self.core.add_NavigationCompleted(self._nw_handler) + # 🆕 真异步 JS 执行队列:单一定时器轮询完成,回调从主线程定时器发出。 + # (旧版 execute_js_async 是阻塞忙等 + processEvents → 从定时器/事件回调里调用 + # 时产生重入嵌套事件循环 → COM 事件分发崩溃 → 启动卡死/拖动时 webview 不刷新) + self._js_pending = [] # [task, cb, t0] + from PyQt6.QtCore import QTimer + self._js_pump = QTimer() + self._js_pump.setInterval(25) + self._js_pump.timeout.connect(self._js_pump_tick) + self._js_pump.start() + # 🆕 预热:立即导航 about:blank,让 msedgewebview2 进程/GPU 在 UI 构建期间冷启动 + # (实测本机首次真实页面导航需 12-15s,预热后降到 ~1s) + try: + print(f"[WV2] {_ts()} warmup: Navigate about:blank") + self.core.Navigate("about:blank") + except Exception as ex: + print("[WV2] warmup navigate error:", ex) + + # ---------- 事件 ---------- + def _make_msg_handler(self): + import json + from System import EventHandler + from Microsoft.Web.WebView2.Core import CoreWebView2WebMessageReceivedEventArgs + + def handler(sender, args): + try: + data = json.loads(args.WebMessageAsJson) + if self.on_message: + self.on_message(data) + except Exception as ex: + print("[WV2] message error:", ex) + return EventHandler[CoreWebView2WebMessageReceivedEventArgs](handler) + + def _make_navigated_handler(self): + from System import EventHandler + from Microsoft.Web.WebView2.Core import CoreWebView2NavigationCompletedEventArgs + + def handler(sender, args): + try: + src = self.core.Source or "" + except Exception: + src = "?" + print(f"[WV2] {_ts()} NavigationCompleted src={src}") + # 预热页(about:blank)的加载完成不触发 loadFinished(避免 JS 探针空转) + if src.startswith("about:blank"): + return + if self.on_load_finished: + self.on_load_finished() + return EventHandler[CoreWebView2NavigationCompletedEventArgs](handler) + + # ---------- 子窗口发现(.NET 不暴露 HWND,轮询枚举) ---------- + def find_child_once(self): + """单次探测;找到返回 hwnd,否则 0(非阻塞,供 UI 线程定时器调)""" + import ctypes + import ctypes.wintypes as wt + u32 = ctypes.windll.user32 + parent = wt.HWND(self.controller.ParentWindow.ToInt64()) + found = [] + + @ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPVOID) + def cb(h, _): + buf = ctypes.create_unicode_buffer(256) + u32.GetClassNameW(h, buf, 256) + if buf.value.startswith("Chrome_WidgetWin"): + found.append(h) + return True + + u32.EnumChildWindows(parent, cb, None) + if found: + self.child_hwnd = int(found[0]) + return self.child_hwnd + return 0 + + def find_child(self): + """同步等待并返回 Chrome_WidgetWin_* 子 HWND(最多 10s)""" + import ctypes + import ctypes.wintypes as wt + u32 = ctypes.windll.user32 + parent = wt.HWND(self.controller.ParentWindow.ToInt64()) + for _ in range(200): + found = [] + + @ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPVOID) + def cb(h, _): + buf = ctypes.create_unicode_buffer(256) + u32.GetClassNameW(h, buf, 256) + if buf.value.startswith("Chrome_WidgetWin"): + found.append(h) + return True + + u32.EnumChildWindows(parent, cb, None) + if found: + self.child_hwnd = int(found[0]) + return self.child_hwnd + self.app.processEvents() + time.sleep(0.05) + return 0 + + # ---------- 几何 ---------- + def set_visible(self, visible: bool): + try: + self.controller.IsVisible = bool(visible) + except Exception: + pass + + def set_bounds(self, left, top, width, height): + """父窗客户区物理像素""" + try: + from System.Drawing import Rectangle as _Rect + self.controller.SetBoundsAndZoomFactor(_Rect(int(left), int(top), + int(width), int(height)), 1.0) + except Exception as ex: + print("[WV2] set_bounds error:", ex) + + def child_size(self): + """子窗口当前屏幕像素尺寸(观察用)""" + if not self.child_hwnd: + return (0, 0) + import ctypes + import ctypes.wintypes as wt + r = wt.RECT() + if ctypes.windll.user32.GetWindowRect(wt.HWND(self.child_hwnd), ctypes.byref(r)): + return (r.right - r.left, r.bottom - r.top) + return (0, 0) + + # ---------- JS ---------- + def navigate(self, url: str): + print(f"[WV2] {_ts()} navigate {url[:80]}") + try: + self.core.Navigate(url) + except Exception as ex: + print("[WV2] navigate error:", ex) + + def execute_js(self, script: str): + """fire-and-forget(ChatBridge.run_js 的替换,JS 文本完全同构)""" + self._js_run(script, None) + + def execute_js_async(self, script: str, cb): + """带回调执行:真异步,回调在主线程定时器 tick 中发出(绝不阻塞)""" + self._js_run(script, cb) + + def _js_run(self, script: str, cb): + try: + task = self.core.ExecuteScriptWithResultAsync(script) + except Exception as ex: + print("[WV2] execute_js error:", ex) + if cb: + try: + cb(None) + except Exception: + pass + return + self._js_pending.append([task, cb, time.time()]) + + def _js_pump_tick(self): + if not self._js_pending: + return + import json + remaining = [] + for task, cb, t0 in self._js_pending: + done = False + try: + done = task.IsCompleted + except Exception: + done = True + if not done and time.time() - t0 > 10: + done = True # 10s 安全超时(渲染器死亡时不永久卡队列) + if not done: + remaining.append([task, cb, t0]) + continue + result = None + try: + if task.IsCompleted and not task.IsFaulted: + r = task.Result # CoreWebView2ExecuteScriptResult 包装结构 + if getattr(r, "Succeeded", True): + s = r.ResultAsJson # JSON 编码字符串(或 null) + if s: + result = json.loads(s) + if isinstance(result, str): + try: + result = json.loads(result) + except Exception: + pass + except Exception as ex: + print("[WV2] js result decode error:", ex) + if cb: + try: + cb(result) + except Exception as ex: + print("[WV2] js callback error:", ex) + self._js_pending = remaining + + def close(self): + try: + self._js_pump.stop() + except Exception: + pass + self._js_pending = [] + try: + self.core.remove_WebMessageReceived(self._msg_handler) + except Exception: + pass + try: + self.core.remove_NavigationCompleted(self._nw_handler) + except Exception: + pass + try: + self.controller.Close() + except Exception: + pass diff --git a/docs/agent-handoff/CURRENT_STATE.md b/docs/agent-handoff/CURRENT_STATE.md new file mode 100644 index 0000000..7ce636a --- /dev/null +++ b/docs/agent-handoff/CURRENT_STATE.md @@ -0,0 +1,110 @@ +# 当前代码状态与结构审计 + +> 用途:处理结构、命名、模块边界或技术债任务前读取。本文只记录已经由源码确认的现状;精确实现仍以当前源码为准。修复顺序和验收要求见 [REPAIR_BACKLOG.md](REPAIR_BACKLOG.md) 与 [VERIFICATION.md](VERIFICATION.md)。 + +## 结论 + +项目的目录分层和文件命名总体符合 Python、JavaScript 与 Qt 项目的常见习惯,不需要为了“看起来规范”而批量改名或搬目录。当前主要风险不在命名,而在以下位置: + +- `ui/views/main_window.py` 承担窗口组装、浏览器选择、会话、附件、截图、弹层和大量事件处理,已经形成高耦合中心。 +- `ui/web/app.js` 同时负责渲染、流式更新、历史、分支和交互状态,Python 与 JavaScript 之间没有显式协议定义。 +- Windows 专用的 WebView2、全局热键、进程终止和路径假设尚未被完整隔离,Linux 不能仅凭“Python 跨平台”获得支持。 +- 测试入口聚合不完整(P2-04 待实施);配置重定向与运行时数据隔离已由 P0-01 统一(见 [REPAIR_BACKLOG.md](REPAIR_BACKLOG.md) P0-01 与 evidence/P0-01.md)。 +- 根目录旧文档混有过时描述;它们不能继续作为实现依据。 + +当前阶段允许修复错误、添加测试和添加小型平台适配器,但不移动或拆分现有模块。超大文件是已记录技术债,不是本轮重构授权。 + +## 目录职责 + +| 路径 | 当前职责 | 审计判断 | +| --- | --- | --- | +| `main.py` | 进程入口、标准输出保护、QtWebEngine 启动参数、`QApplication` 和主窗口创建 | 入口职责基本合理;渲染参数需要区分平台 | +| `core/` | 数据库、LLM 工作线程、日志和 WebView2 后端 | 名称规范;仍含 Windows 专用实现和配置路径旁路 | +| `core/agent/` | 与 pi 对齐的 agent 循环、上下文、压缩、恢复和内置工具 | 应继续保持 GUI-free、可离线测试 | +| `ui/views/` | PyQt6 窗口、桥接层和浏览器包装 | 边界最薄弱;`main_window.py` 是主要风险点 | +| `ui/views/system_tools/` | 文件读取、全局热键、屏幕截图 | 适合作为平台适配落点;当前实现以 Windows 为中心 | +| `ui/web/` | 离线 HTML/CSS/JavaScript 聊天界面和 vendored 前端库 | 无需构建步骤;历史窗口状态机尚未独立 | +| `tools/builtin_tools/` | 面向 agent 的独立实用工具 | 当前仅有 PDF 读取器;与其他两类“工具”需用全路径区分 | +| `tests/` | 纯逻辑测试、offscreen smoke、诊断与人工验证脚本 | 命名前缀有约定,但聚合入口没有覆盖全部自动化测试 | +| `data/` | 源码运行时的配置、数据库和附件 | 本阶段保留项目内路径;凭据处理遵循 [README.md](README.md) 的硬性边界 | +| `vendor/webview2/`、`WebView2Loader.dll` | Windows WebView2 运行依赖 | 只允许 Windows 路径加载,不能成为 Linux 启动前置条件 | +| `svg/` | UI 图标 | 目录职责清晰 | + +## 命名规范审计 + +| 范围 | 现状 | 结论 | +| --- | --- | --- | +| Python 文件、函数、变量 | 基本使用 `snake_case` | 符合 PEP 8 常规写法 | +| Python 类 | 基本使用 `PascalCase` | 符合惯例 | +| Qt 覆盖方法 | 使用 `eventFilter`、`closeEvent` 等 Qt 固定名称 | 正确例外,不应改成 `snake_case` | +| QWebChannel/JS 可调用接口 | 存在 camelCase 名称 | 跨语言协议名称可保留,但必须集中记录 | +| JavaScript | 主要使用 `camelCase` | 符合惯例 | +| CSS | 选择器和属性使用 Web 常规形式 | 符合惯例 | +| 测试文件 | `test_*`、`smoke_*`、`diag_*`、`verify_*`、`tune_*` | 前缀表达运行性质,约定合理 | + +以下名称有可读性或发布规范问题,但不应在当前修复阶段批量改名: + +- `Frame.md` 语义过宽,且与 `readme.md`、`ARCHITECTURE.md` 的职责重叠。旧文档只作历史资料。 +- `readme.md` 的大小写不影响源码运行;是否改成 `README.md` 留到仓库整理阶段。 +- `db_manager.py`、`llm_engine.py` 等名称合规,但 “manager/engine” 隐藏了较宽职责;先通过边界文档约束新增代码。 +- `core/agent` 的版本字符串不是标准 PEP 440 形式;打包阶段再统一。 +- “工具”同时指 agent 的 `read/bash/write/edit`、`tools/builtin_tools/` 实用工具和 `ui/views/system_tools/` 桌面集成。任务与文档必须使用完整路径或明确类别,不能只写“tool”。 + +## 已确认的结构与行为缺陷 + +### 配置和测试隔离 + +- 已解决(P0-01):所有运行时配置读取统一经 `core/config_paths`(`HAOCODE_CONFIG_FILE` 优先);`tests/test_config_isolation.py` 用打开路径拦截器证明真实配置从未被打开。 +- 新测试必须在导入 `MainWindow` 及相关模块前同时重定向数据库和配置(统一用 `tests/_test_env.py` 的 `isolate()`),并检查被测模块是否缓存了路径常量。 +- 少数 GUI 测试(`smoke_offscreen.py`、`smoke_mode.py`、`smoke_copy_session.py`)自身只重定向数据库、未设置 `HAOCODE_CONFIG_FILE`;独立运行时由环境注入临时配置,P2-04 聚合入口将按子进程强制注入。 +- `tests/run_tests.py` 当前不是全套测试聚合器,不能把一次成功运行等同于整个 `tests/` 目录通过。 + +### UI 和事件处理 + +- 已解决(P0-02):`MainWindow` 重复定义的 `eventFilter` 已合并为一份;发送规则收进 `send_message(from_enter=...)` 单一实现(见 evidence/P0-02.md)。 +- UI 层存在直接 SQL 和跨模块私有成员访问,导致数据库、窗口和渲染状态相互渗透。当前只修复会造成实际错误的调用,不展开分层重构。 +- 原生窗口型 WebView2 与 Qt 弹层的遮挡关系需要逐个验证;重命名弹层是已确认案例,不能假定所有 QWidget 弹层都会自然显示在 WebView2 之上。 + +### Python 与 JavaScript 边界 + +- 当前通过 `ui/views/chat_bridge.py`、直接 JavaScript 执行以及硬编码函数名传递状态,没有协议版本、载荷 schema 或统一错误回传。 +- 当前没有“按边界消息 ID + 方向”请求历史页的 JS→Python 协议。现有消息缓冲也不足以重建附件、时间线、工具输出和分支关系完整的历史项。 +- 建立历史滑动窗口时,必须传递完整消息描述符,并用会话/分支 generation 丢弃过期响应;不能让前端自行拼接不完整历史。 +- 跨语言公开名称一旦落地即视为协议。实现 Agent 应把请求、响应、错误和重置事件集中列在同一处,并用契约测试锁定。 + +### 平台边界 + +- `core/webview2.py`、`ui/views/wv2_view.py` 和相关进程处理是 Windows 专用路径;Linux 只能选择 QtWebEngine。 +- `ui/views/system_tools/global_hotkey.py` 使用 Windows 原生 API,Linux 导入和运行路径尚未隔离。 +- agent 的 bash 工具使用隐式 shell 选择:Windows 通常落到 `cmd.exe`,Linux 通常落到 `/bin/sh`,不满足既定的 `/bin/bash -lc` 契约。 +- Windows 使用进程树终止手段;POSIX 侧尚无对完整进程组的等价取消/超时保证。 +- 项目中的 Windows 路径、动态库和 WebView2 探测不能在 Linux 启动阶段被无条件访问。 + +### 文档和仓库状态 + +- `ARCHITECTURE.md` 仍含旧产品名和失效目录;误拼接的外部修复文档已经移除,文件顶部已明确标记为历史资料。 +- 第三方修复要求提到的 `haocode.spec`、`pyi_rth_trace.py`、`tests/diag_render_scale.py`、`tests/diag_panel_scrollbar.py` 和 `tests/diag_rename_overlay.py` 当前不存在。上述三个 `tests/diag_*` 脚本可按任务新建;两个打包文件属于后续阶段。 +- 工作区没有可用 Git 历史,不能引用不存在的基线提交,也不应擅自初始化仓库。任务清单仍按可独立提交的粒度书写,供未来接入版本控制。 +- 源码树存在 `__pycache__`、数据库、日志和锁等运行产物。当前不做清理工程;测试必须使用临时位置,避免继续污染生产数据。 + +## 应保持的边界 + +1. `core/agent/` 保持 GUI-free;纯 agent 行为可不创建 `QApplication` 直接测试。 +2. 浏览器差异留在 WebView2/QtWebEngine 适配层,不把后端判断散落到业务逻辑。 +3. 系统热键、截图和 shell 通过最小平台适配接口选择实现;Windows 模块与 Linux 模块只在对应平台延迟导入。 +4. 数据库存取继续由 `core/db_manager.py` 承担;新的 UI 功能不要增加直接 SQL。 +5. Python↔JavaScript 载荷使用完整、可测试的描述符;DOM 只保存当前窗口,不承担持久化或完整会话真相。 +6. 运行时秘密边界、任务范围和验证要求分别以本目录的 `README.md`、`REPAIR_BACKLOG.md` 和 `VERIFICATION.md` 为准。 + +## 本阶段明确延后 + +- 拆分 `main_window.py`、`app.js` 或迁移现有模块。 +- 批量重命名文件、类或公开跨语言接口。 +- PyInstaller/其他打包配置、安装器、AppData/XDG 数据目录迁移。 +- WebKitGTK 或其 Qt 封装。 +- 通用键盘钩子框架;Linux 只实现现有截图快捷键需要的能力。 +- agent shell 沙盒、命令审批、路径权限边界。 +- 新工具注册系统、参考其他 harness 增加功能或改变 pi 对齐目标。 +- Ubuntu 以外 Linux 发行版的支持承诺。 + +结构或命名任务只有在“发现的问题已逐项归类为确定缺陷、已接受技术债或明确延后,并且没有借机移动/拆分模块”时才算审计完成。 diff --git a/docs/agent-handoff/EXECUTION_STATE.md b/docs/agent-handoff/EXECUTION_STATE.md new file mode 100644 index 0000000..391dbba --- /dev/null +++ b/docs/agent-handoff/EXECUTION_STATE.md @@ -0,0 +1,65 @@ +# 全量修复执行状态 + +状态:ACTIVE +总目标:完成 REPAIR_BACKLOG.md 中所有待实施任务 + +## 调试铁律(跨压缩持久,每次恢复后必须遵守) + +- **所有调试命令、测试与诊断脚本执行都必须设置显式的最长耗时预算(timeout)**。 +- 若中途因超时跳出,先定位卡点,再允许延长预算重跑;**严禁不设超时让它无限卡死**。 + +当前任务:全部完成(P0-01 → P2-04) +当前阶段:COMPLETE(自动化部分;Windows 真机双渲染路径已验证;Linux 真机平台验证项见各任务 evidence) +最后完成动作:Windows 真机验证 —— ① `core/webview2.py` 加 byte[] 加载回落(本机 D: 卷 .NET “网络位置”怪癖),真桌面 WebView2 启动成功(Runtime 153.0.4234.32、controller ready、NavigationCompleted、截图像素证实聊天区真实 DOM 渲染);② `HAOCODE_FORCE_QTWEBENGINE=1` 真桌面回落验证(独立 per-instance profile、无 taskkill、渲染正常);③ P2-02 真机截图(diag_panel_scrollbar 真桌面 ALL PASS,三图已更新);④ T0 守卫真机交叉验证(diag 实例被锁自动回落,未误杀主程序 WebView2);证据更新 evidence/P1-03.md、evidence/P2-02.md、evidence/P2-03.md +下一步唯一动作:P2-03 人工清单 1–5 可在本机真实 WebView2 模式下走查(app 现可正常以 WV2 启动,当前前台实例为强制 QtWebEngine 模式,重开即默认 WV2);其余为 Linux 真机验证(P1-03/P1-04),需 Ubuntu 桌面主机 +当前修改文件:core/webview2.py(byte[] 加载回落 + BaseException 防护)、docs/agent-handoff/evidence/{P1-03,P2-02,P2-03}.md、evidence/win_real_*_mainwindow.png(新) +最近测试结果:test_wv2_guard 10/10(修复后无回归);diag_panel_scrollbar 真桌面 ALL PASS;run_all 25/25(前次终态) +尚未验证的平台:Linux X11、Linux Wayland(P1-03 启动链、P1-04 X11 XGrabKey 真实注册/命中、Wayland portal 三态均需真机);Windows 真机双渲染路径(WebView2 + QtWebEngine 回落)已于 2026-09-17 验证,仅剩 P2-03 人工遮罩走查(1–5,可本机执行) +阻塞项:无 +观察项: +- 【P1-01 教训·offscreen QtWebEngine 诊断】未 `window.resize()`+`show()` 前 `innerHeight=0`,锚点/滚动几何全废;诊断必须先 resize+show、等待 `innerHeight>0`、再显式 `load_messages_to_web` 重载 +- 【P1-01 教训】`runJavaScript` 回调不能返回 DOM 元素(转换失败);`wait_until` 条件一律返回原语(`cond ? 1 : 0`) +- 【P1-01 教训】批次渲染守卫(`__rwPageRendering`)必须在**调度时刻**捕获,延迟回调(rAF+setTimeout)触发时批次已结束、标志已复位,届时再读会漏放 `scrollIntoView` 触发 'newer' 振荡 +- 【P1-01 设计】页 = 半窗(`size//2`),非整窗:整窗页会使向上换页锚点必被裁出窗口,≤2px 锚点恢复不可达 +- 【P1-02 教训】Windows 下 argv 列表形态 Popen 会被 `list2cmdline` 转义内部引号(`\"`),cmd.exe 不认 → 显式 cmd 契约必须用**字符串命令行** `cmd.exe /d /s /c ""` +- 【P1-02 发现】旧 `shell=True` 对带引号程序名实为直接 CreateProcessW(不经 cmd);显式 cmd 后行为统一且可预测 +- 【P1-03 教训】`QTimer.singleShot` 单位是**毫秒**(25 = 25ms),兜底预算要写 30000;事件循环启动前不触发 +- 【P1-03 教训】QtWebEngine 顺序硬约束:QtWebEngineWidgets 必须先于 QApplication 导入;QWebEngineProfile 必须先于使用它的 page/view 创建,且 setPersistentStoragePath/setCachePath 要在 profile 使用前调 +- 【P1-03 发现】PyQt6-WebEngine 6.10 未暴露 `QWebEnginePage.errorOccurred`(Qt 6.5+ API),加载失败诊断用 loadFinished(ok) + processEvents 循环 +- 【P1-03 教训】改构造函数签名必须保旧式位置调用:`CustomWebPage(browser)` 的 view 会被新首形参误当 profile,按 `isinstance(QWebEngineProfile)` 分派 +- 【P1-04 教训】PyQt6:`QThread.run()` 内任何未处理异常 = **abort 整个进程**(退出码 127、无 traceback、stdout 缓冲丢失,极难诊断)→ QThread.run() 必须顶层 try/except 全捕获 + 日志 +- 【P1-04 教训】`ctypes.byref(x)` 返回 CArgObject:真实 CDLL 调用正常,传给测试替身(普通可调用对象)会 TypeError —— 替身端 `getattr(arg, "_obj", arg)` 还原原对象 +- 【P1-04 教训】Windows 上 `os.pipe()` 的 fd 不能可靠用于 select()(10093/10038)——跨平台可 select 假 fd 用 `socket.socketpair()`;且 `a.send()` 的数据在 b 的接收缓冲(方向别写反) +- 【P1-04 发现】`file://` URI 解析用剥前缀+unquote 而非 urlparse().path:`file://D%3A%5Cx`(无第三斜杠)会被当 netloc;gdbus portal 的 FilePicked/Finished 信号都发在 request 对象上 +- 【P2-01 教训】Qt:对刚展开、布局尚未 flush 的 QPlainTextEdit 立即设水平滚动值,随后的挂起 resize flush 会把滚动清零——真实用户无法在未渲染框上滚动,属测试时序伪影;测试设滚动前必须先 settle(N1×N2 探针矩阵证实 N2≥1 即稳定) +- 【P2-01 教训】offscreen harness 收尾用 `os._exit`:`sys.exit` 后 QtWebEngine 渲染/GPU 子进程可能不回收 → 解释器挂起至 timeout;管道执行时只看输出会误判通过(必须验 EXIT 码) +- 【P2-02 教训】offscreen 下 QPlainTextEdit 的 `grab()` 文档区不填充(黑图)但 `panel.grab()` 正常;像素断言一律走 `widget.render(painter)`(渲染到透明 QPixmap),且先做健全性检查(文本色/handle 色像素计数>0)防黑图假通过 +- 【P2-02 教训】PyQt6:`Qt.Vertical`→`Qt.Orientation.Vertical`;`QStyleOptionSlider()` 无参构造(不接受 widget);`subControlRect(cc, opt, sc, widget)` 参数序 + 滚动条箭头子控件枚举名是 `SC_ScrollBarSubLine/AddLine`;`CC_ScrollBar` 属于 `QStyle.ComplexControl` +- 【P2-02 发现】裸控件(无样式表祖先)`style()` 是基础风格,空 option 下 subControlRect 返回 0 矩形——「原生参照」只能靠 sizeHint/实际几何对照(原生横条 14px vs 面板 8px) +- 【P2-03 发现】offscreen/无真实事件循环时 `processEvents()` **不处理 DeferredDelete**:`deleteLater()` 后必须显式 `QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)` 才会真正删除(offscreen 测试断言"已删除"前必须冲刷;生产事件循环常驻不受影响) +- 【P2-03 教训】PyQt6 `setGeometry` 无 `(QPoint, QSize)` 重载→构造 `QRect(tl,size)`;`QTest.mouseClick(widget, button, modifier, pos)` 第 3 参是 **modifier**;`QTest` 在独立模块 `PyQt6.QtTest` +- 【P2-03 发现】`core/webview2.py` `get_environment()` 含 `taskkill /F /IM msedgewebview2.exe` + 共享默认 profile 不可重定向 → 无人值守自动化不得走真实 WebView2 启动路径;真机验收走人工清单(拥有者顶层 Tool 窗 z 序上必盖 WebView2 原生子 HWND,机制与生产已验证的 AttachmentPreviewOverlay 同构) +- 【P2-03 结构判据】顶层窗可带 owner parent:`setWindowFlags(Tool|Frameless)` 后 `parentWidget()` 仍可非 None;判据是 `isWindow()` / `window() is self`,不是 `parentWidget() is None` +- 本机无系统级 Python 3.10;已用 uv 安装 CPython 3.10.21(用户目录托管)并重建项目内 `.venv`(Python 3.10.21),与文档基线对齐 +- WSL 存在 Ubuntu-22.04(当前 Stopped,python3.12.3,未装 PyQt6);未检测到 X11/Wayland 桌面会话 +- `tests/diag_live_agent.py:19`、`tests/tune_model_popup.py:155` 直接引用真实配置路径;属 live/tune 脚本,默认聚合排除,不属 P0-01 允许范围 +- `readme.md` 提及的 `haocode.spec`/`pyi_rth_trace.py` 不在本快照内(打包属后续阶段,不影响源码运行) + +环境探测(只读): +- OS:Windows 11 10.0.26200 x64 +- Python:默认 3.13 / 3.12.10,无 3.10;`.venv` = **3.10.21**(uv 重建,P0-01)+ PyQt6 6.10.2 + PyQt6-WebEngine 6.10.0 + openai 2.26.0 + pythonnet 3.1.0 + PyMuPDF 1.28.0 +- WebView2 Runtime:153.0.4234.32 存在 +- WSL:Ubuntu-22.04(Stopped)、docker-desktop(Running) + +任务状态表: +- P0-01: COMPLETE +- P0-02: COMPLETE +- P0-03: COMPLETE(2026-09-16 复核通过,文档一致性修订已固化) +- P1-01: COMPLETE +- P1-02: COMPLETE(Windows 侧自动化全绿;Linux 进程组用例 C3 待 Linux 环境运行生效) +- P1-03: COMPLETE(Windows 侧自动化 + offscreen 真实启动链全绿;Linux 真机与 root/容器 `--no-sandbox` 接受路径待对应环境验证) +- P1-04: COMPLETE(Windows 侧自动化全绿:路由矩阵/X11 替身/portal 替身/offscreen 真实启动链;Linux X11 XGrabKey 真实注册与 Wayland portal 授权/取消/无 portal 三态待真实 Linux 宿主手动验证,见 evidence/P1-04.md) +- P2-01: COMPLETE(两栏启动倒序 + 状态保持,offscreen 全量覆盖,无平台相关项;见 evidence/P2-01.md) +- P2-02: COMPLETE(offscreen 全量覆盖 + 无泄漏断言;Windows/Linux 真机截图待对应环境运行 diag_panel_scrollbar.py;见 evidence/P2-02.md) +- P2-03: COMPLETE(offscreen 结构+行为全量覆盖 34/34;审计确认唯一受影响遮罩;Windows 真机 WebView2 人工验收清单见 evidence/P2-03.md) +- P2-04: COMPLETE(聚合入口 tests/run_all.py:Windows 25/25 PASS;WSL/3.12 FAIL 0 + 21 有理由 SKIP;夹具演示非零退出码;独立/聚合输出一致;暴露并修复 T9 陈旧断言与 compaction.py 3.11+ dataclass 缺陷;见 evidence/P2-04.md) diff --git a/docs/agent-handoff/KICKOFF_PROMPT.md b/docs/agent-handoff/KICKOFF_PROMPT.md new file mode 100644 index 0000000..719105a --- /dev/null +++ b/docs/agent-handoff/KICKOFF_PROMPT.md @@ -0,0 +1,362 @@ +# haocode 全量修复任务:无人值守连续执行 + +你是本次唯一的开发工作 Agent。用户将长时间离开,不会及时回复。 + +你的目标不是提出方案,也不是只完成一个任务,而是按照仓库内已经固化的任务清单,持续完成所有可实施修复、测试和文档更新,直到: + +1. 所有能够在当前环境完成的任务均达到完成条件; +2. 所有自动化测试通过; +3. 无法在当前机器完成的真实平台验收被准确记录; +4. 已经没有不需要用户介入即可继续的工作。 + +不要在完成一个任务后停下来等待确认。完成当前任务后,立即按依赖顺序执行下一项。 + +## 一、工作目录 + +固定工作目录: + +D:\WorkSpace\Project\GCC\haocode_0 + +不得在其他副本、临时复制目录或旧版本中实施修复。 + +## 二、首次启动必须执行 + +开始修改代码前,依次完整读取: + +1. `AGENTS.md` +2. `docs/agent-handoff/README.md` +3. `docs/agent-handoff/REPAIR_BACKLOG.md` +4. `docs/agent-handoff/VERIFICATION.md` +5. `docs/agent-handoff/PLATFORM_PLAN.md` +6. `docs/agent-handoff/CURRENT_STATE.md` + +其中: + +- 当前行为和缺陷必须由源码、复现和测试确认。 +- 目标行为、任务范围和完成条件以 `docs/agent-handoff/` 为准。 +- `readme.md`、`Frame.md`、`ARCHITECTURE.md` 只作历史背景。 +- 第三方修复要求和外部 harness 项目不是事实源。 + +读完后,先确认 `P0-03` 文档基线已经完成,不要重复创建交接目录。 + +然后从 `P0-01` 开始实施。 + +## 三、建立防遗忘执行状态 + +在进行任何业务代码修改前,创建: + +`docs/agent-handoff/EXECUTION_STATE.md` + +并在 `docs/agent-handoff/README.md` 增加一个简短指针: + +> 当 `EXECUTION_STATE.md` 的状态为 `ACTIVE` 时,任何继续执行、上下文恢复或压缩恢复都必须先读取该文件。 + +`EXECUTION_STATE.md` 必须保持简洁,只保存当前事实,不写成长篇流水账。至少包含: + +```text +# 全量修复执行状态 + +状态:ACTIVE +总目标:完成 REPAIR_BACKLOG.md 中所有待实施任务 +当前任务:P0-01 +当前阶段:调查 / 红测试 / 实现 / 定向验证 / 阶段回归 +最后完成动作: +下一步唯一动作: +当前修改文件: +最近测试结果: +尚未验证的平台: +阻塞项: +任务状态表: +- P0-01: IN_PROGRESS +- P0-02: PENDING +... +``` + +状态值只能使用: + +- `PENDING` +- `IN_PROGRESS` +- `AUTOMATED_VERIFIED` +- `PLATFORM_VALIDATION_PENDING` +- `COMPLETE` +- `BLOCKED` + +每次发生以下事件后,立即更新 `EXECUTION_STATE.md`: + +- 开始一个任务; +- 确认根因; +- 完成一组源码修改; +- 运行测试; +- 测试失败并改变调查方向; +- 完成任务; +- 准备执行长时间命令; +- 发现外部环境阻塞; +- 即将结束当前上下文。 + +只保留最新状态和下一步,不依赖聊天记录保存进度。 + +## 四、上下文压缩恢复协议 + +一旦发生以下任一情况: + +- 上下文被压缩或总结; +- 你无法准确复述当前任务; +- 不确定哪些测试已经运行; +- 不确定下一步应该做什么; +- 会话中断后重新继续; + +立即停止凭记忆操作,按顺序重新读取: + +1. `AGENTS.md` +2. `docs/agent-handoff/README.md` +3. `docs/agent-handoff/EXECUTION_STATE.md` +4. `REPAIR_BACKLOG.md` 中“当前任务”的完整章节 +5. 当前任务引用的 `PLATFORM_PLAN.md` 或 `VERIFICATION.md` 章节 +6. `EXECUTION_STATE.md` 列出的当前修改文件 + +然后核对工作区实际状态和最近测试结果,再从“下一步唯一动作”继续。 + +聊天摘要不能代替 `EXECUTION_STATE.md`。不得因为上下文压缩重新设计范围、跳过测试或把未完成任务误判为完成。 + +## 五、永久硬约束 + +### 凭据铁律 + +`data/config.json` 是不透明的本机密钥文件。 + +绝对不得: + +- 打开; +- 读取; +- 搜索其内容; +- 打印; +- 复制; +- 修改; +- 计算散列; +- 制作快照; +- 让递归内容搜索包含它; +- 让其内容进入工作上下文或测试输出。 + +所有自动化测试必须使用临时配置和临时数据库。 + +验证真实配置未被访问时,使用打开路径拦截器、替身或访问记录;不得通过读取真实文件验证。 + +在 `P0-01` 完成前,不得运行可能绕过临时配置而读取真实配置的 GUI/LLM 测试。先审查测试入口并完成隔离。 + +### 范围约束 + +- 不初始化 Git,不创建提交,不伪造历史。 +- 不删除或清理用户现有数据库、附件、日志、锁文件或运行产物。 +- 不移动或拆分现有模块。 +- 不借修复重写 `main_window.py`、`app.js` 或弹窗系统。 +- 不增加新产品功能。 +- 不增加工具注册系统。 +- 不参考或移植 Claude Code、Codex、Grok Build、DeepSeek Harness。 +- 不改变 `core/agent/` 作为 pi Python 移植的定位。 +- 不增加 Agent shell sandbox、命令审批或路径权限边界。 +- Chromium sandbox 保持默认开启。 +- 不引入 WebKitGTK 或相关 Qt 封装。 +- 不做 PyInstaller、安装器、AppData/XDG 迁移或发行包。 +- 本阶段只保证源码运行。 +- `vendor/webview2/` 和根目录 `WebView2Loader.dll` 不得删除。 +- 产品内部 Agent 继续按原方式调用 LLM,不修改其产品行为。 +- 不把开发工作委派给其他 Agent;由你连续完成。 + +只修改当前任务“允许修改”中列出的文件。发现无关问题时记录到 `EXECUTION_STATE.md` 的“观察项”,不要顺手扩大范围。 + +## 六、任务执行顺序 + +严格按以下顺序连续执行: + +1. `P0-01` 配置路径与测试隔离 +2. `P0-02` 合并重复的 `MainWindow.eventFilter` +3. 复核已经完成的 `P0-03` +4. `P1-01` 双向消息渲染窗口 +5. `P1-02` Windows/Linux shell 与进程树终止 +6. `P1-03` Windows/Linux 渲染器启动链 +7. `P1-04` Linux 截图热键与截图实现 +8. `P2-01` Bash 任务按启动时间倒序 +9. `P2-02` 右侧 Bash 面板滚动条 +10. `P2-03` WebView2 原生窗口遮挡层 +11. `P2-04` 跨平台聚合测试入口 + +不得跳过依赖。某项存在真实平台验收阻塞时,先完成其实现和自动化验证,将状态设为 `PLATFORM_VALIDATION_PENDING`,然后继续所有不受该阻塞影响的后续任务。 + +## 七、每个任务的固定执行循环 + +对每个任务严格执行以下循环: + +### 1. 领取 + +- 在 `EXECUTION_STATE.md` 中把任务设为 `IN_PROGRESS`。 +- 完整读取该任务的“先读文件、允许修改、硬约束、目标测试、完成证据”。 +- 只加载当前任务需要的源码。 + +完成标准:能够准确列出当前任务允许修改的文件、禁止范围和验收条件。 + +### 2. 复现和根因 + +- 从实际源码出发确认附件描述是否正确。 +- 建立最小复现或失败测试。 +- 对确定性缺陷记录实际调用链、状态变化或平台差异。 +- 描述不准确时以代码证据纠正,不机械照抄文档中的猜测。 + +完成标准:测试或可重复证据在修复前能够暴露缺陷。 + +### 3. 实现 + +- 采用与现有代码风格一致的最小修改。 +- 优先复用现有接口和模块边界。 +- 只在确实能隔离平台差异或测试状态时增加窄辅助模块。 +- 不进行无关格式化、批量重命名或结构重构。 + +完成标准:失败复现转绿,且没有扩大任务行为面。 + +### 4. 定向验证 + +运行任务章节列出的全部目标测试。 + +每条测试记录: + +- 精确命令; +- 平台和环境; +- 退出码; +- PASS/FAIL/SKIP; +- 关键断言; +- 失败原因; +- 是否使用临时配置和数据库。 + +测试失败时进入诊断循环并继续修复,不能通过删除断言、放宽正确性要求或把失败改成 SKIP 获得通过。 + +完成标准:全部适用定向测试通过;不适用项有真实平台理由。 + +### 5. 自检 + +逐条核对当前任务的所有“硬约束”和“完成证据”。 + +检查: + +- 是否改了允许范围之外的文件; +- 是否引入了新功能; +- 是否碰到真实配置或数据库; +- 是否只验证了 happy path; +- 是否保留 Windows 现有行为; +- 是否误把 offscreen 当作真实桌面证据; +- 是否有未记录的测试失败。 + +完成标准:每条完成证据都有源码、测试输出或真机证据对应。 + +### 6. 固化进度 + +- 更新 `REPAIR_BACKLOG.md` 的任务状态。 +- 更新 `EXECUTION_STATE.md`。 +- 将简洁证据写入 `docs/agent-handoff/evidence/.md`。 +- 不粘贴大量完整日志,只记录命令、结果和证据文件路径。 +- 立即开始下一任务,不等待用户确认。 + +只有全部完成条件满足时才能标记 `COMPLETE`。 + +## 八、阶段回归 + +完成 P0、P1、P2 每个阶段后,运行 `VERIFICATION.md` 对应的阶段完整回归。 + +要求: + +- 定向测试不能代替阶段回归。 +- `tests/run_tests.py` 在修复聚合入口前不能被称为全套测试。 +- 平台不适用项必须明确显示 `SKIP` 和理由。 +- 共同逻辑测试失败不能以平台差异豁免。 +- 测试不得访问网络、真实 API 或真实凭据,除非任务明确要求且用户已经提供授权;当前没有该授权。 +- 不运行 `diag_live_*` 或 `smoke_live_*` 的真实 API 路径。 + +P2-04 完成后,使用新的聚合入口运行最终自动化集合,并保留每个独立测试的运行能力。 + +## 九、平台验收处理 + +先只读检测当前机器实际具备的环境: + +- Windows 版本; +- WebView2 是否可用; +- QtWebEngine 是否可用; +- 是否存在可用的 WSL/Ubuntu、X11 或 Wayland 环境。 + +不得为了补齐平台矩阵擅自安装操作系统、创建虚拟机或修改宿主机关键配置。 + +当前环境具备的平台必须完成真实验证。 + +当前环境不具备的平台: + +1. 完成平台适配代码; +2. 完成平台路由和替身自动化测试; +3. 记录缺少的真实环境; +4. 将任务标为 `PLATFORM_VALIDATION_PENDING`,不能标记 `COMPLETE`; +5. 继续执行其他任务。 + +不得伪造 Windows WebView2、Linux X11、Linux Wayland、portal、DPI、全局热键或截图的真机证据。 + +## 十、阻塞处理 + +用户正在休息。不要因为普通实现选择、测试失败或代码复杂而询问用户。 + +优先采用: + +1. 现有源码行为; +2. `docs/agent-handoff/` 中已经冻结的决策; +3. 最小、兼容、可测试的实现; +4. 将判断依据写入执行状态和证据文档。 + +只有遇到以下情况才允许停止: + +- 必须获取用户凭据; +- 必须执行不可逆或破坏性操作; +- 必须使用当前不存在的外部机器; +- 两个权威要求存在无法同时满足的真实矛盾; +- 连续诊断后确认没有任何不需要用户介入的工作可继续。 + +即使一个任务阻塞,也要继续所有不依赖该阻塞的任务。 + +## 十一、最终收尾 + +所有可执行工作完成后: + +1. 运行最终自动化聚合测试。 +2. 复核所有单文件测试入口仍可运行。 +3. 复核 `data/config.json` 未被测试访问,但不要读取它。 +4. 检查交接文档与最终代码是否一致。 +5. 更新 `CURRENT_STATE.md`,移除已经修复的“当前缺陷”表述。 +6. 更新 `REPAIR_BACKLOG.md` 中每项真实状态。 +7. 把 `EXECUTION_STATE.md` 状态改为: + - `COMPLETE`:所有自动化和真实平台条件均满足; + - `PLATFORM_VALIDATION_PENDING`:仅剩当前机器无法提供的真机验证; + - `BLOCKED`:仍有必须由用户决定或提供资源的问题。 +8. 创建 `docs/agent-handoff/FINAL_REPORT.md`。 + +`FINAL_REPORT.md` 必须包含: + +- 每个任务的最终状态; +- 根因与实际修复摘要; +- 修改文件清单; +- 自动化测试命令和结果; +- Windows/Linux 真机验证结果; +- 明确的未验证项; +- 对真实配置和数据库的隔离证明方式; +- 仍需用户处理的最少事项; +- 下一位 Agent 的恢复入口。 + +## 十二、最终回复格式 + +只有在没有可继续执行的工作时才回复用户。 + +最终回复必须先说明整体状态,然后依次给出: + +1. 已完成任务; +2. 修改范围; +3. 测试结果; +4. 真机平台证据; +5. 未完成或受阻事项; +6. `FINAL_REPORT.md` 和 `EXECUTION_STATE.md` 路径; +7. 用户醒来后需要执行的最少动作。 + +不要只回复“完成了”。不要隐藏失败、跳过项或未验证平台。 + +现在开始执行首次启动步骤,创建持久化执行状态,然后从 P0-01 连续工作,直到达到上述终止条件。 diff --git a/docs/agent-handoff/PLATFORM_PLAN.md b/docs/agent-handoff/PLATFORM_PLAN.md new file mode 100644 index 0000000..014760d --- /dev/null +++ b/docs/agent-handoff/PLATFORM_PLAN.md @@ -0,0 +1,153 @@ +# Windows / Linux 源码运行契约 + +> 触发条件:修改启动、浏览器、shell、进程取消、系统热键、截图、路径、依赖或平台测试前读取。本文定义目标行为,不代表这些能力已经实现。只有完成本文验收矩阵后,才能把对应环境标为“已验证”。 + +## 支持矩阵 + +| 环境 | Python | 浏览器后端 | 系统集成 | 状态定义 | +| --- | --- | --- | --- | --- | +| Windows x64 | 现有 Python 3.10 基线 | WebView2 首选;失败或 profile 被占用时回落 QtWebEngine | 现有 Windows 原生全局热键和截图 | 必须保持现有产品行为 | +| Ubuntu 22.04/24.04 x64 + X11 | Python 3.10–3.12 | 仅 QtWebEngine | X11 原生全局热键;现有截图交互的 X11 实现 | 本阶段 Linux 主支持目标 | +| Ubuntu 22.04/24.04 x64 + Wayland | Python 3.10–3.12 | 仅 QtWebEngine | `xdg-desktop-portal` 全局快捷键/截图能力 | portal 缺失或桌面不支持时明确报错 | +| `QT_QPA_PLATFORM=offscreen` | 同对应系统 | QtWebEngine 测试路径;Windows 跳过 WebView2 | 不验证真实全局热键和桌面截图 | 只用于自动化测试,不算桌面支持证据 | + +其他 Linux 发行版可以尝试源码运行,但必须标记“未验证”。Linux 不引入 WebKitGTK、WebKitGTK 的 Qt 包装层或第二套网页 UI。 + +## 平台选择原则 + +平台能力通过小型适配器隔离,不建立通用插件框架,也不移动现有模块。适配器至少覆盖以下三类能力: + +- 浏览器选择:Windows 尝试 WebView2 后回落 QtWebEngine;非 Windows 直接进入 QtWebEngine。 +- shell 生命周期:生成固定 shell argv、设置进程组选项、取消和超时时终止完整命令树。 +- 桌面集成:注册/注销 `Alt+S`,启动截图并以现有 Qt 信号返回结果或错误。 + +Windows 专用模块和 Linux 专用模块只在平台选择完成后延迟导入。Linux 启动链不得先访问 `ctypes.WinDLL`、Windows HWND、`taskkill` 或 WebView2 DLL;Windows 启动链不得依赖 DBus、portal 或 X11 包。 + +## 浏览器后端 + +### Windows + +1. 保持 WebView2 为首选,初始化失败时显示可诊断日志并回落 QtWebEngine,主窗口仍可使用。 +2. 保持现有实例锁语义。同一 WebView2 profile 已由另一个实例使用时,新实例回落 QtWebEngine,不与其争用 profile,也不结束对方进程。 +3. WebView2 原生子窗口继续使用现有包装层;修复弹层时只调整已确认的遮挡案例。 +4. 强制 QtWebEngine 回落必须有测试入口,以便在 Windows 上验证两条渲染路径。 + +### Linux 与 QtWebEngine 回落 + +1. 非 Windows 平台不探测、不导入、不加载 WebView2。 +2. 每个 QtWebEngine 应用实例使用独立 profile/storage 目录,避免多个 Chromium 实例争用同一 profile。源码阶段目录仍位于项目 `data/` 范围;自动化测试使用临时目录。 +3. 保持离线加载 `ui/web/` 资源;路径拼接必须兼容大小写敏感文件系统。 +4. QtWebEngine 必须在 `QApplication` 创建前完成项目所需的导入和 Chromium 环境设置。 +5. 保留现有软件渲染/GPU 配置入口。无效可选配置应输出明确警告并回落可启动默认值,不能让源码运行因可选渲染配置直接失败。 + +**Linux 源码运行(最小步骤,Ubuntu 22.04/24.04 x64):** + +```bash +# 1) 依赖(pythonnet/clr_loader 带 sys_platform == "win32" marker,Linux 自动跳过) +python3.10 -m venv .venv && . .venv/bin/activate +pip install -r requirements.txt +sudo apt install -y libnss3 libxkbcommon0 libfontconfig1 libdbus-1-3 libgl1 libegl1 libasound2t64 +# (22.04 无 t64 后缀,用 libasound2) + +# 2) 运行(普通用户;只走 QtWebEngine;每实例独立 profile:data/webengine/profile__*) +python3.10 main.py + +# 3) 仅当 root/容器启动且页面空白时,才显式禁用沙箱(启动会打印高可见风险警告): +QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox" python3.10 main.py + +# 4) 离屏自动化测试: +QT_QPA_PLATFORM=offscreen HAOCODE_RENDER=software QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \ + python3.10 tests/smoke_offscreen.py +``` + +实现锚点:`core/renderer_backend.py`(后端解析 / profile 目录 / sandbox 标志处理);`main.py` 在导入 PyQt6 前调用 `sanitize_chromium_flags`;`ui/views/main_window.py` 以 `sys.platform == "win32"` 门控 `core.webview2` 导入(非 Windows 永不导入,不触达 pythonnet/Win32/WebView2 DLL/taskkill);`CustomWebPage(profile, parent)` 接收本实例独立 `QWebEngineProfile`。 + +### Chromium sandbox + +Chromium sandbox 与 agent 命令权限是两件事。默认保留 Chromium sandbox,项目代码不得普遍追加 `--no-sandbox`。仅当运行者明确为 root/容器场景配置现有 `QTWEBENGINE_CHROMIUM_FLAGS`,且启动代码确认处于该场景时才接受该标志,同时输出高可见警告。普通 Windows/Linux 桌面运行不得关闭 sandbox。 + +## Shell 和进程生命周期 + +“bash 工具”保留产品名称,但执行契约按平台固定: + +| 平台 | argv | `Popen` 要求 | 取消/超时 | +| --- | --- | --- | --- | +| Windows | `cmd.exe /d /s /c ` | `shell=False`;保持现有流式 stdout/stderr | 终止该命令的完整 Windows 进程树 | +| Linux | `/bin/bash -lc ` | `shell=False`、`start_new_session=True`;保持流式 stdout/stderr | 向进程组发 `SIGTERM`,短暂宽限后仍存活则发 `SIGKILL` | + +实现必须满足: + +- 取消、超时、窗口退出三条路径复用同一个幂等终止函数。 +- Linux 以 `os.getpgid(proc.pid)` 定位本次命令进程组,不按进程名杀进程,也不遗留孙进程。 +- 正常退出不进入强杀路径;进程已经结束时重复取消不抛出用户可见异常。 +- `cwd`、环境变量、编码和分块输出保持现有工具语义。命令字符串不在 Python 中按 `;`、`&&` 或管道自行拆分。 +- 平台 shell 名称进入诊断日志,避免把 Linux `/bin/sh` 误报成 bash。 + +## 系统提示词 + +只维护根目录一份 `SYSTEM_PROMPT.md`。每次请求继续重新读取公共正文,再由运行时生成一段短的平台信息并插入请求,不创建 Windows/Linux 两份完整提示词。 + +运行时段至少声明: + +- 当前操作系统和 shell:Windows `cmd.exe` 或 Linux `/bin/bash -lc`。 +- 路径格式和路径分隔符;Linux 文件名大小写敏感。 +- 对应 shell 的环境变量、命令连接和引号规则。 +- agent 没有额外 shell 沙盒或命令审批层,不要虚构这些能力。 + +平台段是请求构造的一部分,不写入会话历史,不参与压缩持久化。契约测试应验证公共提示词只有一份,且不同平台只改变运行时段。 + +## 配置和路径 + +1. 所有配置读取入口统一尊重 `HAOCODE_CONFIG_FILE`;测试在导入 UI/LLM 模块前把它指向临时文件。真实凭据文件按 [README.md](README.md) 的边界处理。 +2. 所有自动化测试同时把默认数据库改到临时目录,不能依赖开发机已有数据库或附件。 +3. 源码运行阶段继续使用项目内 `data/`。AppData、XDG Base Directory、安装器写入权限和配置迁移全部留到打包阶段。 +4. 使用 `pathlib` 或 `os.path` 组合路径,不拼接平台分隔符;资源存在性检查覆盖 Linux 大小写差异。 +5. WebView2 DLL、Windows 锁文件和 Windows 进程命令不成为 Linux 配置校验项。Linux portal/X11 依赖缺失也不能阻止不相关的聊天和 shell 功能启动。 + +## Linux 桌面集成 + +### X11 + +- 为现有 `Alt+S` 截图快捷键实现 X11 原生注册/注销,保持 `GlobalHotkeyThread` 对上层的信号语义。 +- 注册失败时返回具体原因并保留窗口内截图按钮或应用内快捷键;不得后台忙等键盘状态。 +- 屏幕像素获取走 X11 可用的原生抓屏路径;区域选择 UI 可以继续使用 Qt。 +- 截图继续提供现有 `QImage` 返回语义,覆盖多显示器和负坐标桌面布局。 + +### Wayland + +- 使用 `xdg-desktop-portal` 提供的桌面协议请求全局快捷键和截图,不使用 X11 API 假装支持 Wayland。 +- portal 请求必须异步处理授权、拒绝、取消和桌面不支持四种结果;UI 只禁用受影响能力,主程序继续运行。 +- 无法提供全局快捷键时给出明确、可操作的错误,不能静默降级成“看似注册成功”。窗口内按钮/快捷键仍按桌面允许范围工作。 +- portal 返回的截图 URI/数据先完成有效性检查,再转换为现有 `QImage`/附件流程;临时文件生命周期由适配器负责。 + +会话类型优先综合 Qt 平台名和 `XDG_SESSION_TYPE` 判断,并记录最终选择。环境变量与实际 Qt 后端矛盾时,选择可证明可用的后端并输出警告。 + +> **实现状态(P1-04,2026-07-17,证据 `evidence/P1-04.md`)**:`ui/views/system_tools/desktop_session.py` 按上文判定顺序实现 `session_kind()`(offscreen→unknown;QT_QPA_PLATFORM/WAYLAND_DISPLAY/XDG_SESSION_TYPE 综合);X11 热键 = `x11_hotkey.py`(ctypes→libX11 XGrabKey,同 `triggered` 信号语义,失败明确日志);Wayland 截图 = `portal_capture.py`(xdg-desktop-portal Screenshot,系统 gdbus CLI,异步 worker,授权/拒绝/不支持/超时四态明确)。**Wayland 全局快捷键**依赖 compositor 桌面协议(ext-global-shortcut 等),本版本无免依赖实现 → 按上文“无法提供全局快捷键时给出明确、可操作的错误”处理(日志明确说明 + 保留应用内 Alt+S/按钮),不静默降级。真机验证待 Ubuntu 22.04/24.04 X11/Wayland 会话。 + +## 依赖范围 + +- `requirements.txt` 只加入源码确实导入的 Python 包,并使用平台 marker 隔离 Windows/Linux 专用依赖。 +- Ubuntu 所需系统包另写安装说明,不把 apt 包名伪装成 pip 依赖。 +- 不增加 WebKitGTK、打包器、安装器或与现有功能无关的桌面框架。 +- 可选平台能力缺失时要局部报错;聊天、会话和 shell 的基本源码运行仍应可达。 + +## 验收矩阵 + +每行都要留下命令输出、日志或截图证据;人工桌面项不能用 offscreen 结果替代。具体测试分层见 [VERIFICATION.md](VERIFICATION.md)。 + +| 环境 | 必验操作 | 通过标准 | 证据 | +| --- | --- | --- | --- | +| Windows / WebView2 | 启动、流式聊天、弹层、截图、退出 | 选择 WebView2;弹层无已确认遮挡;退出不影响其他实例 | 后端日志 + 桌面截图 | +| Windows / 强制 QtWebEngine | 同一基本流程 | 不加载 WebView2;UI 和消息协议行为一致 | 后端日志 + 定向测试 | +| Windows / 第二实例 | 首实例占用 WebView2 profile 后启动第二实例 | 第二实例回落 QtWebEngine;首实例继续工作 | 双实例日志 | +| Ubuntu 22.04 X11 | 启动、聊天、`Alt+S`、区域截图、shell | 只使用 QtWebEngine;热键与截图成功;shell 为 bash | 日志 + 截图 + shell 测试 | +| Ubuntu 24.04 X11 | 同上 | 与 22.04 相同 | 日志 + 截图 + shell 测试 | +| Ubuntu 22.04/24.04 Wayland | portal 授权、拒绝、取消;截图和快捷键 | 授权路径可用;其余路径明确报错且主程序不退出 | portal 日志 + 桌面截图 | +| Linux 进程生命周期 | 命令创建子进程后取消和超时 | 父、子、孙进程全部退出;无同名进程误杀 | PID/进程组测试记录 | +| Windows 进程生命周期 | 命令创建子进程后取消和超时 | 本次命令树全部退出;其他进程不受影响 | PID 测试记录 | +| Windows/Linux 提示词 | 捕获请求构造结果 | 公共正文一致;shell/路径段与平台匹配;平台段不入历史 | 契约测试 | +| Windows/Linux 临时配置 | 使用临时配置和数据库运行 GUI smoke | 不访问项目真实配置/数据库;运行结束无生产数据变更 | 隔离断言 | +| Windows/Linux offscreen | 运行自动化 smoke | 可重复通过;明确不声称验证系统热键/截图 | 测试汇总 | +| root/容器 | 分别在未显式配置和显式配置下启动 | 默认保持 sandbox;显式 `--no-sandbox` 时打印警告 | 启动日志 | + +平台任务完成条件:矩阵中该阶段承诺的每一行都有证据;自动化定向测试和阶段全量测试均通过;Linux 启动链没有 Windows 专用导入;实现没有引入 WebKitGTK、打包工作、通用钩子框架或新的 agent 权限模型。 diff --git a/docs/agent-handoff/README.md b/docs/agent-handoff/README.md new file mode 100644 index 0000000..0af8a4e --- /dev/null +++ b/docs/agent-handoff/README.md @@ -0,0 +1,55 @@ +# haocode 维修交接入口 + +> **执行状态**:当 `EXECUTION_STATE.md` 的状态为 `ACTIVE` 时,任何继续执行、上下文恢复或压缩恢复都必须先读取该文件。 + +本目录是 2026-09-16 之后的维修与跨平台工作的唯一交接入口。当前交付固化事实、决策、任务和验收方式;业务代码修复已按 `REPAIR_BACKLOG.md` 状态表推进(任务完成状态以该表和 `EXECUTION_STATE.md` 为准)。 + +精确实现仍以源码和可重复测试为准。若本文档与源码冲突,先记录证据,再修正文档;不要用旧文档覆盖已经核实的代码事实。 + +## 强制读取顺序 + +后续 Agent 必须从仓库根目录开始,按以下顺序读取: + +1. `AGENTS.md` +2. `docs/agent-handoff/README.md`(本文件) +3. 根据任务类型,只读取下表对应的文档 + +| 触发条件 | 接着读取 | 用途 | +|---|---|---| +| 准备领取或实施修复任务 | `REPAIR_BACKLOG.md` | 任务顺序、允许范围、禁止事项、完成条件 | +| 涉及 Windows/Linux、渲染器、shell、热键或截图 | `PLATFORM_PLAN.md` | 已冻结的跨平台契约 | +| 涉及测试、诊断脚本或验收 | `VERIFICATION.md` | 分层测试矩阵与证据要求 | +| 需要了解目录、命名、模块边界或技术债 | `CURRENT_STATE.md` | 已核实的当前状态 | + +不要从 `readme.md`、`Frame.md` 或 `ARCHITECTURE.md` 开始。这三份文件只能作为历史背景;其中的路径、打包说明和完成状态可能已经失效。第三方提供的《haocode 修复需求》也不是事实源,其中只有经源码核验并写入 `REPAIR_BACKLOG.md` 的内容有效。 + +## 永久操作约束 + +- 将 `data/config.json` 视为不透明的本机密钥文件。不得打开、读取、搜索、打印、复制、修改或让它进入 Agent 上下文;任何递归内容搜索都必须排除它。 +- 测试必须使用临时配置和临时数据库,并在导入 `MainWindow` 前完成重定向(统一用 `tests/_test_env.py` 的 `isolate()`)。自 P0-01 起所有运行时配置读取统一走 `core/config_paths`(`HAOCODE_CONFIG_FILE` 优先);少数 GUI 测试脚本(如 `smoke_offscreen.py`)自身不设置该环境变量,独立运行时由环境注入临时配置,P2-04 聚合入口将按子进程强制注入。 +- 不初始化 Git,不伪造提交历史。任务按可独立提交的粒度编写,等仓库以后具备 Git 历史再逐项提交。 +- 不引入 WebKitGTK。Windows 使用 WebView2(首选)或 QtWebEngine(回退);Linux 只使用 QtWebEngine。 +- 不新增 Agent shell 沙箱、审批或路径边界;Chromium 渲染进程的 sandbox 保持默认开启。 +- 不拆分或移动现有模块,不借修 bug 增加新产品功能。允许修改确认错误的源码,并新增聚焦测试、平台适配器、文档和配置样例。 +- 本阶段以 Windows/Linux 源码运行正确为目标;PyInstaller、安装器、AppData/XDG 目录迁移和发行包是下一阶段。 +- 不比较或移植 Claude Code、Codex、Grok Build、DeepSeek Harness。当前 `core/agent/` 继续保持 pi 的 Python 移植定位。 + +## 文档职责 + +- `CURRENT_STATE.md` 只记录已经从仓库核实的事实与结构/命名结论。 +- `PLATFORM_PLAN.md` 是平台行为的唯一决策源。 +- `REPAIR_BACKLOG.md` 是工作拆分和改动边界的唯一决策源。 +- `VERIFICATION.md` 是测试命令、平台矩阵和验收证据的唯一决策源。 +- `EXECUTION_STATE.md` 是无人值守执行与上下文压缩后的唯一恢复入口;任务权威状态仍以 `REPAIR_BACKLOG.md` 为准,执行证据放 `evidence/`。 + +同一规则不要在多份文件中复制扩写。需要变更决策时,先修改其唯一归属文档,再更新这里的路由;不要在实现过程中悄悄改变范围。 + +## 领取任务 + +1. 从 `REPAIR_BACKLOG.md` 选择一个未完成任务 ID。 +2. 只读取该任务列出的源码和它引用的规范文档。 +3. 先建立最小复现或失败测试,再修改允许范围内的文件。 +4. 运行任务的聚焦测试;阶段结束时再运行 `VERIFICATION.md` 指定的完整自动化集合。 +5. 记录实际命令、结果和平台证据。没有运行的测试必须明确写“未运行”,不能按通过处理。 + +任务完成的含义是:行为、回归测试、平台适用性和文档中的完成条件全部满足。只提交代码或只写说明都不算完成。 diff --git a/docs/agent-handoff/REPAIR_BACKLOG.md b/docs/agent-handoff/REPAIR_BACKLOG.md new file mode 100644 index 0000000..9557d17 --- /dev/null +++ b/docs/agent-handoff/REPAIR_BACKLOG.md @@ -0,0 +1,548 @@ +# 修复任务清单 + +本文是修复工作的任务源。每次只领取一个任务单元;任务完成后再进入下一个单元。精确实现以当前源码为准,本文约束修复范围和验收结果,不授权功能扩展。 + +验证方法与平台取证要求统一见 [VERIFICATION.md](VERIFICATION.md)。跨平台运行契约见 [PLATFORM_PLAN.md](PLATFORM_PLAN.md)。 + +## 执行边界 + +- 当前阶段只保证源码运行。安装包、冻结构建、AppData/XDG 目录迁移均不在本清单内。 +- 保持现有模块位置,不拆分或移动 `ui/views/main_window.py`、`ui/web/app.js` 等大文件。可以新增测试、窄的平台适配器和配置路径辅助模块。 +- 保持现有 Agent 能力和 `core/agent/` 与 pi 的行为,不增加工具注册、权限确认、命令沙盒或路径边界。 +- 保留 Chromium sandbox。只有明确检测到 root/container 且用户显式选择时,才允许添加 `--no-sandbox`。 +- `data/config.json` 是不透明的运行时秘密。工作 Agent 不得打开、读取、搜索、打印、复制或修改该文件;所有自动化测试使用临时配置。 +- 第三方修复文档只是线索。只有源码可复现的缺陷和本清单明确写出的行为才是修复依据。 +- 当前快照没有 Git 历史。每个任务仍须保持可独立审查;以后接入 Git 时,一个任务对应一个提交。 + +## 顺序与依赖 + +| 顺序 | 任务 | 状态 | 依赖 | +|---|---|---|---| +| P0-01 | 配置路径与测试隔离 | 已完成(P0 阶段回归通过,2026-09-16;见 evidence/P0-01.md) | 无,其他 GUI/配置测试的前置任务 | +| P0-02 | 合并重复的 `MainWindow.eventFilter` | 已完成(P0 阶段回归通过,2026-09-16;见 evidence/P0-02.md) | P0-01 | +| P0-03 | 修复交接文档基线 | 已完成(2026-09-16) | 无;后续只做一致性复核 | +| P1-01 | 双向消息渲染窗口 | 已完成 | P0-01 | +| P1-02 | Windows/Linux shell 与进程树终止 | 已完成(Windows 侧自动化全绿,2026-07-09;见 evidence/P1-02.md) | P0-01 | +| P1-03 | Windows/Linux 渲染器启动链 | 已完成(Windows 侧自动化 + offscreen 真实启动链全绿,2026-07-17;见 evidence/P1-03.md) | P0-01、P1-02 | +| P1-04 | Linux 截图热键与截图实现 | 已完成(Windows 侧自动化全绿,2026-07-17;X11/Wayland 真机待手动;见 evidence/P1-04.md) | P1-03 | +| P2-01 | Bash 任务按启动时间倒序 | 已完成(offscreen 全量覆盖,2026-07-21;见 evidence/P2-01.md) | P0-01 | +| P2-02 | 右侧 Bash 面板滚动条 | 已完成(offscreen 全量覆盖,2026-07-21;真机截图待对应环境跑 diag;见 evidence/P2-02.md) | P2-01 | +| P2-03 | WebView2 原生窗口遮挡层 | 已完成(offscreen 34 项全绿,2026-07-21;真机 WebView2 人工验收清单见 evidence/P2-03.md) | P0-02、P1-03 | +| P2-04 | 跨平台聚合测试入口 | 已完成(2026-07-17) | 新增 `tests/run_all.py`:Windows 25/25 PASS,WSL/3.12 FAIL 0 + 21 有理由 SKIP;暴露并修复 T9 陈旧断言与 `core/agent/compaction.py` 的 3.11+ dataclass 缺陷;证据 `evidence/P2-04.md` | + +## P0-01 配置路径与测试隔离 + +**状态:已实施(定向测试通过)。** 新增 `core/config_paths.py`(统一入口,环境变量优先、容错加载、可见警告);`core/llm_engine.py`、`ui/views/main_window.py`(webview_backend 与 init_model_popup 两处)、`ui/views/bash_panel.py` 全部改走统一入口;新增 `tests/_test_env.py` 统一临时环境与 `tests/test_config_isolation.py` 隔离回归。证据见 [evidence/P0-01.md](evidence/P0-01.md)。 + +**目标** + +让所有配置读取方统一尊重 `HAOCODE_CONFIG_FILE`,并保证自动化测试在导入 GUI 前完成数据库和配置重定向。默认源码运行仍使用项目内 `data/`。 + +**先读文件** + +- `core/llm_engine.py` +- `ui/views/bash_panel.py` +- `ui/views/main_window.py` 中所有配置路径和配置读取函数 +- `core/db_manager.py` 中 `_DEFAULT_DB` 的定义和初始化时机 +- `tests/smoke_bash_panel.py` +- `tests/test_error_persist.py` +- `tests/test_agent_core.py` 中依赖 provider 配置的用例 + +只读源码中的路径引用,不读取 `data/config.json` 的内容。 + +**允许修改** + +- 上述源码和测试。 +- 可以新增一个只负责路径解析和容错加载的 `core/` 辅助模块,以及一个 `tests/` 临时环境辅助模块。 +- 可以新增 `tests/test_config_isolation.py`。 + +**硬约束** + +- 环境变量优先级统一;没有环境变量时才回到项目内现有路径。 +- 路径解析本身不得在 import 时输出、复制或迁移配置内容。 +- 缺失或格式错误的配置必须产生可见警告,并使用现有安全默认值继续启动;不得吞掉错误,也不得因此阻断不需要该配置的源码路径。 +- 测试必须先创建临时配置、设置 `HAOCODE_CONFIG_FILE`、重定向 `core.db_manager._DEFAULT_DB`,然后才能 import `MainWindow`。 +- 不修改真实配置,不引入配置迁移或新配置格式。 + +**目标测试** + +```text +python tests/test_config_isolation.py +python tests/test_error_persist.py +python tests/smoke_bash_panel.py +python tests/test_agent_core.py +``` + +**完成证据** + +- 测试用拦截器记录到的配置打开路径全部位于临时目录,且禁止路径从未被打开;此断言不得通过读取或散列真实配置完成。 +- 临时配置的读写用例通过,临时数据库之外没有数据库写入。 +- 缺失配置和损坏配置各有一个回归用例,日志包含明确警告,进程正常退出。 +- 源码中不存在绕过统一路径解析的运行时配置读取。 + +## P0-02 合并重复的 `MainWindow.eventFilter` + +**状态:已实施(定向测试通过)。** 两处定义合并为一份(位于 `init_chat_events` 前的「事件拦截」节);发送规则(按钮禁用 / 流式生成时 Enter 不发送)收进 `send_message(from_enter=...)` 单一实现,按钮点击的中断语义不变。证据见 [evidence/P0-02.md](evidence/P0-02.md)。 + +**目标** + +修复同一类中后定义方法覆盖前定义方法的确定性缺陷,使 Enter、Shift+Enter、发送按钮状态和流式生成状态使用一套事件策略。 + +**先读文件** + +- `ui/views/main_window.py` 中 `MainWindow.init_chat_events`、两处 `MainWindow.eventFilter`、`send_message` 和 `_update_send_button_state` +- 与输入框发送行为相关的现有 smoke 测试 + +**允许修改** + +- `ui/views/main_window.py` 的事件过滤逻辑。 +- 可以新增 `tests/test_main_window_event_filter.py` 或在现有离屏 smoke 中增加断言。 + +**硬约束** + +- `MainWindow` 最终只能有一个 `eventFilter` 定义。 +- Enter 只发送一次;Shift+Enter 放行换行;发送按钮禁用或当前会话正在流式生成时不得发送。 +- 其他对象和事件必须继续交给父类,不顺带重构主窗口事件系统。 +- 测试遵守 P0-01 的临时配置和临时数据库规则。 + +**目标测试** + +```text +python tests/test_main_window_event_filter.py +python tests/smoke_offscreen.py +python tests/smoke_mode.py +``` + +**完成证据** + +- 四种键盘状态均有断言:Enter 可发送、Enter 被禁用、流式时 Enter 被拦截、Shift+Enter 换行。 +- 静态断言或 AST 检查证明 `MainWindow` 只有一个 `eventFilter`。 +- 测试中一次按键对应最多一次 `send_message` 调用。 + +## P0-03 修复交接文档基线 + +**状态:已完成。** 2026-09-16 无人值守轮次按本节标准复核:三项 `rg` 回归通过,交接目录与 `AGENTS.md` 相对链接全部有效;并随 P0-01/P0-02 结果更新了 README/CURRENT_STATE/VERIFICATION 中相应过时表述。保留本节作为后续文档变更的回归标准,不要重复搬运或重建交接目录。 + +**目标** + +让后续 Agent 只从一条清晰入口读取当前事实,并把损坏或过时材料降级为历史资料。 + +**先读文件** + +- `AGENTS.md` +- `ARCHITECTURE.md` +- `docs/agent-handoff/README.md` +- `docs/agent-handoff/CURRENT_STATE.md` +- `docs/agent-handoff/PLATFORM_PLAN.md` +- 本文和 `VERIFICATION.md` + +**允许修改** + +- 仅上述文档。 + +**硬约束** + +- `AGENTS.md` 的入口指针必须覆盖修复、跨平台、测试、结构审查和工作交接五类触发场景。 +- `ARCHITECTURE.md` 必须移除被拼接进来的第三方任务书正文,并在旧架构内容前明确标记“历史资料”;不得把旧目录树继续描述为当前事实。 +- 当前支持矩阵、任务要求和验证规则分别只有一个权威位置,通过链接引用,不复制成多份。 +- 不创建虚构的 Git 历史,不声称缺失的脚本或打包配置已经存在。 + +**目标测试** + +```text +rg -n "agent-handoff/README.md" AGENTS.md +rg -n "历史资料|当前事实" ARCHITECTURE.md +rg -n "haocode 修复需求(团队任务书)" ARCHITECTURE.md +``` + +最后一条应无匹配;再逐一检查交接文档中的相对链接和文件路径存在性。 + +**完成证据** + +- 新 Agent 按 `AGENTS.md` 指针能在一次跳转内到达 `docs/agent-handoff/README.md`。 +- `ARCHITECTURE.md` 没有拼接残片,且任何保留旧内容均带历史标记。 +- 交接目录中没有互相冲突的支持矩阵、默认值或验收口径。 + +## P1-01 双向消息渲染窗口 + +**目标** + +把前端 DOM 限制为严格的双向滑动窗口,同时保留完整当前会话链、附件、时间线、工具输出、分支切换和流式体验。 + +**先读文件** + +- `ui/views/main_window.py` 中 `load_messages_to_web`、分支切换、删除/重答和 `_active_streams` +- `ui/views/chat_bridge.py` +- `ui/web/app.js` 中消息创建、`messageBuffer`、滚动、`clearChat`、流式完成和时间线回放 +- `ui/web/index.html`、`ui/web/style.css` +- `core/db_manager.py` 中 `get_message_chain` 和分支查询 +- `tests/test_math_extract.js`、`tests/smoke_timeline.py`、`tests/smoke_midswitch.py` + +**允许修改** + +- 上述 Python/JS/CSS 文件和相关测试。 +- 可以新增一个 DOM 无关的 JS 窗口状态模块、`tests/test_render_window.js` 和 `tests/diag_render_scale.py`。 +- 可以在统一配置加载器中增加 `render_window_mode`、`render_window_size` 的解析。 + +**硬约束** + +- Python 保留完整的当前会话可见消息链作为窗口数据源;本任务不改 SQLite 查询模型或数据库 schema。 +- 初始窗口为最新 `size` 条。`size` 只接受非布尔整数 `10..200`,缺失、布尔、字符串、零、负数和越界值都静默回落 `40`。模式只接受 `auto`/`manual`,否则回落 `auto`。 +- `.message-wrapper` 数量始终不超过 `size`。流式消息计入上限;默认值下有一条流式消息时,最多保留另外 39 条。 +- JS 以“方向 + 边界消息 ID”向 Python 请求页;Python 以单个批次返回完整消息描述。描述必须足以独立还原正文、reasoning、附件、时间线和工具结果、分支信息及必要的发送者信息。 +- 请求与响应携带会话/代次标识;切会话、清屏或切分支后到达的旧响应必须丢弃。 +- `messageBuffer` 只负责活动流,不得作为历史分页数据源。 +- `manual` 模式向上只能点击“加载更早消息”;滚到顶部不得自动请求。`auto` 模式由顶部观察器自动请求,同时保留同一按钮。两个模式向下都自动恢复较新消息。 +- 每个新流式 token 延续当前行为:立即回到底部并跟随活动消息。 +- 向上换页使用“首个可见消息 ID + 像素偏移”恢复锚点,误差不超过 2 px。不得只按总高度差估算。 +- 切换分支后重建链,并尽量让目标消息保持在同一视口位置;目标已不存在时回到底部。 +- `clearChat()` 清除当前窗口的游标、缓存、DOM、未决请求和代次;已注入的配置模式和大小保持不变。 +- 一次换页始终执行“加入一端、裁掉另一端”,并维护 `hiddenOlder`、`hiddenNewer`。上方没有更多记录时隐藏加载入口。 +- 不引入 JSDOM、npm 工程或新的前端依赖。 + +**目标测试** + +```text +node tests/test_render_window.js +python tests/diag_render_scale.py 400 +python tests/smoke_offscreen.py +python tests/smoke_timeline.py +python tests/smoke_midswitch.py +python tests/test_file_attach.py +node tests/test_math_extract.js +``` + +`test_render_window.js` 必须直接测试 DOM 无关状态机;DOM/QWebChannel 集成由 Qt smoke 覆盖。 + +**完成证据** + +- 参数化测试覆盖 `auto`/`manual`、10/40/200、全部非法配置类型、双向连续换页、首尾边界和过期响应。 +- 固定 400 条夹具中 `.message-wrapper <= size`,并记录总节点数与页面高度;节点和高度阈值只对该固定夹具验收,见 `VERIFICATION.md`。 +- 附件消息、带工具时间线消息和多分支消息在被裁剪后再次加载,内容与控件完整。 +- 有/无活动流两种情况下均满足严格上限;流式期间没有删除活动消息。 +- 自动与手动模式分别有向上行为断言;两个模式都有无需点击的向下恢复断言。 +- 锚点误差记录不超过 2 px;分支目标缺失的回底行为有断言。 +- 帧耗时只形成真机人工基准报告,不作为自动化硬阈值。 + +## P1-02 Windows/Linux shell 与进程树终止 + +**状态:已完成。** 2026-07-09 无人值守轮次:新增 `core/platform_shell.py` 窄适配(Windows 字符串命令行 `cmd.exe /d /s /c ""`、Linux `/bin/bash -lc` argv + 独立进程组;超时/中止整树终止);`SYSTEM_PROMPT.md` 单一通用正文 + `{{SHELL_PLATFORM_SECTION}}` 运行时短平台段。目标测试 20/20、30/30、35/35、41/41 全绿,smoke_mode 完整 agent 回合 ALL PASS。Linux 进程组用例(C3)在 Linux 环境运行时生效。证据见 `evidence/P1-02.md`。 + +**目标** + +明确工具 shell 契约,并保证超时、取消和异常清理能结束整棵子进程树。 + +**先读文件** + +- `core/agent/tools.py` 中 bash 工具、`Popen`、超时和终止逻辑 +- `core/llm_engine.py` 中 `load_system_prompt` +- `SYSTEM_PROMPT.md` 的 shell/path 说明 +- `tests/test_bash_stream.py`、`tests/test_tool_params.py` + +**允许修改** + +- 上述文件和测试。 +- 可以新增一个窄的平台进程适配模块及 `tests/test_cross_platform_shell.py`。 + +**硬约束** + +- Windows 明确通过 `cmd.exe` 执行;Linux 明确通过 `/bin/bash -lc` 执行,不依赖 `shell=True` 的平台默认值。 +- Windows 使用现有等价机制终止进程树;Linux 创建独立 POSIX 进程组,超时和主动中止均向整组发送终止信号,并在宽限期后强制结束。 +- 输出流、超时提示、截断上限和工具结果结构保持现有接口。 +- 保留一份通用 `SYSTEM_PROMPT.md`;运行时只插入短的平台 shell/path 段。不得维护两份完整提示词。 +- 提示词仍在每次请求时重读。Windows 段不得出现在 Linux 请求中,Linux 段不得出现在 Windows 请求中。 +- 不增加命令审批、Agent shell sandbox 或路径限制。 + +**目标测试** + +```text +python tests/test_cross_platform_shell.py +python tests/test_bash_stream.py +python tests/test_tool_params.py +python tests/test_agent_core.py +``` + +**完成证据** + +- 平台参数测试捕获到 Windows 的 `cmd.exe` argv 和 Linux 的 `/bin/bash -lc` argv。 +- Linux 用例启动父进程和孙进程,分别在超时与主动中止后证明两者都不存在;Windows 有等价进程树用例。 +- 提示词测试模拟两个平台,证明只有对应平台段被插入且通用正文完全相同。 +- 现有 bash 流式输出、返回码、超时和截断回归全部通过。 + +## P1-03 Windows/Linux 渲染器启动链 + +**状态:已完成。** 2026-07-17 无人值守轮次:新增 `core/renderer_backend.py` 窄适配(后端解析:非法/跨平台 `webview2` 可见警告 + 回落平台默认;每实例独立 QtWebEngine profile 目录:源码 `data/webengine/profile__`、测试经 `HAOCODE_WEBENGINE_PROFILE_DIR` 重定向;`--no-sandbox` 仅显式 + root/容器时接受并打印高可见警告,普通桌面剥离);`main_window.py` 以 `sys.platform == "win32"` 门控 `core.webview2` 导入(Linux 永不触达 pythonnet/Win32/DLL/taskkill);`CustomWebPage(profile, parent)` 兼容旧式调用;`main.py` 导入 PyQt6 前 sanitize flags;requirements 平台 marker + Linux 运行说明。目标测试 19/19、10/10、22/0、8/8、39 passed 全绿 + `main.py` offscreen 真实启动链验证;Linux 真机与 root/容器接受路径待对应环境。证据见 `evidence/P1-03.md`。 + +**目标** + +建立明确的渲染器矩阵:Windows 首选 WebView2、失败回落 QtWebEngine;Linux 只使用 QtWebEngine。 + +**先读文件** + +- `main.py` +- `ui/views/main_window.py` 中浏览器创建、JS 就绪和配置读取 +- `core/webview2.py` +- `ui/views/wv2_view.py` +- `ui/views/custom_web_page.py` +- `requirements.txt` +- `tests/test_wv2_guard.py`、`tests/smoke_offscreen.py` + +**允许修改** + +- 上述启动链、依赖说明和测试。 +- 可以新增小型平台检测/QtWebEngine profile 适配器和 Linux 源码运行说明。 + +**硬约束** + +- Linux 路径不得导入或调用 pythonnet、Win32 API、WebView2 DLL 和 `taskkill`。 +- 不引入 WebKitGTK 或它的 Qt 封装。 +- QtWebEngine 使用隔离 profile,两个并行源码实例不得争用同一个 Chromium profile。保持现有页面功能,不伪造 Linux 单实例锁。 +- 非法 `webview_backend` 配置产生可见警告并回落平台默认值,不能阻断源码启动。 +- 正常桌面运行保留 Chromium sandbox;root/container 的无 sandbox 路径必须显式选择并打印风险提示。 +- `vendor/webview2/` 和根目录 `WebView2Loader.dll` 保持 Windows 运行时用途,不删除。 +- 不添加 PyInstaller/spec 文件或打包承诺。 + +**目标测试** + +```text +python tests/test_wv2_guard.py +python tests/smoke_offscreen.py +python tests/test_debug_window.py +node tests/test_math_extract.js +``` + +平台真实启动按 `VERIFICATION.md` 执行。 + +**完成证据** + +- 平台模拟测试证明 Windows 的首选/回落路径和 Linux 的 QtWebEngine-only 路径。 +- Linux import 测试不触达任何 Windows-only 符号。 +- 两个 QtWebEngine 实例同时运行、载入本地页面和关闭,profile 无锁冲突或互相清理。 +- Windows 真实 WebView2 和强制 QtWebEngine 回落各有一次启动记录;Linux X11/Wayland 各有一次 QtWebEngine 启动记录。 +- 普通用户运行参数中不存在 `--no-sandbox`;显式 root/container 路径有单独证据。 + +## P1-04 Linux 截图热键与截图实现 + +**状态:已完成。** 2026-07-17 无人值守轮次:新增三个窄适配器——`desktop_session.py`(会话探测 win32/x11/wayland/unknown + 能力路由/明确不可用日志)、`x11_hotkey.py`(ctypes→libX11 XGrabKey 原生全局热键,零 pip 依赖,注册失败/无显示明确日志,stop 释放)、`portal_capture.py`(Wayland 经 xdg-desktop-portal Screenshot,系统 gdbus CLI,用户授权不绕过 compositor,FilePicked→现有附件流程,拒绝/不支持/超时有明确结果);`main_window.py` 热键与截图按平台路由(Windows 行为保持);`screen_capture.py` 空画面守卫。目标测试 23/23、17/17、9 OK、8/8 全绿 + 回归 41/41 等全绿 + `main.py` offscreen 真实启动链;X11 真实注册/命中与 Wayland portal 三态待真实 Linux 宿主手动。证据见 `evidence/P1-04.md`。 + +**目标** + +把现有“截图全局热键”能力适配到 Linux,而不是扩展成通用键盘钩子系统。 + +**先读文件** + +- `ui/views/system_tools/global_hotkey.py` +- `ui/views/system_tools/screen_capture.py` +- `ui/views/main_window.py` 中热键注册、截图启动和结果处理 +- `main.py` 的平台启动逻辑 +- 相关附件/图片测试 + +**允许修改** + +- 上述文件和测试。 +- 可以新增 Windows、Linux X11、Linux Wayland 的窄适配器;原入口保持稳定。 + +**硬约束** + +- Windows 保持现有行为。 +- Linux X11 使用原生全局快捷键和可行的原生屏幕捕获路径。 +- Linux Wayland 使用 `xdg-desktop-portal` 或桌面协议完成快捷键和截图;遵守用户授权流程,不绕过 compositor。 +- portal、桌面环境或协议版本不支持时,界面/日志必须明确说明当前能力不可用,主程序仍可聊天和使用其他功能。 +- 只处理现有截图快捷键,不增加任意键监听、记录或重映射。 +- 不做打包和发行版泛化;Ubuntu 22.04/24.04 x64 以外标记“未验证”。 + +**目标测试** + +```text +python tests/test_global_hotkey_platforms.py +python tests/test_screen_capture_platforms.py +python tests/test_file_attach.py +python tests/smoke_offscreen.py +``` + +适配器单元测试使用替身;X11/Wayland 真实行为按 `VERIFICATION.md` 手工取证。 + +**完成证据** + +- 平台路由、授权拒绝、portal 缺失和注册失败均有确定的回归测试。 +- X11 真实桌面中,应用失焦时热键仍触发截图,图片回到现有附件流程。 +- Wayland 真实桌面中,通过 portal/桌面协议完成授权、触发和截图;若目标桌面确实不支持,留下明确错误和环境信息,而不是伪造成功。 +- Windows 现有热键和截图回归通过。 + +## P2-01 Bash 任务按启动时间倒序 + +**目标** + +运行中和已完成两栏都让最新启动的任务位于第一项,任务从运行中移动到已完成时仍使用原启动顺序。 + +**先读文件** + +- `ui/views/bash_panel.py` 中 `set_session`、`_refresh`、`on_started`、`on_finished`、`BashLayer` 和两个 section 类 +- `ui/views/main_window.py` 中 `_on_tool_started`、输出/计时/完成转发 +- `tests/smoke_bash_panel.py` + +**允许修改** + +- `ui/views/bash_panel.py`、必要的事件元数据传递和测试。 + +**硬约束** + +- 排序键是启动时间/启动序号,降序显示;不得用完成时间重排。 +- 已落库时间线没有显式时间时,使用消息链顺序和时间线内顺序构造稳定启动序号,不改数据库 schema。 +- 任务从运行中进入已完成后,相对顺序由原启动键决定,不因结束先后跳位。 +- 重排复用现有 `BashLayer` 实例;展开/折叠状态、实时输出、代码框水平/垂直滚动值、运行中栏和已完成栏的 section 滚动位置都保持。 +- 保留现有限量显示和上下文标记语义。 + +**目标测试** + +```text +python tests/smoke_bash_panel.py +python tests/test_bash_stream.py +``` + +**完成证据** + +- 至少三项任务以不同启动/完成顺序运行,两个 section 均断言启动时间降序。 +- 完成中间任务前后,对象 identity、展开状态、输出文本和四类滚动值保持。 +- 切换会话后从 DB/活动流重建的顺序与实时期间一致。 + +**状态:已完成。** 2026-07-21 无人值守轮次:`bash_panel.py` 的 `_refresh()` 两栏显示改为启动序号降序(排序键 = `_order` 稳定启动序号,构造方式 = 消息链顺序 + 时间线内顺序,无时间戳、无 schema 变更;完成时间从不参与排序,`on_finished` 不移动 `_order` 位置);重排仍走 `set_layers` 复用同一批 `BashLayer` 实例,展开/折叠、实时输出、代码框滚动、栏滚动位置全部保持;限量窗口成员与提示语不变。`smoke_bash_panel.py` 新增第 11 节 23 项断言(交错完成顺序、对象 identity、四类滚动值保持、DB 重建一致性)140 项 ALL PASS,`test_bash_stream.py` 30/30,回归 smoke_offscreen 8/8、run_tests 41/41 全绿。证据见 `evidence/P2-01.md`。 + +## P2-02 右侧 Bash 面板滚动条 + +**目标** + +只修复右侧 Bash 面板的原生滚动条和横纵滚动条交汇角,不污染其他 Qt 控件。 + +**先读文件** + +- `ui/views/main_window.py` 中全局 QSS 的 `#right_sidebar`、`#bl_code` 段 +- `ui/views/bash_panel.py` 中 `BashLayer._code_box` 和 section 滚动区 +- Qt 当前版本的 `QAbstractScrollArea::corner`/`QPlainTextEdit::corner` 样式行为 +- `tests/smoke_bash_panel.py` + +**允许修改** + +- 右侧面板的局部 QSS、相关测试和新建 `tests/diag_panel_scrollbar.py`。 + +**硬约束** + +- 保留现有 `#bl_code` 背景、边框、圆角和文本基础样式,只补滚动条与正确的 corner 规则。 +- 所有选择器必须限定在右侧 Bash 面板或 `#bl_code`;不得添加无作用域的 `QScrollBar`/`QAbstractScrollArea` 全局规则。 +- 横纵滚动条目标厚度为 8 px,隐藏箭头,handle 可见且 hover 正常;corner 与代码框背景一致。 +- 使用 Qt 支持的 `QAbstractScrollArea`/`QPlainTextEdit` corner 子控件语法,不写 `QScrollBar::corner`。 +- 不借机调整其他弹窗、会话列表或全局字体。 + +**目标测试** + +```text +python tests/smoke_bash_panel.py +python tests/diag_panel_scrollbar.py +``` + +**完成证据** + +- 离屏测试断言横纵滚动条 sizeHint/实际厚度和箭头 extent,诊断脚本生成局部截图并打印测量值。 +- Windows 与 Linux QtWebEngine 真机截图均显示无箭头、无亮色 corner 方块、handle 可辨识。 +- 模型弹窗、会话列表、附件预览和调试窗口的滚动条与修复前一致。 + +**状态:已完成。** 2026-07-21 无人值守轮次:`main_window.py` 全局 QSS 中 `#bl_code` 规则后插入一段完全限定作用域的滚动条 + corner 规则(`QPlainTextEdit#bl_code QScrollBar:*`、`QPlainTextEdit#bl_code::corner { background-color: #fbfcfe; }`、`QScrollArea#bl_scroll QScrollBar:*`)——零无作用域规则;`#bl_code` 原有背景/边框/圆角/文本样式未动。新建 `tests/diag_panel_scrollbar.py`(18 项断言:厚度/sizeHint/箭头 subControlRect=0/corner 像素 #fbfcfe/无亮白/未命名框仍原生 14px/附件区仍 6px)18 项 ALL PASS,`smoke_bash_panel.py` 140 项、回归 smoke_offscreen 8/8、run_tests 41/41、timeline 11/11、midswitch 7/7 全绿。截图落盘 `evidence/p2-02-*.png`。Windows/Linux 真机截图项:在对应环境直接运行 `diag_panel_scrollbar.py` 即可出图验证。证据见 `evidence/P2-02.md`。 + +## P2-03 WebView2 原生窗口遮挡层 + +**目标** + +修复改名遮罩被 WebView2 原生子窗口压住的确定性缺陷,并审计同类遮罩,只修复能确认的原生窗口遮挡。 + +**先读文件** + +- `ui/views/main_window.py` 中 `AttachmentPreviewOverlay`、`RenameOverlay`、`SessionContextPopup`、`SettingsWindow`、所有以 `bg_widget` 或主窗口为 parent 的全窗口候选,以及 `_rename_session` +- `ui/views/wv2_view.py` +- `core/webview2.py` 中原生子窗口层级说明 +- `tests/smoke_offscreen.py` + +**允许修改** + +- 上述遮罩实现和测试。 +- 可以新增 `tests/diag_rename_overlay.py`;只有出现真实重复时才可加一个窄的几何同步辅助函数。 + +**硬约束** + +- `RenameOverlay` 使用可覆盖原生 WebView2 的独立顶层透明窗口,覆盖主窗口客户区并保持卡片居中;系统标题栏和窗口控制仍可操作。 +- 主窗口移动、缩放、最大化、还原和多显示器/DPI 变化时几何同步正确。 +- 保留点击空白关闭、Esc、关闭按钮、输入框全选、Enter 提交和关闭后焦点恢复。 +- 顶层窗口关闭后释放,不残留遮罩、事件过滤器或焦点捕获。 +- 审计每个遮罩后只修复可复现的同类问题;普通 popup/dialog 不重写成统一框架。 +- QtWebEngine 路径行为不得退化;真正的 WebView2 覆盖只能在 Windows 真机验收。 + +**目标测试** + +```text +python tests/diag_rename_overlay.py +python tests/smoke_offscreen.py +python tests/smoke_copy_session.py +``` + +**完成证据** + +- 结构测试证明遮罩是顶层 Tool 窗口、启用透明背景、覆盖主窗口客户区并可释放。 +- Windows WebView2 真机截图显示聊天区与 Qt 区域均匀变暗,卡片在最上层且可交互。 +- 拖动、缩放、最大化、还原、多 DPI 显示器至少各验证一次;记录覆盖误差。 +- 遮罩审计表列出每个候选、是否受原生窗口影响、复现结果和处理决定。 + +**状态:已完成。** 2026-07-21 无人值守轮次:`RenameOverlay` 整类重写为独立顶层 `FramelessWindowHint|Tool` 透明窗(`WA_TranslucentBackground` + `WA_DeleteOnClose`)——只覆盖主窗口**客户区**(`mapToGlobal(main.rect().topLeft())`+客户区尺寸,系统标题栏/窗口控制保持可操作),eventFilter 跟随 `Move`/`Resize`/`WindowStateChange`(最大化/还原/DPI 变化同路),保留点空白/✕/取消/Enter/输入全选,新增 Esc 关闭;关闭时 `removeEventFilter`+焦点回主窗+`deleteLater`,`_closed` 幂等。调用点 `_rename_session` 未改。审计:`AttachmentPreviewOverlay`/`SettingsWindow`/`SessionContextPopup`/`ModelSelectPopup`/`SessionModePopup`/`PdfModePopup` 均为独立顶层窗(不受影响),`RenameOverlay` 是唯一 bg_widget 子控件模式者(已修)。未抽公共辅助函数(不构成真实重复);未重写普通 popup/dialog。新建 `tests/diag_rename_overlay.py`(34 项:结构/客户区覆盖/跟随/全部关闭与提交行为/释放无残留)ALL PASS;目标测试 smoke_offscreen 8/8、smoke_copy_session ALL PASS;回归 smoke_bash_panel 140 项、run_tests 41/41 全绿(均 EXIT=0)。真机 WebView2 验收:`core/webview2.py` `get_environment()` 含 `taskkill /F /IM msedgewebview2.exe` 且共享 profile 不可重定向,无人值守自动化有意不走该路径;机制与生产已验证的附件预览层同构(拥有者顶层 Tool 窗 z 序必盖 WebView2 原生子 HWND),人工验收清单见 `evidence/P2-03.md`。证据见 `evidence/P2-03.md`。 + +## P2-04 跨平台聚合测试入口 + +**目标** + +提供一个可在 Windows/Linux 调用的自动化聚合入口,同时保留每个现有测试的独立运行方式。 + +**先读文件** + +- `tests/run_tests.py` +- `tests/test_*.py`、`tests/smoke_*.py` 的入口和环境假设 +- 本文各任务新增的测试 +- `VERIFICATION.md` + +**允许修改** + +- `tests/run_tests.py`,或新增 `tests/run_all.py`。 +- 可以新增测试清单/分组元数据和必要的测试环境辅助模块。 +- 可以更新交接测试文档。 + +**硬约束** + +- 保留所有独立命令;聚合入口不得要求 pytest、npm 或网络。 +- 每个测试使用独立临时目录;GUI 测试在 import `MainWindow` 前完成临时数据库与临时配置设置。 +- 默认聚合不运行 `diag_*`、`verify_*`、`tune_*`、真实 API、真实桌面或需要凭据的脚本。 +- 平台不适用项必须以明确 `SKIP` 和理由呈现,不能伪装通过;平台共同项失败时返回非零。 +- 一个子测试崩溃或超时不能阻止结果汇总,最终退出码仍反映失败。 +- 不读取真实配置,不访问网络,不删除用户运行数据。 + +**目标测试** + +```text +python tests/run_all.py --group logic +python tests/run_all.py --group offscreen +python tests/run_all.py --group all +``` + +若选择扩展现有 `run_tests.py`,保持等价分组参数,并同步本文命令。 + +**完成证据** + +- Windows 和 Linux 各有一份汇总,列出 PASS/FAIL/SKIP、耗时和失败命令。✅ Windows 25/25 PASS(logic 12 + offscreen 13,均 EXIT=0);WSL/CPython 3.12.3 全量 PASS 4 / FAIL 0 / SKIP 21(依赖缺失/平台不适用,理由逐条打印,EXIT=0)。 +- 用一个故意失败的临时夹具证明聚合入口返回非零且仍汇总后续测试;夹具不提交。✅ 崩溃夹具→FAIL、超时夹具→TIMEOUT,后续健康测试照跑,退出码 1,完整汇总 + 失败命令清单;夹具用后即删。 +- 默认运行记录证明未启动真实 API 测试、人工诊断脚本或真实桌面脚本。✅ 默认清单仅 25 条自动化条目;diag_*/verify_*/tune_*/real-DB/凭据类均在排除清单带理由(见 run_all.py 注释)。 +- 各单文件命令仍可直接运行,输出与聚合子进程一致。✅ `test_copy_session.py` 独立 vs 聚合子日志尾逐字一致(54/54 PASS)。 + +## 整体验收终点 + +只有同时满足以下条件,本清单才完成: + +1. 每个任务的目标测试通过,并留下该任务要求的证据。 +2. `VERIFICATION.md` 的完整自动化回归在 Windows 和 Linux 通过;合理的平台专属项明确跳过。 +3. Windows WebView2、Windows QtWebEngine 回落、Linux X11 QtWebEngine、Linux Wayland QtWebEngine 均完成真实桌面检查。(Windows 两项:✅ 2026-09-17 已验证,见 evidence/P1-03.md;Linux 两项:待 Ubuntu 桌面主机) +4. X11 与 Wayland 的截图热键按各自协议验证;不支持的 Wayland 环境留下明确错误证据。 +5. 没有业务功能扩展、模块搬迁、打包变更、Agent sandbox 或真实配置访问混入修复。 diff --git a/docs/agent-handoff/VERIFICATION.md b/docs/agent-handoff/VERIFICATION.md new file mode 100644 index 0000000..1108935 --- /dev/null +++ b/docs/agent-handoff/VERIFICATION.md @@ -0,0 +1,262 @@ +# 验证与取证规范 + +本文定义修复任务的统一验证口径。任务范围和完成条件见 [REPAIR_BACKLOG.md](REPAIR_BACKLOG.md)。平台行为契约见 [PLATFORM_PLAN.md](PLATFORM_PLAN.md)。 + +## 测试前置条件 + +### 凭据与运行数据隔离 + +`data/config.json` 是不透明秘密。执行测试的 Agent 不得打开、读取、搜索、打印、复制或修改它,也不得用散列或快照方式“验证未变化”。采用以下正向隔离: + +1. 为每个测试进程创建独立临时目录和最小临时配置。 +2. 在 import 任意可能间接加载 `MainWindow` 的模块前设置 `HAOCODE_CONFIG_FILE`。 +3. 在 import `MainWindow` 前把 `core.db_manager._DEFAULT_DB` 指向临时数据库。 +4. 写入、迁移、附件和截图产物只落到临时目录。 +5. 用文件打开拦截器或替身断言禁止路径从未被访问,不读取禁止路径本身。 + +需要真实 API key 的 `diag_live_*`、`smoke_live_*` 由用户在受控环境手工决定是否运行;默认测试和工作 Agent 均不运行它们。 + +### GUI 环境 + +离屏测试只验证结构和一般 Qt 行为,不算真机视觉证据。 + +Windows PowerShell: + +```powershell +$env:PYTHONIOENCODING = 'utf-8' +$env:QT_QPA_PLATFORM = 'offscreen' +python tests/smoke_offscreen.py +``` + +Linux: + +```bash +PYTHONIOENCODING=utf-8 QT_QPA_PLATFORM=offscreen python tests/smoke_offscreen.py +``` + +真实桌面检查前移除 `QT_QPA_PLATFORM=offscreen`。不得用离屏截图替代 WebView2、X11、Wayland、portal、DPI 或滚动条真机检查。 + +### 结果记录 + +每个任务至少记录: + +- 操作系统、桌面会话类型、Python、PyQt6/Qt 版本; +- 精确命令、退出码和测试摘要; +- 失败或跳过项及理由; +- 真机项的截图/录屏路径和复现步骤; +- 是否使用临时配置、临时数据库和离屏模式。 + +不要写固定“应通过 N 项”的文案;用退出码和当前测试自己报告的数量为准,避免测试增删后文档失真。 + +## 定向测试矩阵 + +每个任务先跑自己的定向测试。定向通过后才跑所在阶段的完整回归。 + +| 任务 | 必跑命令 | 额外人工证据 | +|---|---|---| +| P0-01 配置隔离 | `python tests/test_config_isolation.py`;`python tests/test_error_persist.py`;`python tests/smoke_bash_panel.py`;test_agent_core(经 `python tests/run_tests.py` 运行) | 打开路径拦截记录只含临时目录 | +| P0-02 `eventFilter` | `python tests/test_main_window_event_filter.py`;`python tests/smoke_offscreen.py`;`python tests/smoke_mode.py` | Enter/Shift+Enter 行为记录 | +| P0-03 文档 | 文档链接检查与 `rg` 检查 | 从 `AGENTS.md` 演练一次读取路径 | +| P1-01 渲染窗口 | `node tests/test_render_window.js`;`python tests/diag_render_scale.py 400`;相关 Qt smoke | 400 条固定夹具、锚点误差、帧时间报告 | +| P1-02 shell | `python tests/test_cross_platform_shell.py`;`python tests/test_bash_stream.py`;`python tests/test_tool_params.py` | 两平台进程树消失证明 | +| P1-03 渲染器 | `python tests/test_wv2_guard.py`;`python tests/smoke_offscreen.py`;`node tests/test_math_extract.js` | Windows 两后端、Linux X11/Wayland 启动 | +| P1-04 热键/截图 | 平台适配单元测试、`python tests/test_file_attach.py` | Windows、X11、Wayland 各自真机行为 | +| P2-01 Bash 排序 | `python tests/smoke_bash_panel.py`;`python tests/test_bash_stream.py` | 重排前后状态与滚动位置 | +| P2-02 滚动条 | `python tests/diag_panel_scrollbar.py`;`python tests/smoke_bash_panel.py` | Windows/Linux 局部截图和尺寸 | +| P2-03 遮罩 | `python tests/diag_rename_overlay.py`;`python tests/smoke_offscreen.py` | Windows WebView2 遮罩截图/移动缩放录屏 | +| P2-04 聚合入口 | `python tests/run_all.py --group logic`;`--group offscreen`;`--group all` | Windows/Linux 汇总各一份 | + +表中尚不存在的脚本属于对应任务的交付物,不得在创建前声称已经通过。 + +## P1-01 渲染窗口专项 + +### DOM 无关状态机 + +`tests/test_render_window.js` 直接加载纯状态机,不依赖浏览器、JSDOM 或 npm。至少覆盖: + +- `auto`、`manual` 两种模式; +- `size` 为 10、40、200; +- 缺失、`true`/`false`、字符串、0、负数、9、201 等值都回落 40; +- 初始最新页、连续向上、连续向下、首尾边界; +- 加一端时裁另一端,窗口始终不超过上限; +- 活动流计入上限,且不会被裁; +- 新 token 到来回到底部; +- `clearChat` 清游标/缓存/未决请求但保留配置; +- 会话/代次变化后丢弃旧响应; +- 分支目标保留和目标缺失回底。 + +### Qt/DOM 集成 + +Qt smoke 使用临时会话生成完整描述,至少包含普通消息、附件、reasoning、工具时间线、工具结果和多分支消息。用 QWebChannel 完成多轮双向换页后,逐项比较重建结果。 + +通用硬指标:任意时刻 `.message-wrapper <= render_window_size`。这条对所有夹具、所有窗口大小和流式状态都成立。 + +固定 400 条夹具的附加指标: + +| 指标 | 门槛 | +|---|---| +| `.message-wrapper` | `<= render_window_size`,默认 `<= 40` | +| DOM 总节点 | `<= 4000` | +| 页面总高度 | `<= 30000 px` | +| 向上换页锚点误差 | `<= 2 px` | + +总节点数和页面高度只对版本化的固定 400 条夹具验收。修改夹具必须在结果中说明,不得把这些数字套到任意内容长度。 + +### 帧时间 + +帧时间属于人工基准报告,不是自动化通过门槛。相同机器、相同窗口尺寸、相同 400 条夹具分别记录修复前/后: + +- 流式追加一段固定文本期间的采样次数; +- frame duration 的 median、p95 和最大值; +- 是否发生肉眼可见停顿; +- 浏览器后端和 Qt 版本。 + +报告原始数值和测量方法,不把环境波动包装成确定性断言。 + +## 当前独立回归命令 + +在 P2-04 聚合入口完成前,按影响范围选择下列现有命令。每项任务不要求机械运行所有命令;一个阶段结束时运行完整集合。 + +```text +python tests/run_tests.py +python tests/test_config_isolation.py +python tests/test_main_window_event_filter.py +python tests/test_tool_params.py +python tests/test_compaction_persist.py +python tests/test_copy_session.py +python tests/test_bash_stream.py +python tests/test_error_persist.py +python tests/test_wv2_guard.py +python tests/test_debug_window.py +python tests/test_think_code_neutral.py +python tests/test_file_attach.py +python tests/test_pdf_reader.py +python tests/smoke_offscreen.py +python tests/smoke_mode.py +python tests/smoke_copy_session.py +python tests/smoke_bash_panel.py +node tests/test_math_extract.js +``` + +平台专属测试在不适用的平台明确 `SKIP`。任何共同逻辑测试失败都不能以平台差异豁免。 + +`tests/run_tests.py` 当前只覆盖其显式加载内容;在 P2-04 完成前,不能把它单独称为“全套测试”。 + +## 分阶段完整回归 + +### P0 完成后 + +运行全部纯逻辑测试和所有受影响的离屏 GUI 测试。重点证明测试不会访问真实配置/数据库,并且 `MainWindow` 输入行为没有回归。 + +### P1 完成后 + +运行当前独立回归命令全集,加上: + +```text +node tests/test_render_window.js +python tests/test_cross_platform_shell.py +python tests/test_global_hotkey_platforms.py +python tests/test_screen_capture_platforms.py +``` + +随后完成 Windows/Linux 真实桌面矩阵。平台适配任务没有真实桌面证据时不能标记完成。 + +### P2 完成后 + +优先运行新聚合入口: + +```text +python tests/run_all.py --group all +``` + +再单独运行三个诊断脚本;它们属于人工/半自动取证,不应混入默认聚合: + +```text +python tests/diag_render_scale.py 400 +python tests/diag_panel_scrollbar.py +python tests/diag_rename_overlay.py +``` + +## 真实桌面矩阵 + +### Windows 10/11 x64 + +至少覆盖以下路径: + +1. WebView2 首选路径:启动、发一轮对话、流式输出、附件、分支、切会话。 +2. QtWebEngine 强制回落:使用临时配置启动同一套基本流程。 +3. 同时启动两个 QtWebEngine 实例,证明 profile 不争用。 +4. WebView2 下打开改名遮罩,验证整个客户区覆盖;拖动、缩放、最大化和还原。 +5. Bash 面板三项以上任务,验证最新任务在顶部、状态保持和滚动条视觉。 +6. 截图全局热键在应用失焦时仍能捕获并进入附件流程。 + +证据必须标注实际后端。QtWebEngine 截图不能替代 WebView2 原生遮挡验收。 + +### Ubuntu 22.04/24.04 x64 + X11 + +至少使用 Python 3.10--3.12 范围内一个受支持版本完成: + +1. `python main.py` 启动 QtWebEngine,渲染 Markdown、KaTeX、代码块和工具时间线。 +2. 发一轮对话并执行 shell 工具,实际命令由 `/bin/bash -lc` 执行。 +3. 超时和主动中止后检查父/孙进程均不存在。 +4. 双开应用,两个 QtWebEngine profile 不冲突。 +5. X11 原生全局截图热键在应用失焦时触发,截图进入附件流程。 +6. 消息窗口、Bash 排序与滚动条完成一次真机检查。 + +### Ubuntu 22.04/24.04 x64 + Wayland + +至少完成: + +1. QtWebEngine 正常启动并完成基本对话与渲染。 +2. portal/桌面协议请求有清晰的用户授权流程。 +3. 全局快捷键和截图通过 portal/桌面协议完成;若当前桌面协议不支持,界面明确报错,应用其余功能继续可用。 +4. 拒绝授权、portal 服务缺失和协议版本不足各记录一次结果。 +5. 普通用户启动保持 Chromium sandbox。 + +Wayland 下不得用 X11 私有 API 或静默降级成“仅窗口内快捷键”并声称全局热键通过。 + +### 其他发行版 + +可以记录探索结果,但统一标记“未验证”,不能扩大官方支持矩阵。 + +## 人工 UI 检查 + +### Bash 排序与状态 + +按 A、B、C 顺序启动,按 B、A、C 或其他不同顺序结束。运行中栏和已完成栏始终按 C、B、A 的启动顺序显示。操作前先: + +- 展开其中一个 layer; +- 在输出框分别设置水平和垂直滚动位置; +- 滚动运行中栏和已完成栏。 + +任务状态迁移后,上述展开状态、输出、代码框滚动和两个 section 滚动均保持。 + +### Bash 滚动条 + +同时制造横向与纵向溢出,记录: + +- 横纵滚动条实际厚度; +- 箭头区域是否消失; +- handle 是否可拖动并有 hover; +- 横纵交汇处是否与代码框背景一致; +- 模型弹窗、会话列表、附件预览和调试窗口是否未受影响。 + +### 改名遮罩 + +Windows WebView2 下记录打开、移动、缩放、最大化、还原、提交和取消。截图必须包含整个主窗口,能比较聊天区与 Qt 控件区的遮罩亮度。结构性离屏断言只是补充。 + +## 聚合入口契约 + +P2-04 完成后的聚合入口必须: + +- 使用 Python 子进程执行现有独立测试,不把所有测试 import 到同一进程; +- 为每个子进程建立独立临时环境; +- 支持至少 `logic`、`offscreen`、`all` 三组; +- 汇总命令、退出码、耗时和 PASS/FAIL/SKIP; +- 默认排除 live、diag、verify、tune、网络和凭据测试; +- 一个子进程失败后继续收集其余结果,最终返回非零; +- 在 Windows 和 Linux 使用同一 Python 接口,不嵌入 `.bat` 或 Bash 专属命令串。 + +## 最终判定 + +一项修复只有在以下内容齐全时才算完成:定向测试通过、阶段完整回归通过、该平台需要的真实桌面证据齐全、所有跳过项有理由、真实配置和数据库从未被测试访问。任何一项缺失都应标记为“未完成”而不是“基本完成”。 diff --git a/docs/agent-handoff/evidence/P0-01.md b/docs/agent-handoff/evidence/P0-01.md new file mode 100644 index 0000000..df45beb --- /dev/null +++ b/docs/agent-handoff/evidence/P0-01.md @@ -0,0 +1,45 @@ +# P0-01 配置路径与测试隔离 — 执行证据 + +日期:2026-09-16(无人值守轮次) +平台:Windows 11 10.0.26200 x64 · Python 3.10.21(`.venv`)· PyQt6/Qt 6.10.0/6.10.2 · 离屏 `QT_QPA_PLATFORM=offscreen` + +## 根因(源码确认) + +1. `core/llm_engine.py` 模块常量 `CONFIG_PATH` + `_load_config()` 不读 `HAOCODE_CONFIG_FILE` → 三个 Qt worker(Agent/Chat/Title)直接读项目内真实配置。 +2. `ui/views/main_window.py:3133`(webview_backend 分支)与 `:5244`(`init_model_popup`)直接 `open(data/config.json)`,绕过环境变量。 +3. `ui/views/bash_panel.py` 有私有的环境变量解析(双入口,非统一)。 +4. `core/db_manager._DEFAULT_DB` 为模块级全局,测试在 import 前重定向的既有模式成立,沿用。 + +## 修复摘要 + +- 新增 `core/config_paths.py`:`config_path()`(`HAOCODE_CONFIG_FILE` 优先、调用时解析)+ `load_config()`(缺失/损坏/非对象 → 可见警告 + 安全空 dict,不抛异常)。 +- `core/llm_engine.py`:删除 `CONFIG_PATH` 常量;`_load_config()` 委托 `load_config()`(保留函数名兼容既有调用方)。 +- `ui/views/main_window.py`:两处直接 open 改走 `core.config_paths.load_config`。 +- `ui/views/bash_panel.py`:`_cfg_path()` 委托统一 `config_path()`,删除私有 `_CFG_PATH` 常量。 +- 新增 `tests/_test_env.py`:`isolate(tag, config)` 统一创建临时配置 + 临时数据库并在 import MainWindow 前完成重定向。 +- 改造 3 个在范围测试使用统一临时环境:`tests/smoke_bash_panel.py`、`tests/test_error_persist.py`、`tests/test_agent_core.py`(provider 用例改读临时配置中的 `testprov`,不再依赖真实配置)。 +- 新增 `tests/test_config_isolation.py`:open/sqlite 拦截器 + 缺失/损坏/非对象回归 + AST 静态扫描。 + +## 定向测试(命令 / 退出码 / 结果) + +| 命令 | 退出码 | 结果 | +|---|---|---| +| `python tests/test_config_isolation.py` | 0 | ALL PASS(14 项断言) | +| `python tests/test_error_persist.py` | 0 | 39 PASS(与交接基线 39 一致) | +| `python tests/smoke_bash_panel.py` | 0 | 116 PASS(与交接基线 116 一致) | +| `python tests/run_tests.py`(test_agent_core.py 的仓库标准运行方式,pytest 由 harness stub) | 0 | 41 passed, 0 failed(与交接基线 41 一致) | + +注:`python tests/test_agent_core.py` 直接运行在本仓库不可用(文件无独立 runner 且 .venv 不装 pytest,见 requirements.txt 说明),按其设计经 `tests/run_tests.py` 运行;P2-04 聚合入口将统一固化该运行方式。 + +## 完成证据对应 + +- **拦截器证明**:MainWindow 构造 + `save_panel_width` 写回全程,所有 `config.json`(含原子写 `.tmp`)打开路径均位于 `tempfile.gettempdir()/haocode_test_cfgiso_/`;真实配置路径(仅以字符串比较)从未出现在打开记录中。未读取、未散列真实配置。 +- **临时配置读写**:`llm_engine._load_config()` 读到 `testprov`;`save_panel_width(340)` → `load_panel_width() == 340`,写路径落临时目录。 +- **数据库隔离**:sqlite3.connect 拦截记录中临时库之外零连接/写入。 +- **缺失/损坏/非对象**:三个回归用例均返回 `{}` 且 stdout 含明确警告(`[config] 配置文件缺失/读取/解析失败/不是 JSON 对象`),进程正常退出。 +- **静态扫描**:`core/`、`ui/`、`tools/`、`main.py` 中除 `core/config_paths.py` 外不存在 `config.json` 字符串字面量(AST 级,docstring/注释排除)。 + +## 观察项(未扩范围,留待后续) + +- `tests/smoke_offscreen.py`、`smoke_mode.py`、`smoke_copy_session.py` 只重定向了数据库、未设置 `HAOCODE_CONFIG_FILE`(不在 P0-01 允许修改清单内)。本轮运行这些套件时在启动环境显式导出临时配置;P2-04 聚合入口将按子进程强制注入临时环境,彻底闭环。 +- `tests/diag_live_agent.py:19`、`tests/tune_model_popup.py:155` 直接引用真实配置路径;二者属 live/tune 人工脚本,默认聚合排除。 diff --git a/docs/agent-handoff/evidence/P0-02.md b/docs/agent-handoff/evidence/P0-02.md new file mode 100644 index 0000000..7d33855 --- /dev/null +++ b/docs/agent-handoff/evidence/P0-02.md @@ -0,0 +1,56 @@ +# P0-02 合并重复的 `MainWindow.eventFilter` — 执行证据 + +日期:2026-09-16(无人值守轮次) +平台:Windows 11 10.0.26200 x64 · Python 3.10.21(`.venv`)· PyQt6/Qt 6.10.0/6.10.2 · 离屏 `QT_QPA_PLATFORM=offscreen` + +## 根因(源码确认) + +- `MainWindow` 类体内定义了两个 `eventFilter`(行 3697 与 3763):后定义者覆盖前者,前者的 + `_active_streams` 守卫是死代码。 +- 生效版本(3763)用 `btn_send.isEnabled()` 做守卫,而 `set_send_button_state` 只切换 + 图标、从不禁用按钮 → 守卫恒真 → 流式生成中按 Enter 会落入 `send_message` 的中断路径 + (触发停止),与注释声称的「生成时按回车无效,防止误触」相反。 + +## 修复摘要(仅 `ui/views/main_window.py` 事件过滤逻辑) + +- 删除行 3763 的重复 `eventFilter`(及其后不可达的两行过期分节注释)。 +- 保留行 3697 处为 `MainWindow` 唯一 `eventFilter`:Enter(无 Shift)→ `send_message(from_enter=True)` + 并消费事件(一次按键至多一次调用);Shift+Enter → 返回 False 放行换行;其他对象/事件交父类。 +- `send_message(self, from_enter: bool = False)`:函数顶部为发送规则单一实现: + 1) `btn_send` 禁用 → 一律不发送; + 2) `from_enter=True` 且当前会话在 `_active_streams` → 直接返回(Enter 不参与停止/中断语义); + 3) 按钮点击路径行为完全不变(流式中点击 = 原有红色停止按钮中断语义,含 Fix B/C)。 +- `_update_send_button_state` 未改(其语义与规则一致)。 + +## 定向测试(命令 / 退出码 / 结果) + +| 命令 | 退出码 | 结果 | +|---|---|---| +| `python tests/test_main_window_event_filter.py` | 0 | ALL PASS(18 项断言) | +| `python tests/smoke_offscreen.py` | 0 | ALL PASS: 8/8 | +| `python tests/smoke_mode.py` | 0 | ALL PASS | +| 回归 `python tests/test_config_isolation.py` | 0 | ALL PASS(14 项) | +| 回归 `python tests/test_error_persist.py` | 0 | 39 PASS | +| 回归 `python tests/smoke_bash_panel.py` | 0 | 116 PASS | + +注:`smoke_offscreen.py` / `smoke_mode.py` 自身只重定向数据库、未设置 `HAOCODE_CONFIG_FILE` +(不在 P0-02 允许修改清单内)。本轮运行时在进程环境显式导出指向临时配置的 +`HAOCODE_CONFIG_FILE`;P2-04 聚合入口将按子进程强制注入临时环境,彻底闭环。 + +## 完成证据对应(test_main_window_event_filter.py) + +- **AST 静态断言**:解析 `ui/views/main_window.py`,`MainWindow` 类体内 `eventFilter` 定义恰好 1 个(行 3697); +- **Enter 可发送**(A1–A5):空闲 + 按钮可用 + 有文本 → 一次 Enter 恰好一次 `send_message(from_enter=True)` + 调用(计数 wrapper 包住真实实现),流同步注册、输入框清空、错误路径自清理; +- **Enter 被禁用**(B1–B3):`btn_send.setEnabled(False)` → 至多一次调用且无流、输入内容保留(规则在 `send_message` 内生效); +- **流式时 Enter 被拦截**(C1–C3):注入假流 → Enter 后假流对象未被替换、字段未被改动(未触发中断)、输入保留; +- **Shift+Enter 换行**(D1–D3):零调用,事件放行到输入框(光标处插入 `\n`)、无流; +- **其他键/事件交父类**(E1–E2):按 `a` 正常插入字符、零发送调用; +- **一次按键至多一次调用**:A/B/C/D 各用例均以调用计数断言(全部 ≤1 且语义正确)。 + +## 行为变化说明 + +- 流式生成中按 Enter:旧(生效)代码会触发停止/中断;新代码 no-op(Enter 只管发送, + 停止只走按钮)。这与被覆盖版本注释中声明的原始意图(「生成时按回车无效,防止误触」) + 和 P0-02 硬约束(「流式生成时不得发送」)一致,属本任务预期的确定性修复。 +- 发送按钮点击路径(含流式中点击 = 中断):逐行未动。 diff --git a/docs/agent-handoff/evidence/P0-PHASE_REGRESSION.md b/docs/agent-handoff/evidence/P0-PHASE_REGRESSION.md new file mode 100644 index 0000000..c046f24 --- /dev/null +++ b/docs/agent-handoff/evidence/P0-PHASE_REGRESSION.md @@ -0,0 +1,42 @@ +# P0 阶段完整回归 — 执行证据 + +日期:2026-09-16(无人值守轮次) +平台:Windows 11 10.0.26200 x64 · Python 3.10.21(`.venv`)· PyQt6/Qt 6.10.0/6.10.2 · Node(test_math_extract) +运行方式:全部子进程统一注入 `HAOCODE_CONFIG_FILE` 指向临时配置(`smoke_offscreen/smoke_mode/smoke_copy_session` 自身不设该变量,属 P2-04 前已知观察项);GUI 套件 `QT_QPA_PLATFORM=offscreen`。 + +## 结果(命令 / 退出码 / 摘要) + +纯逻辑(13 个套件,全部 EXIT=0): + +| 套件 | 摘要 | +|---|---| +| `python tests/run_tests.py` | 41 passed, 0 failed | +| `python tests/test_tool_params.py` | ALL PASS | +| `python tests/test_compaction_persist.py` | ALL PASS | +| `python tests/test_copy_session.py` | ALL PASS | +| `python tests/test_bash_stream.py` | ALL PASS | +| `python tests/test_error_persist.py` | ALL PASS(39 项) | +| `python tests/test_wv2_guard.py` | ALL PASS | +| `python tests/test_debug_window.py` | 22 PASS / 0 FAIL | +| `python tests/test_think_code_neutral.py` | ALL PASS | +| `python tests/test_file_attach.py` | OK | +| `python tests/test_pdf_reader.py` | OK | +| `python tests/test_config_isolation.py` | ALL PASS(14 项,P0-01 新增) | +| `python tests/test_main_window_event_filter.py` | ALL PASS(18 项,P0-02 新增) | + +离屏 GUI(4 个套件,全部 EXIT=0): + +| 套件 | 摘要 | +|---|---| +| `python tests/smoke_offscreen.py` | ALL PASS: 8/8 | +| `python tests/smoke_mode.py` | ALL PASS | +| `python tests/smoke_copy_session.py` | ALL PASS | +| `python tests/smoke_bash_panel.py` | ALL PASS(116 项) | + +JS(1 个套件,EXIT=0):`node tests/test_math_extract.js` — 39 passed, 0 failed。 + +## 判定 + +P0 阶段回归通过:测试不访问真实配置/数据库(拦截器证明 + 临时环境),`MainWindow` +输入行为无回归(P0-02 四态断言 + 既有 116 项 bash 面板回归)。真实桌面矩阵属 +P1/P2 阶段(PLATFORM_PLAN 验收矩阵),本阶段不声称桌面已验证。 diff --git a/docs/agent-handoff/evidence/P1-01.md b/docs/agent-handoff/evidence/P1-01.md new file mode 100644 index 0000000..a9005bd --- /dev/null +++ b/docs/agent-handoff/evidence/P1-01.md @@ -0,0 +1,90 @@ +# P1-01 双向消息渲染窗口 — 完成证据 + +**状态**: COMPLETE +**完成时间**: 2026-07-21(会话时间) +**环境**: Windows 11 x64, CPython 3.10.21 (.venv, uv), PyQt6 / Qt 6.10.0, Node + +## 设计 + +**数据流(引擎无关)** +- JS → Python:`bridge.onRequestWindowPage(sessionId, direction, boundaryId, generation)` + (QtWebChannel slot 与 WebView2 postMessage 白名单同名,单一实现)。 +- Python → JS:`run_js("rwPageResponse()")` / `rwInitWindow()` / `rwNoteLive(...)` / `rwBegin(...)` / `rwConfig(...)`。 +- JS 端只做"窗口游标 + DOM 搬移":Python 用既有逐消息 bridge 调用渲染 DOM, + JS 状态机 `ui/web/render_window.js` 跟踪 `order`(消息 id 序列)+ `indexById`(链内下标), + 不解析消息内容。 + +**关键参数** +- 窗口 = `render_window_size`(10/40/200,非法静默回落 40;mode `auto`/`manual` 回落 auto)。 +- **页 = 半窗**(`max(1, size//2)`)。若页 = 整窗,"首个可见消息"锚点必然被裁出窗口, + 需求中的锚点恢复(≤2px)永远不可达——诊断实测验证了这一点后才改为半窗。 +- 活动流式消息受保护(`trimHead`/`trimTail` 跳过 `activeStreamId`),计入上限; + `streamFinished` 解除保护。`rwNoteLive` 经 DOM `.streaming` 类自动接管流式保护, + 并携带实时 `chainLen` 维持 hidden 计数新鲜。 + +**滚动语义** +- 批次渲染期间 `window.__rwPageRendering = true`,抑制 `softScroll()` 与 + `finishMessage` 的 `scrollIntoView`。 +- **守卫在调度时刻捕获**(`var rwBatchSuppressed = !!window.__rwPageRendering` 后立即 + rAF+50ms 延迟回调):延迟回调触发时批次已结束、标志已被 Python 复位,届时再读会漏放 + `scrollIntoView(smooth)` → 平滑滚底 → `rwAutoCheck` 误判贴底 → 触发 'newer' 反向换页振荡。 + 这是诊断中 `scrollY 0→4367` 振荡的根因,已修复。 +- 向上换页锚点恢复:`scrollTop = oldScroll + (anchor.newTop - anchor.docTop)`, + 绝对顶部(`oldScroll <= 1`)例外:停在 0 露出新页;auto 模式沿顶部 60ms 链式补页。 +- 向下换页两模式均自动恢复(`isNearBottom() && canRequest('newer')`)。 +- 过期响应(会话/代次不匹配、pending 已被新请求替换、重复投递)整批丢弃,已渲染 DOM 回滚。 + +**生成代次(generation)** +- Python `MainWindow._rw_generation` 为唯一权威源:每次 `load_messages_to_web` / 新建会话 +1。 +- JS `clear()` 本地防御性 +1,Python 下次推送重新同步。 + +## 文件变更 + +| 文件 | 变更 | +|---|---| +| `core/config_paths.py` | 新增 `render_window_settings()`(16 类配置归一化)、`DEFAULT_RENDER_WINDOW_SIZE`、`ALLOWED_RENDER_WINDOW_SIZES` | +| `ui/web/render_window.js` | 新增:DOM 无关状态机(id+chainIndex 模型,双遍 recompute) | +| `ui/web/app.js` | `rwState`/`rwInitWindow`/`rwPageResponse`/`rwApplyOlder`/`rwApplyNewer`/`rwNoteLive`/`rwRequestPage`/`rwAutoCheck`/`rwCaptureAnchor`/`rwRemoveMessageDom`/`rwEnsureLoadButtons`/`rwUpdateLoadButtons`;`clearChat` 联动状态机清空;`finishMessage` 步骤 C 调 `streamFinished`、步骤 F 守卫捕获式;`createMessage`/`createLongMessage`/`createUserMessageWithAttachments` 的 `softScroll` 守卫;scroll 监听挂 `rwAutoCheck` | +| `ui/web/index.html` | 引入 `render_window.js`(先于 app.js);WebView2 shim 增加 `onRequestWindowPage` | +| `ui/web/style.css` | `.load-window-btn` 样式(含 `[hidden]` 规则) | +| `ui/views/chat_bridge.py` | 信号 `window_page_requested`、slot `onRequestWindowPage`、推送方法 `rw_config`/`rw_begin`/`rw_init_window`/`rw_note_live`/`rw_page_response` | +| `ui/views/wv2_view.py` | `_BRIDGE_METHODS` 白名单加 `onRequestWindowPage` | +| `ui/views/main_window.py` | `init_browser` 窗口状态初始化 + 信号连接;`_on_js_ready_checked` 一次性 `rwConfig` 推送;`load_messages_to_web` 窗口化重写(代次+1、`rwBegin`、最新 size 条窗口渲染、`rwInitWindow`、流式恢复保留);新增 `_rw_visible_chain`/`_rw_note_live`/`_render_history_one`/`_on_window_page_request`(边界缺失安全降级空页);`on_new_chat_clicked` 代次+1;发送/重答/完成 5 处 `_rw_note_live` 挂点 | +| `tests/test_render_window.js` | 新增:状态机 Node 测试 | +| `tests/diag_render_scale.py` | 新增:400 条链 offscreen 规模诊断(真实 viewport:resize+show+等待 innerHeight>0+显式重载) | +| `tests/smoke_timeline.py`、`tests/smoke_midswitch.py` | 转换到 `tests/_test_env.isolate()`(临时 DB+临时配置,P0-01 铁律) | +| `tests/_probe_rw.py` | 调试探针(保留,供后续排障) | + +## 验证(全部 EXIT=0,全部带显式超时执行) + +| 套件 | 结果 | +|---|---| +| `node tests/test_render_window.js` | **424/424 PASS**(配置归一化 16 例、auto/manual×10/40/200 初始窗口、双向连续换页 39 页/向、短链、过期响应 4 类、活动流保护、clear 语义、锚点几何、原子性、noteLive) | +| `python tests/diag_render_scale.py 400`(offscreen) | **6/6 PASS**:初始窗口=最新 40 条(链长 400);中部锚点保持(误差 ≤2px、无 newer 振荡);自顶部向上分页至头部(绝对顶部例外);头部状态+全链 400 条可达无重复;自顶部向下回翻 2 页;auto 模式顶部自动补页。**锚点误差 0.00px**;18 页 @ 169/183/204 ms(min/avg/max);DOM 节点 1057–1080;页面高度 7127–7213 px | +| `python tests/smoke_offscreen.py` | 8/8 ALL PASS | +| `python tests/smoke_timeline.py` | 11/11 ALL PASS(流式/历史路径,含 streaming 类收尾) | +| `python tests/smoke_midswitch.py` | 7/7 ALL PASS(切走切回时间线完整) | +| `python tests/test_file_attach.py` | 9 tests OK | +| `node tests/test_math_extract.js` | 39/39 PASS | +| 回归:`smoke_bash_panel` / `test_config_isolation` / `test_main_window_event_filter` / `smoke_mode` / `test_error_persist` | 全部 ALL PASS | +| `python tests/run_tests.py`(agent core 规范入口) | **41/41 PASS** | + +## 诊断过程记录(问题 → 根因 → 修复) + +1. **`rw_init_window` 链下标偏移**:初始窗口传入局部下标 0..39 而非链下标 → `hiddenOlder` 恒 0。 + 修复:`offset = total - len(window_items)`,传 `offset + i`。 +2. **offscreen 零视口**:未 `resize`+`show` 前 `innerHeight=0`,锚点几何全废。 + 修复(诊断侧):`window.resize(1400,950)` + `show()` + 等待 `innerHeight>0` + 显式 `load_messages_to_web` 重载。 +3. **`runJavaScript` 不能返回 DOM 元素**:回调转换失败 → 用 `cond ? 1 : 0` / 原语返回值。 +4. **分支兄弟偷叶**:链尾补兄弟消息使 `add_message` 自动改叶 → 链被截断。 + 修复(夹具):兄弟消息在循环内 `i==298` 处插入。 +5. **页 = 整窗导致锚点必被裁**(设计缺陷):半窗页修复(见"关键参数")。 +6. **`finishMessage` 守卫延迟求值 → 滚底 → 'newer' 振荡**(见"滚动语义"第 2 条)。 +7. **中部换页落点贴底**:15% 视口位置向上换页后锚点落 65%(不贴底);底部半窗换页本身会 + 落向底部属半窗几何固有——真实入口(顶部"加载更早消息"按钮 / auto 顶部观察器)不触发该位置, + 且落底后自动 'newer' 恢复符合"向下自动恢复"需求。 + +## 已知观察项(不在本任务范围) + +- `diag_render_scale` 的每页耗时(~180ms)只作真机基准参考,非硬阈值(符合任务要求)。 +- 帧耗时真机人工基准报告留待人工验收环节。 diff --git a/docs/agent-handoff/evidence/P1-02.md b/docs/agent-handoff/evidence/P1-02.md new file mode 100644 index 0000000..b1762c7 --- /dev/null +++ b/docs/agent-handoff/evidence/P1-02.md @@ -0,0 +1,74 @@ +# P1-02 证据:Windows/Linux shell 与进程树终止 + +日期:2026-07-09(无人值守轮次) +状态:**完成(Windows 侧自动化全绿;Linux 侧逻辑已实现并单测覆盖参数/提示词,进程组用例在 Linux 上运行时生效)** + +## 目标(摘自 REPAIR_BACKLOG.md) + +- Windows 明确通过 `cmd.exe` 执行;Linux 明确通过 `/bin/bash -lc` 执行,不依赖 `shell=True` 的平台默认值。 +- 超时与主动中止都终止完整子进程树(Windows `taskkill /F /T`;Linux 独立 POSIX 进程组,SIGTERM→宽限→SIGKILL 整组)。 +- 只保留一份通用 `SYSTEM_PROMPT.md`,运行时插入**短**平台 shell/path 段;两平台互不串段。 +- 保留输出流、超时、截断、工具结果结构;不加命令审批/沙箱/路径限制。 + +## 改动文件 + +| 文件 | 改动 | +|---|---| +| `core/platform_shell.py` | **新增**窄平台适配:`shell_command()`、`popen_flags()`、`kill_process_tree()`、`shell_prompt_section()`、`apply_platform_section()`、占位符 `{{SHELL_PLATFORM_SECTION}}` | +| `core/agent/tools.py` | `tool_bash` 的 Popen 改 `shell_command(command) + popen_flags()`;`_kill_tree` 委托 `kill_process_tree`;移除 `ctx["shell"]` 隐式开关 | +| `core/llm_engine.py` | `load_system_prompt()` 读文件后过 `apply_platform_section()`(每次请求仍重读,既有行为不变) | +| `SYSTEM_PROMPT.md` | 通用正文化:第 1 节去 Windows 路径/conda 环境名;原 1.1「shell 真相」cmd 表整体移入运行时 Windows 段;工具表与 3.2 去掉 `cmd.exe`/`dir`/`findstr` 字样;占位符落在原 1.1 位置 | +| `tests/test_cross_platform_shell.py` | **新增** 20 项断言(A 平台参数 / B 提示词 / C 进程树 / D 安全边界) | + +## 关键设计决策 + +1. **Windows 用字符串命令行,不用 argv 列表。** + `["cmd.exe","/d","/c",cmd]` 列表形态会被 CPython `list2cmdline` 把内部引号转义成 `\"`,cmd 不认, + 带引号路径直接 `'...\python.exe"' is not recognized`(实测复现)。最终契约: + `cmd.exe /d /s /c ""` 作为**字符串**交给 CreateProcessW,cmd 按 /s 规则解析 /c 参数 + (外层引号剥离、内部引号保留)。实测矩阵:引号 Python 路径 `rc=0`;`echo a && echo b` 正确; + `%USERPROFILE%` 展开;`;`/单引号行为与旧文档一致。 + 注:旧 `shell=True` 之所以能跑,是因为 CPython 对带引号程序名直接 CreateProcess(不经 cmd); + 新契约统一显式过 cmd,行为更可预测且与提示词一致。 +2. **POSIX 安全不变量**:`kill_process_tree` 仅当 `os.getpgid(pid) == pid`(确认 `start_new_session` + 生效、子进程是组首)才 `killpg`,否则退化单进程 `kill`,绝不误杀调用方所在组。 + 流程:SIGTERM 整组 → 轮询至 `grace_s=3.0s` → SIGKILL 整组。 +3. **提示词单一来源**:`SYSTEM_PROMPT.md` 唯一;`apply_platform_section` 只做占位符替换, + 无占位符(兜底提示词)原样返回。`load_system_prompt()` 每请求重读 → 平台段永远对应当前平台。 + +## 验证结果(本机 Windows 11 x64,CPython 3.10.21,全部显式超时) + +| 命令 | 结果 | +|---|---| +| `python tests/test_cross_platform_shell.py` | **20/20 PASS**(EXIT=0) | +| `python tests/test_bash_stream.py` | **30/30 PASS**(EXIT=0) | +| `python tests/test_tool_params.py` | **35/35 PASS**(EXIT=0) | +| `python tests/run_tests.py`(agent core,含 test_agent_core) | **41/41**(EXIT=0) | +| `python tests/smoke_mode.py`(完整 agent 回合,走 tool_bash) | ALL PASS(EXIT=0) | +| P0-03 一致性检查(AGENTS.md 指针 / ARCHITECTURE 历史资料标记 / 无第三方任务书正文) | 3/3 PASS | + +### 测试点明细(test_cross_platform_shell.py) + +- **A 平台参数**:Windows 命令 == `cmd.exe /d /s /c "echo hi"`;Linux argv == `["/bin/bash","-lc","echo hi"]`; + Linux `popen_flags() == {"start_new_session": True}`,Windows 为空。 +- **B 提示词**:通用正文含占位符且无平台泄漏(无 `cmd.exe`/`/bin/bash`);Windows 段含 `cmd.exe` 无 + `/bin/bash`,Linux 段反之;替换后通用正文逐字节相同(B6);`load_system_prompt()` == 文件+当前平台段。 +- **C 进程树(真实进程,父挂 30s + 孙每 0.2s 写心跳文件)**: + - C1 超时 3s → 错误结果含「超时」,耗时 <15s,**父与孙都不存在**(心跳静默 >0.6s 且无完成标记); + - C2 主动中止 1.5s → 错误结果含「中止」,**父与孙都不存在**; + - C3 Linux 独立进程组(Windows 上 SKIP;`pgid==pid` 断言 + killpg 后子进程消失,Linux 运行即生效)。 +- **D 安全边界**:已退出进程、`None` 输入均不抛异常。 + +### 调试过程记录(铁律:所有调试命令显式超时) + +1. 首跑 C1 失败:`'...\python.exe"' is not recognized` → 定位为 `list2cmdline` 引号转义; + 读 CPython 3.10 `subprocess.py` 确认 `shell=True` 实为直接 CreateProcessW(带引号程序名不经 cmd)。 +2. 尝试 `["cmd.exe","/d","/s","/c", '"'+cmd+'"']` 列表 → 仍失败(同样被转义)。 +3. 改**字符串**命令行 + 实测矩阵(引号路径/&&/管道/%VAR%)→ 全过,定稿。 +4. 首跑进程树用例 `hb_last=None`:父脚本模板漏传 `child.py` 脚本路径(把心跳路径当脚本)→ + 手动 `subprocess.run` 复现(超时 6s/3s 探针)→ 修模板,20/20 通过。 + +## Linux 侧待办(不阻塞本任务) + +- 在 Linux 环境跑一次 `python tests/test_cross_platform_shell.py`(C3 生效)+ `test_bash_stream.py` + 即可闭环;代码路径与 Windows 共用同一套 `kill_process_tree`/`shell_command` 分派。 diff --git a/docs/agent-handoff/evidence/P1-03.md b/docs/agent-handoff/evidence/P1-03.md new file mode 100644 index 0000000..4c799d2 --- /dev/null +++ b/docs/agent-handoff/evidence/P1-03.md @@ -0,0 +1,60 @@ +# P1-03 证据:Windows/Linux 渲染器启动链 + +日期:2026-07-17 · 执行环境:Windows 11 10.0.26200 x64 / CPython 3.10.21 (.venv, uv) / +PyQt6 6.10 + PyQt6-WebEngine 6.10 · 每条命令均带显式超时 + +## 改动文件 + +| 文件 | 性质 | 说明 | +|---|---|---| +| `core/renderer_backend.py` | 新增(仅 stdlib,可在导入 PyQt6 前使用) | `resolve_backend(pref)`(auto/webview2/qtwebengine 归一化;非法值/平台不支持 → 可见警告 + 平台默认,绝不阻断启动);`platform_default_backend()`;`webview2_module()`(win32 门控的按需导入);`webengine_profile_name()/webengine_profile_dir()`(每实例独立 profile 目录:源码运行 `data/webengine/profile__`,测试经 `HAOCODE_WEBENGINE_PROFILE_DIR` 重定向临时目录;只创建、从不清理);`is_root_or_container()`(geteuid==0 / .dockerenv / .containerenv / .lxc / /proc/1/cgroup);`sanitize_chromium_flags()`(`--no-sandbox` 契约) | +| `main.py` | 修改 | sys.path 就绪后、导入 `MainWindow` 前调用 `sanitize_chromium_flags` 并打印最终 `QTWEBENGINE_CHROMIUM_FLAGS` | +| `ui/views/main_window.py` | 修改(手术式) | ① `core.webview2` 导入改为 `if sys.platform == "win32"` 门控(Linux 永不导入,不触达 pythonnet/Win32/WebView2 DLL/taskkill);② 浏览器创建处先 `resolve_backend(config["webview_backend"])` 并打印警告(原「读取配置」从 win32 分支内提前到分支外,Linux 非法值也有可见警告);③ QtWebEngine 路径创建本实例 `QWebEngineProfile`(`setPersistentStoragePath`/`setCachePath` 指向独立目录),`CustomWebPage(profile, browser)` | +| `ui/views/custom_web_page.py` | 修改 | 构造函数兼容 `CustomWebPage(profile, parent)` 与旧式 `CustomWebPage(parent)`(按 `isinstance(QWebEngineProfile)` 分派,防旧调用把 view 误当 profile) | +| `requirements.txt` | 修改 | `pythonnet==3.1.0; sys_platform == "win32"`、`clr_loader==0.3.1; sys_platform == "win32"`;追加 Linux 源码运行最小步骤(apt 系统库清单、root/容器 `--no-sandbox` 说明、离屏测试命令) | +| `tests/_test_env.py` | 修改 | `isolate()` 增加 `HAOCODE_WEBENGINE_PROFILE_DIR` → 临时目录(setdefault,可覆盖) | +| `tests/smoke_offscreen.py` | 修改 | 同上(该文件不走 isolate) | +| `tests/test_renderer_matrix.py` | 新增 | R1/R3/R4/R5(见下)19 项 | +| `docs/agent-handoff/PLATFORM_PLAN.md` | 修改 | 「Linux 与 QtWebEngine 回落」追加 Linux 源码运行最小步骤 + 实现锚点 | + +`vendor/webview2/` 与根 `WebView2Loader.dll` 未动;未新增产品功能/安装器/PyInstaller 改动。 + +## 关键设计决策与踩坑记录 + +1. **`QTimer.singleShot` 单位是毫秒**:R4 首版写 `QTimer.singleShot(25, finish)` 期望 25s 兜底,实际 25ms 就触发 → 两个 worker 都报 `loaded=False`(rc=1)。诊断时手动复现看到 `BACKSTOP t=0.4`(30ms 定时器在事件循环启动 ~0.35s 后即触发),定位后改为 `30000`。 +2. **QtWebEngineWidgets 必须先于 QApplication 导入**(否则 ImportError:`QtWebEngineWidgets must be imported ... before a QCoreApplication instance is created`);**QWebEngineProfile 必须先于使用它的 page/view 创建**,`setPersistentStoragePath/setCachePath` 必须在 profile 使用前调用。探针脚本先后踩中这两个顺序问题(前者 ImportError;后者在 QApplication 前建 profile 直接进程被杀 exit 127、stdout 缓冲丢失)。 +3. **PyQt6-WebEngine 6.10 无 `QWebEnginePage.errorOccurred`**(Qt 6.5+ API 未在此绑定暴露)→ 诊断改用 `loadFinished(ok)` + 手动 processEvents 循环对比三种 profile 配置(默认 profile / 命名 profile 无自定义路径 / 命名 profile + 自定义路径),三者 file:// 加载全部成功,证明「命名 profile + setPersistentStoragePath」组合可用。 +4. **旧式位置调用兼容**:`CustomWebPage(browser)` 的 view 会被新签名当作 `profile` 传入 → `TypeError: argument 1 has unexpected type 'QWebEngineView'`(smoke 前用探针暴露)。按类型分派(`isinstance(QWebEngineProfile)`)同时支持新旧调用。 +5. **R3 Linux 模拟导入**:子进程 `sys.platform='linux'` 后 `import ui.views.main_window` 成功——因 `global_hotkey.py` 的 `from ctypes import wintypes` 只是类型定义(3.10 下跨平台可导入),`ctypes.windll` 访问全在 `if _is_windows` 内;`core.webview2` 模块级仅 stdlib 导入,但被 main_window 的 win32 门控挡住,`clr`/`clr_loader` 未进 `sys.modules`。 + +## 验证结果(全部显式超时) + +| 检查 | 结果 | 命令(超时) | +|---|---|---| +| `tests/test_renderer_matrix.py` | **19/19 PASS** | `PYTHONIOENCODING=utf-8 python tests/test_renderer_matrix.py`(timeout 300) | +| `tests/test_wv2_guard.py`(P0 遗留守卫) | **10/10 PASS** | `timeout 120` | +| `tests/test_debug_window.py` | **22 PASS / 0 FAIL** | `timeout 180` | +| `tests/smoke_offscreen.py` | **8/8 ALL PASS**,日志含 `[Renderer] QtWebEngine 独立 profile: /profile__*` | `QT_QPA_PLATFORM=offscreen HAOCODE_RENDER=software QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu timeout 240` | +| `node tests/test_math_extract.js` | **39 passed, 0 failed** | `timeout 60` | +| `main.py` 真实启动链(offscreen + 临时配置,无 providers) | 启动成功、无 traceback:`[渲染] 最终 QTWEBENGINE_CHROMIUM_FLAGS = '--disable-gpu'`、`[Renderer] QtWebEngine 独立 profile: D:\...\data\webengine\profile_56256_*`、`[System] 浏览器内核: QtWebEngine`;`data/config.json` mtime 前后一致(未触碰) | `HAOCODE_CONFIG_FILE= QT_QPA_PLATFORM=offscreen ... timeout 45 python main.py`(124=到点 kill,预期) | +| `main.py` 真实启动链:普通桌面 + `--no-sandbox` | 标志被剥离 + 告警:`[Renderer] ⚠️ ... 已剥离该标志并保留 Chromium 沙箱`,最终 flags 无 `--no-sandbox` | 同上(timeout 30) | +| 回归:`tests/run_tests.py` | **41 passed** | `timeout 300` | +| 回归:`test_bash_stream` / `test_tool_params` / `test_main_window_event_filter` / `test_config_isolation` | 全部 **ALL PASS / EXIT=0** | 各 `timeout 300` | +| 回归:`node tests/test_render_window.js` | **424 passed, 0 failed** | `timeout 120` | +| 回归:`tests/smoke_mode.py` | **ALL PASS** | `timeout 240` | +| P0-03 一致性抽查(文件存在、requirements marker、wv2 门控、单提示词占位符) | **10/10** | 纯 Python 读文件 | + +## 测试点明细(test_renderer_matrix.py) + +- **R1.1–R1.11** resolve_backend 矩阵(mock 平台):Win × auto/webview2/qtwebengine/非法字符串/非字符串/大小写容差;Linux × auto/webview2(警告+回落)/qtwebengine/非法;`webview2_module()` 非 Windows 返回 None。 +- **R3.1** 子进程 `sys.platform='linux'` 导入 `ui.views.main_window`:`_wv2mod is None`,`core.webview2`/`clr`/`clr_loader` 均不在 `sys.modules`。 +- **R4.1–R4.3** 两个 offscreen 子进程**并行**各建 `QWebEngineProfile`(`HAOCODE_WEBENGINE_PROFILE_DIR` 同基目录、各自 `parallel_` 子目录)+ `CustomWebPage(profile, view)` 载入本地 HTML:均 `WORKER_OK loaded=True`;两 profile 目录不同;进程退出后目录仍在(无互相清理)。 +- **R5.1–R5.4** `sanitize_chromium_flags` 契约(mock root/容器探测 + 捕获 stdout):普通桌面无标志原样;普通桌面剥离 `--no-sandbox`+告警;root/容器+显式保留+「高可见警告」;root/容器未设→原样+提示。 + +## 遗留 / 平台验证待办 + +- **Linux 真机验证(待 Ubuntu 主机)**:`pip install -r requirements.txt`(确认 pythonnet/clr_loader 被 marker 跳过)→ `python3.10 main.py`(X11/Wayland)+ 离屏套件 + `tests/test_renderer_matrix.py` 的 R3/R4(真实 Linux 平台而非模拟)。 +- **root/容器真机**:`--no-sandbox` 接受路径的运行时验证(本环境为普通 Windows 桌面,只能验证剥离路径;接受路径为纯逻辑 + mock 验证)。 +- **Windows 真机 WebView2 首选路径:已验证(2026-09-17 真桌面)**。本机 D: 卷 .NET 把工程卷误判为“网络位置”导致 `clr.AddReference`(LoadFrom)报 0x80131515;在 `core/webview2.py` 加 **byte[] 回落**(快路径仍 `AddReference`,失败才 `Assembly.Load(byte[])`,正常机器行为不变;外层异常改 `BaseException` 以兼容 pythonnet 非 Exception 异常)。修复后真桌面启动日志:`[WV2] AddReference 路径加载失败 → 回落 Assembly.Load(byte[])` → `Runtime ready: 153.0.4234.32` → `controller ready` → `NavigationCompleted src=file:///.../ui/web/index.html`,stderr 无 error/disposed/0x8007。`test_wv2_guard` 10/10 无回归。 +- **Windows 真机 QtWebEngine 回落:已验证(2026-09-17 真桌面,`HAOCODE_FORCE_QTWEBENGINE=1`)**。日志:`[WV2] ... 跳过 WebView2,回落 QtWebEngine`(强制标志生效,**未执行 taskkill**)→ `[Renderer] QtWebEngine 独立 profile: data/webengine/profile__`(per-instance 隔离 profile 真机生效)→ `[System] 浏览器内核: QtWebEngine`。聊天区截图像素统计:83% 亮背景 + 159 种颜色桶(含文本深色像素)= 真实 DOM 渲染,非空白。两模式主窗口截图已存 evidence:`win_real_wv2_mainwindow.png`、`win_real_qtwebengine_mainwindow.png`(半尺寸 PNG)。 + - 交叉验证(T0 守卫真机):WV2 主程序运行期间另跑 diag_panel_scrollbar.py,diag 实例因 instance-lock 被占自动回落 QtWebEngine,未误杀主程序 WebView2 进程。 diff --git a/docs/agent-handoff/evidence/P1-04.md b/docs/agent-handoff/evidence/P1-04.md new file mode 100644 index 0000000..5b6e0a5 --- /dev/null +++ b/docs/agent-handoff/evidence/P1-04.md @@ -0,0 +1,59 @@ +# P1-04 证据:Linux 截图热键与截图实现 + +状态:Windows 侧自动化验证全绿;Linux X11/Wayland 真实宿主验证按 VERIFICATION.md 手动待办(本环境为 Windows 桌面)。 + +## 交付物(文件级) + +| 文件 | 变更 | +|---|---| +| `ui/views/system_tools/desktop_session.py` | **新增**(纯 stdlib):`session_kind()` → `win32/x11/wayland/unknown`(WAYLAND_DISPLAY / QT_QPA_PLATFORM=wayland / DISPLAY 判定,offscreen→unknown);`hotkey_plan(kind)` / `capture_plan(kind)` 能力路由 + 明确能力说明文案 | +| `ui/views/system_tools/x11_hotkey.py` | **新增**(窄适配器,零新依赖,ctypes→libX11):`X11HotkeyThread`(与 Windows `GlobalHotkeyThread` 同一公开面 `triggered/start/stop`);XOpenDisplay→XKeysymToKeycode('s')→XSelectInput(KeyPressMask)→XGrabKey(root, keycode, Mod1Mask, owner_events)→select(X 连接 fd, 0.2s)+XPending/XNextEvent 循环;命中 Alt+S 发射 `triggered`;XEvent 结构体按 xproto.h XKeyEvent 布局(64 位 keycode@76);`_open_x11()` 可注入(测试替身);`wait_ready()` | +| `ui/views/system_tools/portal_capture.py` | **新增**(窄适配器,系统 gdbus CLI,零 pip 依赖):`detect_portal()`(Linux + XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS + gdbus 探测);`portal_screenshot_sync()`:gdbus 调 `org.freedesktop.portal.Screenshot.Screenshot(handle, "/", {})` 取 request 对象路径 → `gdbus monitor --session --object-path ` 监听 `FilePicked`(成功,file:// URI 剥前缀+unquote 解码)/`Request.Finished`(无 FilePicked → 用户取消/拒绝);显式预算:request 10s + 等待 120s,超预算 → timeout;`PortalScreenshotWorker(QThread)` 信号 `done(ok, path)` 回主线程 | +| `ui/views/main_window.py` | 热键注册块:`desktop_session.hotkey_plan(session_kind())` 平台路由(win32→GlobalHotkeyThread 行为字节级保持;x11→X11HotkeyThread;wayland/offscreen→None + 明确"全局热键不可用"日志),非 Windows 保留应用内 `QShortcut(Alt+S)` 兜底;`_start_screenshot()` 路由:win32/x11→现有覆盖层、wayland→`_start_portal_screenshot()`(worker 完成→`_on_image_pasted([path])` 进现有图片附件流程)、unknown→明确"截图不可用,聊天与其他功能不受影响"日志 | +| `ui/views/system_tools/screen_capture.py` | `start()` 增加空画面守卫:无主屏幕 / grabWindow 返回空图(X11 个别 compositor 限制)→ 明确日志 + 不显示覆盖层(Wayland 已在路由层改走 portal) | +| `tests/test_global_hotkey_platforms.py` | **新增** 23 检查:H1 session_kind 矩阵(win32/x11/wayland/offscreen/无显示 + XDG_SESSION_TYPE 三分支 + 矛盾时 WAYLAND_DISPLAY 优先);H2 hotkey_plan 四路由;H3 X11 成功路径(fake libX11:XGrabKey 参数 keycode=39/Mod1Mask=1/root=123/owner_events=1、命中发射 triggered、stop 后 XUngrabKey+XCloseDisplay);H4 失败三分支(键被占用 XGrabKey=0 / 无显示 XOpenDisplay=None / 不支持组合不打开显示)均安静退出+明确日志;H5 Windows 路径保持(本机实测 RegisterHotKey 线程运行 + stop 干净释放) | +| `tests/test_screen_capture_platforms.py` | **新增** 17 检查:C1 capture_plan 四路由;C2 detect_portal 四分支(非 Linux/无 D-Bus/无 gdbus/齐备);C3 成功路径(fake subprocess 校验 gdbus 命令行 `--dest/--object-path/--method=...Screenshot/parent=/`、monitor 监听 request 对象、file:// 含空格文件名 URI 解码 → 真实文件);C4 授权被拒不伪造成功;C5 portal NotSupported 原因透出;C6 超预算 timeout(迟到信号不算成功);C7 worker 信号回主线程;C8 覆盖层 offscreen 构造+空画面守卫不崩 | + +## 关键设计决定 + +1. **Windows 字节级保持**:`GlobalHotkeyThread`(Win32 RegisterHotKey 线程)与覆盖层路径零改动,仅调用处改为经 `hotkey_plan("win32")` 取回同一工厂;`capture_plan("win32")` 仍返回 overlay。 +2. **X11 全局热键 = 原生 XGrabKey,无新 pip 依赖**:libX11 是 X11 桌面必然存在的系统库,ctypes 直调;只映射现有 Alt+S(`_VK_TO_KEYSYM` 窄表,扩展需显式加表项);`owner_events=1`;stop 走 XUngrabKey+XCloseDisplay(关连接本身即释放 grab,双保险)。 +3. **Wayland = xdg-desktop-portal,不绕过 compositor**:compositor 安全模型禁止应用直接抓屏,故 Wayland 截图走 `org.freedesktop.portal.Screenshot`(交互式授权窗口,用户批准/取消);`gdbus`(GLib 系统组件)CLI 完成 D-Bus 调用,不引入 dbus-python;成功返回文件路径 → 直接进现有 `_on_image_pasted([path])` 附件流程(不经过 Qt 覆盖层,因为 Wayland 下无法把画面抓进 Qt widget)。 +4. **Wayland 全局热键:明确"不可用"而非硬做**:通用全局快捷键在 Wayland 依赖 compositor 桌面协议(ext-global-shortcut-unstable-v1 等,无统一 portal API),免依赖实现需完整 Wayland 客户端协议栈,超出窄适配器范围 → `hotkey_plan("wayland")` 返回 None + 日志明确说明(保留应用内 Alt+S + 截图按钮),符合硬约束"不支持时界面/日志必须明确说明能力不可用,主程序仍可聊天"。 +5. **能力不可用的一等公民**:`session_kind()=unknown`(offscreen/无显示)时,热键与截图都有显式日志("全局热键不可用"/"截图功能不可用;聊天与其他功能不受影响"),不再静默。 +6. **异常绝不逃逸 QThread.run()**:X11 事件循环整体 try/except——PyQt6 中 QThread.run() 未处理异常会 **abort 整个进程**(实测:CArgObject TypeError 逃逸 → 进程静默 127 退出、无 traceback、stdout 缓冲丢失)。这是本任务最贵的一个坑,已把"QThread.run() 必须全捕获"写进教训。 +7. **超时语义**:portal 等待中,FilePicked 之前/之后的信号都看预算——`Finished` 在预算内 → denied(用户取消);任何结果晚于预算 → timeout(不伪造、不无限挂起)。 + +## 验证(全部显式超时) + +| 套件 | 结果 | +|---|---| +| `python tests/test_global_hotkey_platforms.py` | **23/23 PASS** EXIT=0(timeout 120) | +| `python tests/test_screen_capture_platforms.py` | **17/17 PASS** EXIT=0(timeout 180) | +| `python tests/test_file_attach.py`(回归) | 9 tests OK(timeout 90) | +| `python tests/smoke_offscreen.py`(回归,offscreen+software+--disable-gpu) | **ALL PASS 8/8**(timeout 240),日志含 `[GlobalHotkey] Windows:系统级全局热键 Alt+S(RegisterHotKey)` | +| `python tests/run_tests.py` | 41/41 | +| `tests/test_main_window_event_filter.py` / `test_config_isolation.py` / `test_wv2_guard.py` / `test_renderer_matrix.py` / `test_cross_platform_shell.py` | ALL PASS / ALL PASS / ALL PASS / 19/19 / 20/20 | +| `main.py` 真实启动链(offscreen + 临时 config,timeout 45→124 kill 预期) | 无 traceback;`[GlobalHotkey] Windows:系统级全局热键 Alt+S` 打印;到达"JS 引擎已就绪";`data/config.json` mtime 前后一致(未触碰);error 行仅为 offscreen 已知 GPU 回落噪音 | + +## 测试点细节(对应目标测试要求) + +- **平台路由矩阵**:H1(session_kind 9 分支,含 XDG_SESSION_TYPE 与矛盾优先级)+ H2(hotkey_plan 4 路由)+ C1(capture_plan 4 路由)。 +- **X11 替身**:FakeX11 鸭子类型 libX11(socketpair 提供可 select 的 fd;XEvent 用 `ctypes.memmove` 填充;`CArgObject._obj` 从 `ctypes.byref(ev)` 还原原 struct——生产走真实 CDLL 不受影响)。 +- **注册失败→明确消息**:H4.1 键占用("Alt+S 可能已被其他程序占用")、H4.2 无显示("XOpenDisplay 失败")、H4.3 不支持组合("暂不支持的快捷键组合")——均无信号、`_registered=False`、线程安静退出。 +- **portal 替身 subprocess**:FakeRun/FakePopen 记录 argv 并回放 gdbus 输出(成功/拒绝/NotSupported/超时四态),验证真实 CLI 命令行与 JSON 事件解析,不依赖真实 portal。 +- **X11/Wayland 真实行为**:本环境为 Windows 桌面,X11/Wayland 真机按 VERIFICATION.md 手动(见下)。 + +## 待办(需真实 Linux 宿主,按 VERIFICATION.md 手动) + +1. Ubuntu 22.04/24.04 x64 X11 会话:`python3.10 main.py` → 日志 `[GlobalHotkey] X11:原生全局热键 Alt+S(XGrabKey)`;窗口失焦按 Alt+S 弹出覆盖层、框选截图成功;XGrabKey 被占用时日志明确。 +2. Wayland 会话(GNOME/KDE):启动日志 `[GlobalHotkey] Wayland:…未启用 → 仅提供应用内 Alt+S…`;点截图按钮(或应用内 Alt+S)→ xdg-desktop-portal 授权窗口出现;批准 → 文件进入附件;取消 → 日志"portal 截图未完成…已取消";无 portal 时日志"portal 不可用"。 +3. 其他发行版/DE 标记"未验证"(硬约束:不做发行版泛化)。 + +## 教训(持久) + +- **PyQt6:QThread.run() 内任何未处理异常 = abort 整个进程**(退出码 127、无 traceback、stdout 缓冲丢失,极难诊断)。QThread.run() 必须顶层 try/except 全捕获 + 日志。 +- `ctypes.byref(x)` 返回 `CArgObject`:真实 CDLL 调用正常,但传给**普通 Python 可调用对象**(测试替身)时对方 `byref()` 会 TypeError——替身端用 `getattr(arg, "_obj", arg)` 还原。 +- Windows 上 `os.pipe()` 的 fd 不能可靠用于 `select()`(无 WSAStartup 时 WinError 10093;初始化后是普通 pipe 又 10038)——跨平台可 select 的假 fd 用 `socket.socketpair()`;且 `a.send()` 的数据在 **b** 的接收缓冲(方向别写反)。 +- `file://` URI 解析用"剥前缀 + unquote"而非 `urlparse().path`:`file://D%3A%5Cx`(无第三斜杠)会被 urlparse 当成 netloc → path 为空。 +- gdbus 的 Screenshot 结果信号(FilePicked/Finished)都发在 **request 对象**上(方法返回的句柄路径),不是单独 handle 对象。 diff --git a/docs/agent-handoff/evidence/P2-01.md b/docs/agent-handoff/evidence/P2-01.md new file mode 100644 index 0000000..d213c8d --- /dev/null +++ b/docs/agent-handoff/evidence/P2-01.md @@ -0,0 +1,50 @@ +# P2-01 证据:Bash 任务按启动时间倒序 + +完成日期:2026-07-21(无人值守轮次) +结论:**完成**。两栏均按启动顺序降序显示(最新启动在第一项);运行中→已完成保持原启动位置;重排复用同一批 `BashLayer` 实例,全部 UI 状态保持。目标测试与回归全绿。 + +## 交付物 + +| 文件 | 改动 | +|---|---| +| `ui/views/bash_panel.py` | 模块 docstring 口径更新;`_refresh()` 两栏显示顺序改为启动序号降序;`layer_ids()` 的 running/done 返回真实显示顺序("all" 仍为原始启动正序,调试口径不变) | +| `tests/smoke_bash_panel.py` | 新增第 11 节(P11.1–P11.20 共 23 项断言);收尾改 `os._exit`(offscreen 铁律,修复解释器退出挂起) | + +未改动:`main_window.py`(事件转发链已是实时、按启动到达顺序带 `call_id` 转发,面板从到达顺序推导启动序号,无需新增元数据)、数据库 schema、`set_layers`(本就复用实例)。 + +## 关键设计决策 + +1. **排序键 = `self._order` 中的位置(稳定启动序号),不引入时间戳、不改 schema。** + 面板的 `_order` 在三个入口按启动先后追加: + - `set_session()` DB 重建:消息链顺序 + 时间线内顺序(= backlog 要求的「稳定启动序号」构造方式); + - `set_session()` 活动流:时间线内顺序(当前轮次天然晚于历史); + - 实时 `on_started()`:事件到达顺序。 + `_order` 即启动序号本身,`_refresh()` 只需对其取逆即可,无需任何新字段。 +2. **只在显示层取逆,不改内部数据。** `run_ids`/`done_ids` 仍按 `_order` 正序过滤;`reversed()` 只作用于传给 `set_layers` 的 widget 列表。已完成栏限量窗口 `done_ids[-LAYER_LIMIT:]` 的成员不变(仍是「最近启动的 30 个」),仅窗口内显示顺序反转,提示语文义保持。 +3. **完成时间从不参与排序。** `on_finished` 对已知层只改状态集合(`_running`→`_done`),绝不移动 `_order` 位置;仅当层完全未知(先收到 finished 事件)才以首次感知时间追加——这是唯一的信息可用时刻。因此「先启动后完成」的任务永远压在「后启动先完成」的任务之下,与结束先后无关(P11.10/P11.13 断言)。 +4. **状态保持靠「同一对象」。** `set_layers` 逻辑未动:`takeAt → setParent(None) → addWidget → show`,操作的是同一批 `BashLayer` 实例。展开/折叠(`expanded` + `body` 显隐)、实时缓冲(`_live`)、代码框滚动值(`out_box`/`arg_box` 子控件属性)、两栏 section 滚动位置(`QScrollArea` 自身属性,子层重排不触碰)全部天然保持,测试逐项断言(P11.4–P11.8、P11.13b–d)。 + +## 验证(全部显式 timeout) + +| 套件 | 结果 | 预算 | +|---|---|---| +| `tests/smoke_bash_panel.py`(含新增 P11 节) | **ALL PASS(140 项断言)EXIT=0** | 240s | +| `tests/test_bash_stream.py` | **30/30 ALL PASS EXIT=0** | 180s | +| 回归 `tests/smoke_offscreen.py` | ALL PASS 8/8 EXIT=0 | 240s | +| 回归 `tests/run_tests.py` | 41 passed / 0 failed EXIT=0 | 300s | +| 回归 `test_main_window_event_filter` / `test_config_isolation` / `test_wv2_guard` | 均 ALL PASS EXIT=0 | 各 120s | + +新增断言要点(对应 backlog「完成证据」三条): +- **≥3 项任务以不同启动/完成顺序**:s1/s2/s3/s4/s6 + t0 + t1..t31 共 36 项已完成、交错完成(s2 先完成仍居顶、s3 最后完成插入启动位而非顶格),两栏均断言启动降序(P11.1/P11.2/P11.9–P11.13); +- **完成中间任务前后状态保持**:对象 identity(`is`)、展开态、实时输出文本、代码框水平滚动值(先强制非 0)、section 垂直滚动值(用 20 行内容撑出真实滚动范围后设 30)在重排后逐项相等(P11.3–P11.8、P11.13b–d); +- **DB 重建与实时一致**:切换会话后 3 条时间线条目按 `db2,db1,db0` 显示(消息链+时间线序的逆),`layer_ids()` 原始正序不变(P11.19/P11.20)。 + +## 测试中发现并处理的问题 + +1. **Qt 布局 flush 会重置代码框水平滚动(测试时序伪影,非产品 bug)**:展开层与 `setValue` 同 tick 执行时,`out_box` 的终宽布局尚未 flush,随后任何布局事件(如新任务触发的 `set_layers`)应用挂起 resize 会把水平滚动清零。探针矩阵(N1×N2 settle 圈数)证实:`setValue` 前至少一次事件循环(N2≥1)则滚动稳定保持。真实用户不可能在未渲染的框上滚动,故测试在设滚动值前补 `settle(120)` 并在注释中记录该伪影。 +2. **`smoke_bash_panel.py` 解释器退出挂起**:末行 `sys.exit(0)` 后 QtWebEngine 渲染/GPU 子进程(offscreen)不回收,进程挂到 timeout 124;此前跑该文件若经管道只看输出会误判通过。改为仓库 offscreen harness 惯例 `os._exit(code)`(stdout 已 flush、临时文件已清理),EXIT=0 即时返回。 +3. **`layer_ids()` 口径**:原返回启动正序;改为 running/done 返回真实显示顺序(便于测试直接断言所见即所得),"all" 保持原始正序。既有断言(单元素/`set()`/`len`)全部不受影响,140 项一次通过。 + +## 未验证项 + +无平台相关项(纯 UI 排序逻辑,offscreen 已全量覆盖)。 diff --git a/docs/agent-handoff/evidence/P2-02.md b/docs/agent-handoff/evidence/P2-02.md new file mode 100644 index 0000000..8639496 --- /dev/null +++ b/docs/agent-handoff/evidence/P2-02.md @@ -0,0 +1,48 @@ +# P2-02 证据:右侧 Bash 面板滚动条与横纵交汇角 + +完成日期:2026-07-21(无人值守轮次) +结论:**完成**。右侧任务面板的代码框(`#bl_code`)与两栏 section 滚动区(`#bl_scroll`)滚动条统一为 8px、无箭头、handle 可见且 hover;横纵交汇角用 `QPlainTextEdit::corner` 子控件染成代码框背景 `#fbfcfe`,原生亮色 corner 方块消除。所有选择器均限定在 `#bl_code`/`#bl_scroll`,未添加任何无作用域的 `QScrollBar`/`QAbstractScrollArea` 规则。 + +## 交付物 + +| 文件 | 改动 | +|---|---| +| `ui/views/main_window.py` | 主窗口全局 QSS 中 `#bl_code` 规则后插入一段**完全限定作用域**的滚动条 + corner 规则(`QPlainTextEdit#bl_code QScrollBar:*`、`QPlainTextEdit#bl_code::corner`、`QScrollArea#bl_scroll QScrollBar:*`、`QScrollArea#bl_scroll::corner`);`#bl_code` 原有背景/边框/圆角/文本样式一字未动 | +| `tests/diag_panel_scrollbar.py` | 新建:离屏测量 + 断言 + 局部截图(面板全貌、代码框 render 图、角落 4x 放大) | + +截图(`docs/agent-handoff/evidence/`):`p2-02-panel.png`、`p2-02-outbox-render.png`、`p2-02-codebox-corner-4x.png`。 + +## 关键设计决策 + +1. **作用域 = objectName 限定,零全局规则。** 主窗口全局 QSS 此前没有任何 `QScrollBar` 规则(各弹窗/附件区各自 `setStyleSheet`),右侧面板因此落到原生 Windows 滚动条(带箭头、17px、亮色 corner 方块)。新增规则全部写成 `QPlainTextEdit#bl_code …` / `QScrollArea#bl_scroll …` 形式,只可能匹配右面板内的对象名,结构上不可能泄漏到其他控件。 +2. **corner 用 `QAbstractScrollArea::corner` 子控件语法**(`QPlainTextEdit#bl_code::corner { background-color: #fbfcfe; }`)——Qt 文档支持的子控件,与代码框背景同色,即 backlog「corner 与代码框背景一致」;未使用不存在的 `QScrollBar::corner`。section 滚动区 `::corner` 置透明(其横向滚动条恒关,corner 本不显示,属保险)。 +3. **口径与仓库既有风格一致**:8px 厚、`add-line/sub-line` 置 0 隐藏箭头、`#d0d0d0` handle + `#a0a0a0` hover、圆角 4px——与 `modern_scrollbar_qss` 及附件预览区风格同源,只是作用域不同。 + +## 验证(全部显式 timeout) + +| 套件 | 结果 | 预算 | +|---|---|---| +| `tests/diag_panel_scrollbar.py`(新) | **18 项 ALL PASS EXIT=0** | 180s | +| `tests/smoke_bash_panel.py` | **140 项 ALL PASS EXIT=0** | 240s | +| 回归 `tests/smoke_offscreen.py` | ALL PASS 8/8 EXIT=0 | 240s | +| 回归 `tests/run_tests.py` | 41/41 EXIT=0 | 300s | +| 回归 `smoke_timeline` / `smoke_midswitch` / `test_main_window_event_filter` | 11/11、7/7、ALL PASS,均 EXIT=0 | 各 ≤300s | + +诊断脚本断言要点(对应 backlog「完成证据」): +- **厚度**:代码框横/纵滚动条实际几何 = 8px 且 `sizeHint` = 8px(S1/S1b/S2/S2b);section 竖滚动条实际 = 8px(S3); +- **箭头 extent**:`subControlRect(CC_ScrollBar, SC_ScrollBarSubLine)` 在样式代理下 = 0(S4 三项)——即箭头子控件零尺寸; +- **corner**:代码框 `render()` 图中,右下角 8×8 交汇块渲染出 `#fbfcfe`(9 px)、无 `(255,255,255)` 亮白像素(S5c/S5d); +- **无泄漏**:未命名 `QPlainTextEdit` 横滚动条仍为原生口径(14px,S6);附件预览滚动条保持自身 6px `sizeHint`(S7); +- 截图三张落盘 evidence 目录,含角落 4x 放大图。 + +Windows 真机截图(backlog「完成证据」第二条):**已完成(2026-09-17,Windows 11 真桌面,非 offscreen)**:`diag_panel_scrollbar.py` 直接运行 → EXIT=0、ALL PASS;实测代码框 H=8px V=8px、section V=8px、corner #fbfcfe 9 像素(与 offscreen 测量一致);三张截图已用真机渲染覆盖:`p2-02-panel.png`、`p2-02-outbox-render.png`、`p2-02-codebox-corner-4x.png`(07:20 时间戳)。运行期间生产 app 以 WebView2 在前台,diag 实例经 instance-lock 守卫自动回落 QtWebEngine,未误杀对方 WebView2 进程(T0 守卫真机验证)。Linux 真机截图仍待对应环境。 + +## 测试中发现的问题与教训 + +1. **offscreen 下 `widget.grab()` 对 `QPlainTextEdit` 的文档区不填充(黑图)**:`out_box.grab()` 整块 (0,0,0),但 `panel.grab()` 正常。改用 `ob.render(painter)`(渲染到透明 QPixmap)后:文本色 `#243043`、边框 `#e6eaf2`、handle `#d0d0d0`、corner `#fbfcfe` 全部出现——**样式子控件在 offscreen 下正常渲染,只有文档区背景填充缺失**(离屏渲染怪癖,非产品 bug)。像素断言一律走 `render()`,且必须先做健全性检查(文本色/handle 色像素计数 > 0)防黑图假通过。 +2. **PyQt6 API 坑(三连)**: + - `Qt.Vertical`/`Qt.Horizontal` 短名已移除 → `Qt.Orientation.*`; + - `QStyleOptionSlider(widget)` 构造器未绑定(只收无参/拷贝)→ 用 `QStyleOptionSlider()`; + - `subControlRect` 参数序是 `(ComplexControl, QStyleOption, SubControl, widget)`,且滚动条箭头子控件在 PyQt6 枚举里叫 `SC_ScrollBarSubLine`/`SC_ScrollBarAddLine`(不是 C++ 文档里的 `SC_DownArrowButton`);`CC_ScrollBar` 属于 `QStyle.ComplexControl` 而非 `ControlElement`。 + - 裸控件(无样式表祖先)的 `style()` 是基础风格且空 option 下 `subControlRect` 返回 0 矩形——「原生参照」只能靠 `sizeHint`/实际几何(如原生横条 14px)对照,不能靠 subControlRect。 +3. **测试数据**:`out_box` 要同时出横纵滚动条,必须「超宽单行(NoWrap 触发横条)+ 足够行数(触发纵条)」,只给长单行时纵条不可见。 diff --git a/docs/agent-handoff/evidence/P2-03.md b/docs/agent-handoff/evidence/P2-03.md new file mode 100644 index 0000000..b6d4a8c --- /dev/null +++ b/docs/agent-handoff/evidence/P2-03.md @@ -0,0 +1,95 @@ +# P2-03 证据:WebView2 原生窗口遮挡层(重命名遮罩) + +**任务**:修复 RenameOverlay 被 WebView2 原生子窗口压住的确定性缺陷,审计同类遮罩,只修可确认的原生窗口遮挡。 +**结论**:`RenameOverlay` 已重写为独立顶层透明 Tool 窗(与 `AttachmentPreviewOverlay` 同一验证过的模式);审计确认它是**唯一**受影响的遮罩,其余全部本就是顶层窗口,未改动。自动化目标测试全绿。 + +## 根因(源码级) + +- `core/webview2.py`(L10-11 注释 + 子窗口发现实现):WebView2 的 `Chrome_WidgetWin_*` 是**主窗口 HWND 的原生子 HWND**(EnumChildWindows 轮询发现,SetBoundsAndZoomFactor 定位)。 +- Windows 上原生子 HWND 永远绘制在其父 HWND 内所有 Qt 渲染内容**之上**(Qt 绘入主窗口 backing store,原生子窗在 z 序更高)。 +- 旧 `RenameOverlay` 是 `bg_widget` 的**子控件**(`QWidget(parent=bg_widget)` + `setGeometry(parent.rect())`):遮罩与卡片都是主窗口内的 Qt 绘制 → 在聊天区(WebView2 所在区域)内被原生 webview 窗盖住:遮罩不暗、卡片被压。 +- 唯一可盖住它的结构:独立顶层窗口(独立 HWND)+ 逐像素 alpha(`WA_TranslucentBackground`)。`AttachmentPreviewOverlay` 早已按此模式修复(其 docstring 明文记录同一缺陷),`RenameOverlay` 是遗留的旧模式。 + +## 修改(ui/views/main_window.py,仅 RenameOverlay 类,L1363 起整类替换) + +| 项 | 旧 | 新 | +|---|---|---| +| 窗口类型 | `bg_widget` 子控件 | 顶层 `FramelessWindowHint \| Tool`(独立 HWND,拥有者=主窗口,Windows 上默认浮于拥有者之上,不入任务栏) | +| 透明 | 无(fillRect 半透明灰) | `WA_TranslucentBackground` + paintEvent 半透明灰(逐像素 alpha) | +| 覆盖范围 | `parent.rect()`(客户区内嵌) | **主窗口客户区**:`mapToGlobal(main.rect().topLeft())` + `main.rect().size()` —— 不含系统标题栏/边框,标题栏与窗口控制保持可操作 | +| 跟随 | `resizeEvent`(子控件自动跟随) | eventFilter 挂主窗口:`Move` → move;`Resize` / `WindowStateChange`(最大化/还原;DPI 变化时 Qt 对主窗合成 move+resize,同路跟随)→ 客户区几何重同步 + 卡片重居中;`Close`/`Hide` → 关闭遮罩 | +| 入场动画 | QGraphicsOpacityEffect(顶层窗不可靠) | windowOpacity 属性动画 150ms(同附件预览层) | +| 行为 | 点空白/✕/取消 关闭、输入全选、Enter 提交 | 全部保留,**新增 Esc 关闭**(keyPressEvent);`confirm()` 空标题不 emit | +| 释放 | `deleteLater` | `close_overlay()`:`removeEventFilter(main)` + `main.activateWindow()/raise_()`(焦点回主窗)+ `deleteLater`;`WA_DeleteOnClose`;`_closed` 幂等防重入 | +| 卡片拖拽 | 有(限父窗内) | 保留(限客户区内) | + +`_rename_session` 调用点未改(仍 `RenameOverlay(current_title, self.bg_widget)`;`parent.window()` 取主窗口)。无新增辅助函数(几何同步逻辑与附件预览层各 10 行,不构成"真实重复",未抽公共函数)。 + +## 同类遮罩审计(只修可复现者) + +| 遮罩/弹窗 | 位置 | 窗口类型 | 结论 | +|---|---|---|---| +| `AttachmentPreviewOverlay` | L53 | 顶层 Tool + `WA_TranslucentBackground` + eventFilter 跟随(frameGeometry) | **不受影响**(已是正确模式,本次修复的参照) | +| `SettingsWindow` | L424 | 顶层 `FramelessWindowHint`(独立窗,内部虚化遮罩是其子控件) | **不受影响**(独立 HWND,天然在 webview 之上) | +| `SessionContextPopup` | L1267 | `Popup \| FramelessWindowHint` | **不受影响**(Popup 为独立顶层原生窗) | +| `ModelSelectPopup` | L1624 | `Popup \| FramelessWindowHint`(L1649) | **不受影响** | +| `SessionModePopup` | L2245 | `Popup \| FramelessWindowHint`(L2260) | **不受影响** | +| `PdfModePopup` | L2368 | `Popup \| FramelessWindowHint`(L2392) | **不受影响** | +| `RenameOverlay` | L1363 | ~~bg_widget 子控件~~ → 顶层 Tool + 透明 | **受影响,已修复**(唯一) | + +未把普通 popup/dialog 重写成统一框架(硬约束)。 + +## 自动化验证(本机 Windows 11 10.0.26200 x64,`.venv` CPython 3.10.21) + +新建 `tests/diag_rename_overlay.py`(隔离临时 DB,offscreen,34 项断言,EXIT=0): + +``` +RESULT: 34/34 -> ALL PASS + R0 遮罩创建且为顶层窗口 + R1 结构:isWindow / window() is self(非内嵌子控件)/ Tool / Frameless / + WA_TranslucentBackground / WA_DeleteOnClose / 主窗口未设透明 + R2 几何:覆盖客户区左上角与尺寸(±2px)/ 卡片居中 / 输入框初始全选 / 预填旧标题 + R3 跟随:主窗 move(+150,+80)→精确跟随 / resize(1200x700)→尺寸同步+卡片重居中 / + WindowStateChange 分支(最大化/还原同路)不崩溃且几何仍正确 + R4 行为:Enter 提交→DB+侧栏标题更新 / 空标题 confirm 不改标题 / + Esc 关闭 / 点空白关闭 / ✕ 关闭 / 取消关闭 / 非确认关闭不改标题 + R5 释放:顶层窗口消失、无残留 rename_form、主窗口存活可用 + R6 焦点回主窗(offscreen 软检查,INFO 记录) +``` + +目标测试 + 回归(均 EXIT=0): + +| 套件 | 结果 | +|---|---| +| `tests/diag_rename_overlay.py` | 34/34 ALL PASS | +| `tests/smoke_offscreen.py`(QtWebEngine 路径) | 8/8 ALL PASS | +| `tests/smoke_copy_session.py`(侧栏/Popup 链路) | ALL PASS | +| `tests/smoke_bash_panel.py` | 140 项 ALL PASS | +| `tests/run_tests.py` | 41 passed, 0 failed | + +## 真机 WebView2 验收(用户走查步骤) + +真机 WebView2 自动化被有意放弃:`core/webview2.py` 的 `get_environment()` 含 `taskkill /F /IM msedgewebview2.exe`(会杀掉用户其他 WebView2 应用进程)且 SDK 怪癖要求默认共享 profile(无法重定向到临时目录)——无人值守自动化运行该路径风险不可接受。 + +**【2026-09-17 更新】本机 WebView2 加载阻塞已解除**(`core/webview2.py` 加 byte[] 回落,见 evidence/P1-03.md):主程序现已能在本机以真实 WebView2 启动(Runtime 153.0.4234.32、controller ready、index.html NavigationCompleted、stderr 无错)。下表人工清单现可在本机真实 WebView2 模式下执行。 + +机制保证(与生产已验证的 AttachmentPreviewOverlay 完全同构):新遮罩是**独立顶层 HWND**(WS_EX_TOOLWINDOW + 拥有者=主窗口);Windows z 序规则下,拥有者窗口之上的顶层窗永远绘制在拥有者的原生子 HWND(WebView2 `Chrome_WidgetWin_*`)之上,与 offscreen 平台无关。 + +人工验收清单(Windows 桌面,真实 WebView2 模式运行主程序后): +1. 会话右键 → 重命名:遮罩盖住**整个客户区**(含聊天区 webview,webview 变暗),标题栏(含最小化/最大化/关闭)仍可点。 +2. 拖主窗口 / 拉边框缩放 / 最大化 / 还原:遮罩与卡片全程贴合客户区、卡片保持居中。 +3. 多显示器间拖主窗 / 改缩放比后重开遮罩:几何正确(mapToGlobal 路径)。 +4. Esc / 点空白 / ✕ / 取消 → 遮罩消失、焦点回主窗、侧栏可继续操作;Enter 或确定 → 标题更新。 +5. 连续开/关 5 次:无残留遮罩、无卡顿、任务栏无新增图标。 + +## 教训(跨压缩持久) + +- 【P2-03 发现】**offscreen/无真实事件循环时 `processEvents()` 不处理 `DeferredDelete`**:`deleteLater()` 的控件必须显式 `QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)` 才会真正删除(探针证实:仅 processEvents 循环后对象仍 alive)。生产事件循环常驻不受影响,但所有 offscreen 测试的"已删除"断言前必须冲刷 DeferredDelete。 +- 【P2-03 教训】PyQt6 `setGeometry` 无 `(QPoint, QSize)` 重载 → 构造 `QRect(tl, size)`;`QTest.mouseClick(widget, button, modifier, pos)` 第 3 参是 **modifier**(易误当 pos)。 +- 【P2-03 发现】`core/webview2.py` `get_environment()` 含 `taskkill /F /IM msedgewebview2.exe` + 共享默认 profile 不可重定向 → 无人值守自动化不得走真实 WebView2 启动路径;真机验收走人工清单。 +- 【P2-03 结构判据】"顶层窗可带 owner parent":`setWindowFlags(Tool|Frameless)` 后 `parentWidget()` 仍可非 None(owner 关系),判据是 `isWindow()` / `window() is self`,不是 `parentWidget() is None`。 + +## 修改文件 + +- `ui/views/main_window.py`(仅 `RenameOverlay` 类整类替换,~170 行;调用点未改) +- `tests/diag_rename_overlay.py`(新,34 项断言) diff --git a/docs/agent-handoff/evidence/P2-04.md b/docs/agent-handoff/evidence/P2-04.md new file mode 100644 index 0000000..81e7c02 --- /dev/null +++ b/docs/agent-handoff/evidence/P2-04.md @@ -0,0 +1,104 @@ +# P2-04 证据:跨平台聚合测试入口 + +状态:COMPLETE +日期:2026-07-17(本机执行时间) +平台:Windows 11 10.0.26200 x64(主)+ WSL Ubuntu-22.04 / CPython 3.12.3(Linux 路径验证) + +## 交付物 + +- `tests/run_all.py`(新增,唯一新文件):跨平台聚合测试入口。 + - `python tests/run_all.py --group logic|offscreen|all`(默认 all) + - `--list` 只列条目;`--only ` 跑子集(调试);`--keep-logs` 保留全部子日志(默认只留失败/超时)。 + - 设计:每个子测试 = 独立子进程 + 独立临时目录(`haocode_RUN_`/`haocode_CONF_`,POSIX 走 TMPDIR、Windows 走 TEMP)+ 显式秒级 timeout(`subprocess.run(timeout=...)`);offscreen 组子进程注入 `QT_QPA_PLATFORM=offscreen`、`HAOCODE_RENDER=software`、`QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu`(均 setdefault,不覆盖外层)。 + - 退出码语义:任一 FAIL/TIMEOUT → 退出码 1(并打印"失败命令"清单);SKIP 不影响退出码。 + - SKIP 双闸门:①依赖探测(`importlib.util.find_spec`:node/openai/PyMuPDF/PyQt6,缺失即 SKIP 并给理由);②平台令牌(`platform:win32` 等,平台不适用即 SKIP 并给理由)。 + - 聚合鲁棒性:子进程崩溃/超时只记 FAIL/TIMEOUT,不中断后续;输出统一 UTF-8(`errors=replace`,Windows cp936/cp1252 宿主安全);WSL 时钟回拨保护(耗时 clamp ≥0)。 + - 不要求 pytest / npm / 网络;聚合器自身零第三方依赖。 + +## 默认聚合范围(25 条)与排除项 + +- logic 12 条:run_tests.py(agent core 41)、test_tool_params、test_bash_stream、test_copy_session、test_compaction_persist、test_file_attach、test_cross_platform_shell、test_global_hotkey_platforms、test_wv2_guard、test_pdf_reader、test_math_extract.js、test_render_window.js。 +- offscreen 13 条:smoke_offscreen、smoke_mode、smoke_copy_session、smoke_bash_panel、smoke_timeline、smoke_midswitch、test_main_window_event_filter、test_config_isolation、test_error_persist、test_think_code_neutral、test_debug_window、test_renderer_matrix、test_screen_capture_platforms。 +- 默认排除(有注释理由,不进默认聚合): + - `diag_*`(rename_overlay / panel_scrollbar / render_scale):人工/像素诊断,需真机或交互。 + - `verify_*`:人工验证脚本。 + - `tune_model_popup.py`:调参实验。 + - `smoke_offscreen.py` 之外的旧式 real-DB 冒烟(引用真实 `data/haocode.db`,未接临时库)。 + - `diag_live_agent.py`、`probe_agent_loop.py`:需真实 API 凭据。 + - `_probe_*`、`_test_env.py`:探针/工具模块(被其他测试 import,非独立用例)。 + - `run_all.py` 自身:聚合器不入聚合。 + +## 汇总结果(硬约束要求:Windows/Linux 各一份) + +### Windows 11 / CPython 3.10.21(.venv,PyQt6+openai+PyMuPDF 齐全) + +| 组 | 结果 | 耗时 | +|---|---|---| +| `--group logic` | **PASS 12 / FAIL 0 / SKIP 0**(EXIT=0) | 43.9s | +| `--group offscreen` | **PASS 13 / FAIL 0 / SKIP 0**(EXIT=0) | 81.9s | +| `--group all` | 25/25 PASS(EXIT=0,两组顺序执行) | ~126s | + +- 日志样例:`D:/tmp/runall_win_logic2.log`、`D:/tmp/runall_win_offscreen.log`。 +- offscreen 明细:smoke_offscreen 8.3s、smoke_bash_panel 30.3s、smoke_midswitch 8.7s、test_think_code_neutral 9.3s 等 13 条全 PASS。 + +### WSL Ubuntu-22.04 / CPython 3.12.3(系统 python3,无 PyQt6/openai/PyMuPDF,离线环境) + +| 组 | 结果 | 耗时 | +|---|---|---| +| `--group all` | **PASS 4 / FAIL 0 / SKIP 21**(EXIT=0) | 0.9s | + +- PASS 4 = test_copy_session、test_file_attach、test_math_extract.js、test_render_window.js(纯 stdlib/node)。 +- SKIP 21 全部带明确理由:无 openai(5,禁止联网安装)、无 PyQt6(14,offscreen 组整体)、无 PyMuPDF(1)、平台不适用 win32-only(1,test_wv2_guard:msvcrt 单实例互斥是 WebView2 守卫的 Windows 专属机制,Linux 无 WebView2 链路)、无 PyQt6 的 GUI 逻辑(1,test_global_hotkey_platforms 含 QShortcut 构造)。 +- 证明:聚合器在 Linux/3.12 上可运行、隔离约定在 POSIX(TMPDIR)成立、诚实 SKIP 不伪装通过、JS 条目跨平台运行。 +- 注:WSL 无 .venv 且离线,依赖型条目按硬约束 SKIP;在按 VERIFICATION 矩阵备齐依赖的 Linux 真机上同命令即可执行全部条目(offscreen 组仍需真实 Linux 桌面环境完成 P1-03/P1-04 的平台验证,状态不变)。 + +## 故意失败夹具演示(硬约束:崩溃/超时不阻断汇总、退出码反映失败) + +- 临时夹具(用后即删,未提交): + - 崩溃夹具(`raise RuntimeError`)→ 聚合器记 **FAIL** 并继续跑后续健康测试。 + - 超时夹具(`time.sleep(60)` + budget 5s)→ 聚合器记 **TIMEOUT** 并继续。 +- 演示运行:`总计 3: PASS 1 FAIL 1 TIMEOUT 1`,退出码 **1**,完整汇总 + 失败命令清单正常打印。 +- 演示日志:`D:/tmp/runall_fixture_demo.log`;夹具文件已删除,仓库无残留(已 rg 复核)。 + +## 等价性(硬约束:单文件命令仍可直接运行,输出与聚合子进程一致) + +- `test_copy_session.py`: + - 独立运行:`===== 54/54 PASS ===== / ALL PASS` + - 聚合子进程(保留子日志 tail):`===== 54/54 PASS ===== / ALL PASS` —— 逐字一致。 +- 其余条目同理(聚合即 `subprocess.run([sys.executable, ...原命令...])`,命令形态未变)。 + +## 聚合入口暴露并修复的两个真实缺陷 + +1. **`tests/test_compaction_persist.py` T9 陈旧断言**(跨平台共同项,Windows 上 FAIL): + - 断言用 `assertIn('msg["role"] not in', src)` 硬编码变量名,而 `ui/views/main_window.py` 现行代码用 `m["role"] not in`(P1-01/P2-01 期间变量名演化,测试未跟进)。 + - 修复:改为变量名无关的语义断言(同时接受 `msg["role"] not in` / `m["role"] not in` 两种等价写法,检查"摘要标记行被过滤"这一语义本身)。 + - 验证:单跑 41/41 PASS;聚合 logic 组 12/12 PASS。 +2. **`core/agent/compaction.py` dataclass 不可哈希默认值(Python 3.11+ 崩溃,真跨平台 bug)**: + - `CompactionPreparation.settings: CompactionSettings = DEFAULT_COMPACTION_SETTINGS`:默认值是 eq-dataclass 实例(`__hash__=None`)。Python ≤3.10 的 dataclass 只拒 list/dict/set 默认值 → 合法;Python 3.11+ 追加"不可哈希默认值"检查 → **import 即 ValueError**。 + - 影响面:Ubuntu 24.04 自带 CPython 3.12(VERIFICATION 矩阵明确支持的 Linux 目标)—— 任何 import `core.agent` 的模块/测试在 3.11+ 全灭。由聚合入口在 WSL/3.12 上首次系统性暴露。 + - 修复(1 行,语义完全等价):`settings: CompactionSettings = field(default_factory=lambda: DEFAULT_COMPACTION_SETTINGS)` —— 仍返回同一共享默认实例,3.10 行为不变。 + - 验证:3.10 本地 `test_compaction_persist` 41/41 + `run_tests` 41/41 无回归;WSL/3.12 上 `core.agent` 链 import 通过(3 个 openai 依赖条目越过 compaction 后才在 openai 处 SKIP,证明 3.12 兼容)。 + +## 注册表元数据修正(诚实 SKIP 的前提) + +- `test_tool_params.py` / `test_bash_stream.py` / `test_cross_platform_shell.py`:补 `openai` 依赖(均 import `core.agent.tools` → 顶层 `from openai import OpenAI`)。 +- `test_global_hotkey_platforms.py`:补 `pyqt6` 依赖(含 QShortcut/overlay 构造)。 +- `test_wv2_guard.py`:标 `platform:win32`(T4 msvcrt 跨进程互斥为 Windows 专属;非 win32 上 `acquire_instance_lock()` 按设计返回 None)。 + +## 边界遵守 + +- 未动任何独立测试命令与既有入口(`run_tests.py` 原样保留)。 +- 未要求 pytest/npm/网络;聚合器零第三方依赖。 +- 默认聚合未启动任何真实 API / 人工诊断 / 真实桌面 / 凭据脚本(见排除清单)。 +- 未读取真实配置(`data/config.json` 零访问);未删除用户运行数据;夹具用后即删。 +- 产品代码改动仅 2 处且均为聚合暴露的缺陷修复:`tests/test_compaction_persist.py`(测试断言更新)+ `core/agent/compaction.py`(1 行 3.11+ 兼容)。 + +## 复现命令 + +```text +python tests/run_all.py --list +python tests/run_all.py --group logic +python tests/run_all.py --group offscreen +python tests/run_all.py --group all +python tests/run_all.py --only test_copy_session --keep-logs +``` diff --git a/docs/agent-handoff/evidence/p2-02-codebox-corner-4x.png b/docs/agent-handoff/evidence/p2-02-codebox-corner-4x.png new file mode 100644 index 0000000..3bf9ce4 Binary files /dev/null and b/docs/agent-handoff/evidence/p2-02-codebox-corner-4x.png differ diff --git a/docs/agent-handoff/evidence/p2-02-outbox-render.png b/docs/agent-handoff/evidence/p2-02-outbox-render.png new file mode 100644 index 0000000..5b58460 Binary files /dev/null and b/docs/agent-handoff/evidence/p2-02-outbox-render.png differ diff --git a/docs/agent-handoff/evidence/p2-02-panel.png b/docs/agent-handoff/evidence/p2-02-panel.png new file mode 100644 index 0000000..b884373 Binary files /dev/null and b/docs/agent-handoff/evidence/p2-02-panel.png differ diff --git a/docs/agent-handoff/evidence/win_real_qtwebengine_mainwindow.png b/docs/agent-handoff/evidence/win_real_qtwebengine_mainwindow.png new file mode 100644 index 0000000..77b9a03 Binary files /dev/null and b/docs/agent-handoff/evidence/win_real_qtwebengine_mainwindow.png differ diff --git a/docs/agent-handoff/evidence/win_real_wv2_mainwindow.png b/docs/agent-handoff/evidence/win_real_wv2_mainwindow.png new file mode 100644 index 0000000..04a77b6 Binary files /dev/null and b/docs/agent-handoff/evidence/win_real_wv2_mainwindow.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..afa3fb6 --- /dev/null +++ b/main.py @@ -0,0 +1,119 @@ +# main.py +import io +import os +import sys + +# ==================== 🌟 冻结/无控制台环境:stdout/stderr 安全化 ==================== +# 🐛 打包成 exe 后踩到的真 bug(开发环境永远不会暴露): +# 打包版没有控制台,sys.stdout 可能是 None 或编码为 GBK 的流 → +# 任何 print 里的 emoji(🚀✅🛑…)都会抛 UnicodeEncodeError, +# 而且是在 MainWindow() 之前就崩 → **主窗口根本不出现**。 +# 统一改成 UTF-8 + errors=replace:打包版与开发版行为一致, +# 即使控制台是 GBK,也只会显示成 '?' 而不会崩。 +for _name in ("stdout", "stderr"): + _s = getattr(sys, _name, None) + if _s is None: + try: + setattr(sys, _name, + open(os.devnull, "w", encoding="utf-8", errors="replace")) + except Exception: + pass + continue + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except Exception: + try: + setattr(sys, _name, io.TextIOWrapper( + _s.buffer, encoding="utf-8", errors="replace", line_buffering=True)) + except Exception: + pass + +# ==================== 🌟 WebEngine 渲染配置(必须在 PyQt6 导入之前设置)==================== +# 本机实测渲染模式:GPU 硬件加速(ANGLE → Direct3D11, AMD 核显),非 CPU 软渲染。 +# +# 黑边/黑框背景(已实测): +# Windows 上窗口缩放的新暴露区域未出帧时,GPU 合成器露出黑色、软件合成器露出白色。 +# 基准数据(重度页面 500 节点+滚动+强制重绘): +# GPU 合成 59.2 FPS(黑框) +# 软件合成 30.1 FPS(白框,视觉反差小;轻量聊天页实测跟手无差别) +# 因此默认采用“软件合成”(白框、好看);若遇重度页面掉帧,用 HAOCODE_COMPOSITING=gpu 回退。 +# +# ⚠️ 2026-07 重大变更:默认改为全 CPU 软渲染(--disable-gpu)。 +# 原因:本机 AMD 核显 GPU 上下文周期性丢失(context lost 实测复现), +# GPU 光栅化产物无法合成上屏 → 流式正文 DOM 已写入但屏幕不刷新。 +# 软渲染在 Chromium 内全 CPU 完成,彻底绕开该故障模式;聊天页性能足够。 +# +# 备选开关(二选一,或直接设置 QTWEBENGINE_CHROMIUM_FLAGS 完全覆盖): +# HAOCODE_RENDER=gpu → 旧默认:GPU 光栅化 + 软合成(需 GPU 驱动稳定) +# HAOCODE_COMPOSITING=gpu → 回退 GPU 合成(黑框、满帧) +if "QTWEBENGINE_CHROMIUM_FLAGS" not in os.environ: + _render_mode = os.environ.get("HAOCODE_RENDER", "").strip().lower() + _comp_mode = os.environ.get("HAOCODE_COMPOSITING", "soft").strip().lower() + if _render_mode in ("gpu", "hardware"): + # 旧默认:GPU 光栅化 + 软合成(仅当 GPU 驱动稳定时使用) + _flags = ["--enable-gpu-rasterization", "--enable-zero-copy", "--ignore-gpu-blocklist"] + if _comp_mode != "gpu": # 默认软合成:黑框→白框 + _flags.append("--disable-gpu-compositing") + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = " ".join(_flags) + else: + # 默认(含 software):全 CPU 软渲染,规避 GPU 上下文丢失导致的不刷新 + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" + +print(f"[渲染] QTWEBENGINE_CHROMIUM_FLAGS = {os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', '(未设置)')}", flush=True) + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt + +# 确保 Python 能找到项目根目录下的模块 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# P1-03:Chromium sandbox 契约(在导入 PyQt6/QtWebEngine 前处理;renderer_backend 仅 stdlib) +# - 默认保留 sandbox;--no-sandbox 仅在显式配置 + root/容器场景下接受(高可见警告) +# - 普通桌面运行中的 --no-sandbox 被剥离并告警 +from core import renderer_backend as _renderer_backend +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = _renderer_backend.sanitize_chromium_flags( + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"]) +print(f"[渲染] 最终 QTWEBENGINE_CHROMIUM_FLAGS = {os.environ['QTWEBENGINE_CHROMIUM_FLAGS']!r}", flush=True) + +# 导入主窗口类 +from ui.views.main_window import MainWindow + +def main(): + + # 1. 开启高 DPI 缩放支持 (让界面在 2K/4K 屏幕上不模糊) + # PyQt6 默认已经处理得很好了,但加上这句更稳妥 + + if hasattr(Qt, 'AA_EnableHighDpiScaling'): + QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True) + if hasattr(Qt, 'AA_UseHighDpiPixmaps'): + QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) + + # 2. 初始化 QApplication 实例 + app = QApplication(sys.argv) + + # 极客细节:设置应用的全局字体 (可选) + # font = app.font() + # font.setFamily("Segoe UI") # Windows 推荐字体 + # app.setFont(font) + + # 防御:若系统解析出的默认字体 pointSize 无效(<=0),补一个合法值, + # 消除启动时 "QFont::setPointSize: Point size <= 0 (-1)" 的 Qt 警告(纯警告,非异常) + _app_font = app.font() + if _app_font.pointSize() <= 0: + _app_font.setPointSize(9) + app.setFont(_app_font) + + print("🚀 GeekAgent-Studio 正在启动...") + print("--------------------------------------------------") + + # 3. 实例化主窗口 + window = MainWindow() + + # 4. 显示窗口 + window.show() + + # 5. 进入主事件循环,并安全退出 + sys.exit(app.exec()) + +if __name__ == "__main__": + main() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..163310f --- /dev/null +++ b/readme.md @@ -0,0 +1,301 @@ +# haocode + +> Windows 桌面 AI Agent 客户端 —— **PyQt6 外壳 + 本地 Web 渲染(WebView2 优先 / QtWebEngine 兜底) +> + 与 [pi](https://github.com/badlogic/pi) **1:1 对齐的 agent 核心**(工具调用 / 自动重试 / 上下文压缩)。** + +能读写文件、执行 bash、带图片与 PDF 附件聊天、把「思考 / 工具调用 / 工具结果」按时间线持久化, +并在此基础上支持会话树分支、会话复制、实时上下文计量与右侧任务面板。 + +--- + +## 一、快速开始 + +### 1. 环境要求 + +| 项 | 要求 | +|---|---| +| 操作系统 | Windows 10 / 11 x64 | +| Python | **3.10**(开发环境:miniconda env `haocode`) | +| WebView2 Runtime | Win11 自带;Win10 需装 [Evergreen Runtime](https://developer.microsoft.com/microsoft-edge/webview2/)(缺失会自动回落 QtWebEngine,仍可用) | +| Node.js | 可选,只有跑 `tests/test_math_extract.js` 需要 | + +### 2. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +> **不需要** pip 安装的运行时依赖(已随仓库提供,勿删): +> `vendor/webview2/`(WebView2 .NET SDK + loader)、根目录 `WebView2Loader.dll`。 + +### 3. 配置 API Key + +编辑 `data/config.json`: + +```jsonc +{ + "providers": { + "deepseek": { + "api_key": "sk-填你自己的", // ← 交接版已清空,必须填 + "base_url": "https://api.deepseek.com", + "models": ["deepseek-v4-flash"], + "model_contexts": { "deepseek-v4-flash": 1000000 } // 决定上下文用量条与压缩阈值 + } + }, + "default_provider": "deepseek", + "default_model": "deepseek-v4-flash", + "debug_window_autostart": true, // 启动时是否自动开「调试窗口」 + "mode_switch": true, // 是否允许会话中途切换 chat ↔ worker + "webview_backend": "auto" // auto | webview2 | qtwebengine +} +``` + +### 4. 运行 + +```bash +python main.py +``` + +首次运行会自动在 `data/` 下创建**空的** `chat_history.db`(含一条初始对话)。 +左侧栏「+ 新建对话」开始聊天;输入框上方可贴图/拖文件/截屏(Ctrl+Shift+S)。 + +### 5. 打包 exe(可选) + +```bash +python -m PyInstaller haocode.spec --distpath "输出目录" --noconfirm +``` + +- 产物为 **onedir**(一个文件夹,里面 `haocode.exe` 双击即用) +- **不含数据库**:首次运行在 exe 同级 `data/` 自建空库 +- 冻结版没有控制台,启动日志写 **exe 同级 `haocode.log`**(`pyi_rth_trace.py` 钩子) +- ⚠️ 打包前务必确认:`main.py` 顶部的 stdout/stderr UTF-8 保护存在(否则 `print` 里的 emoji 会在 GBK 环境直接崩,表现为"双击没反应") + +--- + +## 二、架构总览 + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ ① 表现层 ui/views/*.py (PyQt6) │ +│ main_window.py 主窗口 / bash_panel.py 右侧任务面板 / debug_window.py │ +└───────────────┬──────────────────────────────────────────────────────────┘ + │ QWebChannel(chat_bridge.py:Python → JS 单向调用) +┌───────────────▼──────────────────────────────────────────────────────────┐ +│ ② 渲染层 ui/web/(index.html + app.js + style.css + KaTeX/highlight) │ +│ 容器二选一:wv2_view.py(WebView2) / custom_web_page.py(QtWebEngine) │ +└───────────────▲──────────────────────────────────────────────────────────┘ + │ 事件信号(Qt Signal/Slot,跨线程 queued) +┌───────────────┴──────────────────────────────────────────────────────────┐ +│ ③ 引擎层 core/llm_engine.py │ +│ AgentWorker(worker 模式) / ChatWorker(chat 模式) / TitleWorker(标题) │ +└───────────────┬──────────────────────────────────────────────────────────┘ + │ +┌───────────────▼──────────────────────────────────────────────────────────┐ +│ ④ Agent 核心 core/agent/(pi 1:1 移植) │ +│ loop.py 循环 → stream_fn.py 流式+异常分类 → recovery.py 重试/压缩决策 │ +│ tools.py 四个工具(read/bash/write/edit) · compaction.py 上下文压缩 │ +│ context.py 上下文组装与计量 · types.py 数据结构 │ +└───────────────┬──────────────────────────────────────────────────────────┘ + │ +┌───────────────▼──────────────────────────────────────────────────────────┐ +│ ⑤ 存储层 core/db_manager.py(SQLite:会话树 / 消息 / 压缩标记 / 附件) │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**一句话数据流**: +`输入框 → main_window 组装上下文(core/agent/context) → AgentWorker 跑 agent 循环 → +LLM 流式返回(stream_fn) → 工具在本地执行(tools) → 事件经 chat_bridge 推到 Web 层渲染 → +结束时整轮(正文/思考/工具时间线/usage)落库(db_manager),并成为会话树的新叶子` + +--- + +## 三、目录树(每行说明这是什么) + +``` +haocode/ +│ +├── main.py 程序入口:渲染开关 → UTF-8 保护 → QApplication → MainWindow +├── SYSTEM_PROMPT.md ★ agent 系统提示词(每次请求注入,不进历史、不占压缩) +├── requirements.txt 依赖清单(只列代码真实用到的包) +├── haocode.spec PyInstaller 打包配置(onedir / 不含 db / 带启动日志钩子) +├── pyi_rth_trace.py 打包运行时钩子:冻结版 stdout+stderr → exe 同级 haocode.log +├── WebView2Loader.dll WebView2 loader(工作目录兜底路径) +│ +├── core/ ★ 后端核心(无界面依赖) +│ ├── db_manager.py SQLite 存储层:会话/消息链表树/压缩标记/附件/复制会话 +│ ├── llm_engine.py Qt 线程桥:AgentWorker / ChatWorker / TitleWorker + 配置与提示词加载 +│ ├── webview2.py WebView2 集成:pythonnet 环境、单实例锁、无头守卫、残留进程清理 +│ ├── debug_log.py 调试事件总线(供调试窗口「对话消息 / 应用日志」两页) +│ └── agent/ ★ pi 1:1 agent 核心(9 个文件,见 Frame.md) +│ ├── agent.py Agent 对象:状态、事件订阅、prompt/continue、steering 队列 +│ ├── loop.py agent 主循环:流式一轮 → 工具批执行 → 轮末钩子 → 停止判定 +│ ├── stream_fn.py OpenAI 流式调用 + 异常分类(classify_error) +│ ├── recovery.py 重试/压缩三路决策(对照 pi retry.ts / overflow.ts) +│ ├── compaction.py 上下文压缩(摘要切点、保留尾巴、标记生成) +│ ├── context.py 上下文组装、token 估算、usage 锚定 +│ ├── tools.py 四个工具实现:read / bash / write / edit(+ 参数校验) +│ └── types.py ModelConfig / AgentConfig / AgentMessage / AgentTool… 数据结构 +│ +├── ui/ ★ 表现层 +│ ├── views/ PyQt6 窗口与逻辑 +│ │ ├── main_window.py ★主窗口(6000 行,全项目最核心):布局/样式/流式状态/附件/入库 +│ │ ├── bash_panel.py 右侧「任务面板」:运行中/已完成两栏、层卡片、拖拽调宽 +│ │ ├── chat_bridge.py QWebChannel 桥:把 Python 调用翻译成 JS 函数调用 +│ │ ├── wv2_view.py WebView2 容器控件(QWidget + 原生子窗口) +│ │ ├── custom_web_page.py QtWebEngine 容器控件(回落路径,含 QWebChannel 注入) +│ │ ├── debug_window.py 调试窗口:事件流 + 应用日志 + 命令输入 +│ │ └── system_tools/ 系统级能力(见 Frame.md) +│ │ ├── file_reader.py 文本/代码文件读取与二进制黑名单 +│ │ ├── global_hotkey.py 全局热键注册(Ctrl+Shift+S 截屏) +│ │ └── screen_capture.py 区域截屏 +│ └── web/ 本地 Web 渲染层(离线,无 CDN) +│ ├── index.html 页面骨架 + 三个本地库的引入 +│ ├── app.js ★前端全部逻辑:消息渲染/流式增量/时间线/KaTeX/滚动 +│ ├── style.css 全部样式(气泡/思考块/工具 chip/紧凑模式) +│ ├── marked.min.js Markdown 渲染 +│ ├── dompurify.min.js HTML 消毒(配合 marked) +│ ├── highlight/ 代码高亮(highlight.js + atom-one-dark) +│ └── katex/ 公式渲染(KaTeX + 字体) +│ +├── data/ 运行时数据目录 +│ ├── config.json 供应商/模型/开关配置(★ 需填 API Key) +│ ├── chat_history.db 聊天主库(**不在交接版内**,首次运行自建空库) +│ └── (运行后还会出现) debug_session.log · app_instance.lock · attachments/ · wv2_cache/ +│ +├── vendor/webview2/ WebView2 .NET SDK:net462 Core.dll + webview2loader_x64.dll +├── svg/ 界面图标 18 个(panel.svg 左栏 / panel_right.svg 右栏 / 模式图标…) +├── tools/builtin_tools/ +│ └── pdf_reader.py PDF 解析(extract_pdf_text / extract_pdf_images,被主窗口 import) +│ +├── tests/ ★ 测试与调试工具(见第五节) +│ +└── 历史文档(保留备查,非最新结构说明) + ├── ARCHITECTURE.md 早期架构文档(其中部分目录已在交接版移除) + ├── readme_our.md 早期设计/规划稿(973 行) + └── 黑边两现象分析报告.md Windows 窗口缩放黑边问题的分析记录 +``` + +--- + +## 四、我要改 X,该去哪个文件?(功能 → 文件索引) + +| 需求 | 文件 | 关键位置 | +|---|---|---| +| 改主界面布局 / 气泡 / 输入框 / 主题 | `ui/views/main_window.py` | `setup_ui()`、`setup_stylesheet()` | +| 改前端渲染(Markdown / 公式 / 流式增量) | `ui/web/app.js` + `style.css` | `marked.parse` 统一拦截点、`updateMessage` | +| 右侧任务面板(层卡片/状态) | `ui/views/bash_panel.py` | `BashPanel` / `BashLayer` / `_Section` | +| Python 调前端 JS | `ui/views/chat_bridge.py` | 每个方法 = 一个 JS 函数 | +| 数据库结构 / 会话树 / 附件 | `core/db_manager.py` | `add_message`、`get_message_chain`、`copy_session` | +| **系统提示词** | `SYSTEM_PROMPT.md` | 直接改,每次请求重新读取(无需重启) | +| 工具的实现与参数校验 | `core/agent/tools.py` | `tool_read` / `tool_bash` / `tool_write` / `tool_edit` | +| 工具调用循环 / 停止条件 | `core/agent/loop.py` | `run_agent_loop`、`execute_tool_calls` | +| 重试与压缩策略 | `core/agent/recovery.py` | `_handle_post_agent_run`(三路决策) | +| 上下文压缩算法 | `core/agent/compaction.py` | `compact_context`、`should_compact` | +| token 估算 / usage 锚定 | `core/agent/context.py` | `estimate_context_tokens`、`calculate_context_tokens` | +| 模型请求 / 流式解析 / 错误分类 | `core/agent/stream_fn.py` | `openai_stream`、`classify_error` | +| WebView2 行为(锁/无头/清场) | `core/webview2.py` | `get_environment`、`acquire_instance_lock` | +| 调试窗口 | `ui/views/debug_window.py` + `core/debug_log.py` | — | +| 打包 | `haocode.spec` + `pyi_rth_trace.py` | — | + +--- + +## 五、测试方式 + +### 1. 两条铁律(写新测试必须遵守) + +1. **不得污染真实数据库**:测试启动时必须把 `core.db_manager._DEFAULT_DB` 指向临时文件 + ```python + import core.db_manager as _dbm + _dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"t_{os.getpid()}.db") + # 必须在 import MainWindow 之前 + ``` +2. **不得污染真实配置**(会写 `config.json` 的功能):把 `HAOCODE_CONFIG_FILE` 指向临时文件 + +### 2. 跑测试(建议先设两个环境变量) + +```bat +set PYTHONIOENCODING=utf-8 +set QT_QPA_PLATFORM=offscreen :: 只有带 GUI 的 smoke_* 需要 +``` + +**A. 纯逻辑测试(无 GUI,秒级)** + +```bash +python tests/run_tests.py # agent 核心(流式/工具/重试/压缩) 41 项 +python tests/test_tool_params.py # 四个工具的参数校验与错误串 35 项 +python tests/test_compaction_persist.py # 压缩标记持久化与上下文截断 41 项 +python tests/test_copy_session.py # 会话复制(含附件深拷贝、分支、标题递增) 54 项 +python tests/test_bash_stream.py # bash 实时输出/超时杀进程树/50KB 截断 30 项 +python tests/test_error_persist.py # 失败轮次入库与回放取舍(含旧库迁移安全) 39 项 +python tests/test_wv2_guard.py # WebView2 双守卫(无头/多实例) 10 项 +python tests/test_debug_window.py # 调试窗口 22 项 +python tests/test_think_code_neutral.py # 思考块/代码块中性化 10 项 +python tests/test_file_attach.py # 附件类型判定 +python tests/test_pdf_reader.py # PDF 文本/图片解析 +node tests/test_math_extract.js # 公式提取(前端 JS 逻辑) 39 项 +``` + +**B. GUI 离屏端到端(需 `QT_QPA_PLATFORM=offscreen`)** + +```bash +python tests/smoke_offscreen.py # 主窗口起得来 + 三个基础链路 8 项 +python tests/smoke_mode.py # chat ↔ worker 模式切换 16 项 +python tests/smoke_copy_session.py # 复制会话 UI 全链路 +python tests/smoke_bash_panel.py # 右侧任务面板全链路(含折叠/拖拽调宽) 116 项 +``` + +**C. 需要真机/真 API 的(默认不用跑)** + +`tests/smoke_live_guard.py`、`smoke_midswitch.py`、`smoke_persist.py`、`smoke_probe.py`、 +`smoke_repro_real.py`、`smoke_timeline.py`、`smoke_manual.py`、`diag_live_*.py`、`verify_onscreen.py` +—— 这些是开发期在真窗口/真模型上跑的工具,交接后按需使用。 + +### 3. 基线(本次交接版实测全绿) + +``` +run_tests 41/41 · tool_params 35/35 · compaction 41/41 · copy_session 54/54 +bash_stream 30/30 · error_persist 39/39 · wv2_guard 10/10 · debug_window 22/22 +think_code_neutral 10/10 · math_extract 39/39 +smoke_offscreen 8/8 · smoke_mode 16/16 · smoke_copy_session 全通过 · smoke_bash_panel 116/116 +``` + +### 4. 辅助工具 + +```bash +python tests/check_db_migration.py <某个 .db 或备份 .zip> +# 只读校验:迁移(新增列)后旧库数据是否 100% 原样保留(消息数/父指针/叶子/分支点逐项比对) +``` + +--- + +## 六、已知限制 + +1. **仅 Windows**:WebView2 与 `bash` 工具都按 Windows 语义实现(cmd.exe + taskkill /T)。 +2. **`bash` 工具的真实 shell 是 cmd.exe**(不是 git-bash):`;` 不是命令分隔符、 + `$VAR` 不展开、`cd` 不跨命令保持 —— 系统提示词里已写明这些陷阱与正确写法。 +3. **HTTP 代理/流式**:模型请求走 openai SDK,单次 180s 超时,失败按 2s/4s/8s 重试 3 次。 +4. **打包体积**:默认带 QtWebEngine 兜底,产物约 575 MB;若确定只用 WebView2, + 可从 `haocode.spec` 去掉 `PyQt6.QtWebEngine*` 的 `collect_all`(降到约 110 MB)。 +5. `data/attachments/` 里的图片/PDF 是**文件系统**资源,删库不会删它们;删会话时才会连带清理。 +6. 冻结版与源码版会共用同一个库的**唯一例外**:把 exe 放在源码树的 `dist/` 下时 + (`exe/../../data/` 存在即共用 `chat_history.db`);放到桌面等其它位置则用 exe 同级 `data/`。 +7. **运行时会往项目根目录写诊断产物**(开发期排障用,可直接删): + `compaction_diag.log`(压缩决策日志)、`stream_diag.log`(前端流式体检)、 + `diag_shot_*.png`(每轮回复结束时的画面快照)、`data/debug_session.log`(调试窗口日志)。 + 不想要可自行注释 `ui/views/main_window.py` 里的 `diag_log()` / `diag_shot` 调用点。 + +--- + +## 七、交接版说明(haocode_0 相对原项目做了什么) + +| 动作 | 内容 | +|---|---| +| ✅ 保留 | 全部源码、`SYSTEM_PROMPT.md`、`vendor/webview2/`、18 个 svg、`ui/web/`(含 KaTeX/highlight)、正式测试套件、`haocode.spec` + 打包钩子 | +| 🗑 删除·空文件 | `agents/`(3 个全空)、`workspace/`(4 个全空)、`tools/{registry,conda_env}.py`、`tools/builtin_tools/{file_ops,web_search}.py`、`core/{async_sync,memory_manager,prompt_templates}.py`、`ui/views/components.py`、`ui/assets/`(空 style.qss)、`tests/{test_ast,test_replace}.py`、根目录 0 字节文件 —— **均已确认零引用**(`__init__.py` 属于包结构标记,全部保留) | +| 🗑 删除·测试产物 | 根目录 `diag_shot_*.png`(36)、`*.log`、`_fadechk2.py`、`inspect_*.py`、`read_all_py.py`、`ssh_helper.py`、`tmp_timeout_probe.py`、`flowkit.db`、`stress_report.json` 等;`tests/_tmp*`(81 项,含 `_tmp_resize_vis/` 与全部临时日志) | +| 🗑 删除·数据库 | `data/chat_history.db` 及全部 `.bak/.pre_clean`、`data/attachments/`、`data/debug_session.log`、`data/wv2_cache/` → **首次运行自动新建空库(含初始对话)** | +| 🗑 删除·其它 | `__pycache__/` 全部、旧封装 `geekagent.spec`、未使用的 `untitled.ui` | +| ✏️ 重写 | `requirements.txt`(AST 扫描核对)、`readme.md`(本文件)、`Frame.md`(逐目录细节) | +| ⚠️ 注意 | `data/config.json` 的 `api_key` **已清空**(交接安全),请填入自己的 Key;`.txt` 文本示例按你的要求**全部保留**(`_out.txt`、`_t0.txt`) | + +> 详细到"每个子文件夹/每个文件干什么、关键类与函数叫什么",见 **`Frame.md`**。 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9929022 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,58 @@ +# haocode 运行依赖 +# --------------------------------------------------------------------------- +# 环境:Windows 10/11 x64 + Python 3.10(开发环境为 miniconda env: haocode) +# 安装:pip install -r requirements.txt +# 说明:本清单只列**代码真实 import 到**的第三方包(已用 AST 全量扫描核对), +# 不含开发机上其它项目的包(numpy / matplotlib / pillow / paramiko 等均未使用)。 +# --------------------------------------------------------------------------- + +# ---- GUI 框架(PyQt6)---- +PyQt6==6.10.2 +PyQt6-Qt6==6.10.2 + +# ---- 渲染回退:QtWebEngine(WebView2 不可用 / 无头 / 多实例时自动使用)---- +# 注:WebView2 是首选渲染器,但它依赖微软运行时 + vendor/webview2/ 里的 .NET SDK, +# 不走 pip;QtWebEngine 是本项目自带的兜底渲染路径,必须装。 +PyQt6-WebEngine==6.10.0 +PyQt6-WebEngine-Qt6==6.10.2 + +# ---- 大模型 SDK(OpenAI 兼容接口;DeepSeek / opencode-go / 自建反代均走它)---- +openai==2.26.0 + +# ---- WebView2 绑定:pythonnet(import clr)+ 其加载器(仅 Windows;Linux 自动跳过)---- +pythonnet==3.1.0; sys_platform == "win32" +clr_loader==0.3.1; sys_platform == "win32" + +# ---- PDF 附件解析(ui/views/main_window.py → tools/builtin_tools/pdf_reader.py)---- +PyMuPDF==1.28.0 + +# =========================================================================== +# Linux 源码运行(Ubuntu 22.04/24.04 x64,仅 QtWebEngine;不装 WebView2 任何东西) +# =========================================================================== +# 1) pip install -r requirements.txt # pythonnet/clr_loader 带平台 marker,Linux 自动跳过 +# 2) 安装 QtWebEngine 运行所需的系统库(apt,按需选装): +# sudo apt install -y libnss3 libxkbcommon0 libfontconfig1 libdbus-1-3 \ +# libgl1 libegl1 libasound2t64 # 24.04 用 libasound2t64;22.04 用 libasound2 +# 离屏测试另需:QT_QPA_PLATFORM=offscreen +# 3) 运行:python3.10 main.py +# - 只使用 QtWebEngine;每实例独立 profile(data/webengine/profile__*) +# - root/容器启动若页面空白:显式 QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox" python3.10 main.py +# (启动会打印高可见风险警告;普通桌面不要加) + +# =========================================================================== +# 以下仅在需要时安装 +# =========================================================================== + +# ---- 打包 exe(可选)---- +# PyInstaller==6.21.0 +# pyinstaller-hooks-contrib==2026.6 + +# =========================================================================== +# 不需要 pip 安装的运行时依赖(重要) +# =========================================================================== +# 1) vendor/webview2/ WebView2 .NET SDK(Core.dll)+ webview2loader_x64.dll +# 已随仓库提供,勿删;缺失 → 自动回落 QtWebEngine +# 2) WebView2Loader.dll 项目根目录的 loader(CWD 兜底路径) +# 3) WebView2 Runtime(系统级) Win11 自带;Win10 需安装 Evergreen Runtime +# 4) Node.js 仅 tests/test_math_extract.js 需要(可选) +# 5) pytest 不需要!tests/run_tests.py 自带 harness 并 stub 掉 pytest diff --git a/svg/check.svg b/svg/check.svg new file mode 100644 index 0000000..18ad000 --- /dev/null +++ b/svg/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/svg/chevron_down.svg b/svg/chevron_down.svg new file mode 100644 index 0000000..a7b2834 --- /dev/null +++ b/svg/chevron_down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/svg/chevron_right.svg b/svg/chevron_right.svg new file mode 100644 index 0000000..89e2a27 --- /dev/null +++ b/svg/chevron_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/svg/cross.svg b/svg/cross.svg new file mode 100644 index 0000000..c5d7de0 --- /dev/null +++ b/svg/cross.svg @@ -0,0 +1,3 @@ + + + diff --git a/svg/main.svg b/svg/main.svg new file mode 100644 index 0000000..257f014 --- /dev/null +++ b/svg/main.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/mode_chat.svg b/svg/mode_chat.svg new file mode 100644 index 0000000..09361e1 --- /dev/null +++ b/svg/mode_chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/mode_worker.svg b/svg/mode_worker.svg new file mode 100644 index 0000000..82617bd --- /dev/null +++ b/svg/mode_worker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/model.svg b/svg/model.svg new file mode 100644 index 0000000..71755d3 --- /dev/null +++ b/svg/model.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/panel.svg b/svg/panel.svg new file mode 100644 index 0000000..2f9c682 --- /dev/null +++ b/svg/panel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/svg/panel_right.svg b/svg/panel_right.svg new file mode 100644 index 0000000..804c1af --- /dev/null +++ b/svg/panel_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/svg/provider.svg b/svg/provider.svg new file mode 100644 index 0000000..a24ed70 --- /dev/null +++ b/svg/provider.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/provider2.svg b/svg/provider2.svg new file mode 100644 index 0000000..7d12eef --- /dev/null +++ b/svg/provider2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/provider3.svg b/svg/provider3.svg new file mode 100644 index 0000000..9d84d92 --- /dev/null +++ b/svg/provider3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/send.svg b/svg/send.svg new file mode 100644 index 0000000..7f53b5d --- /dev/null +++ b/svg/send.svg @@ -0,0 +1,3 @@ + + + diff --git a/svg/send_white.svg b/svg/send_white.svg new file mode 100644 index 0000000..81347fb --- /dev/null +++ b/svg/send_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/svg/stop.svg b/svg/stop.svg new file mode 100644 index 0000000..1f7c151 --- /dev/null +++ b/svg/stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/svg/stop_white.svg b/svg/stop_white.svg new file mode 100644 index 0000000..d4cd20b --- /dev/null +++ b/svg/stop_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/svg/upload.svg b/svg/upload.svg new file mode 100644 index 0000000..6b2b62d --- /dev/null +++ b/svg/upload.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_test_env.py b/tests/_test_env.py new file mode 100644 index 0000000..a9ed604 --- /dev/null +++ b/tests/_test_env.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""tests/_test_env.py —— 自动化测试的统一临时环境(P0-01 新增) + +铁律(VERIFICATION.md §凭据与运行数据隔离): + 1. 每个测试进程独立临时目录 + 最小临时配置; + 2. 在 import 任何可能间接加载 MainWindow 的模块之前设置 HAOCODE_CONFIG_FILE; + 3. 在 import MainWindow 之前把 core.db_manager._DEFAULT_DB 指向临时数据库; + 4. 写入、迁移、附件和截图产物只落到临时目录。 + +用法(放在测试文件顶部、任何 UI import 之前): + + from tests._test_env import isolate + isolate("bashpanel", config={"providers": {}, "mode_switch": True}) + +说明: + · 临时目录按 进程 pid 隔离,测试进程互不干扰; + · 默认临时配置为最小可启动结构(空 providers + 测试 provider); + · 本模块不 import 任何 PyQt 模块,可在无 GUI 环境中安全调用。 +""" +import json +import os +import sys +import tempfile + +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_TESTS_DIR) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + + +def default_config() -> dict: + """最小可启动临时配置(不含任何真实凭据)。""" + return { + "providers": { + "testprov": { + "api_key": "test-key-not-real", + "base_url": "http://127.0.0.1:9/v1", + "models": ["test-model"], + "model_contexts": {"test-model": 100000}, + } + }, + "default_provider": "testprov", + "default_model": "test-model", + } + + +def isolate(tag: str = "t", config: dict | None = None) -> dict: + """创建临时配置 + 临时数据库并完成重定向。必须在 import MainWindow 之前调用。 + + 返回 {"base": 临时目录, "config": 临时配置路径, "db": 临时数据库路径}。 + """ + base = os.path.join(tempfile.gettempdir(), + f"haocode_test_{tag}_{os.getpid()}") + os.makedirs(base, exist_ok=True) + + cfg_path = os.path.join(base, "config.json") + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump(config if config is not None else default_config(), + f, ensure_ascii=False, indent=2) + + db_path = os.path.join(base, "chat_history.db") + for p in (db_path, db_path + "-wal", db_path + "-shm"): + if os.path.exists(p): + os.remove(p) + + os.environ["HAOCODE_CONFIG_FILE"] = cfg_path + + # P1-03:QtWebEngine 独立 profile 重定向到临时目录(源码运行才在 data/webengine/) + os.environ.setdefault("HAOCODE_WEBENGINE_PROFILE_DIR", os.path.join(base, "webengine_profile")) + + import core.db_manager as _dbm + _dbm._DEFAULT_DB = db_path + + return {"base": base, "config": cfg_path, "db": db_path} diff --git a/tests/check_db_migration.py b/tests/check_db_migration.py new file mode 100644 index 0000000..8ab2e52 --- /dev/null +++ b/tests/check_db_migration.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +"""用「备份 ZIP 里的真实旧库」验证:迁移新列后旧数据必须 100% 原样保留(只读对比)。 + +用法: python tests/check_db_migration.py +""" +import os +import sys +import json +import shutil +import sqlite3 +import tempfile +import zipfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +ZIP = sys.argv[1] if len(sys.argv) > 1 else r"D:/haocode_backup_20260916_1102.zip" +OUT = os.path.join(tempfile.gettempdir(), "haocode_from_backup.db") +if os.path.exists(OUT): + os.remove(OUT) + +with zipfile.ZipFile(ZIP) as z: + name = [n for n in z.namelist() + if os.path.basename(n) == "chat_history.db"][0] + with z.open(name) as src, open(OUT, "wb") as dst: + shutil.copyfileobj(src, dst) +print("从备份取出:", name, os.path.getsize(OUT), "bytes") + + +def fp(path): + c = sqlite3.connect(path) + c.row_factory = sqlite3.Row + cols = [r[1] for r in c.execute("PRAGMA table_info(messages)")] + d = { + "messages_rows": c.execute("SELECT COUNT(*) FROM messages").fetchone()[0], + "sessions_rows": c.execute("SELECT COUNT(*) FROM sessions").fetchone()[0], + "msg_sum": list(c.execute( + "SELECT COUNT(*), SUM(LENGTH(COALESCE(content,''))), " + "SUM(LENGTH(COALESCE(timeline,''))) FROM messages").fetchone()[:]), + "parents": [tuple(r) for r in c.execute( + "SELECT id, parent_id FROM messages ORDER BY id")], + "leaves": [tuple(r) for r in c.execute( + "SELECT id, current_leaf_msg_id FROM sessions ORDER BY id")], + "branches": sorted((r["parent_id"], r["n"]) for r in c.execute( + "SELECT parent_id, COUNT(*) n FROM messages " + "WHERE parent_id IS NOT NULL GROUP BY parent_id HAVING n>1")), + "roles": sorted((r["role"], r["n"]) for r in c.execute( + "SELECT role, COUNT(*) n FROM messages GROUP BY role")), + "has_new_cols": all(x in cols for x in ("stop_reason", "error_message")), + } + c.close() + return d + + +before = fp(OUT) +print("备份库(迁移前): 消息=%d 会话=%d 分支点=%d 含新列=%s" % ( + before["messages_rows"], before["sessions_rows"], + len(before["branches"]), before["has_new_cols"])) + +import core.db_manager as _dbm # noqa: E402 +_dbm._DEFAULT_DB = OUT +d = _dbm.DBManager(db_path=OUT) # ← 触发迁移 +after = fp(OUT) +print("迁移后 : 消息=%d 会话=%d 分支点=%d 含新列=%s" % ( + after["messages_rows"], after["sessions_rows"], + len(after["branches"]), after["has_new_cols"])) +print() +keys = ("messages_rows", "sessions_rows", "msg_sum", "parents", + "leaves", "branches", "roles") +for k in keys: + print((" OK " if before[k] == after[k] else " DIFF ") + "%-14s" % k) +allok = all(before[k] == after[k] for k in keys) +print() +print("每个会话叶子:", [(r[0][:16], (r[1] or "")[:16]) for r in after["leaves"][:4]], "...") +print(">>> 结论:", "✅ 旧库数据 100% 原样保留(只多两个空列)" + if allok else "❌ 有改动!") +os.remove(OUT) +sys.exit(0 if allok else 1) diff --git a/tests/debug_inject.py b/tests/debug_inject.py new file mode 100644 index 0000000..d25b266 --- /dev/null +++ b/tests/debug_inject.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +"""代理侧调试注入 CLI(与运行中的 app 通过文件通信) + +用法: + python tests/debug_inject.py "备注内容" # 注入 [AGENT] 日志行 + python tests/debug_inject.py --show # 打开独立调试窗口 + python tests/debug_inject.py --hide # 关闭独立调试窗口 + python tests/debug_inject.py --read [N] # 读取会话日志最后 N 行(默认 50) +""" +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from core import debug_log as dl + + +def main(): + args = sys.argv[1:] + if not args: + print(__doc__) + return 1 + if args[0] == "--show": + with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f: + f.write("show") + print(f"已请求打开调试窗口 -> {dl.DEBUG_CMD_PATH}") + return 0 + if args[0] == "--hide": + with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f: + f.write("hide") + print(f"已请求关闭调试窗口 -> {dl.DEBUG_CMD_PATH}") + return 0 + if args[0] == "--read": + n = int(args[1]) if len(args) > 1 else 50 + if not os.path.exists(dl.DEBUG_LOG_PATH): + print("(会话日志尚不存在)") + return 0 + with open(dl.DEBUG_LOG_PATH, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + print(f"===== {os.path.basename(dl.DEBUG_LOG_PATH)} 最后 {min(n, len(lines))} 行 =====") + for l in lines[-n:]: + print(l) + return 0 + # 普通文本 → 注入 [AGENT] + dl.debug_log(args[0], "AGENT") + print(f"已注入 [AGENT]: {args[0]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/diag_live_agent.py b/tests/diag_live_agent.py new file mode 100644 index 0000000..eaedb90 --- /dev/null +++ b/tests/diag_live_agent.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +r"""真实 API 测试:opencode-go / deepseek-v4-flash + 完整 agent 循环(工具执行) +运行: C:\Users\14890\miniconda3\envs\haocode\python.exe -u tests/diag_live_agent.py +""" +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + +from core.agent import (Agent, AgentConfig, ModelConfig, RetryConfig) # noqa: E402 +from core.agent.recovery import AgentRunner # noqa: E402 +from core.agent.stream_fn import openai_stream # noqa: E402 +from core.agent.tools import default_tools # noqa: E402 + +MODEL_NAME = "deepseek-v4-flash" + +cfg = json.load(open(os.path.join(ROOT, "data", "config.json"), encoding="utf-8")) +prov = cfg["providers"]["opencode-go"] + +model = ModelConfig( + provider="opencode-go", name=MODEL_NAME, + context_window=int(prov.get("model_contexts", {}).get(MODEL_NAME, 1000000)), + max_tokens=4096, temperature=0.3, + api_key=prov["api_key"], base_url=prov["base_url"], +) + +with open(os.path.join(ROOT, "SYSTEM_PROMPT.md"), encoding="utf-8") as f: + system_prompt = f.read() + +agent_cfg = AgentConfig( + model=model, + tools=default_tools(), + system_prompt=system_prompt, + tool_context={"cwd": ROOT}, + retry=RetryConfig(max_attempts=2, base_delay_ms=1000), +) +agent = Agent(agent_cfg) +agent.set_stream_fn(openai_stream) + + +def show(e): + if e.type == "message_update" and e.assistant_message_event is not None: + ev = e.assistant_message_event + if ev.type == "text_delta": + print(" [正文] " + ev.text, end="", flush=True) + elif ev.type == "thinking_delta": + print(" [思考] " + ev.text, end="", flush=True) + elif ev.type == "toolcall_delta": + print(f" [tc] {ev.tool_call_field}={ev.tool_call_delta!r}", + end="", flush=True) + elif e.type == "tool_execution_start" and e.tool_call is not None: + print(f"\n [工具开始] {e.tool_call.name} 参数={e.tool_call.arguments}") + elif e.type == "tool_execution_update" and e.arg: + print(" [工具输出] " + str(e.arg), end="", flush=True) + elif e.type == "tool_execution_end" and e.tool_call is not None: + c = e.result.content if e.result else "" + if not isinstance(c, str): + c = "".join(x.get("text", "") for x in c if isinstance(x, dict)) + print(f"\n [工具结束] ok={not e.is_error} 结果={c[:200]!r}") + elif e.type == "agent_end": + print(f"\n [agent_end] stop_reason={e.stop_reason} " + f"error={getattr(e.error, 'message', None)}") + + +agent.subscribe(show) +runner = AgentRunner(agent) + +print("=" * 60) +print(f"实时测试: {model.base_url} / {MODEL_NAME}") +print(f"system prompt: {len(system_prompt)} 字符, tools: {len(default_tools())} 个") +print("=" * 60) + +runner.run("请用 bash 工具执行命令: echo hello-from-haocode && ls,然后告诉我输出结果。") + +print("\n===== 最终消息链 =====") +for m in agent.state.messages: + tc = f" tool_calls={[t.name for t in m.tool_calls]}" if m.tool_calls else "" + print(f"- {m.role}: {(m.content or '')[:100]!r}{tc}") + +ok = any(m.role == "toolResult" for m in agent.state.messages) +print("\n===== 结论:", "✅ 真实 tool_call 被发出并执行" if ok + else "❌ 没有工具执行(可能供应商不支持 tools API,检查是否走了文字兜底)", "=====") +sys.exit(0 if ok else 1) diff --git a/tests/diag_live_onscreen.py b/tests/diag_live_onscreen.py new file mode 100644 index 0000000..afddc14 --- /dev/null +++ b/tests/diag_live_onscreen.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +"""E2E onscreen:真实可见窗口 + 真实 API + 流式过程中多点采样 DOM。 +复现用户环境(非 offscreen,rAF 行为与真实窗口一致)。 +运行: python tests/diag_live_onscreen.py (会在桌面弹出窗口) +""" +import json +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.pop("QT_QPA_PLATFORM", None) # onscreen +os.environ.setdefault("HAOCODE_RENDER", "software") + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtCore import QTimer # noqa: E402 + +from core.agent.types import AssistantMessageEvent # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 + +app = QApplication(sys.argv) +window = None +results = [] + +SNAP_BUF_JS = ( + "(function() {" + " var w = document.getElementById('msg-e2e-text');" + " if (!w) return 'no-wrapper';" + " var segs = w.querySelectorAll('.md-segment');" + " var out = [];" + " for (var i = 0; i < segs.length; i++) {" + " out.push('buf:' + ((segs[i].__buf || '').length)" + " + '/dom:' + ((segs[i].textContent || '').length));" + " }" + " return out.join(' ') || 'no-segs';" + "})()" +) + +SNAP_JS = ( + "(function() {" + " var w = document.getElementById('msg-e2e-text');" + " if (!w) return 'no-wrapper';" + " var segs = w.querySelectorAll('.md-segment');" + " var n = 0;" + " for (var i = 0; i < segs.length; i++)" + " if ((segs[i].textContent || '').trim()) n++;" + " return 'segs=' + segs.length + ' nonempty=' + n +" + " ' chip=' + !!w.querySelector('.tool-chip') +" + " ' streaming=' + w.classList.contains('streaming');" + "})()" +) + +FINAL_JS = """ +(function() { + try { + var w = document.getElementById('msg-e2e-text'); + if (!w) return JSON.stringify({error: 'no wrapper'}); + var tl = w.querySelector('.reply-content'); + var blocks = Array.prototype.map.call(tl.children, + function(el) { return el.className.split(' ')[0]; }); + var segs = w.querySelectorAll('.md-segment'); + var segTexts = Array.prototype.map.call(segs, + function(x) { return (x.textContent || '').slice(0, 80); }); + var chip = w.querySelector('.tool-chip'); + return JSON.stringify({blocks: blocks, segTexts: segTexts, + chip: !!chip, streaming: w.classList.contains('streaming')}); + } catch (e) { return JSON.stringify({error: String(e)}); } +})() +""" + + +def check(name, ok, detail=""): + print(f" {'PASS' if ok else 'FAIL'} {name}" + + (f" [{detail}]" if detail and not ok else "")) + results.append(ok) + + +def step1(): + global window + window = MainWindow() + window.show() + QTimer.singleShot(3500, step2) + + +def step2(): + from core.llm_engine import AgentWorker + import core.llm_engine as le + db = window.db + sess = db.create_session("E2E onscreen 测试") + sid = sess["id"] + mid = "msg-e2e-text" + user_row = db.add_message(sid, "user", "测试问题", None) + window.current_session_id = sid + window.chat_bridge.create_message(mid, "assistant", "", "E2E") + window._active_streams[sid] = { + "msg_id": mid, "content": "", "reasoning": "", + "timeline": [], "tl_kind": None, + "parent_id": user_row["id"], "branch_info": None, "worker": None, + "previous_leaf_id": user_row["id"], + } + + real_stream = le.openai_stream + + def logging_stream(context, model, signal, max_tokens, tools=None): + for kind, payload in real_stream(context, model, signal, + max_tokens, tools): + t = getattr(payload, "type", kind) + if t in ("text_delta", "thinking_delta", "toolcall_delta"): + txt = str(getattr(payload, "text", ""))[:25] + print(f" [stream] {t} {txt!r}", flush=True) + else: + print(f" [stream] {kind}/{t}", flush=True) + yield kind, payload + + le.openai_stream = logging_stream + worker = AgentWorker(provider_name=window.current_provider, + model_name=window.current_model, + openai_messages=[{"role": "user", + "content": "运行命令 echo hello-from-onscreen " + "并告诉我输出内容"}], + enable_tools=True) + + worker.reasoning_received.connect(lambda t: window.on_reasoning_received(sid, t)) + worker.chunk_received.connect(lambda t: window.on_chunk_received(sid, t)) + worker.tool_execution_started.connect( + lambda cid, name, args: window._on_tool_started(sid, cid, name, args)) + worker.tool_execution_updated.connect( + lambda cid, text: window._on_tool_updated(sid, cid, text)) + worker.tool_execution_finished.connect( + lambda cid, name, ok, text: window._on_tool_finished(sid, cid, name, ok, text)) + worker.error_occurred.connect( + lambda err: (print(" [worker error]", err, flush=True), + window.on_error(sid, err))) + worker.finished.connect(lambda: window.on_reply_finished(sid)) + window._active_streams[sid]["worker"] = worker + worker.start() + print("worker started(窗口已可见,观察屏幕)...", flush=True) + + def snapshot(tag): + window.browser.page().runJavaScript( + SNAP_JS, + lambda v, tag=tag: print(f" [snapshot {tag}] {v}", flush=True)) + window.browser.page().runJavaScript( + SNAP_BUF_JS, + lambda v, tag=tag: print(f" [snapshot {tag} BUF] {v}", flush=True)) + + QTimer.singleShot(8000, lambda: snapshot("t+8s")) + QTimer.singleShot(15000, lambda: snapshot("t+15s")) + QTimer.singleShot(25000, lambda: snapshot("t+25s")) + QTimer.singleShot(45000, step3) + + +def step3(): + def got(res): + d = json.loads(str(res)) + if "error" in d: + check("E2E DOM", False, d["error"]) + finish() + return + print(f" blocks = {d['blocks']}") + print(f" segTexts = {d['segTexts']}") + print(f" chip={d['chip']} streaming={d['streaming']}") + check("live 正文段非空", any(t.strip() for t in d["segTexts"]), + str(d["segTexts"])) + check("最终回答含命令输出", + any("hello-from-onscreen" in t for t in d["segTexts"]), + str(d["segTexts"])) + check("工具 chip 存在", d["chip"]) + check("streaming 已收尾", not d["streaming"]) + finish() + + window.browser.page().runJavaScript(FINAL_JS, got) + + +def finish(): + try: + window.db.delete_session(window.current_session_id) + except Exception: + pass + print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: " + f"{sum(results)}/{len(results)} =====") + app.quit() + + +QTimer.singleShot(500, step1) +app.exec() diff --git a/tests/diag_live_text.py b/tests/diag_live_text.py new file mode 100644 index 0000000..4c79a58 --- /dev/null +++ b/tests/diag_live_text.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +"""E2E live 测试:真实 AgentWorker + 假流式 + 真实 MainWindow 信号链路。 +验证 live 时正文 md-segment 是否有内容(用户报告的 bug)。 +运行: QT_QPA_PLATFORM=offscreen python tests/diag_live_text.py +""" +import json +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtCore import QTimer # noqa: E402 + +from core.agent.types import (AssistantMessageEvent, AgentMessage, ToolCall) # noqa: E402 +from core.agent import stream_fn as sf # noqa: E402 +from core.agent.tools import default_tools # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 + +app = QApplication(sys.argv) +window = None +results = [] +CALL_STATE = {"n": 0} + + +def check(name, ok, detail=""): + print(f" {'PASS' if ok else 'FAIL'} {name}" + + (f" [{detail}]" if detail and not ok else "")) + results.append(ok) + + +# ---- 假 stream_fn:第 1 轮 思考+文本+bash 工具调用;第 2 轮 最终回答 ---- +def fake_stream(context, model, signal, max_tokens, tools=None): + CALL_STATE["n"] += 1 + n = CALL_STATE["n"] + if n == 1: + yield ("event", AssistantMessageEvent(type="thinking_delta", text="我先看看")) + yield ("event", AssistantMessageEvent(type="thinking_delta", text="目录。")) + yield ("event", AssistantMessageEvent(type="text_delta", text="我来执行命令")) + yield ("event", AssistantMessageEvent(type="toolcall_delta", + tool_call_index=0, tool_call_field="id", tool_call_delta="call-e2e-1")) + yield ("event", AssistantMessageEvent(type="toolcall_delta", + tool_call_index=0, tool_call_field="name", tool_call_delta="bash")) + yield ("event", AssistantMessageEvent(type="toolcall_delta", + tool_call_index=0, tool_call_field="arguments", + tool_call_delta='{"command": "echo e2e-ok"}')) + time.sleep(0.3) + yield ("final", AgentMessage( + role="assistant", stop_reason="stop", + tool_calls=[ToolCall(id="call-e2e-1", name="bash", + raw_arguments='{"command": "echo e2e-ok"}')])) + else: + for tok in ["最终", "回答", ":任务", "完成。"]: + yield ("event", AssistantMessageEvent(type="text_delta", text=tok)) + time.sleep(0.05) + yield ("final", AgentMessage(role="assistant", stop_reason="stop")) + + +def step1(): + global window + window = MainWindow() + QTimer.singleShot(3500, step2) + + +def step2(): + from core.llm_engine import AgentWorker + db = window.db + sess = db.create_session("E2E 正文测试") + sid = sess["id"] + mid = "msg-e2e-text" + user_row = db.add_message(sid, "user", "测试问题", None) + window.current_session_id = sid + window.chat_bridge.create_message(mid, "assistant", "", "E2E") + window._active_streams[sid] = { + "msg_id": mid, "content": "", "reasoning": "", + "timeline": [], "tl_kind": None, + "parent_id": user_row["id"], "branch_info": None, "worker": None, + "previous_leaf_id": user_row["id"], + } + + # 真实 API + 流事件日志(定位 text_delta 是否到达) + import core.llm_engine as le + real_stream = le.openai_stream + def logging_stream(context, model, signal, max_tokens, tools=None): + n_ev = 0 + for kind, payload in real_stream(context, model, signal, + max_tokens, tools): + t = getattr(payload, "type", kind) + txt = str(getattr(payload, "text", ""))[:30] + if t not in ("text_delta", "thinking_delta", "toolcall_delta"): + print(f" [stream] {kind}/{t}", flush=True) + else: + n_ev += 1 + if n_ev <= 6 or n_ev % 20 == 0: + print(f" [stream] {t} {txt!r}", flush=True) + yield kind, payload + le.openai_stream = logging_stream + worker = AgentWorker(provider_name=window.current_provider, + model_name=window.current_model, + openai_messages=[{"role": "user", + "content": "运行命令 echo hello-from-diag 并告诉我输出"}], + enable_tools=True) + + # 与 send_message 相同的信号连接 + worker.reasoning_received.connect(lambda t: window.on_reasoning_received(sid, t)) + worker.chunk_received.connect(lambda t: window.on_chunk_received(sid, t)) + worker.tool_execution_started.connect( + lambda cid, name, args: window._on_tool_started(sid, cid, name, args)) + worker.tool_execution_updated.connect( + lambda cid, text: window._on_tool_updated(sid, cid, text)) + worker.tool_execution_finished.connect( + lambda cid, name, ok, text: window._on_tool_finished(sid, cid, name, ok, text)) + worker.error_occurred.connect(lambda err: (print(' [worker error]', err), window.on_error(sid, err))) + worker.finished.connect(lambda: window.on_reply_finished(sid)) + + window._active_streams[sid]["worker"] = worker + worker.start() + print("worker started, 等待流结束...") + QTimer.singleShot(40000, step3) + + +def step3(): + # live DOM 检查(不重载 DB) + js = """ + (function() { + try { + var w = document.getElementById('msg-e2e-text'); + if (!w) return JSON.stringify({error: 'no wrapper'}); + var tl = w.querySelector('.reply-content'); + var blocks = Array.prototype.map.call(tl.children, + function(el) { return el.className.split(' ')[0]; }); + var segs = w.querySelectorAll('.md-segment'); + var segTexts = Array.prototype.map.call(segs, + function(x) { return x.textContent; }); + var thinks = w.querySelectorAll('.think-content'); + var thinkTexts = Array.prototype.map.call(thinks, + function(x) { return x.textContent; }); + var chip = w.querySelector('.tool-chip'); + return JSON.stringify({blocks: blocks, segTexts: segTexts, + thinkTexts: thinkTexts, chip: !!chip, + streaming: w.classList.contains('streaming')}); + } catch (e) { return JSON.stringify({error: String(e)}); } + })() + """ + def got(res): + d = json.loads(str(res)) + if "error" in d: + check("E2E DOM", False, d["error"]) + finish() + return + print(f" blocks = {d['blocks']}") + print(f" segTexts = {d['segTexts']}") + print(f" thinkTexts = {d['thinkTexts']}") + print(f" chip = {d['chip']} streaming={d['streaming']}") + check("live 正文段有内容(至少一段非空)", + any((t or "").strip() for t in d["segTexts"]), + str(d["segTexts"])) + check("live 最终回答含命令输出", + any("hello-from-diag" in (t or "") for t in d["segTexts"]), + str(d["segTexts"])) + check("live 思考段有内容(或模型未思考)", + True, str(d["thinkTexts"])[:80]) + check("live 工具 chip 存在", d["chip"]) + check("streaming 已收尾", not d["streaming"]) + # 时间线入库检查 + st = window._active_streams.get(sid := window.current_session_id) + tl = window.db.get_session(window.current_session_id) if False else None + finish() + + window.browser.page().runJavaScript(js, got) + + +def finish(): + try: + window.db.delete_session(window.current_session_id) + except Exception: + pass + print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: " + f"{sum(results)}/{len(results)} =====") + app.quit() + + +QTimer.singleShot(500, step1) +app.exec() diff --git a/tests/diag_panel_scrollbar.py b/tests/diag_panel_scrollbar.py new file mode 100644 index 0000000..4d657c5 --- /dev/null +++ b/tests/diag_panel_scrollbar.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +"""P2-02 诊断:右侧 Bash 面板滚动条与横纵交汇角(离屏) + +测量并断言: + S1 代码框(#bl_code)横滚动条实际厚度 = 8px、sizeHint 一致 + S2 代码框竖滚动条实际厚度 = 8px、sizeHint 一致 + S3 section 滚动区(#bl_scroll)竖滚动条实际厚度 = 8px + S4 面板滚动条箭头 extent = 0(箭头隐藏) + S5 交汇角像素 = 代码框背景 #fbfcfe(无原生亮色 corner 方块) + S6 无泄漏:未命名 QPlainTextEdit 的滚动条仍是原生口径(≠8px、箭头>0) + S7 无泄漏:附件预览滚动条保持自身 6px 口径 +并生成局部截图(面板全貌 + 代码框角落放大)打印测量值。 + +运行: QT_QPA_PLATFORM=offscreen python tests/diag_panel_scrollbar.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成 +from tests._test_env import isolate # noqa: E402 +_TMP = isolate("panelscroll", config={"providers": {}, "mode_switch": True}) +_DB_TMP = _TMP["db"] +_CFG_TMP = _TMP["config"] + +from PyQt6.QtWidgets import (QApplication, QPlainTextEdit, QStyle, # noqa: E402 + QStyleOptionSlider) # noqa: E402 +from PyQt6.QtTest import QTest # noqa: E402 +from PyQt6.QtCore import Qt # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 + +app = QApplication(sys.argv) +ok = True +OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "docs", "agent-handoff", "evidence") + + +def check(name, cond, extra=""): + global ok + print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" {extra}"), flush=True) + if not cond: + ok = False + + +def settle(ms=300): + for _ in range(int(ms / 20) + 1): + app.processEvents() + QTest.qWait(20) + + +def arrow_extent(sb, orient=Qt.Orientation.Vertical): + """滚动条箭头子控件(sub-line)的实际尺寸(px):QSS 把 add-line/sub-line 置 0 后应为 0。 + 用 sb.style()(样式表代理风格)才能反映 QSS 效果;PyQt6 参数序 = (cc, opt, sc, widget)。 + 返回 -2 表示无法测量(样式代理缺失等),调用方不得把 -2 当作 0。""" + try: + st = sb.style() + if orient == Qt.Orientation.Vertical: + return st.subControlRect(QStyle.ComplexControl.CC_ScrollBar, + QStyleOptionSlider(), + QStyle.SubControl.SC_ScrollBarSubLine, sb).height() + return st.subControlRect(QStyle.ComplexControl.CC_ScrollBar, + QStyleOptionSlider(), + QStyle.SubControl.SC_ScrollBarSubLine, sb).width() + except Exception as e: + print(f" (arrow_extent 测量异常: {e})", flush=True) + return -1 + + +window = MainWindow() +window.resize(1400, 800) +window.show() +settle(400) +panel = window.bash_panel +panel.expand_btn.click() +settle(500) + +# ---- 造数据:一个展开的层,out_box 同时触发横/纵滚动条 ---- +panel.on_started("sc1", "bash", {"command": "python long_report.py --all --verbose"}) +panel.on_finished("sc1", "bash", True, + "$ python long_report.py --all --verbose\n" + + "\n".join(f"line-{i:03d}" for i in range(60)) + + "\n[exit 0] (1.2s)") +settle(300) +lay = panel._layers["sc1"] +lay.toggle() +settle(200) +# 超宽单行(NoWrap)→ 横向滚动条;再追加 60 行 → 同时触发纵向滚动条 +panel._layers["sc1"].out_box.setPlainText("X" * 3000 + "\n" + + "\n".join(f"tail-{i:03d}" for i in range(60))) +settle(200) + +ob = lay.out_box +sbh = ob.horizontalScrollBar() +sbv = ob.verticalScrollBar() +check("S0.1 前置:横滚动条可见(超宽单行)", sbh.isVisible(), str(sbh.isVisible())) +check("S0.2 前置:竖滚动条可见(60+ 行超出 230px 上限)", sbv.isVisible(), str(sbv.isVisible())) + +# ---- S1/S2 代码框滚动条厚度 ---- +check("S1 代码框横滚动条实际厚度 = 8px", sbh.height() == 8, f"h={sbh.height()}") +check("S1b 代码框横滚动条 sizeHint 厚 = 8px", sbh.sizeHint().height() == 8, + f"{sbh.sizeHint().height()}") +check("S2 代码框竖滚动条实际厚度 = 8px", sbv.width() == 8, f"w={sbv.width()}") +check("S2b 代码框竖滚动条 sizeHint 宽 = 8px", sbv.sizeHint().width() == 8, + f"{sbv.sizeHint().width()}") + +# ---- S3 section 滚动区竖滚动条厚度 ---- +# 让运行中栏溢出:再加 12 个已完成层(复用 P11 的溢出手法不必,层数即可) +for i in range(12): + cid = f"scf{i}" + panel.on_started(cid, "bash", {"command": f"cmd-{cid}"}) + panel.on_finished(cid, "bash", True, f"$ cmd-{cid}\nok\n[exit 0] (0.1s)") +settle(300) +sdv = panel.sec_done.scroll.verticalScrollBar() +check("S0.3 前置:已完成栏溢出、竖滚动条可见", sdv.isVisible(), str(sdv.isVisible())) +check("S3 section 竖滚动条实际厚度 = 8px", sdv.width() == 8, f"w={sdv.width()}") + +# ---- S4 箭头 extent ---- +print(f" 测量:代码框 H 箭头 extent = {arrow_extent(sbh, Qt.Orientation.Horizontal)}px, " + f"V = {arrow_extent(sbv)}px; section V = {arrow_extent(sdv)}px", + flush=True) +check("S4 代码框横滚动条箭头 extent = 0", arrow_extent(sbh, Qt.Orientation.Horizontal) == 0, + str(arrow_extent(sbh, Qt.Orientation.Horizontal))) +check("S4b 代码框竖滚动条箭头 extent = 0", arrow_extent(sbv) == 0, str(arrow_extent(sbv))) +check("S4c section 竖滚动条箭头 extent = 0", arrow_extent(sdv) == 0, str(arrow_extent(sdv))) + +# ---- S5 交汇角(render 到透明 pixmap 取样;offscreen 下文档区背景不填充, +# 但样式子控件(滚动条/边框/corner)正常渲染,可直接断言)---- +from PyQt6.QtGui import QPainter, QPixmap # noqa: E402 +w, h = ob.width(), ob.height() +pm = QPixmap(w, h); pm.fill(Qt.GlobalColor.transparent) +pr = QPainter(pm); ob.render(pr); pr.end() +img = pm.toImage() +def px_at(x, y): + c = img.pixelColor(x, y) + return (c.red(), c.green(), c.blue()) +CORNER_BG = (0xfb, 0xfc, 0xfe) # #bl_code 背景色 = QSS 里 corner 规则的目标色 +n_bg = n_white = 0 +for dy in range(8): + for dx in range(8): + c = px_at(w - 1 - dx, h - 1 - dy) + if c == CORNER_BG: + n_bg += 1 + if c == (255, 255, 255): + n_white += 1 +# 健全性:样式确已作用到该框(文本色 #243043 与 handle #d0d0d0 应在渲染图中出现) +n_text = sum(1 for y in range(0, h, 2) for x in range(0, w, 2) + if px_at(x, y) == (0x24, 0x30, 0x43)) +n_handle = sum(1 for y in range(h - 10, h) for x in range(0, w - 12, 2) + if px_at(x, y) == (0xd0, 0xd0, 0xd0)) +print(f" 测量:corner 8x8 内 #fbfcfe 像素 = {n_bg},亮白(255,255,255)像素 = {n_white}," + f"文本色像素 = {n_text},handle色像素 = {n_handle}", flush=True) +check("S5 健全性:样式已作用于该框(文本色出现)", n_text > 0, f"n_text={n_text}") +check("S5b 健全性:handle #d0d0d0 出现在横滚动条带", n_handle > 0, f"n_handle={n_handle}") +check("S5c 交汇角渲染出 #fbfcfe(= 代码框背景,::corner 规则生效)", + n_bg >= 1, f"n_bg={n_bg}") +check("S5d 交汇角无原生亮白方块(255,255,255)", n_white == 0, f"n_white={n_white}") +img.save(os.path.join(OUT_DIR, "p2-02-outbox-render.png")) + +# ---- S6 无泄漏:未命名 QPlainTextEdit 仍为原生口径 ---- +probe = QPlainTextEdit() +probe.setPlainText("Y" * 3000) +probe.resize(200, 120) +probe.show() +settle(150) +psb = probe.horizontalScrollBar() +probe_sb_extent = arrow_extent(psb, Qt.Orientation.Horizontal) +check("S6 未命名代码框横滚动条非面板口径(原生厚≠8 或 有箭头)", + (psb.height() != 8) or probe_sb_extent > 0, + f"h={psb.height()} extent={probe_sb_extent}") +probe.close() + +# ---- S7 无泄漏:附件预览滚动条保持自身 6px 口径 ---- +att_sb = window.attachment_scroll_area.horizontalScrollBar() +att_hint_h = att_sb.sizeHint().height() +check("S7 附件预览横滚动条保持 6px(自身 QSS 未被面板规则覆盖)", + att_hint_h == 6, f"sizeHint.h={att_hint_h}") + +# ---- 截图(局部:面板全貌 + 代码框角落放大 4x)---- +# 注意:offscreen 下 ob.grab() 的文档区不填充(黑图),角落放大图从 S5 的 +# render 图(样式子控件已正常渲染)裁出,才有证据价值 +os.makedirs(OUT_DIR, exist_ok=True) +panel_path = os.path.join(OUT_DIR, "p2-02-panel.png") +panel.grab().save(panel_path) +crop = img.copy(max(0, w - 60), max(0, h - 60), 60, 60) +scaled = crop.scaled(240, 240, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation) +corner_path = os.path.join(OUT_DIR, "p2-02-codebox-corner-4x.png") +scaled.save(corner_path) +print(f" 截图:{panel_path}", flush=True) +print(f" 截图:{corner_path}(代码框右下角 60x60 → 4x)", flush=True) + +# ---- 收尾测量汇总 ---- +print(f"\n 汇总:代码框 H={sbh.height()}px V={sbv.width()}px | " + f"section V={sdv.width()}px | corner #fbfcfe 像素={n_bg} | " + f"未命名框 H={psb.height()}px extent={probe_sb_extent} | 附件 H hint={att_hint_h}px", + flush=True) + +print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True) +if os.path.exists(_DB_TMP): + os.remove(_DB_TMP) +if os.path.exists(_CFG_TMP): + os.remove(_CFG_TMP) +# offscreen 铁律:os._exit 强制收尾(QtWebEngine 子进程可能不回收) +os._exit(0 if ok else 1) diff --git a/tests/diag_rename_overlay.py b/tests/diag_rename_overlay.py new file mode 100644 index 0000000..677acf2 --- /dev/null +++ b/tests/diag_rename_overlay.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- +"""P2-03 结构+行为诊断:重命名遮罩 = 独立顶层透明窗(可盖住 WebView2 原生子窗)。 + +覆盖修复目标与硬约束: + R1 结构:顶层窗(非 bg_widget 子控件)、Tool+无边框、WA_TranslucentBackground、WA_DeleteOnClose + R2 几何:覆盖主窗口客户区(标题栏/窗口控制不被盖)、卡片居中、输入框初始全选 + R3 跟随:主窗口移动/缩放/窗口状态变化 → 遮罩同步(move/resize/WindowStateChange 事件过滤器) + R4 行为:Enter 提交(renamed 信号→DB+侧栏)、Esc 关闭、点空白关闭、✕ 关闭、取消关闭 + R5 释放:关闭后顶层窗口消失、无残留(deleteLater + 事件过滤器卸载) + R6 焦点(软检查,仅打印):关闭后焦点回主窗口 + +隔离 + offscreen;os._exit 收尾。 +用法: QT_QPA_PLATFORM=offscreen HAOCODE_RENDER=software .venv/Scripts/python.exe tests/diag_rename_overlay.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu") + +from tests._test_env import isolate +isolate() + +from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6.QtTest import QTest +from ui.views.main_window import MainWindow, RenameOverlay + +results = [] + + +def check(name, cond, info=""): + ok = bool(cond) + results.append((name, ok)) + print(f" [{'PASS' if ok else 'FAIL'}] {name} {info}", flush=True) + + +def settle(ms=120): + t0 = time.time() + while (time.time() - t0) * 1000 < ms: + QtWidgets.QApplication.processEvents() + time.sleep(0.01) + # offscreen 无真实事件循环:processEvents 不处理 DeferredDelete,显式冲刷(生产中事件循环常驻,deleteLater 正常) + QtCore.QCoreApplication.sendPostedEvents(None, QtCore.QEvent.Type.DeferredDelete) + QtWidgets.QApplication.processEvents() + + +def find_overlay(): + for w in QtWidgets.QApplication.topLevelWidgets(): + if isinstance(w, RenameOverlay): + return w + return None + + +def main(): + app = QtWidgets.QApplication(sys.argv) + window = MainWindow() + window.resize(1400, 800) + window.show() + settle(400) + + sid = window.db.create_session("旧标题A")["id"] + window.rebuild_sidebar() + settle(150) + + # ---------- 打开遮罩 ---------- + window._rename_session(sid) + settle(250) # 含 150ms 入场动画 + ov = find_overlay() + check("R0.1 overlay 已创建且为顶层窗口", ov is not None, + "" if ov is not None else "topLevelWidgets 中找不到 RenameOverlay") + if ov is None: + print(f"\n{'='*60}\nRESULT: 1 FAIL -> FAIL\n{'='*60}", flush=True) + os._exit(1) + + # ---------- R1 结构 ---------- + print("R1 结构(独立顶层透明窗)", flush=True) + check("R1.1 isWindow()", ov.isWindow()) + check("R1.2 自身即顶层窗口(非 bg_widget 内嵌子控件)", ov.window() is ov, + f"window() is ov={ov.window() is ov}") + check("R1.3 非主窗口自身", ov is not window) + check("R1.4 Tool 窗(不入任务栏)", bool(ov.windowFlags() & QtCore.Qt.WindowType.Tool)) + check("R1.5 无边框", bool(ov.windowFlags() & QtCore.Qt.WindowType.FramelessWindowHint)) + check("R1.6 WA_TranslucentBackground", ov.testAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground)) + check("R1.7 WA_DeleteOnClose", ov.testAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose)) + check("R1.8 主窗口未设 WA_TranslucentBackground(保持原生不透明底)", + not window.testAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground)) + + # ---------- R2 几何 ---------- + print("R2 几何(覆盖客户区,标题栏可操作)", flush=True) + exp_tl = window.mapToGlobal(window.rect().topLeft()) + exp_size = window.rect().size() + g = ov.geometry() + check("R2.1 覆盖客户区左上角", abs(g.left() - exp_tl.x()) <= 2 and abs(g.top() - exp_tl.y()) <= 2, + f"overlay=({g.left()},{g.top()}) expect=({exp_tl.x()},{exp_tl.y()})") + check("R2.2 覆盖客户区尺寸", abs(g.width() - exp_size.width()) <= 2 and abs(g.height() - exp_size.height()) <= 2, + f"overlay={g.width()}x{g.height()} expect={exp_size.width()}x{exp_size.height()}") + fx, fy = (g.width() - ov.form.width()) // 2, (g.height() - ov.form.height()) // 2 + check("R2.3 卡片居中", abs(ov.form.x() - fx) <= 2 and abs(ov.form.y() - fy) <= 2, + f"form=({ov.form.x()},{ov.form.y()}) expect=({fx},{fy})") + check("R2.4 输入框初始全选", ov.input.hasSelectedText(), f"text={ov.input.text()!r}") + check("R2.5 输入框预填旧标题", ov.input.text() == "旧标题A", f"text={ov.input.text()!r}") + + # ---------- R3 跟随 ---------- + print("R3 跟随(move/resize/状态变化)", flush=True) + base = window.pos() + window.move(base.x() + 150, base.y() + 80) + settle(150) + exp_tl2 = window.mapToGlobal(window.rect().topLeft()) + check("R3.1 主窗口移动→遮罩跟随", abs(ov.geometry().left() - exp_tl2.x()) <= 2 + and abs(ov.geometry().top() - exp_tl2.y()) <= 2, + f"overlay_tl=({ov.geometry().left()},{ov.geometry().top()}) expect=({exp_tl2.x()},{exp_tl2.y()})") + + window.resize(1200, 700) + settle(150) + g2 = ov.geometry() + check("R3.2 主窗口缩放→遮罩同步尺寸", + abs(g2.width() - 1200) <= 4 and abs(g2.height() - 700) <= 4, + f"overlay={g2.width()}x{g2.height()} expect=1200x700") + fx2 = (g2.width() - ov.form.width()) // 2 + check("R3.3 缩放后卡片重新居中", abs(ov.form.x() - fx2) <= 2, f"form.x={ov.form.x()} expect={fx2}") + + # WindowStateChange 分支(最大化/还原走同一条 _sync_geometry 路径;offscreen 直接投递事件验证分支) + before = ov.geometry() + QtWidgets.QApplication.sendEvent(window, QtCore.QEvent(QtCore.QEvent.Type.WindowStateChange)) + QtWidgets.QApplication.processEvents() + check("R3.4 WindowStateChange 分支不崩溃且几何仍正确", + abs(ov.geometry().width() - 1200) <= 4 and ov is find_overlay(), + f"geometry={ov.geometry().width()}x{ov.geometry().height()}") + + # ---------- R4 行为 ---------- + print("R4 行为", flush=True) + # R4.1 Enter 提交 → renamed 信号 → DB + 侧栏 + ov.input.setText("P203新会话名") + ov.confirm() + settle(250) + sessions = {s["id"]: s for s in window.db.get_all_sessions()} + check("R4.1a confirm 后 DB 标题已更新", sessions.get(sid, {}).get("title") == "P203新会话名", + f"db_title={sessions.get(sid, {}).get('title')!r}") + side_ok = False + for i in range(window.history_list.count()): + it = window.history_list.item(i) + if it and it.data(QtCore.Qt.ItemDataRole.UserRole) == sid: + w = window.history_list.itemWidget(it) + if w and hasattr(w, "title_label") and w.title_label.text() == "P203新会话名": + side_ok = True + check("R4.1b confirm 后侧栏标题已更新", side_ok) + check("R4.1c 提交后 overlay 已从顶层窗口消失", find_overlay() is None) + + # R4.2 空标题 confirm 不提交(只关闭) + window._rename_session(sid) + settle(200) + ovz = find_overlay() + if ovz: + ovz.input.clear() + ovz.confirm() + settle(200) + sessions = {s["id"]: s for s in window.db.get_all_sessions()} + check("R4.2 空标题 confirm 不改 DB 标题", + sessions.get(sid, {}).get("title") == "P203新会话名", + f"db_title={sessions.get(sid, {}).get('title')!r}") + + check("R4.2b 空标题确认后 overlay 已消失", find_overlay() is None) + + # R4.3 Esc 关闭 + window._rename_session(sid) + settle(200) + ov2 = find_overlay() + check("R4.3a 重开 overlay", ov2 is not None) + if ov2: + QtWidgets.QApplication.sendEvent( + ov2, QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress, QtCore.Qt.Key.Key_Escape, + QtCore.Qt.KeyboardModifier.NoModifier, "Esc")) + QtWidgets.QApplication.sendEvent( + ov2, QtGui.QKeyEvent(QtCore.QEvent.Type.KeyRelease, QtCore.Qt.Key.Key_Escape, + QtCore.Qt.KeyboardModifier.NoModifier, "Esc")) + QtWidgets.QApplication.processEvents() + settle(200) + check("R4.3b Esc 关闭", find_overlay() is None) + + # R4.4 点空白关闭 + window._rename_session(sid) + settle(200) + ov3 = find_overlay() + check("R4.4a 重开 overlay", ov3 is not None) + if ov3: + QTest.mouseClick(ov3, QtCore.Qt.MouseButton.LeftButton, + QtCore.Qt.KeyboardModifier.NoModifier, QtCore.QPoint(10, 10)) + settle(200) + check("R4.4b 点空白关闭", find_overlay() is None) + + # R4.5 ✕ 关闭 + window._rename_session(sid) + settle(200) + ov4 = find_overlay() + check("R4.5a 重开 overlay", ov4 is not None) + if ov4: + btn_x = None + for b in ov4.form.findChildren(QtWidgets.QPushButton): + if b.text() == "✕": + btn_x = b + break + check("R4.5b ✕ 按钮存在", btn_x is not None) + if btn_x: + btn_x.click() + settle(200) + check("R4.5c ✕ 关闭", find_overlay() is None) + + # R4.6 取消按钮关闭 + window._rename_session(sid) + settle(200) + ov5 = find_overlay() + if ov5: + for b in ov5.form.findChildren(QtWidgets.QPushButton): + if b.text() == "取消": + b.click() + break + settle(200) + check("R4.6 取消按钮关闭", find_overlay() is None) + + # R4.7 非确认关闭(Esc/空白/✕/取消)均不改标题 + sessions = {s["id"]: s for s in window.db.get_all_sessions()} + check("R4.7 非确认关闭不改 DB 标题", + sessions.get(sid, {}).get("title") == "P203新会话名", + f"db_title={sessions.get(sid, {}).get('title')!r}") + + # ---------- R5 释放 ---------- + print("R5 释放(无残留)", flush=True) + check("R5.1 无残留 rename_form 顶层窗口", + not [w for w in QtWidgets.QApplication.topLevelWidgets() + if w.findChild(QtWidgets.QFrame, "rename_form") is not None]) + check("R5.2 主窗口仍存活可用", window.isVisible() and window.db.get_all_sessions() is not None) + + # ---------- R6 焦点(软检查) ---------- + print("R6 焦点回主窗口(软检查)", flush=True) + window.activateWindow() + settle(200) + print(f" [INFO] window.isActiveWindow()={window.isActiveWindow()} " + f"(offscreen 下不可靠,仅记录;真实机器人工走查确认)", flush=True) + + window.close() + app.processEvents() + ok = all(o for _, o in results) + print(f"\n{'='*60}", flush=True) + print(f"RESULT: {sum(o for _, o in results)}/{len(results)} -> {'ALL PASS' if ok else 'FAIL'}", flush=True) + print(f"{'='*60}", flush=True) + sys.stdout.flush() + os._exit(0 if ok else 1) + + +if __name__ == "__main__": + try: + main() + except SystemExit: + raise + except BaseException: + import traceback + traceback.print_exc() + sys.stdout.flush() + os._exit(1) diff --git a/tests/diag_render_scale.py b/tests/diag_render_scale.py new file mode 100644 index 0000000..835f940 --- /dev/null +++ b/tests/diag_render_scale.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +"""P1-01 渲染窗口 400 条消息规模诊断(offscreen;临时 DB + 临时配置;不启动真实 LLM) + +运行: QT_QPA_PLATFORM=offscreen python tests/diag_render_scale.py [N] + N 默认 400(位置参数可覆盖) + +验证项(manual 模式保证确定性;末尾追加 auto 模式抽查): + 1. 初始窗口:.message-wrapper == min(N, size) 且 ≤ size(默认 40); + 窗口 = 最新 size 条;load-older 可见、load-newer 隐藏; + 2. 向上分页至头部:每页锚点误差 ≤ 2px(绝对顶部例外:scrollTop==0 且露出新页); + 全程窗口 ≤ size;到达头部后 hasMoreOlder=false; + 全部 N 条消息可达(各步渲染 id 并集 == 链 id 集),无重复 id; + 3. 自头部向下回翻 2 页:锚点稳定(≤2px); + 4. 规模报告:DOM 节点总数、页面高度、每页耗时采样(ms); + 5. auto 模式抽查:切 auto + 滚到顶部 → 自动补页发生。 + +调试铁律:本脚本自带总超时(QTimer 300s),bash 侧以 timeout=360 运行。 +""" +import json +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" + +# 铁律:临时 DB + 临时配置(含 render_window_mode/size),先于任何 UI import +from tests._test_env import isolate, default_config # noqa: E402 + +N = int(sys.argv[1]) if len(sys.argv) > 1 else 400 +_SIZE = 40 +_PAGE = _SIZE // 2 # 页大小 = 半窗(与 main_window._rw_page_size 一致) +_cfg = dict(default_config()) +_cfg["render_window_mode"] = "manual" # 确定性诊断;auto 行为末尾单独抽查 +_cfg["render_window_size"] = _SIZE +_env = isolate("render_scale", config=_cfg) + +import core.db_manager as _dbm # noqa: E402 +from core.db_manager import DBManager # noqa: E402 + +# ---------- 造数:N 条链(含附件用户消息 / assistant 时间线 / 一个分支兄弟) ---------- +db = DBManager() +sess = db.create_session(title=f"P1-01 scale {N}") +sid = sess["id"] +parent = None +chain_ids = [] +for i in range(N): + role = "user" if i % 2 == 0 else "assistant" + content = f"消息 {i} —— " + "这是一段用于撑起 DOM 高度的填充段落。" * 6 + atts = None + if role == "user" and i % 50 == 0: + atts = json.dumps({"user_text": content, "attachments": [ + {"type": "pdf", "mode": "text", "name": f"spec_{i}.pdf", + "size_kb": 12, "pages": 3, "lines": 40, "content": "文本附件内容"} + ]}, ensure_ascii=False) + timeline = None + if role == "assistant" and i % 75 == 3: + timeline = json.dumps([ + {"t": "think", "text": f"思考片段 {i}"}, + {"t": "text", "text": f"时间线正文 {i}"}, + ], ensure_ascii=False) + msg_id = f"scale-{i}" + db.add_message(session_id=sid, role=role, content=content, parent_id=parent, + msg_id=msg_id, attachment_metadata=atts, timeline=timeline) + parent = msg_id + chain_ids.append(msg_id) + # 分支兄弟:必须插在链中间(add_message 会把新消息设为叶子, + # 若放在主链之后会抢走叶子、截断可见链) + if i == 298 and N > 300: + db.add_message(session_id=sid, role="assistant", content="分支兄弟回复", + parent_id=parent, msg_id="scale-branch-sib") +db.mark_session_has_messages(sid) + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtCore import QTimer # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 + +app = QApplication(sys.argv) +results = [] + + +def check(name, fn): + try: + fn() + print(f" PASS {name}") + results.append(True) + except Exception as e: + import traceback + traceback.print_exc() + print(f" FAIL {name}: {e}") + results.append(False) + + +window = None +js = {"done": False} + + +def _run_js(code, timeout_s=15): + result = {"val": None, "done": False} + + def on_ret(val): + if isinstance(val, str): + try: + val = json.loads(val) + except Exception: + pass + result["val"] = val + result["done"] = True + + if hasattr(window.browser, "execute_js_async"): + window.browser.execute_js_async(code, on_ret) + else: + window.browser.page().runJavaScript(code, on_ret) + t0 = time.time() + while not result["done"] and time.time() - t0 < timeout_s: + app.processEvents() + time.sleep(0.02) + assert result["done"], f"JS 执行超时: {code[:60]}" + return result["val"] + + +def wait_until(cond_js, timeout_s=30, desc=""): + t0 = time.time() + while time.time() - t0 < timeout_s: + if _run_js(cond_js, timeout_s=5): + return True + time.sleep(0.1) + raise AssertionError(f"等待超时: {desc}") + + +page_metrics = {"pages": [], "heights": [], "dom_nodes": []} + + +def try_load(): + global window + window = MainWindow() + # offscreen 零视口(innerHeight=0)→ 给真实尺寸,滚动/锚点几何才有效 + window.resize(1400, 950) + window.show() + for _ in range(10): + app.processEvents() + time.sleep(0.05) + # 等真实视口 + wait_until("window.innerHeight > 0 && window.innerWidth > 0", 30, "视口尺寸") + # 等 JS 就绪 + 首轮(可能零视口)渲染完成 + wait_until("window.jsReady === true", 30, "jsReady") + wait_until(f"document.querySelectorAll('#chat-container .message-wrapper').length >= {_SIZE}", + 60, "首轮渲染") + # 真实视口下走完整 load_messages_to_web 路径重新窗口化加载 + window.load_messages_to_web(window.current_session_id, show_loading=False) + wait_until(f"rwState !== null && rwState.generation >= 2 && " + f"document.querySelectorAll('#chat-container .message-wrapper').length >= {_SIZE}", + 60, "视口重载") + wait_until("(window.__rwPageRendering === false && document.getElementById('load-older') !== null) ? 1 : 0", + 10, "初始窗口收尾") + run_checks() + + +def run_checks(): + sel = "#chat-container .message-wrapper" + + # ---- 1) 初始窗口 ---- + def initial(): + cnt = _run_js(f"document.querySelectorAll('{sel}').length") + assert cnt == _SIZE, f"初始窗口 {cnt} != {_SIZE}" + st = _run_js("JSON.stringify({len: rwState.order.length, hidO: rwState.hiddenOlder, " + "hidN: rwState.hiddenNewer, older: rwState.hasMoreOlder, newer: rwState.hasMoreNewer, " + "mode: rwState.mode, size: rwState.size})") + assert st["mode"] == "manual" and st["size"] == _SIZE, st + assert st["len"] == _SIZE and st["hidO"] == N - _SIZE, st + assert st["newer"] is False and st["older"] is True, st + assert _run_js("document.getElementById('load-older').hidden") is False + assert _run_js("document.getElementById('load-newer').hidden") is True + # 窗口 = 最新 size 条 + first = _run_js(f"document.querySelector('{sel}').id") + assert first == chain_ids[N - _SIZE], first + # 初始贴底 + assert _run_js("isNearBottom()"), "初始窗口未对齐底部" + + check(f"初始窗口 = 最新 {_SIZE} 条(链长 {N})", initial) + + # ---- 2a) 中部锚点保持(真实"窗口中部换页"路径,误差 ≤2px) ---- + seen = set(chain_ids[-_SIZE:]) + anchor_errors = [] + + def page_older_once(): + """执行一次向上换页(调用方保证视口位置);断言窗口上限与锚点。""" + before = _run_js("JSON.stringify(rwCaptureAnchor() || {})") + before = json.loads(before) if isinstance(before, str) else (before or {}) + scroll_before = _run_js("window.scrollY") + t0 = time.time() + _run_js("rwRequestPage('older')") + # 等待 pending 消费 + 批次渲染收尾 + wait_until("rwState.pending === null && window.__rwPageRendering === false", + 30, "换页完成") + dt = (time.time() - t0) * 1000 + page_metrics["pages"].append(dt) + cnt = _run_js(f"document.querySelectorAll('{sel}').length") + assert cnt <= _SIZE, f"窗口超限 {cnt}" + if before.get("msgId"): + el = _run_js(f"document.getElementById({json.dumps(before['msgId'])}) ? " + f"document.getElementById({json.dumps(before['msgId'])}).getBoundingClientRect().top + window.scrollY : null") + assert el is not None, f"锚点消息被裁剪: {before['msgId']}" + if scroll_before <= 1: + # 绝对顶部:停在 0 露出新页(需求定义的例外:不做锚点断言) + assert _run_js("window.scrollY") <= 1, "绝对顶部未保持" + anchor_errors.append(0.0) + else: + expect = scroll_before + (el - before["docTop"]) + actual = _run_js("window.scrollY") + err = abs(expect - actual) + anchor_errors.append(err) + assert err <= 2.0, f"锚点误差 {err}px (expect={expect} actual={actual})" + else: + anchor_errors.append(0.0) + ids_now = _run_js(f"Array.from(document.querySelectorAll('{sel}')).map(e => e.id)") + for i in ids_now: + seen.add(i) + page_metrics["dom_nodes"].append(_run_js("document.querySelectorAll('body *').length")) + page_metrics["heights"].append(_run_js("document.body.scrollHeight")) + + def anchor_middle(): + # 滚到窗口中部(35% 处):既非绝对顶部也非贴底 → 走完整锚点数学 + _run_js("window.scrollTo(0, Math.max(1, document.body.scrollHeight * 0.15)); 1") + time.sleep(0.2) + app.processEvents() + scroll_before = _run_js("window.scrollY") + assert scroll_before > 10, f"未到中部: {scroll_before}" + assert not _run_js("isNearBottom()"), "中部位置不应贴底(防振荡前置条件)" + page_older_once() + st = _run_js("JSON.stringify({hidO: rwState.hiddenOlder, hidN: rwState.hiddenNewer})") + assert st["hidN"] > 0, f"向上换页后应裁出较新隐藏区(无振荡): {st}" + assert st["hidO"] == N - _SIZE - _PAGE, f"窗口应上移 _PAGE 条: {st}" + + check("中部锚点保持(误差≤2px,无 newer 振荡)", anchor_middle) + + # ---- 2b) 自顶部循环向上分页至头部(真实"load older"点击路径) ---- + def page_up_to_head(): + _run_js("window.scrollTo(0, 0); 1") + time.sleep(0.2) + app.processEvents() + steps = 0 + while True: + if not _run_js("rwState.hasMoreOlder"): + break + page_older_once() + steps += 1 + assert steps <= N // _SIZE + 10, "换页次数异常(疑似死循环)" + first = _run_js(f"document.querySelector('{sel}').id") + assert first == chain_ids[0], f"未到达头部: {first}" + + check("自顶部向上分页至头部(绝对顶部例外)", page_up_to_head) + + def head_state(): + st = _run_js("JSON.stringify({len: rwState.order.length, hidO: rwState.hiddenOlder, " + "hidN: rwState.hiddenNewer, older: rwState.hasMoreOlder, newer: rwState.hasMoreNewer})") + assert st["hidO"] == 0 and st["older"] is False, st + assert st["newer"] is True and st["hidN"] == N - _SIZE, st + # 全链可达 + 无重复 + all_ids = _run_js(f"Array.from(document.querySelectorAll('{sel}')).map(e => e.id)") + assert len(all_ids) == len(set(all_ids)), "DOM 出现重复消息 id" + assert len(seen) == N, f"可达 {len(seen)}/{N} 条" + + check("头部状态 + 全链可达无重复", head_state) + + # ---- 3) 自顶部向下回翻 2 页(窗口下移,视口留在顶部) ---- + def page_down_two(): + for k in range(2): + _run_js("window.scrollTo(0, 0); 1") + time.sleep(0.15) + app.processEvents() + _run_js("rwRequestPage('newer')") + wait_until("rwState.pending === null && window.__rwPageRendering === false", + 30, f"向下换页{k}完成") + # 顶部回翻:锚点(最旧一条)随 trimHead 移除 → 视口留在顶部露出新页 + assert _run_js("window.scrollY") <= 1, "顶部回翻应留在顶部" + first = _run_js(f"document.querySelector('{sel}').id") + assert first == chain_ids[_PAGE * (k + 1)], f"窗口未下移: {first}" + st = _run_js("rwState.hasMoreOlder") + assert st is True, "回翻后应仍有更旧消息" + + check("自顶部向下回翻 2 页(窗口下移)", page_down_two) + + # ---- 5) auto 模式抽查:顶部自动补页 ---- + def auto_probe(): + _run_js("window.scrollTo(0, 0); 1") + time.sleep(0.15) + app.processEvents() + _run_js("rwState.mode = 'auto'; 1") + hid_before = _run_js("rwState.hiddenOlder") + _run_js("rwAutoCheck(); 1") # 直接触发自动补页判定(事件接线另行覆盖) + # 给自动补页 12s(每页渲染约 0.5-2s) + t0 = time.time() + moved = False + while time.time() - t0 < 12: + app.processEvents() + time.sleep(0.2) + if _run_js("rwState.hiddenOlder") < hid_before: + moved = True + break + assert moved, "auto 模式顶部未自动补页" + + check("auto 模式:顶部自动补页", auto_probe) + + # ---- 4) 规模报告 ---- + nodes = page_metrics["dom_nodes"] + heights = page_metrics["heights"] + pages_ms = page_metrics["pages"] + print("\n================ 规模报告 ================") + print(f"链长 N={N} | 窗口 size={_SIZE}") + print(f"换页数={len(pages_ms)} | 每页耗时 min/avg/max = " + f"{min(pages_ms):.0f}/{(sum(pages_ms) / len(pages_ms)):.0f}/{max(pages_ms):.0f} ms") + print(f"DOM 节点总数 min/max = {min(nodes)}/{max(nodes)}") + print(f"页面高度 min/max = {min(heights)}/{max(heights)} px") + if anchor_errors: + print(f"锚点误差 min/max = {min(anchor_errors):.2f}/{max(anchor_errors):.2f} px") + print("==========================================") + + +def finish(ok=None): + app.quit() + + +QTimer.singleShot(500, try_load) +QTimer.singleShot(300000, lambda: (print("[超时] 300s 总超时"), finish(False))) +app.exec() +try: + window.close() +except Exception: + pass +print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====") +# QtWebEngine 退出段错误规避:汇总已打印,直接退出 +os._exit(0 if all(results) else 1) diff --git a/tests/inject_math_demo.py b/tests/inject_math_demo.py new file mode 100644 index 0000000..6892866 --- /dev/null +++ b/tests/inject_math_demo.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +"""公式渲染验收消息注入:新建「公式渲染验收」会话,写入用户原始样本 + 正/负例 + +运行: PYTHONIOENCODING=utf-8 python tests/inject_math_demo.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from core.db_manager import DBManager # noqa: E402 + +USER_MSG = """帮我看看这个推导,公式渲染对不对? + +上面是推导结果: +[ +P_4=\\operatorname{BRF}(M_4,M_5) +] + +[ +P_3=\\operatorname{BRF}(S_3,P_4) +] + +其中: +[ +\\operatorname{BRF}(L,H) +L+ +\\Gamma(L,\\operatorname{Up}(H)) +\\odot +\\Phi(\\operatorname{Up}(H)) +] +""" + +ASSISTANT_MSG = r"""## 公式渲染验收 + +### 1) 你原始样本(单括号块,应渲染为独立居中公式) + +上面是推导结果: +[ +P_4=\operatorname{BRF}(M_4,M_5) +] + +[ +P_3=\operatorname{BRF}(S_3,P_4) +] + +其中: +[ +\operatorname{BRF}(L,H) +L+ +\Gamma(L,\operatorname{Up}(H)) +\odot +\Phi(\operatorname{Up}(H)) +] + +### 2) 标准定界符 + +行内混合:能量公式 $E=mc^2$ 出现在句子中间;再来一个 $x_i^2 + y_j^2 = z_{ij}^2$。 + +行内括号形式:\(\alpha + \beta = \gamma\) + +双美元块: +$$ +\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi} +$$ + +方括号块: +\[ +\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6} +\] + +带矩阵与希腊字母: +[ +\begin{pmatrix} a & b \\ c & d \end{pmatrix} +\begin{pmatrix} x \\ y \end{pmatrix} += +\begin{pmatrix} ax+by \\ cx+dy \end{pmatrix} +] + +### 3) 反例(不应渲染成公式) + +- 编号引用:见[1]和[2]的说明。 +- 链接:[KaTeX 官网](https://katex.org) +- 列表内容:[a, b] 只是一个数组。 +- 货币:价格 $1,000 and $2,000 之间。 +- 代码块: + +```python +price = "$5" +pattern = r"$x + y$" +arr[0] = 1 +``` + +- 行内代码:使用 `$z$` 表示变量。 + +### 4) 复杂嵌套(流式增量渲染路径同样适用) + +$$ +f(x) = \sum_{k=0}^{n} \binom{n}{k} x^k (1-x)^{n-k} +$$ +""" + + +def main(): + db = DBManager() + sess = db.create_session("公式渲染验收") + sid = sess["id"] + parent = sess.get("current_leaf_msg_id") + m1 = db.add_message(sid, "user", USER_MSG, parent_id=parent) + db.add_message(sid, "assistant", ASSISTANT_MSG, parent_id=m1["id"]) + db.mark_session_has_messages(sid) + print(f"已注入会话: {sid}") + print(f"标题: 公式渲染验收") + print(f"用户消息 {len(USER_MSG)}c / 助手消息 {len(ASSISTANT_MSG)}c") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/run_all.py b/tests/run_all.py new file mode 100644 index 0000000..a878e22 --- /dev/null +++ b/tests/run_all.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +"""P2-04 跨平台聚合测试入口(无 pytest / npm / 网络要求)。 + +用法: + python tests/run_all.py --group logic + python tests/run_all.py --group offscreen + python tests/run_all.py --group all + python tests/run_all.py --group all --list + python tests/run_all.py --group all --keep-logs + python tests/run_all.py --group logic --only test_tool_params + +设计(对应 REPAIR_BACKLOG.md P2-04 硬约束): + * 保留所有独立命令:每个条目就是 [解释器, 测试文件] 的普通子进程调用, + 与各测试文件头部的“运行:”命令等价(同一解释器、同一 cwd、同组环境)。 + * 聚合入口不要求 pytest / npm / 网络;缺 node 或第三方依赖的条目的 + 呈现方式(SKIP + 理由),不伪装通过。 + * 每个子测试使用独立临时目录(Windows: TEMP/TMP;POSIX: TMPDIR), + GUI 测试的临时配置/临时数据库由各测试自身在 import MainWindow 前 + 通过 tests/_test_env.isolate() 完成(仓库既有惯例)。 + * 默认不运行 diag_* / verify_* / tune_* / 真实 API(smoke_live_*、 + diag_live_*)/ 真实桌面 / 需要凭据的脚本,也不运行使用真实 DB 的 + 遗留脚本(smoke_persist、smoke_repro_real)。 + * 平台不适用项以明确 SKIP + 理由呈现;平台共同项失败 → 退出码非零。 + * 单个子测试崩溃或超时不阻止后续测试与最终汇总;退出码反映失败: + 0 = 无 FAIL/TIMEOUT(SKIP 不影响);1 = 存在 FAIL 或 TIMEOUT。 + * 不读取真实配置、不访问网络、不删除用户运行数据。 +""" +import argparse +import importlib.util +import os +import shutil +import subprocess +import sys +import tempfile +import time +from datetime import datetime + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PY = sys.executable # 用启动本入口的解释器跑所有 Python 子测试(与独立命令一致) +NODE = shutil.which("node") + +# 控制台编码安全化(与 main.py 同惯例):git-bash/GBK/cp1252 终端下 +# 聚合器自身的中文输出不得崩溃(子测试输出经 UTF-8 解码后同样安全打印)。 +for _name in ("stdout", "stderr"): + _s = getattr(sys, _name, None) + if _s is not None: + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +# --------------------------------------------------------------------------- +# 测试清单 / 分组元数据 +# (id, groups, argv, timeout_sec, needs, note) +# needs 取值: +# "node" —— 需要 node 可执行文件 +# "openai" —— 需要 openai 包(当前解释器) +# "pymupdf" —— 需要 PyMuPDF (fitz) +# "pyqt6" —— 需要 PyQt6(GUI/offscreen 条目) +# --------------------------------------------------------------------------- +JS = lambda f: [NODE, os.path.join(REPO, "tests", f)] if NODE else [None] +PYT = lambda f: [PY, os.path.join(REPO, "tests", f)] + +TESTS = [ + # ---- logic:纯逻辑 / 无 GUI(平台共同项,失败不得豁免)---- + ("run_tests.py (agent core 41)", ("logic",), PYT("run_tests.py"), 300, + ("openai",), "core.agent 单测套件(既有 run_tests.py 入口,原样保留)"), + ("test_tool_params.py", ("logic",), PYT("test_tool_params.py"), 120, + ("openai",), "工具参数解析(import core.agent.tools → openai)"), + ("test_bash_stream.py", ("logic",), PYT("test_bash_stream.py"), 180, + ("openai",), "bash 流式 / 进程树(P1-02、P2-01)"), + ("test_copy_session.py", ("logic",), PYT("test_copy_session.py"), 180, + (), "会话复制 DB 逻辑"), + ("test_compaction_persist.py", ("logic",), PYT("test_compaction_persist.py"), 180, + ("openai",), "压缩持久化"), + ("test_file_attach.py", ("logic",), PYT("test_file_attach.py"), 180, + (), "附件链路(P1-04)"), + ("test_cross_platform_shell.py", ("logic",), PYT("test_cross_platform_shell.py"), 240, + ("openai",), "跨平台 shell 契约 + 进程树终止(P1-02)"), + ("test_global_hotkey_platforms.py", ("logic",), PYT("test_global_hotkey_platforms.py"), 240, + ("pyqt6",), "全局热键平台矩阵 mock(P1-04;含 QShortcut/overlay 构造)"), + ("test_wv2_guard.py", ("logic",), PYT("test_wv2_guard.py"), 240, + ("platform:win32",), "WebView2 守卫(T0 事故锁死;msvcrt 单实例互斥为 Windows 专属,Linux 无 WebView2 链路)"), + ("test_pdf_reader.py", ("logic",), PYT("test_pdf_reader.py"), 240, + ("pymupdf",), "PDF 提取"), + ("test_math_extract.js", ("logic",), JS("test_math_extract.js"), 120, + ("node",), "数学提取 JS 单测(P1-03)"), + ("test_render_window.js", ("logic",), JS("test_render_window.js"), 180, + ("node",), "渲染窗口状态机 JS 单测(P1-01)"), + + # ---- offscreen:Qt GUI(QT_QPA_PLATFORM=offscreen 子环境)---- + ("smoke_offscreen.py", ("offscreen",), PYT("smoke_offscreen.py"), 420, + ("pyqt6",), "主窗口 + 核心链路冒烟"), + ("smoke_mode.py", ("offscreen",), PYT("smoke_mode.py"), 420, + ("pyqt6",), "模式切换(P0-02)"), + ("smoke_copy_session.py", ("offscreen",), PYT("smoke_copy_session.py"), 420, + ("pyqt6",), "会话复制 UI 端到端"), + ("smoke_bash_panel.py", ("offscreen",), PYT("smoke_bash_panel.py"), 480, + ("pyqt6",), "右侧 Bash 面板端到端(P2-01、P2-02)"), + ("smoke_timeline.py", ("offscreen",), PYT("smoke_timeline.py"), 420, + ("pyqt6",), "时间线持久化/还原"), + ("smoke_midswitch.py", ("offscreen",), PYT("smoke_midswitch.py"), 420, + ("pyqt6",), "流式中途切会话(P1-01)"), + ("test_main_window_event_filter.py", ("offscreen",), PYT("test_main_window_event_filter.py"), 300, + ("pyqt6",), "MainWindow.eventFilter(P0-02)"), + ("test_config_isolation.py", ("offscreen",), PYT("test_config_isolation.py"), 300, + ("pyqt6",), "配置/DB 隔离铁律(P0-01)"), + ("test_error_persist.py", ("offscreen",), PYT("test_error_persist.py"), 300, + ("pyqt6",), "错误持久化"), + ("test_think_code_neutral.py", ("offscreen",), PYT("test_think_code_neutral.py"), 420, + ("pyqt6",), "思考/代码块渲染中立性"), + ("test_debug_window.py", ("offscreen",), PYT("test_debug_window.py"), 300, + ("pyqt6",), "调试窗口"), + ("test_renderer_matrix.py", ("offscreen",), PYT("test_renderer_matrix.py"), 600, + ("pyqt6",), "渲染器矩阵(P1-03,含子进程并行)"), + ("test_screen_capture_platforms.py", ("offscreen",), PYT("test_screen_capture_platforms.py"), 300, + ("pyqt6",), "截图平台矩阵 mock + overlay 守卫(P1-04)"), +] + +# 默认不聚合(人工/半自动/真实资源):diag_*、verify_*、tune_*、 +# smoke_live_guard.py、diag_live_*.py、smoke_manual.py、smoke_repro_real.py、 +# smoke_persist.py(真实 DB)、smoke_probe.py、_probe_*.py、 +# debug_inject.py、inject_math_demo.py、check_db_migration.py + + +def _has_module(name): + try: + return importlib.util.find_spec(name) is not None + except Exception: + return False + + +def skip_reason(entry): + """返回 SKIP 理由;None 表示可运行。""" + _id, _groups, _argv, _to, needs, _note = entry + for need in needs: + if need.startswith("platform:"): + want = need.split(":", 1)[1] + if sys.platform != want: + return f"平台不适用:该条目仅 {want} 路径适用(当前 {sys.platform})" + continue + if need == "node" and not NODE: + return "node 不可用(聚合入口不要求 npm/node;安装 node 后自动纳入)" + if need == "openai" and not _has_module("openai"): + return "当前解释器无 openai 包(本环境不允许联网安装)" + if need == "pymupdf" and not _has_module("fitz"): + return "当前解释器无 PyMuPDF(本环境不允许联网安装)" + if need == "pyqt6" and not _has_module("PyQt6"): + return "当前解释器无 PyQt6(GUI 条目需在装有依赖的解释器运行)" + return None + + +def _child_env(extra_temp, offscreen): + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + env["PYTHONUNBUFFERED"] = "1" + if extra_temp: + if os.name == "nt": + env["TEMP"] = extra_temp + env["TMP"] = extra_temp + else: + env["TMPDIR"] = extra_temp + if offscreen: + env.setdefault("QT_QPA_PLATFORM", "offscreen") + env.setdefault("HAOCODE_RENDER", "software") + env.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu") + return env + + +def run_group(group, keep_logs, only): + entries = [e for e in TESTS if group in ("all",) or e[1][0] == group] + if only: + subs = [s.strip() for s in only.split(",") if s.strip()] + entries = [e for e in entries if any(s in e[0] for s in subs)] + if not entries: + print("没有匹配的测试条目(--group/--only 组合为空)") + return 1 + + run_dir = tempfile.mkdtemp(prefix="haocode_runall_") + print("=" * 72) + print("haocode 聚合测试入口 (P2-04)") + print(f"platform={sys.platform} python={sys.version.split()[0]} ({PY})") + print(f"group={group} entries={len(entries)} 开始={datetime.now():%H:%M:%S}") + print("=" * 72) + + results = [] # (id, status, seconds, note, argv) + t_all = time.time() + for entry in entries: + tid, _groups, argv, budget, needs, note = entry + off = "offscreen" in entry[1] + reason = skip_reason(entry) + if reason: + results.append((tid, "SKIP", 0.0, reason, argv)) + print(f" SKIP {tid} [{reason}]") + continue + t0 = time.time() + log_path = os.path.join(run_dir, tid.replace(" ", "_").replace("(", "").replace(")", "") + ".log") + try: + with tempfile.TemporaryDirectory(prefix="runall_t_") as td: + proc = subprocess.run( + argv, cwd=REPO, env=_child_env(td, off), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=budget) + rc, err = proc.returncode, None + out = proc.stdout + except subprocess.TimeoutExpired as e: + rc, out, err = 124, (e.stdout or b""), f"超时(预算 {budget}s)" + except Exception as e: # 聚合器自身健壮性:单条崩溃不阻止汇总 + rc, out, err = 125, b"", f"聚合器异常: {e!r}" + dt = max(0.0, time.time() - t0) # WSL/9P 时钟回拨保护 + status = "PASS" if rc == 0 else ("TIMEOUT" if rc == 124 else "FAIL") + results.append((tid, status, dt, note, argv)) + tag = f" {status:<7} {dt:7.1f}s {tid}" + print(tag, flush=True) + # 非 PASS:打印最后 15 行帮助定位 + tail = (out or b"").decode("utf-8", "replace").splitlines()[-15:] + if status != "PASS" and tail: + for line in tail: + print(f" | {line}") + with open(log_path, "wb") as f: + f.write(b"# argv: " + " ".join(str(a) for a in argv).encode() + b"\n") + if err: + f.write(f"# {err}\n".encode()) + f.write(out or b"") + + dt_all = time.time() - t_all + n_pass = sum(1 for r in results if r[1] == "PASS") + n_fail = sum(1 for r in results if r[1] in ("FAIL", "TIMEOUT")) + n_skip = sum(1 for r in results if r[1] == "SKIP") + print("-" * 72) + print(f"总计 {len(results)}: PASS {n_pass} FAIL {n_fail} SKIP {n_skip}" + f" 总耗时 {dt_all:.1f}s") + if keep_logs or n_fail: + print(f"日志目录(保留): {run_dir}") + else: + shutil.rmtree(run_dir, ignore_errors=True) + print("日志目录(无失败,已清理)") + bad = [r for r in results if r[1] in ("FAIL", "TIMEOUT")] + if bad: + print("失败命令:") + for tid, status, dt, _note, argv in bad: + print(f" [{status}] {' '.join(str(a) for a in argv)}") + print("=" * 72) + return 1 if n_fail else 0 + + +def main(): + ap = argparse.ArgumentParser(description="haocode 跨平台聚合测试入口(P2-04)") + ap.add_argument("--group", choices=("logic", "offscreen", "all"), default="all", + help="logic=纯逻辑; offscreen=Qt GUI offscreen; all=两者(默认)") + ap.add_argument("--list", action="store_true", help="只列出条目,不执行") + ap.add_argument("--keep-logs", action="store_true", help="保留全部子测试日志") + ap.add_argument("--only", default="", help="只运行 id 含子串的条目(逗号分隔)") + args = ap.parse_args() + + if args.list: + for tid, groups, argv, budget, needs, note in TESTS: + mark = "SKIP?" if skip_reason((tid, groups, argv, budget, needs, note)) else "run " + print(f" [{mark}] {groups[0]:<10} {budget:>4}s {tid} {note}") + return 0 + sys.exit(run_group(args.group, args.keep_logs, args.only)) + + +if __name__ == "__main__": + main() diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..fa934e9 --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,33 @@ +"""离线测试 harness(无 pytest 依赖): +运行: conda run -n haocode python tests/run_tests.py +""" +import importlib.util +import os +import sys +import traceback + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +spec = importlib.util.spec_from_file_location( + "test_agent_core", os.path.join(os.path.dirname(__file__), "test_agent_core.py")) +mod = importlib.util.module_from_spec(spec) +# 替换 pytest 依赖后加载 +import types +_py_stub = types.ModuleType("pytest") +sys.modules["pytest"] = _py_stub +spec.loader.exec_module(mod) + +tests = [(n, f) for n, f in sorted(vars(mod).items()) + if n.startswith("test_") and callable(f)] +passed = failed = 0 +for name, fn in tests: + try: + fn() + print(f" PASS {name}") + passed += 1 + except Exception: + print(f" FAIL {name}") + traceback.print_exc() + failed += 1 +print(f"\n===== {passed} passed, {failed} failed / {len(tests)} =====") +sys.exit(1 if failed else 0) diff --git a/tests/smoke_bash_panel.py b/tests/smoke_bash_panel.py new file mode 100644 index 0000000..5bb4974 --- /dev/null +++ b/tests/smoke_bash_panel.py @@ -0,0 +1,737 @@ +# -*- coding: utf-8 -*- +"""右侧任务面板 · offscreen 端到端 +链路:顶部按钮(原「导出」位置)→ BashPanel 展开/收起 → tool_execution_* 事件 + → 层(运行中/已完成)→ 单击展开(参数 + 输出)→ 会话切换刷新 + +运行: QT_QPA_PLATFORM=offscreen python tests/smoke_bash_panel.py +""" +import os +import sys +import json +import tempfile +import shutil + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成 +# (临时配置保留本套件的 mode_switch=true 语义) +from tests._test_env import isolate # noqa: E402 +_TMP = isolate("bashpanel", config={"providers": {}, "mode_switch": True}) +_DB_TMP = _TMP["db"] +_CFG_TMP = _TMP["config"] + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtTest import QTest # noqa: E402 +from PyQt6 import QtCore, QtWidgets # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 +from ui.views.bash_panel import BashLayer, LIVE_BUF_CAP, LAYER_LIMIT # noqa: E402 + +app = QApplication(sys.argv) +ok = True + + +def check(name, cond, extra=""): + global ok + print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" {extra}"), flush=True) + if not cond: + ok = False + + +def settle(ms=400): + for _ in range(int(ms / 20) + 1): + app.processEvents() + QTest.qWait(20) + + +window = MainWindow() +window.show() +panel = window.bash_panel + + +def px(widget, x, y): + """取控件自身坐标系下 (x,y) 的渲染像素""" + img = widget.grab().toImage() + c = img.pixelColor(x, y) + return (c.red(), c.green(), c.blue()) + + +# ====================================================================== +# 1) 外壳:默认收起 52px、顶部按钮存在且尺寸与左侧收起按钮一致 +# ====================================================================== +check("P1.1 面板已创建并挂进主布局", panel is not None and panel.parent() is not None) +check("P1.2 默认收起 52px", panel.width() == 52 and panel.collapsed is True, + f"w={panel.width()} collapsed={panel.collapsed}") +check("P1.3 「导出」按钮已移除", not hasattr(window, "btn_export")) +# 🆕 开关只在右栏内部(与左侧栏一致),主界面顶部【不得】有任何面板按钮 +check("P1.4 主界面顶部【没有】面板开关按钮", not hasattr(window, "btn_panel")) + + +def visible_toggles(): + """全窗口可见的面板开关按钮(tooltip 含“任务面板”)""" + return [b for b in window.findChildren(QtWidgets.QPushButton) + if "任务面板" in (b.toolTip() or "") and b.isVisible()] + + +check("P1.5 收起态:可见开关恒为 1 个", len(visible_toggles()) == 1, str(len(visible_toggles()))) +check("P1.6 收起态开关 = 面板内的 expand_btn(居中)", + visible_toggles()[0] is panel.expand_btn) +check("P1.7 expand_btn 34×34、icon 18×18(与左侧栏收起页按钮同规格)", + panel.expand_btn.width() == 34 and panel.expand_btn.height() == 34 + and panel.expand_btn.iconSize() == QtCore.QSize(18, 18) + and panel.expand_btn.width() == window.collapse_expand_btn.width(), + f"{panel.expand_btn.width()}x{panel.expand_btn.height()} vs 左 {window.collapse_expand_btn.width()}") +check("P1.8 expand_btn 与左侧按钮同 objectName(继承同一套 QSS)", + panel.expand_btn.objectName() == window.collapse_expand_btn.objectName() == "collapse_btn") +_x = panel.expand_btn.mapTo(panel, QtCore.QPoint(0, 0)).x() +check("P1.9 expand_btn 水平居中于 52px 栏内", + abs((_x + 34 / 2) - panel.width() / 2) <= 1.5, f"center={_x + 17} panel_half={panel.width()/2}") + +# 展开(用栏内按钮,不用主界面按钮) +panel.expand_btn.click() +settle(500) +check("P1.10 点击收起页按钮 → 展开到 260px", panel.width() == 260 and panel.collapsed is False, + f"w={panel.width()} collapsed={panel.collapsed}") +check("P1.11 展开态:可见开关仍恒为 1 个(不叠加)", + len(visible_toggles()) == 1, str(len(visible_toggles()))) +check("P1.12 展开态开关 = 面板标题行右上角的 fold_btn", + visible_toggles()[0] is panel.fold_btn) +check("P1.13 fold_btn 28×28、icon 16×16(与左侧栏收起按钮同规格)", + panel.fold_btn.width() == 28 and panel.fold_btn.height() == 28 + and panel.fold_btn.iconSize() == QtCore.QSize(16, 16) + and panel.fold_btn.width() == window.collapse_btn.width(), + f"{panel.fold_btn.width()}x{panel.fold_btn.height()} vs 左 {window.collapse_btn.width()}") +check("P1.14 fold_btn 与左侧按钮同 objectName(同一套 QSS)", + panel.fold_btn.objectName() == window.collapse_btn.objectName() == "collapse_btn") +_fx = panel.fold_btn.mapTo(panel, QtCore.QPoint(0, 0)).x() +check("P1.15 fold_btn 位于标题行右端(右侧留白 ≤14px)", + panel.width() - (_fx + 28) <= 14, f"right_gap={panel.width() - (_fx + 28)}") +check("P1.16 展开后两栏同时可见(不互斥)", + (not panel.sec_running.isHidden()) and (not panel.sec_done.isHidden()), + f"run_hidden={panel.sec_running.isHidden()} done_hidden={panel.sec_done.isHidden()}") +check("P1.17 两栏为上下排列(分隔线可拖动)", + panel.splitter.orientation() == QtCore.Qt.Orientation.Vertical) +check("P1.18 默认两栏提示语", + panel.sec_running.hint.text() == "暂无正在运行的 bash" + and panel.sec_done.hint.text() == "本会话还没有已完成的 bash", + f"{panel.sec_running.hint.text()!r} / {panel.sec_done.hint.text()!r}") +# 🐛 已修:QWidget 子类样式表背景 + 底色/分隔线继承左侧 +check("P1.19 样式表背景已启用(WA_StyledBackground)", + panel.testAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground)) +check("P1.20 面板底色 = 左侧栏底色 (#f7f8fa)", + px(panel, 30, 400) == (0xf7, 0xf8, 0xfa), str(px(panel, 30, 400))) +check("P1.21 面板左侧分隔线 = 左侧栏分隔线 (#ececec)", + px(panel, 0, 400) == (0xec, 0xec, 0xec), str(px(panel, 0, 400))) +check("P1.22 左侧栏对照:底色+分隔线", + px(window.sidebar, 30, 400) == (0xf7, 0xf8, 0xfa) + and px(window.sidebar, 259, 400) == (0xec, 0xec, 0xec), + f"{px(window.sidebar, 30, 400)} {px(window.sidebar, 259, 400)}") +check("P1.23 标题行与左侧栏标题行垂直对齐(y 相等、高 36)", + panel.header.mapTo(window, QtCore.QPoint(0, 0)).y() + == window.sidebar_header.mapTo(window, QtCore.QPoint(0, 0)).y() + and panel.header.height() == window.sidebar_header.height() == 36, + f"panel_y={panel.header.mapTo(window, QtCore.QPoint(0, 0)).y()} " + f"left_y={window.sidebar_header.mapTo(window, QtCore.QPoint(0, 0)).y()}") + +# ====================================================================== +# 2) 事件驱动:运行中 → 实时输出 → 读秒 → 完成归位 +# ====================================================================== +sid = window.current_session_id +if not sid: + window.on_new_chat_clicked() + sid = window.current_session_id + +window._on_tool_started(sid, "c1", "bash", json.dumps({"command": "echo hi"}, ensure_ascii=False)) +check("P2.1 bash 事件 → 运行中栏出现 1 层", panel.layer_ids("running") == ["c1"], + str(panel.layer_ids("running"))) +check("P2.2 已完成栏仍为空", panel.layer_ids("done") == [], str(panel.layer_ids("done"))) +lay = panel._layers["c1"] +check("P2.3 层头显示 bash + 运行中标签", + lay.name.text() == "bash" and lay.tag.text() == "运行中", lay.tag.text()) +check("P2.4 层头显示命令预览", "echo hi" in lay.cmd.text(), lay.cmd.text()) + +window._on_tool_updated(sid, "c1", "hi\n") +window._on_tool_updated(sid, "c1", "second\n") +check("P2.5 实时输出被累积", lay._live == "hi\nsecond\n", repr(lay._live)) +window._on_tool_timed(sid, "c1", 3, 120) +check("P2.6 读秒显示 3/120s", lay.meta.text() == "3/120s", lay.meta.text()) + +# 非 bash 工具不登记 +window._on_tool_started(sid, "c9", "read", json.dumps({"path": "x"})) +check("P2.7 非 bash 工具不登记", "c9" not in panel._layers) + +# 完成 → 归位到已完成栏 +final_text = "$ echo hi\nhi\n[exit 0] (0.1s)" +window._on_tool_finished(sid, "c1", "bash", True, final_text) +check("P2.8 完成后从运行中移除", panel.layer_ids("running") == [], str(panel.layer_ids("running"))) +check("P2.9 完成后进入已完成栏", panel.layer_ids("done") == ["c1"], str(panel.layer_ids("done"))) +check("P2.10 已完成标签 + 耗时(从 (0.1s) 解析)", + lay.tag.text() == "已完成" and lay.meta.text() == "0.1s", f"{lay.tag.text()} {lay.meta.text()}") +check("P2.11 已完成层输出 = 进入上下文的原文", lay._final == final_text) + +# ====================================================================== +# 3) 单击展开 → 参数 + 输出 +# ====================================================================== +check("P3.1 初始为收起态", lay.expanded is False and lay.body.isHidden()) +QTest.mouseClick(lay.head, QtCore.Qt.MouseButton.LeftButton, + QtCore.Qt.KeyboardModifier.NoModifier, + QtCore.QPoint(10, lay.head.height() // 2)) +settle(120) +check("P3.2 单击(非双击)即展开", lay.expanded is True and not lay.body.isHidden()) +check("P3.3 参数区显示完整命令", lay.arg_box.toPlainText() == "echo hi", + repr(lay.arg_box.toPlainText())) +check("P3.4 输出区标题标明「进入上下文」", lay.lbl_out.text() == "输出(进入上下文)", + lay.lbl_out.text()) +check("P3.5 输出区显示上下文原文", lay.out_box.toPlainText() == final_text) +QTest.mouseClick(lay.head, QtCore.Qt.MouseButton.LeftButton, + QtCore.Qt.KeyboardModifier.NoModifier, + QtCore.QPoint(10, lay.head.height() // 2)) +settle(120) +check("P3.6 再点一次收起", lay.expanded is False and lay.body.isHidden()) + +# 运行中的层展开 → 实时输出 +window._on_tool_started(sid, "c2", "bash", json.dumps({"command": "ping -t x"})) +window._on_tool_updated(sid, "c2", "line-A\n") +lay2 = panel._layers["c2"] +lay2.toggle() +check("P3.7 运行中层输出标题 = 输出(实时)", lay2.lbl_out.text() == "输出(实时)", + lay2.lbl_out.text()) +check("P3.8 运行中层实时内容可见", "line-A" in lay2.out_box.toPlainText()) +window._on_tool_updated(sid, "c2", "line-B\n") +check("P3.9 展开状态下新输出即时可见(无需重新展开)", + "line-B" in lay2.out_box.toPlainText(), repr(lay2.out_box.toPlainText())) +window._on_tool_finished(sid, "c2", "bash", False, "$ ping -t x\nline-A\n[exit 1] (0.5s)") +check("P3.10 失败层标签为「失败」", lay2.tag.text() == "失败", lay2.tag.text()) + +# 实时缓冲上限 +big = BashLayer("cbuf", "x") +for _ in range(260): + big.append_live("y" * 1000) +check("P3.11 实时缓冲截到 200KB 内", len(big._live) <= LIVE_BUF_CAP, str(len(big._live))) +check("P3.12 截断被标注", big._live_truncated is True) + +# ====================================================================== +# 4) 压缩切点 → 已出上下文标注 +# ====================================================================== +sid2 = window.db.create_session("面板压缩测试")["id"] +leaf = window.db.get_session_leaf(sid2) +tl1 = json.dumps([{"t": "tool", "id": "old1", "name": "bash", + "args": json.dumps({"command": "old-cmd"}), + "ok": True, "result": "$ old-cmd\nold\n[exit 0] (0.1s)"}], + ensure_ascii=False) +m_sys = leaf +m_u1 = window.db.add_message(sid2, "user", "q1", m_sys)["id"] +m_a1 = window.db.add_message(sid2, "assistant", "a1", m_u1, timeline=tl1)["id"] +m_u2 = window.db.add_message(sid2, "user", "q2", m_a1)["id"] +tl2 = json.dumps([{"t": "tool", "id": "new1", "name": "bash", + "args": json.dumps({"command": "new-cmd"}), + "ok": True, "result": "$ new-cmd\nnew\n[exit 0] (0.1s)"}], + ensure_ascii=False) +m_a2 = window.db.add_message(sid2, "assistant", "a2", m_u2, timeline=tl2)["id"] +window.db.insert_compaction_mark(sid2, "【摘要】前文略", cut_before_id=m_a1, + first_retained_id=m_u2) +window.load_messages_to_web(sid2) +settle(300) +check("P4.1 切换会话后面板跟随刷新", set(panel.layer_ids()) == {"old1", "new1"}, + str(panel.layer_ids())) +check("P4.2 压缩切点之前的层标注「已出上下文」", + panel._layers["old1"].tag.text() == "已出上下文", panel._layers["old1"].tag.text()) +check("P4.3 切点之后的层仍为「已完成」", + panel._layers["new1"].tag.text() == "已完成", panel._layers["new1"].tag.text()) +check("P4.4 已完成层不截断输出(原文保留)", + panel._layers["old1"]._final.endswith("[exit 0] (0.1s)")) + +# ====================================================================== +# 5) 层数上限 +# ====================================================================== +sid3 = window.db.create_session("面板层数上限")["id"] +leaf3 = window.db.get_session_leaf(sid3) +entries = [{"t": "tool", "id": f"many{i}", "name": "bash", + "args": json.dumps({"command": f"cmd{i}"}), + "ok": True, "result": f"$ cmd{i}\nok\n[exit 0] (0.1s)"} for i in range(35)] +window.db.add_message(sid3, "assistant", "批量", leaf3, + timeline=json.dumps(entries, ensure_ascii=False)) +window.load_messages_to_web(sid3) +settle(300) +check("P5.1 只渲染最近 30 层", panel.sec_done.count.text() == str(LAYER_LIMIT), + panel.sec_done.count.text()) +check("P5.2 有「仅显示最近 N 层」提示", "仅显示最近 30 层" in panel.sec_done.hint.text(), + panel.sec_done.hint.text()) +check("P5.3 面板内部仍保留全部 35 层(只是不渲染)", + len(panel.layer_ids("done")) == 35, str(len(panel.layer_ids("done")))) + +# ====================================================================== +# 6) 不自动收缩(resizeEvent 不影响右侧面板)+ 手动切换 +# ====================================================================== +was = panel.collapsed +window.resize(920, 700) +settle(200) +check("P6.1 窗口变小不触发面板自动收缩", panel.collapsed == was, + f"before={was} after={panel.collapsed}") +window.resize(1400, 800) +settle(200) +check("P6.2 窗口变大也不触发面板自动展开/收缩", panel.collapsed == was) +panel.fold_btn.click() +settle(500) +check("P6.3 只有点栏内按钮才收起", panel.width() == 52 and panel.collapsed is True) +check("P6.4 收起后可见开关切回 expand_btn(恒为 1 个)", + len(visible_toggles()) == 1 and visible_toggles()[0] is panel.expand_btn) +panel.expand_btn.click() +settle(500) +check("P6.5 收起页按钮再点一次即可展开", panel.width() == 260 and panel.collapsed is False) +check("P6.6 全窗口仍只有 1 个可见面板开关(且主界面顶部没有)", + len(visible_toggles()) == 1 and not hasattr(window, "btn_panel")) + +# ====================================================================== +# 7) 会话切回空会话 → 面板清空 +# ====================================================================== +sid4 = window.db.create_session("面板空会话")["id"] +window.load_messages_to_web(sid4) +settle(250) +check("P7.1 切到空会话后面板清空", panel.layer_ids() == [], str(panel.layer_ids())) + +# ====================================================================== +# 8) 🆕 左边缘拖拽调宽 → 记录 → 下次展开自动恢复(含最小宽度) +# ====================================================================== +from ui.views.bash_panel import (PANEL_W_DEFAULT, PANEL_W_MIN, PANEL_W_MAX, # noqa: E402 + BashPanel, load_panel_width) + +if panel.collapsed: + panel.expand_btn.click() + settle(400) + +check("P8.1 拖拽手柄存在、宽 4px、贴在最左", + panel._handle.width() == 4 and panel._handle.x() == 0, + f"w={panel._handle.width()} x={panel._handle.x()}") +check("P8.2 手柄覆盖面板全高", panel._handle.height() == panel.height(), + f"{panel._handle.height()} vs {panel.height()}") +check("P8.3 手柄光标 = 水平拖拽", + panel._handle.cursor().shape() == QtCore.Qt.CursorShape.SizeHorCursor) +check("P8.4 最小宽度已设定(%d px)" % PANEL_W_MIN, + PANEL_W_MIN == 200 and panel.W_MIN == PANEL_W_MIN) + + +def drag_to(width): + """模拟真实拖拽:按下(记右边界)→ 鼠标移到目标位置 → 松手""" + right = panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x() + panel._drag_begin(right) + panel._drag_to_global_x(right - width) + panel._commit_drag_width() + settle(120) + + +# 8.1 正常拖宽 → 立即生效 + 落盘 +cfg_before = json.load(open(_CFG_TMP, encoding="utf-8")) +drag_to(340) +check("P8.5 拖到 340px 立即生效", panel.width() == 340 and panel.collapsed is False, + f"w={panel.width()}") +cfg_after = json.load(open(_CFG_TMP, encoding="utf-8")) +check("P8.6 已落盘 config.json['bash_panel_width'] = 340", + cfg_after.get("bash_panel_width") == 340, str(cfg_after.get("bash_panel_width"))) +check("P8.7 落盘不破坏其它键(providers/mode_switch 保留)", + "providers" in cfg_after and cfg_after.get("mode_switch") is True) + +# 8.2 收起再展开 → 回到记录值(不是默认 260) +panel.fold_btn.click() +settle(450) +check("P8.8 收起仍为 52px", panel.width() == 52 and panel.collapsed is True, f"w={panel.width()}") +check("P8.9 收起条居中按钮不被 4px 手柄遮住", panel.expand_btn.x() >= panel._handle.width(), + f"btn_x={panel.expand_btn.x()}") +panel.expand_btn.click() +settle(450) +check("P8.10 再展开 = 记录的 340px(不是默认 %d)" % PANEL_W_DEFAULT, + panel.width() == 340 and panel.W_EXPAND == 340, f"w={panel.width()} W_EXPAND={panel.W_EXPAND}") + +# 8.3 最小宽度夹紧 +win_half = int(window.width() * 0.5) +drag_to(120) +check("P8.11 拖到 120px → 被夹到最小 200px", panel.width() == PANEL_W_MIN, f"w={panel.width()}") +check("P8.12 夹紧后仍是展开态(不会误判为收起)", + panel.collapsed is False and panel.width() > panel.W_COLLAPSE) + +# 8.4 上限夹紧 +drag_to(5000) +exp_hi = min(PANEL_W_MAX, max(PANEL_W_MIN, win_half)) +check("P8.13 拖到 5000px → 被夹到上限(≤%d)" % exp_hi, panel.width() == exp_hi, + f"w={panel.width()} exp={exp_hi}") + +# 8.5 从收起态直接拖开 → 自动进展开态 +drag_to(PANEL_W_MIN) +panel.fold_btn.click() +settle(450) +check("P8.14 已收起 52px", panel.width() == 52) +panel._drag_begin(panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x()) +panel._drag_to_global_x(panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x() + 100) +check("P8.15 从收起态向外拖 → 立即进入展开态(不为 0/负数)", + panel.collapsed is False and panel.width() >= PANEL_W_MIN, f"w={panel.width()}") +panel._commit_drag_width() +settle(120) + +# 8.6 持久化:新实例直接读出记录值 +drag_to(300) +check("P8.16 记录 300px 后,新实例读出同一值", + load_panel_width() == 300, str(load_panel_width())) +_p2 = BashPanel() +check("P8.17 新面板 W_EXPAND 即上次记录值(重启后自动恢复)", + _p2.W_EXPAND == 300, str(_p2.W_EXPAND)) +_p2.deleteLater() + +# 8.7 只拖不松手不写盘 +with open(_CFG_TMP, "w", encoding="utf-8") as _f: + json.dump({"providers": {}, "mode_switch": True}, _f) +right = panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x() +panel._drag_begin(right) +panel._drag_to_global_x(right - 420) +check("P8.18 拖动过程中不写盘(只在松手时记录)", + json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width") is None) +panel._commit_drag_width() +check("P8.19 松手才落盘", + json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width") == panel.width(), + str(json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width"))) + +# ====================================================================== +# 9) 🆕 两栏折叠/展开:只收「下面填充的 bash 层」,头部行高恒定(抽屉动画) +# ====================================================================== +if panel.collapsed: + panel.expand_btn.click() + settle(400) + +# 造数据:运行中 1 层 + 已完成 2 层 +panel.on_started("p9_run", "bash", {"command": "python long_task.py"}) +panel.on_timed("p9_run", 3, 120) +panel.on_started("p9_d1", "bash", {"command": "echo a"}) +panel.on_finished("p9_d1", "bash", True, "a") +panel.on_started("p9_d2", "bash", {"command": "echo b"}) +panel.on_finished("p9_d2", "bash", True, "b") +settle(300) + +sr, sd = panel.sec_running, panel.sec_done +check("P9.0 两栏都有内容(运行中1 / 已完成2)", + len(panel.layer_ids("running")) == 1 and len(panel.layer_ids("done")) == 2, + f"{panel.layer_ids('running')} / {panel.layer_ids('done')}") + +head_h = sr.head_height() +total0 = sum(panel.splitter.sizes()) +check("P9.1 头部行高 = 24px 量级(收起/展开恒定的那一行)", + 16 <= head_h <= 40, f"head_h={head_h}") +check("P9.2 最小高已被重写为「只剩头部行」(bug 根因解除)", + sr.minimumSizeHint().height() == head_h and sd.minimumSizeHint().height() == sd.head_height(), + f"{sr.minimumSizeHint().height()} vs head {head_h}") + +h_run_before = sr.height() +h_done_before = sd.height() + +# ---- 收起「运行中」----- +sr.head.clicked.emit() +settle(120) # 只等一半 → 应处于动画中间态 +mid_run = sr.height() +settle(400) +check("P9.3 收起「运行中」→ 该栏只剩头部高(空白归零)", + abs(sr.height() - head_h) <= 2, f"h={sr.height()} head={head_h}") +check("P9.4 splitter sizes[0] == 头部高", + abs(panel.splitter.sizes()[0] - head_h) <= 2, str(panel.splitter.sizes())) +check("P9.5 「已完成」栏吃掉让出的空间", + sd.height() > h_done_before + 50, f"{h_done_before} -> {sd.height()}") +check("P9.6 让出空间 = 两栏高度之和不变", + abs((sr.height() + sd.height()) - (h_run_before + h_done_before)) <= 2, + f"{h_run_before}+{h_done_before} vs {sr.height()}+{sd.height()}") +check("P9.7 动画是渐变(中间帧介于首末之间)", + head_h < mid_run < h_run_before, f"mid={mid_run} ({head_h}, {h_run_before})") +check("P9.8 收起后内容区不可见(scroll.isVisible() == False)", + not sr.scroll.isVisible() and not sr.body.isVisible()) +check("P9.9 收起后头部行仍可见且高度不变", + sr.head.isVisible() and abs(sr.head.height() - head_h) <= 1, f"head={sr.head.height()}") +check("P9.10 收起态:栏头箭头变 ▸", sr.chev.text() == "▸") +check("P9.11 收起态:中间拖动分隔条被禁用", + panel.splitter.handle(1).isEnabled() is False) + +# ---- 再展开 ---- +sr.head.clicked.emit() +settle(120) +mid2 = sr.height() +settle(400) +check("P9.12 再展开 → 恢复原高度(±3)", + abs(sr.height() - h_run_before) <= 3, f"{sr.height()} vs {h_run_before}") +check("P9.13 展开也是渐变", head_h <= mid2 <= h_run_before, f"mid={mid2}") +check("P9.14 展开后内容可见 / 箭头回 ▾ / 分隔条恢复可拖", + sr.scroll.isVisible() and sr.chev.text() == "▾" and panel.splitter.handle(1).isEnabled()) +check("P9.15 折叠不影响层数据与实时缓冲", + len(panel.layer_ids("running")) == 1 and len(panel.layer_ids("done")) == 2 + and panel._layers["p9_run"].ok is None) + +# ---- 「已完成」栏单独收起同样生效 ---- +sd.head.clicked.emit() +settle(500) +check("P9.16 收起「已完成」→ 该栏只剩头部高", + abs(sd.height() - sd.head_height()) <= 2, f"h={sd.height()} head={sd.head_height()}") +# 🆕 新口径:收起「已完成」不再让「运行中」吃掉空间,而是保持它的高度、 +# 余量进底部留白(栏头紧跟上方内容,不被顶到底部) +check("P9.17 收起「已完成」→ 「运行中」高度保持不变(不再吃空间)", + abs(sr.height() - h_run_before) <= 3, f"{sr.height()} vs {h_run_before}") +sd.head.clicked.emit() +settle(500) +check("P9.18 展开恢复", abs(sd.height() - h_done_before) <= 3, f"{sd.height()} vs {h_done_before}") + +# ---- 两栏都收起 → 两个头部相邻在顶部 ---- +sr.head.clicked.emit(); settle(400) +sd.head.clicked.emit(); settle(400) +sizes2 = panel.splitter.sizes() +check("P9.19 两栏都收起 → sizes = [头部高, 头部高, 余量]", + abs(sizes2[0] - head_h) <= 2 and abs(sizes2[1] - head_h) <= 2 + and abs(sum(sizes2) - (panel.splitter.height() - 2 * panel.splitter.handleWidth())) <= 3, + str(sizes2)) +check("P9.20 两栏都收起时两个栏头相邻(间隔 = 分隔条宽)", + abs(sd.mapTo(panel, QtCore.QPoint(0, 0)).y() + - (sr.mapTo(panel, QtCore.QPoint(0, 0)).y() + sr.height()) + - panel.splitter.handleWidth()) <= 2, + f"sec2_y={sd.mapTo(panel, QtCore.QPoint(0, 0)).y()} sec1_y={sr.mapTo(panel, QtCore.QPoint(0, 0)).y()}") + +# ---- 连点 3 次 → 收敛到终态 ---- +for _s in (sr, sd): # 先确保两栏都展开 + if _s.folded: + _s.head.clicked.emit() + settle(500) +h_unfolded = sr.height() +sr.head.clicked.emit(); sr.head.clicked.emit(); sr.head.clicked.emit() +settle(700) +check("P9.21 未收起时连点 3 次 → 终态为收起,且只剩头部高", + sr.folded is True and abs(sr.height() - head_h) <= 2, + f"folded={sr.folded} h={sr.height()}") +sr.head.clicked.emit() +settle(700) +check("P9.22 连点后仍能正常展开并恢复原高度", + sr.folded is False and abs(sr.height() - h_unfolded) <= 3, + f"h={sr.height()} vs {h_unfolded}") + +# ---- 面板尺寸变化后仍钉在头部高 ---- +sr.head.clicked.emit() +settle(500) +sd_h_before = sd.head_height() +sizes_now = panel.splitter.sizes() +panel.splitter.setSizes([sizes_now[1], sizes_now[0]]) # 模拟被外部拉大/拉小 +settle(120) +panel._repin_fold() # resizeEvent 里的重钉 +settle(120) +check("P9.23 尺寸变化后收起栏仍只占头部高(_repin_fold 生效)", + abs(sr.height() - head_h) <= 2, f"h={sr.height()} head={head_h}") +sr.head.clicked.emit() +settle(500) +check("P9.24 收尾:两栏均展开,头部行高不变", + (not sr.folded) and (not sd.folded) + and sr.head_height() == head_h and sd.head_height() == sd_h_before, + f"{sr.head_height()}/{sd.head_height()} vs {head_h}/{sd_h_before}") + +# ====================================================================== +# 10) 🆕 两个实测 bug 的回归测试 +# bug1: 收起后 head 被拉伸成巨条(实测 793px) +# bug2: 收起「已完成」→ 栏头被顶到面板最底部 +# ====================================================================== +def sec_y(s): + return s.mapTo(panel, QtCore.QPoint(0, 0)).y() + +# ---- bug1: 头部行高【硬固定】(任意组合下都不被拉伸)---- +combos = [(False, False), (True, False), (False, True), (True, True)] +bad = [] +for want_r, want_d in combos: + for s, want in ((sr, want_r), (sd, want_d)): + if s.folded != want: + s.head.clicked.emit() + settle(450) + if sr.head.height() != sr.head_height() or sd.head.height() != sd.head_height(): + bad.append((want_r, want_d, sr.head.height(), sd.head.height())) +check("P9.25 四种折叠组合下 head.height() 恒等于 head_height()(不再被拉伸)", + not bad, str(bad)) +check("P9.26 两栏都收起时「已完成」head 高 == 24(修复前实测 793)", + sr.folded and sd.folded and sd.head.height() == sd.head_height() == head_h, + f"folded=({sr.folded},{sd.folded}) head={sd.head.height()}") +_sp = panel.splitter.sizes() +check("P9.27 两栏都收起 → 余量进底部留白(spacer)", + _sp[2] > 100 and abs(sum(_sp) - (panel.splitter.height() - 2 * panel.splitter.handleWidth())) <= 3, + str(_sp)) +check("P9.28 spacer 上方的分隔条不可拖且透明", + panel.splitter.handle(2).isEnabled() is False + and "transparent" in (panel.splitter.handle(2).styleSheet() or ""), + panel.splitter.handle(2).styleSheet()) + +# ---- bug2: 收起「已完成」→ 栏头紧跟「运行中」内容,而不是落到面板底部 ---- +for s in (sr, sd): + if s.folded: + s.head.clicked.emit() + settle(450) +h_run_keep = sr.height() +sd.head.clicked.emit() +settle(500) +sp_y = sd.mapTo(panel, QtCore.QPoint(0, 0)).y() +check("P9.29 收起「已完成」→ 栏头紧跟在「运行中」下方(不是面板底部)", + abs(sp_y - (sec_y(sr) + sr.height() + panel.splitter.handleWidth())) <= 2, + f"done_y={sp_y} run_bottom={sec_y(sr) + sr.height()}") +check("P9.30 收起「已完成」→ 栏头远离面板底部(留白在它下面)", + sp_y < panel.height() - 100, f"done_y={sp_y} panel_h={panel.height()}") +check("P9.31 收起「已完成」→ 「运行中」高度保持记录值", + abs(sr.height() - h_run_keep) <= 3, f"{sr.height()} vs {h_run_keep}") +check("P9.32 收起「已完成」→ spacer 吸收余量", + panel.splitter.sizes()[2] > 100, str(panel.splitter.sizes())) + +# ---- 展开恢复 ---- +sd.head.clicked.emit() +settle(550) +check("P9.33 展开「已完成」→ 恢复记录高度", + abs(sd.height() - h_done_before) <= 4, f"{sd.height()} vs {h_done_before}") +check("P9.34 展开后 spacer 归零(已完成重新填满到底部)", + panel.splitter.sizes()[2] <= 2, str(panel.splitter.sizes())) + +# ---- 不变量:任何折叠态下 sizes 之和 == 可用高度 ---- +ok_sum = True +for want_r, want_d in combos: + for s, want in ((sr, want_r), (sd, want_d)): + if s.folded != want: + s.head.clicked.emit() + settle(450) + avail = panel.splitter.height() - 2 * panel.splitter.handleWidth() + if abs(sum(panel.splitter.sizes()) - avail) > 3: + ok_sum = False +check("P9.35 不变量:任意折叠态下 sizes 之和 == splitter 可用高度", ok_sum) +for s in (sr, sd): + if s.folded: + s.head.clicked.emit() + settle(450) +check("P9.36 收尾:两栏均展开", (not sr.folded) and (not sd.folded)) + +# ====================================================================== +# 11) 🆕 P2-01:两栏按【启动顺序倒序】显示 + 重排复用实例/状态保持 +# · 排序键恒为启动序号(绝不用完成时间重排) +# · 重排复用同一批 BashLayer:展开态、实时输出、代码框滚动、栏滚动位置保持 +# ====================================================================== +panel.clear_all() +settle(120) + +# ---- 11.1 运行中栏:最新启动在第一项 ---- +panel.on_started("s1", "bash", {"command": "cmd-s1"}) +panel.on_started("s2", "bash", {"command": "cmd-s2"}) +panel.on_started("s3", "bash", {"command": "cmd-s3"}) +settle(150) +check("P11.1 运行中栏按启动倒序(s3 最新在第一项)", + panel.layer_ids("running") == ["s3", "s2", "s1"], + str(panel.layer_ids("running"))) +_rendered_run = [w for w in (panel.sec_running.lay.itemAt(i).widget() + for i in range(panel.sec_running.lay.count())) + if w is not None] +check("P11.2 实际布局顺序与显示顺序一致(widget 复用、顺序反转)", + [w.call_id for w in _rendered_run] == ["s3", "s2", "s1"], + str([w.call_id for w in _rendered_run])) + +# ---- 11.2 新任务触发重排:实例/展开态/实时输出/代码框滚动 全部保持 ---- +lay2 = panel._layers["s2"] +lay2.toggle() # 展开 s2 +panel.on_output("s2", "x" * 2000 + "\n") # 实时输出(NoWrap → 有水平滚动范围) +settle(120) # 等布局落定(真实用户滚动必然在渲染后; + # 否则首帧布局 flush 会把水平滚动归零——测试时序伪影,非产品 bug) +hbar = lay2.out_box.horizontalScrollBar() +hbar.setValue(500) +v0 = hbar.value() +dv0 = panel.sec_done.scroll.verticalScrollBar().value() +check("P11.3 前置:水平滚动处于非 0 位置", v0 > 0, f"v0={v0}") +panel.on_started("s4", "bash", {"command": "cmd-s4"}) # 新任务 → 两栏重排 +settle(150) +lay2b = panel._layers["s2"] +check("P11.4 重排后 s2 仍是同一个 BashLayer 实例(不重建)", lay2b is lay2) +check("P11.5 展开/折叠状态保持", lay2b.expanded is True and not lay2b.body.isHidden()) +check("P11.6 实时输出保持(未因重排丢失/重渲染)", lay2b._live == "x" * 2000 + "\n", + repr(lay2b._live[:20])) +check("P11.7 代码框水平滚动值保持", hbar.value() == v0, f"{hbar.value()} vs {v0}") +check("P11.8 已完成栏 section 滚动位置保持", + panel.sec_done.scroll.verticalScrollBar().value() == dv0) +check("P11.9 运行中栏重排后仍启动倒序(s4 顶到第一项)", + panel.layer_ids("running") == ["s4", "s3", "s2", "s1"], + str(panel.layer_ids("running"))) + +# ---- 11.3 完成时间【不是】排序键:s2 启动更晚却先完成 → 仍在 s1 之上 ---- +panel.on_finished("s2", "bash", True, "$ cmd-s2\nok2\n[exit 0] (0.1s)") +panel.on_finished("s1", "bash", True, "$ cmd-s1\nok1\n[exit 0] (0.2s)") +settle(150) +check("P11.10 已完成栏按启动倒序(s2 先完成仍在第一项,不按完成时间)", + panel.layer_ids("done") == ["s2", "s1"], str(panel.layer_ids("done"))) +check("P11.11 完成后从运行中栏消失(启动位置不变,仅换栏)", + panel.layer_ids("running") == ["s4", "s3"], str(panel.layer_ids("running"))) + +# ---- 11.4 运行中→已完成 仍占原启动位置(晚完成不顶到最上) ---- +panel.on_started("s6", "bash", {"command": "cmd-s6"}) +panel.on_finished("s6", "bash", True, "$ cmd-s6\nok6\n[exit 0] (0.1s)") +check("P11.12 s6 最后启动 → 顶到已完成栏第一项", + panel.layer_ids("done") == ["s6", "s2", "s1"], str(panel.layer_ids("done"))) +panel.on_finished("s3", "bash", True, "$ cmd-s3\nok3\n[exit 0] (0.3s)") # s3 最后完成 +settle(150) +check("P11.13 s3 最后完成但按启动位置插入第二项(不顶到最上)", + panel.layer_ids("done") == ["s6", "s3", "s2", "s1"], str(panel.layer_ids("done"))) + +# ---- 11.4b 已完成栏 section 滚动位置保持(有实际滚动范围时) ---- +_long = "\n".join(f"line-{j}" for j in range(20)) # 20 行 → 撑满 out_box 230px 上限 +panel._layers["s6"].set_finished(True, f"$ cmd-s6\n{_long}\n[exit 0] (0.1s)") +panel._layers["s3"].set_finished(True, f"$ cmd-s3\n{_long}\n[exit 0] (0.2s)") +panel._layers["s6"].toggle() # 展开两层 → 内容必然超出栏高,产生真实滚动范围 +panel._layers["s3"].toggle() +settle(150) +sbar = panel.sec_done.scroll.verticalScrollBar() +sbar.setValue(30) +sv0 = sbar.value() +check("P11.13b 前置:已完成栏存在真实滚动范围(v>0)", sv0 > 0, f"sv0={sv0} max={sbar.maximum()}") +panel.on_started("t0", "bash", {"command": "cmd-t0"}) # 新任务 → 两栏重排 +panel.on_finished("t0", "bash", True, "$ cmd-t0\nok\n[exit 0] (0.1s)") +settle(150) +check("P11.13c 重排后已完成栏 section 滚动值保持", sbar.value() == sv0, + f"{sbar.value()} vs {sv0}") +check("P11.13d 展开态在重排后仍保持", panel._layers["s6"].expanded is True + and panel._layers["s3"].expanded is True) + +# ---- 11.5 限量窗口:窗口成员不变,仅显示顺序反转(顶层 = 最新启动) ---- +for i in range(1, 32): + cid = f"t{i}" + panel.on_started(cid, "bash", {"command": f"cmd-{cid}"}) + panel.on_finished(cid, "bash", True, f"$ cmd-{cid}\nok\n[exit 0] (0.1s)") +settle(200) +check("P11.14 内部仍保留全部 36 个已完成(5 手工 + 31 批量)", len(panel.layer_ids("done")) == 36, + str(len(panel.layer_ids("done")))) +check("P11.15 限量显示仍为 30 层", panel.sec_done.count.text() == str(LAYER_LIMIT), + panel.sec_done.count.text()) +check("P11.16 「仅显示最近 N 层」提示保留", "仅显示最近 30 层" in panel.sec_done.hint.text(), + panel.sec_done.hint.text()) +_rendered_done = [w for w in (panel.sec_done.lay.itemAt(i).widget() + for i in range(panel.sec_done.lay.count())) + if w is not None] +check("P11.17 渲染窗口 = 最近启动的 30 个(不含最早启动的 s1/s2/s3/s6/t0/t1)", + [w.call_id for w in _rendered_done][0] == "t31" + and "s1" not in [w.call_id for w in _rendered_done] + and "s2" not in [w.call_id for w in _rendered_done] + and len(_rendered_done) == 30, + str([w.call_id for w in _rendered_done][:5])) +check("P11.18 已完成栏顶层 = 最新启动(t31),底层 = 窗口内最早(t2)", + [w.call_id for w in _rendered_done][0] == "t31" + and [w.call_id for w in _rendered_done][-1] == "t2", + str([w.call_id for w in _rendered_done][:2] + [w.call_id for w in _rendered_done][-1:])) + +# ---- 11.6 DB 重建(切换会话):启动序号 = 消息链顺序 + 时间线内顺序 → 显示倒序 ---- +sid5 = window.db.create_session("P2-01 启动倒序")["id"] +leaf5 = window.db.get_session_leaf(sid5) +tl5 = json.dumps([{"t": "tool", "id": f"db{i}", "name": "bash", + "args": json.dumps({"command": f"db-cmd{i}"}), + "ok": True, "result": f"$ db-cmd{i}\nok\n[exit 0] (0.1s)"} + for i in range(3)], ensure_ascii=False) +window.db.add_message(sid5, "assistant", "a", leaf5, timeline=tl5) +window.load_messages_to_web(sid5) +settle(300) +check("P11.19 DB 重建后已完成栏按启动倒序(db2 顶层、db0 底层)", + panel.layer_ids("done") == ["db2", "db1", "db0"], str(panel.layer_ids("done"))) +check("P11.20 「all」仍返回原始启动序号(正序,调试口径不变)", + panel.layer_ids() == ["db0", "db1", "db2"], str(panel.layer_ids())) + +print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True) +if os.path.exists(_DB_TMP): + os.remove(_DB_TMP) +if os.path.exists(_CFG_TMP): + os.remove(_CFG_TMP) +# offscreen 铁律:QtWebEngine 渲染/GPU 子进程在解释器退出时可能不回收 → 挂起; +# 与 smoke_offscreen 等 offscreen harness 一致,用 os._exit 强制收尾(stdout 已 flush) +os._exit(0 if ok else 1) diff --git a/tests/smoke_copy_session.py b/tests/smoke_copy_session.py new file mode 100644 index 0000000..9652ca5 --- /dev/null +++ b/tests/smoke_copy_session.py @@ -0,0 +1,156 @@ +"""会话复制 · UI 入口端到端(offscreen) +链路:弹窗「📋 复制」按钮 → action_triggered("copy") → on_session_action → + DBManager.copy_session → 侧边栏重建 + 自动切到副本 + +运行: QT_QPA_PLATFORM=offscreen python tests/smoke_copy_session.py +""" +import os +import sys +import tempfile +import shutil + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +# 铁律:测试不得污染真实 data/chat_history.db → 默认 DB 路径重定向到临时文件 +import core.db_manager as _dbm # noqa: E402 +_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_copy_ui_{os.getpid()}.db") +if os.path.exists(_DB_TMP): + os.remove(_DB_TMP) +_dbm._DEFAULT_DB = _DB_TMP + +_FS_TMP = tempfile.mkdtemp(prefix="hocode_copy_ui_files_") # 临时附件根 + +from PyQt6.QtWidgets import QApplication, QPushButton # noqa: E402 +from PyQt6 import QtCore # noqa: E402 +from ui.views.main_window import MainWindow, SessionContextPopup # noqa: E402 + +app = QApplication(sys.argv) +ok = True + + +def check(name, cond, extra=""): + global ok + print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" {extra}"), flush=True) + if not cond: + ok = False + + +def chain_sig(sid): + return [(m["role"], m["content"]) for m in window.db.get_message_chain(sid)] + + +window = MainWindow() +window.db.files_root = _FS_TMP + + +# ====================================================================== +# 1) 准备一个带内容的源会话(直接走 DB 层,不触发真实 API 请求) +# ====================================================================== +window.on_new_chat_clicked() +src = window.current_session_id +for role, text in [("user", "帮我看看这个文件"), ("assistant", "好的,我先读一下")]: + window.db.add_message(src, role, text, window.db.get_session_leaf(src)) +window.db.mark_session_has_messages(src) +window.rebuild_sidebar() +src_title = [s for s in window.db.get_all_sessions() if s["id"] == src][0]["title"] +check("源会话已就绪(2 条消息)", len(window.db.get_message_chain(src)) == 3, + str(len(window.db.get_message_chain(src)))) + +# 图片附件(验证 UI 链路里附件也被深拷贝) +os.makedirs(os.path.join(_FS_TMP, "data", "attachments"), exist_ok=True) +png = os.path.join(_FS_TMP, "data", "attachments", "ui_src.png") +with open(png, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\nUI-IMAGE-BYTES") +meta = ('{"user_text":"看图","attachments":[{"type":"image","size_kb":0.1,' + '"local_path":"data/attachments/ui_src.png"}]}') +window.db.add_message(src, "user", "以及这张图", window.db.get_session_leaf(src), + attachment_metadata=meta) + +# ====================================================================== +# 2) 弹窗里存在「复制」按钮,且点击后发出 ("copy", sid) +# ====================================================================== +popup = SessionContextPopup(src, False, window) +btns = popup.findChildren(QPushButton) +texts = [b.text() for b in btns] +check("弹窗含「📋 复制」按钮", any("复制" in t for t in texts), str(texts)) +check("按钮顺序 编辑/星标/复制/删除", + len(texts) == 4 and "编辑" in texts[0] and "星标" in texts[1] + and "复制" in texts[2] and "删除" in texts[3], str(texts)) + +got = [] +popup.action_triggered.connect(lambda a, s: got.append((a, s))) +copy_btn = [b for b in btns if "复制" in b.text()][0] +copy_btn.click() +check("点击复制按钮发出 copy 动作", got == [("copy", src)], str(got)) + +# ====================================================================== +# 3) on_session_action("copy") → 真实克隆 + 侧边栏刷新 + 切到副本 +# ====================================================================== +n_before = len(window.db.get_all_sessions()) +window.on_session_action("copy", src) +sessions = window.db.get_all_sessions() +check("会话数 +1", len(sessions) == n_before + 1, f"{n_before}→{len(sessions)}") + +rows = [s for s in sessions if s["title"] == src_title + " (副本)"] +check("副本标题 = 源标题 + ' (副本)'", len(rows) == 1, str([s["title"] for s in sessions])) +copy_id = rows[0]["id"] if rows else None + +if copy_id: + check("副本不是源(ID 不同)", copy_id != src) + check("消息链完全一致", chain_sig(src) == chain_sig(copy_id)) + check("已自动切到副本", window.current_session_id == copy_id, str(window.current_session_id)) + check("侧边栏含副本项", + any(window.history_list.item(i).data(QtCore.Qt.ItemDataRole.UserRole) == copy_id + for i in range(window.history_list.count()))) + check("副本排在列表顶部(sort_order 最小)", + sessions[0]["id"] == copy_id, sessions[0]["title"]) + check("副本不带星标", rows[0]["is_starred"] == 0) + check("副本 mode 跟随源(未锁定为 None)", + rows[0]["mode"] == window.db.get_session_mode(src)) + + # 附件深拷贝:新旧文件同时存在,且副本 metadata 指向新文件 + import json as _json + c_rows = [m for m in window.db.get_message_chain(copy_id) if m.get("attachment_metadata")] + c_meta = _json.loads(c_rows[-1]["attachment_metadata"]) + new_rel = c_meta["attachments"][0]["local_path"] + check("副本附件已改名", new_rel != "data/attachments/ui_src.png", new_rel) + check("新旧图片文件同时存在", + os.path.isfile(png) and os.path.isfile(os.path.join(_FS_TMP, new_rel))) + check("副本图片内容一致", + open(os.path.join(_FS_TMP, new_rel), "rb").read() + == open(png, "rb").read()) + + # 删副本后源完好(UI 链路下的删除隔离) + window.db.delete_session(copy_id) + check("删副本后源消息链不变", chain_sig(src) == [ + ("system", "你是一个优秀的助手!"), ("user", "帮我看看这个文件"), + ("assistant", "好的,我先读一下"), ("user", "以及这张图")]) + check("删副本后源图片仍在", os.path.isfile(png)) + +# ====================================================================== +# 4) 生成中拒绝复制 +# ====================================================================== +n_before2 = len(window.db.get_all_sessions()) +window._active_streams[src] = {"worker": None} +window.on_session_action("copy", src) +check("会话生成中 → 拒绝复制(无新会话)", + len(window.db.get_all_sessions()) == n_before2) +window._active_streams.pop(src, None) + +# 5) 源不存在 → 静默失败,不崩 +try: + window.on_session_action("copy", "sess_not_exist_zzz") + check("源不存在时不抛异常", True) +except Exception as e: + check("源不存在时不抛异常", False, str(e)) + +print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True) + +shutil.rmtree(_FS_TMP, ignore_errors=True) +if os.path.exists(_DB_TMP): + os.remove(_DB_TMP) +sys.exit(0 if ok else 1) diff --git a/tests/smoke_live_guard.py b/tests/smoke_live_guard.py new file mode 100644 index 0000000..b8228e6 --- /dev/null +++ b/tests/smoke_live_guard.py @@ -0,0 +1,280 @@ +# -*- coding: utf-8 -*- +"""四项 UI 增强回归测试(真实 WebEngine 页面): +1. rAF 卡死模拟(AMD 核显/合成器不出帧)→ 40ms 兜底仍实时渲染流式正文 +2. 深度思考默认收起 + .streaming-think 蓝色动画类;收尾后标签还原 +3. bash chip 耗时徽章([exit 0] (1.2s))+ 超时徽章(命令超时(>120s)) +4. 长结果:默认尾部 4000 字 + 展开按钮;点击展开全文/再点收起; + 收起状态下 finish 后结果仍可见(修复「结束后展开为空」) +运行: python tests/smoke_live_guard.py +""" +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtCore import QTimer # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 + +app = QApplication(sys.argv) +window = None +results = [] + + +def check(name, ok, detail=""): + print(f" {'PASS' if ok else 'FAIL'} {name}" + + (f" [{detail}]" if detail and not ok else "")) + results.append(ok) + + +# Phase 0: rAF + 所有页面定时器全灭(极端节流)→ +# 内容必须上屏:同步通道(token 内)+ Qt 看门狗(200ms,forceRenderNow) +JS_PHASE_0A = r""" +(function() { + var out = {}; + window.__realRAF = window.requestAnimationFrame; + window.__realCAF = window.cancelAnimationFrame; + window.__realTO = window.setTimeout; + window.__realCTO = window.clearTimeout; + window.__realSI = window.setInterval; + window.__realCSI = window.clearInterval; + window.__fakeRafId = 0; + window.__fakeTimerId = 1000; + window.requestAnimationFrame = function() { return ++window.__fakeRafId; }; + window.cancelAnimationFrame = function(){}; + window.setTimeout = function() { return ++window.__fakeTimerId; }; + window.clearTimeout = function(){}; + window.setInterval = function() { return ++window.__fakeTimerId; }; + window.clearInterval = function(){}; + + var m0 = 'lg-sync-' + Date.now(); + window.__lgM0 = m0; + createMessage(m0, 'assistant', '', 'LG'); + appendReasoning(m0, 'sync think '); + appendToken(m0, 'sync text one '); + appendToken(m0, 'two'); + var segs = document.querySelectorAll('#' + m0 + ' .md-segment'); + var t = ''; + for (var i = 0; i < segs.length; i++) t += segs[i].textContent || ''; + out.textAtBurst = t; + return JSON.stringify(out); +})() +""" + +JS_PHASE_0B = r""" +(function() { + var out = {}; + var m0 = window.__lgM0; + var segs = document.querySelectorAll('#' + m0 + ' .md-segment'); + var t = ''; + for (var i = 0; i < segs.length; i++) t += segs[i].textContent || ''; + var tcs = document.querySelectorAll('#' + m0 + ' .think-content'); + var th = ''; + for (var i = 0; i < tcs.length; i++) th += tcs[i].textContent || ''; + out.text = t; + out.think = th; + // 恢复页面定时器 + window.requestAnimationFrame = window.__realRAF; + window.cancelAnimationFrame = window.__realCAF; + window.setTimeout = window.__realTO; + window.clearTimeout = window.__realCTO; + window.setInterval = window.__realSI; + window.clearInterval = window.__realCSI; + finishMessage(m0); + return JSON.stringify(out); +})() +""" + +# Phase A: 模拟 rAF 永不触发 + 流式 token;断言此刻 DOM 尚空(卡死生效) +JS_PHASE_A = r""" +(function() { + var out = {}; + window.__realRAF = window.requestAnimationFrame; + window.__realCAF = window.cancelAnimationFrame; + // 模拟真实卡死:rAF 返回真实挂起 ID(truthy)但回调永不被调用 + window.__fakeRafId = 0; + window.requestAnimationFrame = function(cb) { window.__rafCalls = (window.__rafCalls||0)+1; return ++window.__fakeRafId; }; + window.cancelAnimationFrame = function(){}; + + var mid = 'lg-' + Date.now(); + window.__lgMid = mid; + createMessage(mid, 'assistant', '', 'LG'); + appendReasoning(mid, 'deep '); + appendReasoning(mid, 'thinking...'); + appendToken(mid, 'live text one '); + appendToken(mid, 'two '); + appendToken(mid, 'three'); + + var segs = document.querySelectorAll('#' + mid + ' .md-segment'); + var domLen = 0; + for (var i = 0; i < segs.length; i++) + domLen += (segs[i].textContent || '').length; + out.rafCalls = window.__rafCalls || 0; + out.domLenBeforeTimers = domLen; + + // 思考块状态(默认收起 + 蓝色动画类) + var block = document.querySelector('#' + mid + ' .think-block'); + out.thinkOpen = block ? block.open : null; + out.thinkStreaming = block ? block.classList.contains('streaming-think') : false; + out.thinkLabel = block ? (block.querySelector('.think-label')||{}).textContent : null; + return JSON.stringify(out); +})() +""" + +# Phase B(timers 跑过之后): 兜底应已渲染正文 + 收尾 + 其余特性 +JS_PHASE_B = r""" +(function() { + var out = {}; + var mid = window.__lgMid; + var segs = document.querySelectorAll('#' + mid + ' .md-segment'); + var domText = ''; + for (var i = 0; i < segs.length; i++) domText += segs[i].textContent || ''; + out.liveTextRendered = domText.indexOf('live text one two three') !== -1; + + // 收尾:思考块标签还原 + 动画类移除 + finishMessage(mid); + var block = document.querySelector('#' + mid + ' .think-block'); + out.thinkLabelAfter = block ? (block.querySelector('.think-label')||{}).textContent : null; + out.thinkStreamingAfter = block ? block.classList.contains('streaming-think') : false; + // 恢复 rAF + window.requestAnimationFrame = window.__realRAF; + window.cancelAnimationFrame = window.__realCAF; + + // ---- 耗时 / 超时徽章 ---- + var mid2 = 'lg-timing-' + Date.now(); + createMessage(mid2, 'assistant', '', 'LG'); + toolExecutionStarted(mid2, 'call-ok', 'bash', JSON.stringify({command:'echo hi'})); + toolExecutionFinished(mid2, 'call-ok', 'bash', true, '$ echo hi\nhi\n[exit 0] (1.2s)'); + toolExecutionStarted(mid2, 'call-tmo', 'bash', JSON.stringify({command:'sleep 999'})); + toolExecutionFinished(mid2, 'call-tmo', 'bash', false, '命令超时(>120s)已终止'); + var w2 = document.getElementById(mid2); + var chipOk = w2.querySelector('[data-call-id="call-ok"]'); + var chipTmo = w2.querySelector('[data-call-id="call-tmo"]'); + out.durBadge = chipOk ? (chipOk.querySelector('.tool-chip-time')||{}).textContent : null; + out.tmoBadge = chipTmo ? (chipTmo.querySelector('.tool-chip-timeout')||{}).textContent : null; + + // ---- 收起状态下 finish:结果必须已写入(展开可见) ---- + var livePre = chipOk ? chipOk.querySelector('.tool-chip-live') : null; + out.resultWhileCollapsed = livePre ? (livePre.textContent || '').indexOf('hi') !== -1 : false; + + // ---- 长结果:尾部预览 + 展开/收起 ---- + var longRes = 'L'.repeat(1000) + ' MIDDLE-MARKER ' + 'R'.repeat(5000); // 6015 字,标记在头部(预览窗外) + toolExecutionStarted(mid2, 'call-long', 'read', JSON.stringify({path:'/big.txt'})); + toolExecutionFinished(mid2, 'call-long', 'read', true, longRes); + var chipLong = w2.querySelector('[data-call-id="call-long"]'); + var pre = chipLong ? chipLong.querySelector('.tool-chip-live') : null; + var btn = chipLong ? chipLong.querySelector('.tool-expand-btn') : null; + out.longHasBtn = !!btn; + out.longPreviewLen = pre ? (pre.textContent || '').length : 0; + out.longPreviewIsTail = pre ? (pre.textContent || '').lastIndexOf('R') > (pre.textContent||'').lastIndexOf('MIDDLE-MARKER') : false; + out.longPreviewHasMarker = pre ? (pre.textContent || '').indexOf('MIDDLE-MARKER') !== -1 : true; + if (btn) { + btn.click(); + out.expandedHasMarker = pre ? (pre.textContent || '').indexOf('MIDDLE-MARKER') !== -1 : false; + out.expandedLen = pre ? (pre.textContent || '').length : 0; + out.fullClass = pre ? pre.classList.contains('tool-chip-full') : false; + btn.click(); + out.collapseBackLen = pre ? (pre.textContent || '').length : 0; + } + return JSON.stringify(out); +})() +""" + + +def step1(): + global window + print("[step1] creating MainWindow...", flush=True) + window = MainWindow() + window.show() + print("[step1] shown, scheduling step2", flush=True) + QTimer.singleShot(3000, step2) + + +def step2(): + print("[step2] phase 0: kill all page timers + burst tokens", flush=True) + # 注册为当前会话的活跃流 → Qt 看门狗(200ms)会对它 forceRenderNow + window.current_session_id = "fake-sync-sid" + window._active_streams["fake-sync-sid"] = { + "msg_id": None, "content": "pending", "reasoning": "", + "timeline": [], "tl_kind": None, "parent_id": None, + "branch_info": None, "worker": None, "previous_leaf_id": None, + } + window.browser.page().runJavaScript(JS_PHASE_0A, on_p0a) + + +def on_p0a(res): + a = json.loads(str(res)) + print(f" phase0 burst 瞬间: text={a['textAtBurst']!r}") + # 把 JS 里生成的 mid 告诉看门狗(msg_id 需一致才会 forceRenderNow) + # —— 简化:直接改 JS 端记录的 mid 对应的 buffer 即可,这里用 runJavaScript 对齐 + window.browser.page().runJavaScript( + "window.__lgM0", lambda r: _align_and_wait(str(r))) + + +def _align_and_wait(mid): + mid = mid.strip().strip('"') + window._active_streams["fake-sync-sid"]["msg_id"] = mid + print("[phase0] 等待 Qt 看门狗渲染 (400ms) ...", flush=True) + QTimer.singleShot(400, on_p0b) + + +def on_p0b(): + window.browser.page().runJavaScript(JS_PHASE_0B, on_p0) + + +def on_p0(res): + p = json.loads(str(res)) + print(f" phase0 400ms 后: text={p['text']!r} think={p['think']!r}") + check("定时器全灭: burst 内思考同步上屏", "sync think" in p["think"], str(p)) + check("定时器全灭: 正文经同步通道/看门狗上屏", + "sync text one two" in p["text"], str(p)) + window._active_streams.pop("fake-sync-sid", None) + window.browser.page().runJavaScript(JS_PHASE_A, on_a) + + +def on_a(res): + print("[on_a] got result:", str(res)[:120], flush=True) + a = json.loads(str(res)) + print(f" phaseA: rafCalls={a['rafCalls']} domLenBefore={a['domLenBeforeTimers']}") + # v7 起:首 token 插入前已同步渲染(:empty 布局失效修复的副作用——首字必现), + # 故此刻 DOM 可能已有首 token 内容;本断言只验证 rAF 被调用但从未触发。 + check("rAF 被卡死(0 帧触发)", a["rafCalls"] > 0, str(a)) + check("思考块默认收起", a["thinkOpen"] is False) + check("思考块带蓝色动画类 streaming-think", a["thinkStreaming"]) + QTimer.singleShot(300, step3) + + +def step3(): + window.browser.page().runJavaScript(JS_PHASE_B, on_b) + + +def on_b(res): + b = json.loads(str(res)) + check("rAF 卡死时兜底仍实时渲染正文", b["liveTextRendered"], str(b)) + check("收尾后思考标签还原为『已完成深度思考』", + b["thinkLabelAfter"] == "已完成深度思考", str(b["thinkLabelAfter"])) + check("收尾后移除 streaming-think 动画类", b["thinkStreamingAfter"] is False) + check("耗时徽章 ⏱ 1.2s", b["durBadge"] == "⏱ 1.2s", str(b["durBadge"])) + check("超时徽章 ⏱ 超时 120s", b["tmoBadge"] == "⏱ 超时 120s", str(b["tmoBadge"])) + check("收起状态下 finish 后结果仍可见(修复展开为空)", b["resultWhileCollapsed"]) + check("长结果有展开按钮", b["longHasBtn"]) + check("长结果默认只显示尾部 4002 字", b["longPreviewLen"] == 4002, + str(b["longPreviewLen"])) + check("尾部预览不含中段标记(标记在头部)", b["longPreviewIsTail"] + and b["longPreviewHasMarker"] is False) + check("点击展开全文(含中段标记 + full 类)", + b["expandedHasMarker"] and b["fullClass"] + and b["expandedLen"] > 5000, str(b)) + check("再点收回到尾部预览", b["collapseBackLen"] == 4002, + str(b["collapseBackLen"])) + print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: " + f"{sum(results)}/{len(results)} =====") + app.quit() + + +QTimer.singleShot(500, step1) +app.exec() diff --git a/tests/smoke_manual.py b/tests/smoke_manual.py new file mode 100644 index 0000000..e1fd0f4 --- /dev/null +++ b/tests/smoke_manual.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""验证人肉 debug 埋点链路: +Python [正文]/[思考] 打印 + JS console 桥 → [JS] 打印 全部出现在控制台 +""" +import os, sys +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from PyQt6.QtWidgets import QApplication +import PyQt6.QtWebEngineWidgets # noqa +from PyQt6.QtCore import QTimer +from ui.views.main_window import MainWindow + +app = QApplication(sys.argv) +window = MainWindow() +window.resize(1280, 800) +window.show() + +MID = "manual-debug-test" + +def step1(): + # 模拟一次真实的 send→token→finish 生命周期 + window.chat_bridge.create_message(MID, "assistant", "", "Test") + window.chat_bridge.append_reasoning(MID, "这是思考内容A") + window.chat_bridge.append_reasoning(MID, "思考B") + window.chat_bridge.append_token(MID, "这是正文第一段。") + window.chat_bridge.append_token(MID, "正文第二段来了。") + window.chat_bridge.tool_execution_started(MID, "call-xyz", "bash", "echo hi") + window.chat_bridge.tool_execution_finished(MID, "call-xyz", "bash", True, "hi\n[exit 0] (0.1s)") + QTimer.singleShot(800, step2) + +def step2(): + window.chat_bridge.finish_message(MID) + QTimer.singleShot(1200, done) + +def done(): + app.quit() + +QTimer.singleShot(2000, step1) +app.exec() +print("===== 测试结束(上方应看到 [生命周期]/[JS] 打印)=====") diff --git a/tests/smoke_midswitch.py b/tests/smoke_midswitch.py new file mode 100644 index 0000000..b915b04 --- /dev/null +++ b/tests/smoke_midswitch.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +"""中途切会话回归:agent 流式进行中切走 → 切回 → 时间线必须完整 +(用户报告:切走再切回,正文/思考那一段显示不出来) +运行: python tests/smoke_midswitch.py +""" +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost + +# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成 +from tests._test_env import isolate # noqa: E402 +_TMP = isolate("midswitch") # noqa: E402 + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtCore import QTimer # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 + +app = QApplication(sys.argv) +window = None +results = [] +S = {} # 测试状态 + + +def check(name, ok, detail=""): + print(f" {'PASS' if ok else 'FAIL'} {name}" + + (f" [{detail}]" if detail and not ok else "")) + results.append(ok) + + +CHECK_JS = r""" +(function() { + var mid = window.__msMid; + var w = document.getElementById(mid); + if (!w) return JSON.stringify({error: 'wrapper missing'}); + var tl = w.querySelector('.reply-content'); + var blocks = Array.prototype.map.call(tl.children, function(el) { + return el.className.split(' ')[0]; + }); + var texts = Array.prototype.map.call( + w.querySelectorAll('.md-segment'), + function(s) { return s.textContent || ''; }); + var thinks = Array.prototype.map.call( + w.querySelectorAll('.think-content'), + function(s) { return s.textContent || ''; }); + return JSON.stringify({blocks: blocks, texts: texts, thinks: thinks, + streaming: w.classList.contains('streaming')}); +})() +""" + + +def fire(session, fn, *args): + """模拟 worker 信号到达(UI 线程直接调 handler)""" + getattr(window, fn)(*([session] + list(args))) + + +def step1(): + global window + window = MainWindow() + window.show() + QTimer.singleShot(3000, step2) + + +def step2(): + db = window.db + sessA = db.create_session("切走测试A") + sessB = db.create_session("切走测试B") + A, B = sessA["id"], sessB["id"] + db.add_message(B, "user", "B 的问题", None) + + # ---- 会话 A 开始流式(手动模拟事件到达)---- + user_row = db.add_message(A, "user", "A 的问题", None) + mid = "msg-midswitch" + window.current_session_id = A + window.chat_bridge.create_message(mid, "assistant", "", "SW") + window._active_streams[A] = { + "msg_id": mid, "content": "", "reasoning": "", + "timeline": [], "tl_kind": None, + "parent_id": user_row["id"], "branch_info": None, "worker": None, + "previous_leaf_id": user_row["id"], + } + S.update(A=A, B=B, mid=mid) + window.browser.page().runJavaScript(f"window.__msMid = '{mid}';") + + # 阶段 1(A 前台):think1 + text1 + 工具 + fire(A, "on_reasoning_received", "思考第一") + fire(A, "on_reasoning_received", "段内容") + fire(A, "on_chunk_received", "工具前正文") + fire(A, "_on_tool_started", "call-ms", "bash", '{"command":"echo ms"}') + + QTimer.singleShot(800, switch_away) + + +def switch_away(): + """切到 B:A 的流继续在后台跑""" + print("[test] 切换到 B ...", flush=True) + window.load_messages_to_web(S["B"]) + # 阶段 2(A 后台):think2 + text2(只应累积,不进 JS) + QTimer.singleShot(600, lambda: ( + fire(S["A"], "on_reasoning_received", "思考第二"), + fire(S["A"], "on_chunk_received", "工具后正文"), + print("[test] 后台 token 已累积", flush=True), + QTimer.singleShot(600, switch_back) + )) + + +def switch_back(): + """切回 A:应重建 wrapper + 恢复时间线 + 后续无缝续流""" + print("[test] 切回 A ...", flush=True) + window.load_messages_to_web(S["A"]) + # ★ 切回后立即检查:恢复的正文/思考必须已同步上屏(不等 rAF/timer) + def check_right_away(): + window.browser.page().runJavaScript(CHECK_JS, lambda res: ( + print("[切回后立即]", str(res), flush=True), + check("切回后立即: 后台正文已同步上屏", + "工具后正文" in json.loads(str(res)).get("texts", [""] + [json.loads(str(res))["texts"][0] if json.loads(str(res)).get("texts") else ""])[0] or "工具后正文" in "".join(json.loads(str(res)).get("texts", []))), + QTimer.singleShot(300, phase3) + )) + def phase3(): + fire(S["A"], "on_chunk_received", " 续流正文") + QTimer.singleShot(600, finish_it) + QTimer.singleShot(400, check_right_away) + + +def finish_it(): + window.on_reply_finished(S["A"]) + QTimer.singleShot(400, step_check) + + +DIAG_JS = r""" +(function() { + var wrappers = Array.prototype.map.call( + document.querySelectorAll('.message-wrapper'), + function(w) { return w.id + ':' + w.className.split(' ')[1]; }); + return JSON.stringify({wrappers: wrappers, + bufKeys: Object.keys(window.messageBuffer || {})}); +})() +""" + +def step_check(): + def diag(res): + print("[diag]", str(res), flush=True) + window.browser.page().runJavaScript(CHECK_JS, on_check) + window.browser.page().runJavaScript(DIAG_JS, diag) + + +def on_check(res): + d = json.loads(str(res)) + if "error" in d: + check("切回后 wrapper 存在", False, d["error"]) + done() + return + blocks = d["blocks"] + texts = "".join(d["texts"]) + thinks = "".join(d["thinks"]) + print(f" blocks = {blocks}") + print(f" texts = {d['texts']}") + print(f" thinks = {d['thinks']}") + check("切回后 wrapper 存在", True) + check("块顺序与事件顺序一致: think→text→chip→think→text", + [b for b in blocks if b in + ("think-block", "tool-chip", "md-segment")] == + ["think-block", "md-segment", "tool-chip", "think-block", "md-segment"], + str(blocks)) + check("正文完整(切走前+后台+切回后)", + "工具前正文" in texts and "工具后正文" in texts + and "续流正文" in texts, str(d["texts"])) + check("思考完整(切走前+后台)", + "思考第一段内容" in thinks and "思考第二" in thinks, + str(d["thinks"])) + check("streaming 已收尾", not d["streaming"]) + # 入库验证 + row = window.db.get_message_chain(S["A"]) + asst = [m for m in row if m["role"] == "assistant"] + ok_db = False + if asst: + tl = asst[-1].get("timeline") + if tl: + tl_list = json.loads(tl) + joined = "".join(e.get("text", "") for e in tl_list if e["t"] == "text") + ok_db = ("工具前正文" in joined and "工具后正文" in joined + and "续流正文" in joined) + check("DB 时间线完整(三段正文都在)", ok_db) + done() + + +def done(): + try: + window.db.delete_session(S["A"]) + window.db.delete_session(S["B"]) + except Exception: + pass + print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: " + f"{sum(results)}/{len(results)} =====") + app.quit() + + +QTimer.singleShot(500, step1) +app.exec() diff --git a/tests/smoke_mode.py b/tests/smoke_mode.py new file mode 100644 index 0000000..cc07655 --- /dev/null +++ b/tests/smoke_mode.py @@ -0,0 +1,94 @@ +"""offscreen 模式切换功能测试:DB 持久化 + 首条消息锁定 + worker 分派 +运行: QT_QPA_PLATFORM=offscreen python tests/smoke_mode.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +# 铁律:测试不得污染真实 data/chat_history.db → DBManager 默认路径重定向到临时文件 +import tempfile as _tf # noqa: E402 +import core.db_manager as _dbm # noqa: E402 +_dbm._DEFAULT_DB = os.path.join(_tf.gettempdir(), f"haocode_test_smoke_mode_{os.getpid()}.db") + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 须先于 QApplication import 完成) + +app = QApplication(sys.argv) +ok = True + + +def check(name, cond): + global ok + print((" PASS " if cond else " FAIL ") + name) + if not cond: + ok = False + + +window = MainWindow() + +# 本套件验证「默认锁定」语义:显式置 mode_switch=false,不受本机 data/config.json 影响 +# (仅内存,不会写回配置文件) +window.config_data["mode_switch"] = False +window._refresh_mode_button() + +# 1) 新建会话 → 未锁定 +window.on_new_chat_clicked() +sid = window.current_session_id +check("新会话未锁定 (mode=None)", window._get_current_mode() is None) +check("按钮显示待选 Chat", "Chat" in window.btn_mode.text() and "🔒" not in window.btn_mode.text()) + +# 2) 浮动弹窗开/关 + 选择 +window.show_mode_popup() +check("弹窗打开", window.mode_popup.isVisible()) +window._select_mode("worker") +check("选 worker 后按钮更新", "Worker" in window.btn_mode.text()) +check("选择后弹窗自动关闭", not window.mode_popup.isVisible()) + +# 3) 首条发送 → 锁定 +window._lock_session_mode() +check("发送后锁定 worker", window.db.get_session_mode(sid) == "worker") +check("按钮显示锁定", "🔒" in window.btn_mode.text()) + +# 4) 锁定后不可改 +window._select_mode("chat") +check("锁定后选择无效", window.db.get_session_mode(sid) == "worker") + +# 5) worker 分派 +w1 = window._create_stream_worker([], "worker") +w2 = window._create_stream_worker([], "chat") +from core.llm_engine import AgentWorker, ChatWorker # noqa: E402 +check("worker 模式 → AgentWorker", isinstance(w1, AgentWorker)) +check("chat 模式 → ChatWorker", isinstance(w2, ChatWorker)) +check("ChatWorker 有基础信号", all(hasattr(w2, s) for s in + ("chunk_received", "reasoning_received", "error_occurred"))) +check("ChatWorker 无工具信号(chat 模式不暴露)", + not hasattr(w2, "tool_execution_started")) + +# 6) 切回新会话 → 解锁 +window.on_new_chat_clicked() +check("新会话恢复可选", window._get_current_mode() is None and "🔒" not in window.btn_mode.text()) + +# 7) 旧会话(无 mode 列值)→ 默认 chat 路径 +window.load_messages_to_web(sid) +check("切回已锁定会话显示锁定", window._get_current_mode() == "worker" and "🔒" in window.btn_mode.text()) + +# 8) mode_switch=true → 中途切换放行(旧锁定会话解锁 + 选择立即落库) +window.config_data["mode_switch"] = True +window._refresh_mode_button() +check("开关开启后旧会话解锁显示", "🔒" not in window.btn_mode.text()) +window._select_mode("chat") +check("开关开启后中途切换生效", window.db.get_session_mode(sid) == "chat") +window.config_data["mode_switch"] = False + +try: + window.close() +except Exception: + pass +app.quit() +print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====") +sys.exit(0 if ok else 1) diff --git a/tests/smoke_offscreen.py b/tests/smoke_offscreen.py new file mode 100644 index 0000000..2e77586 --- /dev/null +++ b/tests/smoke_offscreen.py @@ -0,0 +1,206 @@ +"""offscreen 冒烟测试:主窗口实例化 + 核心链路(不启动真实 LLM) +运行: QT_QPA_PLATFORM=offscreen python tests/smoke_offscreen.py +""" +import os +import sys +import json +import time + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost # 软渲染,离屏最稳 + +# 铁律:测试不得污染真实 data/chat_history.db → DBManager 默认路径重定向到临时文件 +import tempfile as _tf # noqa: E402 +import core.db_manager as _dbm # noqa: E402 +_dbm._DEFAULT_DB = os.path.join(_tf.gettempdir(), f"haocode_test_smoke_offscreen_{os.getpid()}.db") + +# P1-03:QtWebEngine 独立 profile 重定向到临时目录(不占项目 data/webengine/) +os.environ.setdefault( + "HAOCODE_WEBENGINE_PROFILE_DIR", + os.path.join(_tf.gettempdir(), f"haocode_test_smoke_offscreen_{os.getpid()}_profile")) + +import ctypes # noqa: E402 + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtCore import QTimer # noqa: E402 + +# QtWebEngine 必须在 QApplication 创建前 import +from ui.views.main_window import MainWindow # noqa: E402 + +app = QApplication(sys.argv) + +results = [] + + +def check(name, fn): + try: + fn() + print(f" PASS {name}") + results.append(True) + except Exception as e: + import traceback + traceback.print_exc() + print(f" FAIL {name}: {e}") + results.append(False) + + +window = None +page_ready = {"ok": False} + + +def on_page_load_progress(v): + pass + + +def on_js_console(level, msg, line, src): + pass + + +def try_load(): + global window + try: + window = MainWindow() + page_ready["ok"] = True + except Exception: + import traceback + traceback.print_exc() + finish(False) + return + # MainWindow 构造完成时 HTML/JS 已就绪(loadFinished 可能早于连接) + QTimer.singleShot(6000, run_checks) # 等 JS 引擎 + 历史渲染完成 + + +def run_checks(): + # 1) 窗口已创建 + check("MainWindow 实例化", lambda: (_ for _ in ()).throw(AssertionError("no window")) if window is None else None) + + # 2) DB 链路 + def db_chain(): + assert window.db is not None + sessions = window.db.get_all_sessions() + assert isinstance(sessions, list) + + check("DB 会话列表", db_chain) + + # 3) build_api_context(P0 修复验证:不再有 reasoning 字段) + def ctx_build(): + sid = window.current_session_id + if not sid: + return + msgs = window.build_api_context(sid) + assert isinstance(msgs, list) + for m in msgs: + assert "reasoning" not in m, f"reasoning 字段仍在 API payload 里: {m.get('role')}" + + check("build_api_context 无 reasoning 字段", ctx_build) + + # 4) CJK token 估算 + def est(): + n = window._estimate_token_count([ + {"role": "user", "content": "你好,世界!这是一段中文测试。"}, + {"role": "assistant", "content": "hello world " * 20}, + ]) + assert isinstance(n, int) and n > 0 + + check("CJK token 估算", est) + + # 5) AgentWorker 可构造 + 信号齐全 + def worker(): + from core.llm_engine import AgentWorker, TitleWorker + w = AgentWorker(window.current_provider, window.current_model, + [{"role": "user", "content": "x"}]) + for sig in ("chunk_received", "reasoning_received", "error_occurred", + "tool_execution_started", "tool_execution_updated", + "tool_execution_finished", "context_compacted"): + assert hasattr(w, sig), sig + t = TitleWorker(window.current_provider, window.current_model, + [{"role": "user", "content": "x"}]) + assert hasattr(t, "chunk_received") + + check("AgentWorker/TitleWorker 构造", worker) + + # 6) bridge 工具方法 + def bridge(): + b = window.chat_bridge + for m in ("tool_execution_started", "tool_execution_updated", + "tool_execution_finished", "show_note"): + assert hasattr(b, m), m + + check("ChatBridge 工具事件方法", bridge) + + # 7) 离屏渲染 JS 就绪 + check("Web 页面加载完成", lambda: (_ for _ in ()).throw(AssertionError("page not ready")) if not page_ready["ok"] else None) + + # 8) 🌟 KaTeX 公式渲染(真实页面上下文:资源加载 + [...] 供应商格式 + 行内 $) + def _run_js(js, timeout_s=10): + result = {"val": None, "done": False} + + def on_ret(val): + if isinstance(val, str): + try: + val = json.loads(val) + except Exception: + pass + result["val"] = val + result["done"] = True + + if hasattr(window.browser, "execute_js_async"): # WebView2 路径(cb 收 JSON 字符串) + window.browser.execute_js_async(js, on_ret) + else: # QtWebEngine 路径(cb 收 Python 对象) + window.browser.page().runJavaScript(js, on_ret) + t0 = time.time() + while not result["done"] and time.time() - t0 < timeout_s: + app.processEvents() + time.sleep(0.05) + assert result["done"], "JS 执行超时" + return result["val"] + + def katex_render(): + # 等页面 JS 就绪(WV2 冷启动可能慢;app.js 就绪时置 window.jsReady=true) + for _ in range(30): + if _run_js("window.jsReady === true ? 1 : 0", timeout_s=3) == 1: + break + time.sleep(0.5) + else: + raise AssertionError("页面 JS 未就绪(jsReady)") + ver = _run_js("typeof katex !== 'undefined' ? katex.version : null") + assert ver, "katex 全局缺失(资源加载失败?)" + js = ("(function(){var s = '结果:" + chr(92) + "n[" + chr(92) + "nP_4=" + chr(92)*2 + "operatorname{BRF}(M_4,M_5)" + chr(92) + "n]" + chr(92) + "n" + chr(92) + "n行内 $E=mc^2$ 结束。';" + "var html = safeHtml(marked.parse(s));return {" + "has: html.indexOf('katex') !== -1," + "disp: (html.match(/katex-display/g) || []).length," + 'inline: (html.match(/class=\"katex\"/g) || []).length,' + "leaked: html.indexOf('@@K') !== -1," + "vis: html.indexOf('katex-html') !== -1," + "tex: html.indexOf('\\operatorname{BRF}') !== -1};})()") + r = _run_js(js) + assert isinstance(r, dict), f"意外返回: {r!r}" + assert r.get("has"), "未产生 KaTeX HTML" + assert r.get("disp", 0) >= 1, "块公式未渲染为 katex-display" + assert r.get("inline", 0) >= 1, "行内公式未渲染" + assert not r.get("leaked"), "占位符泄漏" + assert r.get("vis"), "缺少 katex-html 可视层(KaTeX 未真正渲染)" + assert r.get("tex"), "tex 未正确传入(转义错误)" + print(f" [info] KaTeX {ver} | display={r.get('disp')} inline={r.get('inline')}") + + check("KaTeX 公式渲染([...] 供应商格式 + 行内 $)", katex_render) + + print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====") + try: + window.close() + except Exception: + pass + app.quit() + + +def finish(ok=None): + app.quit() + + +QTimer.singleShot(500, try_load) +QTimer.singleShot(90000, finish) # 总超时 +app.exec() +sys.exit(0 if all(results) else 1) diff --git a/tests/smoke_persist.py b/tests/smoke_persist.py new file mode 100644 index 0000000..17e1e6c --- /dev/null +++ b/tests/smoke_persist.py @@ -0,0 +1,297 @@ +# -*- coding: utf-8 -*- +"""时间线持久化冒烟测试(真实 DB + 真实 WebEngine 页面): +1. 流式事件 → stream_state 时间线累积(思考/文本/工具 按序) +2. 入库(timeline 列)→ 切会话重载 → DOM 时间线还原(工具气泡不丢) +3. build_api_context 从时间线重建完整 API 链(assistant+tool_calls+tool) +4. 切回进行中的会话:restoreStreamingTimeline 续流 +运行: QT_QPA_PLATFORM=offscreen python tests/smoke_persist.py +""" +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtCore import QTimer # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 + +app = QApplication(sys.argv) +results = [] +window = None +test = {"sid": None, "mid": None, "done": False} + + +def check(name, ok, detail=""): + print(f" {'PASS' if ok else 'FAIL'} {name}" + + (f" [{detail}]" if detail and not ok else "")) + results.append(ok) + + +def finish(): + if test["done"]: + return + test["done"] = True + # 清理测试会话 + try: + if test["sid"] and window: + window.db.delete_session(test["sid"]) + except Exception: + pass + print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: " + f"{sum(results)}/{len(results)} =====") + app.quit() + + +# JS: 校验重载后的时间线 DOM +JS_VERIFY_RELOAD = r""" +(function() { + var out = {}; + try { + var mid = '__MID__'; + var wrapper = document.getElementById(mid); + if (!wrapper) return JSON.stringify({error: 'wrapper missing'}); + var tl = wrapper.querySelector('.reply-content'); + out.blocks = Array.prototype.map.call(tl.children, function(el) { + return el.className.split(' ')[0]; + }); + var chip = wrapper.querySelector('.tool-chip'); + var st = chip ? chip.querySelector('.tool-chip-status') : null; + out.chipStatus = st ? st.textContent : null; + out.chipOk = st ? st.classList.contains('ok') : false; + out.chipCallId = chip ? chip.getAttribute('data-call-id') : null; + var segs = wrapper.querySelectorAll('.md-segment'); + out.lastSegText = segs.length ? segs[segs.length - 1].textContent : ''; + out.thinkCount = wrapper.querySelectorAll('.think-block').length; + out.thinkChevron = !!wrapper.querySelector('.think-block .chev'); + } catch (e) { + out.error = String(e); + } + return JSON.stringify(out); +})() +""" + +# JS: 续流测试 +JS_RESUME_A = r""" +(function() { + try { + var mid = '__MID__'; + createMessage(mid, 'assistant', '', 'Resume'); + restoreStreamingTimeline(mid, '__TLJSON__'); + // 续流:接着最后一段文本写;思考新开一段 + appendToken(mid, '(续流文本)'); + appendReasoning(mid, '续思考内容'); + return 'resumeA-done'; + } catch (e) { + return 'resumeA-err:' + String(e); + } +})() +""" + +JS_RESUME_B = r""" +(function() { + try { + var mid = '__MID__'; + finishMessage(mid); + var wrapper = document.getElementById(mid); + var tl = wrapper.querySelector('.reply-content'); + var out = {}; + out.blocks = Array.prototype.map.call(tl.children, function(el) { + return el.className.split(' ')[0]; + }); + var segs = wrapper.querySelectorAll('.md-segment'); + out.lastSegText = segs.length ? segs[segs.length - 1].textContent : ''; + out.thinkCount = wrapper.querySelectorAll('.think-block').length; + out.chipCount = wrapper.querySelectorAll('.tool-chip').length; + wrapper.parentNode.removeChild(wrapper); + return JSON.stringify(out); + } catch (e) { + return 'resumeB-err:' + String(e); + } +})() +""" + + + +def run_checks(): + db = window.db + + # ---------- 建测试会话 + 模拟流式事件累积 ---------- + sess = db.create_session("持久化测试会话") + sid = sess["id"] + test["sid"] = sid + mid = "msg-persist-test" + test["mid"] = mid + + user_row = db.add_message(sid, "user", "测试问题", None) + user_id = user_row["id"] + + window.current_session_id = sid + window.chat_bridge.create_message(mid, "assistant", "", "PersistTest") + + st = { + "msg_id": mid, "content": "", "reasoning": "", + "timeline": [], "tl_kind": None, + "parent_id": user_id, "branch_info": None, "worker": None, + "previous_leaf_id": user_id, + } + window._active_streams[sid] = st + + # 事件序列:思考 → 文本 → 工具 → 思考 → 文本 + window.on_reasoning_received(sid, "先看一下") + window.on_reasoning_received(sid, "目录结构。") + window.on_chunk_received(sid, "我来执行") + window._on_tool_started(sid, "call-p1", "bash", '{"command": "echo hi"}') + window._on_tool_updated(sid, "call-p1", "hi\n") + window._on_tool_finished(sid, "call-p1", "bash", True, + "$ echo hi\nhi\n[exit 0]") + window.on_reasoning_received(sid, "输出正常。") + window.on_chunk_received(sid, "任务完成。") + + tl = st["timeline"] + check("时间线累积: 5 段", len(tl) == 5, str(tl)) + check("时间线类型序 think/text/tool/think/text", + [e["t"] for e in tl] == ["think", "text", "tool", "think", "text"], + str([e["t"] for e in tl])) + tool_e = tl[2] if len(tl) > 2 else {} + check("工具条目定格 ok+result", + tool_e.get("ok") is True and "[exit 0]" in tool_e.get("result", ""), + str(tool_e)) + check("聚合 content/reasoning 正确", + st["content"] == "我来执行任务完成。" + and st["reasoning"] == "先看一下目录结构。输出正常。", + f"{st['content']!r} / {st['reasoning']!r}") + + tl_json = json.dumps(tl, ensure_ascii=False) + + # ---------- 入库 ---------- + row = db.add_message(sid, "assistant", st["content"], user_id, + reasoning=st["reasoning"], msg_id=mid, + timeline=tl_json) + check("DB timeline 列写入", row.get("timeline") == tl_json) + + # ---------- 切走再切回(重载) ---------- + del window._active_streams[sid] # 模拟流已结束(先于重载,避免 live-restore 重复渲染) + window.load_messages_to_web(sid, show_loading=False) + + def verify_reload(res): + data = json.loads(str(res)) + if "error" in data: + check("重载 DOM 校验", False, data["error"]) + finish() + return + expected = ["think-block", "md-segment", "tool-chip", + "think-block", "md-segment"] + check("重载: 时间线块序还原", data["blocks"] == expected, + str(data["blocks"])) + check("重载: 工具气泡保留且成功", + data["chipOk"] and data["chipStatus"] == "✓ 完成" + and data["chipCallId"] == "call-p1", + f"{data['chipStatus']} {data['chipCallId']}") + check("重载: 思考块 x2 + 末段文本", + data["thinkCount"] == 2 and "任务完成。" in data["lastSegText"], + f"think={data['thinkCount']} seg={data['lastSegText']!r}") + check("重载: SVG 箭头", data["thinkChevron"]) + + # ---------- build_api_context 重建 ---------- + api = window.build_api_context(sid) + roles = [m["role"] for m in api] + check("API 链: user→assistant→tool→assistant", + roles == ["user", "assistant", "tool", "assistant"], + str(roles)) + asst1 = api[1] if len(api) > 1 else {} + check("API: 首条 assistant 带 tool_calls", + asst1.get("content") == "我来执行" + and len(asst1.get("tool_calls", [])) == 1 + and asst1["tool_calls"][0]["function"]["name"] == "bash", + str(asst1)[:200]) + check("API: tool 消息 tool_call_id 对齐", + len(api) > 2 and api[2].get("tool_call_id") == "call-p1" + and "[exit 0]" in api[2].get("content", ""), + str(api[2] if len(api) > 2 else {})[:200]) + check("API: 末条 assistant = 最终回答", + len(api) > 3 and api[3].get("content") == "任务完成。") + check("API: 无 reasoning 字段", + all("reasoning" not in m for m in api)) + + # ---------- 切回进行中会话: 续流 ---------- + tljs = json.dumps(tl_json) + js_a = (JS_RESUME_A + .replace("'__MID__'", "'msg-resume-test'") + .replace("restoreStreamingTimeline(mid, '__TLJSON__')", + f"restoreStreamingTimeline(mid, {tljs})")) + js_b = JS_RESUME_B.replace("'__MID__'", "'msg-resume-test'") + + def verify_resume_data(d2): + if "error" in d2: + check("续流 DOM 校验", False, d2["error"]) + finish() + return + expected2 = ["think-block", "md-segment", "tool-chip", + "think-block", "md-segment", "think-block"] + check("续流: 块序正确", d2["blocks"] == expected2, + str(d2["blocks"])) + check("续流: 文本并入末段", + "任务完成。(续流文本)" in d2["lastSegText"], + d2["lastSegText"]) + check("续流: 思考 x3 + 工具 x1", + d2["thinkCount"] == 3 and d2["chipCount"] == 1, + f"think={d2['thinkCount']} chip={d2['chipCount']}") + finish() + + def run_b(): + def got_b(v): + vstr = "" if v is None else str(v) + print(f" [resume B] {vstr[:120]}") + if vstr.startswith("resumeB-err"): + check("续流 finishMessage", False, vstr) + finish() + return + try: + d2 = json.loads(vstr) + except Exception: + check("续流 DOM 校验", False, repr(vstr)[:120]) + finish() + return + verify_resume_data(d2) + window.browser.page().runJavaScript(js_b, got_b) + + def run_a(): + def got_a(v): + vstr = "" if v is None else str(v) + print(f" [resume A] {vstr[:120]}") + if vstr.startswith("resumeA-err"): + check("续流 restore", False, vstr) + finish() + return + QTimer.singleShot(300, run_b) + window.browser.page().runJavaScript(js_a, got_a) + + QTimer.singleShot(300, run_a) + + + + window.browser.page().runJavaScript( + JS_VERIFY_RELOAD.replace("'__MID__'", f"'{mid}'"), verify_reload) + + +def main(): + global window + try: + window = MainWindow() + except Exception: + import traceback + traceback.print_exc() + app.quit() + return + QTimer.singleShot(6000, run_checks) + QTimer.singleShot(45000, lambda: (check("超时", False, "45s 未完成"), + finish()) if not test["done"] else None) + app.exec() + + +if __name__ == "__main__": + main() diff --git a/tests/smoke_probe.py b/tests/smoke_probe.py new file mode 100644 index 0000000..34477c6 --- /dev/null +++ b/tests/smoke_probe.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""验证 probeStream 探针在真实 WebEngine 页返回正确数据""" +import os, sys +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from PyQt6.QtWidgets import QApplication +import PyQt6.QtWebEngineWidgets # noqa +from PyQt6.QtCore import QTimer +from ui.views.main_window import MainWindow + +PASS = 0 +FAIL = 0 + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" PASS {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + +app = QApplication(sys.argv) +window = MainWindow() +window.resize(1280, 800) +window.show() + +PROBE_JS = r""" +(function() { + var m = 'probe-test-' + Date.now(); + createMessage(m, 'assistant', '', 'LG'); + appendReasoning(m, '思考探针内容'); + appendToken(m, '正文探针 '); + appendToken(m, '第二段'); + // 同步强制渲染 + if (typeof forceRenderNow === 'function') forceRenderNow(m); + var res = probeStream(m); + finishMessage(m); + return res; +})() +""" + +def step1(): + window.browser.page().runJavaScript(PROBE_JS, on_probe) + +def on_probe(res): + import json + d = json.loads(str(res)) + print(" probe:", str(d)[:300]) + check("probe 返回缓冲段", len(d.get("segs", [])) >= 1, str(d)) + if d.get("segs"): + s0 = d["segs"][0] + check("缓冲长度>0", s0["b"] > 0, str(s0)) + check("DOM 已写入", s0["d"] > 0, str(s0)) + check("思考段探针", len(d.get("thinks", [])) >= 1, str(d)) + check("无 JS 异常", "err" not in d, str(d)) + print(f"===== {'ALL PASS' if FAIL == 0 else 'HAS FAILURES'}: {PASS}/{PASS+FAIL} =====") + app.quit() + +QTimer.singleShot(1500, step1) +app.exec() +sys.exit(1 if FAIL else 0) diff --git a/tests/smoke_repro_real.py b/tests/smoke_repro_real.py new file mode 100644 index 0000000..5664ee9 --- /dev/null +++ b/tests/smoke_repro_real.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +"""用 DB 里用户真实消息内容回放流式渲染,验证新路径是否正确。 +每个 chunk 走独立 runJavaScript(模拟 Python 逐 token 推送的真实路径)。""" +import os, sys, json, sqlite3 +# 不加 --disable-gpu:复现真实应用的 GPU 渲染环境 +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "") +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from PyQt6.QtWidgets import QApplication +import PyQt6.QtWebEngineWidgets # noqa +from PyQt6.QtCore import QTimer +from ui.views.main_window import MainWindow + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PASS = 0 +FAIL = 0 + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" PASS {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + +def esc(s): + return s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n") + +# 取用户真实消息 +conn = sqlite3.connect(os.path.join(ROOT, "data", "chat_history.db")) +conn.row_factory = sqlite3.Row +row = conn.execute( + "SELECT content, timeline FROM messages WHERE id LIKE 'msg-99235c06%' " + "ORDER BY created_at DESC LIMIT 1").fetchone() +assert row, "DB 中找不到测试消息" +timeline = json.loads(row["timeline"] or "[]") +print(f"消息: content={len(row['content'])}c, timeline={len(timeline)} 条") + +MID = "repro-real" +# 构造回放脚本序列:think/text 按 20 字符切块,每批 20 个 chunk 一次 JS 调用 +CHUNK = 20 +BATCH = 20 +steps = [] # JS 片段列表 +for e in timeline: + t = e.get("t") + txt = e.get("text", "") + if t in ("think", "text") and txt: + for i in range(0, len(txt), CHUNK): + fn = "appendReasoning" if t == "think" else "appendToken" + steps.append(f"{fn}('{MID}', '{esc(txt[i:i+CHUNK])}');") +steps_batched = [chr(10).join(steps[i:i+BATCH]) for i in range(0, len(steps), BATCH)] + +app = QApplication(sys.argv) +window = MainWindow() +window.resize(1280, 800) +window.show() + +state = {"idx": 0, "probed": False} + +def next_batch(): + """一次送一批(20 个 chunk 拼接在一个 JS 任务里)""" + if state["idx"] >= len(steps_batched): + QTimer.singleShot(300, mid_probe) + return + bi = state["idx"] + js = steps_batched[bi] + total = len(steps_batched) + + def done(r, bi=bi): + state["idx"] = bi + 1 + # 过半时探针一次 + if not state["probed"] and bi + 1 >= total // 2: + state["probed"] = True + window.browser.page().runJavaScript( + f"probeStream('{MID}')", on_mid_probe) + else: + QTimer.singleShot(0, next_batch) + window.browser.page().runJavaScript(js, done) + +def on_mid_probe(res): + d = json.loads(str(res)) + print(" [过半探针]", str(d)[:400]) + check("中途: 缓冲已累积", any(s["b"] > 0 for s in d.get("segs", [])), str(d)) + check("中途: DOM 已写入部分正文", + any(s["d"] > 0 for s in d.get("segs", [])), str(d)) + check("中途: 思考 DOM 已写入", + any(t["d"] > 0 for t in d.get("thinks", [])), str(d)) + finish_seq() + +def mid_probe(): + window.browser.page().runJavaScript(f"probeStream('{MID}')", on_mid_probe) + +def finish_seq(): + window.browser.page().runJavaScript( + f"finishMessage('{MID}'); probeStream('{MID}')", on_final) + +def on_final(res): + d = json.loads(str(res)) + print(" [最终探针]", str(d)[:400]) + tot_b = sum(s["b"] for s in d.get("segs", [])) + tot_d = sum(s["d"] for s in d.get("segs", [])) + check("最终: 正文缓冲完整", tot_b > 500, f"b={tot_b}") + check("最终: 正文 DOM 完整", tot_d > 500, f"d={tot_d}") + check("最终: 思考 DOM 完整", + sum(t["d"] for t in d.get("thinks", [])) > 500, + str([t['d'] for t in d.get('thinks', [])])) + # DOM 实际包含关键子串 + window.browser.page().runJavaScript( + "(function(){ var w = document.getElementById('" + MID + "');" + " return w ? w.textContent.length : -1; })()", on_text) + +def on_text(res): + n = int(str(res) or 0) + check("DOM 总文本量正常", n > 1000, f"total={n}") + print(f"===== {'ALL PASS' if FAIL == 0 else 'HAS FAILURES'}: {PASS}/{PASS+FAIL} =====") + app.quit() + +def start(): + window.browser.page().runJavaScript( + f"createMessage('{MID}', 'assistant', '', 'Real');", + lambda r: QTimer.singleShot(200, next_batch)) + +QTimer.singleShot(1500, start) +app.exec() +sys.exit(1 if FAIL else 0) diff --git a/tests/smoke_timeline.py b/tests/smoke_timeline.py new file mode 100644 index 0000000..0e6f087 --- /dev/null +++ b/tests/smoke_timeline.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +"""时间线 DOM 冒烟测试(真实 WebEngine 页面内执行): +1. 流式事件序列 → 思考/正文/工具按事件顺序穿插 +2. 工具 chip 按 call_id 对号入座 + ok 状态(修复反转 bug) +3. SVG 箭头存在、流式光标移除 +4. 历史消息静态路径不受影响 +运行: QT_QPA_PLATFORM=offscreen python tests/smoke_timeline.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost + +# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成 +from tests._test_env import isolate # noqa: E402 +_TMP = isolate("timeline") # noqa: E402 + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtCore import QTimer # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 已先导入) + +app = QApplication(sys.argv) + +results = [] +window = None +done = {"ok": False} + + +def check(name, ok, detail=""): + print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" [{detail}]" if detail and not ok else "")) + results.append(ok) + + +def js(s): + return s + + +JS_SIM = js(r""" +(function() { + var out = {}; + try { + // ---- 1. 流式时间线 ---- + var mid = 'tl-test-' + Date.now(); + createMessage(mid, 'assistant', '', 'TL'); + var wrapper = document.getElementById(mid); + appendReasoning(mid, 'think part 1 '); + appendReasoning(mid, 'more thinking'); + appendToken(mid, 'before tool text '); + toolExecutionStarted(mid, 'call-1', 'bash', JSON.stringify({command: 'echo hi'})); + toolExecutionUpdated(mid, 'call-1', 'hi\n'); + toolExecutionFinished(mid, 'call-1', 'bash', true, '$ echo hi\nhi\n[exit 0]'); + appendToken(mid, ' after tool'); + appendReasoning(mid, ' second thinking'); + appendToken(mid, ' final answer'); + finishMessage(mid); + + var tl = wrapper.querySelector('.reply-content'); + out.blocks = Array.prototype.map.call(tl.children, function(el) { + return el.className.split(' ')[0]; + }); + var chip = wrapper.querySelector('.tool-chip'); + var status = chip ? chip.querySelector('.tool-chip-status') : null; + out.chipStatusText = status ? status.textContent : null; + out.chipOk = status ? status.classList.contains('ok') : false; + out.chipBrief = chip ? (chip.querySelector('.tool-chip-brief') || {}).textContent : null; + out.chipCallId = chip ? chip.getAttribute('data-call-id') : null; + out.thinkChevron = !!wrapper.querySelector('.think-block .chev'); + out.chipChevron = !!wrapper.querySelector('.tool-chip .chev'); + out.typingRemoved = !wrapper.querySelector('.streaming-typing'); + out.streamClassOff = !wrapper.classList.contains('streaming'); + out.thinkBlocks = wrapper.querySelectorAll('.think-block').length; + out.mdSegs = wrapper.querySelectorAll('.md-segment').length; + + // ---- 2. 历史消息静态路径 ---- + var mid2 = 'tl-hist-' + Date.now(); + createMessage(mid2, 'assistant', 'hello **world**', 'TL'); + insertThinkBlock(mid2, 'history thinking'); + finishMessage(mid2); + var w2 = document.getElementById(mid2); + var content2 = w2.querySelector('.message-content'); + out.histOrder = Array.prototype.map.call(content2.children, function(el) { + return el.className.split(' ')[0]; + }); + out.histBold = w2.querySelector('.reply-content strong') !== null; + out.histThinkChevron = !!w2.querySelector('.think-block .chev'); + out.histThinkLabel = (w2.querySelector('.think-label') || {}).textContent; + + // ---- 3. 失败工具 chip(反转修复验证)---- + var mid3 = 'tl-fail-' + Date.now(); + createMessage(mid3, 'assistant', '', 'TL'); + toolExecutionStarted(mid3, 'call-x', 'bash', '{"command":"false"}'); + toolExecutionFinished(mid3, 'call-x', 'bash', false, 'boom'); + var w3 = document.getElementById(mid3); + var st3 = w3.querySelector('.tool-chip-status'); + out.failChipFail = st3 ? st3.classList.contains('fail') : false; + out.failChipText = st3 ? st3.textContent : null; + finishMessage(mid3); + + // 清理 + [mid, mid2, mid3].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.parentNode.removeChild(el); + }); + } catch (e) { + out.error = String(e) + ' | ' + (e.stack || '').split('\n')[1]; + } + return JSON.stringify(out); +})() +""") + + +def run_js_check(): + page = window.browser.page() if hasattr(window, "browser") else None + if page is None: + # 找 QWebEnginePage + from PyQt6.QtWebEngineWidgets import QWebEngineView + views = window.findChildren(QWebEngineView) + page = views[0].page() if views else None + if page is None: + check("页面句柄", False, "no page") + finish() + return + + def on_result(res): + try: + import json + data = json.loads(str(res)) + except Exception as e: + check("JS 执行", False, f"bad json: {res} ({e})") + finish() + return + if "error" in data: + check("JS 执行", False, data["error"]) + finish() + return + + # 期望时间线: think → text → chip → text → think → text + expected = ["think-block", "md-segment", "tool-chip", + "md-segment", "think-block", "md-segment"] + check("时间线块顺序(思考/正文/工具穿插)", data["blocks"] == expected, + str(data["blocks"])) + check("两个思考段 + 三个正文段", + data["thinkBlocks"] == 2 and data["mdSegs"] == 3, + f"think={data['thinkBlocks']} segs={data['mdSegs']}") + check("工具 chip 成功状态(非反转)", + data["chipOk"] and data["chipStatusText"] == "✓ 完成", + f"{data['chipStatusText']} ok={data['chipOk']}") + check("chip 摘要=结果首行", data["chipBrief"] == "$ echo hi", + str(data["chipBrief"])) + check("chip call_id 贯通", data["chipCallId"] == "call-1", + str(data["chipCallId"])) + check("SVG 箭头(思考+工具)", data["thinkChevron"] and data["chipChevron"]) + check("流式光标已移除 + streaming 类移除", + data["typingRemoved"] and data["streamClassOff"]) + check("失败 chip 标 ✗", data["failChipFail"] and data["failChipText"] == "✗ 失败", + f"{data['failChipText']}") + check("历史消息: 思考在正文前", + data["histOrder"].index("think-block") < data["histOrder"].index("reply-content"), + str(data["histOrder"])) + check("历史消息: markdown 正常解析", data["histBold"]) + check("历史消息: 思考块带 SVG 箭头+标签", + data["histThinkChevron"] and data["histThinkLabel"] == "已完成深度思考", + str(data["histThinkLabel"])) + finish() + + page.runJavaScript(JS_SIM, on_result) + + +def finish(): + done["ok"] = True + print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: " + f"{sum(results)}/{len(results)} =====") + app.quit() + + +def main(): + global window + try: + window = MainWindow() + except Exception: + import traceback + traceback.print_exc() + app.quit() + return + QTimer.singleShot(6000, run_js_check) + QTimer.singleShot(25000, lambda: (check("超时", False, "25s 未完成"), finish()) + if not done["ok"] else None) + app.exec() + + +if __name__ == "__main__": + main() diff --git a/tests/test_agent_core.py b/tests/test_agent_core.py new file mode 100644 index 0000000..ca47c81 --- /dev/null +++ b/tests/test_agent_core.py @@ -0,0 +1,1323 @@ +""" +tests/test_agent_core.py +======================== +离线确定性测试:用脚本化 mock 流函数验证 core/agent 的 pi 1:1 语义。 +运行:conda run -n haocode python -m pytest tests/test_agent_core.py -v +(无网络、无 Qt 依赖) +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# P0-01:provider 用例经统一配置入口读取临时配置(测试 provider),不碰真实 data/config.json +from tests._test_env import isolate # noqa: E402 +isolate("agentcore") + +import pytest + +from core.agent import (Agent, AgentConfig, AgentError, ModelConfig, RetryConfig) +from core.agent.compaction import ( + compact_context, find_cut_point, find_turn_start, find_valid_cut_points, + prepare_compaction, serialize_conversation, format_file_operations, + FileOperations, extract_file_ops_from_message, compute_file_lists, +) +from core.agent.context import (clamp_max_tokens_to_context, should_compact, + estimate_context_tokens) +from core.agent.recovery import AgentRunner +from core.agent.stream_fn import to_openai_messages, from_openai_messages +from core.agent.tools import default_tools +from core.agent.types import (AgentMessage, ToolCall, AgentTool, + AgentToolResult) + + +# ====================================================================== +# 脚本化 mock 流:每个 turn 一个 dict,按序消费 +# ====================================================================== +def make_scripted_stream(turns): + state = {"i": 0} + + def stream_fn(context, model, signal, max_tokens, tools=None): + turn = turns[min(state["i"], len(turns) - 1)] + state["i"] += 1 + content = turn.get("content", "") + reasoning = turn.get("reasoning", "") + tool_calls = turn.get("tool_calls", []) + chunks = max(1, turn.get("chunks", 3)) + + if turn.get("raise"): + raise turn["raise"] + + if reasoning: + yield ("event", _thinking(reasoning)) + for i in range(0, len(content), max(1, len(content) // chunks) if content else 1): + piece = content[i:i + max(1, len(content) // chunks) if content else 1] + if signal.aborted: + return # 中止:生成器提前结束(真实流行为) + yield ("event", _text(piece)) + for tc in tool_calls: + yield ("event", _tc_delta(0, tc["id"], "id")) + yield ("event", _tc_delta(0, tc["name"], "name")) + yield ("event", _tc_delta(0, _json(tc["arguments"]), "arguments")) + + usage = turn.get("usage", {"input": 10, "output": 10}) + final = AgentMessage( + role="assistant", content=content, reasoning=reasoning, + tool_calls=[ToolCall(id=tc["id"], name=tc["name"], + arguments=tc["arguments"]) for tc in tool_calls], + stop_reason=turn.get("stop_reason", "stop"), usage=usage, + ) + yield ("event", _done()) + yield ("final", final) + + return stream_fn + + +def _text(t): + from core.agent.types import AssistantMessageEvent + return AssistantMessageEvent.text_delta(t) + + +def _thinking(t): + from core.agent.types import AssistantMessageEvent + return AssistantMessageEvent.thinking_delta(t) + + +def _tc_delta(idx, d, field): + from core.agent.types import AssistantMessageEvent + return AssistantMessageEvent.toolcall_delta(idx, d, field=field) + + +def _done(): + from core.agent.types import AssistantMessageEvent + return AssistantMessageEvent.done() + + +def _json(o): + import json + return json.dumps(o, ensure_ascii=False) + + +def make_agent(turns, **cfg_kw): + model = ModelConfig(provider="mock", name="mock-model", + context_window=cfg_kw.pop("context_window", 100000), + max_tokens=cfg_kw.pop("max_tokens", 32768), + api_key="x", base_url="http://mock") + cfg = AgentConfig(model=model, tools=cfg_kw.pop("tools", []), **cfg_kw) + agent = Agent(cfg) + agent.set_stream_fn(make_scripted_stream(turns)) + return agent + + +def run_events(agent): + events = [] + agent.subscribe(lambda e: events.append(e)) + return events + + +# ====================================================================== +# 1. 基础对话 + 事件顺序(对照 pi 事件流) +# ====================================================================== +def test_basic_chat_event_order(): + agent = make_agent([{"content": "你好呀!", "chunks": 4}]) + events = run_events(agent) + result = agent.prompt("hi") + types = [e.type for e in events] + assert types[0] == "agent_start" + assert types[1] == "turn_start" + assert "message_start" in types and "message_end" in types + assert types.count("message_update") == 4 # 4 个 chunk → 4 次增量 + assert types[-1] == "agent_end" + assert result.stop_reason == "stop" + assert agent.state.messages[0].role == "user" + assert agent.state.messages[1].role == "assistant" + assert agent.state.messages[1].content == "你好呀!" + assert not agent.state.is_streaming + + +# ====================================================================== +# 2. 工具循环:bash 调用 → 执行 → 第二轮回答(对照 pi tool 管线) +# ====================================================================== +def test_tool_loop_executes_and_continues(): + agent = make_agent( + [{"tool_calls": [{"id": "call_1", "name": "bash", + "arguments": {"command": "echo hi-from-tool"}}], + "stop_reason": "stop"}, + {"content": "命令执行完了。"}], + tools=default_tools(), + ) + events = run_events(agent) + result = agent.prompt("执行一下") + types = [e.type for e in events] + assert "tool_execution_start" in types + assert "tool_execution_end" in types + # 工具结果消息进了上下文 + trs = [m for m in agent.state.messages if m.role == "toolResult"] + assert len(trs) == 1 + assert trs[0].tool_name == "bash" + tr_text = trs[0].content if isinstance(trs[0].content, str) else \ + "".join(c.get("text", "") for c in trs[0].content) + assert "hi-from-tool" in tr_text + # 两轮:2 个 assistant + assistants = [m for m in agent.state.messages if m.role == "assistant"] + assert len(assistants) == 2 + assert assistants[1].content == "命令执行完了。" + assert result.stop_reason == "stop" + # end 事件带错误标记=False + end_evt = [e for e in events if e.type == "tool_execution_end"][0] + assert end_evt.is_error is False + + +# ====================================================================== +# 3. length 截断 + 工具调用 → 一律失败不执行(对照 failToolCalls) +# ====================================================================== +def test_length_truncation_fails_tool_calls(): + agent = make_agent( + [{"tool_calls": [{"id": "c1", "name": "bash", + "arguments": {"command": "echo should-not-run"}}], + "stop_reason": "length"}, + {"content": "恢复回答。"}], + tools=default_tools(), + ) + events = run_events(agent) + result = agent.prompt("go") + trs = [m for m in agent.state.messages if m.role == "toolResult"] + assert len(trs) == 1 + assert trs[0].is_error is True + tr_text = trs[0].content if isinstance(trs[0].content, str) else \ + "".join(c.get("text", "") for c in trs[0].content) + assert "未执行" in tr_text + # 循环继续(hasMoreToolCalls=True)→ 第二轮 + assistants = [m for m in agent.state.messages if m.role == "assistant"] + assert len(assistants) == 2 + assert result.stop_reason == "stop" + + +# ====================================================================== +# 4. 可恢复 length:删除坏消息 + 压缩 + 重试一次(对照 isRecoverableLength) +# ====================================================================== +def test_recoverable_length_compact_retry(): + # 预填 30 条大历史(~60K token)使 1:1 切点(保留 20K)有可摘要内容 + agent = make_agent( + [{"content": "", "stop_reason": "length", + "usage": {"input": 100, "output": 50}}, # 50 < max_tokens → 可恢复 + {"content": "压缩后恢复。"}], + max_tokens=100, + ) + for i in range(30): + agent.state.messages.append( + AgentMessage(role="user", + content=f"历史消息 {i} " + "长" * 2000)) + summarize_calls = [] + + def summarize(prompt, system_prompt, max_tokens): + summarize_calls.append((prompt, system_prompt, max_tokens)) + return "SUMMARY-OK" + + runner = AgentRunner(agent, summarize_fn=summarize) + events = run_events(agent) + result = runner.run("新问题") + assert result.stop_reason == "stop" + assert runner.last_action == "length_compact" + assert len(summarize_calls) == 1 + # pi 原版摘要提示词(逐字)进入了调用 + prompt, system, mt = summarize_calls[0] + assert "" in prompt and "## Goal" in prompt + assert "context summarization assistant" in system + assert mt == min(int(0.8 * 16384), 100) # min(0.8×reserve, model.maxTokens) + # 上下文里有压缩摘要(kind 标记,对照 pi compaction 条目) + sums = [m for m in agent.state.messages if m.kind == "compaction_summary"] + assert len(sums) == 1 and "SUMMARY-OK" in sums[0].content + # 保留尾巴非空且不含被摘要的历史 + assert len(agent.state.messages) > 1 + # 坏的 length 助手消息被移除(只剩恢复后的那个) + assistants = [m for m in agent.state.messages if m.role == "assistant"] + assert all(m.stop_reason != "length" for m in assistants) + + +# ====================================================================== +# 5. 可重试错误:退避重试(对照 isRetryable + 1600ms×1.6^n) +# ====================================================================== +def test_retryable_error_backoff_retry(): + agent = make_agent( + [{"raise": AgentError(message="429 Too Many Requests", + kind="rate_limit", status_code=429, + recoverable=True)}, + {"content": "重试成功。"}], + retry=RetryConfig(max_attempts=3, base_delay_ms=10, factor=2.0, + max_delay_ms=50), + ) + runner = AgentRunner(agent) + result = runner.run("hi") + assert result.stop_reason == "stop" + assert runner._retry_attempt == 1 + assert runner.last_action == "retry" + assert agent.state.messages[-1].content == "重试成功。" + + +# ====================================================================== +# 6. 不可重试错误:立即结束(401 auth) +# ====================================================================== +def test_auth_error_stops_immediately(): + agent = make_agent( + [{"raise": AgentError(message="401 Unauthorized", kind="auth", + status_code=401)}]) + runner = AgentRunner(agent, + summarize_fn=lambda *a: "S") # 压缩可用也不该触发 + result = runner.run("hi") + assert result.stop_reason == "error" + assert result.error.kind == "auth" + assert runner.last_action in ("none",) + + +# ====================================================================== +# 7. steering:turn 边界注入(对照 getSteeringMessages) +# ====================================================================== +def test_steering_injected_at_turn_boundary(): + agent = make_agent( + [{"content": "第一段回答。"}, + {"content": "看到转向了。"}], + ) + events = run_events(agent) + # 第一轮的 message_end 之后注入 steering(只注入一次) + steered = {"done": False} + + def on_event(e): + if e.type == "message_end" and e.message is not None \ + and e.message.role == "assistant" and not steered["done"]: + steered["done"] = True + agent.steer("插一句:改方向") + agent.subscribe(on_event) + result = agent.prompt("原始问题") + contents = [m.content for m in agent.state.messages if m.role == "user"] + assert "插一句:改方向" in contents + assistants = [m for m in agent.state.messages if m.role == "assistant"] + assert len(assistants) == 2 + assert result.stop_reason == "stop" + + +# ====================================================================== +# 8. followUp:run 结束前注入 → 自动续跑(对照外层循环) +# ====================================================================== +def test_follow_up_continues_run(): + agent = make_agent( + [{"content": "第一段。"}, + {"content": "第二段(followUp 触发)。"}], + ) + seen = {"n": 0} + + def on_event(e): + if e.type == "message_end" and e.message is not None \ + and e.message.role == "assistant": + seen["n"] += 1 + if seen["n"] == 1: + agent.follow_up("追加一问") + agent.subscribe(on_event) + result = agent.prompt("第一问") + user_msgs = [m for m in agent.state.messages if m.role == "user"] + assert len(user_msgs) == 2 + assistants = [m for m in agent.state.messages if m.role == "assistant"] + assert len(assistants) == 2 + assert result.stop_reason == "stop" + + +# ====================================================================== +# 9. abort:流中中止 → stop_reason=aborted → agent_end(对照 pi 中止语义) +# ====================================================================== +def test_abort_mid_stream(): + agent = make_agent([{"content": "ABCDEFGH", "chunks": 8}, + {"content": "不应到达"}]) + events = run_events(agent) + fired = {"n": 0} + + def on_event(e): + if e.type == "message_update": + fired["n"] += 1 + if fired["n"] == 2: # 收到第 2 个 chunk 后中止 + agent.abort() + agent.subscribe(on_event) + result = agent.prompt("hi") + assert result.stop_reason == "aborted" + types = [e.type for e in events] + assert types[-1] == "agent_end" + # 中止消息保留已输出部分 + last_a = [m for m in agent.state.messages if m.role == "assistant"][-1] + assert last_a.stop_reason == "aborted" + assert "AB" in last_a.content + # 第二轮未发生 + assert len([m for m in agent.state.messages if m.role == "assistant"]) == 1 + + +# ====================================================================== +# 10. 输出预算钳制公式(对照 simple-options clampMaxTokensToContext) +# ====================================================================== +def test_clamp_max_tokens_formula(): + model = ModelConfig(context_window=100000, max_tokens=32768) + # 构造已知输入:50000 个 ASCII 字符 ≈ 12500 token(/4)+ 每消息 4 + msg = AgentMessage(role="user", content="a" * 50000) + clamped, input_tokens = clamp_max_tokens_to_context(model, [msg]) + # 12500 + 4 = 12504;max_output = 100000 - 12504 - 4096 = 833... 不,= 83400 + assert clamped == min(32768, 100000 - input_tokens - 4096) + # 输入几乎占满窗口 → 输出预算被压到 1 + big = AgentMessage(role="user", content="a" * 400000) # 100004 token + assert clamp_max_tokens_to_context(model, [big]) is None # 溢出 + mid = AgentMessage(role="user", content="a" * 392000) # 98004 token + clamped2, _ = clamp_max_tokens_to_context(model, [mid]) + assert clamped2 == 1 # 100000-98004-4096 < 0 → 下限 1 + + +# ====================================================================== +# 11. 压缩切分点(1:1 对照 pi findCutPoint):token 预算回扫 + 有效切点 + 断轮 +# ====================================================================== +def test_compaction_split_point_alignment(): + msgs = [AgentMessage(role="user", content=f"u{i}") for i in range(4)] + a = AgentMessage(role="assistant", content="", tool_calls=[ + ToolCall(id="c1", name="bash", arguments={})]) + msgs.append(a) + msgs.append(AgentMessage(role="toolResult", tool_call_id="c1", + tool_name="bash", content="r")) + msgs.extend([AgentMessage(role="assistant", content=f"a{i}") for i in range(4)]) + # 有效切点:user/assistant 位置;toolResult(index 5) 绝不能做切点 + cuts = find_valid_cut_points(msgs, 0, len(msgs)) + assert 5 not in cuts + assert set(cuts) == {0, 1, 2, 3, 4, 6, 7, 8, 9} + # 小预算 → 尾部回扫立刻达标 → 切在 index 9(assistant)→ 断轮 + cut = find_cut_point(msgs, 0, len(msgs), keep_recent_tokens=1) + assert cut.first_kept_index == 9 + assert cut.is_split_turn is True + # 轮起点 = 向前最近的 user(index 3) + assert cut.turn_start_index == 3 + assert find_turn_start(msgs, 9, 0) == 3 + # 大预算(够不着)→ 切点落在第一个有效切点 index 0(user)→ 不断轮 + cut2 = find_cut_point(msgs, 0, len(msgs), keep_recent_tokens=10 ** 6) + assert cut2.first_kept_index == 0 and cut2.is_split_turn is False + + +# ====================================================================== +# 11b. should_compact 触发公式(1:1 对照 pi: tokens > window - reserve) +# ====================================================================== +def test_should_compact_formula(): + model = ModelConfig(context_window=100000, max_tokens=32768) + # 330000 ASCII 字符 ≈ 82500 + 4 = 82504 token < 100000-16384=83616 → 不触发 + small = AgentMessage(role="user", content="a" * 330000) + ok, t1 = should_compact([small], model, reserve_tokens=16384) + assert not ok and t1 == 82504 + # 335000 ASCII ≈ 83754 > 83616 → 触发 + big = AgentMessage(role="user", content="a" * 335000) + ok2, t2 = should_compact([big], model, reserve_tokens=16384) + assert ok2 and t2 == 83754 + + +# ====================================================================== +# 11c. usage 锚定估算(1:1 对照 pi estimateContextTokens) +# ====================================================================== +def test_usage_anchored_estimation(): + a = AgentMessage(role="assistant", content="x" * 100, + stop_reason="stop", usage={"totalTokens": 5000}) + tail = AgentMessage(role="user", content="b" * 4000) # 1000 + 4 = 1004 + est = estimate_context_tokens([a, tail]) + assert est.usage_tokens == 5000 + assert est.trailing_tokens == 1004 + assert est.tokens == 6004 + assert est.last_usage_index == 0 + # aborted/error 的 assistant usage 无效(对照 pi getAssistantUsage) + a2 = AgentMessage(role="assistant", content="y", + stop_reason="error", usage={"totalTokens": 9999}) + est2 = estimate_context_tokens([a2, tail]) + assert est2.last_usage_index is None + assert est2.tokens == 1004 + (1 + 4) + + +# ====================================================================== +# 11d. serialize_conversation 格式(1:1 对照 pi utils.ts) +# ====================================================================== +def test_serialize_conversation_format(): + msgs = [ + AgentMessage(role="user", content="做个工具"), + AgentMessage(role="assistant", reasoning="想一想…", content="好,", + tool_calls=[ToolCall(id="c1", name="bash", + arguments={"command": "ls"})]), + AgentMessage(role="toolResult", tool_call_id="c1", tool_name="bash", + content="R" * 3000), + ] + s = serialize_conversation(msgs) + assert "[User]: 做个工具" in s + assert "[Assistant thinking]: 想一想…" in s + assert "[Assistant]: 好," in s + assert "[Assistant tool calls]: bash(command=\"ls\")" in s + # 工具结果 2000 字符截断(pi TOOL_RESULT_MAX_CHARS) + assert "[Tool result]: " + "R" * 2000 in s + assert "[... 1000 more characters truncated]" in s + + +# ====================================================================== +# 11e. 文件操作提取与附录(1:1 对照 pi extractFileOps*/formatFileOperations) +# ====================================================================== +def test_file_ops_extraction_and_appendix(): + ops = FileOperations() + extract_file_ops_from_message(AgentMessage( + role="assistant", tool_calls=[ + ToolCall(id="1", name="read", arguments={"path": "/a/f.py"}), + ToolCall(id="2", name="write", arguments={"path": "/a/g.py"}), + ToolCall(id="3", name="edit", arguments={"path": "/a/f.py"}), + ]), ops) + read_files, modified = compute_file_lists(ops) + assert read_files == [] # f.py 被改过 → 不算只读 + assert modified == ["/a/f.py", "/a/g.py"] + appendix = format_file_operations(read_files, modified) + assert "\n/a/f.py\n/a/g.py\n" in appendix + # 压缩后摘要尾部带文件附录 + calls = [] + + def summarize(prompt, system, mt): + calls.append((prompt, mt)) + return "S" + msgs = [AgentMessage(role="user", content="u" + "x" * 40000), + AgentMessage(role="assistant", + tool_calls=[ToolCall(id="1", name="read", + arguments={"path": "/a/f.py"})]), + AgentMessage(role="user", content="u2" + "x" * 40000), + AgentMessage(role="user", content="u3" + "x" * 40000)] + out = compact_context(msgs, ModelConfig(max_tokens=8192), summarize) + assert out is not None + assert out[0].kind == "compaction_summary" + assert "\n/a/f.py\n" in out[0].content + + +# ====================================================================== +# 11f. 迭代式 previousSummary 更新(1:1 对照 pi UPDATE_SUMMARIZATION_PROMPT) +# ====================================================================== +def test_iterative_summary_update(): + calls = [] + + def summarize(prompt, system, mt): + calls.append(prompt) + return "UPDATED" + prev = AgentMessage(role="user", content="旧摘要内容ABC", + kind="compaction_summary") + msgs = [prev, + AgentMessage(role="user", content="n" + "x" * 40000), + AgentMessage(role="user", content="n2" + "x" * 40000), + AgentMessage(role="user", content="n3" + "x" * 40000)] + out = compact_context(msgs, ModelConfig(max_tokens=8192), summarize) + assert out is not None and out[0].kind == "compaction_summary" + assert "UPDATED" in out[0].content + # 旧摘要进 ,且用 UPDATE 提示词 + p = calls[0] + assert "\n旧摘要内容ABC\n" in p + assert "NEW conversation messages to incorporate" in p + + +# ====================================================================== +# 11g. 断轮双摘要拼接(1:1 对照 pi compact() 的 split-turn 分支) +# ====================================================================== +def test_split_turn_double_summary(): + calls = [] + + def summarize(prompt, system, mt): + calls.append(prompt) + return f"SUMMARY-{len(calls)}" + # 轮1: u1/a1(40K 各一);轮2: u2/a2/a3/a4(a 连续 → 切点必落 assistant → 断轮) + msgs = [AgentMessage(role="user", content="u1" + "x" * 40000), + AgentMessage(role="assistant", content="a1" + "x" * 40000), + AgentMessage(role="user", content="u2" + "x" * 40000), + AgentMessage(role="assistant", content="a2" + "x" * 40000), + AgentMessage(role="assistant", content="a3" + "x" * 40000), + AgentMessage(role="assistant", content="a4" + "x" * 40000)] + out = compact_context(msgs, ModelConfig(max_tokens=8192), summarize) + assert out is not None + assert len(calls) == 2 # 历史摘要 + 轮前缀摘要(pi split-turn 双调用) + assert "PREFIX of a turn" in calls[1] + c = out[0].content + assert "SUMMARY-1" in c and "SUMMARY-2" in c + assert "---" in c + assert "**Turn Context (split turn):**" in c + # 保留尾巴 = 切点之后的 a3/a4 + assert [m.content[:2] for m in out[1:]] == ["a3", "a4"] + + +# ====================================================================== +# 12. pi 消息 → OpenAI 消息转换(tool 回传格式) +# ====================================================================== +def test_to_openai_messages_format(): + a = AgentMessage(role="assistant", content="", tool_calls=[ + ToolCall(id="c1", name="bash", arguments={"command": "ls"})]) + t = AgentMessage(role="toolResult", tool_call_id="c1", tool_name="bash", + content="ok") + out = to_openai_messages([ + AgentMessage(role="user", content="hi"), a, t, + ]) + assert out[0] == {"role": "user", "content": "hi"} + assert out[1]["role"] == "assistant" + assert out[1]["tool_calls"][0]["function"]["name"] == "bash" + assert out[2]["role"] == "tool" + assert out[2]["tool_call_id"] == "c1" + assert out[2]["content"] == "ok" + + +# ====================================================================== +# 12b. reasoning 回传(thinking 模式,DeepSeek 400 修复) +# ====================================================================== +def test_reasoning_passthrough_toggle(): + """thinking 模式要求 assistant 的 reasoning_content 原样回传 → 默认开启; + 显式 False 关闭(特殊网关逃生阀)。只影响带 reasoning 的 assistant 条目。""" + from core.agent.types import ModelConfig + a = AgentMessage(role="assistant", content="答", reasoning="思考过程...", + tool_calls=[ToolCall(id="c1", name="bash", + arguments={"command": "ls"})]) + msgs = [AgentMessage(role="user", content="hi"), a, + AgentMessage(role="toolResult", tool_call_id="c1", + tool_name="bash", content="ok")] + # 默认:reasoning_content 回传 + out_def = to_openai_messages(msgs) + assert out_def[1]["reasoning_content"] == "思考过程..." + assert out_def[1]["content"] == "答" + assert out_def[1]["tool_calls"][0]["function"]["name"] == "bash" + assert out_def[2]["role"] == "tool" + # 显式关闭(逃生阀):不注入 + out_off = to_openai_messages(msgs, pass_reasoning=False) + assert "reasoning_content" not in out_off[1] + # 无 reasoning 的 assistant 条目不产生空字段 + b = AgentMessage(role="assistant", content="无思考", tool_calls=[]) + out_mix = to_openai_messages([b], pass_reasoning=True) + assert "reasoning_content" not in out_mix[0] + # ModelConfig 默认开 + mc = ModelConfig(name="t") + assert mc.pass_reasoning is True + + +# ====================================================================== +# 12c. bash 秒级读秒 + 真超时(进程树杀,修 Windows 孤儿进程假超时) +# ====================================================================== +def test_bash_timer_ticks_and_real_timeout(): + """Popen 秒级滴答:on_timer 每秒回调(气泡读秒); + 到期杀进程树 → 真中断(修复前:Windows 孤儿子进程跑满全时长才报超时)。""" + import time as _t + import core.agent.tools as T + from core.agent.types import AbortSignal + ticks = [] + ctx = {"cwd": os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "shell": True, "on_timer": lambda el, to: ticks.append((el, to))} + # 1) 3s 命令 timeout=5 → 正常结束 + 收到 1、2 滴 + r = T.tool_bash("tb1", {"command": r'python -c "import time; time.sleep(3)"', + "timeout": 5}, AbortSignal(), None, ctx) + assert not r.is_error, r.as_text() + assert "[exit 0]" in r.as_text(), r.as_text() + els = [x[0] for x in ticks] + assert 1 in els and 2 in els, f"读秒缺失: {ticks}" + assert all(x[1] == 5 for x in ticks), ticks + # 2) 5s 命令 timeout=1 → 真杀:总耗时 < 4s(修复前孤儿进程要跑满 5s) + ticks.clear() + t0 = _t.time() + r2 = T.tool_bash("tb2", {"command": r'python -c "import time; time.sleep(5)"', + "timeout": 1}, AbortSignal(), None, ctx) + real = _t.time() - t0 + assert r2.is_error and "超时" in r2.as_text(), r2.as_text() + assert real < 4.0, f"进程树杀不生效,实际阻塞 {real:.1f}s" + assert [x[0] for x in ticks] == [1], f"超前应读 1 秒: {ticks}" + + +# ====================================================================== +# 13. 同一 Agent 多次 prompt(会话记忆累积,对照 pi Agent 复用) +# ====================================================================== +def test_agent_reuse_across_prompts(): + agent = make_agent( + [{"content": "第一次。"}, {"content": "第二次。"}]) + r1 = agent.prompt("问题1") + r2 = agent.prompt("问题2") + users = [m for m in agent.state.messages if m.role == "user"] + assert len(users) == 2 + # 第二轮上下文包含第一轮全部消息 + assert r1.stop_reason == "stop" and r2.stop_reason == "stop" + + +# ====================================================================== +# 14. AgentWorker 接线回归(P0:必须注入 stream_fn,否则"未配置 stream_fn") +# ====================================================================== +def test_agent_worker_stream_fn_wiring(): + """AgentWorker.run() 必须把 openai_stream 注入 Agent(对照 pi 的 + agentLoopConfig.streamFn 注入点)。用假流函数验证整条接线。""" + import core.llm_engine as le + from PyQt6.QtCore import QCoreApplication + + QCoreApplication.instance() or QCoreApplication([]) + + chunks, errors = [], [] + real_stream = le.openai_stream + + def fake_stream(context, model, signal, max_tokens, tools=None): + yield ("event", _text("你好,")) + yield ("event", _text("世界!")) + yield ("final", AgentMessage(role="assistant", content="你好,世界!", + stop_reason="stop", + usage={"input": 5, "output": 5})) + + le.openai_stream = fake_stream + try: + cfg = le._load_config() + providers = list(cfg.get("providers", {}).keys()) + assert providers, "config.json 里没有 provider" + w = le.AgentWorker(providers[0], "any-model", + [{"role": "user", "content": "hi"}]) + w.chunk_received.connect(lambda t: chunks.append(t)) + w.error_occurred.connect(lambda e: errors.append(e)) + w.run() # 同步跑(不走线程),验证接线 + finally: + le.openai_stream = real_stream + + assert not errors, f"出现错误(stream_fn 未注入?): {errors}" + assert "".join(chunks) == "你好,世界!" + + +# ====================================================================== +# 15. 系统提示词:请求头部注入 + 不入历史 + SYSTEM_PROMPT.md 加载 +# ====================================================================== +def test_system_prompt_injected_at_request_head(): + seen = {} + + def spy_stream(context, model, signal, max_tokens, tools=None): + seen["context"] = list(context) + yield ("event", _text("ok")) + yield ("final", AgentMessage(role="assistant", content="ok", + stop_reason="stop", + usage={"input": 1, "output": 1})) + + model = ModelConfig(provider="mock", name="m", context_window=100000, + max_tokens=8192, api_key="x", base_url="http://mock") + cfg = AgentConfig(model=model, tools=[], system_prompt="SYS-TEST-123") + agent = Agent(cfg) + agent.set_stream_fn(spy_stream) + agent.prompt("hi") + + ctx = seen["context"] + assert ctx[0].role == "system" and ctx[0].content == "SYS-TEST-123" + # 系统提示词不能混进会话历史(不占压缩/持久化) + assert all(m.role != "system" for m in agent.state.messages) + # to_openai_messages:头部 system 放行,中途 system 跳过 + from core.agent.stream_fn import to_openai_messages + msgs = [ctx[0], + AgentMessage(role="user", content="u"), + AgentMessage(role="system", content="stray")] + out = to_openai_messages(msgs) + assert out[0] == {"role": "system", "content": "SYS-TEST-123"} + assert sum(1 for m in out if m["role"] == "system") == 1 + + +def test_system_prompt_md_file_loaded(): + from core.llm_engine import load_system_prompt + prompt = load_system_prompt() + assert "haocode" in prompt and len(prompt) > 200 + + +# ====================================================================== +# 16. tools 必须进 API 请求(回归:模型因此"文字演戏"工具调用) +# ====================================================================== +def test_openai_stream_sends_tools(): + import types as _pytypes + import core.agent.stream_fn as sf + from core.agent.stream_fn import AbortSignal + from core.agent.tools import default_tools + + captured = {} + + class _FakeStream: + def __init__(self, chunks): + self._it = iter(chunks) + + def __iter__(self): + return self + + def __next__(self): + return next(self._it) + + def close(self): + pass + + def _chunk(choices, usage=None): + return _pytypes.SimpleNamespace(usage=usage, choices=choices) + + def _delta(content=None, tool_calls=None): + return _pytypes.SimpleNamespace( + content=content, tool_calls=tool_calls, + **{"reasoning_content": None}) + + _tc = _pytypes.SimpleNamespace( + index=0, id="call_1", + function=_pytypes.SimpleNamespace(name="bash", + arguments='{"command":"echo hi"}')) + chunks = [ + _chunk([_pytypes.SimpleNamespace(delta=_delta(tool_calls=[_tc]), + finish_reason="tool_calls")]), + _chunk([], usage=_pytypes.SimpleNamespace(prompt_tokens=3, + completion_tokens=2)), + ] + + class _FakeCompletions: + def create(self, **params): + captured.update(params) + return _FakeStream(chunks) + + real_openai = sf.OpenAI + sf.OpenAI = lambda **kw: _pytypes.SimpleNamespace( + chat=_pytypes.SimpleNamespace(completions=_FakeCompletions())) + try: + model = ModelConfig(provider="t", name="m", context_window=100000, + max_tokens=100, api_key="k", base_url="http://x") + sig = AbortSignal() + out = list(sf.openai_stream([AgentMessage(role="user", content="hi")], + model, sig, 100, tools=default_tools())) + finally: + sf.OpenAI = real_openai + + tools = captured.get("tools") + assert tools, "tools 没进请求体!" + names = {t["function"]["name"] for t in tools} + assert names == {"read", "bash", "write", "edit"} + assert tools[0]["type"] == "function" + + final = [p for k, p in out if k == "final"][0] + assert final.tool_calls and final.tool_calls[0].name == "bash" + assert final.tool_calls[0].arguments == {"command": "echo hi"} + + +# ====================================================================== +# 17. 循环层兜底:文字"演"的工具调用被解析执行 +# ====================================================================== +def test_text_tool_calls_loop_fallback(): + turns = [ + {"content": "我来看看。echo haocode-test 稍等", + "tool_calls": []}, + {"content": "命令输出正常。"}, + ] + agent = make_agent(turns, tools=default_tools()) + result = agent.prompt("看一下") + + msgs = agent.state.messages + asst1 = next(m for m in msgs + if m.role == "assistant" and m.tool_calls) + assert asst1.tool_calls[0].name == "bash" + assert asst1.tool_calls[0].arguments["command"] == "echo haocode-test" + tr = next(m for m in msgs if m.role == "toolResult") + assert "haocode-test" in (tr.content if isinstance(tr.content, str) + else str(tr.content)) + assert result.stop_reason == "stop" + + +# ====================================================================== +# 18. from_openai_messages 全保真(worker 模式上下文丢失 bug 的回归守护) +# ====================================================================== +def test_from_openai_messages_preserves_tool_history(): + """build_api_context 产物(含 tool 消息 + assistant tool_calls)经 + from_openai_messages → to_openai_messages 往返后必须保真: + 工具历史不能丢,残缺参数 JSON 必须兑底成合法 "{}"。""" + import json as _json + src = [ + {"role": "user", "content": "帮我运行 main.py"}, + {"role": "assistant", "content": "我来读一下。"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "c1", "type": "function", + "function": {"name": "read", "arguments": '{"path": "main.py"}'}}]}, + {"role": "tool", "tool_call_id": "c1", "content": "print('hello')"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "c2", "type": "function", + "function": {"name": "bash", + "arguments": '{"command": "python main.py"}'}}]}, + {"role": "tool", "tool_call_id": "c2", "content": "hello"}, + {"role": "assistant", "content": "运行成功。"}, + {"role": "user", "content": "刚才的代码是什么?"}, + ] + msgs = from_openai_messages(src) + # 角色序列保真(8 条,无丢失、无空 assistant) + assert [m.role for m in msgs] == [ + "user", "assistant", "assistant", "toolResult", + "assistant", "toolResult", "assistant", "user"] + # tool_call 保真 + assert msgs[2].tool_calls[0].id == "c1" + assert msgs[2].tool_calls[0].name == "read" + assert msgs[2].tool_calls[0].arguments == {"path": "main.py"} + assert msgs[4].tool_calls[0].name == "bash" + # toolResult 保真 + assert msgs[3].tool_call_id == "c1" + assert msgs[3].content == "print('hello')" + # 纯工具轮不再是空 assistant + assert msgs[2].content == "" and len(msgs[2].tool_calls) == 1 + + # 往返:to_openai_messages(from_openai_messages(x)) 角色序一致 + JSON 合法 + rt = to_openai_messages(msgs) + assert [m["role"] for m in rt] == [m["role"] for m in src] + for m in rt: + if m["role"] == "assistant" and m.get("tool_calls"): + for tc in m["tool_calls"]: + _json.loads(tc["function"]["arguments"]) # 必须是合法 JSON + + # 残缺参数 JSON 兑底(中断导致的历史截断场景) + bad = [{"role": "user", "content": "hi"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "c9", "type": "function", + "function": {"name": "bash", "arguments": '{"command": "ls -'}}]}, + {"role": "tool", "tool_call_id": "c9", "content": "out"}] + bmsgs = from_openai_messages(bad) + assert bmsgs[1].tool_calls[0].arguments == {} + assert bmsgs[1].tool_calls[0].raw_arguments == "{}" + brt = to_openai_messages(bmsgs) + _json.loads(brt[1]["tool_calls"][0]["function"]["arguments"]) + + # token 估算器能消化 toolResult/tool_calls(不崩 + 计入工具历史) + est_full = estimate_context_tokens(msgs).tokens + est_notools = estimate_context_tokens( + [m for m in msgs if m.role != "toolResult"]).tokens + assert est_full > est_notools + + +def test_agent_worker_second_turn_sees_tool_history(): + """集成:AgentWorker 第 2 轮真实发出的 context 必须包含第 1 轮的全部 + 工具历史(toolResult + assistant tool_calls)——任务完成后追问不丢上下文。""" + import core.llm_engine as le + from PyQt6.QtCore import QCoreApplication + + QCoreApplication.instance() or QCoreApplication([]) + seen = {} + real_stream = le.openai_stream + + def fake_stream(context, model, signal, max_tokens, tools=None): + seen["context"] = list(context) + yield ("event", _text("ok")) + yield ("final", AgentMessage(role="assistant", content="ok", + stop_reason="stop", + usage={"input": 1, "output": 1})) + + le.openai_stream = fake_stream + try: + cfg = le._load_config() + providers = list(cfg.get("providers", {}).keys()) + assert providers, "config.json 里没有 provider" + w = le.AgentWorker(providers[0], "any-model", [ + {"role": "user", "content": "帮我运行 main.py"}, + {"role": "assistant", "content": "我来读一下。"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "c1", "type": "function", + "function": {"name": "read", + "arguments": '{"path": "main.py"}'}}]}, + {"role": "tool", "tool_call_id": "c1", + "content": "print('hello')"}, + {"role": "assistant", "content": "运行成功。"}, + {"role": "user", "content": "刚才的代码是什么?"}, + ]) + errors = [] + w.error_occurred.connect(lambda e: errors.append(e)) + w.run() # 同步跑 + finally: + le.openai_stream = real_stream + + assert not errors, f"AgentWorker 报错: {errors}" + ctx = seen["context"] + # 工具历史必须完整在场 + trs = [m for m in ctx if m.role == "toolResult"] + assert len(trs) == 1 and trs[0].tool_call_id == "c1" + assert "print('hello')" in trs[0].content + asst_tc = [m for m in ctx if m.role == "assistant" and m.tool_calls] + assert len(asst_tc) == 1 and asst_tc[0].tool_calls[0].name == "read" + # 末尾是本次提问 + assert ctx[-1].role == "user" and ctx[-1].content == "刚才的代码是什么?" + + +# ====================================================================== +# 🆕 P0: usage 锚点时效校验(对照 pi getLastAssistantUsageInfo) +# ====================================================================== +def test_usage_anchor_stale_after_compaction_summary(): + """压缩后:保留窗里 assistant 的旧 usage(来自压缩前)必须失效, + 否则下一次 should_compact 会锚定到过期 60K → 误触发二次压缩。""" + stale = AgentMessage(role="assistant", content="旧回答", + timestamp=2000, stop_reason="stop", + usage={"input": 55000, "output": 5000}) + # 对照组:无摘要 → 应锚定到 stale usage + est_ctrl = estimate_context_tokens([ + AgentMessage(role="user", content="旧问题", timestamp=1000), + stale, + ]) + assert est_ctrl.last_usage_index == 1 + assert est_ctrl.usage_tokens == 60000 + + # 实验组:新摘要消息(timestamp 更晚)插到前面 → 旧 usage 失效 + summary = AgentMessage(role="user", content="(压缩摘要)", + timestamp=9000, kind="compaction_summary") + est = estimate_context_tokens([summary, + AgentMessage(role="user", content="旧问题", timestamp=1000), + stale]) + assert est.last_usage_index is None, f"过期 usage 未被失效: {est}" + assert est.usage_tokens == 0 + + +def test_usage_anchor_recovers_after_new_assistant(): + """压缩后第一个新 assistant 回复到达 → 新 usage 自动成为锚点""" + msgs = [ + AgentMessage(role="user", content="(压缩摘要)", timestamp=9000, + kind="compaction_summary"), + AgentMessage(role="assistant", content="旧回答", timestamp=2000, + stop_reason="stop", usage={"input": 55000, "output": 5000}), + AgentMessage(role="user", content="新问题", timestamp=10000), + AgentMessage(role="assistant", content="新回答", timestamp=11000, + stop_reason="stop", usage={"input": 20000, "output": 300}), + ] + est = estimate_context_tokens(msgs) + assert est.last_usage_index == 3, f"应锚定到新回复: {est}" + assert est.usage_tokens == 20300 + + +def test_estimate_no_anchor_without_timestamps_backward_compatible(): + """无 timestamp 信息(全 0,旧 DB 回放)→ 行为等同旧版「取最后一条有效」""" + msgs = [ + AgentMessage(role="user", content="q1"), + AgentMessage(role="assistant", content="a1", stop_reason="stop", + usage={"input": 100, "output": 10}), + AgentMessage(role="user", content="q2"), + AgentMessage(role="assistant", content="a2", stop_reason="stop", + usage={"input": 200, "output": 20}), + ] + est = estimate_context_tokens(msgs) + assert est.last_usage_index == 3 + assert est.usage_tokens == 220 + + +# ====================================================================== +# 🆕 P2: 无锚点时 system 提示词 + 工具 schema 计入估算/钳制 +# ====================================================================== +class _FakeTool: + name = "read" + description = "读取文件内容" + parameters = {"type": "object", "properties": {"path": {"type": "string"}}} + + +def test_estimate_includes_system_and_tools_without_anchor(): + sysp = "你是一个编程助手。" * 100 # 900 字(。U+3002 不在 CJK 类 → 估算 ≈825) + base_msgs = [AgentMessage(role="user", content="hi")] + plain = estimate_context_tokens(list(base_msgs)) + full = estimate_context_tokens(list(base_msgs), + system_prompt=sysp, tools=[_FakeTool()]) + # system(≈825) + 工具 schema(>30) 必须被计入 + assert full.tokens >= plain.tokens + 800, f"{plain} -> {full}" + + # 有锚点时:usage 已精确覆盖 system+tools,不重复计 + anchor_msgs = [AgentMessage(role="assistant", content="x", + stop_reason="stop", + usage={"input": 1000, "output": 100})] + a1 = estimate_context_tokens(list(anchor_msgs)) + a2 = estimate_context_tokens(list(anchor_msgs), + system_prompt=sysp, tools=[_FakeTool()]) + assert a1.tokens == a2.tokens == 1100, f"{a1} vs {a2}" + + +def test_clamp_counts_system_tools(): + # 小窗口(8K):钳制结果受「窗口-输入-4096」约束,system+tools 计入后必须缩小 + model = ModelConfig(provider="p", name="m", + context_window=8000, max_tokens=8192) + msgs = [AgentMessage(role="user", content="hi")] + c1 = clamp_max_tokens_to_context(model, list(msgs)) + c2 = clamp_max_tokens_to_context(model, list(msgs), + system_prompt="x" * 4000, + tools=[_FakeTool()]) + # 输入估算上升 → 输出预算如实缩小(对照 pi 传完整 Context) + assert c2[1] > c1[1] + 900, f"input: {c1[1]} -> {c2[1]}" + assert c2[0] < c1[0], f"max_tokens: {c1[0]} -> {c2[0]}" + + +# ====================================================================== +# 🆕 M1/M2: 流式字段提取(reasoning 三字段 / choice.usage 兜底) +# ====================================================================== +def test_pick_reasoning_field_priority(): + from types import SimpleNamespace as NS + from core.agent.stream_fn import _pick_reasoning + # reasoning_content 优先(vLLM/Qwen) + assert _pick_reasoning(NS(reasoning="a", reasoning_content="b", + reasoning_text="c")) == "b" + # 其次 reasoning(llama.cpp) + assert _pick_reasoning(NS(reasoning="a")) == "a" + # 再次 reasoning_text + assert _pick_reasoning(NS(reasoning_text="c")) == "c" + # 全空 + assert _pick_reasoning(NS()) == "" + assert _pick_reasoning(None) == "" + # pydantic 对象的 model_dump 兜底 + class _D: + def model_dump(self): + return {"reasoning_text": "z"} + assert _pick_reasoning(_D()) == "z" + + +def test_pick_usage_choice_fallback(): + from types import SimpleNamespace as NS + from core.agent.stream_fn import _pick_usage + u1 = object() + assert _pick_usage(NS(usage=u1)) is u1 + # chunk.usage 为空 → choice.usage 兜底(Moonshot 系) + u2 = object() + assert _pick_usage(NS(usage=None, choices=[NS(usage=u2)])) is u2 + assert _pick_usage(NS(usage=None, choices=[])) is None + assert _pick_usage(NS(usage=None)) is None + + +# ====================================================================== +# 🆕 M3: AgentRunner 重试回调(onRetryScheduled / onRetryFinished) +# ====================================================================== +def test_agent_runner_retry_callbacks(): + from core.agent.recovery import AgentRunner + from core.agent.types import AgentConfig, ModelConfig, RetryConfig, RunResult + + events = [] + + class _State: + def __init__(self): + self.messages = [ + AgentMessage(role="user", content="q"), + AgentMessage(role="assistant", stop_reason="error", + error_message="connection error"), + ] + + class _Agent: + def __init__(self): + self.state = _State() + self.config = AgentConfig( + model=ModelConfig(provider="p", name="m", + context_window=128000, max_tokens=8192), + retry=RetryConfig(max_attempts=3, base_delay_ms=1)) + self.continued = False + + def has_queued(self): + return False + + def continue_(self): + self.continued = True + self.state.messages[-1] = AgentMessage( + role="assistant", stop_reason="stop", content="ok") + return RunResult(stop_reason="stop") + + agent = _Agent() + runner = AgentRunner( + agent, summarize_fn=None, + on_retry_scheduled=lambda a, m, d, r: events.append(("sched", a, m, r)), + on_retry_finished=lambda ok: events.append(("fin", ok))) + + runner._post_loop(RunResult(stop_reason="stop")) + + assert agent.continued, "可重试错误未触发 continue" + kinds = [e[0] for e in events] + assert "sched" in kinds and "fin" in kinds, f"events={events}" + sched = next(e for e in events if e[0] == "sched") + assert sched[1] == 1 and sched[2] == 3 + assert sched[3] == "connection error", f"reason 未透传: {sched}" + assert events[-1] == ("fin", True) + + +# ====================================================================== +# 🆕 P1: usage/timestamp 入库回放链路 +# ====================================================================== +def test_from_openai_maps_usage_and_timestamp(): + msgs = from_openai_messages([ + {"role": "user", "content": "q", "timestamp": 111}, + {"role": "assistant", "content": "a", "timestamp": 222, + "usage": {"input": 100, "output": 50}}, + {"role": "tool", "tool_call_id": "c1", "content": "r", + "timestamp": 333}, + ]) + assert msgs[0].timestamp == 111 + assert msgs[1].timestamp == 222 + assert msgs[1].usage == {"input": 100, "output": 50} + assert msgs[2].timestamp == 333 + est = estimate_context_tokens(msgs) + assert est.last_usage_index == 1 and est.usage_tokens == 150 + + +def test_db_usage_roundtrip(): + """add_message(usage=...) → get_message_chain 行带 usage → 锚定生效""" + import json as _json + import tempfile + import time as _time + from core.db_manager import DBManager + + tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + tmp.close() + try: + db = DBManager(tmp.name) + sid = db.create_session("P1 usage 测试")["id"] + uid = db.add_message(sid, "user", "问题一", None) + _time.sleep(1.1) # 保证 created_at(秒)递增,模拟真实时序 + db.add_message(sid, "assistant", "回答一(很短)", uid["id"], + usage=_json.dumps({"input": 30000, "output": 2000})) + chain = db.get_message_chain(sid) + assert [m["role"] for m in chain] == ["user", "assistant"], \ + f"chain={[(m['role'], m['parent_id']) for m in chain]}" + assert _json.loads(chain[1]["usage"]) == {"input": 30000, "output": 2000} + + # 模拟 build_api_context 的回放(timestamp/usage 挂到 OpenAI dict) + oai = [{"role": m["role"], "content": m["content"], + "timestamp": int(m["created_at"]) * 1000, + **({"usage": _json.loads(m["usage"])} + if m.get("usage") else {})} + for m in chain] + agent_msgs = from_openai_messages(oai) + est = estimate_context_tokens(agent_msgs) + assert est.last_usage_index == 1, f"usage 未锚定: {est}" + assert est.usage_tokens == 32000 + finally: + try: + os.remove(tmp.name) + except OSError: + pass + + +# ====================================================================== +# 轮中主动压缩检查(🆕 haocode 增强,偏离 pi 1:1) +# 内层循环每次 LLM 请求前跑 should_compact 同公式,超阈值先压缩再发。 +# ====================================================================== + +def _mid_tool(name, text): + """确定性工具:返回固定文本(避免真实 bash)""" + def execute(tool_call_id, args, signal, on_update, context): + return AgentToolResult.text(text) + return AgentTool( + name=name, description=name, + parameters={"type": "object", "properties": {}}, + execute=execute, label=name) + + +def _prefill_history(agent, n=8, chars=2000): + for i in range(n): + agent.state.messages.append( + AgentMessage(role="user", content=f"hist{i} " + "长" * chars)) + + +def _capture_requests(agent, captured): + """包一层 stream_fn,记录每次请求的 api 上下文""" + orig = agent._stream_fn + + def wrapper(context, model, signal, max_tokens, tools=None): + captured.append(list(context)) + yield from orig(context, model, signal, max_tokens, tools) + + agent._stream_fn = wrapper + + +def test_mid_turn_compaction_fires_on_growth(): + # 窗口 40000 → 阈值 23616;预填 8×2000 字 ≈ 16k(轮首检查不触发)。 + # ⚠️ 脚本流的 usage 必须真实(P0 锚点机制:估算锚定到最后一条 + # assistant 的真实 usage.input + 其后新增消息)。turn1 请求时真实 + # 输入 ≈ 16053 → usage.input=16000。 + agent = make_agent( + [{"tool_calls": [{"id": "c1", "name": "midtool", + "arguments": {}}], "stop_reason": "stop", + "usage": {"input": 16000, "output": 10}}, + {"content": "done.", + "usage": {"input": 20000, "output": 10}}], + tools=[_mid_tool("midtool", "长" * 8000)], # 8k → 锚定后 ≈ 24k > 23616 + context_window=40000, + ) + _prefill_history(agent) + summarize_calls = [] + + def summarize(prompt, system_prompt, max_tokens): + summarize_calls.append(1) + return "SUMMARY-MID" + + runner = AgentRunner(agent, summarize_fn=summarize) + agent.config.compact_fn = runner.compact_if_needed # 模拟 llm_engine 接线 + captured = [] + _capture_requests(agent, captured) + + result = runner.run("go") + assert result.stop_reason == "stop" + # 轮中压缩恰好触发一次(轮首 16k 不触发;压缩后 ≈20k 不再触发) + assert len(summarize_calls) == 1 + assert runner.compactions_performed == 1 + assert any(ev.get("path") == "mid_turn" for ev in runner.compaction_events) + # 状态首条是摘要,最早两条历史被摘要掉 + assert agent.state.messages[0].kind == "compaction_summary" + assert "SUMMARY-MID" in agent.state.messages[0].content + all_text = "".join(str(getattr(m, "content", "")) for m in agent.state.messages) + assert "hist0" not in all_text and "hist1" not in all_text + assert "hist7" in all_text + # 第二次请求(压缩后的那个)上下文首条就是摘要 + assert len(captured) == 2 + assert captured[1][0].kind == "compaction_summary" + + +def test_mid_turn_compaction_failure_capped(): + # 单条巨型工具结果(30k ≥ keep_recent 20k)在尾部 → 无有效切点 + # (cut_pinned_at_zero)→ 压缩失败;连败 2 次后本轮不再尝试(防刷屏护栏)。 + # 窗口 100000 → 阈值 83616;预填 34×2000 ≈ 68k。usage 必须真实 + # (锚点估算 = 最后 asst 的 usage.input + 其后新增)。 + import core.agent.recovery as rec + calls = [] + orig = rec.compact_context + + def counting(*a, **kw): + calls.append(1) + return orig(*a, **kw) + + rec.compact_context = counting + try: + agent = make_agent( + [{"tool_calls": [{"id": "c1", "name": "midtool", + "arguments": {}}], "stop_reason": "stop", + "usage": {"input": 60000, "output": 10}}, + {"tool_calls": [{"id": "c2", "name": "midtool", + "arguments": {}}], "stop_reason": "stop", + "usage": {"input": 65000, "output": 10}}, + {"content": "done.", + "usage": {"input": 50000, "output": 10}}], + tools=[_mid_tool("midtool", "长" * 30000)], + context_window=100000, + ) + for i in range(34): + agent.state.messages.append( + AgentMessage(role="user", content=f"hist{i} " + "长" * 2000)) + + def summarize(prompt, system_prompt, max_tokens): + raise AssertionError("prepare 返回 None → 不应调用 LLM 摘要") + + runner = AgentRunner(agent, summarize_fn=summarize) + agent.config.compact_fn = runner.compact_if_needed + result = runner.run("go") + assert result.stop_reason == "stop" + # 3 次请求机会:第 2、3 次前尝试压缩(均失败),第… 耗尽后跳过 + # turn2 前: 60000+30004=90004 > 83616 → 尝试#1 失败 + # turn3 前: 65000+30004=95004 > 83616 → 尝试#2 失败 → 耗尽 + assert len(calls) == 2, f"compact_context 应恰好尝试 2 次,实际 {len(calls)}" + assert runner._mid_turn_exhausted is True + assert runner.compactions_performed == 0 + # 状态未被修改(无摘要),原样继续 + assert not any(getattr(m, "kind", None) == "compaction_summary" + for m in agent.state.messages) + finally: + rec.compact_context = orig + + +def test_mid_turn_no_compaction_below_threshold(): + # 工具结果小(2k)→ 锚定后 16000+2004 ≈ 18k < 23616 → 不压缩,正常完成 + agent = make_agent( + [{"tool_calls": [{"id": "c1", "name": "midtool", + "arguments": {}}], "stop_reason": "stop", + "usage": {"input": 16000, "output": 10}}, + {"content": "done.", + "usage": {"input": 18000, "output": 10}}], + tools=[_mid_tool("midtool", "长" * 2000)], + context_window=40000, + ) + _prefill_history(agent) + summarize_calls = [] + + def summarize(prompt, system_prompt, max_tokens): + summarize_calls.append(1) + return "SHOULD-NOT-RUN" + + runner = AgentRunner(agent, summarize_fn=summarize) + agent.config.compact_fn = runner.compact_if_needed + result = runner.run("go") + assert result.stop_reason == "stop" + assert summarize_calls == [] + assert runner.compactions_performed == 0 + assert not any(getattr(m, "kind", None) == "compaction_summary" + for m in agent.state.messages) + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) diff --git a/tests/test_bash_stream.py b/tests/test_bash_stream.py new file mode 100644 index 0000000..abc75cc --- /dev/null +++ b/tests/test_bash_stream.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +""" +tool_bash 实时输出(增量流)单测 —— 纯函数级,无 UI 依赖 + +本轮改动核心:communicate(timeout=1) 阻塞式收集 + → stdout/stderr reader 线程 + 队列 + 主循环抽干回调 on_update +本测试锁死「改动没有破坏任何既有语义」+「输出真的是实时的」。 + +覆盖: + T1 增量流:on_update 被多次调用(不是结束时一次性给) + T2 实时性:第一块输出到达时刻 << 总耗时(证明是流式而非收尾补发) + T3 上下文文本格式完全不变($ cmd / stdout / [stderr] / [exit N] (Ns)) + T4 stderr 实时流带 [stderr] 分隔标记,且最终文本仍有 [stderr] 段 + T5 每 1 秒读秒回调 on_timer 仍然工作 + T6 超时:杀进程树 + 「命令超时(>Ns)已终止」+ 实际耗时远小于命令时长 + T7 中止:AbortSignal.aborted → 「操作已中止」 + T8 50KB 截断仍然生效 + T9 退出非 0 → is_error,且 exit code 写入 details + T10 空命令 → 参数校验错误 + T11 无输出命令:不产生 on_update,但读秒正常 + T12 最终文本 = 全部 stdout 拼接(不丢不重) + +运行: PYTHONIOENCODING=utf-8 python tests/test_bash_stream.py +""" +import os +import re +import sys +import time +import tempfile +import shutil + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.agent.tools import tool_bash # noqa: E402 +from core.agent.types import AbortSignal # noqa: E402 + +RESULTS = [] + + +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + + +_TMP = tempfile.mkdtemp(prefix="hocode_bashstream_") +PY = sys.executable + + +def run(script=None, timeout=None, abort_after=None, shell_script=None): + """把脚本写成临时文件再执行(避开 cmd 引号地狱)。 + 返回 (result, updates, first_t, dur, timer_calls)""" + path = None + if script is not None: + path = os.path.join(_TMP, f"s_{int(time.time() * 1000000) % 10**9}.py") + with open(path, "w", encoding="utf-8") as f: + f.write(script) + cmd = f'"{PY}" -u "{path}"' + else: + cmd = shell_script + + updates = [] + first_t = [None] + t0 = time.time() + + def on_update(text): + if first_t[0] is None: + first_t[0] = time.time() - t0 + updates.append(text) + + timers = [] + + def on_timer(elapsed, total): + timers.append((elapsed, total)) + + sig = AbortSignal() + if abort_after is not None: + import threading + + def _aborter(): + time.sleep(abort_after) + sig.abort("test") + threading.Thread(target=_aborter, daemon=True).start() + + args = {"command": cmd} + if timeout is not None: + args["timeout"] = timeout + res = tool_bash("call_test", args, sig, on_update, + {"cwd": _TMP, "shell": True, "on_timer": on_timer}) + dur = time.time() - t0 + return res, updates, first_t[0], dur, timers + + +def text_of(res): + c = res.content + if isinstance(c, str): + return c + return "".join(x.get("text", "") for x in c if isinstance(x, dict)) + + +# ====================================================================== +try: + # ---------- T1/T2/T3/T12: 流式 + 上下文格式 ---------- + res, ups, first_t, dur, timers = run( + "import sys, time\n" + "for i in range(3):\n" + " print('LINE%d' % i, flush=True)\n" + " time.sleep(0.5)\n", timeout=30) + txt = text_of(res) + check("T1.1 增量流被多次回调(>=3 块)", len(ups) >= 3, f"updates={len(ups)} {ups!r}") + check("T1.2 每块都是本次输出的一部分", all(u.strip() for u in ups), repr(ups[:3])) + check("T2.1 第一块在 1s 内到达(实时,不是收尾补发)", + first_t is not None and first_t < 1.0, f"first_t={first_t}") + check("T2.2 第一块明显早于总耗时", + first_t is not None and (dur - first_t) > 0.6, f"first={first_t} dur={dur:.2f}") + check("T3.1 上下文文本以 $ 命令开头", txt.startswith("$ "), repr(txt[:60])) + check("T3.2 含 [exit 0] 与耗时", + re.search(r"\[exit 0\] \(\d+\.\d+s\)", txt) is not None, repr(txt[-60:])) + check("T12.1 三行输出全部进入最终文本,不丢不重", + all(f"LINE{i}" in txt for i in range(3)) and txt.count("LINE0") == 1, repr(txt)) + check("T3.3 is_error=False", res.is_error is False) + check("T3.4 details 带 exit_code", res.details.get("exit_code") == 0, str(res.details)) + + # ---------- T4: stderr 实时 + 最终段 ---------- + res, ups, first_t, dur, timers = run( + "import sys\nprint('OUT1', flush=True)\nprint('ERR1', file=sys.stderr, flush=True)\n", + timeout=30) + txt = text_of(res) + joined = "".join(ups) + check("T4.1 实时流里有 [stderr] 分隔标记", "[stderr]" in joined, repr(joined)) + check("T4.2 最终文本含 [stderr] 段与内容", + "[stderr]" in txt and "ERR1" in txt, repr(txt)) + check("T4.3 stdout 内容也在最终文本里", "OUT1" in txt, repr(txt)) + + # ---------- T5/T11: 读秒 ---------- + res, ups, first_t, dur, timers = run( + "import time\ntime.sleep(2.6)\nprint('done', flush=True)\n", timeout=30) + check("T5.1 长命令期间读秒回调(>=2 次)", len(timers) >= 2, f"timers={timers}") + check("T5.2 读秒总量正确", all(t[1] == 30 for t in timers), str(timers)) + + res, ups, first_t, dur, timers = run( + "import time\ntime.sleep(1.5)\n", timeout=30) + check("T11.1 无输出命令不产生 on_update", len(ups) == 0, repr(ups)) + check("T11.2 无输出命令仍有读秒", len(timers) >= 1, str(timers)) + + # ---------- T6: 超时杀树 ---------- + res, ups, first_t, dur, timers = run( + "import time\nprint('start', flush=True)\ntime.sleep(30)\n", timeout=2) + txt = text_of(res) + check("T6.1 超时文案", "命令超时(>2s)已终止" in txt, repr(txt)) + check("T6.2 真的提前返回(耗时应 <10s)", dur < 10, f"dur={dur:.2f}") + check("T6.3 超时结果是 is_error", res.is_error is True) + + # ---------- T7: 中止 ---------- + res, ups, first_t, dur, timers = run( + "import time\ntime.sleep(30)\n", timeout=60, abort_after=1.5) + txt = text_of(res) + check("T7.1 中止文案", "操作已中止" in txt, repr(txt)) + check("T7.2 中止后迅速返回(<8s)", dur < 8, f"dur={dur:.2f}") + + # ---------- T8: 50KB 截断 ---------- + res, ups, first_t, dur, timers = run( + "import sys\n" + "line = 'X' * 100 + '\\n'\n" + "for _ in range(1200):\n" + " sys.stdout.write(line)\n" + "sys.stdout.flush()\n", timeout=30) + txt = text_of(res) + check("T8.1 超过 50KB 触发截断标记", "[输出超过 50KB 已截断]" in txt, repr(txt[-80:])) + check("T8.2 截断后字节数 <= 50KB+余量", + len(txt.encode("utf-8")) <= 50 * 1024 + 200, str(len(txt.encode("utf-8")))) + + # ---------- T9: 非 0 退出 ---------- + res, ups, first_t, dur, timers = run("import sys\nsys.exit(3)\n", timeout=30) + txt = text_of(res) + check("T9.1 exit 3 写入文本", "[exit 3]" in txt, repr(txt)) + check("T9.2 is_error=True", res.is_error is True) + check("T9.3 details.exit_code=3", res.details.get("exit_code") == 3, str(res.details)) + + # ---------- T10: 空命令 ---------- + res = tool_bash("c", {"command": " "}, AbortSignal(), None, + {"cwd": _TMP, "shell": True}) + check("T10.1 空命令被拒", "command 不能为空" in text_of(res), repr(text_of(res))) + + # ---------- T13: 秒级短命令仍正常(回归:不因线程化而变慢/丢输出) ---------- + res, ups, first_t, dur, timers = run(shell_script="echo hello_from_cmd") + txt = text_of(res) + check("T13.1 短命令输出正确", "hello_from_cmd" in txt, repr(txt)) + check("T13.2 短命令耗时 <3s", dur < 3, f"dur={dur:.2f}") + check("T13.3 短命令实时流也拿到了输出", "hello_from_cmd" in "".join(ups), repr(ups)) + +finally: + shutil.rmtree(_TMP, ignore_errors=True) + +failed = [n for n, ok in RESULTS if not ok] +print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True) +print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True) +sys.exit(0 if not failed else 1) diff --git a/tests/test_compaction_persist.py b/tests/test_compaction_persist.py new file mode 100644 index 0000000..48345e0 --- /dev/null +++ b/tests/test_compaction_persist.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +""" +离屏验证:压缩持久化(链上标记点) +背景:压缩此前只改内存 agent.state.messages,下一轮提问从 DB 全量重建历史 +→「压缩等于没有压缩」。现:压缩成功后在链上插标记行(role="compaction", +is_ignored=1),保留首条改指到标记;build_api_context 在最后一个标记处截断、 +以摘要(user 消息)替代之前一切。历史行全部保留,前端切会话渲染不受影响。 +""" +import os, sys, json, tempfile, types + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from core.db_manager import DBManager # noqa: E402 +from core.agent.types import AgentMessage, AgentConfig, ModelConfig # noqa: E402 +from core.agent.stream_fn import from_openai_messages, to_openai_messages # noqa: E402 +from core.agent.compaction import prepare_compaction, CompactionSettings # noqa: E402 +from core.agent.context import estimate_context_tokens # noqa: E402 +from core.agent.recovery import AgentRunner, _cut_ids_of # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 纯方法 unbound 调用,不实例化 + +RESULTS = [] +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + +TMP = os.path.join(tempfile.gettempdir(), f"haocode_compact_persist_{os.getpid()}.db") +if os.path.exists(TMP): + os.remove(TMP) +db = DBManager(TMP) +sess = db.create_session("压缩持久化测试") +sid = sess["id"] + +def add(role, content, parent): + return db.add_message(session_id=sid, role=role, content=content, parent_id=parent) + +# 链:r1(user 旧) → r2(assistant 旧) → r3(user 旧) → r4(assistant 保留) → r5(user 保留) +r1 = add("user", "旧问题1", None) +r2 = add("assistant", "旧回答1", r1["id"]) +r3 = add("user", "旧问题2", r2["id"]) +r4 = add("assistant", "保留回答", r3["id"]) +r5 = add("user", "保留问题", r4["id"]) +leaf_before = db.get_session_leaf(sid) +check("T0.初始叶子=最后一条", leaf_before == r5["id"]) + +MARK_SUMMARY = "## 摘要\n上方对话已摘要:用户问了两个旧问题。" + +# ---------- T1:标记插入(原子,不动叶子) ---------- +mid = db.insert_compaction_mark(sid, MARK_SUMMARY, r3["id"], r4["id"], + json.dumps({"path": "pre_prompt"}, ensure_ascii=False)) +chain = db.get_message_chain(sid) +check("T1.标记行已插入链上", mid is not None and any(m["id"] == mid for m in chain)) +check("T1.链=6行且顺序正确", + [m["id"] for m in chain] == [r1["id"], r2["id"], r3["id"], mid, r4["id"], r5["id"]], + [m["id"] for m in chain]) +check("T1.叶子指针不变", db.get_session_leaf(sid) == leaf_before) +check("T1.保留首条改指标记", chain[4]["parent_id"] == mid) +mark_row = next(m for m in chain if m["id"] == mid) +check("T1.标记 role/is_ignored", mark_row["role"] == "compaction" and mark_row["is_ignored"] == 1) + +# ---------- T5:分支一致性(无幻影分支) ---------- +check("T5.旧N唯一子=标记", [m["id"] for m in db.get_branch_info(r3["id"])] == [mid]) +check("T5.标记唯一子=保留首条", [m["id"] for m in db.get_branch_info(mid)] == [r4["id"]]) + +# ---------- T2:build_api_context 截断 ---------- +class _Dummy: + db = db +api = MainWindow.build_api_context(_Dummy(), sid) +api_text = json.dumps([m.get("content") for m in api], ensure_ascii=False) +check("T2.首条=摘要user消息(带_kind)", + api and api[0]["role"] == "user" and api[0]["content"] == MARK_SUMMARY + and api[0].get("_kind") == "compaction_summary", api[:1]) +check("T2.保留行仍在", "保留回答" in api_text and "保留问题" in api_text) +check("T2.切点前行消失", + "旧问题1" not in api_text and "旧回答1" not in api_text and "旧问题2" not in api_text) +check("T2.共3条(摘要+2保留)", len(api) == 3, len(api)) + +# ---------- T3:kind 传播 → 二次压缩走迭代摘要 ---------- +msgs = from_openai_messages(api) +check("T3.摘要 kind 还原", msgs and msgs[0].kind == "compaction_summary", + [m.kind for m in msgs]) +prep = prepare_compaction(msgs, CompactionSettings(reserve_tokens=1000, + keep_recent_tokens=2)) +check("T3.走 previous_summary 迭代路径", + prep is not None and prep.previous_summary == MARK_SUMMARY) + +# ---------- T4:双标记取最后一个 ---------- +mid2 = db.insert_compaction_mark(sid, "摘要2", r4["id"], r5["id"]) +api2 = MainWindow.build_api_context(_Dummy(), sid) +api2_text = json.dumps([m.get("content") for m in api2], ensure_ascii=False) +check("T4.取最后一个标记", api2[0]["content"] == "摘要2" and len(api2) == 2, len(api2)) +check("T4.只留第二个切点之后", "保留问题" in api2_text and "保留回答" not in api2_text) + +# ---------- T6:_cut_ids_of 切点计算(单元) ---------- +A = AgentMessage(role="user", content="x", db_msg_id="a") +B = AgentMessage(role="assistant", content="y", db_msg_id="") +C = AgentMessage(role="toolResult", content="z", db_msg_id="") +S = AgentMessage(role="user", content="SUM", kind="compaction_summary") +check("T6.尾巴首条无DB行→first为空(UI跳过插标记)", + _cut_ids_of([A, B, C], [S, B, C]) == ("a", "")) +B2 = AgentMessage(role="assistant", content="y", db_msg_id="b") +check("T6.正常切点", _cut_ids_of([A, B2, C], [S, B2, C]) == ("a", "b")) +D = AgentMessage(role="assistant", content="d", db_msg_id="d") +check("T6.尾巴不在旧列表→不插", _cut_ids_of([A, B2, C], [S, D]) == ("", "")) +check("T6.无之前消息→无切点", _cut_ids_of([B2, C], [S, B2, C]) == ("", "")) + +# T6c:⚠️ 同 id 回放序列(timeline 多条目共享一行 id)—— 曾导致 DB 自环卡死 +Pm = AgentMessage(role="user", content="p", db_msg_id="p") +A1 = AgentMessage(role="assistant", content="a1", db_msg_id="a") +A2 = AgentMessage(role="assistant", content="a2", db_msg_id="a") +A3 = AgentMessage(role="toolResult", content="a3", db_msg_id="a") +Qm = AgentMessage(role="user", content="q", db_msg_id="q") +cut = _cut_ids_of([Pm, A1, A2, A3, Qm], [S, A2, A3, Qm]) +check("T6c.同id回放切点回退到行头(cut_before≠first_retained)", + cut == ("p", "a"), cut) +cut2 = _cut_ids_of([Pm, A1, A2, A3, Qm], [S, A3, Qm]) +check("T6c.尾巴起点更深也回退到行头", cut2 == ("p", "a"), cut2) + +# ---------- T10:insert_compaction_mark 自环守卫 ---------- +bad = db.insert_compaction_mark(sid, "S", r5["id"], r5["id"]) +check("T10.cut==retained 拒绝插入(防自环)", bad is None) + +# ---------- T11:get_message_chain 环守卫(手工造 2 环) ---------- +sid2 = db.create_session("环守卫")["id"] +x1 = db.add_message(session_id=sid2, role="user", content="x1", parent_id=None) +x2 = db.add_message(session_id=sid2, role="assistant", content="x2", parent_id=x1["id"]) +with db.get_connection() as conn: + conn.execute("UPDATE messages SET parent_id=? WHERE id=?", (x1["id"], x2["id"])) + conn.execute("UPDATE messages SET parent_id=? WHERE id=?", (x2["id"], x1["id"])) + conn.commit() +import time as _t +t0 = _t.time() +chain11 = db.get_message_chain(sid2) +dur = _t.time() - t0 +check("T11.手工2环不死循环(<2s返回)", dur < 2 and 1 <= len(chain11) <= 3, (dur, len(chain11))) + +# ---------- T7:pre_prompt_compaction 事件 payload 端到端 ---------- +model = ModelConfig(provider="t", name="t", context_window=500, + max_tokens=100, api_key="k", base_url="http://x") +cfg = AgentConfig(model=model, system_prompt="s", tools=[], tool_context={}, + compaction_reserve=100, compaction_keep_recent=100) +from core.agent.agent import Agent # noqa: E402 +agent = Agent(cfg) +old_msgs = [ + AgentMessage(role="user", content="旧旧问题", db_msg_id="m1"), + AgentMessage(role="assistant", content="旧旧回答", db_msg_id="m2"), + AgentMessage(role="user", content="旧上下文 " * 200, db_msg_id="m3"), + AgentMessage(role="assistant", content="新回答", db_msg_id="m4"), + AgentMessage(role="user", content="新问题", db_msg_id="m5"), +] +agent.state.messages = old_msgs +runner = AgentRunner(agent, summarize_fn=lambda p, s, mt: "摘要X") +ok = runner.pre_prompt_compaction() +check("T7.压缩被触发", ok is True) +ev = runner.compaction_events[-1] +new_msgs = agent.state.messages +first = new_msgs[1] +idx = next(i for i, m in enumerate(old_msgs) if m is first) +check("T7.payload cut_before_id=切点前DB行", + ev.get("cut_before_id") == old_msgs[idx - 1].db_msg_id, ev) +check("T7.payload first_retained_id=尾巴首条DB行", + ev.get("first_retained_id") == first.db_msg_id, ev) +check("T7.新消息=摘要+尾巴", new_msgs[0].kind == "compaction_summary" + and new_msgs[1:] == old_msgs[idx:]) + +# ---------- T8:_key 不泄漏到 API ---------- +api_out = to_openai_messages(old_msgs) +check("T8.下划线内部字段不进API", + all(not any(k.startswith("_") for k in m.keys()) for m in api_out)) + +# ---------- T9:UI 渲染过滤排除 compaction(静态) ---------- +_mw_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "ui", "views", "main_window.py") +with open(_mw_path, "r", encoding="utf-8") as f: + _src = f.read() +# 注意:探针与循环变量名解耦(源码列表推导式用 `m` 或 `msg` 均可), +# 只锁死“渲染过滤排除 system/compaction”这一语义。 +check("T9.渲染过滤含 compaction", '["role"] not in ("system", "compaction")' in _src) + +# ---------- T12:Fix A — 摘要条目 timestamp → P0 失效过期锚点 ---------- +# 场景(长程会话 92.7k→11k):保留行里 assistant 的入库 usage 是压缩前快照; +# 若摘要条目不带 timestamp,显示/压缩判定会锚到过期值(92.7k),而真实 +# 下一轮输入只有摘要+保留行(~11k)。 +sid3 = db.create_session("过期锚点失效")["id"] +u1 = db.add_message(session_id=sid3, role="user", content="u" * 2000, parent_id=None) +a1 = db.add_message(session_id=sid3, role="assistant", content="A" * 40000, + parent_id=u1["id"], + usage=json.dumps({"input": 92700, "output": 500, + "cacheRead": 0, "cacheWrite": 0})) +u2 = db.add_message(session_id=sid3, role="user", content="v" * 2000, parent_id=a1["id"]) +db.add_message(session_id=sid3, role="assistant", content="B" * 40000, parent_id=u2["id"], + usage=json.dumps({"input": 92700, "output": 500, + "cacheRead": 0, "cacheWrite": 0})) +# 把全部行时间戳拨到 3000s 前(模拟它们早于压缩发生) +with db.get_connection() as conn: + conn.execute("UPDATE messages SET created_at=? WHERE session_id=?", + (int(_t.time()) - 3000, sid3)) + conn.commit() +api_ns = MainWindow.build_api_context(_Dummy(), sid3) +est_ns = estimate_context_tokens(from_openai_messages(api_ns)) +check("T12.对照:无标记→锚定入库usage", est_ns.usage_tokens > 0, est_ns.usage_tokens) +mid3 = db.insert_compaction_mark(sid3, "摘要", a1["id"], u2["id"]) +api3 = MainWindow.build_api_context(_Dummy(), sid3) +check("T12.摘要条目带timestamp且晚于保留行", + api3 and api3[0].get("timestamp", 0) > 0 + and all(api3[0]["timestamp"] > m.get("timestamp", 0) for m in api3[1:])) +est3 = estimate_context_tokens(from_openai_messages(api3)) +check("T12.有标记→过期锚点失效(usage_tokens=0)", est3.usage_tokens == 0, + est3.usage_tokens) +check("T12.估算=全量公式(远离过期93.2k)", est3.tokens < 40000, est3.tokens) + +# ---------- T13:Fix B + G1 — 工具行显示不锚点(G1 行块规则单元化) ---------- +# 场景(任务流会话 40.5k→80k):工具行的入库 usage 是本轮最后一个子请求的 +# 快照(工具输出未截断),下一轮真实输入=4k 截断回放。🆕 G1 后不再需要 +# 手动清锚:锚点选择器自动失效含工具行的 usage → 估算=全量公式。 +sid4 = db.create_session("工具行不锚点")["id"] +p1 = db.add_message(session_id=sid4, role="user", content="prompt", parent_id=None) +tl = json.dumps([ + {"t": "text", "text": "code" * 50000}, + {"t": "tool", "id": "c1", "name": "bash", "args": "{}", "result": "ok"}, + {"t": "text", "text": "done"}, +], ensure_ascii=False) +db.add_message(session_id=sid4, role="assistant", content="x", parent_id=p1["id"], + timeline=tl, + usage=json.dumps({"input": 32517, "output": 7999, + "cacheRead": 0, "cacheWrite": 0})) +api4 = MainWindow.build_api_context(_Dummy(), sid4) +check("T13.回放含tool条目(规则条件成立)", + any(m.get("role") == "tool" for m in api4)) +_est4 = estimate_context_tokens(from_openai_messages(api4)) +check("T13.G1:工具行入库usage自动失效锚点(无需手动清锚)", + _est4.usage_tokens == 0, _est4.usage_tokens) +check("T13.估算=全量公式(>旧锚点40516,度量截断回放)", + _est4.tokens > 40516, _est4.tokens) +# G1 精确性:仅失效「含工具活动的行」的 usage,其后的纯文本行锚点保留 +sid4b = db.create_session("工具行后纯文本行锚点保留")["id"] +p1b = db.add_message(session_id=sid4b, role="user", content="prompt", parent_id=None) +t1b = db.add_message(session_id=sid4b, role="assistant", content="x", parent_id=p1b["id"], + timeline=tl, + usage=json.dumps({"input": 32517, "output": 7999, + "cacheRead": 0, "cacheWrite": 0})) +u1b = db.add_message(session_id=sid4b, role="user", content="q2", parent_id=t1b["id"]) +_a2b = db.add_message(session_id=sid4b, role="assistant", content="y", parent_id=u1b["id"], + usage=json.dumps({"input": 90000, "output": 100, + "cacheRead": 0, "cacheWrite": 0})) +api4b = MainWindow.build_api_context(_Dummy(), sid4b) +_est4b = estimate_context_tokens(from_openai_messages(api4b)) +check("T13.G1:工具行(旧)usage失效、其后纯文本行(新)usage保留锚点", + _est4b.usage_tokens > 0 and _est4b.usage_tokens == 90100, + _est4b.usage_tokens) +# 纯文本会话:规则条件不成立 → 保留精确锚点(provider 实测值更准) +sid5 = db.create_session("文本行锚点")["id"] +p2 = db.add_message(session_id=sid5, role="user", content="hi", parent_id=None) +db.add_message(session_id=sid5, role="assistant", content="hello", parent_id=p2["id"], + usage=json.dumps({"input": 100, "output": 50, + "cacheRead": 0, "cacheWrite": 0})) +api5 = MainWindow.build_api_context(_Dummy(), sid5) +check("T13.纯文本行→无tool条目(保留锚点)", + not any(m.get("role") == "tool" for m in api5) + and estimate_context_tokens(from_openai_messages(api5)).usage_tokens > 0) + +# ---------- T14:G2 — should_compact 透传 system/tools(无锚点分支度量下一请求) ---------- +from core.agent.context import should_compact +_mc14 = ModelConfig(name="t", context_window=100000) +_msgs14 = from_openai_messages(api4) # 工具行 → G1 无锚点 +_sh_no, _tok_no = should_compact(_msgs14, _mc14, 16384) +_sh_yes, _tok_yes = should_compact(_msgs14, _mc14, 16384, + system_prompt="S" * 2000, + tools=[types.SimpleNamespace( + name="bash", description="d" * 1000, + parameters={})]) +check("T14.传入system/tools后估算增大(无锚点分支)", + _tok_yes > _tok_no, (_tok_no, _tok_yes)) +# 阈值边缘:同样内容,带上 system/tools 后跨过阈值 → 触发判定变化 +_mc_edge = ModelConfig(name="t2", context_window=_tok_no + 16384 + 5) +_sh_edge_no, _ = should_compact(_msgs14, _mc_edge, 16384) +_sh_edge_yes, _ = should_compact(_msgs14, _mc_edge, 16384, + system_prompt="S" * 2000, + tools=[types.SimpleNamespace( + name="bash", description="d" * 1000, + parameters={})]) +check("T14.阈值边缘:计入system/tools才触发(防低估漏压缩)", + _sh_edge_no is False and _sh_edge_yes is True, + (_sh_edge_no, _sh_edge_yes)) + +failed = [n for n, okk in RESULTS if not okk] +print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True) +print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True) +sys.exit(0 if not failed else 1) diff --git a/tests/test_config_isolation.py b/tests/test_config_isolation.py new file mode 100644 index 0000000..5d2b62a --- /dev/null +++ b/tests/test_config_isolation.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- +"""tests/test_config_isolation.py —— P0-01 配置路径与测试隔离回归 + +运行: python tests/test_config_isolation.py +(仓库惯例:无 pytest 依赖,独立可跑;GUI 部分自动走 offscreen) + +完成证据(REPAIR_BACKLOG.md P0-01): + 1. 打开路径拦截器记录到的配置打开路径全部位于临时目录,真实配置路径从未被打开; + 该断言只用路径字符串比较,不读取、不散列真实配置文件; + 2. 临时配置读写用例通过,临时数据库之外没有数据库写入; + 3. 缺失配置与损坏配置各有一个回归用例:日志含明确警告、返回安全默认值、无异常; + 4. 静态扫描:源码中不存在绕过统一路径解析(core/config_paths)的运行时配置读取。 +""" +import ast +import contextlib +import io +import json +import os +import sqlite3 +import sys + +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_TESTS_DIR) +sys.path.insert(0, _ROOT) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu") +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +from tests._test_env import isolate, default_config # noqa: E402 + +_TMP = isolate("cfgiso") # 必须在 import MainWindow 之前 +_REAL_CONFIG = os.path.join(_ROOT, "data", "config.json") # 仅作路径字符串,永不打开 + +ok = True + + +def check(name, cond, extra=""): + global ok + print((" PASS " if cond else " FAIL ") + name + + ("" if cond else f" {extra}"), flush=True) + if not cond: + ok = False + + +# ====================================================================== +# 拦截器:记录 builtins.open 与 sqlite3.connect 触碰的路径(不读取真实配置内容) +# ====================================================================== +class OpenInterceptor: + def __init__(self): + self.ops = [] # (path, mode) + self._real_open = None + + def __enter__(self): + self._real_open = __builtins__["open"] if isinstance( + __builtins__, dict) else __builtins__.open + import builtins + builtins.open = self._open + return self + + def _open(self, file, mode="r", *args, **kwargs): + try: + p = os.fspath(file) + except TypeError: + p = None + if p is not None: + self.ops.append((os.path.abspath(str(p)), str(mode))) + return self._real_open(file, mode, *args, **kwargs) + + def __exit__(self, *exc): + import builtins + builtins.open = self._real_open + return False + + # ---- 断言辅助 ---- + def config_ops(self): + # 含原子写的临时文件 config.json.tmp + return [(p, m) for p, m in self.ops + if os.path.basename(p).startswith("config.json")] + + def real_config_opened(self): + return [op for op in self.ops + if os.path.normcase(op[0]) == os.path.normcase(_REAL_CONFIG)] + + +class SqliteInterceptor: + def __init__(self): + self.paths = [] + self._real_connect = None + + def __enter__(self): + self._real_connect = sqlite3.connect + sqlite3.connect = self._connect + return self + + def _connect(self, database, *args, **kwargs): + self.paths.append(str(database)) + return self._real_connect(database, *args, **kwargs) + + def __exit__(self, *exc): + sqlite3.connect = self._real_connect + return False + + +# ====================================================================== +# 1) 静态扫描:不得存在绕过统一路径解析的配置读取 +# (源码中不允许出现 "config.json" 字符串字面量,core/config_paths.py 除外; +# 文档字符串与注释不计) +# ====================================================================== +def _docstring_values(tree) -> set: + vals = set() + + def _first_expr_body(body): + if body and isinstance(body[0], ast.Expr) and \ + isinstance(body[0].value, ast.Constant) and \ + isinstance(body[0].value.value, str): + vals.add(body[0].value.value) + + _first_expr_body(tree.body) + for node in ast.walk(tree): + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + _first_expr_body(node.body) + return vals + + +def static_scan(): + allowed = os.path.normcase(os.path.join(_ROOT, "core", "config_paths.py")) + bad = [] + scan_dirs = [os.path.join(_ROOT, d) for d in ("core", "ui", "tools")] + scan_files = [os.path.join(_ROOT, "main.py")] + targets = list(scan_files) + for d in scan_dirs: + for dirpath, _dirnames, filenames in os.walk(d): + if "__pycache__" in dirpath: + continue + for fn in filenames: + if fn.endswith(".py"): + targets.append(os.path.join(dirpath, fn)) + for path in targets: + with open(path, "r", encoding="utf-8") as f: + src = f.read() + tree = ast.parse(src, filename=path) + docstrings = _docstring_values(tree) + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str) \ + and "config.json" in node.value \ + and node.value not in docstrings: + if os.path.normcase(path) != allowed: + bad.append(f"{os.path.relpath(path, _ROOT)}:{node.lineno}") + return bad + + +bad_literal = static_scan() +check("静态:源码无绕过统一解析的 config.json 字面量(仅 core/config_paths.py 允许)", + not bad_literal, f"发现: {bad_literal}") + + +# ====================================================================== +# 2) 缺失配置 / 损坏配置:可见警告 + 安全默认值 + 无异常 +# ====================================================================== +from core.config_paths import config_path, load_config # noqa: E402 +import core.llm_engine as le # noqa: E402 + +saved_env = os.environ.get("HAOCODE_CONFIG_FILE") + +_missing = os.path.join(_TMP["base"], "no_such_config.json") +os.environ["HAOCODE_CONFIG_FILE"] = _missing +_buf = io.StringIO() +with contextlib.redirect_stdout(_buf): + _res = load_config() +_log = _buf.getvalue() +check("缺失配置:返回安全空 dict", _res == {}) +check("缺失配置:日志含明确警告", "缺失" in _log and "config" in _log, _log) + +_corrupt = os.path.join(_TMP["base"], "corrupt.json") +with open(_corrupt, "w", encoding="utf-8") as f: + f.write("{ this is not valid json") +os.environ["HAOCODE_CONFIG_FILE"] = _corrupt +_buf = io.StringIO() +with contextlib.redirect_stdout(_buf): + _res = load_config() +_log = _buf.getvalue() +check("损坏配置:返回安全空 dict", _res == {}) +check("损坏配置:日志含明确警告", "解析失败" in _log or "读取/解析失败" in _log, _log) + +_notdict = os.path.join(_TMP["base"], "notdict.json") +with open(_notdict, "w", encoding="utf-8") as f: + f.write("[1, 2, 3]") +os.environ["HAOCODE_CONFIG_FILE"] = _notdict +_buf = io.StringIO() +with contextlib.redirect_stdout(_buf): + _res = load_config() +_log = _buf.getvalue() +check("非对象配置:返回安全空 dict 且有警告", _res == {} and "不是 JSON 对象" in _log, _log) + +os.environ["HAOCODE_CONFIG_FILE"] = saved_env # 恢复临时配置 +check("环境变量恢复:config_path 回到临时目录", + os.path.normcase(config_path()) == os.path.normcase(_TMP["config"])) + + +# ====================================================================== +# 3) 临时配置读取(llm_engine 统一入口) +# ====================================================================== +_cfg_now = le._load_config() +check("llm_engine._load_config 读取临时配置(含测试 provider)", + "testprov" in _cfg_now.get("providers", {})) + + +# ====================================================================== +# 4) MainWindow 全链路:构造主窗口期间,配置打开/数据库连接全部落在临时目录 +# ====================================================================== +from PyQt6.QtWidgets import QApplication # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 +import ui.views.bash_panel as bp # noqa: E402 + +app = QApplication.instance() or QApplication(sys.argv) + +oi = OpenInterceptor() +si = SqliteInterceptor() +with oi, si: + win = MainWindow() + # 宽度记录写回(config.json 写路径) + bp.save_panel_width(340) + win.close() + win.deleteLater() + app.processEvents() + +check("MainWindow 构造期间真实配置从未被打开", + not oi.real_config_opened(), f"打开记录: {oi.real_config_opened()}") +_cfg_ops = oi.config_ops() +check("MainWindow 链路至少发生一次配置读取(路径被验证而非空转)", + len(_cfg_ops) >= 1, f"记录: {_cfg_ops}") +_check_base = os.path.normcase(os.path.abspath(_TMP["base"])) +_outside = [op for op in _cfg_ops + if not os.path.normcase(op[0]).startswith(_check_base)] +check("全部 config.json 打开路径均位于临时目录", not _outside, f"越界: {_outside}") +_write_ops = [op for op in _cfg_ops if "w" in op[1]] +check("配置写路径(bash_panel 宽度)落在临时目录", + bool(_write_ops) and not [op for op in _write_ops + if not os.path.normcase(op[0]).startswith(_check_base)], + f"写记录: {_write_ops}") +check("save_panel_width 写回可被读回", bp.load_panel_width() == 340) + +_db_outside = [p for p in si.paths + if not (os.path.normcase(os.path.abspath(p)).startswith(_check_base) + or p in (":memory:", ""))] +check("临时数据库之外没有数据库连接/写入", not _db_outside, f"越界: {_db_outside}") +check("MainWindow.config_data 来自临时配置(providers 无真实凭据)", + set(win.config_data.get("providers", {}).keys()) == {"testprov"}) + + +# ====================================================================== +# 汇总 +# 注:QtWebEngine 在 offscreen 下的 C++ 静态析构可能在正常 sys.exit 后段错误, +# 测试进程用 os._exit 直接退出(退出码已在上方断言中确定),不改变测试结果。 +# ====================================================================== +print(f"\n===== test_config_isolation: {'ALL PASS' if ok else 'HAS FAILURES'} =====", flush=True) +os._exit(0 if ok else 1) diff --git a/tests/test_copy_session.py b/tests/test_copy_session.py new file mode 100644 index 0000000..c7eb720 --- /dev/null +++ b/tests/test_copy_session.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +""" +会话复制(DBManager.copy_session)单测 +—— 全程临时 DB + 临时附件目录,绝不触碰真实 data/chat_history.db + +覆盖: + T1 线性会话复制(消息数/顺序/内容一致,ID 全新) + T2 parent_id 链重建正确(首条 parent 为 None,链走通) + T3 分叉会话整树复制(分支数一致) + T4 压缩标记原样复制(数量/内容/切点关系/时间戳晚于全部保留行) + T5 current_leaf_msg_id 重映射且真实存在 + T6 源会话零改动(全列快照比对) + T7 删副本 → 源完好;删源 → 副本完好 + T8 图片附件物理复制到新路径(新旧文件同时存在、内容相同) + T9 文本附件原样保留(不产生新文件、metadata 逐字节相同) + T10 title / is_starred / mode / has_messages / sort_order 语义 + T11 不存在的 session_id → None,且无残留 + T12 副本再复制 → 标题 (副本 2) + T13 外键无违规 + 无孤儿 parent_id + T14 get_message_chain(源) 与 副本 的 (role, content, is_ignored) 序列完全相同 + +运行: PYTHONIOENCODING=utf-8 python tests/test_copy_session.py +""" +import os +import sys +import json +import uuid +import time +import tempfile +import shutil + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.db_manager import DBManager # noqa: E402 + +RESULTS = [] + + +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + + +_TMP = tempfile.mkdtemp(prefix="hocode_copysess_") +_ATT = os.path.join(_TMP, "data", "attachments") +os.makedirs(_ATT, exist_ok=True) + +db = DBManager(os.path.join(_TMP, "data", "chat_history.db")) +db.files_root = _TMP # 附件根指向临时目录 + + +# ====================================================================== +# 工具函数 +# ====================================================================== +def mk_session(title, msgs, mode=None, starred=0, has_messages=1, att_meta=None): + """建一个线性会话。msgs=[(role, content)];att_meta={index: json_string} + 返回 (session_id, [msg_id])""" + sid = "sess_" + uuid.uuid4().hex[:12] + now = int(time.time()) + with db.get_connection() as conn: + cur = conn.cursor() + cur.execute( + "INSERT INTO sessions (id,title,created_at,updated_at,has_messages,sort_order," + "is_starred,current_leaf_msg_id,mode) VALUES (?,?,?,?,?,?,?,?,?)", + (sid, title, now, now, has_messages, db.get_min_sort_order(), starred, None, mode)) + prev, ids = None, [] + for i, (role, content) in enumerate(msgs): + mid = ("comp_" if role == "compaction" else "msg_") + uuid.uuid4().hex[:16] + meta = (att_meta or {}).get(i) + cur.execute( + "INSERT INTO messages (id,session_id,role,content,reasoning,is_ignored," + "created_at,attachment_metadata,parent_id,timeline,usage) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?)", + (mid, sid, role, content, "思考内容", 0, now - 1000 + i, meta, prev, + '{"t":1}', '{"input":10,"output":5}')) + ids.append(mid) + prev = mid + cur.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev, sid)) + conn.commit() + return sid, ids + + +def add_child(session_id, parent_id, role, content, ts): + """在指定 parent 下挂一个子消息(构造分叉用)""" + mid = "msg_" + uuid.uuid4().hex[:16] + with db.get_connection() as conn: + conn.execute( + "INSERT INTO messages (id,session_id,role,content,reasoning,is_ignored," + "created_at,attachment_metadata,parent_id,timeline,usage) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?)", + (mid, session_id, role, content, "", 0, ts, None, parent_id, None, None)) + conn.commit() + return mid + + +def rows_of(sid): + with db.get_connection() as conn: + return [dict(r) for r in conn.execute( + "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, rowid ASC", + (sid,)).fetchall()] + + +def sess_of(sid): + with db.get_connection() as conn: + r = conn.execute("SELECT * FROM sessions WHERE id = ?", (sid,)).fetchone() + return dict(r) if r else None + + +def sess_count(): + with db.get_connection() as conn: + return conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] + + +def chain_sig(sid): + """(role, content, is_ignored) 序列""" + return [(m["role"], m["content"], m["is_ignored"]) + for m in db.get_message_chain(sid)] + + +# ====================================================================== +try: + # ---------------- T1/T2: 线性会话 ---------------- + src, src_ids = mk_session("线性会话", [ + ("system", "sys"), ("user", "u1"), ("assistant", "a1"), + ("user", "u2"), ("assistant", "a2")]) + n_before = sess_count() + cpy = db.copy_session(src) + check("T1.1 返回新会话 dict 且 id 不同", cpy and cpy["id"] != src) + check("T1.2 会话数 +1", sess_count() == n_before + 1, f"{n_before}->{sess_count()}") + + s_rows, c_rows = rows_of(src), rows_of(cpy["id"]) + check("T1.3 消息数一致", len(s_rows) == len(c_rows) == 5, f"{len(s_rows)}/{len(c_rows)}") + check("T1.4 ID 全新无交集", + not (set(r["id"] for r in s_rows) & set(r["id"] for r in c_rows))) + check("T1.5 role+content 序列一致", + [(r["role"], r["content"]) for r in s_rows] == + [(r["role"], r["content"]) for r in c_rows]) + check("T1.6 reasoning/timeline/usage 原样复制", + all(s["reasoning"] == c["reasoning"] and s["timeline"] == c["timeline"] + and s["usage"] == c["usage"] for s, c in zip(s_rows, c_rows))) + + # T2: parent 链重建 + idmap = {s["id"]: c["id"] for s, c in zip(s_rows, c_rows)} + ok_chain = c_rows[0]["parent_id"] is None + for s, c in list(zip(s_rows, c_rows))[1:]: + ok_chain = ok_chain and c["parent_id"] == idmap.get(s["parent_id"]) + check("T2.1 parent_id 全量重映射", ok_chain) + check("T2.2 副本链首 parent 为 None(未指回源 ID)", c_rows[0]["parent_id"] is None) + check("T2.3 副本链里不含任何源 ID", + not (set(idmap.keys()) & set(r["parent_id"] for r in c_rows if r["parent_id"]))) + + # ---------------- T3: 分叉会话整树复制 ---------------- + sid3, ids3 = mk_session("分叉会话", [("system", "s"), ("user", "q")]) + base = ids3[-1] + add_child(sid3, base, "assistant", "br1", int(time.time()) + 10) + add_child(sid3, base, "assistant", "br2", int(time.time()) + 11) + copy3 = db.copy_session(sid3) + c3 = rows_of(copy3["id"]) + src_branches = len(db.get_branch_info(base)) + c_base = [r for r in c3 if r["role"] == "user"][0]["id"] + check("T3.1 源有 2 个分支", src_branches == 2, str(src_branches)) + check("T3.2 副本分支数一致", len(db.get_branch_info(c_base)) == 2, + str(len(db.get_branch_info(c_base)))) + check("T3.3 副本消息总数一致", len(c3) == len(rows_of(sid3)) == 4) + check("T3.4 分支内容一致", + sorted(b["content"] for b in db.get_branch_info(c_base)) == ["br1", "br2"]) + + # ---------------- T4: 压缩标记 ---------------- + sid4, ids4 = mk_session("压缩会话", [ + ("system", "s"), ("user", "u1"), ("assistant", "a1"), + ("user", "u2"), ("assistant", "a2")]) + mark = db.insert_compaction_mark(sid4, "【摘要】前面聊了 u1/a1", + cut_before_id=ids4[2], first_retained_id=ids4[3]) + copy4 = db.copy_session(sid4) + c4 = rows_of(copy4["id"]) + s4 = rows_of(sid4) + c_marks = [r for r in c4 if r["role"] == "compaction"] + s_marks = [r for r in s4 if r["role"] == "compaction"] + check("T4.1 标记存在且已复制", len(s_marks) == 1 and len(c_marks) == 1) + check("T4.2 摘要内容一致", c_marks and c_marks[0]["content"] == s_marks[0]["content"]) + m4 = {s["id"]: c["id"] for s, c in zip(s4, c4)} + check("T4.3 标记 parent 指向副本的 cut_before", + c_marks[0]["parent_id"] == m4.get(ids4[2])) + check("T4.4 first_retained 的 parent 指向副本标记", + [r for r in c4 if r["id"] == m4.get(ids4[3])][0]["parent_id"] == c_marks[0]["id"]) + check("T4.5 标记 id 带 comp_ 前缀", c_marks[0]["id"].startswith("comp_")) + retained = [r for r in c4 if r["created_at"] is not None + and r["id"] != c_marks[0]["id"]] + check("T4.6 标记时间戳晚于全部其它行", + all(c_marks[0]["created_at"] >= r["created_at"] for r in retained)) + check("T4.7 标记 is_ignored 保持 1", c_marks[0]["is_ignored"] == 1) + # created_at 相对次序必须与源完全一致(兄弟排序依赖它) + s_order = [i for i, _ in sorted(enumerate([r["created_at"] for r in s4]), + key=lambda x: x[1])] + c_order = [i for i, _ in sorted(enumerate([r["created_at"] for r in c4]), + key=lambda x: x[1])] + check("T4.8 created_at 相对次序与源一致", s_order == c_order, f"{s_order} vs {c_order}") + + # ---------------- T5: 叶子重映射 ---------------- + check("T5.1 副本叶子非空且不是源叶子", + cpy["current_leaf_msg_id"] and cpy["current_leaf_msg_id"] != sess_of(src)["current_leaf_msg_id"]) + check("T5.2 副本叶子真实存在于副本", + cpy["current_leaf_msg_id"] in [r["id"] for r in c_rows]) + check("T5.3 副本叶子对应源叶子的内容", + [r for r in c_rows if r["id"] == cpy["current_leaf_msg_id"]][0]["content"] + == s_rows[-1]["content"]) + + # ---------------- T6: 源会话零改动 ---------------- + src_snapshot = (sess_of(src), [tuple(sorted(r.items())) for r in rows_of(src)]) + _ = db.copy_session(src) + src_after = (sess_of(src), [tuple(sorted(r.items())) for r in rows_of(src)]) + check("T6.1 复制两次后源 sessions 行不变", src_snapshot[0] == src_after[0]) + check("T6.2 复制两次后源 messages 全列不变", src_snapshot[1] == src_after[1]) + + # ---------------- T7: 双向删除隔离 ---------------- + sid7, _ = mk_session("隔离A", [("system", "s"), ("user", "x")]) + c7 = db.copy_session(sid7) + db.delete_session(c7["id"]) + check("T7.1 删副本后源仍在且消息完整", + sess_of(sid7) is not None and len(rows_of(sid7)) == 2) + # 方向二另起一对,避免与方向一互相干扰 + sid7b, _ = mk_session("隔离B", [("system", "s"), ("user", "y")]) + c7b = db.copy_session(sid7b) + db.delete_session(sid7b) + check("T7.2 删源后副本仍在且消息完整", + sess_of(c7b["id"]) is not None and len(rows_of(c7b["id"])) == 2, + f'cpy sess={sess_of(c7b["id"]) is not None} rows={len(rows_of(c7b["id"]))}') + + # ---------------- T8: 图片附件物理复制 ---------------- + png_src = os.path.join(_ATT, "img_src_test.png") + with open(png_src, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n" + b"FAKEIMAGEDATA" * 8) + img_meta = json.dumps({"user_text": "看看这张图", + "attachments": [{"type": "image", "size_kb": 0.2, + "local_path": "data/attachments/img_src_test.png"}]}, + ensure_ascii=False) + sid8, _ = mk_session("图片会话", [("system", "s"), ("user", "带图")], + att_meta={1: img_meta}) + c8 = db.copy_session(sid8) + m8 = [r for r in rows_of(c8["id"]) if r["attachment_metadata"]][0] + meta8 = json.loads(m8["attachment_metadata"]) + new_rel = meta8["attachments"][0]["local_path"] + new_abs = os.path.join(_TMP, new_rel) + check("T8.1 local_path 已改写为新文件", + new_rel != "data/attachments/img_src_test.png", new_rel) + check("T8.2 新旧文件同时存在", + os.path.isfile(png_src) and os.path.isfile(new_abs)) + check("T8.3 新文件内容与源一致", + open(new_abs, "rb").read() == open(png_src, "rb").read()) + check("T8.4 user_text 等其它字段保留", meta8["user_text"] == "看看这张图") + check("T8.5 源 metadata 未被改动", + json.loads([r for r in rows_of(sid8) if r["attachment_metadata"]][0] + ["attachment_metadata"])["attachments"][0]["local_path"] + == "data/attachments/img_src_test.png") + + # 缺文件容错:metadata 保留原路径,不抛异常 + bad_meta = json.dumps({"user_text": "x", "attachments": [ + {"type": "image", "local_path": "data/attachments/does_not_exist.png"}]}, + ensure_ascii=False) + sid8b, _ = mk_session("缺文件", [("system", "s"), ("user", "y")], att_meta={1: bad_meta}) + c8b = db.copy_session(sid8b) + bad_after = [r for r in rows_of(c8b["id"]) if r["attachment_metadata"]][0] + check("T8.6 源文件缺失时保留原路径且不失败", + json.loads(bad_after["attachment_metadata"])["attachments"][0]["local_path"] + == "data/attachments/does_not_exist.png") + + # ---------------- T9: 文本附件原样 ---------------- + txt_meta = json.dumps({"user_text": "", "attachments": [ + {"type": "text", "size_kb": 1.0, "lines": 3, "content": "aaa\nbbb\nccc"}]}, + ensure_ascii=False) + sid9, _ = mk_session("文本附件", [("system", "s"), ("user", "t")], att_meta={1: txt_meta}) + n_files_before = len(os.listdir(_ATT)) + c9 = db.copy_session(sid9) + t9 = [r for r in rows_of(c9["id"]) if r["attachment_metadata"]][0] + check("T9.1 文本附件 metadata 逐字节相同", + t9["attachment_metadata"] == txt_meta, t9["attachment_metadata"][:60]) + check("T9.2 文本附件不产生新文件", len(os.listdir(_ATT)) == n_files_before) + + # ---------------- T10: 字段语义 ---------------- + sid10, _ = mk_session("语义检查", [("system", "s"), ("user", "m")], + mode="worker", starred=1) + min_order_before = db.get_min_sort_order() + c10 = db.copy_session(sid10) + check("T10.1 标题 = 原名 + ' (副本)'", c10["title"] == "语义检查 (副本)", c10["title"]) + check("T10.2 副本不带星标", c10["is_starred"] == 0, str(c10["is_starred"])) + check("T10.3 mode 跟随源", c10["mode"] == "worker", str(c10["mode"])) + check("T10.4 has_messages 跟随源", c10["has_messages"] == sess_of(sid10)["has_messages"]) + check("T10.5 sort_order 置顶", c10["sort_order"] == min_order_before, + f'{c10["sort_order"]} vs {min_order_before}') + check("T10.6 源仍是星标且未被改标题", + sess_of(sid10)["is_starred"] == 1 and sess_of(sid10)["title"] == "语义检查") + check("T10.7 created_at/updated_at = 当前时间", + abs(c10["updated_at"] - int(time.time())) <= 5) + + # 无消息的会话(只有 system 行)也能复制 + sid10b, ids10b = mk_session("孤儿", [("system", "only")], has_messages=0) + c10b = db.copy_session(sid10b) + check("T10.8 仅 system 行的会话可复制", + c10b and len(rows_of(c10b["id"])) == 1 and c10b["current_leaf_msg_id"] is not None) + + # ---------------- T12: 副本再复制 → (副本 2) ---------------- + c12 = db.copy_session(c10["id"]) + check("T12.1 再复制标题递增为 (副本 2)", c12["title"] == "语义检查 (副本 2)", c12["title"]) + c12b = db.copy_session(c12["id"]) + check("T12.2 第三次复制为 (副本 3)", c12b["title"] == "语义检查 (副本 3)", c12b["title"]) + c12c = db.copy_session(sid10, new_title="自定义标题") + check("T12.3 可显式指定标题", c12c["title"] == "自定义标题", c12c["title"]) + + # ---------------- T11: 不存在的会话 ---------------- + n11 = sess_count() + check("T11.1 不存在 → None", db.copy_session("sess_not_exist_xxx") is None) + check("T11.2 无残留(会话数不变)", sess_count() == n11) + + # ---------------- T13: 外键 / 孤儿 ---------------- + bad_fk = 0 + orphans = 0 + with db.get_connection() as conn: + bad_fk = len(conn.execute("PRAGMA foreign_key_check").fetchall()) + for r in rows_of(cpy["id"]): + if r["parent_id"] and r["parent_id"] not in [x["id"] for x in c_rows]: + orphans += 1 + check("T13.1 外键无违规", bad_fk == 0, str(bad_fk)) + check("T13.2 无孤儿 parent_id", orphans == 0, str(orphans)) + + # ---------------- T14: 链签名一致(端到端) ---------------- + check("T14.1 线性会话链签名一致", chain_sig(src) == chain_sig(cpy["id"])) + check("T14.2 分叉会话链签名一致", chain_sig(sid3) == chain_sig(copy3["id"])) + check("T14.3 压缩会话链签名一致(含标记行)", chain_sig(sid4) == chain_sig(copy4["id"])) + +finally: + shutil.rmtree(_TMP, ignore_errors=True) + +failed = [n for n, ok in RESULTS if not ok] +print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True) +print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True) +sys.exit(0 if not failed else 1) diff --git a/tests/test_cross_platform_shell.py b/tests/test_cross_platform_shell.py new file mode 100644 index 0000000..57171a0 --- /dev/null +++ b/tests/test_cross_platform_shell.py @@ -0,0 +1,253 @@ +# -*- coding: utf-8 -*- +""" +P1-02 跨平台 shell 契约单测(纯函数级 + 真实进程级,无 UI 依赖) + +覆盖: + A. 平台参数:Windows `cmd.exe /d /c` argv / Linux `/bin/bash -lc` argv; + Linux Popen 独立进程组(start_new_session),Windows 无该参数 + B. 提示词平台段:通用正文完全相同、只插入对应平台段、互不串段; + load_system_prompt() 与文件内容一致(当前平台) + C. 进程树(真实进程,父+孙): + - 当前平台:超时 → 父与孙都不存在 + - 当前平台:主动中止 → 父与孙都不存在 + - Linux 专属:独立进程组整组终止(Windows 上跳过) + D. kill_process_tree 安全边界:已退出的进程 / 无独立组 → 不抛异常、不误杀 + +运行: PYTHONIOENCODING=utf-8 python tests/test_cross_platform_shell.py +""" +import os +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core import platform_shell # noqa: E402 +from core.agent.tools import tool_bash # noqa: E402 +from core.agent.types import AbortSignal # noqa: E402 + +RESULTS = [] + + +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + + +def text_of(res): + c = res.content + if isinstance(c, str): + return c + return "".join(x.get("text", "") for x in c if isinstance(x, dict)) + + +_TMP = tempfile.mkdtemp(prefix="haocode_crossshell_") +PY = sys.executable +IS_WIN = os.name == "nt" + +# ====================================================================== +# A. 平台参数(mock 平台,纯逻辑) +# ====================================================================== +_orig_is_win = platform_shell.is_windows + + +def _mock_win(v): + platform_shell.is_windows = lambda: v + + +try: + _mock_win(True) + check("A1 Windows 命令 = cmd.exe /d /s /c \"\"", + platform_shell.shell_command("echo hi") + == 'cmd.exe /d /s /c "echo hi"', + repr(platform_shell.shell_command("echo hi"))) + check("A2 Windows popen_flags 无进程组参数", + platform_shell.popen_flags() == {}, repr(platform_shell.popen_flags())) + + _mock_win(False) + check("A3 Linux argv = /bin/bash -lc ", + platform_shell.shell_command("echo hi") == ["/bin/bash", "-lc", "echo hi"], + repr(platform_shell.shell_command("echo hi"))) + check("A4 Linux popen_flags 独立进程组", + platform_shell.popen_flags() == {"start_new_session": True}, + repr(platform_shell.popen_flags())) +finally: + platform_shell.is_windows = _orig_is_win + +# ====================================================================== +# B. 提示词平台段 +# ====================================================================== +_PROMPT_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "SYSTEM_PROMPT.md") +with open(_PROMPT_FILE, "r", encoding="utf-8") as f: + _PROMPT_TEXT = f.read().strip() + +check("B1 通用正文含占位符", + platform_shell.SHELL_SECTION_PLACEHOLDER in _PROMPT_TEXT) + +_GENERIC_NO_LEAK = ("cmd.exe" not in _PROMPT_TEXT + and "/bin/bash" not in _PROMPT_TEXT) +check("B2 通用正文无平台泄漏(无 cmd.exe / 无 /bin/bash)", _GENERIC_NO_LEAK) + +try: + _mock_win(True) + full_win = platform_shell.apply_platform_section(_PROMPT_TEXT) + sec_win = platform_shell.shell_prompt_section() + _mock_win(False) + full_lin = platform_shell.apply_platform_section(_PROMPT_TEXT) + sec_lin = platform_shell.shell_prompt_section() +finally: + platform_shell.is_windows = _orig_is_win + +check("B3 Windows 段含 cmd.exe、无 /bin/bash", + "cmd.exe" in sec_win and "/bin/bash" not in sec_win) +check("B4 Linux 段含 /bin/bash -lc、无 cmd.exe", + "/bin/bash -lc" in sec_lin and "cmd.exe" not in sec_lin) +check("B5 只有对应平台段被插入", + full_win == _PROMPT_TEXT.replace(platform_shell.SHELL_SECTION_PLACEHOLDER, sec_win) + and full_lin == _PROMPT_TEXT.replace(platform_shell.SHELL_SECTION_PLACEHOLDER, sec_lin)) +check("B6 通用正文两平台完全相同", + full_win.replace(sec_win, "§SEC§") == full_lin.replace(sec_lin, "§SEC§")) +check("B7 无占位符文本原样返回", + platform_shell.apply_platform_section("无占位符的兜底文本") == "无占位符的兜底文本") + +# load_system_prompt()(当前平台,真实文件) +from core import llm_engine # noqa: E402 (PyQt6/openai 已装,无需 QApplication) +check("B8 load_system_prompt = 文件内容 + 当前平台段", + llm_engine.load_system_prompt() + == platform_shell.apply_platform_section(_PROMPT_TEXT)) + +# ====================================================================== +# C. 进程树(真实进程:父 + 孙,心跳文件证明生死) +# ====================================================================== +_CHILD = os.path.join(_TMP, "child.py") +with open(_CHILD, "w", encoding="utf-8") as f: + f.write( + "import sys, time\n" + "hb = open(sys.argv[1], 'a', encoding='utf-8')\n" + "t0 = time.time()\n" + "while time.time() - t0 < 30:\n" + " hb.write(f'{time.time():.3f}\\n')\n" + " hb.flush()\n" + " time.sleep(0.2)\n" + "open(sys.argv[2], 'w').write('done')\n" + ) + + +def _spawn_parent(hb_path, done_path, out_path): + """写一个父进程脚本(启动孙进程后挂 30s),返回 bash 命令字符串。""" + parent = os.path.join(_TMP, f"parent_{os.path.basename(hb_path)}.py") + with open(parent, "w", encoding="utf-8") as f: + f.write( + "import subprocess, sys, time\n" + "p = subprocess.Popen([sys.executable, " + + repr(_CHILD) + ", sys.argv[1], sys.argv[2]], " + "stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n" + "time.sleep(30)\n" + ) + cmd = f'"{PY}" -u "{parent}" "{hb_path}" "{done_path}"' + return cmd + + +def _hb_last(hb_path): + try: + with open(hb_path, "r", encoding="utf-8") as f: + lines = [l.strip() for l in f if l.strip()] + return float(lines[-1]) if lines else None + except FileNotFoundError: + return None + + +def _tree_dead(hb_path, done_path, quiesce=1.2): + """等待静默后判断:孙进程不再有心跳、且未跑完 → 进程树已死。""" + time.sleep(quiesce) + last = _hb_last(hb_path) + return last is not None and (time.time() - last) > 0.6, os.path.exists(done_path) + + +def run_tree_case(tag, timeout=None, abort_after=None): + hb = os.path.join(_TMP, f"hb_{tag}.txt") + done = os.path.join(_TMP, f"done_{tag}.txt") + out = os.path.join(_TMP, f"out_{tag}.log") + cmd = _spawn_parent(hb, done, out) + + updates, timers = [], [] + sig = AbortSignal() + if abort_after is not None: + import threading + def _aborter(): + time.sleep(abort_after) + sig.abort("test") + threading.Thread(target=_aborter, daemon=True).start() + + t0 = time.time() + res = tool_bash(f"call_{tag}", {"command": cmd, "timeout": timeout or 60}, sig, + lambda t: updates.append(t), + {"cwd": _TMP, "on_timer": lambda e, tt: timers.append((e, tt))}) + dur = time.time() - t0 + return res, dur, hb, done, timers + + +# ---- C1: 超时 → 父与孙都不存在 ---- +res, dur, hb, done, timers = run_tree_case("timeout", timeout=3) +txt = text_of(res) +dead, finished = _tree_dead(hb, done) +check("C1.1 超时返回错误结果", res.is_error and "超时" in txt, f"{txt!r}") +check("C1.2 超时快速返回(<15s,含 taskkill 缓冲)", dur < 15, f"dur={dur:.1f}s") +check("C1.3 超时后父+孙都不存在(心跳停止且未跑完)", dead and not finished, + f"hb_last={_hb_last(hb)} finished={finished}") +check("C1.4 读秒滴答仍工作", len(timers) >= 2, f"timers={timers}") + +# ---- C2: 主动中止 → 父与孙都不存在 ---- +res, dur, hb, done, _timers2 = run_tree_case("abort", timeout=60, abort_after=1.5) +txt = text_of(res) +dead, finished = _tree_dead(hb, done) +check("C2.1 中止返回错误结果", res.is_error and "中止" in txt, f"{txt!r}") +check("C2.2 中止后父+孙都不存在(心跳停止且未跑完)", dead and not finished, + f"hb_last={_hb_last(hb)} finished={finished}") + +# ---- C3: Linux 独立进程组整组终止(Windows 跳过) ---- +if not IS_WIN: + proc = subprocess.Popen( + platform_shell.shell_command("sleep 30"), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + **platform_shell.popen_flags()) + time.sleep(0.3) + try: + pgid = os.getpgid(proc.pid) + check("C3.1 start_new_session 生效(pgid == 子 pid)", pgid == proc.pid, + f"pgid={pgid} pid={proc.pid}") + platform_shell.kill_process_tree(proc, grace_s=1.0) + time.sleep(0.5) + check("C3.2 整组终止后子进程不存在", + proc.poll() is not None, f"poll={proc.poll()}") + except Exception as e: + try: + platform_shell.kill_process_tree(proc, grace_s=1.0) + except Exception: + pass + check("C3 Linux 进程组用例", False, repr(e)) +else: + print("SKIP C3 Linux 进程组用例(当前为 Windows,Windows 用例见 C1/C2)", flush=True) + +# ====================================================================== +# D. kill_process_tree 安全边界 +# ====================================================================== +_p = subprocess.Popen([PY, "-c", "pass"]) +_p.wait(timeout=5) +try: + platform_shell.kill_process_tree(_p, grace_s=0.5) + check("D1 已退出进程:不抛异常", True) +except Exception as e: + check("D1 已退出进程:不抛异常", False, repr(e)) + +try: + platform_shell.kill_process_tree(None) + check("D2 None 输入:不抛异常", True) +except Exception as e: + check("D2 None 输入:不抛异常", False, repr(e)) + +# ====================================================================== +print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True) +sys.exit(0 if all(ok for _, ok in RESULTS) else 1) diff --git a/tests/test_debug_window.py b/tests/test_debug_window.py new file mode 100644 index 0000000..308b002 --- /dev/null +++ b/tests/test_debug_window.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +"""独立调试器窗口 + 调试日志协议 回归测试(离屏,临时文件,不碰真实 DB/日志) +运行: PYTHONIOENCODING=utf-8 QT_QPA_PLATFORM=offscreen python tests/test_debug_window.py +""" +import sys, os, re, tempfile +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +_TMPDIR = tempfile.mkdtemp(prefix="haocode_dbg_") +os.environ["HAOCODE_DEBUG_LOG"] = os.path.join(_TMPDIR, "debug_session.log") +os.environ["HAOCODE_DEBUG_CMD"] = os.path.join(_TMPDIR, "debug_window.cmd") + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" + +from core import debug_log as dl + +PASS, FAIL = 0, 0 + + +def check(name, cond, extra=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" PASS {name}") + else: + FAIL += 1 + print(f" FAIL {name} {extra}") + + +def read_log(): + if not os.path.exists(dl.DEBUG_LOG_PATH): + return "" + with open(dl.DEBUG_LOG_PATH, "r", encoding="utf-8") as f: + return f.read() + + +LINE_RE = re.compile(r"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] " + r"\[(USER|AGENT|APP|SYS)\] .+$") + +# ============ T1: debug_log 三方写入 + 行格式 ============ +print("T1: debug_log 行格式与三方 TAG") +dl.debug_log("hello app", "APP") +dl.debug_log("代理注入一条备注", "AGENT") +dl.debug_log("用户观察到标签 40.5k", "USER") +lines = [l for l in read_log().splitlines() if l] +check("三行全部写入", len(lines) == 3, lines) +check("行格式 [ts] [TAG] msg", + all(LINE_RE.match(l) for l in lines), lines) +check("TAG 顺序 APP/AGENT/USER", + [re.search(r"\[(USER|AGENT|APP|SYS)\]", l).group(1) for l in lines] + == ["APP", "AGENT", "USER"]) + +# ============ T2: poll_debug_cmd 控制协议 ============ +print("T2: poll_debug_cmd 消费语义") +with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f: + f.write("show\n") +check("show 被识别", dl.poll_debug_cmd() == "show") +check("文件被消费(再读为 None)", dl.poll_debug_cmd() is None) +with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f: + f.write(" HIDE ") +check("hide 大小写/空白容忍", dl.poll_debug_cmd() == "hide") +with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f: + f.write("garbage") +check("非法内容忽略", dl.poll_debug_cmd() is None) + +# ============ T3-T6: DebugWindow 行为(离屏) ============ +print("T3: DebugWindow 用户输入 → [USER] 落盘") +from PyQt6.QtWidgets import QApplication +app = QApplication.instance() or QApplication(sys.argv) +import ui.views.debug_window as dw +# 应用日志 tab 指向临时文件(不读真实 diag.log) +_tmp_diag = os.path.join(_TMPDIR, "diag.log") +dw._APP_LOGS[:] = [("DIAG", _tmp_diag)] + +win = dw.DebugWindow() +check("窗口标题独立", win.windowTitle() == "Haocode 调试器") +win._input.setText(" 发送后标签跳到 80k ") +win._on_submit() +check("输入框被清空", win._input.text() == "") +check("[USER] 已落盘且去首尾空白", + any(l.endswith("[USER] 发送后标签跳到 80k") for l in read_log().splitlines())) + +print("T4: 首次 tick 记 [SYS] 开启事件") +win._tick() +check("[SYS] 调试窗口开启 已写入", + any("[SYS] 调试窗口开启" in l for l in read_log().splitlines())) + +print("T5: 实时 tail 会话日志 + 应用日志") +dl.debug_log("tick 前注入的 APP 事件", "APP") +dl.debug_log("AGENT: 现在检查 compaction_diag.log", "AGENT") +win._tick() +txt = win._view_session.toPlainText() +check("会话视图含 APP 事件", "tick 前注入的 APP 事件" in txt, txt[-300:]) +check("会话视图含 AGENT 注入", "AGENT: 现在检查 compaction_diag.log" in txt) +with open(_tmp_diag, "w", encoding="utf-8") as f: + f.write("[12:00:00.000] DIAG_TEST_LINE\n") +win._tick() +check("应用日志视图含 DIAG tail", + "DIAG_TEST_LINE" in win._view_app.toPlainText()) +check("应用日志带文件头", "[DIAG]" in win._view_app.toPlainText()) + +print("T6: 暂停显示 + 截断重置") +win._chk_pause.setChecked(True) # 暂停 +dl.debug_log("暂停期间的行不应上屏", "APP") +win._tick() +check("暂停期间不上屏", "暂停期间的行不应上屏" + not in win._view_session.toPlainText()) +# 文件截断(模拟「清空会话日志」)→ 偏移重置,新行仍可读取 +win._chk_pause.setChecked(False) +with open(dl.DEBUG_LOG_PATH, "w", encoding="utf-8") as f: + f.write("[2026-01-01 00:00:00.000] [SYS] 截断后新内容\n") +win._tick() +check("截断后偏移重置、新行上屏", + "截断后新内容" in win._view_session.toPlainText()) + +win.close() + +# ============ T4: 调试窗口随程序启动(autostart_debug_window) ============ +print("T4: 调试窗口随程序启动") +p = dl.DEBUG_CMD_PATH +if os.path.exists(p): + os.remove(p) +# 显式 false → 不写控制文件 +check("T4.autostart=False 不写控制文件", + dl.autostart_debug_window({"debug_window_autostart": False}) is False + and not os.path.exists(p)) +# 缺省(键不存在)→ 默认开 +check("T4.缺省(无键)写入 show", + dl.autostart_debug_window({}) is True and os.path.exists(p)) +check("T4.轮询消费 show", + dl.poll_debug_cmd() == "show" and not os.path.exists(p)) +# 显式 true +check("T4.autostart=true 写入 show", + dl.autostart_debug_window({"debug_window_autostart": True}) is True) +check("T4.轮询再消费 show", + dl.poll_debug_cmd() == "show") + +print(f"\n===== {PASS} PASS / {FAIL} FAIL =====") +sys.exit(1 if FAIL else 0) diff --git a/tests/test_error_persist.py b/tests/test_error_persist.py new file mode 100644 index 0000000..01c2d5e --- /dev/null +++ b/tests/test_error_persist.py @@ -0,0 +1,361 @@ +# -*- coding: utf-8 -*- +"""失败轮次持久化(对照 pi:message_end 无条件入库 → 出错也写会话)+ 回放取舍。 + +核心诉求:agent 跑了 200 个工具后第 201 步出错,**已完成的工具结果不能白跑** —— +必须入库、必须能进下次上下文,这样"接着执行最后一次"才可能。 + +覆盖矩阵: + T1 全空失败轮 → 入库留痕(is_ignored=1),回放**跳过** + T2 有工具/正文的失败轮 → 入库 + 回放(工具调用与结果成对)★核心 + T3 轮内重试 → 只动内存、**不入库**(不会产生一堆错误行) + T4 孤儿工具(ok=None) → 回放注入合成结果(对照 pi insertSyntheticToolResults) + T5 叶子前进 → 不再"时光倒流";parent_id 指向本轮提问 + T6 压缩切点交互 → 切点之后照常回放;之前 → 出上下文 + T7 旧库自动迁移 → 新列补齐,旧行 stop_reason=NULL 行为不变 + T8 copy_session → 复制错误行时携带 stop_reason/error_message/is_ignored + +运行: QT_QPA_PLATFORM=offscreen python tests/test_error_persist.py +""" +import os +import sys +import json +import uuid +import sqlite3 +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成 +from tests._test_env import isolate # noqa: E402 +_TMP = isolate("errpersist", config={"providers": {}}) +_DB_TMP = _TMP["db"] +_CFG_TMP = _TMP["config"] + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtTest import QTest # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 +from core.db_manager import DBManager # noqa: E402 +from core.agent.recovery import AgentRunner # noqa: E402 +from core.agent.types import (AgentConfig, AgentMessage, ModelConfig, # noqa: E402 + RetryConfig) + +app = QApplication(sys.argv) +ok = True + + +def check(name, cond, extra=""): + global ok + print((" PASS " if cond else " FAIL ") + name + + ("" if cond else f" {extra}"), flush=True) + if not cond: + ok = False + + +def settle(ms=120): + for _ in range(int(ms / 20) + 1): + app.processEvents() + QTest.qWait(20) + + +window = MainWindow() +window.show() +settle(300) + + +def mk_failed_turn(sid, body="", reasoning="", tools=None, err="连接失败: Connection error"): + """模拟一次「已流出一部分 → 出错」的轮次,返回 (user_msg_id, stream_state)""" + chain = window.db.get_message_chain(sid) + parent = chain[-1]["id"] if chain else None + st = { + "msg_id": f"msg_err_{uuid.uuid4().hex[:10]}", + "parent_id": parent, + "previous_leaf_id": parent, + "content": body, + "reasoning": reasoning, + "timeline": list(tools or []), + "usage": {}, + "tl_kind": "text", + "worker": None, + } + window.current_session_id = sid + window._active_streams[sid] = st + window.on_error(sid, err) + settle(120) + return parent, st + + +def tool_entry(cid, name, args, ok_flag, result): + return {"t": "tool", "id": cid, "name": name, "args": args, + "ok": ok_flag, "result": result} + + +# ====================================================================== +# T1 全空失败轮 → 入库留痕但回放跳过 +# ====================================================================== +sid1 = window.db.create_session("T1 全空失败")["id"] +uid1 = window.db.add_message(session_id=sid1, role="user", content="开始吧", + parent_id=None)["id"] +mk_failed_turn(sid1, body="", reasoning="", tools=[]) +chain1 = window.db.get_message_chain(sid1) +last1 = chain1[-1] +check("T1.1 全空失败轮也入库(对照 pi 无条件 append)", + len(chain1) == 2 and last1["role"] == "assistant", f"chain={len(chain1)}") +check("T1.2 标记 stop_reason=error + error_message", + last1.get("stop_reason") == "error" and "Connection error" in (last1.get("error_message") or ""), + f"{last1.get('stop_reason')} / {last1.get('error_message')}") +check("T1.3 全空 → is_ignored=1(只在库里留痕)", int(last1.get("is_ignored") or 0) == 1) +check("T1.4 正文含 ⚠️ 中断说明(供历史/UI 可见)", + "⚠️" in (last1.get("content") or "") and "本轮中断" in (last1.get("content") or ""), + (last1.get("content") or "")[:60]) +ctx1 = window.build_api_context(sid1) +check("T1.5 回放**跳过**全空错误行(避免空 assistant 触发服务商 400)", + not any(m.get("role") == "assistant" for m in ctx1), + str([m.get("role") for m in ctx1])) + +# ====================================================================== +# T2 ★核心:200 个工具后第 201 步出错 → 工具结果必须进上下文 +# ====================================================================== +sid2 = window.db.create_session("T2 工具后出错")["id"] +uid2 = window.db.add_message(session_id=sid2, role="user", + content="帮我建 3 个目录", parent_id=None)["id"] +tools2 = [tool_entry(f"c{i}", "bash", json.dumps({"command": f"mkdir d{i}"}), + True, f"d{i} created") for i in range(1, 4)] +tools2.append(tool_entry("c4", "bash", json.dumps({"command": "mkdir d4"}), + None, "")) # 孤儿:开始了但没结果 +mk_failed_turn(sid2, body="正在创建目录…", reasoning="先建目录", tools=tools2) +chain2 = window.db.get_message_chain(sid2) +last2 = chain2[-1] +check("T2.1 有工具/正文的失败轮入库且 is_ignored=0(会回放)", + last2["role"] == "assistant" and int(last2.get("is_ignored") or 0) == 0, + f"ignored={last2.get('is_ignored')}") +check("T2.2 timeline 完整保留 4 个工具条目", + len([e for e in json.loads(last2["timeline"] or "[]") + if e.get("t") == "tool"]) == 4, + str([e.get("t") for e in json.loads(last2["timeline"] or "[]")])) +check("T2.2b timeline 尾部多一条 text 条目(中断说明,回放时才看得到)", + (json.loads(last2["timeline"] or "[]")[-1].get("t") == "text" + and "⚠️" in json.loads(last2["timeline"] or "[]")[-1].get("text", "")), + str(json.loads(last2["timeline"] or "[]")[-1])[:80]) +check("T2.3 正文保留 + 尾部中断说明", + "正在创建目录" in last2["content"] and "⚠️" in last2["content"]) + +ctx2 = window.build_api_context(sid2) +tcs = [tc for m in ctx2 if m.get("role") == "assistant" for tc in (m.get("tool_calls") or [])] +tool_msgs = [m for m in ctx2 if m.get("role") == "tool"] +check("T2.4 ★回放里出现 4 个 tool_call(200 个工具的记录没丢)", len(tcs) == 4, + f"tool_calls={len(tcs)}") +check("T2.5 ★每个 tool_call 都有配对的 tool 结果(成对,服务商不会 400)", + len(tool_msgs) == len(tcs) and + {m["tool_call_id"] for m in tool_msgs} == {t["id"] for t in tcs}, + f"tool_msgs={len(tool_msgs)}") +check("T2.6 已完成的工具结果原文进上下文", + any("d1 created" in (m.get("content") or "") for m in tool_msgs)) +check("T2.7 错误说明文本也进上下文(模型知道上轮为何断)", + any(m.get("role") == "assistant" and "⚠️" in (m.get("content") or "") + for m in ctx2)) +check("T2.8 顺序正确:assistant tool_calls 在 tool 结果之前", + [m.get("role") for m in ctx2].index("assistant") < + [m.get("role") for m in ctx2].index("tool")) + +# ====================================================================== +# T3 轮内重试只动内存、不入库 +# ====================================================================== +class _FakeState: + def __init__(self, msgs): + self.messages = msgs + + +class _FakeAgent: + def __init__(self): + self.config = AgentConfig( + model=ModelConfig(name="fake"), + retry=RetryConfig(max_attempts=3, base_delay_ms=1, factor=2.0), + ) + self.state = _FakeState([ + AgentMessage(role="user", content="hi"), + AgentMessage(role="assistant", stop_reason="error", + error_message="connection error"), + ]) + + +fa = _FakeAgent() +runner = AgentRunner(fa, summarize_fn=None) +n_before = len(window.db.get_message_chain(sid2)) +retried = runner._prepare_retry(fa.config.retry, reason="connection error") +n_after = len(window.db.get_message_chain(sid2)) +check("T3.1 _prepare_retry 生效(可重试错误)", retried is True) +check("T3.2 重试只从**内存**移除坏消息(轮次未结束,不该入库)", + len(fa.state.messages) == 1 and fa.state.messages[-1].role == "user", + str([m.role for m in fa.state.messages])) +check("T3.3 ★重试前后 DB 行数不变(不会堆一堆错误行)", n_before == n_after, + f"{n_before} -> {n_after}") + +# 正常成功轮 → 不写 error 列 +sid3 = window.db.create_session("T3 正常轮")["id"] +uid3 = window.db.add_message(session_id=sid3, role="user", content="你好", + parent_id=None)["id"] +st3 = {"msg_id": "msg_ok_1", "parent_id": uid3, "previous_leaf_id": uid3, + "content": "你好,我在。", "reasoning": "", "timeline": [], "usage": {}, + "tl_kind": "text", "worker": None} +window.current_session_id = sid3 +window._active_streams[sid3] = st3 +window.on_reply_finished(sid3) +settle(150) +ok_row = window.db.get_message_chain(sid3)[-1] +check("T3.4 正常轮 stop_reason 为空(与错误行可区分)", + not ok_row.get("stop_reason"), f"{ok_row.get('stop_reason')!r}") + +# ====================================================================== +# T4 孤儿工具 → 合成结果(对照 pi insertSyntheticToolResults) +# ====================================================================== +sid4 = window.db.create_session("T4 孤儿工具")["id"] +uid4 = window.db.add_message(session_id=sid4, role="user", content="跑个命令", + parent_id=None)["id"] +mk_failed_turn(sid4, body="", tools=[tool_entry("orphan1", "bash", "{}", None, "")]) +ctx4 = window.build_api_context(sid4) +tool4 = [m for m in ctx4 if m.get("role") == "tool"] +check("T4.1 孤儿工具也有配对结果", len(tool4) == 1, str(len(tool4))) +check("T4.2 孤儿结果是合成说明(不是空串,避免服务商拒绝)", + tool4 and "未收到" in tool4[0]["content"], tool4[0]["content"] if tool4 else "") +check("T4.3 孤儿工具的 tool_call 同时存在", + any(tc["id"] == "orphan1" for m in ctx4 if m.get("role") == "assistant" + for tc in (m.get("tool_calls") or []))) + +# ====================================================================== +# T5 叶子前进(不再时光倒流) +# ====================================================================== +check("T5.1 会话叶子 = 错误行(叶子前进,不再回退到提问)", + window.db.get_session_leaf(sid2) == last2["id"], + f"leaf={window.db.get_session_leaf(sid2)} last={last2['id']}") +check("T5.2 错误行 parent_id 指向本轮提问", + last2["parent_id"] == uid2, f"{last2['parent_id']} vs {uid2}") +check("T5.3 链上顺序 = [user, assistant(error)]", + [m["role"] for m in chain2] == ["user", "assistant"], + str([m["role"] for m in chain2])) +check("T5.4 下次提问可接着链(叶子非空 → 可继续)", + window.db.get_session_leaf(sid2) is not None) + +# ====================================================================== +# T6 压缩切点交互 +# ====================================================================== +sid6 = window.db.create_session("T6 压缩交互")["id"] +u6 = window.db.add_message(session_id=sid6, role="user", content="老问题", + parent_id=None)["id"] +mk_failed_turn(sid6, body="中途断了", tools=[]) +err6 = window.db.get_message_chain(sid6)[-1] +u6b = window.db.add_message(session_id=sid6, role="user", content="新问题", + parent_id=err6["id"])["id"] +a6 = window.db.add_message(session_id=sid6, role="assistant", content="新回答", + parent_id=u6b)["id"] +window.db.insert_compaction_mark(sid6, "【摘要】老问题与中断", err6["id"], u6b, + json.dumps({"path": "test", "before": 1, "after": 1})) +ctx6 = window.build_api_context(sid6) +flat6 = json.dumps(ctx6, ensure_ascii=False) +check("T6.1 切点之前的错误行 → 不进上下文(已出上下文)", + "中途断了" not in flat6 and "⚠️" not in flat6) +check("T6.2 摘要进上下文 + 切点之后照常", + "【摘要】老问题与中断" in flat6 and "新回答" in flat6) + +# ====================================================================== +# T7 旧库自动迁移(无新列 → 补齐;旧行 stop_reason=NULL) +# ====================================================================== +_OLD = os.path.join(tempfile.gettempdir(), f"haocode_old_schema_{os.getpid()}.db") +if os.path.exists(_OLD): + os.remove(_OLD) +_c = sqlite3.connect(_OLD) +_c.executescript(""" +CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER); +CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, + reasoning TEXT, is_ignored BOOLEAN, created_at INTEGER, + attachment_metadata TEXT, parent_id TEXT, timeline TEXT, usage TEXT); +""") +_c.execute("INSERT INTO sessions VALUES ('s1','旧会话',1,1)") +_c.execute("INSERT INTO messages (id,session_id,role,content,created_at,parent_id) " + "VALUES ('m1','s1','user','你好',1,NULL)") +_c.commit() +_c.close() +d_old = DBManager(db_path=_OLD) +cols = [r[1] for r in d_old.get_connection().execute("PRAGMA table_info(messages)")] +check("T7.1 旧库自动补上 stop_reason / error_message 列", + "stop_reason" in cols and "error_message" in cols, str(cols)) +row_old = d_old.get_message_chain("s1") +check("T7.2 旧行读出来 stop_reason 为空(行为与升级前一致)", + row_old and not row_old[-1].get("stop_reason"), + str(row_old[-1].get("stop_reason") if row_old else "no row")) +check("T7.3 旧库可正常写入错误行(新列可用)", + d_old.add_message(session_id="s1", role="assistant", content="⚠️ 中断", + parent_id="m1", stop_reason="error", + error_message="boom")["id"] is not None) +check("T7.4 旧库错误行可读回", + d_old.get_message_chain("s1")[-1].get("error_message") == "boom") + +# ====================================================================== +# T8 copy_session 携带新列 +# ====================================================================== +copy8 = window.db.copy_session(sid2) +chain8 = window.db.get_message_chain(copy8["id"]) +last8 = chain8[-1] +check("T8.1 复制后错误行保留 stop_reason/error_message", + last8.get("stop_reason") == "error" and "Connection error" in (last8.get("error_message") or ""), + f"{last8.get('stop_reason')} / {last8.get('error_message')}") +check("T8.2 复制后 timeline 工具条目一致", + len(json.loads(last8["timeline"] or "[]")) == len(json.loads(last2["timeline"] or "[]"))) +check("T8.3 复制后 is_ignored 一致", + int(last8.get("is_ignored") or 0) == int(last2.get("is_ignored") or 0)) +check("T8.4 复制后链条角色一致", + [m["role"] for m in chain8] == [m["role"] for m in chain2]) + +# ====================================================================== +# T9 ★数据安全:追加新列绝不得触发「旧数据链表化重构」 +# _upgrade_schema 里 upgraded=True 会把每个会话的消息按时间拍平成线性链, +# 直接毁掉树状分支(用户的 12 个分支点!)。新列迁移必须走旁路。 +# ====================================================================== +_BR = os.path.join(tempfile.gettempdir(), f"haocode_branch_{os.getpid()}.db") +if os.path.exists(_BR): + os.remove(_BR) +_bc = sqlite3.connect(_BR) +_bc.executescript(""" +CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER, + has_messages BOOLEAN DEFAULT 0, sort_order INTEGER DEFAULT 0, + is_starred BOOLEAN DEFAULT 0, current_leaf_msg_id TEXT, mode TEXT); +CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, + reasoning TEXT, is_ignored BOOLEAN, created_at INTEGER, + attachment_metadata TEXT, parent_id TEXT, timeline TEXT, usage TEXT); +""") +_bc.execute("INSERT INTO sessions VALUES ('s9','分支会话',1,1,1,0,0,'m5',NULL)") +# 链 u1(1) → a1(2) → u2(3) → {a2(4), a2b(5)} ← u2 下两个孩子 = 分支点 +for _mid, _role, _ct, _par in [("m1", "user", 1, None), ("m2", "assistant", 2, "m1"), + ("m3", "user", 3, "m2"), ("m4", "assistant", 4, "m3"), + ("m5", "assistant", 5, "m3")]: + _bc.execute("INSERT INTO messages (id,session_id,role,content,created_at,parent_id) " + "VALUES (?,?,?,?,?,?)", (_mid, "s9", _role, _mid, _ct, _par)) +_bc.commit() +_bc.close() +d_b = DBManager(db_path=_BR) +_cols_b = [r[1] for r in d_b.get_connection().execute("PRAGMA table_info(messages)")] +check("T9.1 旧库自动追加 stop_reason / error_message 列", + "stop_reason" in _cols_b and "error_message" in _cols_b, str(_cols_b)) +_rows_b = {r["id"]: r for r in d_b.get_connection().execute( + "SELECT id, parent_id FROM messages WHERE session_id='s9'")} +check("T9.2 ★分支结构未被拍平(a2b.parent 仍指向分支点 u2)", + _rows_b["m5"]["parent_id"] == "m3", + f"m5.parent={_rows_b['m5']['parent_id']}(拍平后会变成 m4)") +check("T9.3 叶子节点未被改写", d_b.get_session_leaf("s9") == "m5", + str(d_b.get_session_leaf("s9"))) +check("T9.4 原有行内容未被改动", + all(_rows_b[k]["parent_id"] == v for k, v in + [("m2", "m1"), ("m3", "m2"), ("m4", "m3")]), + str({k: _rows_b[k]["parent_id"] for k in _rows_b})) + +print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True) +for _p in (_DB_TMP, _CFG_TMP, _OLD, _BR): + try: + if os.path.exists(_p): + os.remove(_p) + except Exception: + pass +sys.exit(0 if ok else 1) diff --git a/tests/test_file_attach.py b/tests/test_file_attach.py new file mode 100644 index 0000000..eb144e7 --- /dev/null +++ b/tests/test_file_attach.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +""" +file_reader 单元测试(标准库 unittest,零额外依赖) +运行:在项目根目录执行 python -m unittest discover tests -v + 或直接 python tests/test_file_attach.py +""" +import os +import sys +import tempfile +import unittest + +# 保证直接运行(python tests/xxx.py)时也能 import 到项目根下的包 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + +from ui.views.system_tools.file_reader import BINARY_EXTS, read_text_file # noqa: E402 + + +class ReadTextFileTest(unittest.TestCase): + """read_text_file:编码探测 / 二进制探测 / 大小守卫""" + + def _write(self, data: bytes, suffix: str = ".txt") -> str: + fd, path = tempfile.mkstemp(suffix=suffix) + with os.fdopen(fd, "wb") as f: + f.write(data) + self.addCleanup(os.remove, path) + return path + + def test_utf8_file(self): + path = self._write("你好,世界\nhello".encode("utf-8")) + content, enc, size_kb, lines = read_text_file(path) + self.assertIn("你好,世界", content) + self.assertIn("hello", content) + self.assertEqual(lines, 2) + self.assertIn(enc, ("utf-8-sig", "utf-8")) + self.assertGreater(size_kb, 0) + + def test_utf8_bom_file(self): + path = self._write(b"\xef\xbb\xbf" + "带BOM".encode("utf-8")) + content, enc, _, _ = read_text_file(path) + self.assertEqual(content, "带BOM") # utf-8-sig 会吃掉 BOM + self.assertEqual(enc, "utf-8-sig") + + def test_gbk_file_falls_back_to_gb18030(self): + path = self._write("中文GBK内容".encode("gbk")) + content, enc, _, _ = read_text_file(path) + self.assertEqual(content, "中文GBK内容") + self.assertEqual(enc, "gb18030") + + def test_binary_file_rejected(self): + path = self._write(b"\x00\x01\x02\x03binary-payload") + with self.assertRaises(ValueError): + read_text_file(path) + + def test_oversize_file_rejected(self): + path = self._write(b"a" * 100) + with self.assertRaises(ValueError): + read_text_file(path, max_bytes=10) + + def test_latin1_fallback_never_fails(self): + # 0xFF 既非合法 UTF-8 也非合法 GB18030 引导字节,应由 latin-1 兜底 + path = self._write(b"\xff\xfe\xfd plain text") + content, enc, _, _ = read_text_file(path) + self.assertEqual(enc, "latin-1") + self.assertIn("plain text", content) + + +class BinaryExtBlacklistTest(unittest.TestCase): + """黑名单分类:Word/Excel 等被拒,常见代码/文本文件放行""" + + def test_office_and_binary_blocked(self): + for ext in (".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".zip", ".exe", ".dll", ".mp4", ".ttf", ".db", ".psd"): + self.assertIn(ext, BINARY_EXTS, f"{ext} 应在黑名单中") + + def test_pdf_not_blacklisted(self): + # PDF 改由专用分支(pdf_reader)处理,不再走二进制黑名单 + self.assertNotIn(".pdf", BINARY_EXTS, ".pdf 应由 PDF 专用分支处理,不应在黑名单中") + + def test_text_and_code_allowed(self): + for ext in (".py", ".js", ".ts", ".java", ".c", ".cpp", ".go", ".rs", + ".md", ".txt", ".json", ".yaml", ".html", ".css", ".sql", + ".sh", ".csv", ".log", ".ipynb", ""): + self.assertNotIn(ext, BINARY_EXTS, f"{ext} 不应在黑名单中") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_global_hotkey_platforms.py b/tests/test_global_hotkey_platforms.py new file mode 100644 index 0000000..e7646c0 --- /dev/null +++ b/tests/test_global_hotkey_platforms.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +""" +P1-04 全局热键平台矩阵单测(适配器替身,不依赖真实 X11 显示 / 不新增依赖) + +覆盖: + H1 session_kind 矩阵(win32 / x11 / wayland / offscreen-unknown,mock 环境变量 + sys.platform) + H2 hotkey_plan 路由(win32→RegisterHotKey 线程;x11→XGrabKey 线程;wayland/offscreen→None+明确日志) + H3 X11HotkeyThread 成功路径(fake libX11:XGrabKey 参数正确、命中 keycode 发射 triggered、 + stop 后 XUngrabKey/XCloseDisplay 释放) + H4 X11 注册失败(XGrabKey=0 键被占用 / 无显示 / 不支持的组合 → _registered=False,线程安静退出) + H5 Windows GlobalHotkeyThread 行为保持(非 Windows run() 静默就绪退出;Windows 常量完整) + +运行: PYTHONIOENCODING=utf-8 python tests/test_global_hotkey_platforms.py +""" +import contextlib +import io +import os +import socket +import sys +import threading +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtWidgets import QApplication # noqa: E402 + +from ui.views.system_tools import desktop_session as ds # noqa: E402 +from ui.views.system_tools import x11_hotkey as xh # noqa: E402 +from ui.views.system_tools import global_hotkey as gh # noqa: E402 + +RESULTS = [] +APP = QApplication.instance() or QApplication(sys.argv) + + +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + + +def with_env(patch: dict, fn): + saved = {k: os.environ.get(k) for k in patch} + for k, v in patch.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + try: + return fn() + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +# ====================================================================== +# H1. session_kind 矩阵 +# ====================================================================== +_orig_platform = ds.sys.platform + +def _set_platform(p): + ds.sys = type("FakeSys", (), {"platform": p})() + + +try: + _set_platform("win32") + check("H1.1 win32", ds.session_kind() == "win32") + _set_platform("linux") + check("H1.2 X11(DISPLAY 有、无 WAYLAND_DISPLAY)", + with_env({"DISPLAY": ":0", "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": None, + "QT_QPA_PLATFORM": "xcb"}, lambda: ds.session_kind()) == "x11") + check("H1.3 Wayland(WAYLAND_DISPLAY 有)", + with_env({"WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ":0", "XDG_SESSION_TYPE": None, + "QT_QPA_PLATFORM": "wayland"}, lambda: ds.session_kind()) == "wayland") + check("H1.4 QT_QPA_PLATFORM=wayland(无 WAYLAND_DISPLAY)", + with_env({"WAYLAND_DISPLAY": None, "DISPLAY": None, "XDG_SESSION_TYPE": None, + "QT_QPA_PLATFORM": "wayland"}, lambda: ds.session_kind()) == "wayland") + check("H1.5 offscreen → unknown(即使有 DISPLAY)", + with_env({"DISPLAY": ":0", "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": None, + "QT_QPA_PLATFORM": "offscreen"}, lambda: ds.session_kind()) == "unknown") + check("H1.6 无显示无 WAYLAND → unknown", + with_env({"DISPLAY": None, "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": None, + "QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "unknown") + check("H1.7 XDG_SESSION_TYPE=wayland(无 WAYLAND_DISPLAY/QT 平台)", + with_env({"DISPLAY": None, "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": "wayland", + "QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "wayland") + check("H1.8 XDG_SESSION_TYPE=x11(无 DISPLAY)", + with_env({"DISPLAY": None, "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": "x11", + "QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "x11") + check("H1.9 矛盾时 WAYLAND_DISPLAY 优先于 XDG_SESSION_TYPE=x11", + with_env({"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0", "XDG_SESSION_TYPE": "x11", + "QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "wayland") +finally: + ds.sys = sys # 还原 + +# ====================================================================== +# H2. hotkey_plan 路由 +# ====================================================================== +try: + _set_platform("win32") + factory, msg = ds.hotkey_plan("win32") + check("H2.1 win32 → GlobalHotkeyThread 工厂 + 说明", + factory is gh.GlobalHotkeyThread and "Windows" in msg, repr((factory, msg))) +finally: + ds.sys = sys + +factory, msg = ds.hotkey_plan("x11") +check("H2.2 x11 → X11HotkeyThread 工厂 + 说明", + factory is xh.X11HotkeyThread and "X11" in msg, repr((factory, msg))) + +factory, msg = ds.hotkey_plan("wayland") +check("H2.3 wayland → None + 明确不可用说明(不绕过 compositor)", + factory is None and "Wayland" in msg and "Alt+S" in msg, repr((factory, msg))) + +factory, msg = ds.hotkey_plan("unknown") +check("H2.4 unknown → None + 明确不可用说明", + factory is None and "不可用" in msg, repr((factory, msg))) + +# ====================================================================== +# H3–H4. X11HotkeyThread(fake libX11) +# ====================================================================== + +class FakeX11: + def __init__(self, grab_rc=1, open_rc=0xAB, keycode=39, unsupported_vk=False): + self.grab_rc = grab_rc + self.open_rc = open_rc + self.keycode = keycode + self.unsupported_vk = unsupported_vk + self.grab_calls = [] + self.ungrab_calls = [] + self.close_calls = 0 + self.select_calls = 0 + self.opened = False + self.pending_left = 1 + # socketpair:跨平台可被 select() 监听(Windows 上 os.pipe 的 fd 不行) + self._a, self._b = socket.socketpair() + self._b.send(b"\x01") # 让 a 有可读数据 → select 首次就绪 + self._event = xh.XEvent() + self._event.type = xh.KeyPress + self._event.keycode = self.keycode + + # -- libX11 API(鸭子类型替身) -- + def XOpenDisplay(self, name): + self.opened = True + return self.open_rc + def XCloseDisplay(self, d): + self.close_calls += 1 + def XConnectionNumber(self, d): + return self._a.fileno() + def XDefaultRootWindow(self, d): + return 123 + def XKeysymToKeycode(self, d, keysym): + if self.unsupported_vk: + return 0 + return self.keycode + def XSelectInput(self, d, w, mask): + self.select_calls += 1 + def XGrabKey(self, d, kc, mod, w, owner): + self.grab_calls.append((kc, mod, w, owner)) + return self.grab_rc + def XUngrabKey(self, d, kc, mod, w): + self.ungrab_calls.append((kc, mod, w)) + def XPending(self, d): + if self.pending_left > 0: + self.pending_left -= 1 + return 1 + return 0 + def XNextEvent(self, d, evp): + # 生产代码传入 ctypes.byref(ev)(CArgObject);真实 CDLL 自行解引用, + # 替身可调用对象则通过 ._obj 拿回原始 struct + import ctypes as _ct + target = getattr(evp, "_obj", evp) + _ct.memmove(_ct.byref(target), _ct.byref(self._event), _ct.sizeof(xh.XEvent)) + self._event.type = 0 # 之后无事件 + + def close(self): + for s in (self._a, self._b): + try: + s.close() + except Exception: + pass + + +def run_hotkey_case(fake, **kw): + """启动 X11HotkeyThread,等待 triggered 或退出,回收。返回 (got_signal, thread)""" + orig_open = xh._open_x11 + xh._open_x11 = lambda: (fake, fake.open_rc) + try: + t = xh.X11HotkeyThread(**kw) + got = threading.Event() + t.triggered.connect(lambda: got.set()) + t.start() + t.wait_ready(3.0) + deadline = time.time() + 6.0 + while not got.is_set() and time.time() < deadline: + APP.processEvents() + time.sleep(0.02) + t.stop() + t.wait(3000) + return got.is_set(), t + finally: + xh._open_x11 = orig_open + fake.close() + + +# H3.1 成功路径:grab 参数正确 + 命中发射 + stop 释放 +fake = FakeX11() +got, t = run_hotkey_case(fake) +check("H3.1 命中 Alt+S → triggered 发射", got) +check("H3.2 XGrabKey 参数(keycode=39, Mod1Mask=1, root=123, owner_events=1)", + fake.grab_calls == [(39, xh.Mod1Mask, 123, 1)], repr(fake.grab_calls)) +check("H3.3 stop 后 XUngrabKey + XCloseDisplay 释放", + len(fake.ungrab_calls) == 1 and fake.close_calls == 1, + repr((fake.ungrab_calls, fake.close_calls))) +check("H3.4 注册成功标志 _registered", t._registered is False) # cleanup 后复位为 False + +# H4.1 键被占用(XGrabKey → 0) +fake = FakeX11(grab_rc=0) +buf = io.StringIO() +with contextlib.redirect_stdout(buf): + got, t = run_hotkey_case(fake) +check("H4.1 注册失败 → 无信号、_registered=False、明确日志", + not got and t._registered is False and ("占用" in buf.getvalue() or "失败" in buf.getvalue()), + buf.getvalue()[-200:]) + +# H4.2 无显示(XOpenDisplay → None) +fake = FakeX11(open_rc=None) +buf = io.StringIO() +with contextlib.redirect_stdout(buf): + got, t = run_hotkey_case(fake) +check("H4.2 无 X11 显示 → 安静退出 + 明确日志", + not got and t._registered is False and "XOpenDisplay" in buf.getvalue(), + buf.getvalue()[-200:]) + +# H4.3 不支持的组合(vk 不在窄映射表) +fake = FakeX11() +orig_open = xh._open_x11 +t_probe = xh.X11HotkeyThread(vk=0x41) +xh._open_x11 = lambda: (fake, 0xAB) +try: + t_probe.start() + t_probe.wait_ready(3.0) + deadline = time.time() + 5.0 + while t_probe.isRunning() and time.time() < deadline: + time.sleep(0.02) + t_probe.stop() + t_probe.wait(2000) +finally: + xh._open_x11 = orig_open + fake.close() +check("H4.3 不支持的组合 → 不打开显示即退出", + t_probe._registered is False and not fake.opened, repr(t_probe._registered)) + +# ====================================================================== +# H5. Windows 路径保持 +# ====================================================================== +check("H5.1 GlobalHotkeyThread 常量完整(MOD_ALT/VK_S/WM_HOTKEY)", + gh.MOD_ALT == 0x0001 and gh.VK_S == 0x53 and gh.WM_HOTKEY == 0x0312) + +t_win = gh.GlobalHotkeyThread() +t_win.start() +t_win._ready.wait(3.0) +if gh._is_windows: + # 本机 Windows:真实 RegisterHotKey + GetMessage 循环 = 行为保持的存活检查(随后立即释放 Alt+S) + check("H5.2 Windows:RegisterHotKey 线程运行中(行为保持)", t_win.isRunning()) + t_win.stop() + t_win.wait(3000) + check("H5.3 Windows:stop() 干净退出(释放热键)", not t_win.isRunning()) +else: + t_win.wait(3000) + check("H5.2 非 Windows:run() 静默就绪退出(不注册、不崩)", + not t_win.isRunning() and t_win._ready.is_set(), + f"running={t_win.isRunning()}") + +print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True) +sys.exit(0 if all(ok for _, ok in RESULTS) else 1) diff --git a/tests/test_main_window_event_filter.py b/tests/test_main_window_event_filter.py new file mode 100644 index 0000000..8e70948 --- /dev/null +++ b/tests/test_main_window_event_filter.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +"""tests/test_main_window_event_filter.py —— P0-02 事件策略回归 + +运行: python tests/test_main_window_event_filter.py +(仓库惯例:无 pytest 依赖,独立可跑;GUI 走 offscreen) + +完成证据(REPAIR_BACKLOG.md P0-02): + 1. AST 静态断言:MainWindow 只有一个 eventFilter; + 2. 四种键盘状态:Enter 可发送 / 按钮禁用时 Enter 不发送 / 流式生成时 Enter 被拦截 / + Shift+Enter 放行换行; + 3. 一次按键对应最多一次 send_message 调用(计数 wrapper 验证); + 4. 其他键与事件继续交给父类(按 a 正常插入字符、不触发发送)。 +遵守 P0-01:临时配置 + 临时数据库,在 import MainWindow 之前完成。 +""" +import ast +import os +import sys +import time + +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_TESTS_DIR) +sys.path.insert(0, _ROOT) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu") +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +from tests._test_env import isolate # noqa: E402 + +_TMP = isolate("winfilter") # 必须在 import MainWindow 之前 + +ok = True + + +def check(name, cond, extra=""): + global ok + print((" PASS " if cond else " FAIL ") + name + + ("" if cond else f" {extra}"), flush=True) + if not cond: + ok = False + + +# ====================================================================== +# 1) AST 静态断言:MainWindow 只有一个 eventFilter +# ====================================================================== +with open(os.path.join(_ROOT, "ui", "views", "main_window.py"), + encoding="utf-8") as f: + _tree = ast.parse(f.read()) +_mw = [n for n in _tree.body + if isinstance(n, ast.ClassDef) and n.name == "MainWindow"][0] +_ef_lines = [n.lineno for n in _mw.body + if isinstance(n, ast.FunctionDef) and n.name == "eventFilter"] +check("AST:MainWindow 只有一个 eventFilter", len(_ef_lines) == 1, + f"行号: {_ef_lines}") + +# ====================================================================== +# 2) 四种键盘状态(真实事件派发:QApplication.sendEvent → 已安装过滤器 → 控件本身) +# ====================================================================== +from PyQt6 import QtGui, QtCore # noqa: E402 +from PyQt6.QtWidgets import QApplication # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 +import core.llm_engine as le # noqa: E402 + +app = QApplication.instance() or QApplication(sys.argv) +win = MainWindow() + +if not win.current_session_id: + win.on_new_chat_clicked() +sid = win.current_session_id +check("前置:存在当前会话", bool(sid)) + +# 计数 wrapper:包住真实 send_message(记录调用并透传执行) +calls = [] +_orig_send = win.send_message + + +def _spy_send(*a, **k): + calls.append(k) + return _orig_send(*a, **k) + + +win.send_message = _spy_send + + +def _press(key, shift=False, text=""): + """通过 Qt 事件系统向输入框派发一次真实的 KeyPress。""" + mods = (QtCore.Qt.KeyboardModifier.ShiftModifier if shift + else QtCore.Qt.KeyboardModifier.NoModifier) + ev = QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress, key, mods, text) + QApplication.sendEvent(win.text_input, ev) + app.processEvents() + + +def _clear_stream(): + """确保当前会话没有残留流(等待 worker 错误自清理,兜底手工清理)。""" + for _ in range(100): + if sid not in win._active_streams: + return + app.processEvents() + time.sleep(0.05) + st = win._active_streams.pop(sid, None) + if st and st.get("worker") is not None: + try: + st["worker"].abort() + st["worker"].wait(2000) + except Exception: + pass + win.set_send_button_state(False) + + +# ---- A) Enter 可发送(空闲 + 按钮可用 + 有文本) ---- +# 网络层桩:立即抛 ConnectionError(等价于不可达端口),走真实错误清理路径 +_REAL_OPENAI_STREAM = le.openai_stream + + +def _fake_openai_stream(*a, **k): + raise ConnectionError("test stub: no network") + yield # 保持生成器函数形态 + + +le.openai_stream = _fake_openai_stream +win.btn_send.setEnabled(True) +win.text_input.setPlainText("p002 enter send") +calls.clear() +_press(QtCore.Qt.Key.Key_Return) +check("A1 一次 Enter 至多一次 send_message 调用", len(calls) == 1, + f"调用次数: {len(calls)}") +check("A2 Enter 走 from_enter 规则", calls and calls[0].get("from_enter") is True, + f"kwargs: {calls}") +check("A3 发送已同步执行(流已注册)", sid in win._active_streams) +check("A4 输入框被清空", win.text_input.toPlainText() == "") +_clear_stream() +check("A5 流清理完毕(错误路径自恢复)", sid not in win._active_streams) +le.openai_stream = _REAL_OPENAI_STREAM + +# ---- B) 发送按钮禁用时 Enter 不发送 ---- +_clear_stream() +win.btn_send.setEnabled(False) +win.text_input.setPlainText("disabled should not send") +calls.clear() +_press(QtCore.Qt.Key.Key_Return) +check("B1 禁用时仍至多一次调用(规则在 send_message 内单一实现)", + len(calls) <= 1, f"调用次数: {len(calls)}") +check("B2 禁用时未发送(无流)", sid not in win._active_streams) +check("B3 禁用时输入内容保留", win.text_input.toPlainText() == "disabled should not send") +win.btn_send.setEnabled(True) + +# ---- C) 流式生成时 Enter 被拦截(不发送、不触发中断) ---- +win.set_send_button_state(True) # 与真实流式状态一致(停止图标,按钮仍可用) +_fake_stream = {"msg_id": "fake-ai", "content": "", "worker": None, "timeline": [], + "parent_id": None, "branch_info": {"current": 1, "total": 1}} +win._active_streams[sid] = _fake_stream +win.text_input.setPlainText("typing while generating") +calls.clear() +_press(QtCore.Qt.Key.Key_Return) +check("C1 流式时未产生真实发送(流仍是注入的假流)", + win._active_streams.get(sid) is _fake_stream) +check("C2 流式时未触发中断(假流字段未被改动)", + _fake_stream["msg_id"] == "fake-ai" and _fake_stream["worker"] is None) +check("C3 流式时输入内容保留", win.text_input.toPlainText() == "typing while generating") +del win._active_streams[sid] +win.set_send_button_state(False) + +# ---- D) Shift+Enter 放行换行 ---- +win.text_input.setPlainText("abc") +_cur = win.text_input.textCursor() +_cur.movePosition(QtGui.QTextCursor.MoveOperation.End) +win.text_input.setTextCursor(_cur) +calls.clear() +_press(QtCore.Qt.Key.Key_Return, shift=True) +check("D1 Shift+Enter 未调用 send_message", len(calls) == 0, + f"调用次数: {len(calls)}") +check("D2 Shift+Enter 插入换行(事件放行到输入框)", + win.text_input.toPlainText() == "abc\n", + repr(win.text_input.toPlainText())) +check("D3 Shift+Enter 未发送(无流)", sid not in win._active_streams) + +# ---- E) 其他键/事件继续交给父类 ---- +win.text_input.setPlainText("") +calls.clear() +_press(QtCore.Qt.Key.Key_A, text="a") +check("E1 普通字符键正常插入", win.text_input.toPlainText() == "a", + repr(win.text_input.toPlainText())) +check("E2 普通字符键不触发发送", len(calls) == 0) + +# ---- 收尾 ---- +try: + win.close() +except Exception: + pass +app.quit() + +print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True) +os._exit(0 if ok else 1) # 避免 QtWebEngine offscreen 静态析构段错误(不影响结果) diff --git a/tests/test_math_extract.js b/tests/test_math_extract.js new file mode 100644 index 0000000..aa1ca46 --- /dev/null +++ b/tests/test_math_extract.js @@ -0,0 +1,123 @@ +// -*- coding: utf-8 -*- +// 公式管线单测:从 app.js 中切出【真实】公式段 + computeSafeLen,在沙箱里执行 +// 运行: node tests/test_math_extract.js +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'ui', 'web', 'app.js'), 'utf8'); + +// ---- 切出公式段(公式渲染 section 起点 → 全局事件委托 section 之前)---- +const secStart = src.indexOf('// ==================== 🌟 公式渲染'); +const secEnd = src.indexOf('// ==================== 全局事件委托'); +if (secStart < 0 || secEnd < 0 || secEnd <= secStart) { + console.error('FAIL 无法切出公式段 (start=%d end=%d)', secStart, secEnd); + process.exit(1); +} +let mathCode = src.slice(secStart, secEnd); + +// ---- 切出 computeSafeLen 函数(行首锚定,避开文档注释里的同名行)---- +var _m = src.match(/^function computeSafeLen\(s, stableLen\) \{$/m); +if (!_m) { console.error('FAIL 无法切出 computeSafeLen'); process.exit(1); } +var csStart = _m.index; // 注意:indexOf 会命中文档注释里的同名行,必须用正则的 index +const csEnd = src.indexOf('// 初始化/获取某个容器 div 的增量渲染状态'); +if (csStart < 0 || csEnd < 0) { console.error('FAIL 无法切出 computeSafeLen'); process.exit(1); } +mathCode += '\n' + src.slice(csStart, csEnd); + +// ---- 沙箱 ---- +const sandbox = { + console, + escapeHtml: (t) => String(t).replace(/&/g, '&').replace(//g, '>'), + katex: { renderToString: (tex, opts) => '' + tex + '' }, + marked: { parse: (t) => '' + String(t), setOptions() {}, bind: null }, +}; +sandbox.marked.parse.bind = function () { return sandbox.marked.parse; }; +vm.createContext(sandbox); +vm.runInContext(mathCode, sandbox, { filename: 'app.js-math-section' }); + +const extractMath = sandbox.extractMath; +const restoreMath = sandbox.restoreMath; +const findUnclosedMathFrom = sandbox.findUnclosedMathFrom; +const computeSafeLen = sandbox.computeSafeLen; + +let pass = 0, fail = 0; +function check(name, cond, extra) { + if (cond) { pass++; console.log('PASS ' + name); } + else { fail++; console.log('FAIL ' + name + (extra !== undefined ? ' | ' + extra : '')); } +} +function itemsOf(text) { return extractMath(text).items; } + +// ============ 1) 用户原始样本(部分供应商单括号格式)============ +const userSample = '上面是推导结果:\n[\nP_4=\\operatorname{BRF}(M_4,M_5)\n]\n\n[\nP_3=\\operatorname{BRF}(S_3,P_4)\n]\n\n其中:\n[\n\\operatorname{BRF}(L,H)\nL+\n\\Gamma(L,\\operatorname{Up}(H))\n\\odot\n\\Phi(\\operatorname{Up}(H))\n]\n'; +{ + const ex = extractMath(userSample); + check('用户样本: 抽出 3 个块公式', ex.items.length === 3, 'got=' + ex.items.length); + check('用户样本: 全是 display', ex.items.every(i => i.display)); + check('用户样本: 占位符已就位', /@@K[BI]MA/.test(ex.md)); + check('用户样本: 无残留括号([ ] 已吞)', !/[\[\]]/.test(ex.md), JSON.stringify(ex.md)); + const html = restoreMath(ex.md, ex.items); + check('用户样本: 恢复出 3 个 KATEX 块', (html.match(//g) || []).length === 3); + check('用户样本: tex 内容正确', ex.items[0].tex.includes('P_4=\\operatorname{BRF}(M_4,M_5)')); +} + +// ============ 2) 标准定界符 ============ +check('$$ 块', itemsOf('a $$x^2+y^2$$ b').length === 1); +check('$$ 块 display', itemsOf('$$x$$')[0].display === true); +check('\\[ \\] 块', itemsOf('\\[E=mc^2\\]').length === 1); +check('行内 $x^2$', itemsOf('能量 $E=mc^2$ 著名').length === 1); +check('行内非 display', itemsOf('$x^2$')[0].display === false); +check('\\( \\) 行内', itemsOf('\\(a+b\\)').length === 1); +check('多公式同段', itemsOf('$a^2$ 和 $$b^2$$ 及 $c$').length === 3); + +// ============ 3) 防误伤 ============ +check('编号 [1] 不抽', itemsOf('见[1]参考').length === 0); +check('链接 [text](url) 不抽', itemsOf('[text](http://a.b) 和 [\nfoo\n](x)').length === 0); +check('列表 [a, b] 不抽', itemsOf('[a, b] 数组').length === 0); +check('货币 $1,000 and $2,000 不抽', itemsOf('价格 $1,000 and $2,000').length === 0); +check('单词 $a$ 按变量公式(有意取舍)', itemsOf('花 $a$ 朵').length === 1); +check('多词 $a b$ 不抽', itemsOf('这 $a b$ 个').length === 0); +check('未闭合 $x 不抽', itemsOf('价格 $x 未闭合').length === 0); +check('代码块内 $y$ [0] 不抽', itemsOf('```\nx = $y$\narr[0]\n```').length === 0); +check('行内代码 `$z$` 不抽', itemsOf('使用 `$z$` 变量').length === 0); +check('未闭合围栏内不抽', itemsOf('```\n$w$ 流式中').length === 0); + +// ============ 4) 混合定位 ============ +{ + const ex = extractMath('前文\n\n$$\na=b\n$$\n\n后文 $c$ 尾'); + check('混合: 2 公式', ex.items.length === 2); + check('混合: 占位符顺序', ex.md.indexOf('@@KBMA0@@') < ex.md.indexOf('@@KMIA1@@')); +} + +// ============ 5) findUnclosedMathFrom ============ +check('无公式 → 0', findUnclosedMathFrom('hello\nworld\n') === 0); +check('未闭合 $$ → 位置', findUnclosedMathFrom('a\n$$x+y\n') === 2); +check('闭合 $$ → 0', findUnclosedMathFrom('a\n$$x+y\n$$\n') === 0); +check('未闭合 [ 行 → 位置', findUnclosedMathFrom('a\n[\nx=1\n') === 2); +check('闭合 [..] → 0', findUnclosedMathFrom('a\n[\nx=1\n]\n') === 0); +check('链接 [t](u) 不算开 → 0', findUnclosedMathFrom('a\n[t](u)\n') === 0); +check('行内未闭合 $ → 位置', findUnclosedMathFrom('cost $5 plus') === 5); +check('代码围栏内忽略', findUnclosedMathFrom('```\n$$x\n```\n') === 0); + +// ============ 6) computeSafeLen 公式感知回退 ============ +{ + // “para1\n\n” = 7 字符(5 + 两个换行)。未闭合 [ 块在边界之后 → 稳定区止于 7 + const s1 = 'para1\n\n[\nx=1\n'; + check('流式: 未闭合 [ 前缀止于空行', computeSafeLen(s1, 0) === 7, 'got=' + computeSafeLen(s1, 0)); + check('流式: 增量无进展返回 0', computeSafeLen(s1, 7) === 0); + // 闭合后 → 整个块可固化 + const s2 = 'para1\n\n[\nx=1\n]\n\n'; + check('流式: 闭合后可固化全部', computeSafeLen(s2, 0) === s2.length, 'got=' + computeSafeLen(s2, 0)); + // 关键回退:块边界(空行)在 未闭合 $$ 之后 → 必须回退到 $$ 起点 + const s3 = 'a b\n\n$$x +\ny z\n\n'; + check('流式: 未闭合 $$ 触发回退到起点', computeSafeLen(s3, 0) === 5, 'got=' + computeSafeLen(s3, 0)); + // 闭合 $$ 后不再回退 + const s3b = 'a b\n\n$$x +\ny z$$\n\n'; + check('流式: 闭合 $$ 可固化全部', computeSafeLen(s3b, 0) === s3b.length, 'got=' + computeSafeLen(s3b, 0)); + // 链接不触发回退(candidate 到链接后的空行 15;若误判未闭合会回退到 7) + const s4 = 'para1\n\n[t](u)\n\ndone\n'; + check('流式: 链接不触发回退', computeSafeLen(s4, 0) === 15, 'got=' + computeSafeLen(s4, 0)); +} + +console.log('\n===== ' + pass + ' passed, ' + fail + ' failed ====='); +process.exit(fail ? 1 : 0); diff --git a/tests/test_pdf_reader.py b/tests/test_pdf_reader.py new file mode 100644 index 0000000..425935c --- /dev/null +++ b/tests/test_pdf_reader.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +"""tools/builtin_tools/pdf_reader.py 的单元测试。 + +用 PyMuPDF 现场生成含文本与内嵌图片的测试 PDF,验证文本结构化提取、 +图片提取落地,以及异常分支(文件不存在 / 超过大小上限)。 + +需在装有 PyMuPDF 的 haocode 环境运行:: + + python -m unittest discover tests +""" +import os +import sys +import tempfile +import unittest + +# 让 tests 目录能 import 到项目根目录下的 tools 包 +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +try: + import pymupdf +except ImportError: # 兼容旧版导入名 + import fitz as pymupdf # type: ignore + +from tools.builtin_tools.pdf_reader import ( + extract_pdf_text, + extract_pdf_images, +) + + +def _make_png_bytes() -> bytes: + """生成一张 8x8 红色小图的 PNG 字节流。""" + pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 8, 8), 0) + pix.set_rect(pix.irect, (255, 0, 0)) + data = pix.tobytes("png") + pix = None + return data + + +def _build_sample_pdf(path: str) -> None: + """造一个 2 页 PDF:第 1 页含文本 + 图片,第 2 页仅文本。""" + doc = pymupdf.open() + p1 = doc.new_page() + p1.insert_text((72, 72), "Hello PDF page one") + p1.insert_image(pymupdf.Rect(72, 100, 172, 200), stream=_make_png_bytes()) + p2 = doc.new_page() + p2.insert_text((72, 72), "Second page text here") + doc.save(path) + doc.close() + + +class PdfReaderTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = self.tmp.name + self.pdf = os.path.join(self.dir, "sample.pdf") + _build_sample_pdf(self.pdf) + + def test_extract_text_structure(self): + text, pages = extract_pdf_text(self.pdf) + self.assertEqual(pages, 2) + self.assertIn("[第 1 页]", text) + self.assertIn("[第 2 页]", text) + self.assertIn("Hello PDF page one", text) + self.assertIn("Second page text here", text) + + def test_extract_images(self): + out_dir = os.path.join(self.dir, "imgs") + imgs = extract_pdf_images(self.pdf, out_dir) + self.assertGreaterEqual(len(imgs), 1) + im = imgs[0] + for key in ("page", "index", "abs_path", "mime", "size_kb", "width", "height"): + self.assertIn(key, im) + self.assertEqual(im["page"], 1) + self.assertEqual(im["mime"], "image/png") + self.assertTrue(os.path.isfile(im["abs_path"])) + self.assertGreater(os.path.getsize(im["abs_path"]), 0) + + def test_missing_file(self): + with self.assertRaises(ValueError): + extract_pdf_text(os.path.join(self.dir, "nope.pdf")) + + def test_oversize(self): + # 用一个极小的上限触发超大分支 + with self.assertRaises(ValueError): + extract_pdf_text(self.pdf, max_bytes=10) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_render_window.js b/tests/test_render_window.js new file mode 100644 index 0000000..1305a4a --- /dev/null +++ b/tests/test_render_window.js @@ -0,0 +1,347 @@ +// -*- coding: utf-8 -*- +// P1-01 渲染窗口状态机单测(DOM 无关,直接加载 ui/web/render_window.js,无 JSDOM/npm) +// 运行: node tests/test_render_window.js +'use strict'; +const path = require('path'); +const RW = require(path.join(__dirname, '..', 'ui', 'web', 'render_window.js')); + +let pass = 0, fail = 0; +function check(name, cond, extra) { + if (cond) { pass++; console.log('PASS ' + name); } + else { fail++; console.log('FAIL ' + name + (extra !== undefined ? ' | ' + extra : '')); } +} + +// ---------- 夹具 ---------- +function makeChain(n) { + var out = []; + for (var i = 0; i < n; i++) { + out.push({ id: 'm' + i, role: i % 2 === 0 ? 'user' : 'assistant', + content: 'msg ' + i, created_at: 1000 + i }); + } + return out; +} +// 模拟 Python 页载荷:按 (方向 + 边界消息 ID) 返回 [{id, chainIndex}] + 链长 +function pageFor(chain, st, req) { + var bi = chain.findIndex(function (d) { return d.id === req.boundaryId; }); + if (bi < 0) return null; + var start, items; + if (req.direction === 'older') { + start = Math.max(0, bi - st.size); + items = chain.slice(start, bi); + } else { + start = bi + 1; + items = chain.slice(bi + 1, bi + 1 + st.size); + } + return { + sessionId: st.sessionId, generation: st.generation, + boundaryId: req.boundaryId, direction: req.direction, + chainLen: chain.length, + items: items.map(function (d, k) { return { id: d.id, chainIndex: start + k }; }) + }; +} +function initChain(st, chain) { + var n = Math.min(st.size, chain.length); + var items = chain.slice(chain.length - n); + return RW.initFullChain(st, { + sessionId: 'sess-1', generation: 1, + chainLen: chain.length, + items: items.map(function (d, k) { return { id: d.id, chainIndex: chain.length - n + k }; }) + }); +} +function idsOf(st) { return st.order.slice(); } +function chainIds(chain) { return chain.map(function (d) { return d.id; }); } + +// ============ 1) 配置规范化 ============ +{ + var c; + c = RW.normalizeConfig({}); + check('size 缺失 → 40 / mode 缺失 → auto', c.size === 40 && c.mode === 'auto'); + c = RW.normalizeConfig(undefined); + check('配置 undefined → {auto,40}', c.size === 40 && c.mode === 'auto'); + c = RW.normalizeConfig({ render_window_size: true }); + check('size=true(布尔)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: false }); + check('size=false(布尔)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: '40' }); + check('size="40"(字符串)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: 0 }); + check('size=0 → 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: -5 }); + check('size=-5 → 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: 9 }); + check('size=9(下越界)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: 201 }); + check('size=201(上越界)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: 40.5 }); + check('size=40.5(小数)→ 40', c.size === 40); + c = RW.normalizeConfig({ render_window_size: 10 }); + check('size=10 保留', c.size === 10); + c = RW.normalizeConfig({ render_window_size: 200 }); + check('size=200 保留', c.size === 200); + c = RW.normalizeConfig({ render_window_mode: 'manual' }); + check('mode=manual 保留', c.mode === 'manual'); + c = RW.normalizeConfig({ render_window_mode: 'AUTO' }); + check('mode=AUTO(大小写不符)→ auto', c.mode === 'auto'); + c = RW.normalizeConfig({ render_window_mode: 1 }); + check('mode=1 → auto', c.mode === 'auto'); + c = RW.normalizeConfig({ render_window_mode: null, render_window_size: 10 }); + check('mode=null + size=10 → {auto,10}', c.mode === 'auto' && c.size === 10); +} + +// ============ 2) auto/manual × 10/40/200 参数化初始窗口 ============ +[10, 40, 200].forEach(function (size) { + ['auto', 'manual'].forEach(function (mode) { + var st = RW.create({ render_window_mode: mode, render_window_size: size }); + var chain = makeChain(400); + initChain(st, chain); + var tail = chainIds(chain).slice(400 - size); + check('初始窗口 [' + mode + '/' + size + '] 长度=size', st.order.length === size); + check('初始窗口 [' + mode + '/' + size + '] = 最新 size 条', + JSON.stringify(idsOf(st)) === JSON.stringify(tail)); + check('初始窗口 [' + mode + '/' + size + '] hasMoreOlder', st.hasMoreOlder === true); + check('初始窗口 [' + mode + '/' + size + '] hiddenOlder=' + (400 - size), + st.hiddenOlder === 400 - size); + check('初始窗口 [' + mode + '/' + size + '] hiddenNewer=0', st.hiddenNewer === 0); + RW.clear(st); + check('clear 后模式/大小保持 [' + mode + '/' + size + ']', + st.mode === mode && st.size === size); + check('clear 后窗口/缓存/计数清空', st.order.length === 0 && + Object.keys(st.indexById).length === 0 && + st.hiddenOlder === 0 && st.hiddenNewer === 0); + }); +}); + +// ============ 3) 双向连续换页(size=10,400 条链)============ +{ + var st = RW.create({ render_window_size: 10 }); + var chain = makeChain(400); + initChain(st, chain); + + // ---- 连续向上,直到头部边界 ---- + var pages = 0; + while (RW.canRequest(st, 'older')) { + var req = RW.beginRequest(st, 'older'); + check('向上换页边界=窗口最旧条 p' + pages, + req && req.boundaryId === st.order[0]); + var res = RW.applyPage(st, req, pageFor(chain, st, req)); + check('向上 p' + pages + ' 非 stale', res.stale !== true); + check('向上 p' + pages + ' 窗口≤size', st.order.length <= 10, 'len=' + st.order.length); + check('向上 p' + pages + ' 加一端裁另一端', res.removedIds.length === 10, + 'removed=' + res.removedIds.length); + check('向上 p' + pages + ' 加入数=10', res.addedIds.length === 10); + // 顺序性:窗口内 id 在链中连续且递增 + var idx = chainIds(chain).indexOf(st.order[0]); + var okSeq = true; + for (var i = 1; i < st.order.length; i++) { + if (chainIds(chain).indexOf(st.order[i]) !== idx + i) { okSeq = false; break; } + } + check('向上 p' + pages + ' 窗口连续无重复', okSeq); + pages++; + if (pages > 50) { check('向上换页终止', false, '死循环'); break; } + } + check('向上换页到达头部边界', !st.hasMoreOlder && !RW.canRequest(st, 'older')); + check('头部窗口=链首 10 条', JSON.stringify(idsOf(st)) === JSON.stringify( + chainIds(chain).slice(0, 10))); + check('头部 hiddenOlder=0', st.hiddenOlder === 0); + check('头部 hiddenNewer=390', st.hiddenNewer === 390); + check('向上总换页数=39(窗口 10,400 条)', pages === 39, 'pages=' + pages); + + // ---- 连续向下,直到尾部边界 ---- + var down = 0; + while (RW.canRequest(st, 'newer')) { + var req2 = RW.beginRequest(st, 'newer'); + var res2 = RW.applyPage(st, req2, pageFor(chain, st, req2)); + check('向下 p' + down + ' 非 stale', res2.stale !== true); + check('向下 p' + down + ' 窗口≤size', st.order.length <= 10); + down++; + if (down > 50) { check('向下换页终止', false, '死循环'); break; } + } + check('向下换页到达尾部边界', !st.hasMoreNewer && !RW.canRequest(st, 'newer')); + check('尾部窗口=链尾 10 条', JSON.stringify(idsOf(st)) === JSON.stringify( + chainIds(chain).slice(390))); + check('向下总换页数=39', down === 39, 'down=' + down); + check('往返后窗口无重复', new Set(idsOf(st)).size === idsOf(st).length); +} + +// ============ 4) 短链边界(链 ≤ size)============ +{ + var st = RW.create({ render_window_size: 40 }); + var chain = makeChain(7); + initChain(st, chain); + check('短链: 全链入窗', st.order.length === 7); + check('短链: 无向上/向下', !st.hasMoreOlder && !st.hasMoreNewer); + check('短链: 不可请求', !RW.canRequest(st, 'older') && !RW.canRequest(st, 'newer')); +} + +// ============ 5) 过期响应 ============ +{ + var st = RW.create({ render_window_size: 10 }); + var chain = makeChain(60); + initChain(st, chain); + + // 5a) 切会话(sessionId 变化) + var req = RW.beginRequest(st, 'older'); + var payload = pageFor(chain, st, req); + st.sessionId = 'sess-2'; // 模拟切会话(未走 clear 的代次变化也需被拒) + var r = RW.applyPage(st, req, payload); + check('切会话后旧响应 → stale', r.stale === true); + check('stale 后 pending 清空', st.pending === null); + + // 5b) clear 递增代次 → 旧载荷(旧代次)失效 + initChain(st, chain); + req = RW.beginRequest(st, 'older'); + payload = pageFor(chain, st, req); + RW.clear(st); + r = RW.applyPage(st, req, payload); + check('clear 后旧代次响应 → stale', r.stale === true); + + // 5c) 边界不匹配(竞态:窗口已变化才到达的旧页) + initChain(st, chain); + req = RW.beginRequest(st, 'older'); + var reqB = RW.beginRequest(st, 'older'); + check('pending 时不可重复请求', reqB === null); + var bogus = { sessionId: st.sessionId, generation: st.generation, + boundaryId: 'm0', direction: 'older', chainLen: chain.length, items: [] }; + r = RW.applyPage(st, { direction: 'older', boundaryId: 'm0' }, bogus); + check('边界不匹配 → stale', r.stale === true); + + // 5d) 同一页重复投递(幂等) + req = RW.beginRequest(st, 'older'); + var p1 = pageFor(chain, st, req); + r = RW.applyPage(st, req, p1); + check('正常页投递成功', r.stale !== true); + r = RW.applyPage(st, req, p1); + check('同页重复投递 → stale(pending 已消费)', r.stale === true); +} + +// ============ 6) 活动流:计入上限、永不裁剪 ============ +{ + var st = RW.create({ render_window_size: 40 }); + var chain = makeChain(600); + initChain(st, chain); + var streamId = st.order[st.order.length - 1]; // 最新条即活动流 + RW.noteStream(st, streamId); + + // 默认 size=40 + 1 条流 → 最多另外 39 条 + var req = RW.beginRequest(st, 'older'); + var res = RW.applyPage(st, req, pageFor(chain, st, req)); + check('流在窗时向上换页非 stale', res.stale !== true); + check('流在窗时窗口恒≤size', st.order.length <= 40, 'len=' + st.order.length); + check('流在窗时窗口含流', st.order.indexOf(streamId) >= 0); + var others = st.order.filter(function (id) { return id !== streamId; }).length; + check('流在窗时另外 ≤39 条', others <= 39, 'others=' + others); + check('流未被裁剪(removed 不含流)', res.removedIds.indexOf(streamId) < 0); + + // 继续向上:流始终存活 + for (var i = 0; i < 4 && RW.canRequest(st, 'older'); i++) { + var rq = RW.beginRequest(st, 'older'); + var rs = RW.applyPage(st, rq, pageFor(chain, st, rq)); + check('流持续存活 p' + i, st.order.indexOf(streamId) >= 0 && + rs.removedIds.indexOf(streamId) < 0); + } + + // 流结束后不再受保护 + RW.streamFinished(st, streamId); + var rq2 = RW.beginRequest(st, 'older'); + var rs2 = RW.applyPage(st, rq2, pageFor(chain, st, rq2)); + check('流结束后可被正常裁剪', rs2.removedIds.indexOf(streamId) >= 0); + + // 流在窗口最旧端 + 向下换页:trimHead 保护(手工构造中部窗口) + var st2 = RW.create({ render_window_size: 10 }); + initChain(st2, chain); + st2.order = ['m140', 'm141', 'm142', 'm143', 'm144', 'm145', 'm146', 'm147', 'm148', 'm149']; + st2.indexById = {}; + for (var k = 0; k < st2.order.length; k++) st2.indexById[st2.order[k]] = 140 + k; + st2.hasMoreNewer = true; // 模拟窗口不在链尾 + st2.hiddenNewer = 100; + RW.noteStream(st2, st2.order[0]); + var rq3 = RW.beginRequest(st2, 'newer'); + var rs3 = RW.applyPage(st2, rq3, pageFor(chain, st2, rq3)); + check('流在最旧端时向下换页仍受保护', st2.order.indexOf(st2.order[0]) >= 0 && + rs3.removedIds.indexOf('m140') < 0); + check('流在最旧端时窗口恒≤size', st2.order.length <= 10); + check('流在最旧端时裁掉 10 条并补新页 10 条', rs3.removedIds.length === 10 && + rs3.addedIds.length === 10); +} + +// ============ 7) clear 语义(保留配置模式/大小)============ +{ + var st = RW.create({ render_window_mode: 'manual', render_window_size: 10 }); + var chain = makeChain(60); + initChain(st, chain); + var req = RW.beginRequest(st, 'older'); + var g0 = st.generation; + RW.clear(st); + check('clear: 代次+1', st.generation === g0 + 1); + check('clear: pending 清空', st.pending === null); + check('clear: 模式/大小保留', st.mode === 'manual' && st.size === 10); + check('clear: 不可请求', !RW.canRequest(st, 'older') && !RW.canRequest(st, 'newer')); +} + +// ============ 8) 锚点几何(纯数学)============ +{ + var entries = [ + { id: 'a', top: 0, height: 100 }, + { id: 'b', top: 100, height: 100 }, + { id: 'c', top: 200, height: 100 } + ]; + var an = RW.computeAnchor(entries, 50, 150); + check('锚点: 首个可见=a, offset=50', an && an.msgId === 'a' && an.offset === 50, + JSON.stringify(an)); + an = RW.computeAnchor(entries, 101, 200); + check('锚点: 首个可见=b, offset=1', an && an.msgId === 'b' && an.offset === 1, + JSON.stringify(an)); + an = RW.computeAnchor(entries, 100, 200); + check('锚点: 视口顶恰在 b 起点, offset=0', an && an.msgId === 'b' && an.offset === 0); + an = RW.computeAnchor(entries, 500, 600); + check('锚点: 无可见 → null', an === null); + check('滚动增量: 350-100=250', RW.scrollDeltaFromRects(100, 350) === 250); + check('滚动增量: 无位移=0', RW.scrollDeltaFromRects(100, 100) === 0); +} + +// ============ 9) 一次换页的原子性(加一端 + 裁另一端 + 计数同步)============ +{ + var st = RW.create({ render_window_size: 10 }); + var chain = makeChain(100); + initChain(st, chain); + var before = st.hiddenOlder; // 90 + var req = RW.beginRequest(st, 'older'); + var res = RW.applyPage(st, req, pageFor(chain, st, req)); + check('加 10 条', res.addedIds.length === 10); + check('裁 10 条', res.removedIds.length === 10); + check('hiddenOlder 递减=加入数', st.hiddenOlder === before - 10, + 'before=' + before + ' now=' + st.hiddenOlder); + check('返回计数与状态一致', res.hiddenOlder === st.hiddenOlder && + res.hiddenNewer === st.hiddenNewer && + res.hasMoreOlder === st.hasMoreOlder && res.hasMoreNewer === st.hasMoreNewer); +} + +// ============ 10) 活动消息追加(noteLive):计入上限、裁旧端 ============ +{ + var st = RW.create({ render_window_size: 10 }); + var chain = makeChain(60); + initChain(st, chain); // 窗口 m50..m59 + var r1 = RW.noteLive(st, 'live-user', -1); // 用户消息(未持久化) + check('noteLive: 追加到较新一端', r1.added === true && + st.order[st.order.length - 1] === 'live-user'); + check('noteLive: 窗口≤size(裁掉最旧 1 条)', st.order.length <= 10 && + st.order.indexOf('m50') < 0); + check('noteLive: 裁掉的 id 同步清除下标', st.indexById['m50'] === undefined); + + RW.noteStream(st, 'live-ai'); + var r2 = RW.noteLive(st, 'live-ai', -1); // 助手占位(活动流) + check('noteLive: 流占位入窗', r2.added === true && st.activeStreamId === 'live-ai'); + check('noteLive: 流计入上限后仍≤size', st.order.length <= 10); + + var r3 = RW.noteLive(st, 'live-ai', 61); // 持久化后补下标(幂等) + check('noteLive: 重复 id 幂等补下标', r3.added === false && + st.indexById['live-ai'] === 61); + + // hidden 计数:trimHead 两次后窗口最旧=m52;链尾未知条计入 hiddenNewer(截断≥0) + check('noteLive 后 hiddenOlder=52', st.hiddenOlder === 52, 'got=' + st.hiddenOlder); + check('noteLive 后 hiddenNewer=0(链尾无已持久化更新消息)', st.hiddenNewer === 0 && + st.hasMoreNewer === false, 'got=' + st.hiddenNewer); +} + +console.log('\n===== ' + pass + ' passed, ' + fail + ' failed ====='); +process.exit(fail ? 1 : 0); diff --git a/tests/test_renderer_matrix.py b/tests/test_renderer_matrix.py new file mode 100644 index 0000000..bfe5221 --- /dev/null +++ b/tests/test_renderer_matrix.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +""" +P1-03 渲染器矩阵单测(平台模拟 + 真实进程级,无 UI 依赖,不启动真实 LLM) + +覆盖: + R1 resolve_backend 平台矩阵:Windows/Linux × auto/webview2/qtwebengine/非法值 + (非法值 → 可见警告 + 平台默认;Linux 请求 webview2 → 警告 + qtwebengine) + R3 Linux 模拟导入链(子进程 sys.platform='linux' 导入 ui.views.main_window): + _wv2mod 为 None,且 core.webview2 / clr / clr_loader 未进入 sys.modules + R4 两个 QtWebEngine 实例并行(2 个 offscreen 子进程同时跑): + 各自独立 profile 载入本地页面并关闭,无 profile 锁争用、无互相清理 + R5 Chromium sandbox 标志契约:普通桌面剥离 --no-sandbox + 告警; + root/容器 + 显式 --no-sandbox → 保留 + 高可见警告;root/容器未设 → 提示 + +运行: PYTHONIOENCODING=utf-8 python tests/test_renderer_matrix.py +""" +import contextlib +import io +import os +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PY = sys.executable +IS_WIN = os.name == "nt" + +from core import renderer_backend as rb # noqa: E402 + +RESULTS = [] + + +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + + +# ====================================================================== +# R1. resolve_backend 平台矩阵(mock 平台,纯逻辑) +# ====================================================================== +_orig_is_win = rb.is_windows + + +def _mock(is_win: bool): + rb.is_windows = lambda: is_win + + +try: + _mock(True) + b, w = rb.resolve_backend("auto") + check("R1.1 Win auto → webview2 无警告", b == "webview2" and w is None, f"{b} {w!r}") + b, w = rb.resolve_backend("webview2") + check("R1.2 Win webview2 → webview2", b == "webview2" and w is None, f"{b} {w!r}") + b, w = rb.resolve_backend("qtwebengine") + check("R1.3 Win qtwebengine → qtwebengine(强制回落)", b == "qtwebengine" and w is None, + f"{b} {w!r}") + b, w = rb.resolve_backend("gecko") + check("R1.4 Win 非法值 → webview2 + 可见警告", + b == "webview2" and w and "非法" in w, f"{b} {w!r}") + b, w = rb.resolve_backend(123) + check("R1.5 Win 非字符串 → webview2 + 警告", b == "webview2" and w is not None, + f"{b} {w!r}") + b, w = rb.resolve_backend(" QTWebEngine ") + check("R1.6 大小写/空白容差 → qtwebengine", b == "qtwebengine" and w is None, + f"{b} {w!r}") + + _mock(False) + b, w = rb.resolve_backend("auto") + check("R1.7 Linux auto → qtwebengine 无警告", b == "qtwebengine" and w is None, + f"{b} {w!r}") + b, w = rb.resolve_backend("webview2") + check("R1.8 Linux webview2 → qtwebengine + 警告", + b == "qtwebengine" and w and "webview2" in w, f"{b} {w!r}") + b, w = rb.resolve_backend("qtwebengine") + check("R1.9 Linux qtwebengine → qtwebengine", b == "qtwebengine" and w is None, + f"{b} {w!r}") + b, w = rb.resolve_backend("gecko") + check("R1.10 Linux 非法值 → qtwebengine + 警告", + b == "qtwebengine" and w and "非法" in w, f"{b} {w!r}") +finally: + rb.is_windows = _orig_is_win + +# webview2_module 门控(同进程 mock) +try: + _mock(False) + check("R1.11 非 Windows:webview2_module() 返回 None 且不导入", + rb.webview2_module() is None) +finally: + rb.is_windows = _orig_is_win + +# ====================================================================== +# R5. Chromium sandbox 标志契约(mock root/容器探测) +# ====================================================================== +_orig_rootc = rb.is_root_or_container + + +def _run_sanitize(flags, rootc): + rb.is_root_or_container = lambda: rootc + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + out = rb.sanitize_chromium_flags(flags, verbose=True) + return out, buf.getvalue() + + +try: + out, log = _run_sanitize("--disable-gpu", False) + check("R5.1 普通桌面:无 --no-sandbox 原样返回、无告警", + out == "--disable-gpu" and "no-sandbox" not in log, f"{out!r} {log!r}") + + out, log = _run_sanitize("--disable-gpu --no-sandbox", False) + check("R5.2 普通桌面:剥离 --no-sandbox + 告警", + out == "--disable-gpu" and "--no-sandbox" in log, f"{out!r} {log!r}") + + out, log = _run_sanitize("--disable-gpu --no-sandbox", True) + check("R5.3 root/容器 + 显式:保留 --no-sandbox + 高可见警告", + out == "--disable-gpu --no-sandbox" and "高可见警告" in log, f"{out!r} {log!r}") + + out, log = _run_sanitize("--disable-gpu", True) + check("R5.4 root/容器未设:原样返回 + 提示显式设置", + out == "--disable-gpu" and "--no-sandbox" in log and "高可见警告" not in log, + f"{out!r} {log!r}") +finally: + rb.is_root_or_container = _orig_rootc + +# ====================================================================== +# R3. Linux 模拟导入链(子进程:sys.platform='linux' 后 import main_window) +# ====================================================================== +r3_code = ( + "import os, sys;" + f"sys.path.insert(0, {ROOT!r});" + "os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen');" + "os.environ['QTWEBENGINE_CHROMIUM_FLAGS'] = '--disable-gpu';" + "sys.platform = 'linux';" # 模拟非 Windows(导入门控按 sys.platform 判断) + "import ui.views.main_window as m;" + "bad = sorted(k for k in ('core.webview2', 'clr', 'clr_loader') if k in sys.modules);" + "ok = m._wv2mod is None and not bad;" + "print('R3_OK' if ok else f'R3_FAIL {bad!r}')" +) +r3 = subprocess.run([PY, "-c", r3_code], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=180) +check("R3.1 Linux 模拟:main_window 导入成功且 _wv2mod 为 None、未导入 webview2/clr", + "R3_OK" in (r3.stdout or ""), + f"rc={r3.returncode} out={(r3.stdout or '')[-200:]!r} err={(r3.stderr or '')[-300:]!r}") + +# ====================================================================== +# R4. 两个 QtWebEngine 实例并行(独立 profile,offscreen) +# ====================================================================== +_r4_base = tempfile.mkdtemp(prefix="haocode_p103_profiles_") +_worker = os.path.join(_r4_base, "qtwe_worker.py") +_worker_code = f'''# -*- coding: utf-8 -*- +import os, sys +sys.path.insert(0, {ROOT!r}) +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import QTimer, QUrl +from PyQt6.QtWebEngineCore import QWebEngineProfile +from PyQt6.QtWebEngineWidgets import QWebEngineView +from ui.views.custom_web_page import CustomWebPage +from core import renderer_backend as rbr + +app = QApplication(sys.argv) +pid = os.getpid() +name = f"parallel_{{pid}}" +pdir = rbr.webengine_profile_dir(name) +prof = QWebEngineProfile(name) +prof.setPersistentStoragePath(os.path.join(pdir, "storage")) +prof.setCachePath(os.path.join(pdir, "cache")) + +html = os.path.join(os.path.dirname(pdir), f"page_{{pid}}.html") +with open(html, "w", encoding="utf-8") as f: + f.write(f"ok {{pid}}") + +view = QWebEngineView() +view.setPage(CustomWebPage(prof, view)) +view.resize(800, 600) +view.show() + +st = {{"ok": False}} +fired = {{"v": False}} + +def finish(): + if fired["v"]: return + fired["v"] = True + print(f"WORKER_OK pid={{pid}} loaded={{st['ok']}} profile={{pdir}}", flush=True) + app.quit() + +view.page().loadFinished.connect(lambda o: (st.__setitem__("ok", bool(o)), finish())) +view.load(QUrl.fromLocalFile(html)) + +QTimer.singleShot(30000, finish) # 兜底显式时间预算(30s) +app.exec() +sys.exit(0 if st["ok"] else 1) +''' +with open(_worker, "w", encoding="utf-8") as f: + f.write(_worker_code) + +_r4_env = {**os.environ, + "QT_QPA_PLATFORM": "offscreen", + "QTWEBENGINE_CHROMIUM_FLAGS": "--disable-gpu", + "HAOCODE_WEBENGINE_PROFILE_DIR": _r4_base, + "PYTHONIOENCODING": "utf-8"} + +procs = [] +try: + for _ in range(2): + procs.append(subprocess.Popen([PY, _worker], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, + encoding="utf-8", errors="replace", env=_r4_env)) + outs = [] + for p in procs: + try: + out, err = p.communicate(timeout=90) + outs.append((p.returncode, out, err)) + except subprocess.TimeoutExpired: + p.kill() + outs.append((None, "", "TIMEOUT")) + oks = [rc == 0 and "WORKER_OK" in out for rc, out, err in outs] + check("R4.1 两个 QtWebEngine 实例并行:都成功载入本地页面", + all(oks) and len({out for _, out, _ in outs if out}) == 2, + repr([(rc, (out or '')[-120:], (err or '')[-200:]) for rc, out, err in outs])) + # 两个不同 profile 目录、关闭后目录仍在(无互相清理) + prof_dirs = [] + for rc, out, err in outs: + for line in (out or "").splitlines(): + if line.startswith("WORKER_OK"): + prof_dirs.append(line.rsplit("profile=", 1)[1].strip()) + check("R4.2 两个实例 profile 目录不同(独立,不争用)", + len(prof_dirs) == 2 and prof_dirs[0] != prof_dirs[1], repr(prof_dirs)) + check("R4.3 关闭后 profile 目录仍存在(无互相清理)", + all(os.path.isdir(d) for d in prof_dirs), repr(prof_dirs)) +finally: + for p in procs: + try: + if p.poll() is None: + p.kill() + except Exception: + pass + +# ====================================================================== +print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True) +sys.exit(0 if all(ok for _, ok in RESULTS) else 1) diff --git a/tests/test_screen_capture_platforms.py b/tests/test_screen_capture_platforms.py new file mode 100644 index 0000000..12d8c8a --- /dev/null +++ b/tests/test_screen_capture_platforms.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- +""" +P1-04 平台截图能力单测(portal 用替身 subprocess,不依赖真实 portal / compositor) + +覆盖: + C1 capture_plan 路由(win32/x11 → overlay;wayland → portal;unknown → unavailable+明确日志) + C2 detect_portal(非 Linux / 无 D-Bus 会话 / 无 gdbus / 齐备 → 四分支) + C3 portal_screenshot_sync 成功路径(fake subprocess:Screenshot 返回解析 + FilePicked + file:// URI 解码(含空格文件名)→ 现有附件流程可用的真实文件路径;校验 gdbus 命令行) + C4 用户取消/授权被拒(只有 Finished 无 FilePicked → denied) + C5 portal 缺失(call rc≠0 NotSupported → "portal 不可用" + 原因) + C6 等待超时(显式预算内无信号 → timeout,不无限挂起) + C7 PortalScreenshotWorker 信号回主线程(done(ok, path)) + C8 ScreenCaptureOverlay offscreen 构造 + start() 空画面守卫不崩 + +运行: PYTHONIOENCODING=utf-8 python tests/test_screen_capture_platforms.py +""" +import contextlib +import io +import json +import os +import shutil +import sys +import tempfile +import time +import types +import urllib.parse + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtWidgets import QApplication # noqa: E402 + +from ui.views.system_tools import desktop_session as ds # noqa: E402 +from ui.views.system_tools import portal_capture as pc # noqa: E402 +from ui.views.system_tools import screen_capture as sc # noqa: E402 + +RESULTS = [] +APP = QApplication.instance() or QApplication(sys.argv) + + +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + + +# ====================================================================== +# C1. capture_plan 路由 +# ====================================================================== +mode, msg = ds.capture_plan("win32") +check("C1.1 win32 → overlay", mode == "overlay" and msg is None, repr((mode, msg))) +mode, msg = ds.capture_plan("x11") +check("C1.2 x11 → overlay(原生 grabWindow 路径)", mode == "overlay" and msg is None, + repr((mode, msg))) +mode, msg = ds.capture_plan("wayland") +check("C1.3 wayland → portal(不绕过 compositor)", mode == "portal" and msg is None, + repr((mode, msg))) +mode, msg = ds.capture_plan("unknown") +check("C1.4 unknown → unavailable + 明确日志(主程序其余功能不受影响)", + mode == "unavailable" and msg and "不可用" in msg, repr((mode, msg))) + +# ====================================================================== +# C2. detect_portal 四分支 +# ====================================================================== +_orig_sys = pc.sys +_orig_which = pc.shutil.which + +try: + pc.sys = types.SimpleNamespace(platform="win32") + ok, reason = pc.detect_portal() + check("C2.1 非 Linux → 不可用", ok is False and reason, repr((ok, reason))) + + pc.sys = types.SimpleNamespace(platform="linux") + saved = {k: os.environ.get(k) for k in ("XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS")} + for k in saved: + os.environ.pop(k, None) + ok, reason = pc.detect_portal() + check("C2.2 无 D-Bus 会话 → 不可用(明确原因)", + ok is False and "D-Bus" in reason, repr((ok, reason))) + os.environ["XDG_RUNTIME_DIR"] = "/run/user/1000" + pc.shutil.which = lambda name: None + ok, reason = pc.detect_portal() + check("C2.3 无 gdbus CLI → 不可用(明确原因)", + ok is False and "gdbus" in reason, repr((ok, reason))) + pc.shutil.which = lambda name: "/usr/bin/gdbus" + ok, reason = pc.detect_portal() + check("C2.4 齐备 → 可用", ok is True and reason is None, repr((ok, reason))) +finally: + pc.sys = _orig_sys + pc.shutil.which = _orig_which + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +# ====================================================================== +# C3–C7. portal_screenshot_sync(fake subprocess,模拟 Linux 环境) +# ====================================================================== +_linux_sys = types.SimpleNamespace(platform="linux") + + +class FakeRun: + """替身 subprocess.run:记录 argv,返回预先设定的 call 输出""" + def __init__(self, rc=0, stdout="", stderr=""): + self.rc = rc + self.stdout = stdout + self.stderr = stderr + self.cmds = [] + + def __call__(self, cmd, **kw): + self.cmds.append(list(cmd)) + p = types.SimpleNamespace() + p.returncode = self.rc + p.stdout = self.stdout + p.stderr = self.stderr + return p + + +class FakePipe: + """替身 monitor 的 stdout:按序输出 JSON 行;readline 可模拟阻塞""" + def __init__(self, lines, delay_s=0.0): + self._lines = list(lines) + self._delay = delay_s + self.closed = False + + def readline(self): + if self._delay: + time.sleep(self._delay) + self._delay = 0 + if self._lines: + return self._lines.pop(0) + "\n" + # 模拟管道关闭(EOF) + return "" + + +class FakePopen: + def __init__(self, lines, delay_s=0.0): + self._lines = lines + self._delay = delay_s + self.cmd = None + self.killed = False + + def __call__(self, cmd, **kw): + self.cmd = list(cmd) + p = types.SimpleNamespace() + p.stdout = FakePipe(self._lines, self._delay) + p.stderr = io.StringIO() + self._rc = None + p.poll = lambda: self._rc + p.kill = lambda: setattr(p, "killed", True) or setattr(self, "killed", True) + p.wait = lambda timeout=None: 0 + return p + + +def _uri(path: str) -> str: + return "file://" + urllib.parse.quote(path) + + +def _json(member, iface, body): + return json.dumps({"interface": iface, "member": member, "body": body}, + ensure_ascii=False) + + +# C3.1 成功路径(含空格文件名 → URI 解码) +tmpdir = tempfile.mkdtemp(prefix="haocode_p104_portal_") +shot = os.path.join(tmpdir, "shot test.png") +with open(shot, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\nfake") + +pc.sys = _linux_sys # C3–C7 模拟 Linux 宿主 +_orig_which2 = pc.shutil.which +pc.shutil.which = lambda name: "/usr/bin/gdbus" # 本机无 gdbus,替身探测 +_saved_xdg = os.environ.get("XDG_RUNTIME_DIR") +os.environ["XDG_RUNTIME_DIR"] = "/run/user/1000" +run_fake = FakeRun(rc=0, stdout="('/org/freedesktop/portal/desktop/request/1000/haocode/7', <>)") +popen_fake = FakePopen([ + _json("FilePicked", "org.freedesktop.portal.FileChooser", [_uri(shot), {}]), + _json("Finished", "org.freedesktop.portal.Request", [0]), +]) +_orig_run, _orig_popen = pc.subprocess.run, pc.subprocess.Popen +pc.subprocess.run = run_fake +pc.subprocess.Popen = popen_fake +try: + t0 = time.time() + ok, result = pc.portal_screenshot_sync("haocode-shot", request_timeout_s=5, wait_budget_s=10) + dt = time.time() - t0 +finally: + pc.subprocess.run = _orig_run + pc.subprocess.Popen = _orig_popen + +check("C3.1 成功:返回真实文件路径(URI 空格解码正确)", + ok is True and result == shot and os.path.exists(result), repr((ok, result))) +check("C3.2 Screenshot gdbus 命令行正确(dest/path/method/parent=/)", + run_fake.cmds and run_fake.cmds[0][:5] == ["gdbus", "call", "--session", "--dest", + pc.PORTAL_DEST] + and "--method=org.freedesktop.portal.Screenshot.Screenshot" in run_fake.cmds[0] + and "/" in run_fake.cmds[0], repr(run_fake.cmds)) +check("C3.3 monitor 监听 request 对象路径", + popen_fake.cmd and popen_fake.cmd[-2:] == ["--object-path", + "/org/freedesktop/portal/desktop/request/1000/haocode/7"], + repr(popen_fake.cmd)) +check("C3.4 全程在显式预算内完成(<10s)", dt < 10, f"{dt:.1f}s") + +# C4. 用户取消/授权被拒(只有 Finished) +popen_fake = FakePopen([_json("Finished", "org.freedesktop.portal.Request", [0])]) +pc.subprocess.run = run_fake +pc.subprocess.Popen = popen_fake +try: + ok, result = pc.portal_screenshot_sync("haocode-shot", 5, 10) +finally: + pc.subprocess.run = _orig_run + pc.subprocess.Popen = _orig_popen +check("C4.1 授权被拒 → 不伪造成功,明确原因", + ok is False and "取消" in result, repr((ok, result))) + +# C5. portal 缺失/方法不支持(call rc≠0) +run_fake2 = FakeRun(rc=1, stderr="Error: org.freedesktop.DBus.Error.NotSupported: " + "Method not supported by portal") +pc.subprocess.run = run_fake2 +pc.subprocess.Popen = FakePopen([]) +try: + ok, result = pc.portal_screenshot_sync("haocode-shot", 5, 10) +finally: + pc.subprocess.run = _orig_run + pc.subprocess.Popen = _orig_popen +check("C5.1 portal 不支持 → 不可用 + 原因透出", + ok is False and "portal 不可用" in result and "NotSupported" in result, repr((ok, result))) + +# C6. 等待超时(monitor 首行前阻塞 > 预算) +popen_fake = FakePopen([_json("Finished", "org.freedesktop.portal.Request", [0])], + delay_s=3.0) +pc.subprocess.run = run_fake +pc.subprocess.Popen = popen_fake +try: + t0 = time.time() + ok, result = pc.portal_screenshot_sync("haocode-shot", 5, 1.0) + dt = time.time() - t0 +finally: + pc.subprocess.run = _orig_run + pc.subprocess.Popen = _orig_popen +check("C6.1 超预算 → timeout(不无限挂起)", + ok is False and "超时" in result and dt < 5, repr((ok, result, f"{dt:.1f}s"))) + +# ====================================================================== +# C7. PortalScreenshotWorker 信号 +# ====================================================================== +popen_fake = FakePopen([ + _json("FilePicked", "org.freedesktop.portal.FileChooser", [_uri(shot), {}]), + _json("Finished", "org.freedesktop.portal.Request", [0]), +]) +pc.subprocess.run = run_fake +pc.subprocess.Popen = popen_fake +try: + w = pc.PortalScreenshotWorker(wait_budget_s=10) + done = {} + w.done.connect(lambda ok, r: done.update(ok=ok, r=r)) + w.start() + deadline = time.time() + 15 + while "ok" not in done and time.time() < deadline: + APP.processEvents() + time.sleep(0.02) + w.wait(3000) +finally: + pc.subprocess.run = _orig_run + pc.subprocess.Popen = _orig_popen + pc.shutil.which = _orig_which2 + if _saved_xdg is None: + os.environ.pop("XDG_RUNTIME_DIR", None) + else: + os.environ["XDG_RUNTIME_DIR"] = _saved_xdg + pc.sys = _orig_sys +check("C7.1 worker done 信号回主线程(ok + 路径)", + done.get("ok") is True and done.get("r") == shot, repr(done)) + +# ====================================================================== +# C8. 覆盖层 offscreen 构造 + start() 空画面守卫 +# ====================================================================== +ov = sc.ScreenCaptureOverlay() +buf = io.StringIO() +with contextlib.redirect_stdout(buf): + ov.start() # offscreen:无屏幕或空画面都不得崩 +shown = ov.isVisible() +check("C8.1 offscreen start() 不崩;空画面时不显示覆盖层且日志明确", + (not shown and "空画面" in buf.getvalue()) or (shown and ov._full_pixmap is not None), + f"shown={shown} log={buf.getvalue()[-160:]!r}") +if shown: + ov.close() + +print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True) +sys.exit(0 if all(ok for _, ok in RESULTS) else 1) diff --git a/tests/test_think_code_neutral.py b/tests/test_think_code_neutral.py new file mode 100644 index 0000000..f8ba920 --- /dev/null +++ b/tests/test_think_code_neutral.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +""" +离屏验证:思考/压缩气泡内代码朴素渲染(去"紫+边框")+ 正文代码块不受影响 +背景:压缩摘要充满代码,finishTimelineMessage 对全部 pre code 做 hljs 高亮后, +.code-block-wrapper 的浅色语法色(#a626a4 紫)+ 1px 边框 + 语言栏让定格后的 +压缩气泡观感"紫色带边框"。修复:.think-content 作用域内代码统一朴素灰、 +无边框、无头部、无 min-width;正文气泡代码块保持原样。 +""" +import os, sys, json + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ["QT_QPA_PLATFORM"] = "offscreen" +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" +os.environ.setdefault("HAOCODE_RENDER", "software") + +from PyQt6.QtWidgets import QApplication # noqa: E402 铁律:WebEngine 先于 QApplication 创建导入 +from PyQt6.QtCore import QTimer, QUrl # noqa: E402 +from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402 + +RESULTS = [] +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + +GRAY = "rgb(119, 119, 119)" # #777 +PURPLE = "rgb(166, 38, 164)" # #a626a4(正文代码块关键字色,保持) + +SUMMARY = ("## 摘要\n说明文字。\n```python\nimport os\ndef run():\n return \"x\"\n```\n" + "另外 `flowkit` 与 `read_file` 是工具名。") + +TEST_JS = r""" +(function(){ + try { + var out = {}; + var SUM = __SUMMARY__; + + // ---------- 压缩气泡:定格 + finish(触发全量 hljs 兜底)后探测 ---------- + createMessage('m1','assistant','','Assistant'); + compactionStarted('m1','pre_prompt'); + compactionFinished('m1',{summary:SUM,before:100,after:50,duration_ms:10,path:'pre_prompt'}); + finishMessage('m1'); + (function(){ + var block = document.querySelector('#m1 .compaction-think'); + if (!block) { out.m1 = 'NO-BLOCK'; return; } + var wrap = block.querySelector('.code-block-wrapper'); + if (!wrap) { out.m1 = 'NO-WRAP'; return; } + var cs = getComputedStyle(wrap); + out.m1_border = cs.borderTopWidth; + out.m1_minw = cs.minWidth; + var hdr = wrap.querySelector('.code-header'); + out.m1_header_display = hdr ? getComputedStyle(hdr).display : 'NO-HDR'; + var kw = wrap.querySelector('.hljs-keyword'); + out.m1_kw_color = kw ? getComputedStyle(kw).color : 'NO-KW-SPAN'; + var pre = wrap.querySelector('pre code'); + out.m1_pre_color = pre ? getComputedStyle(pre).color : 'NO-PRE'; + var ic = block.querySelector('p code'); + out.m1_inline_color = ic ? getComputedStyle(ic).color : 'NO-INLINE'; + })(); + + // ---------- 对照组:正文消息的代码块保持原样(边框+语法色) ---------- + createMessage('m2','assistant','','Assistant'); + appendToken('m2','前文。\n```python\nimport os\ndef run():\n return "x"\n```\n后文。'); + finishMessage('m2'); + (function(){ + var w = document.querySelector('#m2 .code-block-wrapper'); + if (!w) { out.m2 = 'NO-WRAP'; return; } + var cs = getComputedStyle(w); + out.m2_border = cs.borderTopWidth; + var hdr = w.querySelector('.code-header'); + out.m2_header_display = hdr ? getComputedStyle(hdr).display : 'NO-HDR'; + var kw = w.querySelector('.hljs-keyword'); + out.m2_kw_color = kw ? getComputedStyle(kw).color : 'NO-KW-SPAN'; + })(); + + return JSON.stringify(out); + } catch(e) { return 'JSERR:' + e.message + ' @' + ((e.stack||'').split('\n')[1]||''); } +})() +""".replace("__SUMMARY__", json.dumps(SUMMARY, ensure_ascii=False)) + +app = QApplication(sys.argv) +_js = {"v": None} +view = QWebEngineView(); view.resize(1000, 700) +_index = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "ui", "web", "index.html")) +def _run(): + view.page().runJavaScript(TEST_JS, lambda v: _js.update(v=v)) +view.loadFinished.connect(lambda ok: QTimer.singleShot(1500, _run)) +QTimer.singleShot(9000, app.quit) +view.load(QUrl.fromLocalFile(_index)) +app.exec() + +try: + r = json.loads(_js["v"]) +except Exception: + r = None + print(f"DOM 结果解析失败: {_js['v']!r}", flush=True) + +check("T.DOM 返回", isinstance(r, dict), repr(_js["v"])[:300]) +if isinstance(r, dict): + # 压缩气泡内:朴素化 + check("T.压缩气泡代码块边框=0", r.get("m1_border") == "0px", r) + check("T.压缩气泡代码块min-width=0", r.get("m1_minw") == "0px", r) + check("T.压缩气泡代码头部=隐藏", r.get("m1_header_display") == "none", r) + check("T.压缩气泡关键字色=#777(去紫)", r.get("m1_kw_color") == GRAY, r.get("m1_kw_color")) + check("T.压缩气泡pre代码色=#777", r.get("m1_pre_color") == GRAY, r.get("m1_pre_color")) + check("T.压缩气泡内联代码色=#777(去粉)", r.get("m1_inline_color") == GRAY, r.get("m1_inline_color")) + # 正文对照:保持原样 + check("T.正文代码块边框=1px(不变)", r.get("m2_border") == "1px", r) + check("T.正文代码头部=可见(不变)", r.get("m2_header_display") not in ("none", "NO-HDR"), r) + check("T.正文关键字色=#a626a4(不变)", r.get("m2_kw_color") == PURPLE, r.get("m2_kw_color")) + +failed = [n for n, ok in RESULTS if not ok] +print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True) +print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True) +sys.exit(0 if not failed else 1) diff --git a/tests/test_tool_params.py b/tests/test_tool_params.py new file mode 100644 index 0000000..9188ed3 --- /dev/null +++ b/tests/test_tool_params.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- +""" +四大工具参数层单测(纯函数级,无 DB/无 UI 依赖) + +覆盖本轮修复: + T1-T9 参数归一化 + JSON Schema 校验(全错误上报 / null 归一化 / 轻量强制转换 / bool 漏洞) + T10-T13 edit 参数预处理(legacy 单条 / edits 为 JSON 字符串) + T14-T19 edit 区间规划(唯一性 / 重叠检测 / 空 oldText) + T20-T25 read 参数钳制(limit 负数漏洞 / 越界 / 空文件) + T26-T29 write + edit 原子写(无临时文件残留) + T30-T31 before 钩子改参后重新校验 + +运行: PYTHONIOENCODING=utf-8 python tests/test_tool_params.py +""" +import os +import sys +import json +import glob +import tempfile +import shutil + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.agent.tools import (validate_json_schema, normalize_and_coerce, # noqa: E402 + _prepare_edit_args, _plan_edits, + tool_read, tool_write, tool_edit, + execute_tool_call, PreparedToolCall) +from core.agent.types import (AgentTool, AgentToolResult, AbortSignal, # noqa: E402 + ToolCall, AgentMessage) + +RESULTS = [] + + +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + + +# ====================================================================== +# 测试用 schema(与 default_tools() 一致) +# ====================================================================== +S_READ = {"type": "object", + "properties": {"path": {"type": "string"}, + "offset": {"type": "integer"}, + "limit": {"type": "integer"}}, + "required": ["path"]} +S_BASH = {"type": "object", + "properties": {"command": {"type": "string"}, + "timeout": {"type": "number"}}, + "required": ["command"]} +S_EDIT = {"type": "object", + "properties": { + "path": {"type": "string"}, + "edits": {"type": "array", + "items": {"type": "object", + "properties": {"oldText": {"type": "string"}, + "newText": {"type": "string"}}, + "required": ["oldText"]}}}, + "required": ["path", "edits"]} + +SG = AbortSignal() + + +# ====================================================================== +# T1-T9 校验层 +# ====================================================================== +def err_of(args, schema): + return validate_json_schema(normalize_and_coerce(args, schema), schema) + + +check("T1.缺必填参数", err_of({"offset": 1}, S_READ) == "缺少必填参数: path", + err_of({"offset": 1}, S_READ)) +check("T2.string 类型错误", "应为 string" in (err_of({"path": 123}, S_READ) or ""), + err_of({"path": 123}, S_READ)) +check("T3.bool 不得冒充 integer(旧版漏洞)", + "应为 integer" in (err_of({"path": "a", "offset": True}, S_READ) or ""), + err_of({"path": "a", "offset": True}, S_READ)) +check("T4.bool 不得冒充 number(旧版漏洞)", + "应为 number" in (err_of({"command": "x", "timeout": True}, S_BASH) or ""), + err_of({"command": "x", "timeout": True}, S_BASH)) +check("T5.整数 1.5 拒绝", "期望 integer" in (err_of({"path": "a", "offset": 1.5}, S_READ) or ""), + err_of({"path": "a", "offset": 1.5}, S_READ)) +check("T6.多余参数宽容(与 pi 一致)", err_of({"path": "a", "lines": 10}, S_READ) is None, + err_of({"path": "a", "lines": 10}, S_READ)) +_err2 = err_of({"path": 123, "offset": "bad"}, S_READ) or "" +check("T7.多错全量上报(不再只报第 1 个)", + _err2.count(";") >= 1 and "path" in _err2 and "offset" in _err2, _err2) +check("T8.嵌套必填字段", + err_of({"path": "a", "edits": [{"newText": "y"}]}, S_EDIT) + == "参数 edits[0] 缺少必填字段 oldText", + err_of({"path": "a", "edits": [{"newText": "y"}]}, S_EDIT)) +check("T9.数组元素类型错误定位到下标", + "edits[1]" in (err_of({"path": "a", "edits": [{"oldText": "x"}, {"oldText": 5}]}, S_EDIT) or ""), + err_of({"path": "a", "edits": [{"oldText": "x"}, {"oldText": 5}]}, S_EDIT)) + +# ---- 归一化 / 强制转换 ---- +check("T10.可选字段 null 被删除(不再误报类型错)", + normalize_and_coerce({"path": "a", "offset": None}, S_READ) == {"path": "a"}) +check("T11.数字字符串被转换 \"30\"→30", + normalize_and_coerce({"command": "x", "timeout": "30"}, S_BASH)["timeout"] == 30) +check("T12.integer 字段收 \"1.5\" 保持原值(交由校验报错)", + normalize_and_coerce({"path": "a", "offset": "1.5"}, S_READ)["offset"] == "1.5") +_orig = {"path": "a", "offset": None} +normalize_and_coerce(_orig, S_READ) +check("T13.归一化不修改入参", _orig == {"path": "a", "offset": None}, _orig) + +# ====================================================================== +# T14-T15 edit 参数预处理 +# ====================================================================== +check("T14.legacy 单条形式 {oldText,newText} → edits[]", + _prepare_edit_args({"path": "a", "oldText": "x", "newText": "y"}) + == {"path": "a", "edits": [{"oldText": "x", "newText": "y"}]}) +check("T15.edits 为 JSON 字符串 → 解析为数组", + _prepare_edit_args({"path": "a", "edits": '[{"oldText":"x"}]'}) + == {"path": "a", "edits": [{"oldText": "x"}]}) + +# ====================================================================== +# T16-T19 edit 区间规划 +# ====================================================================== +_e, _sp = _plan_edits("hello world", [{"oldText": "world", "newText": "there"}]) +check("T16.唯一命中 → 规划成功", _e is None and _sp[0][0] == 6, (_e, _sp)) +check("T17.未命中 → 报错", "未找到匹配文本" in (_plan_edits("abc", [{"oldText": "zz"}])[0] or ""), + _plan_edits("abc", [{"oldText": "zz"}])[0]) +check("T18.重复命中 → 报错", "匹配到 2 处" in (_plan_edits("foo foo", [{"oldText": "foo"}])[0] or ""), + _plan_edits("foo foo", [{"oldText": "foo"}])[0]) +# 旧版会:两条对原始内容各自唯一 → 校验通过 → 应用时第 2 条已找不到(静默 no-op,仍报“已应用 2 处”) +# 现在必须判定为重叠并整批拒绝 +_e2 = _plan_edits("foo bar", [{"oldText": "foo bar", "newText": "foo BAR"}, + {"oldText": "bar", "newText": "baz"}])[0] +check("T19.区间重叠被检出(旧版静默 no-op 第 2 条)", "重叠" in (_e2 or ""), _e2) +check("T19b.嵌套重叠被检出", + "重叠" in (_plan_edits("abc", [{"oldText": "abc", "newText": "Z"}, + {"oldText": "ab", "newText": "Q"}])[0] or "")) +check("T19d.原文件不存在的 oldText 被拒(对齐 pi:对原始快照匹配)", + "未找到匹配文本" in (_plan_edits("abc", [{"oldText": "ab", "newText": "aX"}, + {"oldText": "aXb", "newText": "ZZ"}])[0] or "")) +check("T19c.空 oldText 被拒(旧版空文件会静默插入)", + "不能为空" in (_plan_edits("", [{"oldText": "", "newText": "INJ"}])[0] or ""), + _plan_edits("", [{"oldText": "", "newText": "INJ"}])[0]) + +# ====================================================================== +# T20-T25 read 参数钳制(临时文件,不碰项目数据) +# ====================================================================== +_TMP = tempfile.mkdtemp(prefix="haocode_tp_") +CTX = {"cwd": _TMP} + + +def _mk(name, text): + p = os.path.join(_TMP, name) + with open(p, "w", encoding="utf-8") as f: + f.write(text) + return p + + +try: + _mk("lines.txt", "".join(f"L{i}\n" for i in range(1, 11))) # 10 行 + _mk("empty.txt", "") + + r = tool_read("t", {"path": "lines.txt", "limit": -5}, SG, None, CTX) + _t20 = r.as_text() + check("T20.limit 负数不再读全文件(旧版返回 10 行减 5)", + "L1" in _t20 and "L2" not in _t20, _t20.replace("\n", " | ")[:120]) + + r = tool_read("t", {"path": "lines.txt", "limit": 0}, SG, None, CTX) + check("T21.limit=0 钳到 1(旧版返回空块 + 无意义脚注)", + "L1" in r.as_text() and "L2" not in r.as_text(), r.as_text().replace("\n", " | ")[:120]) + + r = tool_read("t", {"path": "lines.txt", "offset": 999}, SG, None, CTX) + check("T22.offset 越界 → 明确提示(旧版'已显示 999–998 行')", + "超出文件范围" in r.as_text() and "共 10 行" in r.as_text(), r.as_text()) + + r = tool_read("t", {"path": "empty.txt"}, SG, None, CTX) + check("T23.空文件 → 明确提示", "文件为空" in r.as_text(), r.as_text()) + + r = tool_read("t", {"path": ""}, SG, None, CTX) + check("T24.path 为空 → 'path 不能为空'(旧版报'文件不存在: ')", + r.is_error and "不能为空" in r.as_text(), r.as_text()) + + r = tool_read("t", {"path": "lines.txt", "offset": 3, "limit": 2}, SG, None, CTX) + check("T25.正常行窗口不变(offset=3,limit=2 → L3,L4)", + "L3" in r.as_text() and "L4" in r.as_text() and "L5" not in r.as_text() + and "offset=5" in r.as_text(), r.as_text().replace("\n", " | ")[:140]) + + # ================================================================== + # T26-T29 原子写 / edit 端到端 + # ================================================================== + r = tool_write("t", {"path": "w.txt", "content": "a\nb\n"}, SG, None, CTX) + left = glob.glob(os.path.join(_TMP, ".hocode_w_*")) + check("T26.write 成功且无临时文件残留", not r.is_error and not left, (r.as_text(), left)) + tool_write("t", {"path": "w2.txt", "content": "foo bar"}, SG, None, CTX) + + r = tool_edit("t", {"path": "w2.txt", + "edits": [{"oldText": "foo bar", "newText": "foo BAR"}, + {"oldText": "bar", "newText": "baz"}]}, SG, None, CTX) + check("T27.重叠 edit 整批拒绝且文件未被改动", + r.is_error and "重叠" in r.as_text(), r.as_text()) + + r = tool_edit("t", {"path": "w.txt", + "edits": [{"oldText": "a", "newText": "A"}, + {"oldText": "b", "newText": "B"}]}, SG, None, CTX) + with open(os.path.join(_TMP, "w.txt"), encoding="utf-8") as f: + after = f.read() + check("T28.多条不重叠 edit 一次性应用(对原始快照)", + not r.is_error and after == "A\nB\n", (r.as_text(), repr(after))) + + r = tool_edit("t", {"path": "w.txt", "edits": [{"oldText": "", "newText": "X"}]}, SG, None, CTX) + with open(os.path.join(_TMP, "w.txt"), encoding="utf-8") as f: + after = f.read() + check("T29.空 oldText 被拒且文件未变", r.is_error and after == "A\nB\n", + (r.as_text(), repr(after))) + + # ================================================================== + # T30-T31 before 钩子改参后重新校验 + # ================================================================== + def _echo(args): + return AgentToolResult.text(f"echo:{json.dumps(args, sort_keys=True)}") + + def _echo5(tool_call_id, args, signal, on_update, ctx): + return _echo(args) + + class _Cfg: + tool_context = {"cwd": _TMP} + before_tool_call = None + after_tool_call = None + + etool = AgentTool(name="echo", description="d", parameters=S_READ, execute=_echo5) + tc = ToolCall(id="c1", name="echo", arguments={"path": "a"}) + + cfg_bad = _Cfg() + cfg_bad.before_tool_call = lambda payload, sig: {"args": {"path": 123}} + res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}), + AgentMessage(role="assistant", content=""), cfg_bad, SG, None) + check("T30.before 钩子返回非法参数 → 拒绝执行(pi 同款重校验)", + res.is_error and "校验失败" in res.as_text(), res.as_text()) + + cfg_ok = _Cfg() + cfg_ok.before_tool_call = lambda payload, sig: {"args": {"path": "b", "offset": "7"}} + res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}), + AgentMessage(role="assistant", content=""), cfg_ok, SG, None) + check("T31.before 钩子改参后归一化生效(\"7\"→7)", + not res.is_error and '"offset": 7' in res.as_text(), res.as_text()) + + cfg_blk = _Cfg() + cfg_blk.before_tool_call = lambda payload, sig: {"block": True, "reason": "nope"} + res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}), + AgentMessage(role="assistant", content=""), cfg_blk, SG, None) + check("T32.before 钩子 block 语义不变", res.is_error and "nope" in res.as_text(), res.as_text()) + +finally: + shutil.rmtree(_TMP, ignore_errors=True) + +failed = [n for n, ok in RESULTS if not ok] +print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True) +print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True) +sys.exit(0 if not failed else 1) diff --git a/tests/test_wv2_guard.py b/tests/test_wv2_guard.py new file mode 100644 index 0000000..dc6e316 --- /dev/null +++ b/tests/test_wv2_guard.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +""" +WebView2 守卫单测 —— 锁死 T0 级事故不再复发 + +事故链(已发生): + 每个 MainWindow()(含 offscreen 测试)→ get_environment() + → 无条件 `taskkill /F /IM msedgewebview2.exe` + → 把【正在运行的生产 app】的 WebView2 浏览器进程一并杀掉 + → 它的 controller 变 disposed(set_bounds 报 0x8007139F) + → DOM 照渲染但视觉层永久空白(“选中会话不渲染核心内容”) + +覆盖: + T1 无头环境(QT_QPA_PLATFORM=offscreen)→ 不启用 WebView2 + T2 HAOCODE_FORCE_QTWEBENGINE=1 → 不启用 + T3 桌面平台(QT_QPA_PLATFORM=windows)→ 允许 + T4 单实例锁:持有者独占;【另一个进程】拿不到(跨进程互斥,是真守卫) + T5 offscreen 下 get_environment() 直接返回 None(根本不碰共享 profile) + T6 同一进程重复 acquire 幂等返回 True(不会把自己锁死) + +运行: PYTHONIOENCODING=utf-8 python tests/test_wv2_guard.py +""" +import os +import sys +import tempfile +import subprocess + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# 铁律:测试用独立锁文件,不得与正在运行的 app 争 data/app_instance.lock +# (否则断言会依赖“app 是否在跑”) +_LOCK_TMP = os.path.join(tempfile.gettempdir(), "haocode_wv2guard_%d.lock" % os.getpid()) +os.environ["HAOCODE_INSTANCE_LOCK_FILE"] = _LOCK_TMP +if os.path.exists(_LOCK_TMP): + try: + os.remove(_LOCK_TMP) + except Exception: + pass + +RESULTS = [] + + +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + + +import core.webview2 as W # noqa: E402 + +_ORIG = dict(os.environ) + +try: + # ---------------- T1/T2/T3: 环境守卫 ---------------- + os.environ["QT_QPA_PLATFORM"] = "offscreen" + os.environ.pop("HAOCODE_FORCE_QTWEBENGINE", None) + check("T1.1 offscreen → 不允许 WebView2", W._wv2_allowed_here() is False) + + os.environ["QT_QPA_PLATFORM"] = "windows" + check("T1.2 windows → 允许 WebView2", W._wv2_allowed_here() is True) + + for p in ("minimal", "minimalegl", "vnc", "wayland", "embedded", "offscreen"): + os.environ["QT_QPA_PLATFORM"] = p + if W._wv2_allowed_here() is not False: + check(f"T1.3 平台 {p} 应被拒绝", False, p) + break + else: + check("T1.3 非 windows 平台全部拒绝", True) + + os.environ["QT_QPA_PLATFORM"] = "windows" + os.environ["HAOCODE_FORCE_QTWEBENGINE"] = "1" + check("T2.1 强制回退开关生效", W._wv2_allowed_here() is False) + os.environ.pop("HAOCODE_FORCE_QTWEBENGINE", None) + + # ---------------- T4: 跨进程单实例互斥(核心) ---------------- + os.environ["QT_QPA_PLATFORM"] = "windows" + W._INSTANCE_LOCK["fh"] = None # 从头开始,避免受本进程历史影响 + first = W.acquire_instance_lock() + check("T4.1 首个 acquires 成功", first is True, str(first)) + check("T4.2 同进程重复 acquire 幂等为 True", + W.acquire_instance_lock() is True) + + code = ("import sys; sys.path.insert(0, r'%s');" + "import core.webview2 as W; print(W.acquire_instance_lock())" + % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + r = subprocess.run([sys.executable, "-c", code], capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=60) + got = (r.stdout or "").strip().splitlines() + got = got[-1] if got else "" + check("T4.3 另一进程拿不到锁(互斥生效)", got == "False", f"stdout={got!r} err={(r.stderr or '')[:120]}") + + # ---------------- T5: offscreen 下 get_environment 直接 None ---------------- + os.environ["QT_QPA_PLATFORM"] = "offscreen" + W._env = None + env = W.get_environment(None) + check("T5.1 offscreen 下 get_environment → None(不碰共享 profile)", env is None) + check("T5.2 被守卫拦下后 _env 仍为空", W._env is None) + + # ---------------- T6: 已有实例在跑时 → 不启用 WebView2 ---------------- + # 此时本进程仍持有锁;用子进程模拟“后来的实例” + code2 = ("import sys; sys.path.insert(0, r'%s');" + "import core.webview2 as W; print(W.get_environment(None) is None)" + % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + r2 = subprocess.run([sys.executable, "-c", code2], capture_output=True, + text=True, encoding="utf-8", errors="replace", + env={**os.environ, "QT_QPA_PLATFORM": "windows"}, + timeout=60) + out2 = (r2.stdout or "").strip().splitlines() + out2 = out2[-1] if out2 else "" + check("T6.1 已有实例持锁 → 后来的实例拿到 None(回落 QtWebEngine,不 taskkill)", + out2 == "True", f"stdout={out2!r} err={(r2.stderr or '')[:160]}") + +finally: + try: + fh = W._INSTANCE_LOCK.get("fh") + if fh is not None: + fh.close() + except Exception: + pass + try: + if os.path.exists(_LOCK_TMP): + os.remove(_LOCK_TMP) + except Exception: + pass + os.environ.clear() + os.environ.update(_ORIG) + +failed = [n for n, ok in RESULTS if not ok] +print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True) +print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True) +sys.exit(0 if not failed else 1) diff --git a/tests/tune_mode_popup.py b/tests/tune_mode_popup.py new file mode 100644 index 0000000..4e0de55 --- /dev/null +++ b/tests/tune_mode_popup.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +"""会话模式弹窗 · 可视化调参工具 +运行: C:\\Users\\14890\\miniconda3\\envs\\haocode\\python.exe tests\\tune_mode_popup.py +- 左侧:调参窗(拖动/修改参数 → 右侧弹窗实时重渲染) +- 右侧:真实的 SessionModePopup 本体(独立窗口,点调参窗不会消失) +- 满意后点「确定」→ 参数 JSON 写入 data/mode_popup_tune.json → 自动退出 +""" +import os +import sys +import json +import traceback + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" +os.environ.setdefault("HAOCODE_RENDER", "software") + +from PyQt6.QtWidgets import ( # noqa: E402 + QApplication, QWidget, QFormLayout, QHBoxLayout, QSpinBox, + QPushButton, QCheckBox, QMessageBox, +) +from PyQt6.QtCore import Qt # noqa: E402 +from PyQt6.QtGui import QPixmap # noqa: E402 +from ui.views.main_window import SessionModePopup, _ModeRow, _popup_svg_path # noqa: E402 + +DEFAULTS = { + "icon_size": 14, # 图标尺寸 + "name_font": 12, # 名称字号 + "desc_font": 12, # 描述字号 + "row_margin_v": 8, # 行上下内边距 + "row_margin_h": 10, # 行左右内边距 + "icon_text_gap": 12, # 图标-文字间距 + "name_desc_gap": 4, # 名称/描述行间距 + "row_radius": 8, # 行圆角 + "popup_width": 320, # 弹窗宽度 + "container_margin": 4, # 容器边距 +} +RANGES = { + "icon_size": (8, 48), "name_font": (10, 20), "desc_font": (9, 18), + "row_margin_v": (2, 20), "row_margin_h": (4, 24), "icon_text_gap": (4, 24), + "name_desc_gap": (0, 8), "row_radius": (0, 16), "popup_width": (240, 420), + "container_margin": (0, 16), +} +CN = { + "icon_size": "图标尺寸 (px)", + "name_font": "名称字号 (px)", + "desc_font": "描述字号 (px)", + "row_margin_v": "行上下内边距 (px)", + "row_margin_h": "行左右内边距 (px)", + "icon_text_gap": "图标-文字间距 (px)", + "name_desc_gap": "名称/描述行距 (px)", + "row_radius": "行圆角 (px)", + "popup_width": "弹窗宽度 (px)", + "container_margin": "容器边距 (px)", +} +FONT_STACK = '"HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif' + + +class Tuner: + def __init__(self): + self.app = QApplication(sys.argv) + self.params = dict(DEFAULTS) + + # ---- 弹窗本体(真实类):去掉 Popup 标志 → 独立普通无边框窗,不随失焦消失 ---- + self.popup = SessionModePopup(None) + self.popup.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint) + self.rows = self.popup.container.findChildren(_ModeRow) + icon_by_mode = {m: _popup_svg_path(ic) for m, _, _, ic in SessionModePopup.MODES} + for r in self.rows: + r._tune_pix = QPixmap(icon_by_mode[r.mode]) # 原始分辨率,重缩不糊 + + screen = self.app.primaryScreen().availableGeometry() + self.px = screen.center().x() - 300 + self.py = screen.center().y() - 200 + self.popup.move(self.px, self.py) + self.popup.show() + + # ---- 调参窗 ---- + self.win = QWidget() + self.win.setWindowTitle("会话模式弹窗调参 —— 调好后点「确定」") + form = QFormLayout(self.win) + form.setSpacing(8) + self.spins = {} + for key, (lo, hi) in RANGES.items(): + sp = QSpinBox() + sp.setRange(lo, hi) + sp.setValue(self.params[key]) + sp.valueChanged.connect(lambda v, k=key: self.on_change(k, v)) + self.spins[key] = sp + form.addRow(CN[key], sp) + + self.chk_selected = QCheckBox("显示选中态(预览)") + self.chk_selected.toggled.connect(lambda _: self.apply_all()) + form.addRow(self.chk_selected) + + btns = QHBoxLayout() + b_reset = QPushButton("重置默认") + b_reset.clicked.connect(self.reset) + b_ok = QPushButton("✅ 确定(写入日志并退出)") + b_ok.setStyleSheet("font-weight: bold;") + b_ok.clicked.connect(self.finish) + btns.addWidget(b_reset) + btns.addWidget(b_ok) + form.addRow(btns) + + self.win.resize(360, self.win.sizeHint().height()) + self.win.move(self.px - 380, self.py) + self.win.show() + + self.apply_all() + + # ---------- 实时渲染 ---------- + def on_change(self, k, v): + self.params[k] = v + self.apply_all() + + def apply_all(self): + p = self.params + popup = self.popup + popup.setFixedWidth(p["popup_width"]) + cl = popup.container.layout() + cl.setContentsMargins(p["container_margin"], p["container_margin"], + p["container_margin"], p["container_margin"]) + sel = self.chk_selected.isChecked() + for r in self.rows: + r.lbl_icon.setFixedSize(p["icon_size"], p["icon_size"]) + r.lbl_icon.setPixmap(r._tune_pix.scaled( + p["icon_size"], p["icon_size"], + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation)) + lay = r.layout() + lay.setContentsMargins(p["row_margin_h"], p["row_margin_v"], + p["row_margin_h"] + 2, p["row_margin_v"]) + lay.setSpacing(p["icon_text_gap"]) + txt = lay.itemAt(1).layout() + if txt is not None: + txt.setSpacing(p["name_desc_gap"]) + name_color = "#1a73e8" if sel else "#333333" + r.lbl_name.setStyleSheet( + f"font-family: {FONT_STACK}; font-size: {p['name_font']}px;" + f"font-weight: bold; color: {name_color}; background: transparent;") + r.lbl_desc.setStyleSheet( + f"font-family: {FONT_STACK}; font-size: {p['desc_font']}px;" + f"color: #888888; background: transparent;") + bg = "#e8f0fe" if sel else "transparent" + r.setStyleSheet(f"background-color: {bg}; border-radius: {p['row_radius']}px;") + self.app.processEvents() + popup.adjust_popup_height() + popup.adjustSize() + + def reset(self): + self.params = dict(DEFAULTS) + for k, sp in self.spins.items(): + sp.blockSignals(True) + sp.setValue(self.params[k]) + sp.blockSignals(False) + self.apply_all() + + # ---------- 确定 → 写日志 ---------- + def finish(self): + log = os.path.join(os.path.dirname(__file__), "..", "data", "mode_popup_tune.json") + os.makedirs(os.path.dirname(log), exist_ok=True) + with open(log, "w", encoding="utf-8") as f: + json.dump(self.params, f, ensure_ascii=False, indent=2) + print("TUNE_LOG=" + os.path.abspath(log), flush=True) + print(json.dumps(self.params, ensure_ascii=False), flush=True) + self.app.quit() + + +def main(): + try: + Tuner() + QApplication.instance().exec() + except Exception: + err = traceback.format_exc() + try: + with open(os.path.join(os.path.dirname(__file__), "..", "data", + "tune_error.log"), "w", encoding="utf-8") as f: + f.write(err) + except OSError: + pass + QMessageBox.critical(None, "调参工具启动失败", err) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/tune_model_popup.py b/tests/tune_model_popup.py new file mode 100644 index 0000000..b5d0f3b --- /dev/null +++ b/tests/tune_model_popup.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- +"""模型选择弹窗 · 可视化调参工具 +运行: C:\\Users\\14890\\miniconda3\\envs\\haocode\\python.exe tests\\tune_model_popup.py +- 左侧:调参窗(改参数 → 右侧弹窗实时重渲染) +- 右侧:真实的 ModelSelectPopup 本体(独立窗口,点调参窗不会消失) +- 满意后点「确定」→ 参数 JSON 写入 data/model_popup_tune.json → 自动退出 +""" +import os +import sys +import json +import traceback + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" +os.environ.setdefault("HAOCODE_RENDER", "software") + +from PyQt6.QtWidgets import ( # noqa: E402 + QApplication, QWidget, QFormLayout, QHBoxLayout, QSpinBox, + QPushButton, QCheckBox, QLabel, QMessageBox, QComboBox, +) +from PyQt6.QtCore import Qt, QSize # noqa: E402 +from PyQt6.QtGui import QPixmap, QIcon, QPainter, QFont, QFontDatabase # noqa: E402 +from ui.views.main_window import ModelSelectPopup, MainWindow, _popup_svg_path # noqa: E402 (先于 QApplication) + +DEFAULTS = { + # —— 字体 —— + "font_family": "Microsoft YaHei", # 字体族(下拉选择;调参确认值) + # —— 模型行 —— + "item_font": 13, # 模型名字号(经 list_widget.setFont 生效) + "model_item_h": 27, # 模型行高 + "model_icon": 16, # 模型图标尺寸 + "model_icon_pad": 34, # 模型行左边距(图标透明左垫,仅作用模型行) + # —— 供应商行 —— + "header_height": 38, # 供应商行高(= widget 高;默认 38 = 当前代码实际行高) + "header_font": 13, # 供应商名字号 + "count_font": 10, # 数量字号 + "provider_svg": "provider.svg", # 供应商图标(3 个 SVG 可切换) + "header_icon": 16, # 供应商图标尺寸 + "header_icon_pad": 0, # 供应商图标水平位置(左侧留白,仅推图标与后续内容) + "header_icon_pad_v": 1, # 供应商图标垂直位置(正=下移,负=上移) + "arrow_size": 13, # 展开/收起箭头尺寸 + "header_hpad_l": 3, # 供应商行左边距(头部按钮左 margin) + # —— 整体 —— + "popup_width": 340, # 弹窗宽度 + "container_vmargin": 4, # 容器上下边距 +} +RANGES = { + "item_font": (10, 20), + "model_item_h": (18, 40), "model_icon": (8, 28), + "model_icon_pad": (0, 48), "header_height": (20, 60), "header_font": (9, 18), + "count_font": (8, 16), "header_icon": (10, 28), "header_icon_pad": (0, 30), + "header_icon_pad_v": (-10, 10), + "arrow_size": (8, 20), + "header_hpad_l": (0, 20), + "popup_width": (260, 420), "container_vmargin": (0, 12), +} +CN = { + "font_family": "字体(下拉选择)", + "item_font": "模型名字号 (px)", + "model_item_h": "模型行高 (px)", + "model_icon": "模型图标尺寸 (px)", "model_icon_pad": "模型行左边距 (px)", + "header_height": "供应商行高 (px)", "header_font": "供应商名字号 (px)", + "count_font": "数量字号 (px)", "header_icon": "供应商图标尺寸 (px)", + "provider_svg": "供应商图标(SVG 切换)", + "header_icon_pad": "供应商图标水平位置 (px)", + "header_icon_pad_v": "供应商图标垂直位置 (px,正=下移)", + "arrow_size": "箭头尺寸 (px)", "header_hpad_l": "供应商行左边距 (px)", + "popup_width": "弹窗宽度 (px)", + "container_vmargin": "容器上下边距 (px)", +} +SECTIONS = [ + ("字体", ["font_family"]), + ("模型行", ["item_font", "model_item_h", "model_icon", "model_icon_pad"]), + ("供应商行", ["header_height", "header_font", "count_font", + "provider_svg", "header_icon", "header_icon_pad", + "header_icon_pad_v", "arrow_size", "header_hpad_l"]), + ("整体", ["popup_width", "container_vmargin"]), +] +# 供应商图标候选(文件名, 显示名) +PROVIDER_SVGS = [ + ("provider.svg", "图标 1 · 服务器"), + ("provider2.svg", "图标 2 · 二级服务器"), + ("provider3.svg", "图标 3 · 服务器(细线)"), +] +FONT_STACK = '"HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans SC", sans-serif' + +QSS_TMPL = """ + * { + font-family: FONT_STACK; + } + #popup_container { + background-color: #ffffff; + border: 1px solid #dcdcdc; + border-radius: 12px; + } + #group_toggle_btn { background: transparent; border: none; } + #group_toggle_btn:hover { background-color: #f2f5f9; border-radius: 6px; } + #model_list { + border: none; + background: transparent; + outline: none; + } + #model_list::item { + font-family: FONT_STACK; + color: #333333; + font-weight: normal; + /* 实测本 Qt/PyQt6 构建下 ::item 仅 background-color/color 生效; + font-size/font 缩写/font-family/margin/padding/border-radius 全部无效—— + 字号走 setFont,行高走 sizeHint,边距走图标透明垫/按钮 margin */ + } + #model_list::item:hover { + background-color: #f0f4f9; + color: #111111; + } + #model_list::item:selected { + background-color: #e8f0fe; + color: #1a73e8; + font-weight: bold; + } + QScrollBar:vertical { + border: none; + background: transparent; + width: 5px; + margin: 12px 2px; + } + QScrollBar::handle:vertical { + background: #d0d0d0; + min-height: 20px; + border-radius: 3px; + } + QScrollBar::handle:vertical:hover { + background: #a0a0a0; + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; + } +""" + + +class Tuner: + def __init__(self): + self.app = QApplication(sys.argv) + self.params = dict(DEFAULTS) + + # ---- 弹窗本体(真实类 + 真实 config + 真实祖先上下文) ---- + # ⚠️ 保真关键:实际 App 中弹窗是 MainWindow 的子部件,会继承其全局样式表 + # (QListWidget::item 的 padding/margin/选中蓝条等);若 parent=None 渲染, + # 就会出“调试与实际不符”的矛盾 → 借 MainWindow.setup_stylesheet 把真实 + # 样式表套到宿主上,弹窗挂在宿主下渲染(与生产同构) + self._host = QWidget() + MainWindow.setup_stylesheet(self._host) + self._host.resize(1, 1) + self._host.move(-200, -200) + self._host.show() + cfg_path = os.path.join(os.path.dirname(__file__), "..", "data", "config.json") + with open(cfg_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + self.popup = ModelSelectPopup(self._host, cfg) + self.popup.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint) + self.list = self.popup.list_widget + self._tag_headers() + + screen = self.app.primaryScreen().availableGeometry() + self.px = screen.center().x() - 200 + self.py = screen.center().y() - 300 + self.popup.move(self.px, self.py) + self.popup.show() + + # ---- 调参窗 ---- + self.win = QWidget() + self.win.setWindowTitle("模型选择弹窗调参 —— 调好后点「确定」") + form = QFormLayout(self.win) + form.setSpacing(7) + self.spins = {} + self.combos = {} + self.broken = set() # 用户标记的“不生效”参数 + for title, keys in SECTIONS: + form.addRow(QLabel(f"—— {title} ——")) + for key in keys: + mark = QPushButton("不生效") + mark.setCheckable(True) + mark.setFixedWidth(58) + mark.setCursor(Qt.CursorShape.PointingHandCursor) + mark.setStyleSheet(""" + QPushButton { font-size: 11px; color: #888888; border: 1px solid #cccccc; + border-radius: 4px; background: #fafafa; } + QPushButton:checked { color: #ffffff; background: #d93025; border-color: #d93025; } + """) + mark.toggled.connect(lambda on, k=key: self.on_mark(k, on)) + row = QHBoxLayout() + row.setSpacing(4) + if key == "font_family": + combo = QComboBox() + fams = ["HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans SC", + "Segoe UI", "Arial", "Consolas", "SimHei", "PingFang SC"] + seen = set() + for fam in fams + sorted(QFontDatabase.families()): + if fam and fam not in seen: + seen.add(fam) + combo.addItem(fam) + combo.setCurrentText(self.params[key]) + combo.currentTextChanged.connect(lambda v, k=key: self.on_change(k, v)) + self.combos[key] = combo + row.addWidget(combo, 1) + elif key == "provider_svg": + combo = QComboBox() + for fname, cname in PROVIDER_SVGS: + combo.addItem(cname, fname) + combo.setCurrentIndex(max(combo.findData(self.params[key]), 0)) + combo.currentIndexChanged.connect( + lambda i, k=key, c=combo: + self.on_change(k, c.itemData(i) or "provider.svg")) + self.combos[key] = combo + row.addWidget(combo, 1) + else: + lo, hi = RANGES[key] + sp = QSpinBox() + sp.setRange(lo, hi) + sp.setValue(self.params[key]) + sp.valueChanged.connect(lambda v, k=key: self.on_change(k, v)) + self.spins[key] = sp + row.addWidget(sp, 1) + row.addWidget(mark) + form.addRow(CN[key], row) + + self.chk_selected = QCheckBox("显示选中行(预览)") + self.chk_selected.toggled.connect(self.on_toggle_selected) + form.addRow(self.chk_selected) + + btns = QHBoxLayout() + b_reset = QPushButton("重置默认") + b_reset.clicked.connect(self.reset) + b_ok = QPushButton("✅ 确定(写入日志并退出)") + b_ok.setStyleSheet("font-weight: bold;") + b_ok.clicked.connect(self.finish) + btns.addWidget(b_reset) + btns.addWidget(b_ok) + form.addRow(btns) + + self.win.resize(380, min(self.win.sizeHint().height(), screen.height() - 80)) + self.win.move(max(20, self.px - 400), self.py) + self.win.show() + + self.apply_all() + + # ---------- 启动时给头部标签打角色标签(此时尺寸还是默认值,判定可靠) ---------- + def _tag_headers(self): + for g in self.popup._groups: + hwd = self.list.itemWidget(g["header_item"]) + for lbl in hwd.findChildren(QLabel): + txt = lbl.text() + if txt == "": + # 默认: 供应商图标 16×16, 箭头 12×12 → 启动时按尺寸区分(此后不再变判定基准) + lbl.setProperty("_role", "icon" if lbl.size().width() >= 14 else "chevron") + elif txt.isdigit(): + lbl.setProperty("_role", "count") + else: + lbl.setProperty("_role", "name") + + # ---------- 实时渲染 ---------- + def on_mark(self, k, on): + if on: + self.broken.add(k) + else: + self.broken.discard(k) + + def on_change(self, k, v): + self.params[k] = v + self.apply_all() + + def _rebuild_icons(self): + p = self.params + pop = self.popup + pop._provider_pixmap = QPixmap(_popup_svg_path(p.get("provider_svg", "provider.svg"))).scaled( + p["header_icon"], p["header_icon"], + Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + pop._arrow_expanded = QIcon(_popup_svg_path("chevron_down.svg")).pixmap(p["arrow_size"], p["arrow_size"]) + pop._arrow_collapsed = QIcon(_popup_svg_path("chevron_right.svg")).pixmap(p["arrow_size"], p["arrow_size"]) + m = QPixmap(_popup_svg_path("model.svg")).scaled( + p["model_icon"], p["model_icon"], + Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + pad_w = p["model_icon"] + p["model_icon_pad"] + _pad = QPixmap(pad_w, p["model_icon"]) + _pad.fill(Qt.GlobalColor.transparent) + _pp = QPainter(_pad) + _pp.drawPixmap(p["model_icon_pad"], 0, m) + _pp.end() + pop._model_icon = QIcon(_pad) + # 关键:item 持有 QIcon 的拷贝,类属性替换后必须逐个 setIcon 才会更新(实测) + for g in pop._groups: + for it in g["model_items"]: + it.setIcon(pop._model_icon) + self.list.setIconSize(QSize(pad_w, p["model_icon"])) + + def apply_all(self): + p = self.params + pop = self.popup + family = p["font_family"] + stack = f'"{family}", "Microsoft YaHei", "Noto Sans SC", sans-serif' + pop.setFixedWidth(p["popup_width"]) + pop.container_layout.setContentsMargins(0, p["container_vmargin"], 0, p["container_vmargin"]) + self._rebuild_icons() + + for g in pop._groups: + hwd = self.list.itemWidget(g["header_item"]) + hwd.setFixedHeight(p["header_height"]) + g["header_item"].setSizeHint(QSize(0, p["header_height"])) # 行高 = widget 高 + btns = hwd.findChildren(QPushButton) + if btns: + bl = btns[0].layout() + if bl is not None: + bl.setContentsMargins(p["header_hpad_l"], 0, 8, 0) + for lbl in hwd.findChildren(QLabel): + role = lbl.property("_role") + if role == "icon": + pad = p["header_icon_pad"] + pv = p["header_icon_pad_v"] + # 2 倍 margin:抵消 label 行内居中吸收的一半增高 → 1:1 位移 + top, bot = 2 * max(pv, 0), 2 * max(-pv, 0) + lbl.setFixedSize(p["header_icon"] + pad, p["header_icon"] + top + bot) + lbl.setContentsMargins(pad, top, 0, bot) + lbl.setPixmap(pop._provider_pixmap) + elif role == "chevron": + lbl.setFixedSize(p["arrow_size"], p["arrow_size"]) + lbl.setPixmap(pop._arrow_expanded if g["expanded"] else pop._arrow_collapsed) + elif role == "name": + lbl.setStyleSheet( + f"font-family: {stack}; color: #555555; font-weight: normal;" + f"font-size: {p['header_font']}px; letter-spacing: 1px; background: transparent;") + elif role == "count": + lbl.setStyleSheet( + f"font-family: {stack}; color: #888888;" + f"font-size: {p['count_font']}px; font-weight: normal; background: transparent;") + for it in g["model_items"]: + it.setSizeHint(QSize(0, p["model_item_h"])) + + pop.setStyleSheet(QSS_TMPL + .replace("FONT_STACK", stack)) + # 模型行字体:widget 级 setFont(QSS ::item 的 font-size/缩写都不可靠,实测) + _f = self.list.font() + _f.setPixelSize(p["item_font"]) + _f.setFamily(family) + self.list.setFont(_f) + self.app.processEvents() + pop.adjust_popup_height() + pop.adjustSize() + + def on_toggle_selected(self, on): + if on: + for g in self.popup._groups: + if g["model_items"] and not g["model_items"][0].isHidden(): + self.list.setCurrentItem(g["model_items"][0]) + break + else: + self.list.clearSelection() + + def reset(self): + self.params = dict(DEFAULTS) + for k, sp in self.spins.items(): + sp.blockSignals(True) + sp.setValue(self.params[k]) + sp.blockSignals(False) + for k, cb in self.combos.items(): + cb.blockSignals(True) + cb.setCurrentText(self.params[k]) + cb.blockSignals(False) + self.apply_all() + + # ---------- 确定 → 写日志 ---------- + def finish(self): + log = os.path.join(os.path.dirname(__file__), "..", "data", "model_popup_tune.json") + os.makedirs(os.path.dirname(log), exist_ok=True) + payload = dict(self.params) + payload["not_working"] = sorted(self.broken) # 用户标记的不生效参数 + with open(log, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + print("TUNE_LOG=" + os.path.abspath(log), flush=True) + print(json.dumps(payload, ensure_ascii=False), flush=True) + self.app.quit() + + +def main(): + try: + Tuner() + QApplication.instance().exec() + except Exception: + err = traceback.format_exc() + try: + with open(os.path.join(os.path.dirname(__file__), "..", "data", + "tune_error.log"), "w", encoding="utf-8") as f: + f.write(err) + except OSError: + pass + QMessageBox.critical(None, "调参工具启动失败", err) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/verify_math_render.py b/tests/verify_math_render.py new file mode 100644 index 0000000..c3d3e75 --- /dev/null +++ b/tests/verify_math_render.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +"""公式渲染端到端验证:真实会话消息 → 真实前端管线 → DOM 断言 + +运行: PYTHONIOENCODING=utf-8 QT_QPA_PLATFORM=offscreen python tests/verify_math_render.py + +铁律:不污染真实 DB —— 先把 data/chat_history.db 复制到临时文件,再指向副本。 +""" +import os +import sys +import json +import time +import shutil +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("HAOCODE_RENDER", "software") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" + +_REAL_DB = os.path.join(os.path.dirname(__file__), "..", "data", "chat_history.db") +_TMP_DB = os.path.join(tempfile.gettempdir(), f"haocode_verify_math_{os.getpid()}.db") +shutil.copyfile(_REAL_DB, _TMP_DB) + +import core.db_manager as _dbm # noqa: E402 +_dbm._DEFAULT_DB = _TMP_DB + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtCore import QTimer # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 + +app = QApplication(sys.argv) +results = [] +win = {"w": None, "sid": None} + +JS_QUERY = r"""(function(){ + var scope = document.getElementById('chat-container') || document.body; + var q = function(s){ return scope.querySelectorAll(s).length; }; + // 只取助手消息(user 消息按设计是纯文本,本来就不渲染公式) + var asst = scope.querySelectorAll('.message-wrapper.assistant'); + var asstScope = asst.length ? asst[asst.length - 1] : scope; + // 可见文本:剔除 katex-mathml(CSS 视觉隐藏的 MathML 层,内部保留原始 tex 属正常) + var clone = asstScope.cloneNode(true); + clone.querySelectorAll('.katex-mathml').forEach(function(e){ e.parentNode.removeChild(e); }); + var visTxt = clone.textContent || ''; + var codeTxt = ''; + asstScope.querySelectorAll('pre, code').forEach(function(e){ codeTxt += e.textContent + '\n'; }); + return { + katex: q('.katex'), + display: q('.katex-display'), + mathml: q('.katex-mathml'), + vislayer: q('.katex-html'), + leakedPlaceholder: visTxt.indexOf('@@K') !== -1, + rawBackslash: visTxt.indexOf('\\operatorname') !== -1, + bareBracketFormula: visTxt.indexOf('P_4=\\operatorname') !== -1, + codeHasDollar: codeTxt.indexOf('$x + y$') !== -1, + codeHasArr: codeTxt.indexOf('arr[0]') !== -1 + }; +})()""" + + +def check(name, cond, extra=""): + print((" PASS " if cond else " FAIL ") + name + ((" | " + str(extra)) if extra else "")) + results.append(bool(cond)) + + +def run_js(js, timeout_s=15): + r = {"v": None, "d": False} + + def cb(val): + if isinstance(val, str): + try: + val = json.loads(val) + except Exception: + pass + r["v"] = val + r["d"] = True + + b = win["w"].browser + if hasattr(b, "execute_js_async"): + b.execute_js_async(js, cb) + else: + b.page().runJavaScript(js, cb) + t0 = time.time() + while not r["d"] and time.time() - t0 < timeout_s: + app.processEvents() + time.sleep(0.05) + return r["v"] + + +def boot(): + w = MainWindow() + win["w"] = w + sid = None + for s in w.db.get_all_sessions(): + if s.get("title") == "公式渲染验收": + sid = s["id"] + break + if not sid: + print("FAIL 未找到「公式渲染验收」会话(先跑 tests/inject_math_demo.py)") + app.quit() + return + win["sid"] = sid + print(f"会话: {sid}") + w.load_messages_to_web(sid) + QTimer.singleShot(9000, phase_check) + + +def phase_check(): + for _ in range(40): + if run_js("window.jsReady === true ? 1 : 0", timeout_s=3) == 1: + break + time.sleep(0.5) + + r = run_js(JS_QUERY) + if not isinstance(r, dict): + print(f"FAIL DOM 查询失败: {r!r}") + app.quit() + return + print("\n DOM 统计: " + json.dumps(r, ensure_ascii=False)) + check("KaTeX 渲染出公式(.katex > 0)", r["katex"] > 0, f"katex={r['katex']}") + check("块公式 7 个(.katex-display == 7)", r["display"] == 7, f"display={r['display']}") + check("可视层存在(.katex-html > 0)", r["vislayer"] > 0, f"vislayer={r['vislayer']}") + check("无占位符泄漏", not r["leakedPlaceholder"]) + check("可见文本无原始 tex 残留", not r["rawBackslash"]) + check("无裸括号公式残留", not r["bareBracketFormula"]) + check("代码块内 $x + y$ 保持原样", r["codeHasDollar"]) + check("代码块内 arr[0] 保持原样", r["codeHasArr"]) + + try: + shot_path = os.path.join(os.path.dirname(__file__), "_tmp_math_render.png") + win["w"].browser.grab().save(shot_path) + print(f" 截图: {shot_path}") + except Exception as e: + print(f" 截图失败: {e}") + + print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====") + try: + win["w"].close() + except Exception: + pass + app.quit() + + +QTimer.singleShot(400, boot) +QTimer.singleShot(120000, app.quit) +app.exec() +try: + os.remove(_TMP_DB) +except Exception: + pass +sys.exit(0 if all(results) else 1) diff --git a/tests/verify_onscreen.py b/tests/verify_onscreen.py new file mode 100644 index 0000000..dd85190 --- /dev/null +++ b/tests/verify_onscreen.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""on-screen 布局验证:正文 .md-segment 在真实窗口中的 offsetHeight。 +offscreen 没有布局(一切 h=0),必须在真实窗口验证。 +PASS 条件:流式中 + finish 后 正文段 h > 0。 +""" +import os, sys +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from PyQt6.QtWidgets import QApplication +import PyQt6.QtWebEngineWidgets # noqa +from PyQt6.QtCore import QTimer +from ui.views.main_window import MainWindow + +app = QApplication(sys.argv) +w = MainWindow() +w.resize(1000, 700) +w.show() + +AUDIT = {} + +def js(expr, cb=None): + if cb: + w.browser.page().runJavaScript(expr, cb) + else: + w.browser.page().runJavaScript(expr) + +def on_ready(_res): + js(f"createMessage('vtest', 'assistant');") + QTimer.singleShot(300, phase_stream) + +def phase_stream(): + js("appendReasoning('vtest', '这是思考第一段内容。');") + body = "测试!测试!测试!\n\n这是第二段正文,用于验证布局高度。" + import json as _json + for ch in body: + js("appendToken('vtest', " + _json.dumps(ch) + ");") + QTimer.singleShot(1500, phase_mid_check) + +def phase_mid_check(_r=None): + js("""(function(){ + var seg = document.querySelector('#vtest .md-segment'); + var tc = document.querySelector('#vtest .think-content'); + return JSON.stringify({ + mid: true, + segH: seg ? seg.offsetHeight : -1, + segConnected: seg ? seg.isConnected : null, + segRect: seg ? Math.round(seg.getBoundingClientRect().height) : -1, + tcH: tc ? tc.offsetHeight : -1 + }); + })()""", on_mid) + +def on_mid(res): + AUDIT["mid"] = res + js("finishMessage('vtest');") + QTimer.singleShot(1200, phase_finish_check) + +def phase_finish_check(_r=None): + js("""(function(){ + var seg = document.querySelector('#vtest .md-segment'); + return JSON.stringify({ + fin: true, + segH: seg ? seg.offsetHeight : -1, + segText: seg ? seg.textContent.length : -1, + segDisplay: seg ? getComputedStyle(seg).display : null + }); + })()""", on_finish) + +def on_finish(res): + AUDIT["fin"] = res + print("MID =", AUDIT.get("mid")) + print("FIN =", AUDIT.get("fin")) + try: + import json + m, f = json.loads(AUDIT["mid"]), json.loads(AUDIT["fin"]) + ok = m["segH"] > 0 and m["tcH"] > 0 and f["segH"] > 0 and f["segText"] > 20 + print("===== " + ("PASS: 正文段真实布局高度正常" if ok else "FAIL: 正文段高度异常") + f" mid.segH={m['segH']} fin.segH={f['segH']} =====") + except Exception as e: + print("===== FAIL: 解析异常", e, "=====") + app.quit() + +QTimer.singleShot(2500, lambda: js("document.readyState", on_ready)) +QTimer.singleShot(30000, app.quit) +app.exec() diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/builtin_tools/__init__.py b/tools/builtin_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/builtin_tools/pdf_reader.py b/tools/builtin_tools/pdf_reader.py new file mode 100644 index 0000000..477489c --- /dev/null +++ b/tools/builtin_tools/pdf_reader.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +""" +PDF 读取工具 (pdf_reader) +========================= + +项目内第一个正式内置工具(builtin tool)。对外提供两项能力: + + 1. ``extract_pdf_text`` —— 提取 PDF 全部文本,按页结构化为提示词友好格式 + 2. ``extract_pdf_images`` —— 提取 PDF 内嵌的所有图片,逐张落地为 PNG + +代码规范与调用模式(约定) +-------------------------- +* 每个对外函数都带完整类型注解(参数 + 返回值)。 +* docstring 采用 ``Args / Returns / Raises`` 分段,逐参数说明含义与单位。 +* 将来的工具注册器(tools/registry.py,尚未实现)可通过 + ``inspect.signature`` + ``__doc__`` 自动反射出 OpenAI 格式的 Tool JSON Schema, + 因此本文件的函数签名与文档即"接口契约",请保持稳定、描述清晰。 + +当前阶段说明 +------------ +* 本工具暂**不接入** agent 的自动工具调用链路(registry 未实现), + 由 UI 附件流程(``ui/views/main_window.py`` 的 PDF 处理)直接 import 调用。 +* 参数与返回值已按"可被自动调用"的标准描述清楚,后续接线无需改动函数本身。 + +依赖与协议 +---------- +* 依赖 PyMuPDF(导入名 ``pymupdf``)。 +* PyMuPDF 为 **AGPL-3.0 / Artifex 商业** 双许可:分发或通过网络提供服务时, + 需按 AGPL 开源本项目或购买商业许可。纯自用/内部不分发则无约束。 +""" +import os +import uuid + +try: + import pymupdf # PyMuPDF >= 1.24 的官方导入名 +except ImportError: # 兼容旧版导入名 fitz + import fitz as pymupdf # type: ignore + + +# 单个 PDF 的大小上限(字节):超过则拒绝解析,避免内存与上下文爆炸 +PDF_MAX_BYTES = 20 * 1024 * 1024 # 20 MB + + +def _open_doc(path: str, max_bytes: int) -> "pymupdf.Document": + """内部辅助:做存在性/大小校验后打开 PDF,统一异常为 ValueError。""" + if not os.path.isfile(path): + raise ValueError("文件不存在") + size = os.path.getsize(path) + if size > max_bytes: + raise ValueError( + f"PDF 过大({size / 1024 / 1024:.1f} MB > {max_bytes / 1024 / 1024:.0f} MB)" + ) + try: + doc = pymupdf.open(path) + except Exception as e: # pymupdf 抛出的异常类型不固定,统一兜底 + raise ValueError(f"PDF 打开失败(可能损坏): {e}") + if doc.needs_pass: + doc.close() + raise ValueError("PDF 已加密,需要密码,暂不支持") + return doc + + +def extract_pdf_text(path: str, max_bytes: int = PDF_MAX_BYTES) -> tuple: + """提取 PDF 的全部文本,按页结构化。 + + 输出格式(无文本的空页会被跳过):: + + [第 1 页] + <该页文本> + + [第 2 页] + <该页文本> + + Args: + path: PDF 文件路径(绝对路径,或相对当前工作目录的路径)。 + max_bytes: 允许解析的最大文件字节数,默认 ``PDF_MAX_BYTES``(20MB)。 + + Returns: + ``(structured_text, page_count)`` 二元组: + + * ``structured_text`` (str): 按页拼接的结构化文本,各页之间以空行分隔; + 若整份 PDF 无文本则为空字符串 ``""``。 + * ``page_count`` (int): PDF 的总页数(含空页)。 + + Raises: + ValueError: 文件不存在 / 超过 ``max_bytes`` / PDF 损坏 / PDF 加密。 + """ + doc = _open_doc(path, max_bytes) + try: + page_count = doc.page_count + parts = [] + for i, page in enumerate(doc, 1): + text = page.get_text("text").strip() + if text: + parts.append(f"[第 {i} 页]\n{text}") + return "\n\n".join(parts), page_count + finally: + doc.close() + + +def extract_pdf_images(path: str, out_dir: str, max_bytes: int = PDF_MAX_BYTES) -> list: + """提取 PDF 内嵌的所有图片,逐张保存为 PNG。 + + 遍历每一页的内嵌图片(``page.get_images``),用 ``Pixmap`` 解码后写出; + CMYK 色彩空间会自动转换为 RGB(否则无法存为 PNG)。单张图片解码失败 + 会被跳过,不影响其余图片。 + + Args: + path: PDF 文件路径。 + out_dir: 图片输出目录(建议使用绝对路径)。不存在时自动创建。 + max_bytes: 允许解析的最大文件字节数,默认 ``PDF_MAX_BYTES``(20MB)。 + + Returns: + 图片元数据列表(按 ``(page, index)`` 升序),每项为 dict:: + + { + "page": int, # 所在页码(从 1 开始) + "index": int, # 该页内的第几张(从 1 开始) + "abs_path": str, # 落地 PNG 的绝对路径 + "mime": str, # 固定为 "image/png" + "size_kb": float, # 文件大小(KB,保留两位小数) + "width": int, # 图片像素宽 + "height": int, # 图片像素高 + } + + PDF 中无内嵌图片时返回空列表 ``[]``。 + + Raises: + ValueError: 文件不存在 / 超过 ``max_bytes`` / PDF 损坏 / PDF 加密。 + """ + doc = _open_doc(path, max_bytes) + os.makedirs(out_dir, exist_ok=True) + tag = uuid.uuid4().hex[:8] # 本批次文件名前缀,避免跨 PDF 撞名 + results = [] + try: + for page_num, page in enumerate(doc, 1): + for img_index, img_info in enumerate(page.get_images(full=True), 1): + xref = img_info[0] + try: + pix = pymupdf.Pixmap(doc, xref) + if pix.n - pix.alpha >= 4: # CMYK → RGB + pix = pymupdf.Pixmap(pymupdf.csRGB, pix) + fname = f"pdfimg_{tag}_p{page_num}_{img_index}.png" + abs_path = os.path.join(out_dir, fname) + pix.save(abs_path) + width, height = pix.width, pix.height + pix = None # 及时释放位图内存 + except Exception: + continue # 单张失败不影响整体 + results.append({ + "page": page_num, + "index": img_index, + "abs_path": abs_path, + "mime": "image/png", + "size_kb": round(os.path.getsize(abs_path) / 1024, 2), + "width": width, + "height": height, + }) + return results + finally: + doc.close() diff --git a/ui/views/__init__.py b/ui/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/views/bash_panel.py b/ui/views/bash_panel.py new file mode 100644 index 0000000..05cb975 --- /dev/null +++ b/ui/views/bash_panel.py @@ -0,0 +1,956 @@ +# -*- coding: utf-8 -*- +"""右侧任务面板:本次会话的 bash 任务监控(运行中 / 已完成) + +统一口径(与用户确认): + · 两栏 = 「运行中」/「已完成」(按状态分);两栏【同时可见、不互斥】, + 中间是可拖动分隔线(260px 宽若真做左右并排,每栏仅 ~130px, + 展开区放不下「参数 + 输出」两块列表,故用上下两栏)。 + 若要严格左右并排:把 _SPLIT_ORIENTATION 改成 Horizontal 即可。 + · 一次 bash = 一层(BashLayer),自上而下按【启动先后倒序】排列(P2-01: + 最新启动的任务在第一项;排序键恒为启动顺序,运行中→完成归位时 + 不按完成时间重排;已落库时间线无显式时间时,启动序号 = 消息链 + 顺序 + 时间线内顺序,稳定可复现) + · 单击层头 = 展开/收起;展开后分「参数」「输出」两块 + · 运行中:输出为 tool_bash 的实时流(缓冲上限 200KB,超出丢弃最旧并标注) + · 已完成:输出为「进入上下文的原文」(DB messages.timeline[].result,不截断) + · 范围永远是【当前会话】;切换会话跟随刷新 + · 面板【不自动收缩、也不自动展开】,开关就在面板自身: + 收起态 = 栏正中按钮;展开态 = 标题行右上角按钮(永远只有一个可动按钮) + · 🆕 左边缘可拖拽调宽(最小 200px);松手立即记录到 config.json["bash_panel_width"], + 下次按展开按钮自动恢复到记录的宽度 + · 「已出上下文」= 该层在最近一次压缩切点之前(其输出已不在 API 上下文里) +""" +import json +import os +import re + +from PyQt6 import QtCore, QtGui, QtWidgets + +LIVE_BUF_CAP = 200 * 1024 # 每条实时输出缓冲上限 +LAYER_LIMIT = 30 # 已完成栏默认只渲染最近 N 层 +_SPLIT_ORIENTATION = QtCore.Qt.Orientation.Vertical # 改 Horizontal = 左右并排 + +_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_DUR_RE = re.compile(r"\((\d+(?:\.\d+)?)s\)\s*$") + +# ---------------------------------------------------------------- 宽度 +PANEL_W_DEFAULT = 260 # 首次运行的默认展开宽度 +PANEL_W_MIN = 200 # 🆕 拖拽最小宽度(再窄「参数/输出」两块就挤坏了) +PANEL_W_MAX = 560 # 绝对上限(实际还会受「不超过主窗口 50%」约束) +_CFG_KEY = "bash_panel_width" + +# P0-01:配置路径统一走 core.config_paths(环境变量 HAOCODE_CONFIG_FILE 优先) +from core.config_paths import config_path as _unified_config_path # noqa: E402 + + +def _cfg_path() -> str: + """配置文件路径(P0-01 统一入口:测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)""" + return _unified_config_path() + + +def load_panel_width(default: int = PANEL_W_DEFAULT) -> int: + """读回上次拖拽记录的宽度;缺失/异常 → 默认值(并强制夹在 [MIN, MAX])""" + try: + with open(_cfg_path(), "r", encoding="utf-8") as f: + w = int(json.load(f).get(_CFG_KEY, default)) + except Exception: + return default + return max(PANEL_W_MIN, min(PANEL_W_MAX, w)) + + +def save_panel_width(w: int) -> bool: + """只改这一个键、其余配置原样保留;临时文件 + replace 原子写""" + p = _cfg_path() + try: + cfg = {} + if os.path.exists(p): + with open(p, "r", encoding="utf-8") as f: + cfg = json.load(f) or {} + cfg[_CFG_KEY] = int(w) + tmp = p + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + os.replace(tmp, p) + return True + except Exception as e: + print(f"[UI]: 保存任务面板宽度失败: {e}") + return False + + +def _svg(name: str) -> str: + return os.path.join(_ROOT, "svg", name) + + +def _fmt_dur(sec) -> str: + try: + sec = float(sec) + except Exception: + return "" + return f"{sec:.1f}s" if sec < 60 else f"{int(sec // 60)}m{int(sec % 60)}s" + + +def _parse_dur(text): + """从上下文文本结尾的 (Ns) 里取耗时;取不到返回 None""" + m = _DUR_RE.search((text or "").strip()) + if not m: + return None + try: + return float(m.group(1)) + except Exception: + return None + + +def _cmd_of(args) -> str: + """args 可能是 JSON 字符串或 dict → 取出 bash 的 command""" + if isinstance(args, dict): + return args.get("command", "") or "" + try: + d = json.loads(args or "{}") + if isinstance(d, dict): + return d.get("command", "") or "" + except Exception: + pass + return str(args or "") + + +def _iter_bash_entries(timeline): + """timeline 可能是 JSON 字符串(DB)或 list[dict](内存)→ 产出 name=='bash' 的工具条目""" + data = timeline + if isinstance(timeline, str): + if not timeline.strip(): + return + try: + data = json.loads(timeline) + except Exception: + return + if not isinstance(data, list): + return + for e in data: + if isinstance(e, dict) and e.get("t") == "tool" and e.get("name") == "bash": + yield e + + +class _ClickFrame(QtWidgets.QFrame): + """整块可单击(单击即触发,不用双击)""" + clicked = QtCore.pyqtSignal() + + def mouseReleaseEvent(self, e): + if (e.button() == QtCore.Qt.MouseButton.LeftButton + and self.rect().contains(e.position().toPoint())): + self.clicked.emit() + super().mouseReleaseEvent(e) + + +# ====================================================================== +# 一层 = 一次 bash 执行 +# ====================================================================== +class BashLayer(QtWidgets.QFrame): + def __init__(self, call_id: str, command: str = "", parent=None): + super().__init__(parent) + self.setObjectName("bl_layer") + self.call_id = call_id + self.command = command or "" + self.ok = None # None=运行中 / True / False=已完成 + self.in_context = True + self.expanded = False + self.elapsed = 0 + self.timeout = 0 + self._live = "" + self._live_truncated = False + self._final = None + self._build() + self._apply_status() + + # ---------------- UI ---------------- + def _build(self): + v = QtWidgets.QVBoxLayout(self) + v.setContentsMargins(0, 0, 0, 0) + v.setSpacing(0) + + self.head = _ClickFrame() + self.head.setObjectName("bl_head") + self.head.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self.head.setToolTip(self.command or "") + hl = QtWidgets.QHBoxLayout(self.head) + hl.setContentsMargins(8, 6, 8, 6) + hl.setSpacing(6) + + self.dot = QtWidgets.QLabel("●") + self.dot.setObjectName("bl_dot") + self.name = QtWidgets.QLabel("bash") + self.name.setObjectName("bl_name") + self.meta = QtWidgets.QLabel("") + self.meta.setObjectName("bl_meta") + self.tag = QtWidgets.QLabel("") + self.tag.setObjectName("bl_tag") + self.cmd = QtWidgets.QLabel(self._preview()) + self.cmd.setObjectName("bl_cmd") + self.cmd.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, + QtWidgets.QSizePolicy.Policy.Preferred) + self.chev = QtWidgets.QLabel("▸") + self.chev.setObjectName("bl_chev") + + hl.addWidget(self.dot, 0) + hl.addWidget(self.name, 0) + hl.addWidget(self.meta, 0) + hl.addWidget(self.tag, 0) + hl.addWidget(self.cmd, 1) + hl.addWidget(self.chev, 0) + v.addWidget(self.head) + + self.body = QtWidgets.QWidget() + self.body.setObjectName("bl_body") + bl = QtWidgets.QVBoxLayout(self.body) + bl.setContentsMargins(8, 0, 8, 8) + bl.setSpacing(4) + + self.lbl_arg = QtWidgets.QLabel("参数") + self.lbl_arg.setObjectName("bl_sect_label") + self.arg_box = self._code_box(80, wrap=True) + bl.addWidget(self.lbl_arg) + bl.addWidget(self.arg_box) + + self.lbl_out = QtWidgets.QLabel("输出") + self.lbl_out.setObjectName("bl_sect_label") + self.out_box = self._code_box(230, wrap=False) + bl.addWidget(self.lbl_out) + bl.addWidget(self.out_box) + + self.body.hide() + v.addWidget(self.body) + self.head.clicked.connect(self.toggle) + + def _code_box(self, max_h, wrap): + box = QtWidgets.QPlainTextEdit() + box.setObjectName("bl_code") + box.setReadOnly(True) + box.setMaximumHeight(max_h) + box.setLineWrapMode( + QtWidgets.QPlainTextEdit.LineWrapMode.WidgetWidth if wrap + else QtWidgets.QPlainTextEdit.LineWrapMode.NoWrap) + box.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + box.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) + box.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded if not wrap + else QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + return box + + def _preview(self) -> str: + s = " ".join((self.command or "").split()) + return s[:58] + ("…" if len(s) > 58 else "") + + # ---------------- 状态 ---------------- + def _apply_status(self): + if self.ok is None: + self.dot.setProperty("state", "run") + self.meta.setText(f"{self.elapsed}/{self.timeout}s" if self.timeout + else (f"{self.elapsed}s" if self.elapsed else "…")) + else: + self.dot.setProperty("state", "ok" if self.ok else "bad") + self.meta.setText(_fmt_dur(self.elapsed) if self.elapsed else "") + + if self.ok is not None and not self.in_context: + self.tag.setText("已出上下文") + self.tag.setProperty("kind", "out") + elif self.ok is None: + self.tag.setText("运行中") + self.tag.setProperty("kind", "run") + elif self.ok: + self.tag.setText("已完成") + self.tag.setProperty("kind", "ok") + else: + self.tag.setText("失败") + self.tag.setProperty("kind", "bad") + + for w in (self.dot, self.tag): + w.style().unpolish(w) + w.style().polish(w) + + def set_running(self, elapsed, timeout): + self.ok = None + self.elapsed = int(elapsed or 0) + self.timeout = int(timeout or 0) + self._apply_status() + if self.expanded: + self._render_output() + + def append_live(self, text: str): + if not text: + return + self._live += text + if len(self._live) > LIVE_BUF_CAP: + self._live = self._live[-LIVE_BUF_CAP:] + self._live_truncated = True + if self.expanded: + self._render_output() + + def set_finished(self, ok, text): + self.ok = bool(ok) + self._final = text or "" + d = _parse_dur(self._final) + if d is not None: + self.elapsed = d + self._apply_status() + if self.expanded: + self._render_output() + + def set_in_context(self, in_ctx: bool): + self.in_context = bool(in_ctx) + self._apply_status() + + # ---------------- 展开 ---------------- + def toggle(self): + self.expanded = not self.expanded + self.body.setVisible(self.expanded) + self.chev.setText("▾" if self.expanded else "▸") + if self.expanded: + self.arg_box.setPlainText(self.command or "(无参数)") + self._render_output() + + def _render_output(self): + if self.ok is None: + body = self._live + if self._live_truncated: + body = "…(实时缓冲已截断,仅保留最后 200KB)\n" + body + self.lbl_out.setText("输出(实时)") + self.out_box.setPlainText(body if body else "(等待输出…)") + sb = self.out_box.verticalScrollBar() + sb.setValue(sb.maximum()) + else: + self.lbl_out.setText("输出(进入上下文)") + self.out_box.setPlainText(self._final or "(无输出)") + + +# ====================================================================== +# 一栏:标题(可折叠)+ 层列表 +# ====================================================================== +class _Section(QtWidgets.QWidget): + def __init__(self, title, empty_hint, on_fold=None, parent=None): + super().__init__(parent) + self.empty_hint = empty_hint + self.folded = False + self._on_fold = on_fold # 回调:交面板做高度动画(抽屉式) + self._effect = None # 内容区淡入淡出用(按需创建,静止时移除) + self._head_hint = 0 # 头部行高缓存(恒定值) + v = QtWidgets.QVBoxLayout(self) + v.setContentsMargins(0, 0, 0, 0) + v.setSpacing(0) + + self.head = _ClickFrame() + self.head.setObjectName("bl_sect_head") + self.head.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + hl = QtWidgets.QHBoxLayout(self.head) + hl.setContentsMargins(10, 6, 8, 6) + hl.setSpacing(6) + self.chev = QtWidgets.QLabel("▾") + self.chev.setObjectName("bl_sect_chev") + self.title = QtWidgets.QLabel(title) + self.title.setObjectName("bl_sect_title") + self.count = QtWidgets.QLabel("0") + self.count.setObjectName("bl_sect_count") + hl.addWidget(self.chev, 0) + hl.addWidget(self.title, 0) + hl.addStretch(1) + hl.addWidget(self.count, 0) + v.addWidget(self.head) + + self.scroll = QtWidgets.QScrollArea() + self.scroll.setObjectName("bl_scroll") + self.scroll.setWidgetResizable(True) + self.scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + # 🐛 修复:QScrollArea 的 viewport 默认按 palette base(白)自画背景, + # 会把父级 #f7f8fa 盖成白色 → 强制透明 + self.scroll.viewport().setStyleSheet("background: transparent;") + self.scroll.viewport().setAutoFillBackground(False) + self.host = QtWidgets.QWidget() + self.host.setObjectName("bl_host") + self.host.setAutoFillBackground(False) + self.lay = QtWidgets.QVBoxLayout(self.host) + self.lay.setContentsMargins(6, 2, 6, 6) + self.lay.setSpacing(5) + self.scroll.setWidget(self.host) + + # 🆕 「下面填充的 bash 层」整块放进 body:折叠/展开动画只针对它, + # 头部行(self.head)高度永远不变 + self.body = QtWidgets.QWidget() + self.body.setObjectName("bl_body") + self.body.setAutoFillBackground(False) + bv = QtWidgets.QVBoxLayout(self.body) + bv.setContentsMargins(0, 0, 0, 0) + bv.setSpacing(0) + bv.addWidget(self.scroll, 1) + + self.hint = QtWidgets.QLabel("") + self.hint.setObjectName("bl_hint") + self.hint.setWordWrap(True) + bv.addWidget(self.hint) + v.addWidget(self.body, 1) + + self.head.clicked.connect(self._toggle_fold) + self._set_hint(self.empty_hint) + # 🐛 头部行高【硬固定】:收起时 body 被隐、布局里只剩 head,而 QFrame 默认 + # 竖直策略可伸长 → head 会被拉满整栏(实测 793px 巨条 = 「按钮变大很高」) + self._head_hint = max(16, self.head.sizeHint().height()) + self.head.setFixedHeight(self._head_hint) + + # ---------------- 头部行高(收起/展开恒定的那一行)---------------- + def head_height(self) -> int: + # 🐛 必须缓存 sizeHint:若用实时 self.head.height(),会形成正反馈—— + # 头部被压矮 → minimumSizeHint 变小 → splitter 允许更矮 → 头部再被压矮… + if self._head_hint <= 0: + self._head_hint = max(16, self.head.sizeHint().height()) + return self._head_hint + def minimumSizeHint(self): + # 🐛 关键修复:默认最小高 ≈ 113px(头部 + QScrollArea 最小高 + 提示语), + # QSplitter 完全没法把本栏收缩 → 收起后留一大块空白(用户报的 bug)。 + # 改为「只剩头部行」,内容区允许被压到 0(超出部分天然被父级裁切)。 + return QtCore.QSize(0, self.head_height()) + + # ---------------- 内容区透明度(动画帧驱动)---------------- + def set_body_opacity(self, op: float): + op = max(0.0, min(1.0, float(op))) + if op >= 0.999: + self._drop_effect() + return + if self._effect is None: + self._effect = QtWidgets.QGraphicsOpacityEffect(self.body) + self.body.setGraphicsEffect(self._effect) + self._effect.setOpacity(op) + + def _drop_effect(self): + """去掉离屏渲染开销(静止时不挂 effect)""" + if self.body.graphicsEffect() is not None: + self.body.setGraphicsEffect(None) + self._effect = None + + def show_body(self): + self.body.setVisible(True) + self.scroll.setVisible(True) + self.hint.setVisible(bool(self.hint.text())) + + def hide_body(self): + self.body.setVisible(False) + + def set_folded(self, folded: bool): + """应用折叠状态(高度由面板的 splitter 动画负责)""" + self.folded = bool(folded) + self.chev.setText("▸" if self.folded else "▾") + if self.folded: + self._drop_effect() + else: + self.show_body() + self.set_body_opacity(1.0) + + def _toggle_fold(self): + if self._on_fold is not None: + self._on_fold(self) # 交面板:状态切换 + 高度抽屉动画 + return + self.set_folded(not self.folded) + self.scroll.setVisible(not self.folded) + self.hint.setVisible(not self.folded and bool(self.hint.text())) + + def _set_hint(self, text): + self.hint.setText(text or "") + self.hint.setVisible(not self.folded and bool(text)) + + def set_layers(self, layers, total=None): + """重排层(复用同一批 widget → 实时缓冲不丢)""" + while self.lay.count(): + it = self.lay.takeAt(0) + w = it.widget() + if w is not None: + w.setParent(None) + for w in layers: + self.lay.addWidget(w) + w.show() + self.lay.addStretch(1) + self.count.setText(str(len(layers))) + total = len(layers) if total is None else total + if total > len(layers): + self._set_hint(f"仅显示最近 {len(layers)} 层(本会话共 {total} 层)") + else: + self._set_hint("" if layers else self.empty_hint) + + +# ====================================================================== +# 右侧任务面板(外壳,镜像左侧边栏) +# ====================================================================== +class _ResizeHandle(QtWidgets.QWidget): + """面板左边缘的拖拽手柄(4px 隐形条,hover 才亮;光标 SizeHor)""" + + W = 4 + + def __init__(self, panel): + super().__init__(panel) + self._panel = panel + self.setObjectName("bl_resize_handle") + # QWidget 子类必须显式开启才会绘制样式表背景(hover 高亮靠它) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True) + self.setCursor(QtCore.Qt.CursorShape.SizeHorCursor) + self.setStyleSheet("#bl_resize_handle:hover { background: #dfe3ea; }") + self.setToolTip("拖拽调整任务面板宽度(最小 %dpx)" % PANEL_W_MIN) + self._drag = False + + def mousePressEvent(self, e): + if e.button() == QtCore.Qt.MouseButton.LeftButton: + self._drag = True + self._panel._drag_begin(e.globalPosition().x()) + e.accept() + return + super().mousePressEvent(e) + + def mouseMoveEvent(self, e): + if self._drag: + self._panel._drag_to_global_x(e.globalPosition().x()) + e.accept() + return + super().mouseMoveEvent(e) + + def mouseReleaseEvent(self, e): + if self._drag: + self._drag = False + self._panel._commit_drag_width() + e.accept() + return + super().mouseReleaseEvent(e) + + +class BashPanel(QtWidgets.QWidget): + W_EXPAND = PANEL_W_DEFAULT + W_COLLAPSE = 52 + W_MIN = PANEL_W_MIN + W_MAX = PANEL_W_MAX + FOLD_MS = 200 # 栏折叠/展开时长(与 ModelSelectPopup.DRAWER_DURATION 一致) + FOLD_STEP = 16 # 帧间隔(与 DRAWER_STEP 一致,~60fps) + MIN_BODY_H = 60 # 展开后「下面填充的层」至少保留的高度 + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("right_sidebar") + # 🐛 关键修复:QWidget 的自定义子类默认【不绘制样式表背景/边框】 + # (#sidebar 是原生 QWidget 实例所以正常,BashPanel 是子类所以失效—— + # 表现为右侧栏底色变白、左侧分隔线丢失、空间感消失) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True) + # 🆕 展开宽度:优先用上次拖拽记录的值(config.json: bash_panel_width) + self.W_EXPAND = load_panel_width() + self._end_w = self.W_EXPAND + self._drag_right = None + self._w = self.W_COLLAPSE + self.setFixedWidth(self._w) # 默认收起 + self.collapsed = True + self._target = True + self._anim = None + self._session_id = None + self._db = None + self._layers = {} # call_id -> BashLayer + self._order = [] # 稳定启动序号(0=最早启动;追加顺序 = 启动先后) + self._running = set() + self._done = set() + self._saved_h = [120, 520] # 每栏收起前的高度(展开时恢复) + self._fold = None # 折叠动画状态 + self._fold_timer = QtCore.QTimer(self) + self._fold_timer.setInterval(self.FOLD_STEP) + self._fold_timer.timeout.connect(self._fold_tick) + self._build() + # 🆕 左边缘拖拽手柄(浮在最左 4px,收起/展开两种状态都在) + self._handle = _ResizeHandle(self) + self._handle.setFixedWidth(_ResizeHandle.W) + self._handle.raise_() + + # ---------------- 外壳 ---------------- + def _build(self): + v = QtWidgets.QVBoxLayout(self) + v.setContentsMargins(0, 0, 0, 0) + v.setSpacing(0) + self.stack = QtWidgets.QStackedWidget() + v.addWidget(self.stack) + + # ---- 展开页(顶部留白 20px + 36px 标题行,与左侧栏完全对齐)---- + self.expand_page = QtWidgets.QWidget() + ev = QtWidgets.QVBoxLayout(self.expand_page) + ev.setContentsMargins(0, 20, 0, 12) + ev.setSpacing(0) + + self.header = QtWidgets.QWidget() + self.header.setObjectName("bl_panel_head") + self.header.setFixedHeight(36) + hl = QtWidgets.QHBoxLayout(self.header) + hl.setContentsMargins(10, 0, 10, 0) + hl.setSpacing(6) + self.lbl_title = QtWidgets.QLabel("任务面板") + self.lbl_title.setObjectName("bl_panel_title") + self.lbl_total = QtWidgets.QLabel("") + self.lbl_total.setObjectName("bl_panel_total") + # 🐛 修复双按钮:面板内不再自带收起按钮(唯一开关 = 顶部工具栏按钮), + # 与左侧栏的区别:左侧栏的开关在栏内,右侧栏的开关在顶部(原「导出」位置) + hl.addWidget(self.lbl_title, 0) + hl.addWidget(self.lbl_total, 0) + hl.addStretch(1) + # 🆕 展开态开关:标题行右端(与左侧栏 collapse_btn 完全同规格) + self.fold_btn = QtWidgets.QPushButton("") + self.fold_btn.setObjectName("collapse_btn") + self.fold_btn.setIcon(QtGui.QIcon(_svg("panel_right.svg"))) + self.fold_btn.setIconSize(QtCore.QSize(16, 16)) + self.fold_btn.setFixedSize(28, 28) + self.fold_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self.fold_btn.setToolTip("收起任务面板") + hl.addWidget(self.fold_btn, 0) + ev.addWidget(self.header) + ev.addSpacing(10) + + self.splitter = QtWidgets.QSplitter(_SPLIT_ORIENTATION) + self.splitter.setObjectName("bl_splitter") + self.splitter.setChildrenCollapsible(False) + self.splitter.setHandleWidth(5) + self.sec_running = _Section("运行中", "暂无正在运行的 bash", on_fold=self._toggle_section) + self.sec_done = _Section("已完成", "本会话还没有已完成的 bash", on_fold=self._toggle_section) + self.splitter.addWidget(self.sec_running) + self.splitter.addWidget(self.sec_done) + # 🆕 底部留白占位:最后一栏收起时由它吸收余量(否则栏头会被顶到面板底部, + # 或被拉伸成巨条) + self.spacer = QtWidgets.QWidget() + self.spacer.setObjectName("bl_spacer") + self.spacer.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True) + self.spacer.setStyleSheet("#bl_spacer { background: transparent; }") + self.splitter.addWidget(self.spacer) + self.splitter.setCollapsible(2, True) # 占位层允许被压到 0 + self.splitter.setSizes(self._saved_h + [0]) + _h2 = self.splitter.handle(2) + if _h2 is not None: # 占位层上方的分隔条:不可拖、不可见 + _h2.setEnabled(False) + _h2.setStyleSheet("background: transparent;") + ev.addWidget(self.splitter, 1) + self.stack.addWidget(self.expand_page) + + # ---- 收起页:栏正中一个展开按钮(与左侧栏 collapse_expand_btn 完全同规格)---- + self.collapse_page = QtWidgets.QWidget() + cl = QtWidgets.QVBoxLayout(self.collapse_page) + cl.setContentsMargins(0, 0, 0, 0) + cl.addStretch(1) + self.expand_btn = QtWidgets.QPushButton("") + self.expand_btn.setObjectName("collapse_btn") + self.expand_btn.setIcon(QtGui.QIcon(_svg("panel_right.svg"))) + self.expand_btn.setIconSize(QtCore.QSize(18, 18)) + self.expand_btn.setFixedSize(34, 34) + self.expand_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self.expand_btn.setToolTip("展开任务面板") + cl.addWidget(self.expand_btn, 0, QtCore.Qt.AlignmentFlag.AlignHCenter) + cl.addStretch(1) + self.stack.addWidget(self.collapse_page) + + # 两个按钮分居 QStackedWidget 两页 → 任何时刻可见开关恒为 1 个 + self.stack.setCurrentWidget(self.collapse_page) + self.fold_btn.clicked.connect(self.toggle) + self.expand_btn.clicked.connect(self.toggle) + + # ---------------- 两栏折叠/展开:抽屉式高度动画(头部行高恒定)---------------- + def _toggle_section(self, sec): + """点栏头 → 只把「下面填充的 bash 层」的高度收到 0,头部行高度不变""" + self._finish_fold() # 连点:先把上一段动画落定 + idx = 0 if sec is self.sec_running else 1 + sizes = self.splitter.sizes() + avail = sum(sizes) + head_h = sec.head_height() + other = self.sec_done if idx == 0 else self.sec_running + if not sec.folded and not other.folded: + # 即将收起 + 另一栏展开 → 记住当前高度(用于下次展开恢复); + # 另一栏已收起时不记(此时本栏高度是「余量」而非用户意图高度) + self._saved_h[idx] = max(sizes[idx], head_h + self.MIN_BODY_H) + sec.set_folded(not sec.folded) + target = self._layout_targets(actor=sec) + if not sec.folded: # 展开 → 内容从透明开始淡入 + sec.show_body() + sec.set_body_opacity(0.0) + self._sync_handle() + if avail <= 0 or self.splitter.width() <= 0: + self.splitter.setSizes(target) + self._end_fold_visual(sec) + return + self._fold = {"sec": sec, "from": list(sizes), "to": target, "t": 0.0} + self._fold_timer.start() + + def _layout_targets(self, actor=None): + """统一布局策略(层从顶部堆叠): + · 收起栏 = 只剩头部行 + · 本次被点开的那栏 → 用记录的高度 + · 余量只由「展开着的已完成」吸收(填满到底部); + 若「已完成」已收起 → 余量进底部留白(栏头紧跟上方内容) + """ + s = self.splitter.sizes() + avail = sum(s) + hr = self.sec_running.head_height() + hd = self.sec_done.head_height() + h0, h1 = s[0], s[1] + run_f, done_f = self.sec_running.folded, self.sec_done.folded + if run_f: + h0 = hr + if done_f: + h1 = hd + if (not run_f) and actor is self.sec_running: + h0 = self._saved_h[0] + if (not done_f) and actor is self.sec_done: + h1 = self._saved_h[1] + if not done_f: # 已完成展开 → 吃掉全部余量 + floor0 = hr if run_f else hr + self.MIN_BODY_H + h1 = max(hd + self.MIN_BODY_H, avail - max(h0, floor0)) + h0 = max(hr if run_f else hr + self.MIN_BODY_H, h0) + h1 = max(hd if done_f else hd + self.MIN_BODY_H, h1) + return [h0, h1, max(0, avail - h0 - h1)] + + def _fold_tick(self): + """抽屉式每帧:ease-out 三次方插值高度 + 内容淡入淡出(同模型选择窗口)""" + d = self._fold + if d is None: + self._fold_timer.stop() + return + d["t"] = min(1.0, d["t"] + self.FOLD_STEP / self.FOLD_MS) + e = 1.0 - (1.0 - d["t"]) ** 3 + a, b = d["from"], d["to"] + self.splitter.setSizes([int(round(x + (y - x) * e)) for x, y in zip(a, b)]) + sec = d["sec"] + sec.set_body_opacity((1.0 - e) if sec.folded else e) + if d["t"] >= 1.0: + self._finish_fold() + + def _finish_fold(self): + self._fold_timer.stop() + d = self._fold + if d is None: + return + self._fold = None + self.splitter.setSizes(d["to"]) + self._end_fold_visual(d["sec"]) + + def _end_fold_visual(self, sec): + if sec.folded: + sec.set_body_opacity(0.0) + sec.hide_body() # 隐藏后连离屏渲染也省了 + else: + sec.show_body() + sec.set_body_opacity(1.0) # 置 1 → 自动移除 effect + + def _sync_handle(self): + """两栏都展开 → 中间分隔条可拖;任一栏收起 → 禁用(避免「刚收起又被拖大」)""" + h = self.splitter.handle(1) + if h is not None: + ok = not (self.sec_running.folded or self.sec_done.folded) + h.setEnabled(ok) + h.setCursor(QtCore.Qt.CursorShape.SplitVCursor if ok + else QtCore.Qt.CursorShape.ArrowCursor) + + def _repin_fold(self): + """窗口/面板尺寸变化后,按统一策略重新钉住(收起栏 = 只剩头部行)""" + if self._fold is not None or self.collapsed: + return + if not (self.sec_running.folded or self.sec_done.folded): + return + want = self._layout_targets() + if want != self.splitter.sizes(): + self.splitter.setSizes(want) + + def get_panelWidth(self): + return self._w + + def set_panelWidth(self, w): + self._w = int(w) + self.setFixedWidth(int(w)) + + panelWidth = QtCore.pyqtProperty(int, fget=get_panelWidth, fset=set_panelWidth) + + def toggle(self): + self._set_collapsed(not self._target) + + def _set_collapsed(self, collapsed: bool): + self._stop_anim() + self._target = collapsed + self.stack.setCurrentWidget( + self.collapse_page if collapsed else self.expand_page) + start_w = self.width() + # 展开时若记录的宽度超出当前窗口可承受范围 → 夹一下 + self._end_w = self.W_COLLAPSE if collapsed else self._clamp_w(self.W_EXPAND) + end_w = self._end_w + self._anim = QtCore.QPropertyAnimation(self, b"panelWidth") + self._anim.setDuration(260) + self._anim.setStartValue(float(start_w)) + self._anim.setEndValue(float(end_w)) + self._anim.setEasingCurve(QtCore.QEasingCurve.Type.InOutCubic) + self._anim.finished.connect(self._on_anim_finished) + self._anim.start() + + def _on_anim_finished(self): + self.setFixedWidth(self.W_COLLAPSE if self._target else self._end_w) + self.collapsed = self._target + self._stop_anim() + + # ---------------- 宽度拖拽(右边界固定,左边界跟随鼠标)---------------- + def _stop_anim(self): + if self._anim is not None: + try: + self._anim.stop() + self._anim.deleteLater() + except Exception: + pass + self._anim = None + + def _clamp_w(self, w) -> int: + """夹在 [W_MIN, min(W_MAX, 主窗口 50%)](最小宽度就在这里生效)""" + hi = self.W_MAX + par = self.parentWidget() + if par is not None and par.width() > 0: + hi = min(hi, max(self.W_MIN, int(par.width() * 0.5))) + return max(self.W_MIN, min(hi, int(w))) + + def resizeEvent(self, e): + super().resizeEvent(e) + h = getattr(self, "_handle", None) + if h is not None: + h.setGeometry(0, 0, _ResizeHandle.W, self.height()) + h.raise_() + self._repin_fold() + + def _drag_begin(self, gx): + self._stop_anim() + # 动画中途按下 → 以当前实际宽度为准确定状态 + self.collapsed = self.width() <= self.W_COLLAPSE + 8 + self._target = self.collapsed + self.stack.setCurrentWidget( + self.collapse_page if self.collapsed else self.expand_page) + self._drag_right = self.mapToGlobal(QtCore.QPoint(self.width(), 0)).x() + + def _drag_to_global_x(self, gx): + if self._drag_right is None: + return + w = self._clamp_w(self._drag_right - int(gx)) + if self.collapsed: + if w <= self.W_COLLAPSE + 8: + return # 还在收起条内拖 → 不响应 + self.collapsed = False # 从收起态直接拖开 → 立即进展开态 + self._target = False + self.stack.setCurrentWidget(self.expand_page) + self._w = w + self.setFixedWidth(w) + + def _commit_drag_width(self): + self._drag_right = None + if self.collapsed: + return + self.W_EXPAND = self._w + if save_panel_width(self._w): + print(f"[UI]: 任务面板宽度 -> {self._w}px(已记录,下次展开自动使用)") + + # ---------------- 数据 ---------------- + def set_session(self, session_id, db, active_stream=None): + """切换会话 → 全量重建。已完成来自 DB 链;未落库的在跑任务来自 active_stream。""" + self._session_id = session_id + self._db = db + self._layers.clear() + self._order.clear() + self._running.clear() + self._done.clear() + + # ① DB:本会话链上的历史 bash(标注是否还在上下文内) + try: + chain = db.get_message_chain(session_id) if db else [] + except Exception: + chain = [] + mark_idx = -1 + for i, m in enumerate(chain): + if m.get("role") == "compaction": + mark_idx = i + for i, m in enumerate(chain): + msg_id = m.get("id") or "" + for e in _iter_bash_entries(m.get("timeline")): + cid = e.get("id") or f"dbmsg_{msg_id}_{i}" + if cid in self._layers: + continue + lay = BashLayer(cid, _cmd_of(e.get("args"))) + lay.set_finished(bool(e.get("ok")), e.get("result") or "") + lay.set_in_context(i > mark_idx) + self._layers[cid] = lay + self._order.append(cid) + self._done.add(cid) + + # ② 内存:正在进行的这一轮(包括已开始但未落库、以及刚结束未落库的) + for e in _iter_bash_entries((active_stream or {}).get("timeline")): + cid = e.get("id") + if not cid or cid in self._layers: + continue + lay = BashLayer(cid, _cmd_of(e.get("args"))) + if e.get("ok") is None: + lay.set_running(0, 0) + if e.get("result"): + lay.append_live(str(e.get("result"))) # 尽力回放(上游上限 4000 字符) + self._running.add(cid) + else: + lay.set_finished(bool(e.get("ok")), e.get("result") or "") + self._done.add(cid) + self._layers[cid] = lay + self._order.append(cid) + + self._refresh() + + def _refresh(self): + # P2-01:两栏都按【启动顺序倒序】显示(最新启动在第一项)。 + # 排序键恒为 self._order 中的位置(稳定启动序号)—— + # · 运行中→已完成 的任务仍占原启动位置(绝不按完成时间重排); + # · 已完成栏的限量窗口 = 最近启动的 LAYER_LIMIT 个(窗口成员不变,仅显示顺序反转)。 + # 重排走 set_layers(复用同一批 BashLayer 实例):展开/折叠状态、实时输出、 + # 代码框水平/垂直滚动值、两栏 section 滚动位置全部保持(同对象,不重建)。 + run_ids = [c for c in self._order if c in self._running] + done_ids = [c for c in self._order if c in self._done] + self.sec_running.set_layers([self._layers[c] for c in reversed(run_ids)]) + shown = done_ids[-LAYER_LIMIT:] + self.sec_done.set_layers([self._layers[c] for c in reversed(shown)], total=len(done_ids)) + self.lbl_total.setText(f"共 {len(self._order)}" if self._order else "") + + # ---------------- 事件 ---------------- + def on_started(self, call_id, name, args): + if name != "bash" or not call_id or call_id in self._layers: + return + lay = BashLayer(call_id, _cmd_of(args)) + lay.set_running(0, 0) + self._layers[call_id] = lay + self._order.append(call_id) + self._running.add(call_id) + self._refresh() + + def on_output(self, call_id, text): + lay = self._layers.get(call_id) + if lay is not None and lay.ok is None: + lay.append_live(text) + + def on_timed(self, call_id, elapsed, timeout): + lay = self._layers.get(call_id) + if lay is not None and lay.ok is None: + lay.set_running(elapsed, timeout) + + def on_finished(self, call_id, name, ok, text): + lay = self._layers.get(call_id) + if lay is None: + if name != "bash" or not call_id: + return + lay = BashLayer(call_id, "") + self._layers[call_id] = lay + self._order.append(call_id) + self._running.discard(call_id) + self._done.add(call_id) + lay.set_finished(bool(ok), text or "") + lay.set_in_context(True) + self._refresh() + + def clear_all(self): + self._layers.clear() + self._order.clear() + self._running.clear() + self._done.clear() + self._refresh() + + # ---------------- 测试/调试辅助 ---------------- + def layer_ids(self, which="all"): + # running/done 返回真实显示顺序(启动倒序);"all" = 原始启动序号(正序) + if which == "running": + return [c for c in self._order if c in self._running][::-1] + if which == "done": + return [c for c in self._order if c in self._done][::-1] + return list(self._order) diff --git a/ui/views/chat_bridge.py b/ui/views/chat_bridge.py new file mode 100644 index 0000000..2d32338 --- /dev/null +++ b/ui/views/chat_bridge.py @@ -0,0 +1,230 @@ +# chat_bridge.py +import json +from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal + +class ChatBridge(QObject): + # 信号定义 + regenerate_clicked = pyqtSignal(str) # 携带需要重新回答的助手消息ID + branch_switch_clicked = pyqtSignal(str, int) # 分支切换信号 (msg_id, direction: -1 为上一条, 1 为下一条) + delete_message_requested = pyqtSignal(str) # 删除消息信号 (msg_id) + attachment_clicked = pyqtSignal(str) + scroll_changed = pyqtSignal(float, float, float) # 页面 scrollY / scrollHeight / innerHeight + # 🆕 P1-01 渲染窗口:前端请求换页(fire-and-forget;响应经 run_js 推送) + window_page_requested = pyqtSignal(str, str, str, int) # (session_id, direction, boundary_id, generation) + + def __init__(self, page, channel, js_runner=None): + """ + :param page: QWebEnginePage 实例(QtWebEngine 路径) + :param channel: QWebChannel 实例(QtWebEngine 路径) + :param js_runner: 可选 JS 执行函数(WebView2 路径:script -> None) + """ + super().__init__() + self.page = page + self._js_runner = js_runner + self.window = None # 🆕 P1-01:MainWindow 引用(分页请求需要访问 DB/代次) + if channel is not None: + # 将自身注册到 channel 中,前端可通过 bridge 对象调用 + channel.registerObject("bridge", self) + print("ChatBridge被初始化") + + def run_js(self, script: str): + if self._js_runner is not None: + self._js_runner(script) + elif self.page is not None: + self.page.runJavaScript(script) + else: + print("[ChatBridge] run_js: 无可用执行通道") + + # ---------- 消息构造 (Python -> JS) ---------- + + def create_message(self, msg_id: str, role: str, text: str = "", sender_name: str = "", branch_info: dict = None): + """ + 在前端创建一条消息容器 + :param branch_info: 格式如 {"current": 0, "total": 2},若为 None 则不显示分支切换器 + """ + safe_text = json.dumps(text) + safe_name = json.dumps(sender_name) + safe_branch = json.dumps(branch_info) if branch_info else "null" + self.run_js(f"createMessage('{msg_id}', '{role}', {safe_text}, {safe_name}, {safe_branch});") + + def create_long_message(self, msg_id: str, role: str, text: str, sender_name: str = ""): + safe_text = json.dumps(text) + safe_name = json.dumps(sender_name) + size_kb = round(len(text.encode('utf-8')) / 1024, 2) + self.run_js(f"createLongMessage('{msg_id}', '{role}', {safe_text}, {safe_name}, {size_kb});") + + def create_user_message_with_attachments(self, msg_id: str, text: str, attachments: list): + safe_text = json.dumps(text) + safe_attachments = json.dumps(attachments) + self.run_js(f"createUserMessageWithAttachments('{msg_id}', {safe_text}, {safe_attachments});") + + # ---------- 流式输出 (Python -> JS) ---------- + def append_token(self, msg_id: str, token: str): + safe_token = json.dumps(token) + self.run_js(f"appendToken('{msg_id}', {safe_token});") + + def append_reasoning(self, msg_id: str, token: str): + safe_token = json.dumps(token) + self.run_js(f"appendReasoning('{msg_id}', {safe_token});") + + def finish_message(self, msg_id: str): + self.run_js(f"finishMessage('{msg_id}');") + + # ---------- 工具执行事件 (Python -> JS) —— pi tool_execution_* ---------- + def tool_execution_started(self, msg_id: str, call_id: str, name: str, args: str): + """工具开始执行:前端在时间线当前位置插入 chip(带 call_id 对号入座)""" + safe_call = json.dumps(call_id) + safe_name = json.dumps(name) + safe_args = json.dumps(args) + self.run_js(f"toolExecutionStarted('{msg_id}', {safe_call}, {safe_name}, {safe_args});") + + def tool_execution_updated(self, msg_id: str, call_id: str, text: str): + """工具执行中的增量输出(bash stdout 等)""" + safe_call = json.dumps(call_id) + safe_text = json.dumps(text) + self.run_js(f"toolExecutionUpdated('{msg_id}', {safe_call}, {safe_text});") + + def tool_execution_timed(self, msg_id: str, call_id: str, + elapsed: int, timeout: int): + """🆕 bash 运行中每秒读秒:气泡摘要行刷新 N/Ts""" + safe_call = json.dumps(call_id) + self.run_js(f"toolExecutionTimed('{msg_id}', {safe_call}, " + f"{int(elapsed)}, {int(timeout)});") + + def tool_execution_finished(self, msg_id: str, call_id: str, name: str, ok: bool, text: str): + """工具执行结束:chip 按 call_id 标记成功/失败并显示结果摘要""" + safe_call = json.dumps(call_id) + safe_name = json.dumps(name) + safe_text = json.dumps(text) + self.run_js(f"toolExecutionFinished('{msg_id}', {safe_call}, {safe_name}, {str(ok).lower()}, {safe_text});") + + # ---------- 时间线 (Python -> JS) ---------- + def restore_streaming_timeline(self, msg_id: str, timeline_json: str): + """切回进行中的会话:按时间线 JSON 恢复 思考/文本/工具 块(续流)""" + safe_json = json.dumps(timeline_json) + self.run_js(f"restoreStreamingTimeline('{msg_id}', {safe_json});") + + def render_timeline_history(self, msg_id: str, timeline_json: str): + """历史消息:按时间线 JSON 静态渲染 思考/文本/工具 块""" + safe_json = json.dumps(timeline_json) + self.run_js(f"renderTimelineHistory('{msg_id}', {safe_json});") + + # ---------- 压缩可视化 (Python -> JS) ---------- + def compaction_started(self, msg_id: str, path: str): + """压缩开始 → 当前消息时间线内显示「上下文压缩」思考气泡(与深度思考同款)""" + safe_id = json.dumps(msg_id or "") + safe_path = json.dumps(path or "") + self.run_js(f"compactionStarted({safe_id}, {safe_path});") + + def compaction_finished(self, msg_id: str, payload: dict): + """压缩完成 → 同一气泡原地更新(前→后 token + 摘要全文)""" + safe_id = json.dumps(msg_id or "") + safe_payload = json.dumps(payload or {}, ensure_ascii=False) + self.run_js(f"compactionFinished({safe_id}, {safe_payload});") + + # ---------- 系统提示 (Python -> JS) ---------- + def show_note(self, text: str): + """在聊天流中插入一条居中系统提示(如:已自动压缩上下文)""" + safe_text = json.dumps(text) + self.run_js(f"showSystemNote({safe_text});") + + # ---------- 错误与清理 (Python -> JS) ---------- + def show_error(self, msg_id: str, error_text: str): + safe_text = json.dumps(error_text) + self.run_js(f"showError('{msg_id}', {safe_text});") + + def clear_chat(self): + self.run_js("clearChat();") + + def delete_message(self, msg_id: str): + """通知前端从 DOM 中移除特定消息""" + self.run_js(f"deleteMessage('{msg_id}');") + + # ---------- 历史记录渲染 ---------- + + def render_history_message(self, msg_id: str, role: str, content: str, reasoning: str = "", branch_info: dict = None): + self.create_message(msg_id, role, content, branch_info=branch_info) + if reasoning: + safe_reasoning = json.dumps(reasoning) + self.run_js(f"insertThinkBlock('{msg_id}', {safe_reasoning});") + self.finish_message(msg_id) + + def show_welcome(self): + self.run_js("showWelcome();") + + # ---------- 会话加载界面 (Python -> JS) ---------- + def show_loading(self): + """显示统一加载界面(图标 + 从左到右扫描条)""" + self.run_js("showLoadingOverlay();") + + def hide_loading(self): + """隐藏加载界面(带渐变退场)""" + self.run_js("hideLoadingOverlay();") + + # ---------- 🆕 P1-01 渲染窗口 (Python -> JS) ---------- + + def rw_config(self, mode: str, size: int): + """注入渲染窗口配置(JS 就绪后、首次加载前调用一次)""" + self.run_js("rwApplyConfig(" + json.dumps( + {"render_window_mode": mode, "render_window_size": size}) + ");") + + def rw_begin(self, session_id: str, generation: int, total: int): + """开始一轮窗口化加载:清屏 + 重同步 (session, generation)""" + self.run_js(f"rwBegin({json.dumps(session_id)}, {int(generation)}, {int(total)});") + + def rw_init_window(self, session_id: str, generation: int, chain_len: int, items): + """初始窗口就绪:items = [(msg_id, chain_index), ...](旧→新)""" + payload = json.dumps({ + "sessionId": session_id, + "generation": int(generation), + "chainLen": int(chain_len), + "items": [{"id": mid, "chainIndex": ix} for mid, ix in items], + }, ensure_ascii=False) + self.run_js(f"rwInitWindow({payload});") + + def rw_note_live(self, session_id: str, generation: int, msg_id: str, + chain_index: int, chain_len: int = -1): + """新消息追加进窗口(发送用户消息 / 助手占位 / 切回续流)。 + 未持久化/未知 → chain_index=-1;未知链长 → chain_len=-1(JS 保持旧值)""" + self.run_js(f"rwNoteLive({json.dumps(session_id)}, {int(generation)}, " + f"{json.dumps(msg_id)}, {int(chain_index)}, {int(chain_len)});") + + def rw_page_response(self, payload: dict): + """分页响应推送:payload 由 MainWindow._on_window_page_request 构造""" + self.run_js("rwPageResponse(" + json.dumps(payload, ensure_ascii=False) + ");") + + # ---------- 交互 Slot (JS -> Python) ---------- + + @pyqtSlot(str, str, str, int) + def onRequestWindowPage(self, session_id, direction, boundary_msg_id, generation): + """🆕 P1-01:前端请求换页(direction: older/newer)。 + fire-and-forget:结果由 Python 经 rwPageResponse 推送(双引擎同构)。""" + self.window_page_requested.emit(session_id, direction, boundary_msg_id, int(generation)) + + @pyqtSlot(str) + def onRegenerateClicked(self, msg_id): + """前端点击“重新生成”按钮时触发""" + print(f"Regenerate requested for: {msg_id}") + self.regenerate_clicked.emit(msg_id) + + @pyqtSlot(str, int) + def onBranchSwitch(self, msg_id, direction): + """前端点击分支切换箭头时触发""" + print(f"Branch switch requested for: {msg_id}, direction: {direction}") + self.branch_switch_clicked.emit(msg_id, direction) + + @pyqtSlot(str) + def onDeleteMessageClicked(self, msg_id): + """接收 JS 发来的确认删除指令(🌟 已去重,原先定义了两次)""" + print(f"Delete requested for: {msg_id}") + self.delete_message_requested.emit(msg_id) + + @pyqtSlot(str) + def onAttachmentClicked(self, meta_json_str): + """接收 JS 发来的附件点击事件""" + self.attachment_clicked.emit(meta_json_str) + + @pyqtSlot(float, float, float) + def onScrollChanged(self, y, content_h, client_h): + """接收前端上报的页面滚动信息,用于自定义滚动条位置/滑块长度镜像""" + self.scroll_changed.emit(y, content_h, client_h) diff --git a/ui/views/custom_web_page.py b/ui/views/custom_web_page.py new file mode 100644 index 0000000..0571d37 --- /dev/null +++ b/ui/views/custom_web_page.py @@ -0,0 +1,77 @@ +from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineScript, QWebEngineProfile +from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QDesktopServices + +class CustomWebPage(QWebEnginePage): + """ + 自定义网页类,集中约束内置 QtWebEngine 的不合规浏览器行为: + + 1. 拦截所有外部链接跳转,改用系统默认浏览器打开; + 2. 禁用 Ctrl/Meta + 滚轮 及 Ctrl + +/-/0 快捷键的页面缩放 + (浏览器式缩放对桌面聊天工具无意义且易误触)。 + """ + def __init__(self, profile_or_parent=None, parent=None): + # P1-03:兼容两种调用 —— 新式 CustomWebPage(profile, parent) 与旧式 CustomWebPage(parent)。 + # profile 缺省 None → QtWebEngine 默认 profile;main_window 传入本实例独立 profile。 + if isinstance(profile_or_parent, QWebEngineProfile): + profile, par = profile_or_parent, parent + else: + profile, par = None, profile_or_parent + if parent is not None: + raise TypeError("CustomWebPage(profile, parent) 或 CustomWebPage(parent)") + if profile is not None: + super().__init__(profile, par) + else: + super().__init__(par) + self._inject_zoom_lock() + + def _inject_zoom_lock(self): + """ + 🌟 缩放锁定:通过网页脚本在文档创建阶段(DocumentCreation)注入监听, + 使用隔离世界(ApplicationWorld)注入,不污染页面自身逻辑。 + + 原理:Chromium 的 Ctrl+滚轮缩放会先经过页面 wheel 事件的 + preventDefault() 判定 —— 页面阻止即取消缩放,这是地图类应用 + 禁用缩放的官方做法;Ctrl+=/-/0 快捷键同理用 keydown 拦截。 + """ + script = QWebEngineScript() + script.setName("haocode_zoom_lock") + script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) + script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld) + script.setRunsOnSubFrames(True) + script.setSourceCode(r""" +(function () { + if (window.__haocodeZoomLocked) return; // 防重复注入 + window.__haocodeZoomLocked = true; + + // 1) Ctrl/Meta + 滚轮 → 阻止缩放 + document.addEventListener('wheel', function (e) { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + } + }, { passive: false }); // 必须非被动,preventDefault 才有效 + + // 2) Ctrl + = / + / - / 0 → 阻止缩放快捷键 + document.addEventListener('keydown', function (e) { + if ((e.ctrlKey || e.metaKey) && ['+', '-', '=', '0'].indexOf(e.key) >= 0) { + e.preventDefault(); + } + }, false); +})(); +""") + self.scripts().insert(script) + + def acceptNavigationRequest(self, url: QUrl, nav_type, is_main_frame): + # 如果是本地文件(我们自己的 index.html),允许加载 + if url.scheme() == "file": + return True + + # 如果是外部链接(http/https),用系统浏览器打开 + if url.scheme() in ["http", "https"]: + print(f"[System]: 在系统浏览器中打开 -> {url.toString()}") + QDesktopServices.openUrl(url) + return False # 阻止在应用内跳转 + + # 其他情况(如 javascript:void(0)),允许 + return True \ No newline at end of file diff --git a/ui/views/debug_window.py b/ui/views/debug_window.py new file mode 100644 index 0000000..db6c76d --- /dev/null +++ b/ui/views/debug_window.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +"""独立调试器窗口 —— 与主窗口/项目树完全解耦(顶层窗口,parent=None) + +功能: + Tab1「调试会话」 实时 tail data/debug_session.log,按来源着色: + [USER]蓝 / [AGENT]绿 / [APP]灰 / [SYS]紫 + Tab2「应用日志」 实时 tail diag.log + compaction_diag.log + stream_diag.log + 底部输入框 用户输入观察到的情况,回车 → 记 [USER] + 按钮 暂停显示 / 清空会话日志 / 打开日志文件 + +控制: 代理写 data/debug_window.cmd (show/hide),主窗口 2s 轮询后调用本窗口。 +本文件不 import main_window,可独立离屏测试。 +""" +import os +import re +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QTextCharFormat, QTextCursor, QColor +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, + QLineEdit, QPushButton, QCheckBox, QLabel, + QTabWidget, QMessageBox) +from core.debug_log import DEBUG_LOG_PATH, DEBUG_CMD_PATH, debug_log + +_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_APP_LOGS = [ + ("DIAG", os.path.join(_ROOT, "diag.log")), + ("COMPACT", os.path.join(_ROOT, "compaction_diag.log")), + ("STREAM", os.path.join(_ROOT, "stream_diag.log")), +] + +_TAG_COLORS = {"USER": "#2563eb", "AGENT": "#16a34a", + "APP": "#6b7280", "SYS": "#9333ea"} +_LINE_RE = re.compile(r"^\[([^\]]+)\]\s+\[([A-Z]+)\]\s?(.*)$") + + +class _TailReader: + """单文件增量读取器(文件被截断时自动重置偏移)""" + + def __init__(self, path: str): + self.path = path + self.offset = 0 + + def read_new(self) -> str: + try: + if not os.path.exists(self.path): + return "" + size = os.path.getsize(self.path) + if size < self.offset: # 被清空/轮转 + self.offset = 0 + if size == self.offset: + return "" + with open(self.path, "r", encoding="utf-8", errors="replace") as f: + f.seek(self.offset) + data = f.read() + self.offset = size + return data + except Exception: + return "" + + +class DebugWindow(QWidget): + def __init__(self): + super().__init__(None) # 顶层独立窗口 + self.setWindowTitle("Haocode 调试器") + self.resize(780, 540) + self.setWindowFlags(Qt.WindowType.Window) + # 默认停靠主屏右上角,避免被主窗口挡住 + try: + from PyQt6.QtGui import QGuiApplication + _geo = QGuiApplication.primaryScreen().availableGeometry() + self.move(_geo.right() - self.width() - 24, _geo.top() + 24) + except Exception: + pass + + self._paused = False + self._announced = False + + root = QVBoxLayout(self) + root.setContentsMargins(8, 8, 8, 8) + + # ---- Tab 容器 ---- + self._tabs = QTabWidget() + self._view_session = QPlainTextEdit() + self._view_session.setReadOnly(True) + self._view_session.setMaximumBlockCount(3000) + self._view_session.setLineWrapMode( + QPlainTextEdit.LineWrapMode.NoWrap) + self._view_app = QPlainTextEdit() + self._view_app.setReadOnly(True) + self._view_app.setMaximumBlockCount(3000) + self._view_app.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + self._tabs.addTab(self._view_session, "调试会话") + self._tabs.addTab(self._view_app, "应用日志") + root.addWidget(self._tabs, 1) + + # ---- 状态行 ---- + status = QLabel(f"会话日志: {DEBUG_LOG_PATH}\n" + f"控制文件: {DEBUG_CMD_PATH} (show/hide)") + status.setStyleSheet("color:#888; font-size:11px;") + status.setWordWrap(True) + root.addWidget(status) + + # ---- 按钮行 ---- + btn_row = QHBoxLayout() + self._chk_pause = QCheckBox("暂停显示(记录继续)") + self._chk_pause.toggled.connect(self._on_pause_toggled) + self._chk_top = QCheckBox("置顶") + self._chk_top.toggled.connect(self._on_top_toggled) + btn = QPushButton("清空会话日志") + btn.clicked.connect(self._on_clear) + btn2 = QPushButton("打开日志文件") + btn2.clicked.connect(self._on_open_file) + btn_row.addWidget(self._chk_pause) + btn_row.addWidget(self._chk_top) + btn_row.addStretch(1) + btn_row.addWidget(btn) + btn_row.addWidget(btn2) + root.addLayout(btn_row) + + # ---- 用户输入行 ---- + in_row = QHBoxLayout() + hint = QLabel("观察到的情况(回车记录为 [USER]):") + hint.setStyleSheet("color:#555; font-size:12px;") + self._input = QLineEdit() + self._input.setPlaceholderText("例如: 上下文标签显示 40.5k,刚发送了「接着输出」") + self._input.returnPressed.connect(self._on_submit) + in_row.addWidget(hint) + in_row.addWidget(self._input, 1) + root.addLayout(in_row) + + # ---- 文件增量读取器 + 轮询 ---- + self._reader_session = _TailReader(DEBUG_LOG_PATH) + self._readers_app = {tag: _TailReader(p) for tag, p in _APP_LOGS} + self._timer = QTimer(self) + self._timer.setInterval(500) + self._timer.timeout.connect(self._tick) + self._timer.start() + + # ==================== 轮询 ==================== + def _tick(self): + try: + if not self._announced: + self._announced = True + debug_log("调试窗口开启", "SYS") + if not self._paused: + data = self._reader_session.read_new() + if data: + self._append_tagged(self._view_session, data) + for tag, _p in _APP_LOGS: + d = self._readers_app[tag].read_new() + if d: + self._view_app.appendPlainText( + f"── [{tag}] {os.path.basename(_p)} ──") + self._view_app.appendPlainText(d.rstrip("\n")) + except Exception: + pass + + def _append_tagged(self, view: QPlainTextEdit, data: str): + cur = view.textCursor() + cur.movePosition(QTextCursor.MoveOperation.End) + for line in data.splitlines(): + if not line: + continue + m = _LINE_RE.match(line) + color = _TAG_COLORS.get(m.group(2)) if m else None + fmt = QTextCharFormat() + if color: + fmt.setForeground(QColor(color)) + cur.insertText(line + "\n", fmt) + view.setTextCursor(cur) + view.ensureCursorVisible() + + # ==================== 交互 ==================== + def _on_submit(self): + text = self._input.text().strip() + if not text: + return + debug_log(text, "USER") + self._input.clear() + + def _on_pause_toggled(self, checked: bool): + self._paused = checked + + def _on_top_toggled(self, checked: bool): + f = self.windowFlags() + if checked: + f |= Qt.WindowType.WindowStaysOnTopHint + else: + f &= ~Qt.WindowType.WindowStaysOnTopHint + self.setWindowFlags(f) + self.show() # setWindowFlags 会隐藏窗口,需重新 show + + def _on_clear(self): + if QMessageBox.question( + self, "清空会话日志", + "将清空 debug_session.log(USER/AGENT/APP 记录全部丢失),确定?") \ + != QMessageBox.StandardButton.Yes: + return + try: + open(DEBUG_LOG_PATH, "w", encoding="utf-8").close() + self._reader_session.offset = 0 + self._view_session.clear() + debug_log("会话日志已清空", "SYS") + except Exception: + pass + + def _on_open_file(self): + try: + import subprocess + if os.name == "nt": + os.startfile(DEBUG_LOG_PATH) # noqa + else: + subprocess.Popen(["xdg-open", DEBUG_LOG_PATH]) + except Exception: + pass diff --git a/ui/views/main_window.py b/ui/views/main_window.py new file mode 100644 index 0000000..9365e9e --- /dev/null +++ b/ui/views/main_window.py @@ -0,0 +1,6222 @@ +import sys +from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6.QtWebEngineWidgets import QWebEngineView +# 引入我们刚才写的线程工作类 +from core.llm_engine import AgentWorker, ChatWorker, TitleWorker +import os +import json +from ui.views.custom_web_page import CustomWebPage +from PyQt6.QtCore import QUrl # 🌟 新增:用于加载本地 HTML + +# ========== 流式诊断日志(排查显示问题的根本手段,写 stream_diag.log) ========== +import time as _diag_time +DIAG_LOG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "stream_diag.log") + +def diag_log(line: str): + try: + t = _diag_time.time() + with open(DIAG_LOG_PATH, "a", encoding="utf-8") as f: + f.write(f"[{_diag_time.strftime('%H:%M:%S', _diag_time.localtime(t))}." + f"{int(t * 1000) % 1000:03d}] {line}\n") + except Exception: + pass +import uuid # <--- 🌟 加上这一行! +from ui.views.chat_bridge import ChatBridge # 🌟 新增:引入桥接器 +from core import renderer_backend as _renderer_backend # P1-03 渲染器矩阵(纯 stdlib) +# 🆕 WebView2 后端(A′ 双轨:Windows 首选,任何失败自动回落 QtWebEngine) +# P1-03:非 Windows 永不导入 core.webview2(不触达 pythonnet/Win32 API/WebView2 DLL/taskkill) +_wv2mod = None +if sys.platform == "win32": + try: + from core import webview2 as _wv2mod + except Exception as _wv2_imp_err: # pythonnet 缺失等 + _wv2mod = None + print(f"[WV2] 后端不可用(回落 QtWebEngine): {_wv2_imp_err}") +from ui.views.system_tools.screen_capture import ScreenCaptureOverlay # 🌟 截图覆盖层 +from ui.views.system_tools.global_hotkey import GlobalHotkeyThread # 🌟 全局热键监听 +from ui.views.system_tools import desktop_session # P1-04 平台会话/能力路由(纯 stdlib) +from core.db_manager import DBManager +from core.debug_log import debug_log, poll_debug_cmd # 🆕 调试窗口文件协议(独立于项目树) +from ui.views.system_tools.file_reader import read_text_file, BINARY_EXTS # 🌟 文本/代码文件读取(编码探测+二进制探测) +from ui.views.bash_panel import BashPanel # 🆕 右侧任务面板(bash 任务监控) +from tools.builtin_tools.pdf_reader import extract_pdf_text, extract_pdf_images # 🌟 PDF 工具(文本+图片解析) +from PyQt6 import QtWidgets, QtCore, QtGui +# -*- coding: utf-8 -*- +""" +设置窗口 (SettingsWindow) - 完整改进版 +修复了: +1. 右侧按钮卡边问题(优化内边距) +2. 居中虚化遮罩(固定卡片尺寸 + 全屏半透明背景) +3. 模型垂直排列(改用 QVBoxLayout) +""" +class AttachmentPreviewOverlay(QtWidgets.QWidget): + """🌟 原生附件预览覆盖层 (全屏半透明遮罩 + 居中卡片 + 现代UI + 一键复制) + + 🆕 必须是无边框顶层窗口(独立 HWND):WebView2 是原生子窗,永远绘制在主窗口 + 内所有 Qt 控件之上 —— 若预览层是 bg_widget 的子控件,遮罩盖不住 webview + (webview 显得特别亮),居中的卡片会被压到 webview 下面。独立顶层窗口 + + WA_TranslucentBackground(逐像素 alpha)才能盖住原生子窗。 + """ + def __init__(self, meta_dict, parent=None, siblings=None, index=0, att_data=None): + super().__init__(parent) + self.meta = meta_dict + self._siblings = siblings if siblings else [meta_dict] # 同组可翻页图片(单图时即自身) + self._index = index + self._att_data = att_data # PDF 附件字典(提供时显示“选择”按钮并回写 selected_images) + # 🆕 无边框顶层 Tool 窗(不入任务栏,Windows 上默认浮在父窗之上) + self._main = parent.window() if parent is not None else None + self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) + if self._main is not None: + # 完全覆盖主窗口(含边框);移动/大小变化由 eventFilter 跟随 + self.setGeometry(self._main.frameGeometry()) + self._main.installEventFilter(self) + + # 灰色半透明遮罩 + self._overlay_color = QtGui.QColor(0, 0, 0, 100) + + # 主卡片 + self.card = QtWidgets.QFrame(self) + self.card.setObjectName("preview_card") + self.card.setStyleSheet(""" + #preview_card { background: #ffffff; border-radius: 12px; border: 1px solid #dcdcdc; } + """) + + lay = QtWidgets.QVBoxLayout(self.card) + lay.setContentsMargins(20, 16, 20, 20) + lay.setSpacing(12) + + # 🌟 统一的现代滚动条样式 + modern_scrollbar_qss = """ + QScrollBar:vertical { + border: none; background: transparent; width: 8px; margin: 0px; + } + QScrollBar::handle:vertical { + background: #d0d0d0; min-height: 40px; border-radius: 4px; + } + QScrollBar::handle:vertical:hover { + background: #a0a0a0; + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; border: none; background: transparent; + } + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: transparent; + } + QScrollBar:horizontal { + border: none; background: transparent; height: 8px; margin: 0px; + } + QScrollBar::handle:horizontal { + background: #d0d0d0; min-width: 40px; border-radius: 4px; + } + QScrollBar::handle:horizontal:hover { + background: #a0a0a0; + } + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0px; border: none; background: transparent; + } + QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + background: transparent; + } + """ + + # 标题栏 + title_lay = QtWidgets.QHBoxLayout() + title_label = QtWidgets.QLabel(self.meta.get("name", "文本内容")) + self._title_label = title_label + title_label.setStyleSheet(""" + font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif; + font-size: 16px; font-weight: bold; color: #222; background: transparent; + """) + title_lay.addWidget(title_label) + title_lay.addStretch() + + # 🌟 核心新增:如果是长文本,添加“复制”按钮 + if self.meta.get("type") != "image": + self.btn_copy = QtWidgets.QPushButton("复制") + self.btn_copy.setFixedSize(64, 28) + self.btn_copy.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self._set_copy_btn_style(self.btn_copy, False) # 设置初始样式 + self.btn_copy.clicked.connect(self._on_copy_clicked) + title_lay.addWidget(self.btn_copy) + title_lay.addSpacing(8) # 复制按钮和关闭按钮之间留点空隙 + + # PDF 解析图:添加“选择”按钮(选中后绿色边框,行为对标复制按钮) + if self.meta.get("type") == "image" and self._att_data is not None: + self.btn_select = QtWidgets.QPushButton("选择") + self.btn_select.setFixedSize(64, 28) + self.btn_select.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self._set_select_btn_style(self._is_current_selected()) + self.btn_select.clicked.connect(self._on_select_clicked) + title_lay.addWidget(self.btn_select) + title_lay.addSpacing(8) # 选择按钮和关闭按钮之间留点空隙 + + btn_x = QtWidgets.QPushButton("✕") + btn_x.setFixedSize(28, 28) + btn_x.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + btn_x.setStyleSheet(""" + QPushButton { + font-family: "Microsoft YaHei UI", sans-serif; + background: transparent; border: none; border-radius: 14px; color: #999; font-size: 15px; + } + QPushButton:hover { background: #eee; color: #333; } + """) + btn_x.clicked.connect(self.close_overlay) + title_lay.addWidget(btn_x) + lay.addLayout(title_lay) + + # 内容区 (图片或文本) + if self.meta.get("type") == "image": + import os + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.abspath(os.path.join(current_dir, '..', '..')) + abs_path = os.path.join(root_dir, self.meta.get("local_path", "")) + + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setStyleSheet(f""" + QScrollArea {{ border: 1px solid #eee; border-radius: 8px; background: #f9f9f9; }} + {modern_scrollbar_qss} + """) + + img_label = QtWidgets.QLabel() + img_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + img_label.setStyleSheet("background: transparent;") + self._img_label = img_label # 翻页时复用它来切换图片 + + if os.path.exists(abs_path): + pixmap = QtGui.QPixmap(abs_path) + if pixmap.width() > 800 or pixmap.height() > 600: + pixmap = pixmap.scaled(800, 600, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.TransformationMode.SmoothTransformation) + img_label.setPixmap(pixmap) + else: + img_label.setText("❌ 图片文件已丢失或被物理删除") + img_label.setStyleSheet('font-family: "Microsoft YaHei UI", sans-serif; color: red; font-size: 14px;') + + scroll.setWidget(img_label) + lay.addWidget(scroll) + self.card.setFixedSize(860, 680) + else: + text_edit = QtWidgets.QTextEdit() + text_edit.setReadOnly(True) + text_edit.setPlainText(self.meta.get("content", "")) + text_edit.setStyleSheet(f""" + QTextEdit {{ + border: 1px solid #eee; border-radius: 8px; padding: 12px; + background: #fafafa; color: #333; + font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif; + font-size: 14px; line-height: 1.5; + }} + {modern_scrollbar_qss} + """) + lay.addWidget(text_edit) + self.card.setFixedSize(600, 500) + + self._center_card() + # 多张图片(如 PDF 解析图)时,叠加左右翻页按钮 + 页码计数 + if self.meta.get("type") == "image" and len(self._siblings) > 1: + self._add_nav_buttons() + self.show() + self.raise_() + self.activateWindow() + self._animate_in() + + # ==================== 🌟 复制功能相关方法 ==================== + def _on_copy_clicked(self): + """一键复制到剪贴板,并触发按钮变色动画""" + text = self.meta.get("content", "") + if text: + # 写入系统剪贴板 + QtWidgets.QApplication.clipboard().setText(text) + + # 按钮变绿反馈 + self.btn_copy.setText("已复制") + self._set_copy_btn_style(self.btn_copy, is_copied=True) + + # 1.5秒后恢复原状 + QtCore.QTimer.singleShot(1500, lambda: self._restore_copy_btn()) + + def _restore_copy_btn(self): + """恢复复制按钮状态""" + try: + self.btn_copy.setText("复制") + self._set_copy_btn_style(self.btn_copy, is_copied=False) + except RuntimeError: + pass # 防止窗口已经关闭导致报错 + + def _set_copy_btn_style(self, btn, is_copied): + """统一管理复制按钮的 QSS 样式""" + if is_copied: + # 成功状态:浅绿色背景,深绿色文字 + btn.setStyleSheet(""" + QPushButton { + font-family: "Microsoft YaHei UI", sans-serif; + background: #e6f4ea; border: none; border-radius: 6px; color: #1e8e3e; font-size: 13px; font-weight: bold; + } + """) + else: + # 默认状态:浅灰色背景,深灰色文字 + btn.setStyleSheet(""" + QPushButton { + font-family: "Microsoft YaHei UI", sans-serif; + background: #f0f0f0; border: none; border-radius: 6px; color: #555; font-size: 13px; + } + QPushButton:hover { background: #e0e0e0; color: #333; } + """) + # ========================================================= + + # ==================== 🌟 图片选择功能相关方法(PDF 解析图) ==================== + def _current_image_dict(self): + """当前预览图对应的完整 image dict(用于写入 selected_images)。""" + if not (self._att_data and self._att_data.get("images")): + return None + lp = self._siblings[self._index].get("local_path") + return next((x for x in self._att_data["images"] if x.get("local_path") == lp), None) + + def _is_current_selected(self): + """当前预览图是否已在 selected_images 中。""" + if not self._att_data: + return False + lp = self._siblings[self._index].get("local_path") + return any(s.get("local_path") == lp for s in self._att_data.get("selected_images", [])) + + def _on_select_clicked(self): + """切换当前图的选中态,并回写 att_data["selected_images"](重开弹窗勾选仍在)。""" + im = self._current_image_dict() + if im is None: + return + sel = self._att_data.setdefault("selected_images", []) + lp = im["local_path"] + if any(s.get("local_path") == lp for s in sel): + self._att_data["selected_images"] = [s for s in sel if s.get("local_path") != lp] + selected = False + else: + sel.append(im) + selected = True + self._set_select_btn_style(selected) + + def _set_select_btn_style(self, selected): + """统一管理选择按钮的 QSS 样式:选中=绿色边框,未选=灰底。""" + if selected: + self.btn_select.setText("已选择") + self.btn_select.setStyleSheet(""" + QPushButton { + font-family: "Microsoft YaHei UI", sans-serif; + background: #e6f4ea; border: 1px solid #1e8e3e; border-radius: 6px; + color: #1e8e3e; font-size: 13px; font-weight: bold; + } + """) + else: + self.btn_select.setText("选择") + self.btn_select.setStyleSheet(""" + QPushButton { + font-family: "Microsoft YaHei UI", sans-serif; + background: #f0f0f0; border: 1px solid transparent; border-radius: 6px; + color: #555; font-size: 13px; + } + QPushButton:hover { background: #e0e0e0; color: #333; } + """) + # ========================================================= + + def _center_card(self): + x = (self.width() - self.card.width()) // 2 + y = (self.height() - self.card.height()) // 2 + self.card.move(x, y) + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + painter.fillRect(self.rect(), self._overlay_color) + painter.end() + + def mousePressEvent(self, event): + if not self.card.geometry().contains(event.pos()): + self.close_overlay() + + def resizeEvent(self, event): + self._center_card() + + def eventFilter(self, obj, ev): + """跟随主窗口移动/大小变化;主窗口隐藏(最小化)时自动关闭预览。""" + if self._main is not None and obj is self._main: + t = ev.type() + if t == QtCore.QEvent.Type.Move: + self.move(self._main.frameGeometry().topLeft()) + elif t == QtCore.QEvent.Type.Resize: + self.setGeometry(self._main.frameGeometry()) + self._center_card() + elif t in (QtCore.QEvent.Type.Close, QtCore.QEvent.Type.Hide): + self.close_overlay() + return super().eventFilter(obj, ev) + + def _animate_in(self): + # 顶层窗口用 windowOpacity 动画(QGraphicsEffect 作用在顶层窗上不可靠) + self.setWindowOpacity(0.0) + self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity") + self._anim.setDuration(150) + self._anim.setStartValue(0.0) + self._anim.setEndValue(1.0) + self._anim.start() + + def close_overlay(self): + self.deleteLater() + + # ==================== 🌟 图片翻页功能(PDF 解析图等多图场景) ==================== + def _load_image(self, local_path): + """按相对路径加载图片到 self._img_label,超出 800x600 则等比缩放。""" + import os + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.abspath(os.path.join(current_dir, '..', '..')) + abs_path = os.path.join(root_dir, local_path) + if os.path.exists(abs_path): + pixmap = QtGui.QPixmap(abs_path) + if pixmap.width() > 800 or pixmap.height() > 600: + pixmap = pixmap.scaled(800, 600, QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation) + self._img_label.setStyleSheet("background: transparent;") + self._img_label.setPixmap(pixmap) + else: + self._img_label.clear() + self._img_label.setText("❌ 图片文件已丢失或被物理删除") + self._img_label.setStyleSheet( + 'font-family: "Microsoft YaHei UI", sans-serif; color: red; font-size: 14px;') + + def _add_nav_buttons(self): + """在卡片左右两侧叠加圆形翻页按钮(页码信息改由顶部标题展示)。""" + nav_qss = """ + QPushButton { + background: rgba(0,0,0,0.35); border: none; border-radius: 20px; + color: #ffffff; font-size: 24px; font-weight: bold; + } + QPushButton:hover { background: rgba(0,0,0,0.55); } + """ + self.btn_prev = QtWidgets.QPushButton("‹", self.card) + self.btn_next = QtWidgets.QPushButton("›", self.card) + for b in (self.btn_prev, self.btn_next): + b.setFixedSize(40, 40) + b.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + b.setStyleSheet(nav_qss) + b.raise_() + self.btn_prev.clicked.connect(self._nav_prev) + self.btn_next.clicked.connect(self._nav_next) + cy = self.card.height() // 2 - 20 + self.btn_prev.move(44, cy) # 较初版向内收 30px + self.btn_next.move(self.card.width() - 84, cy) # 较初版向内收 30px(与左侧对称) + + def _show_sibling(self): + meta = self._siblings[self._index] + self._title_label.setText(meta.get("name", "")) + self._load_image(meta.get("local_path", "")) + if getattr(self, "btn_select", None) is not None: # 翻页后刷新“选择”按钮状态 + self._set_select_btn_style(self._is_current_selected()) + + def _nav_prev(self): + self._index = (self._index - 1) % len(self._siblings) + self._show_sibling() + + def _nav_next(self): + self._index = (self._index + 1) % len(self._siblings) + self._show_sibling() + # ===================================================================== + + +class SettingsWindow(QtWidgets.QWidget): + """设置窗口:虚化遮罩居中自适应卡片""" + close_requested = QtCore.pyqtSignal() + + def __init__(self, parent=None, config_data=None): + super().__init__(parent) + self.config_data = config_data or {} + self._setup_ui() + self._setup_animation() + + # ------------------------------------------------------------------ + def _setup_ui(self): + # 全屏透明遮罩(只负责背景展示,不负责关闭) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True) + self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint) + + self.overlay = QtWidgets.QWidget(self) + self.overlay.setStyleSheet("background-color: rgba(0, 0, 0, 0.45);") + # 🌟 遮罩不负责关闭,只响应空白区域 + + # 主卡片(居中,动态尺寸) + self.main_card = QtWidgets.QWidget(self.overlay) + self.main_card.setStyleSheet("background-color: #ffffff; border-radius: 12px;") + # 🌟 关键:安装事件过滤器,阻止点击冒泡到 overlay + self.main_card.installEventFilter(self) + + # 内部垂直布局 + self._vlayout = QtWidgets.QVBoxLayout(self.main_card) + self._vlayout.setContentsMargins(0, 0, 0, 0) + self._vlayout.setSpacing(0) + + self._build_topbar() + + body = QtWidgets.QWidget() + body_layout = QtWidgets.QHBoxLayout(body) + body_layout.setContentsMargins(0, 0, 0, 0) + body_layout.setSpacing(0) + + self.sidebar = QtWidgets.QWidget() + self.sidebar.setStyleSheet("background-color: #f7f7f7; border-radius: 0 0 0 12px;") + self._build_sidebar() + body_layout.addWidget(self.sidebar) + + self.content_stack = QtWidgets.QStackedWidget() + self.content_stack.addWidget(self._build_provider_section()) + self.content_stack.addWidget(self._build_placeholder_section()) + body_layout.addWidget(self.content_stack, 1) + + self._vlayout.addWidget(body) + + self.resize_to_parent() + self.content_stack.setCurrentWidget(self.content_stack.widget(0)) + + # ------------------------------------------------------------------ + def eventFilter(self, obj, event): + """🌟 核心:拦截 main_card 上的鼠标点击,防止冒泡到 overlay 触发关闭""" + if obj is self.main_card and event.type() == QtCore.QEvent.Type.MouseButtonPress: + event.ignore() # 阻止冒泡 + return True + return super().eventFilter(obj, event) + + # ------------------------------------------------------------------ + def _build_topbar(self): + topbar = QtWidgets.QWidget() + topbar.setFixedHeight(48) + topbar.setStyleSheet("background-color: #ffffff; border-radius: 12px 12px 0 0;") + topbar_layout = QtWidgets.QHBoxLayout(topbar) + topbar_layout.setContentsMargins(24, 0, 8, 0) + topbar_layout.setSpacing(0) + + # 🌟 用 layout 自动推 close_btn 到最右边 + title = QtWidgets.QLabel("设置") + title.setStyleSheet( + "font-size: 15px; font-weight: 600; color: #333; background: transparent;" + ) + topbar_layout.addWidget(title) + topbar_layout.addStretch() + + close_btn = QtWidgets.QPushButton("✕") + close_btn.setFixedSize(40, 40) + close_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + close_btn.setStyleSheet( + "QPushButton { background: transparent; border: none; " + "font-size: 16px; color: #999; border-radius: 8px; } " + "QPushButton:hover { background-color: #f0f0f0; color: #555; }" + ) + close_btn.clicked.connect(lambda: self._close(immediate=True)) + + topbar_layout.addWidget(close_btn) + + self._vlayout.addWidget(topbar) + + # ------------------------------------------------------------------ + def _build_sidebar(self): + sidebar_layout = QtWidgets.QVBoxLayout(self.sidebar) + sidebar_layout.setContentsMargins(0, 16, 0, 16) + sidebar_layout.setSpacing(4) + + nav_data = [ + ("模型提供商", "provider"), + ("该功能未开发", "placeholder"), + ] + self.nav_buttons = {} + for label, key in nav_data: + btn = QtWidgets.QPushButton(label) + btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + btn.setStyleSheet( + "QPushButton { " + " background: transparent; border: none; " + " text-align: left; padding: 10px 20px; " + " font-size: 14px; color: #555; " + "} " + "QPushButton:hover { background-color: #e8e8e8; color: #333; } " + "QPushButton.active { " + " background-color: #e3f2fd; color: #1976d2; font-weight: 500; " + " border-left: 3px solid #1976d2; " + "} " + ) + self.nav_buttons[key] = btn + sidebar_layout.addWidget(btn) + + self.nav_buttons["provider"].setProperty("class", "active") + self.nav_buttons["provider"].clicked.connect(lambda: self._show_section("provider")) + self.nav_buttons["placeholder"].clicked.connect(lambda: self._show_placeholder_toast()) + sidebar_layout.addStretch() + + # ------------------------------------------------------------------ + def _build_provider_section(self) -> QtWidgets.QWidget: + page = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(page) + layout.setContentsMargins(24, 20, 24, 20) + layout.setSpacing(12) + + header = QtWidgets.QWidget() + header_layout = QtWidgets.QHBoxLayout(header) + header_layout.setContentsMargins(0, 0, 0, 0) + + header_title = QtWidgets.QLabel("模型提供商") + header_title.setStyleSheet("font-size: 16px; font-weight: bold; color: #111;") + header_layout.addWidget(header_title) + header_layout.addStretch() + + add_btn = QtWidgets.QPushButton("+ 新建") + add_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + add_btn.setFixedHeight(28) + add_btn.setStyleSheet( + "QPushButton { " + " background-color: #1976d2; color: #fff; border: none; " + " padding: 0 14px; border-radius: 6px; font-size: 12px; font-weight: 500; " + "} " + "QPushButton:hover { background-color: #1565c0; }" + ) + header_layout.addWidget(add_btn) + layout.addWidget(header) + + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setStyleSheet( + "QScrollArea { border: none; background: transparent; } " + "QScrollBar:vertical { width: 5px; background: #f0f0f0; border-radius: 3px; } " + "QScrollBar::handle { background: #ccc; border-radius: 3px; }" + ) + + inner = QtWidgets.QWidget() + inner_layout = QtWidgets.QVBoxLayout(inner) + inner_layout.setContentsMargins(0, 4, 0, 0) + inner_layout.setSpacing(10) + + providers = self.config_data.get("providers", {}) + if not providers: + empty = QtWidgets.QLabel("暂无配置") + empty.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter) + empty.setStyleSheet("color: #bbb; font-size: 14px; padding: 40px;") + inner_layout.addWidget(empty) + else: + for provider_name, provider_config in providers.items(): + card = self._build_provider_card(provider_name, provider_config) + inner_layout.addWidget(card) + + inner_layout.addStretch() + scroll.setWidget(inner) + layout.addWidget(scroll, 1) + + return page + + # ------------------------------------------------------------------ + def _build_provider_card(self, name: str, config: dict) -> QtWidgets.QWidget: + card = QtWidgets.QWidget() + card.setStyleSheet( + "background-color: #fafafa; border-radius: 10px; " + "border: 1px solid #e8e8e8;" + ) + layout = QtWidgets.QVBoxLayout(card) + layout.setContentsMargins(16, 14, 16, 14) + layout.setSpacing(10) + + row1 = QtWidgets.QHBoxLayout() + row1.setSpacing(10) + + name_label = QtWidgets.QLabel(f"🤖 {name}") + name_label.setStyleSheet("font-size: 14px; font-weight: 600; color: #222;") + row1.addWidget(name_label) + row1.addStretch() + + edit_btn = QtWidgets.QPushButton("API Key") + edit_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + edit_btn.setFixedHeight(26) + edit_btn.setStyleSheet( + "QPushButton { " + " background-color: #f0f0f0; color: #666; border: none; " + " padding: 0 12px; border-radius: 5px; font-size: 12px; " + "} " + "QPushButton:hover { background-color: #e0e0e0; color: #333; }" + ) + row1.addWidget(edit_btn) + + add_btn = QtWidgets.QPushButton("+模型") + add_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + add_btn.setFixedHeight(26) + add_btn.setStyleSheet( + "QPushButton { " + " background-color: #e3f2fd; color: #1976d2; border: none; " + " padding: 0 12px; border-radius: 5px; font-size: 12px; font-weight: 500; " + "} " + "QPushButton:hover { background-color: #bbdefb; }" + ) + row1.addWidget(add_btn) + layout.addLayout(row1) + + # 🌟 第二行:模型垂直排列,加左缩进,不超出边框 + models = config.get("models", []) + if models: + models_wrap = QtWidgets.QWidget() + models_layout = QtWidgets.QVBoxLayout(models_wrap) + models_layout.setContentsMargins(4, 4, 0, 4) # 左4px,右0px + models_layout.setSpacing(5) + + for model_name in models: + mbtn = QtWidgets.QPushButton(model_name) + mbtn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + mbtn.setFixedHeight(30) # 🌟 明确高度,防止被内容撑开 + mbtn.setStyleSheet( + "QPushButton { " + " background-color: #e8f4fd; color: #1976d2; " + " border: 1px solid #b3d9f7; " + " padding: 0 12px; border-radius: 5px; " + " font-size: 13px; text-align: left; " + "} " + "QPushButton:hover { background-color: #d0ebff; }" + ) + mbtn.clicked.connect( + lambda checked, m=model_name: print(f"[设置] 选中模型: {m}") + ) + models_layout.addWidget(mbtn) + + layout.addWidget(models_wrap) + else: + empty = QtWidgets.QLabel("暂无模型") + empty.setStyleSheet( + "font-size: 12px; color: #aaa; font-style: italic; padding-left: 4px;" + ) + layout.addWidget(empty) + + return card + + # ------------------------------------------------------------------ + def _build_placeholder_section(self) -> QtWidgets.QWidget: + page = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(page) + layout.setContentsMargins(24, 20, 24, 20) + layout.addStretch() + label = QtWidgets.QLabel("🚧 该功能未开发") + label.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter) + label.setStyleSheet("font-size: 16px; color: #bbb;") + layout.addWidget(label) + layout.addStretch() + return page + + # ------------------------------------------------------------------ + def _show_section(self, key: str): + idx = 0 if key == "provider" else 1 + self.content_stack.setCurrentIndex(idx) + for k, btn in self.nav_buttons.items(): + btn.setProperty("class", "active" if k == key else "") + btn.style().unpolish(btn) + btn.style().polish(btn) + + # ------------------------------------------------------------------ + def _show_placeholder_toast(self): + toast = QtWidgets.QLabel(self.main_card) + toast.setText("🚧 该功能未开发") + toast.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + toast.setFixedSize(160, 34) + toast.move( + (self.main_card.width() - toast.width()) // 2, + self.main_card.height() - 70 + ) + toast.setStyleSheet( + "background-color: #333; color: #fff; border-radius: 8px; font-size: 13px;" + ) + toast.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents) + toast.show() + QtCore.QTimer.singleShot(1500, toast.deleteLater) + + # ------------------------------------------------------------------ + def resize_to_parent(self): + parent = self.parentWidget() + if not parent: + return + self.setFixedSize(parent.size()) + self.overlay.setFixedSize(self.size()) + + W = int(self.width() * 0.8) + H = int(self.height() * 0.88) + self.main_card.setFixedSize(W, H) + self.main_card.move( + (self.width() - W) // 2, + (self.height() - H) // 2 + ) + + # ------------------------------------------------------------------ + def _setup_animation(self): + self._anim = QtCore.QPropertyAnimation(self.overlay, b"windowOpacity") + self._anim.setDuration(500) + self._anim.setStartValue(0.0) + self._anim.setEndValue(1.0) + + # ------------------------------------------------------------------ + def show_with_animation(self): + # 🌟 关键:先 setFixedSize 再 show,否则 show() 时瞬间以极小尺寸闪一下 + self.setFixedSize(self.parentWidget().size()) + self.overlay.setFixedSize(self.size()) + self.overlay.setGraphicsEffect(None) # 重置之前可能的 effect + + # 🌟 初始 opacity=0,再 show 出来,然后做正向动画淡入 + self._anim.setStartValue(0.0) + self._anim.setEndValue(1.0) + self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Forward) + self.show() + self._anim.start() + + + # ------------------------------------------------------------------ + def _close(self, immediate=False): + """关闭窗口。immediate=True 时立即关闭(关闭按钮用),否则播放淡出动画""" + if immediate: + self._do_close() + else: + self._anim.setDirection(QtCore.QAbstractAnimation.Direction.Backward) + self._anim.finished.connect(self._on_close_finished) + self._anim.start() + + def _do_close(self): + """直接关闭,不播放动画""" + try: + self._anim.finished.disconnect(self._on_close_finished) + except TypeError: + pass + + self.hide() + parent = self.parent() + if parent: + for attr_name in dir(parent): + try: + if getattr(parent, attr_name, None) is self: + setattr(parent, attr_name, None) + break + except Exception: + pass + self.deleteLater() + self.close_requested.emit() + + + # ------------------------------------------------------------------ + def _on_close_finished(self): + self._anim.finished.disconnect(self._on_close_finished) + self.hide() + parent = self.parent() + if parent: + for attr_name in dir(parent): + try: + if getattr(parent, attr_name, None) is self: + setattr(parent, attr_name, None) + break + except Exception: + pass + self.deleteLater() + self.close_requested.emit() + + # ------------------------------------------------------------------ + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key.Key_Escape: + self._close() + + + +class HLine(QtWidgets.QFrame): + """水平分隔线""" + def __init__(self): + super().__init__() + self.setFixedHeight(1) + self.setStyleSheet("background-color: #e8e8e8; border: none;") + + +class PasteAwareTextEdit(QtWidgets.QPlainTextEdit): + """拦截粘贴/拖拽操作:图片自动识别,超长文本自动折叠,并修复中文输入法占位符重叠Bug""" + long_text_pasted = QtCore.pyqtSignal(str) + image_pasted = QtCore.pyqtSignal(object) # 传递 QImage 或文件路径列表(str) + files_pasted = QtCore.pyqtSignal(list) # 传递文本/代码文件路径列表(拖拽/粘贴/上传) + + LONG_TEXT_THRESHOLD = 2000 + LONG_TEXT_LINES = 50 + IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._original_placeholder = "" + self._is_composing = False # 标记是否正在使用输入法 + self.setAcceptDrops(True) # 允许拖拽接收 + + def setPlaceholderText(self, text: str): + """重写设置占位符方法,保存原始文本""" + if not self._is_composing: + self._original_placeholder = text + super().setPlaceholderText(text) + + def inputMethodEvent(self, event: QtGui.QInputMethodEvent): + """拦截输入法事件,修复拼音与占位符重叠""" + # 检查是否有正在输入的拼音字母 (pre-edit string) + if event.preeditString(): + if not self._is_composing: + self._is_composing = True + # 输入法激活时,临时清空占位符 + super().setPlaceholderText("") + else: + if self._is_composing: + self._is_composing = False + # 输入法提交或取消时,恢复原始占位符 + super().setPlaceholderText(self._original_placeholder) + + # 必须调用父类方法,否则输入法无法正常打字 + super().inputMethodEvent(event) + + def insertFromMimeData(self, source: QtCore.QMimeData): + """拦截粘贴操作:优先识别图片,其次超长文本折叠,最后普通文本""" + # 1. 优先检查剪切板中是否有图片(如微信截图、QQ截图等) + if source and source.hasImage(): + image = source.imageData() + if isinstance(image, QtGui.QImage): + self.image_pasted.emit(image) + return + + # 2. 检查剪切板中的文件 URL:图片走图片管线,其余交给文件管线探测 + if source and source.hasUrls(): + image_paths, other_paths = self._classify_urls(source.urls()) + if image_paths: + self.image_pasted.emit(image_paths) + if other_paths: + self.files_pasted.emit(other_paths) + if image_paths or other_paths: + return + + # 3. 文本粘贴处理(原有逻辑) + text = source.text() if source else '' + if not text: + super().insertFromMimeData(source) + return + + line_count = text.count('\n') + 1 + char_count = len(text) + + if char_count > self.LONG_TEXT_THRESHOLD or line_count > self.LONG_TEXT_LINES: + self.long_text_pasted.emit(text) + else: + super().insertFromMimeData(source) + + def dragEnterEvent(self, event): + """拖拽进入:接受图片数据和图片文件""" + mime = event.mimeData() + if mime.hasImage() or mime.hasUrls(): + event.acceptProposedAction() + else: + super().dragEnterEvent(event) + + def dragMoveEvent(self, event): + """拖拽移动:保持接受状态""" + mime = event.mimeData() + if mime.hasImage() or mime.hasUrls(): + event.acceptProposedAction() + else: + super().dragMoveEvent(event) + + def dropEvent(self, event): + """拖拽放下:提取图片并发送信号""" + mime = event.mimeData() + + # 1. 直接拖拽的图片数据(如从浏览器拖入的图片) + if mime.hasImage(): + image = mime.imageData() + if isinstance(image, QtGui.QImage): + self.image_pasted.emit(image) + event.acceptProposedAction() + return + + # 2. 拖拽的文件:图片走图片管线,其余交给文件管线探测 + if mime.hasUrls(): + image_paths, other_paths = self._classify_urls(mime.urls()) + if image_paths: + self.image_pasted.emit(image_paths) + if other_paths: + self.files_pasted.emit(other_paths) + if image_paths or other_paths: + event.acceptProposedAction() + return + + super().dropEvent(event) + + def _classify_urls(self, urls): + """把文件 URL 分成 (图片路径, 其他文件路径);跳过文件夹。 + 非图片文件(含黑名单)一律交给下游 _on_files_dropped 探测并反馈。""" + image_paths, other_paths = [], [] + for url in urls: + if not url.isLocalFile(): + continue + path = url.toLocalFile() + if not os.path.isfile(path): # 跳过文件夹 + continue + ext = os.path.splitext(path)[1].lower() + (image_paths if ext in self.IMAGE_EXTS else other_paths).append(path) + return image_paths, other_paths + + +class DraggableHistoryList(QtWidgets.QListWidget): + """长按拖拽排序 + 星标/普通分区 + 禁止跨区 (零崩溃版)""" + order_changed = QtCore.pyqtSignal(list) + SEPARATOR_ROLE = QtCore.Qt.ItemDataRole.UserRole + 1 + + def __init__(self, parent=None): + super().__init__(parent) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + self.setMouseTracking(True) + + self._long_press_timer = QtCore.QTimer(self) + self._long_press_timer.setSingleShot(True) + self._long_press_timer.setInterval(100) + self._long_press_timer.timeout.connect(self._on_long_press) + + # 边缘滚动定时器 + self._scroll_timer = QtCore.QTimer(self) + self._scroll_timer.setInterval(30) + self._scroll_timer.timeout.connect(self._do_edge_scroll) + self._scroll_dir = 0 + + self._dragging = False + self._drag_item = None + self._drag_start_row = -1 + self._press_pos = None + self._ghost = None + self._ghost_offset = None + self._drop_anim = None + self._drag_zone = None + self._insert_row = -1 # 松手后要插入的目标行 + + # 插入指示线 + self._indicator = QtWidgets.QFrame(self.viewport()) + self._indicator.setFixedHeight(2) + self._indicator.setStyleSheet("background-color: #4a90d9; border-radius: 1px;") + self._indicator.hide() + + # ---------- 工具方法 ---------- + def _is_separator(self, item): + return item and item.data(self.SEPARATOR_ROLE) == "separator" + + def _get_zone(self, item): + if not item or self._is_separator(item): + return None + row = self.row(item) + for i in range(row, -1, -1): + if self._is_separator(self.item(i)): + return "normal" + return "starred" + + def _find_separator_row(self): + for i in range(self.count()): + if self._is_separator(self.item(i)): + return i + return -1 + + def _zone_bounds(self, zone): + """返回指定区域的 (起始行, 结束行+1)""" + sep = self._find_separator_row() + total = self.count() + if sep < 0: + return (0, total) + if zone == "starred": + return (0, sep) + else: + return (sep + 1, total) + + # ---------- 长按触发 ---------- + def _on_long_press(self): + if not self._drag_item or self._is_separator(self._drag_item): + return + self._dragging = True + self._drag_zone = self._get_zone(self._drag_item) + self._drag_start_row = self.row(self._drag_item) + self._insert_row = self._drag_start_row + + # 半透明标记原位 + self._drag_item.setForeground(QtGui.QBrush(QtGui.QColor(200, 200, 200))) + + # 创建幽灵 + rect = self.visualItemRect(self._drag_item) + w = self.itemWidget(self._drag_item) + title = w.title_label.text() if w and hasattr(w, 'title_label') else "" + + self._ghost = QtWidgets.QLabel(self.parentWidget()) + self._ghost.setText(title) + self._ghost.setFixedSize(rect.width() - 12, rect.height()) + self._ghost.setStyleSheet( + "background-color: rgba(255,255,255,0.95);" + "border: 1.5px solid #c0c0c0; border-radius: 8px;" + "padding: 10px 12px; color: #222; font-size: 13px;" + ) + self._ghost.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents) + + item_tl = self.mapToParent(rect.topLeft()) + press_parent = self.mapToParent(self._press_pos) + self._ghost_offset = press_parent - item_tl + self._ghost.move(item_tl) + self._ghost.show() + + shadow = QtWidgets.QGraphicsDropShadowEffect(self._ghost) + shadow.setBlurRadius(16) + shadow.setOffset(0, 3) + shadow.setColor(QtGui.QColor(0, 0, 0, 45)) + self._ghost.setGraphicsEffect(shadow) + + # ---------- 鼠标事件 ---------- + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.MouseButton.LeftButton: + item = self.itemAt(event.pos()) + if item and not self._is_separator(item): + self._press_pos = event.pos() + self._drag_item = item + self._long_press_timer.start() + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self._long_press_timer.isActive() and self._press_pos: + if (event.pos() - self._press_pos).manhattanLength() > 10: + self._long_press_timer.stop() + + if self._dragging and self._ghost: + # 移动幽灵(轻量操作) + self._ghost.move(self.mapToParent(event.pos()) - self._ghost_offset) + + # 计算目标插入行 + y = event.pos().y() + lo, hi = self._zone_bounds(self._drag_zone) + best_row = self._drag_start_row + min_dist = 999999 + + for i in range(lo, hi): + it = self.item(i) + if self._is_separator(it): + continue + r = self.visualItemRect(it) + mid = r.top() + r.height() // 2 + d = abs(y - mid) + if d < min_dist: + min_dist = d + best_row = i + + self._insert_row = best_row + + # 画指示线(轻量操作) + if best_row != self._drag_start_row: + target_item = self.item(best_row) + if target_item: + r = self.visualItemRect(target_item) + if best_row > self._drag_start_row: + line_y = r.bottom() + else: + line_y = r.top() + self._indicator.setGeometry(8, line_y - 1, self.viewport().width() - 16, 2) + self._indicator.show() + else: + self._indicator.hide() + + # 边缘滚动 + if y < 30: + self._scroll_dir = -4 + if not self._scroll_timer.isActive(): + self._scroll_timer.start() + elif y > self.viewport().height() - 30: + self._scroll_dir = 4 + if not self._scroll_timer.isActive(): + self._scroll_timer.start() + else: + self._scroll_timer.stop() + return + + super().mouseMoveEvent(event) + + def _do_edge_scroll(self): + self.verticalScrollBar().setValue( + self.verticalScrollBar().value() + self._scroll_dir + ) + + def mouseReleaseEvent(self, event): + self._long_press_timer.stop() + self._scroll_timer.stop() + self._indicator.hide() + + if self._dragging: + if self._drag_item: + self._drag_item.setForeground(QtGui.QBrush()) + + src = self._drag_start_row + dst = self._insert_row + + if self._ghost and src != dst: + # 落地动画 + target_rect = self.visualItemRect(self.item(dst)) + land_pos = self.mapToParent(target_rect.topLeft()) + + self._drop_anim = QtCore.QPropertyAnimation(self._ghost, b"pos") + self._drop_anim.setDuration(180) + self._drop_anim.setStartValue(self._ghost.pos()) + self._drop_anim.setEndValue(land_pos) + self._drop_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutBack) + self._drop_anim.finished.connect(lambda: self._finalize_drop(src, dst)) + self._drop_anim.start() + else: + self._cleanup() + + self._dragging = False + self._drag_zone = None + return + + super().mouseReleaseEvent(event) + + def _finalize_drop(self, src, dst): + """动画结束后,一次性完成真实重排""" + # 🌟 先取数据,再清理 + item_data = None + if self._drag_item: + item_data = self._drag_item.data(QtCore.Qt.ItemDataRole.UserRole) + + self._cleanup() + + if not item_data or src == dst or src < 0 or dst < 0: + return + + self.takeItem(src) + + new_item = QtWidgets.QListWidgetItem() + new_item.setData(QtCore.Qt.ItemDataRole.UserRole, item_data) + new_item.setSizeHint(QtCore.QSize(0, 44)) + self.insertItem(dst, new_item) + + main_win = self.window() + if hasattr(main_win, '_create_session_widget'): + widget = main_win._create_session_widget(item_data) + self.setItemWidget(new_item, widget) + + self.setCurrentItem(new_item) + + new_order = [] + for i in range(self.count()): + it = self.item(i) + if not self._is_separator(it): + sid = it.data(QtCore.Qt.ItemDataRole.UserRole) + if sid: + new_order.append(sid) + if new_order: + self.order_changed.emit(new_order) + + + def _cleanup(self): + if self._ghost: + self._ghost.deleteLater() + self._ghost = None + self._drag_item = None + self._press_pos = None + self._insert_row = -1 + self._drag_start_row = -1 + + + +class SessionItemWidget(QtWidgets.QWidget): + """会话列表项:标题 + 右侧悬浮 ··· 按钮""" + menu_requested = QtCore.pyqtSignal(str, QtCore.QPoint) # session_id, 按钮全局坐标 + + def __init__(self, session_id, title, parent=None): + super().__init__(parent) + self.session_id = session_id + self.setMouseTracking(True) + + # 🟢 改为: + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(10, 0, 6, 0) + layout.setSpacing(0) + layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter) + + self.title_label = QtWidgets.QLabel(title) + self.title_label.setStyleSheet("color: #444; font-size: 15px; background: transparent;") + self.title_label.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents) + + self.menu_btn = QtWidgets.QPushButton("···") + self.menu_btn.setFixedSize(25, 20) + self.menu_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self.menu_btn.setStyleSheet(""" + QPushButton { + background: transparent; border: none; border-radius: 6px; + color: #999; font-size: 16px; font-weight: bold; letter-spacing: 2px; + padding-bottom: 4px; + } + QPushButton:hover { background-color: #e0e0e0; color: #333; } + """) + self.menu_btn.setVisible(False) + self.menu_btn.clicked.connect(self._on_menu_click) + + layout.addWidget(self.title_label, 1) + layout.addWidget(self.menu_btn, 0) + + def set_title(self, title): + self.title_label.setText(title) + + def _on_menu_click(self): + pos = self.menu_btn.mapToGlobal(QtCore.QPoint(0, self.menu_btn.height())) + self.menu_requested.emit(self.session_id, pos) + + def mouseMoveEvent(self, event): + # 只有鼠标在右侧 40px 区域才显示 ··· + in_zone = event.pos().x() > self.width() - 40 + self.menu_btn.setVisible(in_zone) + super().mouseMoveEvent(event) + + def leaveEvent(self, event): + self.menu_btn.setVisible(False) + super().leaveEvent(event) +class SessionContextPopup(QtWidgets.QWidget): + """会话操作弹出菜单:编辑 / 星标 / 复制 / 删除""" + action_triggered = QtCore.pyqtSignal(str, str) # (action, session_id) + + def __init__(self, session_id, is_starred=False, parent=None): + super().__init__(parent) + self.session_id = session_id + self.setWindowFlags(QtCore.Qt.WindowType.Popup | QtCore.Qt.WindowType.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground) + self.setFixedWidth(160) + + container = QtWidgets.QFrame(self) + container.setObjectName("ctx_popup") + main_lay = QtWidgets.QVBoxLayout(self) + main_lay.setContentsMargins(0, 0, 0, 0) + main_lay.addWidget(container) + + lay = QtWidgets.QVBoxLayout(container) + lay.setContentsMargins(6, 6, 6, 6) + lay.setSpacing(2) + + # 编辑 + btn_edit = self._make_btn("✏️ 编辑", "edit") + lay.addWidget(btn_edit) + + # 星标 / 取消星标 + star_text = "⭐ 取消星标" if is_starred else "☆ 星标" + btn_star = self._make_btn(star_text, "star") + lay.addWidget(btn_star) + + # 🆕 复制会话(深度克隆:分支/压缩标记/附件文件) + btn_copy = self._make_btn("📋 复制", "copy") + lay.addWidget(btn_copy) + + # 分隔线 + sep = QtWidgets.QFrame() + sep.setFrameShape(QtWidgets.QFrame.Shape.HLine) + sep.setStyleSheet("color: #eee; margin: 4px 8px;") + lay.addWidget(sep) + + # 删除 + btn_del = self._make_btn("🗑️ 删除", "delete") + btn_del.setStyleSheet(btn_del.styleSheet() + "QPushButton { color: #e04040; } QPushButton:hover { background: #fdecea; color: #c62828; }") + lay.addWidget(btn_del) + + self.setStyleSheet(""" + #ctx_popup { + background: #ffffff; + border: 1px solid #dcdcdc; + border-radius: 10px; + } + """) + self.adjustSize() + + def _make_btn(self, text, action): + btn = QtWidgets.QPushButton(text) + btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + btn.setStyleSheet(""" + QPushButton { + text-align: left; border: none; border-radius: 6px; + padding: 8px 12px; font-size: 13px; color: #333; + background: transparent; + } + QPushButton:hover { background-color: #f0f4f9; } + """) + btn.clicked.connect(lambda: self._emit(action)) + return btn + + def _emit(self, action): + self.action_triggered.emit(action, self.session_id) + self.close() + + def show_at(self, pos: QtCore.QPoint): + """带浮现动画显示""" + self.setWindowOpacity(0.0) + self.move(pos.x(), pos.y()) + self.show() + + self._anim_group = QtCore.QParallelAnimationGroup(self) + + opacity_anim = QtCore.QPropertyAnimation(self, b"windowOpacity") + opacity_anim.setDuration(120) + opacity_anim.setStartValue(0.0) + opacity_anim.setEndValue(1.0) + opacity_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) + + pos_anim = QtCore.QPropertyAnimation(self, b"pos") + pos_anim.setDuration(120) + pos_anim.setStartValue(QtCore.QPoint(pos.x(), pos.y() + 8)) + pos_anim.setEndValue(pos) + pos_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) + + self._anim_group.addAnimation(opacity_anim) + self._anim_group.addAnimation(pos_anim) + self._anim_group.start() + +class RenameOverlay(QtWidgets.QWidget): + """无边框重命名 — 独立顶层透明窗口(P2-03 修复) + + 🆕 必须是可覆盖原生 WebView2 的独立顶层窗(独立 HWND):WebView2 是原生子窗, + 永远绘制在主窗口内所有 Qt 控件之上 —— 旧实现是 bg_widget 的子控件,遮罩盖不住 + webview 区域(聊天区不暗)、卡片被压到 webview 下面。独立顶层 Tool 窗 + + WA_TranslucentBackground(逐像素 alpha)才能盖住原生子窗(同 AttachmentPreviewOverlay)。 + + 只覆盖主窗口【客户区】:系统标题栏与窗口控制保持可操作。 + 主窗口 Move / Resize / WindowStateChange(最大化/还原,含多显示器/DPI 变化时 + Qt 合成的 move+resize)通过 eventFilter + mapToGlobal 跟随。 + 行为保持:点空白关闭、Esc 关闭、✕ 关闭、输入框初始全选、Enter 提交、关闭后焦点回主窗口。 + """ + renamed = QtCore.pyqtSignal(str) # 发射新标题 + + def __init__(self, current_title, parent=None): + super().__init__(parent) + self._main = parent.window() if parent is not None else None + # 无边框顶层 Tool 窗(不入任务栏;Windows 上作为拥有者窗口默认浮在父窗之上) + self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Tool) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) + + # 半透明灰色遮罩 + self._overlay_color = QtGui.QColor(0, 0, 0, 80) + + # ---- 居中表单 ---- + self.form = QtWidgets.QFrame(self) + self.form.setFixedSize(380, 200) + self.form.setObjectName("rename_form") + self.form.setStyleSheet(""" + #rename_form { + background: #ffffff; + border: 1px solid #dcdcdc; + border-radius: 12px; + } + """) + + lay = QtWidgets.QVBoxLayout(self.form) + lay.setContentsMargins(24, 20, 24, 18) + lay.setSpacing(16) + + # 标题行 + title_lay = QtWidgets.QHBoxLayout() + title_label = QtWidgets.QLabel("编辑会话名称") + title_label.setStyleSheet("font-size: 15px; font-weight: bold; color: #222;") + title_lay.addWidget(title_label) + title_lay.addStretch() + + btn_x = QtWidgets.QPushButton("✕") + btn_x.setFixedSize(24, 24) + btn_x.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + btn_x.setStyleSheet(""" + QPushButton { background: transparent; border: none; border-radius: 12px; color: #999; font-size: 14px; } + QPushButton:hover { background: #eee; color: #333; } + """) + btn_x.clicked.connect(self.close_overlay) + title_lay.addWidget(btn_x) + lay.addLayout(title_lay) + + # 输入框(加大内边距防文字被裁) + self.input = QtWidgets.QLineEdit(current_title) + self.input.selectAll() + self.input.setStyleSheet(""" + QLineEdit { + border: 1.5px solid #e0e0e0; border-radius: 8px; + padding: 10px 14px; font-size: 14px; color: #222; + background: #fafafa; + } + QLineEdit:focus { border-color: #4a90d9; background: #fff; } + """) + self.input.returnPressed.connect(self.confirm) + lay.addWidget(self.input) + + # 按钮行 + btn_lay = QtWidgets.QHBoxLayout() + btn_lay.addStretch() + + btn_cancel = QtWidgets.QPushButton("取消") + btn_cancel.setFixedSize(72, 32) + btn_cancel.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + btn_cancel.setStyleSheet(""" + QPushButton { background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 8px; color: #666; font-size: 13px; } + QPushButton:hover { background: #eaeaea; } + """) + btn_cancel.clicked.connect(self.close_overlay) + + btn_ok = QtWidgets.QPushButton("确定") + btn_ok.setFixedSize(72, 32) + btn_ok.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + btn_ok.setStyleSheet(""" + QPushButton { background: #4a90d9; border: none; border-radius: 8px; color: white; font-size: 13px; font-weight: bold; } + QPushButton:hover { background: #3a7bc8; } + """) + btn_ok.clicked.connect(self.confirm) + + btn_lay.addWidget(btn_cancel) + btn_lay.addSpacing(8) + btn_lay.addWidget(btn_ok) + lay.addLayout(btn_lay) + + # 拖拽(卡片可拖动,限制在客户区内) + self._drag_pos = None + self.form.mousePressEvent = self._form_press + self.form.mouseMoveEvent = self._form_move + self.form.mouseReleaseEvent = self._form_release + + self._result = None + self._closed = False + + # 先同步几何(覆盖主窗口客户区)并挂事件过滤器,再显示 + self._sync_geometry() + if self._main is not None: + self._main.installEventFilter(self) + self.show() + self.raise_() + self.activateWindow() + self.input.setFocus() + self._animate_in() + + # ==================== 几何同步(客户区覆盖,标题栏/窗口控制可操作) ==================== + def _client_top_left(self): + """主窗口客户区左上角的【全局】坐标(mapToGlobal 已含多显示器/DPI 换算)。""" + return self._main.mapToGlobal(self._main.rect().topLeft()) + + def _sync_geometry(self): + if self._main is None: + return + self.setGeometry(QtCore.QRect(self._client_top_left(), self._main.rect().size())) + self._center_form() + + def eventFilter(self, obj, ev): + """跟随主窗口移动/大小变化/窗口状态(最大化、还原);主窗口关闭/隐藏时关闭遮罩。 + DPI 变化时 Qt 对主窗口合成 move+resize 事件,此过滤器随之自动跟随。""" + if self._main is not None and obj is self._main: + t = ev.type() + if t == QtCore.QEvent.Type.Move: + self.move(self._client_top_left()) + elif t in (QtCore.QEvent.Type.Resize, QtCore.QEvent.Type.WindowStateChange): + self._sync_geometry() + elif t in (QtCore.QEvent.Type.Close, QtCore.QEvent.Type.Hide): + self.close_overlay() + return super().eventFilter(obj, ev) + # ============================================================================== + + def _center_form(self): + x = (self.width() - self.form.width()) // 2 + y = (self.height() - self.form.height()) // 2 + self.form.move(x, y) + + def _animate_in(self): + # 顶层窗口用 windowOpacity 动画(QGraphicsEffect 作用在顶层窗上不可靠) + self.setWindowOpacity(0.0) + self._anim = QtCore.QPropertyAnimation(self, b"windowOpacity") + self._anim.setDuration(150) + self._anim.setStartValue(0.0) + self._anim.setEndValue(1.0) + self._anim.start() + + # ---- 绘制半透明遮罩 ---- + def paintEvent(self, event): + painter = QtGui.QPainter(self) + painter.fillRect(self.rect(), self._overlay_color) + painter.end() + + # ---- 点击遮罩区域关闭 ---- + def mousePressEvent(self, event): + if not self.form.geometry().contains(event.pos()): + self.close_overlay() + + # ---- Esc 关闭 ---- + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key.Key_Escape: + self.close_overlay() + return + super().keyPressEvent(event) + + # ---- 表单拖拽(限制在主窗口客户区内)---- + def _form_press(self, event): + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self._drag_pos = event.pos() + + def _form_move(self, event): + if self._drag_pos: + new_pos = self.form.pos() + event.pos() - self._drag_pos + # 限制在覆盖层范围内 + x = max(0, min(new_pos.x(), self.width() - self.form.width())) + y = max(0, min(new_pos.y(), self.height() - self.form.height())) + self.form.move(x, y) + + def _form_release(self, event): + self._drag_pos = None + + def confirm(self): + title = self.input.text().strip() + if title: + self._result = title + self.renamed.emit(title) + self.close_overlay() + + def close_overlay(self): + """关闭并释放:卸事件过滤器、焦点回主窗口、延迟删除(不残留遮罩/过滤器/焦点捕获)。""" + if self._closed: + return + self._closed = True + if self._main is not None: + self._main.removeEventFilter(self) + self._main.activateWindow() + self._main.raise_() + self.deleteLater() + + def get_result(self): + return self._result + + +from PyQt6 import QtCore, QtGui, QtWidgets +def _popup_svg_path(filename: str) -> str: + """🌟 弹窗类内部取 svg 图标绝对路径(与 MainWindow.get_svg_path 同一套定位逻辑,不依赖主窗口实例)""" + base_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.abspath(os.path.join(base_dir, "..", "..")) + return os.path.join(project_root, "svg", filename) + + +# 🆕 抽屉 v2:模型行淡入淡出 / 相邻行滑移 的 per-item 数据角色 +_FADE_ROLE = QtCore.Qt.ItemDataRole.UserRole + 901 # 模型行不透明度 0..1(None=1) +_SLIDE_ROLE = QtCore.Qt.ItemDataRole.UserRole + 902 # 纯 item 垂直滑移偏移 dy (px)(None=0) + + +class _ModelFadeDelegate(QtWidgets.QStyledItemDelegate): + """模型行 淡出+滑移 委托(抽屉 v2) + + - _FADE_ROLE: paint 时 setOpacity → 模型行渐渐变浅透明 + - _SLIDE_ROLE: paint 时 translate(0, dy) → 供应商头以下的整块行垂直滑移 + (供应商头是 setItemWidget 真实控件,不走 delegate,由 _SlideListView 偏移) + """ + + def paint(self, painter, option, index): + fade = index.data(_FADE_ROLE) + dy = index.data(_SLIDE_ROLE) + if fade is None and not dy: + super().paint(painter, option, index) + return + painter.save() + if fade is not None: + painter.setOpacity(max(0.0, min(1.0, float(fade)))) + if dy: + painter.translate(0, int(dy)) + # 🆕 背景穿透修复:滑移中的行先铺不透明白底(随平移后的位置), + # 盖住底下正在淡出的模型行文字,避免两层文字重叠 + painter.fillRect(option.rect, QtGui.QColor("#ffffff")) + super().paint(painter, option, index) + painter.restore() + + +class _SlideListView(QtWidgets.QListWidget): + """可保持供应商头控件垂直滑移偏移的 QListWidget(抽屉 v2) + + QListView 会在每次 layout/resize/scroll 时把 setItemWidget 子控件 + 复位到自然位置——单帧 move() 会被下一个事件冲掉。这里在基类处理 + 完成之后重新应用偏移,保证滑移状态持续到该帧绘制。 + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._slide_offsets = [] # [(QListWidgetItem, dy), ...](item 不可哈希,用列表) + + def _offset_of(self, it): + for item, dy in self._slide_offsets: + if item is it: + return dy + return 0 + + def set_slide_offset(self, it, dy): + if dy: + for i, (item, _) in enumerate(self._slide_offsets): + if item is it: + self._slide_offsets[i] = (item, int(dy)) + break + else: + self._slide_offsets.append((it, int(dy))) + else: + self._slide_offsets = [(item, d) for item, d in self._slide_offsets if item is not it] + w = self.itemWidget(it) + if w is not None: + r = self.visualItemRect(it) + w.move(r.x(), r.y() + self._offset_of(it)) + + def clear_slide_offsets(self): + self._slide_offsets.clear() + + def _reapply_slide(self): + for it, dy in list(self._slide_offsets): + w = self.itemWidget(it) + if w is not None: + r = self.visualItemRect(it) + w.move(r.x(), r.y() + dy) + + def paintEvent(self, e): + # 每帧绘制前重应用偏移:layout/resize/scroll 都会把 itemWidget 复位, + # paint 前再移一次保证该帧最终视觉(注:PyQt6 的 doItemsLayout 无法 + # 经 super() 调用,不能在那里钩) + if self._slide_offsets: + self._reapply_slide() + super().paintEvent(e) + + def resizeEvent(self, e): + super().resizeEvent(e) + self._reapply_slide() + + def scrollContentsBy(self, dx, dy): + super().scrollContentsBy(dx, dy) + self._reapply_slide() + + +class ModelSelectPopup(QtWidgets.QWidget): + """ + 🌟 极客级自定义向上弹出菜单 (带丝滑浮现动画 & 供应商抽屉折叠) + - 供应商分组头:[SVG箭头] [provider.svg] 名称(微软雅黑·不加粗) —空白— 数量(右对齐·纯文字),整行可点 + - 模型项:model.svg 图标(左带透明边,比供应商头缩进一点)+ 模型名,行距紧凑 + - 滚动条上下内缩 12px,避免滑轨顶满右边两个圆角 + """ + model_selected = QtCore.pyqtSignal(str, str) + + ICON_SIZE = 16 # 模型项图标尺寸(调参工具确认) + MODEL_ICON_PAD = 34 # 模型图标左侧透明边(模型行缩进,调参工具确认) + HEADER_ICON_SIZE = 16 # 供应商头图标尺寸(调参工具确认 15→16) + HEADER_ICON_PAD = 0 # 供应商头图标水平偏移(左侧留白,调参工具可调) + HEADER_ICON_PAD_V = 1 # 供应商头图标垂直偏移(正=下移,调参工具确认 0→1) + MODEL_ROW_H = 27 # 模型行高(调参工具确认 26→27;抽屉 v2 中恒定不压缩) + MIN_POPUP_H = 50 # 弹窗高度钳制下限(与 adjust_popup_height 一致) + MAX_POPUP_H = 400 # 弹窗高度钳制上限(与 adjust_popup_height 一致) + DRAWER_DURATION = 200 # 抽屉淡出/滑移段时长 (ms) + DRAWER_SETTLE = 120 # 抽屉回座段时长(收起后整体下滑 / 展开前整体上移,ms) + DRAWER_STEP = 16 # 抽屉动画帧间隔 (ms) ~60fps + PROVIDER_SVG = "provider.svg" # 供应商图标(可选 provider/provider2/provider3,调参工具可切) + ARROW_SIZE = 13 # 展开/收起箭头尺寸(调参工具确认) + + def __init__(self, parent=None, config_data=None): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.WindowType.Popup | QtCore.Qt.WindowType.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground) + + self.config_data = config_data or {"providers": {}} + self.setFixedWidth(340) + + # 供应商头图标(PROVIDER_SVG 可切换,调参工具确认) + self._provider_pixmap = QtGui.QPixmap(_popup_svg_path(self.PROVIDER_SVG)).scaled( + self.HEADER_ICON_SIZE, self.HEADER_ICON_SIZE, + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation) + # 🆕 展开/收起 SVG 箭头(替换旧 ▾/▸ 文字字符) + self._arrow_expanded = QtGui.QIcon(_popup_svg_path("chevron_down.svg")).pixmap(self.ARROW_SIZE, self.ARROW_SIZE) + self._arrow_collapsed = QtGui.QIcon(_popup_svg_path("chevron_right.svg")).pixmap(self.ARROW_SIZE, self.ARROW_SIZE) + # 🆕 模型图标:左带 MODEL_ICON_PAD 透明边 → 模型行比供应商头缩进一点点 + _m = QtGui.QPixmap(_popup_svg_path("model.svg")).scaled( + self.ICON_SIZE, self.ICON_SIZE, + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation) + _pad = QtGui.QPixmap(self.ICON_SIZE + self.MODEL_ICON_PAD, self.ICON_SIZE) + _pad.fill(QtCore.Qt.GlobalColor.transparent) + _p = QtGui.QPainter(_pad) + _p.drawPixmap(self.MODEL_ICON_PAD, 0, _m) + _p.end() + self._model_icon = QtGui.QIcon(_pad) + self._groups = [] # [{"provider", "header_item", "model_items", "expanded", "chevron"}] + self._drawer = None # 进行中的抽屉动画状态 {group, t, timer, row_h, anchor_bottom} + + self.setup_ui() + self.populate_data() + self.adjust_popup_height() + + def setup_ui(self): + self.container = QtWidgets.QFrame(self) + self.container.setObjectName("popup_container") + self.main_layout = QtWidgets.QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.main_layout.addWidget(self.container) + + self.container_layout = QtWidgets.QVBoxLayout(self.container) + self.container_layout.setContentsMargins(0, 4, 0, 4) + self.container_layout.setSpacing(0) + + self.list_widget = _SlideListView() + self.list_widget.setObjectName("model_list") + self.list_widget.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + self.list_widget.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + # 模型行字体:widget 级 setFont(实测 QSS ::item 的 font-size 在本构建无效) + _list_font = QtGui.QFont("Microsoft YaHei") + _list_font.setPixelSize(13) + self.list_widget.setFont(_list_font) + self.container_layout.addWidget(self.list_widget) + + self.list_widget.itemClicked.connect(self.on_item_clicked) + self._fade_delegate = _ModelFadeDelegate(self.list_widget) # 抽屉 v2 淡出/滑移 + self.list_widget.setItemDelegate(self._fade_delegate) # 🐛 必须安装才生效(之前只创建未挂载→fade/遮罩全不渲染) + + # 🎨 注入灵魂:加入圆润字体栈,调整字重与间距,让视觉更柔和 + self.setStyleSheet(""" + * { + /* 全局字体:微软雅黑(调参工具确认)→ 鸿蒙黑体 → Noto Sans SC */ + font-family: "Microsoft YaHei", "HarmonyOS Sans SC", "Noto Sans SC", sans-serif; + } + #popup_container { + background-color: #ffffff; + border: 1px solid #dcdcdc; + border-radius: 12px; + } + /* 🆕 分组头整行可点(抽屉折叠触发器) */ + #group_toggle_btn { background: transparent; border: none; } + #group_toggle_btn:hover { background-color: #f2f5f9; border-radius: 6px; } + #model_list { + border: none; + background: transparent; + outline: none; + } + #model_list::item { + /* ⚠️ 显式声明必须保留——作用是【屏蔽 MainWindow 全局 QListWidget::item + 的级联泄漏】(padding 10px 12px / margin 2px 4px / border-radius 8px): + id 选择器 > 类型选择器,级联必胜;实测泄漏会把 38px 供应商头 widget + 压裁(“卡进去”)、并在供应商行与首模型行间多出间隙 */ + font-family: "Microsoft YaHei", "HarmonyOS Sans SC", "Noto Sans SC", sans-serif; + padding: 0px; + margin: 0px; + border: none; + border-radius: 0px; + color: #333333; + font-weight: normal; + } + #model_list::item:hover { + background-color: #f0f4f9; + color: #111111; + } + #model_list::item:selected { + background-color: #e8f0fe; + color: #1a73e8; + font-weight: bold; /* 选中时使用真正的粗体 */ + border: none; border-left: none; /* 屏蔽全局选中态的 3px 蓝色左竖条 */ + } + + /* 🆕 滚动条:上下内缩 12px,避免灰色滑轨顶满右边两个圆角 */ + QScrollBar:vertical { + border: none; + background: transparent; + width: 5px; + margin: 12px 2px; + } + QScrollBar::handle:vertical { + background: #d0d0d0; + min-height: 20px; + border-radius: 3px; + } + QScrollBar::handle:vertical:hover { + background: #a0a0a0; + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; + } + """) + + + def populate_data(self): + providers = self.config_data.get("providers", {}) + # 🆕 图标区宽 = 16 + 34px 透明左边(缩进),高 16(调参工具确认) + self.list_widget.setIconSize(QtCore.QSize(self.ICON_SIZE + self.MODEL_ICON_PAD, self.ICON_SIZE)) + + for provider_name, info in providers.items(): + models = info.get("models", []) + if not models: continue + + # --- 🆕 供应商分组头(整行可点:展开/收起抽屉) --- + header_item = QtWidgets.QListWidgetItem(self.list_widget) + header_item.setFlags(header_item.flags() & ~QtCore.Qt.ItemFlag.ItemIsSelectable & ~QtCore.Qt.ItemFlag.ItemIsEnabled) + + header_widget = QtWidgets.QWidget() + header_widget.setFixedHeight(38) # 🐛 供应商行高 38 = 整行高(调参工具确认) + outer_layout = QtWidgets.QVBoxLayout(header_widget) + outer_layout.setContentsMargins(2, 2, 2, 2) + + toggle_btn = QtWidgets.QPushButton() + toggle_btn.setObjectName("group_toggle_btn") + toggle_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + # 🐛 QPushButton 默认垂直 sizePolicy=Fixed,不会填满行高 → 内容行只有 12px, + # 16px 图标溢出被裁、文字偏下。“字只有一半”的另一半原因。 + toggle_btn.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) + btn_layout = QtWidgets.QHBoxLayout(toggle_btn) + # 🐛 垂直边距归零:图标+名称由布局自动垂直居中 + # (旧版 (8,6,8,2) 不对称边距会把内容往下顶,加剧底部裁切) + btn_layout.setContentsMargins(3, 0, 8, 0) # 🐛 左边距 8→3:供应商头整体再靠左一点 + btn_layout.setSpacing(6) + + icon_lbl = QtWidgets.QLabel() + icon_lbl.setPixmap(self._provider_pixmap) + # 尺寸含水平/垂直偏移:内容区 = 图标尺寸,图标整体平移。 + # 垂直 margin 取 2 倍:label 在行内居中会吸收一半增高,双倍才 1:1 位移 + _top = 2 * max(self.HEADER_ICON_PAD_V, 0) + _bot = 2 * max(-self.HEADER_ICON_PAD_V, 0) + icon_lbl.setFixedSize( + self.HEADER_ICON_SIZE + self.HEADER_ICON_PAD, + self.HEADER_ICON_SIZE + _top + _bot) + icon_lbl.setContentsMargins(self.HEADER_ICON_PAD, _top, 0, _bot) + icon_lbl.setStyleSheet("background: transparent;") + + # 🆕 供应商名:鸿蒙黑体·不加粗 + lbl_name = QtWidgets.QLabel(provider_name) + lbl_name.setStyleSheet(""" + font-family: "Microsoft YaHei", "HarmonyOS Sans SC", "Noto Sans SC", sans-serif; + color: #555555; + font-weight: normal; + font-size: 13px; /* 调参工具确认 12→13px */ + letter-spacing: 1px; + background: transparent; + """) + + # 🆕 数量:去掉灰色方块,纯文字右对齐(靠 stretch) + lbl_count = QtWidgets.QLabel(str(len(models))) + lbl_count.setStyleSheet(""" + font-family: "Microsoft YaHei", "HarmonyOS Sans SC", "Noto Sans SC", sans-serif; + color: #888888; + font-size: 10px; + font-weight: normal; + background: transparent; + """) + + # 🆕 SVG 箭头(展开=朝下 / 收起=朝右),放在最左 + lbl_chevron = QtWidgets.QLabel() + lbl_chevron.setFixedSize(self.ARROW_SIZE, self.ARROW_SIZE) + lbl_chevron.setPixmap(self._arrow_expanded) + lbl_chevron.setStyleSheet("background: transparent;") + + btn_layout.addWidget(lbl_chevron) + btn_layout.addWidget(icon_lbl) + btn_layout.addWidget(lbl_name) + btn_layout.addStretch() + btn_layout.addWidget(lbl_count) + outer_layout.addWidget(toggle_btn) + + # item 高度 = widget 高度(实测 QSS padding/margin 在 ::item 无效,行高直接等于 widget 高) + header_item.setSizeHint(QtCore.QSize(0, 38)) + self.list_widget.setItemWidget(header_item, header_widget) + toggle_btn.clicked.connect(lambda _checked=False, p=provider_name: self.toggle_group(p)) + + # --- 模型项(model.svg 图标 + 纯模型名) --- + model_items = [] + for model_name in models: + item = QtWidgets.QListWidgetItem(model_name) + item.setIcon(self._model_icon) + # 🐛 显式 sizeHint(宽 0):防止最长模型名把单列网格撑宽超出 viewport + # (网格被撑宽时供应商头 widget 会按网格宽布局 → 右侧数量溢出弹窗外看不见) + item.setSizeHint(QtCore.QSize(0, self.MODEL_ROW_H)) # 调参工具确认 26→27 + item.setData(QtCore.Qt.ItemDataRole.UserRole, (provider_name, model_name)) + self.list_widget.addItem(item) + model_items.append(item) + + self._groups.append({ + "provider": provider_name, + "header_item": header_item, + "model_items": model_items, + "expanded": True, + "chevron": lbl_chevron, + }) + + # ==================== 🆕 抽屉 v2:fade + 相邻供应商行滑移 ==================== + # 收起:A 段(顶部锚定,模型行 27px 不变原地淡出 1→0,下方供应商行整体 + # 上滑逐渐盖住 → 两供应商行合并)+ B 段(整体下滑回座按钮锚点) + # 展开:B' 段(整体上移让位)+ A' 段(顶部锚定,下方供应商行整体下滑打开, + # 模型行淡入 0→1,底缘同步落回按钮)——收起的严格逆过程 + # ⚠️ 窗口几何一律用 delta_h = clamp(终态内容高) - clamp(起态内容高) + # (被 50/400 钳制后的真实窗口高差);内容滑移 dy 才用 span。 + # 列表超长(钳制在 400)时 delta_h 可能 = 0 → 窗口完全不动, + # 动画全部发生在窗口高度之下(滚动区内淡出/滑移)。 + def _natural_content_height(self): + """内容自然总高(可见 item + 10,未钳制;与 adjust_popup_height 同公式)""" + total = 0 + lw = self.list_widget + for i in range(lw.count()): + it = lw.item(i) + if it is None or it.isHidden(): + continue + total += lw.visualItemRect(it).height() + return total + 10 + + def _clamped_h(self, natural): + return max(self.MIN_POPUP_H, min(self.MAX_POPUP_H, natural)) + + def toggle_group(self, provider_name: str): + """点击供应商头 → 折叠/展开该供应商的模型组(🆕 抽屉 v2 动画)""" + g = None + for gg in self._groups: + if gg["provider"] == provider_name: + g = gg + break + if g is None: + return + # 上一个抽屉动画进行中 → 先立即收敛到终态(允许快速连点切换) + self._finish_drawer() + g["expanded"] = not g["expanded"] + # SVG 箭头切换(展开=朝下 / 收起=朝右) + g["chevron"].setPixmap( + self._arrow_collapsed if not g["expanded"] else self._arrow_expanded) + if not self.isVisible(): + # 防御:弹窗未显示时直接应用终态(不播动画) + self._apply_group_state(g) + self._apply_height_and_position() + return + self._start_drawer(g) + + def _apply_group_state(self, g): + """应用组终态:展开=可见 27px 行 / 收起=隐藏(行高恒定不变)""" + for it in g["model_items"]: + it.setSizeHint(QtCore.QSize(0, self.MODEL_ROW_H)) + it.setData(_FADE_ROLE, None) + it.setHidden(not g["expanded"]) + + def _clear_fx(self): + """清除所有淡出/滑移偏移(收敛/打断共用)""" + self.list_widget.clear_slide_offsets() + lw = self.list_widget + for i in range(lw.count()): + it = lw.item(i) + if it is None: + continue + it.setData(_FADE_ROLE, None) + it.setData(_SLIDE_ROLE, None) + + def _set_slide(self, it, dy): + """给单个 item 施加滑移:纯模型行→data role(delegate 绘制); + 供应商头→_SlideListView 偏移(可抗 layout/resize 复位)""" + if self.list_widget.itemWidget(it) is not None: + self.list_widget.set_slide_offset(it, dy) + else: + it.setData(_SLIDE_ROLE, dy if dy else None) + + def _start_drawer(self, g): + """启动抽屉 v2 动画:抓取几何,创建帧定时器""" + timer = QtCore.QTimer(self) + timer.setInterval(self.DRAWER_STEP) + timer.timeout.connect(self._drawer_tick) + n = len(g["model_items"]) + lw = self.list_widget + after = [] + if n: + last_idx = lw.indexFromItem(g["model_items"][-1]).row() + 1 + for i in range(last_idx, lw.count()): + after.append(lw.item(i)) + # 🆕 背景穿透修复:滑移的供应商头(真实控件)铺不透明白底, + # 真正“盖住”底下淡出的模型行;动画收敛时恢复透明 + for it in after: + w = lw.itemWidget(it) + if w is not None: + w.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True) + w.setStyleSheet("background-color: #ffffff;") + # 强制立即生效(stylesheet 默认要等下一次 polish,会留一帧穿透) + st = w.style() + st.unpolish(w) + st.polish(w) + w.update() + self._drawer = { + "group": g, "timer": timer, + "span": self.MODEL_ROW_H * n, # 内容总滑移距离(行高 × 行数) + "after_items": after, # 本组最后一个模型行之后的所有 item + # 收起: A(淡出+滑上 200ms) → B(下滑回座 120ms) + # 展开: B(上移让位 120ms) → A(淡入+滑下 200ms) + "phase": "A" if not g["expanded"] else "B", + "t": 0.0, + "top0": self.y(), + "h0": self.minimumHeight(), + "bottom0": self.y() + self.minimumHeight(), + } + # 🆕 窗口真实高度差(被 50/400 钳制后)——窗口位移/长高只许用这个, + # 不能用 span(内容差):列表超长时窗口被钳在 400,delta_h=0, + # 动画全部发生在窗口高度之下(否则窗口会越界飞走) + natural_now = self._natural_content_height() + natural_final = natural_now + self._drawer["span"] if g["expanded"] \ + else natural_now - self._drawer["span"] + self._drawer["delta_h"] = self._clamped_h(natural_final) - self._clamped_h(natural_now) + timer.start() + + def _drawer_tick(self): + """抽屉 v2 每帧(方向/阶段感知)""" + d = self._drawer + if d is None: + return + g = d["group"] + span = d["span"] + dh = d["delta_h"] # 窗口真实高差(已钳制,可正可负可为 0) + if d["phase"] == "A": + # ---- A 段:顶部锚定(顶部左上角不动,setFixedHeight 天然保顶) ---- + d["t"] = min(1.0, d["t"] + self.DRAWER_STEP / self.DRAWER_DURATION) + e = 1.0 - (1.0 - d["t"]) ** 3 # ease-out 三次缓出 + if g["expanded"]: + # 展开 A':模型行淡入,下方块下滑打开,窗口高按 delta_h 渐长 + for it in g["model_items"]: + it.setData(_FADE_ROLE, e) + dy = -int(round(span * (1.0 - e))) # 内容滑移:-span → 0 + top_fixed = d["top0"] - dh # B' 段已上移到位 + else: + # 收起 A:模型行淡出,下方块上滑盖住,窗口高按 delta_h 渐缩 + for it in g["model_items"]: + it.setData(_FADE_ROLE, 1.0 - e) + dy = -int(round(span * e)) # 内容滑移:0 → -span + top_fixed = d["top0"] + h_new = d["h0"] + int(round(dh * e)) # 只用钳制后的高差 + self.setFixedHeight(max(1, h_new)) + self.move(self.x(), top_fixed) # 显式保顶(稳健) + self.list_widget.doItemsLayout() + for it in d["after_items"]: + self._set_slide(it, dy) + if d["t"] >= 1.0: + if g["expanded"]: + self._finish_drawer() # 展开完成 → 落座 + else: + # 收起 A 完成 → 隐藏行、清滑移,进入 B(下滑回座) + self._apply_group_state(g) + for it in d["after_items"]: + it.setData(_SLIDE_ROLE, None) + self.list_widget.clear_slide_offsets() + self.list_widget.doItemsLayout() + d["phase"] = "B" + d["t"] = 0.0 + d["h0"] = self.minimumHeight() + else: + # ---- B 段:整体平移回座(收起=下滑 / 展开=上移;距离 = |delta_h|) ---- + d["t"] = min(1.0, d["t"] + self.DRAWER_STEP / self.DRAWER_SETTLE) + e = 1.0 - (1.0 - d["t"]) ** 3 + delta = int(round(abs(dh) * e)) + if g["expanded"]: + self.move(self.x(), d["top0"] - delta) # 展开 B':整体上移让位 + if d["t"] >= 1.0: + # B' 完成 → 模型行以透明态就位(27px 恒高),进入 A' 淡入 + for it in g["model_items"]: + it.setHidden(False) + it.setSizeHint(QtCore.QSize(0, self.MODEL_ROW_H)) + it.setData(_FADE_ROLE, 0.0) + self.list_widget.doItemsLayout() + d["phase"] = "A" + d["t"] = 0.0 + d["h0"] = self.minimumHeight() + return + else: + self.move(self.x(), d["top0"] + delta) # 收起 B:整体下滑回座 + if d["t"] >= 1.0: + self._finish_drawer() + + def _finish_drawer(self): + """抽屉动画收敛到终态(动画完成 / 连点打断共用)""" + d = self._drawer + if d is None: + return + d["timer"].stop() + self._drawer = None + # 🆕 恢复滑移供应商头的透明背景(_start_drawer 里铺的白底) + for it in d["after_items"]: + w = self.list_widget.itemWidget(it) + if w is not None: + w.setStyleSheet("") + w.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, False) + self._clear_fx() + self._apply_group_state(d["group"]) + self.list_widget.doItemsLayout() + self.adjust_popup_height() + if not self.isVisible(): + return + parent = self.parentWidget() + btn = getattr(parent, "model_selector", None) if parent else None + if btn is not None: + # 真实 App:回到“按钮正上方右对齐”锚点 + btn_pos = btn.mapToGlobal(QtCore.QPoint(0, 0)) + self.move(btn_pos.x() + btn.width() - self.width(), + btn_pos.y() - self.minimumHeight() - 5) + elif d.get("bottom0") is not None: + # 无锚定按钮(如调参工具宿主):保持动画起点底缘不漂 + self.move(self.x(), d["bottom0"] - self.minimumHeight()) + + def _apply_height_and_position(self): + """折叠/展开后:按可见项重算高度;若弹窗正显示中,保持“按钮正上方右对齐”锚点""" + self.adjust_popup_height() + if not self.isVisible(): + return + parent = self.parentWidget() + btn = getattr(parent, "model_selector", None) + if btn is None: + return + btn_pos = btn.mapToGlobal(QtCore.QPoint(0, 0)) + x = btn_pos.x() + btn.width() - self.width() + y = btn_pos.y() - self.height() - 5 + self.move(x, y) + + def adjust_popup_height(self): + self.list_widget.doItemsLayout() + total_height = 0 + for i in range(self.list_widget.count()): + it = self.list_widget.item(i) + if it.isHidden(): + continue + rect = self.list_widget.visualItemRect(it) + total_height += rect.height() + + target_height = total_height + 10 + + if target_height > 400: + target_height = 400 + elif target_height < 50: + target_height = 50 + + self.setFixedHeight(target_height) + + # ==================== 🌟 核心新增:丝滑浮现动画 ==================== + def show_with_animation(self, target_pos: QtCore.QPoint): + """带透明度和位移的弹出动画""" + # 初始状态:完全透明,且位置比目标位置低 15 个像素 + self.setWindowOpacity(0.0) + self.move(target_pos.x(), target_pos.y() + 15) + self.show() + + # 创建并行动画组 (同时执行位移和透明度) + self.anim_group = QtCore.QParallelAnimationGroup(self) + + # 1. 透明度动画 (0.0 -> 1.0) + self.opacity_anim = QtCore.QPropertyAnimation(self, b"windowOpacity") + self.opacity_anim.setDuration(150) # 150毫秒,极速响应 + self.opacity_anim.setStartValue(0.0) + self.opacity_anim.setEndValue(1.0) + self.opacity_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) # 缓出曲线,非常自然 + + # 2. 位移动画 (向上滑动 15 像素) + self.pos_anim = QtCore.QPropertyAnimation(self, b"pos") + self.pos_anim.setDuration(150) + self.pos_anim.setStartValue(QtCore.QPoint(target_pos.x(), target_pos.y() + 15)) + self.pos_anim.setEndValue(target_pos) + self.pos_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) + + self.anim_group.addAnimation(self.opacity_anim) + self.anim_group.addAnimation(self.pos_anim) + self.anim_group.start() + + def on_item_clicked(self, item): + data = item.data(QtCore.Qt.ItemDataRole.UserRole) + if data: + provider, model = data + self.model_selected.emit(provider, model) + self.close() + + +class _ModeRow(QtWidgets.QWidget): + """会话模式弹窗的单个行控件:左图标 + 右两行文字(名称/作用),自管 hover/选中/点击""" + row_clicked = QtCore.pyqtSignal(str) + + ICON_SIZE = 14 + + def __init__(self, mode: str, name: str, desc: str, icon_path: str, parent=None): + super().__init__(parent) + self.mode = mode + self._selected = False + self._hover = False + self._normal_bg = "transparent" + self._hover_bg = "#f0f4f9" + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(10, 8, 12, 8) + layout.setSpacing(12) + + # 左:SVG 图标(必须显式 transparent:行级 background-color 会传染给未声明背景的子控件, + # 否则选中/悬停时图标会被涂成浅色色块) + self.lbl_icon = QtWidgets.QLabel() + self.lbl_icon.setStyleSheet("background: transparent;") + self.lbl_icon.setFixedSize(self.ICON_SIZE, self.ICON_SIZE) + pix = QtGui.QPixmap(icon_path) + if not pix.isNull(): + self.lbl_icon.setPixmap(pix.scaled( + self.ICON_SIZE, self.ICON_SIZE, + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation)) + + # 右:两行(名称 + 作用) + self.lbl_name = QtWidgets.QLabel(name) + self.lbl_name.setStyleSheet(""" + font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "微软雅黑", sans-serif; + font-size: 12px; font-weight: bold; color: #333333; + background: transparent; + """) + self.lbl_desc = QtWidgets.QLabel(desc) + self.lbl_desc.setStyleSheet(""" + font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "微软雅黑", sans-serif; + font-size: 12px; color: #888888; + background: transparent; + """) + txt = QtWidgets.QVBoxLayout() + txt.setContentsMargins(0, 0, 0, 0) + txt.setSpacing(4) + txt.addWidget(self.lbl_name) + txt.addWidget(self.lbl_desc) + + layout.addWidget(self.lbl_icon, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) + layout.addLayout(txt, 1) + + # ---- 状态与样式 ---- + def set_selected(self, selected: bool): + self._selected = selected + self._apply() + + def _apply(self): + if self._selected: + self._normal_bg = "#e8f0fe" + name_color, name_weight = "#1a73e8", "bold" + else: + self._normal_bg = "transparent" + name_color, name_weight = "#333333", "bold" + self.setStyleSheet( + f"background-color: {self._hover_bg if self._hover else self._normal_bg};" + "border-radius: 8px;") + self.lbl_name.setStyleSheet(f""" + font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "微软雅黑", sans-serif; + font-size: 13px; font-weight: {name_weight}; color: {name_color}; + background: transparent; + """) + + def _hover_set(self, on: bool): + self._hover = on + self._apply() + + def enterEvent(self, e): # noqa: N802 + self._hover_set(True) + super().enterEvent(e) + + def leaveEvent(self, e): # noqa: N802 + self._hover_set(False) + super().leaveEvent(e) + + def mouseReleaseEvent(self, e): # noqa: N802 + if e.button() == QtCore.Qt.MouseButton.LeftButton: + self.row_clicked.emit(self.mode) + super().mouseReleaseEvent(e) + + +class SessionModePopup(QtWidgets.QWidget): + """ + 🆕 会话模式选择浮动弹窗(无边框浮动窗 + 150ms 透明淡入 + 15px 上移丝滑浮现动画) + 改版:去掉「会话模式」标题行;每行 = 左 SVG 图标 + 右两行文字(名称/作用) + """ + mode_selected = QtCore.pyqtSignal(str) + + MODES = ( + ("chat", "Chat 模式", "普通问答,无工具调用", "mode_chat.svg"), + ("worker", "Worker 模式", "Agent 循环:工具 / 重试 / 压缩", "mode_worker.svg"), + ) + ROW_HEIGHT = 56 + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.WindowType.Popup | QtCore.Qt.WindowType.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground) + self.setFixedWidth(320) + self.setup_ui() + self.adjust_popup_height() + + def setup_ui(self): + self.container = QtWidgets.QFrame(self) + self.container.setObjectName("popup_container") + self.main_layout = QtWidgets.QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.main_layout.addWidget(self.container) + + self.container_layout = QtWidgets.QVBoxLayout(self.container) + self.container_layout.setContentsMargins(4, 4, 4, 4) + self.container_layout.setSpacing(2) + + for mode, name, desc, icon in self.MODES: + row = _ModeRow(mode, name, desc, _popup_svg_path(icon), self.container) + row.row_clicked.connect(self.on_row_clicked) + self.container_layout.addWidget(row) + + self.setStyleSheet(""" + * { + font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif; + } + #popup_container { + background-color: #ffffff; + border: 1px solid #dcdcdc; + border-radius: 10px; + } + """) + + def on_row_clicked(self, mode: str): + if mode: + self.mode_selected.emit(mode) + self.close() + + def mark_selected(self, mode): + # 行控件直管选中态 + for w in self.container.findChildren(_ModeRow): + w.set_selected(w.mode == mode) + + def adjust_popup_height(self): + # 无列表:直接取内容提示高度(两行 + 容器边距) + target_height = self.container.sizeHint().height() + 2 + if target_height > 400: + target_height = 400 + elif target_height < 50: + target_height = 50 + self.setFixedHeight(target_height) + + def show_with_animation(self, target_pos: QtCore.QPoint): + """带透明度和位移的弹出动画(与模型选择弹窗同款风格)""" + self.setWindowOpacity(0.0) + self.move(target_pos.x(), target_pos.y() + 15) + self.show() + self.anim_group = QtCore.QParallelAnimationGroup(self) + self.opacity_anim = QtCore.QPropertyAnimation(self, b"windowOpacity") + self.opacity_anim.setDuration(150) + self.opacity_anim.setStartValue(0.0) + self.opacity_anim.setEndValue(1.0) + self.opacity_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) + self.pos_anim = QtCore.QPropertyAnimation(self, b"pos") + self.pos_anim.setDuration(150) + self.pos_anim.setStartValue(QtCore.QPoint(target_pos.x(), target_pos.y() + 15)) + self.pos_anim.setEndValue(target_pos) + self.pos_anim.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) + self.anim_group.addAnimation(self.opacity_anim) + self.anim_group.addAnimation(self.pos_anim) + self.anim_group.start() + + +# ==================== 🌟 PDF 图片提取子线程工作者 ==================== +class _PdfImageExtractWorker(QtCore.QObject): + """在独立线程里调用 pdf_reader 提取 PDF 内嵌图片,避免大文件解析阻塞 UI。 + + 通过 finished / failed 信号把结果送回主线程。本类不持有任何控件引用, + 因此可安全地 moveToThread 到子线程运行;线程与工作者均无父级、自管理生命周期 + (finished 后各自 deleteLater),即使弹窗中途关闭也不会因删除运行中的线程而崩溃。 + """ + finished = QtCore.pyqtSignal(list) # 提取到的图片元数据列表 + failed = QtCore.pyqtSignal(str) # 失败原因 + + def __init__(self, pdf_abs: str, out_dir: str): + super().__init__() + self.pdf_abs = pdf_abs + self.out_dir = out_dir + + def run(self): + try: + raw = extract_pdf_images(self.pdf_abs, self.out_dir) + except (ValueError, OSError) as e: + self.failed.emit(str(e)) + return + images = [] + for im in raw: + images.append({ + "page": im["page"], "index": im["index"], + "local_path": f"data/attachments/{os.path.basename(im['abs_path'])}", + "abs_path": im["abs_path"], + "mime": im["mime"], "size_kb": im["size_kb"], + "width": im["width"], "height": im["height"], + }) + self.finished.emit(images) + + +# ==================== 🌟 PDF 解析模式候选栏 ==================== +class PdfModePopup(QtWidgets.QWidget): + """PDF 附件的解析模式候选栏(弹出于 PDF 标签正上方,仿 ModelSelectPopup)。 + + - 两个互斥模式按钮:文本读取模式 / 图片解析模式。 + - 选中图片模式后懒提取 PDF 全部内嵌图片,展开纯文字勾选列表(默认不勾选), + 提供“全选”,每项可调用原生预览层查看图片。 + - 鼠标移出框边界即淡出消失;点击按钮不会关闭。 + - 通过 mode_changed / preview_requested 信号与主窗口通信, + 并直接读写共享的 att_data(mode / images / selected_images)。 + """ + mode_changed = QtCore.pyqtSignal(str) # "text" / "image" + preview_requested = QtCore.pyqtSignal(dict, list, int, dict) # 当前图 meta, 同组列表, 下标, 附件字典 + + # ---- 布局尺寸常量(_setup_ui 与 _adjust_size 共用,保证估算与实际一致)---- + POPUP_W = 360 # 弹窗固定宽度 + MARGIN = 12 # 容器四周留白 + SPACING = 10 # 主布局控件间距 + TITLE_H = 24 # 标题行高 + BTN_H = 36 # 模式按钮高度 + ROW_H = 40 # 图片列表单行高度 + MAX_VISIBLE_ROWS = 6 # 图片列表最多显示行数(超出滚动) + + def __init__(self, att_data, parent=None): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.WindowType.Popup | QtCore.Qt.WindowType.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground) + self.setMouseTracking(True) + self.att_data = att_data + self.setFixedWidth(self.POPUP_W) + self._left_x = 0 + self._bottom_y = 0 + self._row_checks = [] # 每行勾选框引用 + self._processing = False # 是否正在子线程解析图片 + self._dot_count = 0 # “正在处理...”动态省略号计数 + self._extract_thread = None # 当前解析线程(无父级,自管理生命周期) + self._extract_worker = None + self._dot_timer = QtCore.QTimer(self) + self._dot_timer.setInterval(400) + self._dot_timer.timeout.connect(self._tick_dots) + self._setup_ui() + self._apply_mode(att_data.get("mode", "text"), first=True) + + def _setup_ui(self): + self.container = QtWidgets.QFrame(self) + self.container.setObjectName("pdf_popup_container") + self.container.setMouseTracking(True) + outer = QtWidgets.QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(self.container) + + lay = QtWidgets.QVBoxLayout(self.container) + lay.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN) + lay.setSpacing(self.SPACING) + + # 标题 + title = QtWidgets.QLabel(f"PDF 解析模式 · {self.att_data.get('name', '')}") + title.setFixedHeight(self.TITLE_H) + title.setStyleSheet("color:#333333; font-size:13px; font-weight:bold;") + lay.addWidget(title) + + # 两个模式按钮(互斥) + self.btn_text = QtWidgets.QPushButton("📝 文本读取模式") + self.btn_image = QtWidgets.QPushButton("🖼️ 图片解析模式") + for b in (self.btn_text, self.btn_image): + b.setCheckable(True) + b.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + b.setFixedHeight(self.BTN_H) + self.btn_text.clicked.connect(lambda: self._apply_mode("text")) + self.btn_image.clicked.connect(lambda: self._apply_mode("image")) + lay.addWidget(self.btn_image) # 图片解析模式在上 + lay.addWidget(self.btn_text) # 文本读取模式在下 + + # 图片区(默认隐藏,选中图片模式后展开) + self.image_section = QtWidgets.QWidget() + img_lay = QtWidgets.QVBoxLayout(self.image_section) + img_lay.setContentsMargins(0, 2, 0, 0) + img_lay.setSpacing(8) + + head = QtWidgets.QHBoxLayout() + self.select_all_cb = QtWidgets.QCheckBox("全选") + self.select_all_cb.stateChanged.connect(self._on_select_all) + self.lbl_img_count = QtWidgets.QLabel("已提取 0 张") + self.lbl_img_count.setStyleSheet("color:#888888; font-size:11px;") + head.addWidget(self.select_all_cb) + head.addStretch() + head.addWidget(self.lbl_img_count) + img_lay.addLayout(head) + + self.image_list = QtWidgets.QListWidget() + self.image_list.setObjectName("pdf_image_list") + self.image_list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + self.image_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + img_lay.addWidget(self.image_list) + + self.lbl_empty = QtWidgets.QLabel("该 PDF 未提取到内嵌图片") + self.lbl_empty.setStyleSheet("color:#aaaaaa; font-size:12px; padding:6px;") + self.lbl_empty.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.lbl_empty.setVisible(False) + img_lay.addWidget(self.lbl_empty) + + # 子线程解析期间的动态提示(与列表/空提示互斥显示) + self.lbl_processing = QtWidgets.QLabel("正在处理") + self.lbl_processing.setStyleSheet("color:#1a73e8; font-size:13px; padding:14px;") + self.lbl_processing.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.lbl_processing.setVisible(False) + img_lay.addWidget(self.lbl_processing) + + lay.addWidget(self.image_section) + self.image_section.setVisible(False) + + self.setStyleSheet(""" + * { font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif; } + #pdf_popup_container { background-color:#ffffff; border:1px solid #dcdcdc; border-radius:10px; } + #pdf_popup_container > QPushButton { + background-color:#f5f5f5; color:#444444; border:1px solid #e5e5e5; + border-radius:6px; font-size:13px; text-align:left; padding:0 12px; + } + #pdf_popup_container > QPushButton:hover { border:1px solid #b3d9ff; color:#0066cc; } + #pdf_popup_container > QPushButton:checked { + background-color:#e8f0fe; color:#1a73e8; border:1px solid #b3d9ff; font-weight:bold; + } + #pdf_image_list { border:1px solid #eeeeee; border-radius:6px; background:#fafafa; outline:none; } + #pdf_image_list::item { padding:0px; margin:0px; border:none; background:transparent; } + #pdf_image_list::item:hover { background:#f0f7ff; } + QCheckBox { font-size:13px; color:#333333; } + QScrollBar:vertical { border:none; background:transparent; width:5px; margin:2px 2px 2px 0px; } + QScrollBar::handle:vertical { background:#d0d0d0; min-height:20px; border-radius:2px; } + QScrollBar::handle:vertical:hover { background:#a0a0a0; } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height:0px; } + """) + + # ---------- 模式切换 ---------- + def _apply_mode(self, mode, first=False): + self.att_data["mode"] = mode + is_image = (mode == "image") + self.btn_text.setChecked(not is_image) + self.btn_image.setChecked(is_image) + self.image_section.setVisible(is_image) + if is_image: + if self.att_data.get("images"): + # 已缓存:直接渲染,不重复解析(一个文档只解析一次) + self._set_processing(False) + self._populate_image_list() + else: + # 首次:子线程解析,期间显示动态“正在处理...” + self._start_extract_async() + else: + self._set_processing(False) + self._adjust_size() + if not first: + self.mode_changed.emit(mode) + + # ---------- 子线程图片解析 ---------- + def _start_extract_async(self): + """启动子线程提取 PDF 内嵌图片,期间 UI 显示动态“正在处理...”。""" + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.abspath(os.path.join(current_dir, '..', '..')) + pdf_abs = os.path.join(root_dir, self.att_data.get("local_path", "")) + out_dir = os.path.join(root_dir, "data", "attachments") + + self._set_processing(True) + self._extract_thread = QtCore.QThread() # 无父级,自管理生命周期 + self._extract_worker = _PdfImageExtractWorker(pdf_abs, out_dir) + self._extract_worker.moveToThread(self._extract_thread) + self._extract_thread.started.connect(self._extract_worker.run) + self._extract_worker.finished.connect(self._on_extract_done) + self._extract_worker.failed.connect(self._on_extract_failed) + self._extract_worker.finished.connect(self._extract_thread.quit) + self._extract_worker.failed.connect(self._extract_thread.quit) + # 线程/工作者结束后各自销毁,弹窗中途关闭也不会删除运行中的线程 + self._extract_thread.finished.connect(self._extract_thread.deleteLater) + self._extract_worker.finished.connect(self._extract_worker.deleteLater) + self._extract_worker.failed.connect(self._extract_worker.deleteLater) + self._extract_thread.start() + + def _set_processing(self, on: bool): + """切换“处理中 / 正常”两种界面状态(列表、空提示、按钮、计数互斥)。""" + self._processing = on + has_imgs = len(self.att_data.get("images", [])) > 0 + self.lbl_processing.setVisible(on) + self.image_list.setVisible((not on) and has_imgs) + self.lbl_empty.setVisible((not on) and not has_imgs) + self.btn_text.setEnabled(not on) + self.btn_image.setEnabled(not on) + self.select_all_cb.setEnabled(not on) + if on: + self.lbl_img_count.setText("处理中…") + self._dot_count = 0 + self.lbl_processing.setText("正在处理") + self._dot_timer.start() + else: + self._dot_timer.stop() + + def _tick_dots(self): + """动态省略号:正在处理. → .. → ... 循环。""" + self._dot_count = (self._dot_count % 3) + 1 + self.lbl_processing.setText("正在处理" + "." * self._dot_count) + + def _on_extract_done(self, images): + """子线程解析成功:缓存结果(此后不再重复解析)并渲染列表。""" + self.att_data["images"] = images + self.att_data["selected_images"] = [] # 默认不勾选 + self._set_processing(False) + self._populate_image_list() + self._adjust_size() + + def _on_extract_failed(self, msg): + """子线程解析失败:按“无图片”处理,不阻断其它操作。""" + print(f"[Warn]: PDF 图片解析失败: {msg}") + self.att_data["images"] = [] + self._set_processing(False) + self._populate_image_list() + self._adjust_size() + + def closeEvent(self, event): + """关闭时停掉动画定时器;解析线程自管理,无需在此阻塞等待。""" + self._dot_timer.stop() + super().closeEvent(event) + + def _populate_image_list(self): + self.image_list.clear() + self._row_checks = [] + images = self.att_data.get("images", []) + selected_paths = {s["local_path"] for s in self.att_data.get("selected_images", [])} + self.lbl_img_count.setText(f"已提取 {len(images)} 张") + self.lbl_empty.setVisible(len(images) == 0) + self.image_list.setVisible(len(images) > 0) # 无图时不显示空白列表框 + for im in images: + item = QtWidgets.QListWidgetItem(self.image_list) + item.setSizeHint(QtCore.QSize(0, self.ROW_H)) + row = QtWidgets.QWidget() + row.setFixedHeight(self.ROW_H) + hl = QtWidgets.QHBoxLayout(row) + hl.setContentsMargins(16, 0, 16, 0) + hl.setSpacing(14) + cb = QtWidgets.QCheckBox(f"第 {im['page']} 页 no.{im['index']}") + cb.setFixedHeight(self.ROW_H) # 撑满行高,由 QStyle 在整行内垂直居中文字 + cb.setChecked(im["local_path"] in selected_paths) # 还原上次勾选(在 connect 前设置,不触发信号) + cb.stateChanged.connect(lambda st, m=im: self._on_row_toggled(m, st)) + btn = QtWidgets.QPushButton("预览") + btn.setFixedSize(56, 28) + btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + btn.setStyleSheet( + "QPushButton{background:#eeeeee;border:none;border-radius:6px;color:#555555;font-size:12px;}" + "QPushButton:hover{background:#d9e8ff;color:#1a73e8;}" + ) + btn.clicked.connect(lambda chk=False, m=im: self._request_preview(m)) + # checkbox 已撑满行高(内容自居中);预览按钮较小,显式垂直居中 + hl.addWidget(cb) + hl.addStretch() + hl.addWidget(btn, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) + self.image_list.addItem(item) + self.image_list.setItemWidget(item, row) + self._row_checks.append(cb) + # 列表高度:最多显示 MAX_VISIBLE_ROWS 行,超出滚动;上下各留 6px 内边距 + rows = min(max(len(images), 1), self.MAX_VISIBLE_ROWS) + self.image_list.setFixedHeight(rows * self.ROW_H + 12) + n = len(self._row_checks) + k = sum(1 for c in self._row_checks if c.isChecked()) + self.select_all_cb.blockSignals(True) + self.select_all_cb.setChecked(n > 0 and k == n) # 还原“全选”勾选态 + self.select_all_cb.blockSignals(False) + + # ---------- 勾选逻辑 ---------- + def _on_row_toggled(self, im, state): + checked = (state == QtCore.Qt.CheckState.Checked.value) + sel = self.att_data.setdefault("selected_images", []) + if checked: + if not any(s["local_path"] == im["local_path"] for s in sel): + sel.append(im) + else: + self.att_data["selected_images"] = [s for s in sel if s["local_path"] != im["local_path"]] + n = len(self._row_checks) + k = sum(1 for c in self._row_checks if c.isChecked()) + self.select_all_cb.blockSignals(True) + self.select_all_cb.setChecked(n > 0 and k == n) + self.select_all_cb.blockSignals(False) + + def _on_select_all(self, state): + checked = (state == QtCore.Qt.CheckState.Checked.value) + for cb in self._row_checks: + cb.blockSignals(True) + cb.setChecked(checked) + cb.blockSignals(False) + self.att_data["selected_images"] = list(self.att_data.get("images", [])) if checked else [] + + def _request_preview(self, im): + """呼叫主窗口用原生预览层查看该图片,并带上同组图片供左右翻页。 + + 先立即关闭本弹窗(越快越好),再让主窗口打开预览覆盖层。 + """ + base_name = self.att_data.get("name", "") + images = self.att_data.get("images", []) + siblings = [{ + "type": "image", + "name": f'{base_name} · 第 {x["page"]} 页 no.{x["index"]}', + "local_path": x["local_path"], + "mime": x["mime"], + "size_kb": x.get("size_kb", 0), + } for x in images] + index = next((i for i, x in enumerate(images) if x["local_path"] == im["local_path"]), 0) + self.close() # 解析选择窗口立即消失 + self.preview_requested.emit(siblings[index], siblings, index, self.att_data) + + # ---------- 尺寸与定位 ---------- + def _adjust_size(self): + # 高度 = 上边距 + 标题 + 间距 + 两按钮 + [图片区] + 下边距,全部取自布局常量 + m = self.MARGIN + h = m + self.TITLE_H + self.SPACING + self.BTN_H + self.SPACING + self.BTN_H + # 用 mode 而非 image_section.isVisible():构造期弹窗未 show,isVisible() 恒为 False, + # 会导致图片模式被误判成文本模式、按矮高度计算从而压扁列表 + if self.att_data.get("mode") == "image": + if self._processing: + body_h = 46 # “正在处理...”动态提示区 + else: + n = len(self.att_data.get("images", [])) + if n == 0: + body_h = 30 # “未提取到图片”提示行 + else: + rows = min(n, self.MAX_VISIBLE_ROWS) + body_h = rows * self.ROW_H + 12 # 列表行高 + 上下内边距 + h += self.SPACING + (2 + 26 + 8 + body_h) # 间距 + 图片区(上边距+表头+间距+主体) + h += m + self.setFixedHeight(int(h)) + if self._bottom_y: # 保持底边锚定在标签上方 + self.move(self._left_x, self._bottom_y - self.height()) + + def show_with_animation(self, target_pos: QtCore.QPoint): + """淡入 + 上滑弹出(仿 ModelSelectPopup),并记录锚点供增高时重新定位。""" + self._left_x = target_pos.x() + self.setWindowOpacity(0.0) + self.move(target_pos.x(), target_pos.y() + 15) + self.show() + self._bottom_y = target_pos.y() + self.height() + + self.anim_group = QtCore.QParallelAnimationGroup(self) + op = QtCore.QPropertyAnimation(self, b"windowOpacity") + op.setDuration(150); op.setStartValue(0.0); op.setEndValue(1.0) + op.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) + ps = QtCore.QPropertyAnimation(self, b"pos") + ps.setDuration(150) + ps.setStartValue(QtCore.QPoint(target_pos.x(), target_pos.y() + 15)) + ps.setEndValue(target_pos) + ps.setEasingCurve(QtCore.QEasingCurve.Type.OutQuad) + self.anim_group.addAnimation(op) + self.anim_group.addAnimation(ps) + self.anim_group.start() + + # 关闭方式:依赖 Qt.Popup 原生行为——点击弹窗以外任意区域即关闭(与 ModelSelectPopup 一致), + # 鼠标移出不再关闭。 + + +# ==================== 可点击标签(附件预览用) ==================== +class ClickableLabel(QtWidgets.QLabel): + """鼠标悬停显示下划线、点击发射 clicked 信号的 QLabel""" + clicked = QtCore.pyqtSignal() + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self.clicked.emit() + + +# ==================== 🌟 自定义网页滚动条 ==================== +class WebScrollBar(QtWidgets.QWidget): + """自定义网页滚动条:完全镜像 QtWebEngine 页面滚动。 + + - 位置镜像:页面 scrollY ↔ 滑块位置(双向,经 QWebChannel) + - 长度镜像:滑块高度 ∝ 视口/内容比例(内容长→滑块短;内容短→滑块长) + - 交互:拖动滑块 / 点击轨道跳转 / 滚轮翻页 均回写页面 window.scrollTo + - 全程无浏览器原生滚动条参与(原生已被 CSS 隐藏),避免重绘延迟观感 + """ + TRACK_MARGIN_X = 4 # 轨道左右边距 + MIN_SLIDER_H = 30 # 滑块最小高度 + WHEEL_STEP = 60 # 滚轮单格滚动像素 + + def __init__(self, bridge, parent=None): + super().__init__(parent) + self.bridge = bridge + self.setFixedWidth(16) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True) + self.setMouseTracking(True) + self.setStyleSheet(self._qss()) + + self.track = QtWidgets.QFrame(self) + self.track.setObjectName("web_scroll_track") + self.track.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + self.slider = QtWidgets.QFrame(self) + self.slider.setObjectName("web_scroll_slider") + self.slider.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + self._y = 0.0 # 页面 scrollY + self._content = 1.0 # 页面 scrollHeight + self._client = 1.0 # 页面 innerHeight + self._track_h = 0 + self._slider_h = 0 + self._slider_y = 0 + self._hover = False + self._dragging = False + self._drag_grab = 0 # 按下时指针相对滑块顶部的偏移 + + def _qss(self): + return """ + #web_scroll_track { background: rgba(0,0,0,0.05); border-radius: 4px; } + #web_scroll_slider { background-color: rgba(0,0,0,0.16); border-radius: 4px; } + #web_scroll_slider[state="hover"] { background-color: rgba(0,0,0,0.30); } + #web_scroll_slider[state="drag"] { background-color: rgba(37,99,235,0.60); } + """ + + # ---------------- 页面 → 控件(镜像方向 1) ---------------- + def set_scroll_info(self, y, content, client): + """前端上报页面滚动状态后调用,刷新滑块位置与长度。""" + self._y = float(y) + self._content = float(content) if content > 0 else 1.0 + self._client = float(client) + self._layout_slider() + + def _scrollable(self): + return self._content > self._client + 1 + + def _layout_slider(self): + self._track_h = self.height() + if self._track_h <= 0: + return + track_w = self.width() - 2 * self.TRACK_MARGIN_X + self.track.setGeometry(self.TRACK_MARGIN_X, 0, track_w, self._track_h) + + if not self._scrollable(): + self.slider.hide() + return + self.slider.show() + + # 🌟 滑块高度 = 视口占比 × 轨道高(内容越长滑块越短),带最小高度 + sh = max(self.MIN_SLIDER_H, int(self._track_h * (self._client / self._content))) + sh = min(sh, self._track_h) + span = self._track_h - sh + + # 🌟 位置镜像:页面滚动比例 → 滑块位置比例 + max_scroll = self._content - self._client + if max_scroll <= 0: + y_frac = 0.0 + else: + y_frac = min(1.0, max(0.0, self._y / max_scroll)) + + self._slider_h = sh + self._slider_y = int(y_frac * span) + self.slider.setGeometry(self.TRACK_MARGIN_X, self._slider_y, track_w, sh) + + # ---------------- 控件 → 页面(镜像方向 2) ---------------- + def _slider_y_to_page(self, slider_y): + if not self._scrollable(): + return 0.0 + span = self._track_h - self._slider_h + if span <= 0: + return 0.0 + frac = min(1.0, max(0.0, slider_y / span)) + return frac * (self._content - self._client) + + def _scroll_page_to_y(self, y): + max_scroll = self._content - self._client + if max_scroll <= 0: + y = 0.0 + y = min(max_scroll, max(0.0, y)) + self._y = y + # 统一走 webScrollTo(内部经 document.scrollingElement,兼容 body 内部滚动容器) + self.bridge.run_js(f"webScrollTo({int(y)});") + + # ---------------- 鼠标/滚轮交互 ---------------- + def mousePressEvent(self, event): + if event.button() != QtCore.Qt.MouseButton.LeftButton or not self._scrollable(): + return super().mousePressEvent(event) + if self.slider.isVisible(): + sr = self.slider.geometry() + if sr.contains(event.pos()): + # 拖动滑块 + self._dragging = True + self._drag_grab = event.pos().y() - sr.y() + self._apply_state() + event.accept() + return + # 点击轨道:滑块跳跃到点击处 + target = event.pos().y() - self._slider_h // 2 + self._scroll_page_to_y(self._slider_y_to_page(target)) + self._layout_slider() + event.accept() + + def mouseMoveEvent(self, event): + if self._dragging: + slider_y = event.pos().y() - self._drag_grab + self._scroll_page_to_y(self._slider_y_to_page(slider_y)) + self._layout_slider() + event.accept() + return + # hover 反馈 + hover = self.slider.isVisible() and self.slider.geometry().contains(event.pos()) + if hover != self._hover: + self._hover = hover + self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) if hover else self.unsetCursor() + self._apply_state() + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if self._dragging and event.button() == QtCore.Qt.MouseButton.LeftButton: + self._dragging = False + self._apply_state() + event.accept() + return + super().mouseReleaseEvent(event) + + def leaveEvent(self, event): + self._hover = False + self.unsetCursor() + self._apply_state() + super().leaveEvent(event) + + def wheelEvent(self, event): + delta = event.angleDelta().y() + if delta == 0: + event.accept() + return + self._scroll_page_to_y(self._y + (self.WHEEL_STEP if delta > 0 else -self.WHEEL_STEP)) + self._layout_slider() + event.accept() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._layout_slider() + + def _apply_state(self): + """按 hover / 拖动 状态刷新滑块 QSS""" + state = "drag" if self._dragging else ("hover" if self._hover else "normal") + self.slider.setProperty("state", state) + self.slider.style().unpolish(self.slider) + self.slider.style().polish(self.slider) + + +# ==================== 主窗口 ==================== +class MainWindow(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("haocode - 极客级协同系统") + self.resize(1100, 800) + self.setMinimumSize(900, 600) + + # 🌟 侧边栏收缩状态(False=展开 260px / True=收缩为 52px 细条) + self.sidebar_collapsed = False + self._sidebar_anim = None + self._sidebar_target = False + + # 🌟 v2: 恢复 Windows 11 原生边框(移除无边框自绘标题栏)。 + # 原生标题栏自带 最小化/最大化/关闭、窗口拖拽、边缘吸附与圆角; + # 自绘按钮、EdgeGrip 拉伸、四分屏吸附逻辑全部移除,交给 DWM 接管。 + + # 🌟 调试标记:设置 HAOCODE_WINDOW_TAG=xxx 可在标题栏追加标记, + # 用于多实例(如 A/B 渲染模式对比)时区分窗口,平时不设置则无影响。 + _win_title = "haocode" + _win_tag = os.environ.get("HAOCODE_WINDOW_TAG", "") + if _win_tag: + _win_title = f"{_win_title} [{_win_tag}]" + self.setWindowTitle(_win_title) + + self.setup_ui() + self.setup_stylesheet() + + # 🌟 初始化模型选择与发送逻辑 + self.current_provider = None + self.current_model = None + self.worker = None + + self.is_generating = False + self._was_cancelled = False + + # 🌟 提前初始化(init_model_popup 内部会触发 update_context_display 用到) + self._active_streams = {} + self._diag_chunk_n = 0 + self._diag_think_n = 0 + try: + open(DIAG_LOG_PATH, "w").close() + diag_log("APP_START") + except Exception: + pass + # 🌟 渲染看门狗:若前端 rAF/定时器被浏览器节流(窗口隐藏/GPU 问题), + # 由 Qt 侧定时器每 200ms 主动 forceRenderNow,保证流式正文一定上屏 + self._render_watchdog = QtCore.QTimer(self) + self._render_watchdog.setInterval(200) + self._render_watchdog.timeout.connect(self._render_watchdog_tick) + self._render_watchdog.start() + # 人肉 debug:JS console 桥抽取器(JS console.log → Python 控制台) + self._jslog_timer = QtCore.QTimer(self) + self._jslog_timer.setInterval(500) + self._jslog_timer.timeout.connect(self._jslog_drain_tick) + self._jslog_timer.start() + # 🆕 Fix E: 上下文标签防抖定时器(工具/思考/正文任一显现 → 400ms 内合并刷新一次) + self._ctx_refresh_timer = QtCore.QTimer(self) + self._ctx_refresh_timer.setSingleShot(True) + self._ctx_refresh_timer.setInterval(400) + self._ctx_refresh_timer.timeout.connect(self.update_context_display) + # 🆕 调试窗口控制:文件协议 data/debug_window.cmd (show/hide),与项目树完全独立 + self.debug_window = None + self._debug_cmd_timer = QtCore.QTimer(self) + self._debug_cmd_timer.setInterval(2000) + self._debug_cmd_timer.timeout.connect(self._poll_debug_cmd) + self._debug_cmd_timer.start() + self._title_workers = {} + self.current_ai_msg_id = None + self._ghost_msg_map = {} # 🌟 新增:专门记录幽灵报错气泡与提问的映射关系 + import time + self._last_click_time = 0 + + self.init_model_popup() + self.init_mode_popup() + self.init_chat_events() + + # 🌟 长文本折叠相关 + self._folded_texts = [] + self._is_folding = False + self.LONG_TEXT_THRESHOLD = 500 + self.LONG_TEXT_LINES = 15 + + self.init_browser() + + # 🆕 调试窗口随程序启动(config.json debug_window_autostart,缺省 true): + # 写 "show" 到控制文件,上方 2s 轮询在事件循环启动后自动开窗 + try: + from core.llm_engine import _load_config + from core.debug_log import autostart_debug_window + autostart_debug_window(_load_config()) + except Exception: + pass + + + def setup_ui(self): + self.bg_widget = QtWidgets.QWidget(self) + self.bg_widget.setObjectName("bg_widget") + self.setCentralWidget(self.bg_widget) + + + self.main_layout = QtWidgets.QHBoxLayout(self.bg_widget) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.main_layout.setSpacing(0) + + # ==================== 1. 左侧边栏 ==================== + self.sidebar = QtWidgets.QWidget() + self.sidebar.setObjectName("sidebar") + self.sidebar.setFixedWidth(260) + self.sidebar_root = QtWidgets.QVBoxLayout(self.sidebar) + self.sidebar_root.setContentsMargins(0, 0, 0, 0) + self.sidebar_root.setSpacing(0) + + # 🌟 双页面容器:展开页 / 收缩页(收缩图标用独立干净布局精确居中) + self.sidebar_stack = QtWidgets.QStackedWidget() + self.sidebar_root.addWidget(self.sidebar_stack) + + # ---- 页面0:展开状态(标题行、logo、历史列表、功能按钮)---- + self.expand_page = QtWidgets.QWidget() + self.sidebar_layout = QtWidgets.QVBoxLayout(self.expand_page) + self.sidebar_layout.setContentsMargins(10, 20, 10, 20) + + # 🌟 顶部标题行:应用图标 + haocode + 收缩按钮(统一高度对齐) + self.sidebar_header = QtWidgets.QWidget() + self.sidebar_header.setFixedHeight(36) + self.sidebar_header_layout = QtWidgets.QHBoxLayout(self.sidebar_header) + self.sidebar_header_layout.setContentsMargins(2, 0, 2, 0) + self.sidebar_header_layout.setSpacing(8) + + self.logo_icon = QtWidgets.QLabel() + self.logo_icon.setObjectName("logo_icon") + self.logo_icon.setFixedSize(22, 22) + self.logo_icon.setPixmap(QtGui.QPixmap(self.get_svg_path("main.svg")) + .scaled(22, 22, QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation)) + + self.logo_label = QtWidgets.QLabel("haocode") + self.logo_label.setObjectName("logo_label") + + self.collapse_btn = QtWidgets.QPushButton("") + self.collapse_btn.setObjectName("collapse_btn") + self.collapse_btn.setIcon(QtGui.QIcon(self.get_svg_path("panel.svg"))) + self.collapse_btn.setIconSize(QtCore.QSize(16, 16)) + self.collapse_btn.setFixedSize(28, 28) + self.collapse_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self.collapse_btn.setToolTip("收起侧边栏") + + self.sidebar_header_layout.addWidget(self.logo_icon, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) + self.sidebar_header_layout.addWidget(self.logo_label, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) + self.sidebar_header_layout.addStretch() + self.sidebar_header_layout.addWidget(self.collapse_btn, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) + + self.sidebar_layout.addWidget(self.sidebar_header) + self.sidebar_layout.addSpacing(10) + + self.history_list = DraggableHistoryList() + self.history_list.setObjectName("history_list") + self.sidebar_layout.addWidget(self.history_list) + + self.btn_new_chat = QtWidgets.QPushButton(" + 新建对话") + self.btn_new_chat.setObjectName("btn_new_chat") + self.sidebar_layout.addWidget(self.btn_new_chat) + self.sidebar_layout.addSpacing(20) + + self.btn_skills = QtWidgets.QPushButton("Skill & Tools") + self.btn_settings = QtWidgets.QPushButton("设置") + self.btn_help = QtWidgets.QPushButton("帮助") + self.btn_about = QtWidgets.QPushButton("关于 (1.19.0)") + for btn in [self.btn_skills, self.btn_settings, self.btn_help, self.btn_about]: + btn.setObjectName("sidebar_menu_btn") + self.sidebar_layout.addWidget(btn) + + self.sidebar_stack.addWidget(self.expand_page) + + # ---- 页面1:收缩状态(上下 stretch 使图标精确垂直居中)---- + self.collapse_page = QtWidgets.QWidget() + self.collapse_lay = QtWidgets.QVBoxLayout(self.collapse_page) + self.collapse_lay.setContentsMargins(0, 0, 0, 0) + self.collapse_lay.addStretch(1) + self.collapse_expand_btn = QtWidgets.QPushButton("") + self.collapse_expand_btn.setObjectName("collapse_btn") + self.collapse_expand_btn.setIcon(QtGui.QIcon(self.get_svg_path("panel.svg"))) + self.collapse_expand_btn.setIconSize(QtCore.QSize(18, 18)) + self.collapse_expand_btn.setFixedSize(34, 34) + self.collapse_expand_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self.collapse_expand_btn.setToolTip("展开侧边栏") + self.collapse_lay.addWidget(self.collapse_expand_btn, 0, QtCore.Qt.AlignmentFlag.AlignHCenter) + self.collapse_lay.addStretch(1) + self.sidebar_stack.addWidget(self.collapse_page) + + # ==================== 2. 右侧主聊天区 ==================== + self.chat_area = QtWidgets.QWidget() + self.chat_area.setObjectName("chat_area") + self.chat_layout = QtWidgets.QVBoxLayout(self.chat_area) + self.chat_layout.setContentsMargins(0, 0, 0, 0) + self.chat_layout.setSpacing(0) + + # --- 2.1 自定义顶部标题栏 --- + self.top_bar = QtWidgets.QWidget() + self.top_bar.setObjectName("top_bar") + self.top_bar.setFixedHeight(50) + self.top_bar_layout = QtWidgets.QHBoxLayout(self.top_bar) + self.top_bar_layout.setContentsMargins(20, 0, 10, 0) + + self.top_label = QtWidgets.QLabel("") + self.top_label.setStyleSheet("font-weight: bold; font-size: 16px;") + self.top_bar_layout.addWidget(self.top_label) + self.top_bar_layout.addStretch() + + self.btn_history = QtWidgets.QPushButton("历史") + self.btn_history.setObjectName("top_tool_btn") + self.top_bar_layout.addWidget(self.btn_history) + + # 🆕 右侧任务面板的开关【不在这里】:与左侧栏一致,开关在栏内 + # (收起=栏正中按钮 / 展开=栏标题行右上角按钮)→ 永远只有一个可动按钮 + + # 🌟 v2: 移除自绘 最小化/最大化/关闭 按钮 —— 已由 Windows 11 原生标题栏接管。 + # 历史 按钮保留,随顶部工具栏整体下移到原生标题栏下方。 + self.chat_layout.addWidget(self.top_bar) + + # --- 2.2 🌟 核心修复:浏览器渲染引擎防穿帮“幕布” --- + self.browser_container = QtWidgets.QWidget() + self.browser_container.setObjectName("browser_container") + self.browser_container.setStyleSheet("background-color: #ffffff;") # 纯白幕布 + self.browser_layout = QtWidgets.QVBoxLayout(self.browser_container) + self.browser_layout.setContentsMargins(0, 0, 0, 0) + self.browser_layout.setSpacing(0) + + # 创建浏览器(🆕 A′ 双轨:Windows 上优先 WebView2,失败/配置指定时回落 QtWebEngine) + # P1-03:平台矩阵 —— 解析 webview_backend 配置;非法值可见警告 + 回落平台默认,不阻断启动 + self._wv2_session = None + _backend_pref = "auto" + try: + from core.config_paths import load_config as _load_cfg # P0-01 统一配置入口 + _backend_pref = _load_cfg().get("webview_backend", "auto") + except Exception: + pass + _backend, _backend_warn = _renderer_backend.resolve_backend(_backend_pref) + if _backend_warn: + print(_backend_warn, flush=True) + if _backend == "webview2" and _wv2mod is not None and sys.platform == "win32": + _app = QtWidgets.QApplication.instance() + _env = _wv2mod.get_environment(_app) + if _env is not None: + try: + self._wv2_session = _wv2mod.Wv2Session(_env, int(self.winId()), _app) + except Exception as _ex: + print(f"[WV2] session 创建失败 → 回落 QtWebEngine: {_ex}") + self._wv2_session = None + + if self._wv2_session is not None: + # ---- WebView2 路径 ---- + from ui.views.wv2_view import WebView2View + self.browser = WebView2View(self._wv2_session) + self.browser.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding) + self.browser.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu) + # 桥接:Python→JS 走 ExecuteScript(JS 文本同构);JS→Python 走 WebMessageReceived + self.chat_bridge = ChatBridge(None, None, js_runner=self.browser.run_js) + self.browser.attach_bridge(self.chat_bridge) + self.web_channel = None + print("[System] 浏览器内核: WebView2") + else: + # ---- QtWebEngine 路径(原逻辑,保持可用) ---- + # P1-03:本实例独立 profile 目录(两个并行源码实例不争用同一个 Chromium profile; + # 目录在 data/webengine/,测试经 HAOCODE_WEBENGINE_PROFILE_DIR 重定向到临时目录) + from PyQt6.QtWebEngineCore import QWebEngineProfile + _prof_name = _renderer_backend.webengine_profile_name() + _prof_dir = _renderer_backend.webengine_profile_dir(_prof_name) + self._qtwe_profile = QWebEngineProfile(_prof_name) + self._qtwe_profile.setPersistentStoragePath(os.path.join(_prof_dir, "storage")) + self._qtwe_profile.setCachePath(os.path.join(_prof_dir, "cache")) + print(f"[Renderer] QtWebEngine 独立 profile: {_prof_dir}", flush=True) + + self.browser = QWebEngineView() + # 🌟 关键修改:使用自定义的 WebPage + custom_page = CustomWebPage(self._qtwe_profile, self.browser) + self.browser.setPage(custom_page) + + # 🌟 v2: 不透明绘制属性 —— 告诉 Qt 无需先擦背景再等 WebEngine 纹理, + # 减少窗口缩放瞬间新暴露区域的底色闪烁(配合白底幕布 + main.py 的 GPU 光栅化 flags) + self.browser.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent, True) + self.browser.setAttribute(QtCore.Qt.WidgetAttribute.WA_NoSystemBackground, True) + + # 🌟 让 Chromium 合成器用白色作为清屏/新暴露区域的底色,而非默认黑色: + # → GPU 合成(59 FPS)+ 白底不黑闪,兼得速度与观感(配合 main.py 的合成模式) + self.browser.page().setBackgroundColor(QtGui.QColor("#ffffff")) + + # 其他配置保持不变 + self.browser.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding) + self.browser.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu) + + # 🌟 初始化桥接器时,传入自定义的 page + from PyQt6.QtWebChannel import QWebChannel + + # 创建 QWebChannel,它是 Python ↔ JS 的桥梁 + self.web_channel = QWebChannel() + + # 创建 ChatBridge,传入 page 和 channel(构造函数内部会 channel.registerObject) + self.chat_bridge = ChatBridge(custom_page, self.web_channel) + + # 把 channel 设置到 page 上,使 qt.webChannelTransport 生效 + custom_page.setWebChannel(self.web_channel) + print("[System] 浏览器内核: QtWebEngine") + + self.browser_container.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent, True) + + # 将浏览器放入布局 + self.browser_layout.addWidget(self.browser) + + # 🌟 自定义滚动条:固定在浏览器右侧,总高度随浏览器变化(镜像网页滚动) + self.web_scrollbar = WebScrollBar(self.chat_bridge) + self.browser_row = QtWidgets.QWidget() + self.browser_row_layout = QtWidgets.QHBoxLayout(self.browser_row) + self.browser_row_layout.setContentsMargins(0, 0, 0, 0) + self.browser_row_layout.setSpacing(0) + self.browser_row_layout.addWidget(self.browser_container, 1) + self.browser_row_layout.addWidget(self.web_scrollbar, 0) + self.chat_layout.addWidget(self.browser_row) # 浏览器 + 滚动条整行加入主布局 + + # --- 2.3 底部输入框容器 --- + self.input_container = QtWidgets.QFrame() + self.input_container.setObjectName("input_container") + # 💡 小优化:把最大高度从 200 调到 250,给折叠框和滚动条留出足够的呼吸空间 + self.input_container.setMaximumHeight(250) + + self.input_container_layout = QtWidgets.QVBoxLayout(self.input_container) + + # 【第 1 行】工具栏 (图标) + self.input_tools_layout = QtWidgets.QHBoxLayout() + self.btn_upload = QtWidgets.QPushButton("") # 清空原来的 Emoji 字符 + self.btn_upload.setIcon(QtGui.QIcon(self.get_svg_path("upload.svg"))) # 使用我们刚才写的安全路径工具 + self.btn_upload.setIconSize(QtCore.QSize(18, 18)) # 图标设为 18x18,配合 30x30 的按钮留出绝佳边距 + + self.btn_screenshot = QtWidgets.QPushButton("✂️") + self.btn_web = QtWidgets.QPushButton("🌐") + self.btn_skill = QtWidgets.QPushButton("🛠️") + self.btn_server = QtWidgets.QPushButton("☁️") + for btn in [self.btn_upload, self.btn_screenshot, self.btn_web, self.btn_skill, self.btn_server]: + btn.setFixedSize(30, 30) + btn.setObjectName("icon_btn") + btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self.input_tools_layout.addWidget(btn) + self.input_tools_layout.addStretch() + self.input_container_layout.addLayout(self.input_tools_layout) + + # 【第 2 行】🌟 多附件标签滚动区域 + self.attachment_scroll_area = QtWidgets.QScrollArea() + self.attachment_scroll_area.setVisible(False) + self.attachment_scroll_area.setWidgetResizable(True) + self.attachment_scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.attachment_scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.attachment_scroll_area.setFixedHeight(48) + self.attachment_scroll_area.setStyleSheet(""" + QScrollArea { border: none; background: transparent; } + QScrollBar:horizontal { border: none; background: transparent; height: 6px; margin: 0px; } + QScrollBar::handle:horizontal { background: #c0c0c0; min-width: 30px; border-radius: 3px; } + QScrollBar::handle:horizontal:hover { background: #a0a0a0; } + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0px; } + """) + + self.attachment_area = QtWidgets.QWidget() + self.attachment_area.setStyleSheet("background: transparent;") + self.attachment_area_layout = QtWidgets.QHBoxLayout(self.attachment_area) + self.attachment_area_layout.setContentsMargins(4, 0, 4, 8) + self.attachment_area_layout.setSpacing(8) + self.attachment_area_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) + + self.attachment_scroll_area.setWidget(self.attachment_area) + self.input_container_layout.addWidget(self.attachment_scroll_area) # <--- 确保它被作为第2行加入 + + # 【第 3 行】文本输入框 + self.text_input = PasteAwareTextEdit() + self.text_input.setPlaceholderText("给 haocode 发送消息...") + self.text_input.setObjectName("text_input") + self.text_input.setMaximumHeight(100) + self.input_container_layout.addWidget(self.text_input) + + # 【第 4 行】底部栏 (提示文字、模型选择、发送按钮) + self.input_bottom_layout = QtWidgets.QHBoxLayout() + self.hint_label = QtWidgets.QLabel("AI 生成内容可能不准确。") + self.hint_label.setObjectName("hint_label") + + self.context_label = QtWidgets.QLabel("128k / 200k") + self.context_label.setObjectName("context_label") + + self.model_selector = QtWidgets.QPushButton("gemini-3-pro-preview...") + self.model_selector.setObjectName("model_selector") + # 🆕 圆角改造:与模式按钮(mode_btn)完全一致的白底 13px 圆角,同高 26 + self.model_selector.setFixedHeight(26) + self.model_selector.setMinimumWidth(96) + + self.btn_send = QtWidgets.QPushButton("") # 1. 传入空字符串,去除原本的文字 + self.btn_send.setIcon(QtGui.QIcon(self.get_svg_path("send.svg"))) # 2. 加载 SVG 图标(用冻结安全路径) + self.btn_send.setIconSize(QtCore.QSize(20, 20)) # 3. 设置图标大小 (稍微比 32x32 的按钮小一点,留出边距更好看) + self.btn_send.setObjectName("btn_send") + self.btn_send.setFixedSize(32, 32) + + # ============ 🆕 模式切换按钮(chat / worker) ============ + # 按按钮 → 上方浮动窗缓慢弹出(SessionModePopup,与模型选择弹窗同款实现); + # 选择后发送第一条消息 → 该会话锁定此模式 + self._mode_panel_selected = "chat" + + self.btn_mode = QtWidgets.QToolButton() + self.btn_mode.setObjectName("mode_btn") + self.btn_mode.setFixedSize(96, 26) + self.btn_mode.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self.btn_mode.setToolTip("切换聊天模式(发送第一条消息后锁定)") + self.btn_mode.clicked.connect(self.show_mode_popup) + + self.input_bottom_layout.addWidget(self.hint_label) + self.input_bottom_layout.addStretch() + self.input_bottom_layout.addWidget(self.context_label) + self.input_bottom_layout.addWidget(self.model_selector) + self.input_bottom_layout.addWidget(self.btn_mode) + self.input_bottom_layout.addWidget(self.btn_send) + self.input_container_layout.addLayout(self.input_bottom_layout) + + # 打包装入主聊天区域 + self.input_wrapper = QtWidgets.QHBoxLayout() + self.input_wrapper.setContentsMargins(20, 0, 20, 20) + self.input_wrapper.addWidget(self.input_container) + self.chat_layout.addLayout(self.input_wrapper) + + self.main_layout.addWidget(self.sidebar) + self.main_layout.addWidget(self.chat_area) + # 🆕 右侧任务面板(默认收起 52px;不参与 resizeEvent 自动收缩,只能手动切换) + self.bash_panel = BashPanel(self) + self.main_layout.addWidget(self.bash_panel) + self.browser.loadFinished.connect(self.on_web_load_finished) + + # ========================================== + # 🌟 数据库与历史记录初始化逻辑 (放在 UI 控件全建好之后) + # ========================================== + self.db = DBManager() + + if self.db.is_first_run: + print("[System] 欢迎!检测到初次运行,已自动创建本地数据库和初始对话。") + else: + print("[System] 欢迎回来!成功读取本地数据库。") + + # 1. 获取所有会话并在侧边栏渲染 + sessions = self.db.get_all_sessions() + + # 清空 UI 上可能残留的占位符 + self.history_list.clear() + + # 🟢 改为: + self.current_session_id = None # 先初始化 + self.pending_mode = "chat" # 🆕 锁定前选中的模式(默认 chat) + self.rebuild_sidebar() + + # 2. 确定启动时默认加载的对话 (默认最新的一条) + if sessions: + self.current_session_id = sessions[0]["id"] + # 让 UI 列表默认选中第一项 + self.history_list.setCurrentRow(0) + + # ⚠️ 注意:不要在这里直接调用 load_messages_to_web! + # 因为此时 QWebEngineView 的网页还没加载完,直接执行 JS 会报错。 + # 我们只需记住 current_session_id,等网页加载完毕后再灌入数据。 + else: + self.current_session_id = None + self.pending_load_session_id = None + + + # ==================== 🌟 核心新增:初始化浏览器 ==================== + # 把它放在 def setup_ui(self): 的正上方 + def get_svg_path(self, filename): + """🌟 自动获取 svg 文件的绝对路径,并检测文件是否存在""" + import os + base_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.abspath(os.path.join(base_dir, "..", "..")) + full_path = os.path.join(project_root, "svg", filename) + + # 🛠️ 杀手锏:帮你自动检查文件到底在不在! + if not os.path.exists(full_path): + print(f"\n❌ [严重警告]: 找不到图标文件!系统去这里找了,但是没找到: \n -> {full_path}\n") + else: + print(f"\n✅ [成功]: 找到了图标文件: {full_path}") + + return full_path + + def init_browser(self): + """初始化浏览器设置、信号绑定及页面加载""" + + # 🆕 P1-01 渲染窗口:配置(10/40/200,非法值静默回落 40)+ 代次管理 + from core import config_paths as _cp + _rw_cfg = _cp.render_window_settings() + self._rw_mode = _rw_cfg["mode"] + self._rw_size = _rw_cfg["size"] + # 页大小 = 半窗:保证向上换页时“首个可见消息”锚点留在窗口内(锚点恢复前提) + self._rw_page_size = max(1, self._rw_size // 2) + self._rw_generation = 0 # 每次加载/切会话/分支/删除/重答/新建 +1 + self._rw_config_pushed = False + self._rw_page_inflight = False + + # 1. 信号绑定 (ChatBridge 已在 setup_ui 中通过 QWebChannel 注册) + # 重新回答信号 + self.chat_bridge.regenerate_clicked.connect(self._on_regenerate_clicked) + + # 分支切换信号 + self.chat_bridge.branch_switch_clicked.connect(self._on_branch_switch) + + # 删除消息信号 + self.chat_bridge.delete_message_requested.connect(self._on_delete_message) + # 🌟 绑定附件点击信号 + self.chat_bridge.attachment_clicked.connect(self._on_attachment_clicked) + # 🆕 P1-01 渲染窗口换页请求(fire-and-forget,响应经 rwPageResponse 推送) + self.chat_bridge.window_page_requested.connect(self._on_window_page_request) + # 2. 计算本地 HTML 路径 + # 假设 web 文件夹位于当前脚本目录的上一级 + base_dir = os.path.dirname(os.path.abspath(__file__)) + html_path = os.path.normpath(os.path.join(base_dir, '..', 'web', 'index.html')) + + # 3. 加载页面 + # 注意:确保在加载 URL 之前,QWebChannel 已经正确设置到了 self.browser.page() 上 + # 🛡 防缓存:file:// URL 追加时间戳查询串 —— 每次启动保证加载最新 web/ 代码, + # 避免 QtWebEngine 残留旧 JS/CSS 导致“改了前端没生效” + _u = QUrl.fromLocalFile(html_path) + # 🆕 立即设按钮初始文本:WebView2 冷启动链路慢(~10s+),不能等页面加载后才填按钮 + try: + self._refresh_mode_button() + except Exception: + pass + self.browser.setUrl(QUrl(_u.toString() + "?hocode_v=" + str(int(_diag_time.time() * 1000)))) + def _on_attachment_clicked(self, meta_json_str): + """🌟 弹出原生附件预览窗口""" + import json + try: + meta = json.loads(meta_json_str) + # self.bg_widget 是铺满整个主窗口的底层容器,挂在它上面就能完美覆盖全屏 + # PDF 图片卡:用勾选的图片开可翻页预览(抄弹窗预览那套,页码显示在顶部标题) + if meta.get("type") == "pdf" and meta.get("mode") == "image" and meta.get("selected_images"): + base_name = meta.get("name", "") + siblings = [{ + "type": "image", + "name": f'{base_name} · 第 {x["page"]} 页 no.{x["index"]}', + "local_path": x["local_path"], + "mime": x.get("mime", "image/png"), + "size_kb": x.get("size_kb", 0), + } for x in meta["selected_images"]] + overlay = AttachmentPreviewOverlay(siblings[0], self.bg_widget, + siblings=siblings, index=0) + else: + overlay = AttachmentPreviewOverlay(meta, self.bg_widget) + except Exception as e: + print(f"[Warn]: 打开附件预览失败: {e}") + + def setup_stylesheet(self): + self.setStyleSheet(""" + * { font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", "Segoe UI", sans-serif; } + + #bg_widget { background-color: #ffffff; } + #chat_area { background-color: #ffffff; } + + /* ---- 左侧边栏 / 右侧任务面板(共用一套淡蓝设计语言)---- */ + #sidebar { background-color: #f7f8fa; border-right: 1px solid #ececec; } + #right_sidebar { background-color: #f7f8fa; border-left: 1px solid #ececec; } + #logo_icon { background: transparent; } + #logo_label { font-size: 20px; font-weight: 900; color: #1a2332; letter-spacing: 0.5px; background: transparent; } + #collapse_btn { background: transparent; border: none; border-radius: 8px; } + #collapse_btn:hover { background-color: #e6e8ee; } + + /* ---- 侧边栏历史列表(🐛 用 objectName 限定作用域:旧版是 QListWidget 类型选择器, + 会级联泄漏进模型选择弹窗——选中行 3px 蓝竖条/padding/margin 全部漏进去, + 造成“调参工具与实际不一致”。现在只作用于 #history_list) ---- */ + #history_list { border: none; background: transparent; outline: none; } + #history_list::item { padding: 10px 12px; border-radius: 8px; margin: 2px 4px; color: #444; } + #history_list::item:hover { background-color: #eceef2; } + #history_list::item:selected { background-color: #e4edfa; color: #1a3a63; font-weight: 600; border-left: 3px solid #3b82f6; } + + #btn_new_chat { background-color: #ffffff; border: 1px solid #e2e5ea; border-radius: 10px; padding: 10px; font-weight: 600; color: #1f2937; } + #btn_new_chat:hover { background-color: #eef2f7; border-color: #cfd6e0; } + #sidebar_menu_btn { text-align: left; border: none; padding: 9px 12px; margin: 0 4px; color: #5b6472; font-size: 13px; border-radius: 8px; } + #sidebar_menu_btn:hover { background-color: #eceef2; color: #111827; } + + /* ---- 🆕 右侧任务面板(继承左侧栏配色:#f7f8fa / #ececec / 淡蓝 #e4edfa)---- */ + #bl_panel_head { background: transparent; } + #bl_panel_title { font-size: 13px; font-weight: 700; color: #1a2332; } + #bl_panel_total { font-size: 11px; color: #8a94a6; } + #bl_splitter::handle { background: #e9ecf3; } + #bl_splitter::handle:hover { background: #d7deea; } + #bl_sect_head { background: transparent; } + #bl_sect_head:hover { background-color: #eceef2; } + #bl_sect_title { font-size: 12px; font-weight: 600; color: #33415c; } + #bl_sect_count { font-size: 11px; color: #7c8698; } + #bl_sect_chev { color: #8a94a6; font-size: 10px; } + #bl_scroll, #bl_host { background: transparent; border: none; } + #bl_hint { color: #9aa4b5; font-size: 11px; padding: 2px 10px 8px 10px; } + #bl_layer { background-color: #ffffff; border: 1px solid #e6eaf2; border-radius: 9px; } + #bl_layer:hover { border-color: #cfe0f7; } + #bl_head { background: transparent; border-radius: 9px; } + #bl_head:hover { background-color: #f2f7fe; } + #bl_dot { font-size: 10px; color: #9aa4b5; } + #bl_dot[state="run"] { color: #3b82f6; } + #bl_dot[state="ok"] { color: #16a34a; } + #bl_dot[state="bad"] { color: #dc2626; } + #bl_name { font-size: 11px; font-weight: 700; color: #1a3a63; } + #bl_meta { font-size: 10px; color: #7c8698; } + #bl_cmd { font-size: 10px; color: #5b6472; } + #bl_chev { color: #9aa4b5; font-size: 10px; } + #bl_tag { font-size: 9px; border-radius: 6px; padding: 1px 5px; + color: #4b5563; background-color: #eef0f4; } + #bl_tag[kind="run"] { color: #1d4ed8; background-color: #e4edfa; } + #bl_tag[kind="ok"] { color: #15803d; background-color: #e7f6ec; } + #bl_tag[kind="bad"] { color: #b91c1c; background-color: #fdeaea; } + #bl_tag[kind="out"] { color: #6b7280; background-color: #eef0f4; } + #bl_sect_label { font-size: 10px; font-weight: 600; color: #7c8698; } + #bl_code { background-color: #fbfcfe; border: 1px solid #e6eaf2; border-radius: 7px; + padding: 4px; font-family: Consolas, "Courier New", monospace; + font-size: 11px; color: #243043; } + /* ---- 🆕 P2-02:右侧任务面板滚动条 + 横纵交汇角(全部选择器限定在 + #bl_code / #bl_scroll,绝不添加无作用域的 QScrollBar/QAbstractScrollArea + 全局规则,不污染模型弹窗/会话列表/附件预览等其他控件) ---- + 口径:厚 8px、隐藏箭头(add-line/sub-line 置 0)、handle 可见 + hover、 + corner 用 Qt 支持的 QAbstractScrollArea::corner 子控件(不写 QScrollBar::corner), + 与代码框背景 #fbfcfe 一致(消除原生亮色 corner 方块) */ + QPlainTextEdit#bl_code QScrollBar:vertical { border: none; background: transparent; width: 8px; margin: 0px; } + QPlainTextEdit#bl_code QScrollBar::handle:vertical { background: #d0d0d0; min-height: 30px; border-radius: 4px; } + QPlainTextEdit#bl_code QScrollBar::handle:vertical:hover { background: #a0a0a0; } + QPlainTextEdit#bl_code QScrollBar::add-line:vertical, QPlainTextEdit#bl_code QScrollBar::sub-line:vertical { height: 0px; border: none; background: transparent; } + QPlainTextEdit#bl_code QScrollBar::add-page:vertical, QPlainTextEdit#bl_code QScrollBar::sub-page:vertical { background: transparent; } + QPlainTextEdit#bl_code QScrollBar:horizontal { border: none; background: transparent; height: 8px; margin: 0px; } + QPlainTextEdit#bl_code QScrollBar::handle:horizontal { background: #d0d0d0; min-width: 30px; border-radius: 4px; } + QPlainTextEdit#bl_code QScrollBar::handle:horizontal:hover { background: #a0a0a0; } + QPlainTextEdit#bl_code QScrollBar::add-line:horizontal, QPlainTextEdit#bl_code QScrollBar::sub-line:horizontal { width: 0px; border: none; background: transparent; } + QPlainTextEdit#bl_code QScrollBar::add-page:horizontal, QPlainTextEdit#bl_code QScrollBar::sub-page:horizontal { background: transparent; } + QPlainTextEdit#bl_code::corner { background-color: #fbfcfe; } + QScrollArea#bl_scroll QScrollBar:vertical { border: none; background: transparent; width: 8px; margin: 0px; } + QScrollArea#bl_scroll QScrollBar::handle:vertical { background: #d0d0d0; min-height: 30px; border-radius: 4px; } + QScrollArea#bl_scroll QScrollBar::handle:vertical:hover { background: #a0a0a0; } + QScrollArea#bl_scroll QScrollBar::add-line:vertical, QScrollArea#bl_scroll QScrollBar::sub-line:vertical { height: 0px; border: none; background: transparent; } + QScrollArea#bl_scroll QScrollBar::add-page:vertical, QScrollArea#bl_scroll QScrollBar::sub-page:vertical { background: transparent; } + QScrollArea#bl_scroll::corner { background: transparent; } + #bl_body { background: transparent; } + + /* ---- 顶部栏 ---- */ + #top_bar { background-color: #ffffff; border-bottom: 1px solid #eeeeee; } + #top_tool_btn { background: transparent; border: 1px solid #e2e5ea; border-radius: 8px; padding: 5px 14px; color: #4b5563; font-size: 12px; } + #top_tool_btn:hover { background-color: #f3f4f6; border-color: #d1d5db; color: #111827; } + + /* ---- 输入区 ---- */ + #input_container { background-color: #f6f7f9; border: 1px solid #e4e6ea; border-radius: 20px; padding: 10px; } + #icon_btn { background: transparent; border: none; border-radius: 15px; } + #icon_btn:hover { background-color: #e6e8ec; } + #text_input { border: none; background: transparent; font-size: 15px; color: #1f2937; selection-background-color: #bfdbfe; } + #hint_label { color: #9ca3af; font-size: 11px; } + #context_label { color: #6b7280; font-size: 12px; margin-right: 10px; } + /* 🆕 模型选择按钮:与模式按钮(mode_btn)完全一致的圆角样式 */ + #model_selector { background: #ffffff; border: 1px solid #e2e5ea; border-radius: 13px; font-size: 12px; color: #4b5563; padding: 2px 8px; } + #model_selector:hover { background-color: #eef1f5; border-color: #cbd5e1; color: #111827; } + + /* 🆕 模式切换按钮(选择窗为 SessionModePopup 浮动窗,配色由其自己注入) */ + #mode_btn { background: #ffffff; border: 1px solid #e2e5ea; border-radius: 13px; font-size: 12px; color: #4b5563; padding: 2px 8px; } + #mode_btn:hover { background-color: #eef1f5; border-color: #cbd5e1; color: #111827; } + + /* ---- 发送按钮(实际配色由 set_send_button_state 动态控制) ---- */ + #btn_send { background-color: #2563eb; border: none; border-radius: 16px; font-weight: 700; } + #btn_send:hover { background-color: #1d4ed8; } + #btn_send:pressed { background-color: #1e40af; } + """) + + def set_send_button_state(self, is_generating: bool): + """🌟 统一管理发送/停止按钮的 UI 状态""" + if is_generating: + # 正在生成:红色停止按钮(白色图标) + self.btn_send.setIcon(QtGui.QIcon(self.get_svg_path("stop_white.svg"))) + self.btn_send.setStyleSheet(""" + QPushButton { background-color: #ef4444; border: none; border-radius: 16px; font-weight: 700; } + QPushButton:hover { background-color: #dc2626; } + QPushButton:pressed { background-color: #b91c1c; } + """) + else: + # 空闲状态:蓝色发送按钮(白色图标) + self.btn_send.setIcon(QtGui.QIcon(self.get_svg_path("send_white.svg"))) + self.btn_send.setStyleSheet(""" + QPushButton { background-color: #2563eb; border: none; border-radius: 16px; font-weight: 700; } + QPushButton:hover { background-color: #1d4ed8; } + QPushButton:pressed { background-color: #1e40af; } + """) + + # ==================== 🌟 侧边栏收缩 ==================== + @QtCore.pyqtProperty(float) + def sidebarWidth(self): + """侧边栏宽度属性:供 QPropertyAnimation 平滑插值收缩/展开""" + return float(self.sidebar.width()) + + @sidebarWidth.setter + def sidebarWidth(self, w): + self.sidebar.setFixedWidth(int(w)) + + def _effective_collapsed(self): + """当前生效的收缩状态:动画播放中按其目标方向,否则按已落定状态。""" + if self._sidebar_anim is not None: + return self._sidebar_target + return self.sidebar_collapsed + + def toggle_sidebar(self): + """手动切换侧边栏展开/收缩""" + self._set_sidebar_collapsed(not self._effective_collapsed()) + + def _set_sidebar_collapsed(self, collapsed: bool): + """收缩/展开侧边栏:从右到左平滑滑动宽度动画。 + 展开/收缩分别使用独立干净页面(QStackedWidget), + 收缩图标由上下 stretch 精确垂直居中,彻底规避残留间距导致的偏移。 + """ + # 若已有动画正在播放,先停止并重定向到新目标(保证 resize 时也能正常工作) + if self._sidebar_anim is not None: + self._sidebar_anim.stop() + self._sidebar_anim.deleteLater() + self._sidebar_anim = None + + self._sidebar_target = collapsed + + # 🌟 切换页面:收缩页(图标精确居中) / 展开页(完整内容) + self.sidebar_stack.setCurrentWidget( + self.collapse_page if collapsed else self.expand_page) + + # 🌟 从右到左 / 从左到右 宽度滑动动画 + start_w = self.sidebar.width() + end_w = 52 if collapsed else 260 + self._sidebar_anim = QtCore.QPropertyAnimation(self, b"sidebarWidth") + self._sidebar_anim.setDuration(260) + self._sidebar_anim.setStartValue(float(start_w)) + self._sidebar_anim.setEndValue(float(end_w)) + self._sidebar_anim.setEasingCurve(QtCore.QEasingCurve.Type.InOutCubic) + self._sidebar_anim.finished.connect(self._on_sidebar_anim_finished) + self._sidebar_anim.start() + + def _on_sidebar_anim_finished(self): + """动画结束:锁定最终宽度并落定状态。""" + self.sidebar.setFixedWidth(52 if self._sidebar_target else 260) + self.sidebar_collapsed = self._sidebar_target + self._sidebar_anim.deleteLater() + self._sidebar_anim = None + + def resizeEvent(self, event): + super().resizeEvent(event) + # 🌟 侧边栏自动收缩:带回滞,且动画播放中也能正确重定向(resize 下同样正常工作) + eff = self._effective_collapsed() + if eff: + # 当前处于(或正趋向)收缩态:仅当宽度明显变大才展开 + if self.width() > 1150: + self._set_sidebar_collapsed(False) + else: + # 当前处于(或正趋向)展开态:仅当宽度明显变小才收缩 + if self.width() < 1050: + self._set_sidebar_collapsed(True) + + # 🌟 v2: 原生边框自带拉伸,EdgeGrip 隐形拉伸块已移除,无需手动几何计算。 + + # 设置窗口跟随主窗口自适应 + if hasattr(self, '_settings_win') and self._settings_win is not None: + self._settings_win.resize_to_parent() + + def showEvent(self, event): + super().showEvent(event) + # 🌟 v2: 每次显示都重新声明圆角偏好(Windows 11 22000+)。 + # 最大化时 Windows 会按系统规则自动切换为直角,属正常行为。 + self._apply_win11_rounded_corners() + + def _apply_win11_rounded_corners(self): + """通过 DWM 设置窗口圆角偏好(Windows 11 原生圆角)。""" + try: + import ctypes + if not hasattr(ctypes, 'windll'): + return # 非 Windows 平台无需处理 + # DWMWA_WINDOW_CORNER_PREFERENCE = 33, DWMWCP_ROUND = 2 + val = ctypes.c_int(2) + ctypes.windll.dwmapi.DwmSetWindowAttribute( + ctypes.c_void_p(int(self.winId())), + 33, + ctypes.byref(val), + ctypes.sizeof(val) + ) + except Exception as e: + print(f"[Warn]: 设置窗口圆角失败: {e}") + + + def _try_remove_file(self, path: str, absolute: bool = False) -> None: + """尽力物理删除一个附件缓存文件, 失败仅打印告警。 + + Args: + path: 相对项目根目录的路径, 或 absolute=True 时的绝对路径。 + absolute: path 是否已是绝对路径。 + """ + import os + if not path: + return + if absolute: + abs_path = path + else: + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.abspath(os.path.join(current_dir, '..', '..')) + abs_path = os.path.join(root_dir, path) + try: + if os.path.exists(abs_path): + os.remove(abs_path) + print(f"[System]: 已清理废弃附件缓存 -> {path}") + except Exception as e: + print(f"[Warn]: 清理废弃附件失败: {e}") + + def remove_attachment(self, tag_widget, index): + """删除指定的附件标签,如果是未发送的图片,同时清理本地缓存文件""" + import os + + # 将对应位置标记为 None(不直接 pop,防止索引错乱) + if index < len(self._folded_texts): + att_data = self._folded_texts[index] + + # 🌟 核心新增:垃圾回收机制 (物理删除废弃附件缓存) + if att_data and att_data.get("type") in ("image", "pdf"): + # 附件本体文件 (图片 / PDF 拷贝) + if att_data.get("local_path"): + self._try_remove_file(att_data["local_path"]) + # PDF 图片解析模式提取出的所有图片 + for im in att_data.get("images", []): + abs_im = im.get("abs_path") + if abs_im: + self._try_remove_file(abs_im, absolute=True) + + # 标记数据为废弃 + self._folded_texts[index] = None + + # 从布局中移除并销毁 widget + self.attachment_area_layout.removeWidget(tag_widget) + tag_widget.deleteLater() + + # 如果所有附件都被删除了,隐藏容器并恢复提示语 + if all(t is None for t in self._folded_texts): + self._folded_texts.clear() + self.attachment_scroll_area.setVisible(False) + self.text_input.setPlaceholderText("给 haocode 发送消息...") + + print(f"[UI]: 已移除附件 {index + 1}") + + + + + def reset_input_ui(self): + """恢复输入框和按钮的初始状态""" + self.set_send_button_state(False) + + + # ==================== 事件拦截(P0-02:MainWindow 唯一 eventFilter) ==================== + def eventFilter(self, obj, event): + """统一事件过滤器:只处理输入框的 Enter / Shift+Enter,其余对象与事件全部交给父类。 + 「是否允许发送」的规则是 send_message(from_enter=True) 里的单一实现, + 与发送按钮点击路径共用;一次按键至多触发一次 send_message 调用。""" + if obj == self.text_input and event.type() == QtCore.QEvent.Type.KeyPress: + if event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter): + if event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier: + return False # Shift+Enter:放行换行 + self.send_message(from_enter=True) + return True # 事件已消费:不往输入框插入换行 + return super().eventFilter(obj, event) + # ==================== 🌟 核心新增:发送与线程控制 ==================== + def init_chat_events(self): + # 1. 绑定发送按钮 + self.btn_send.clicked.connect(self.send_message) + + # 2. 为输入框安装事件过滤器 (拦截 Enter 键) + self.text_input.installEventFilter(self) + self.text_input.long_text_pasted.connect(self._on_long_text_pasted) + self.text_input.image_pasted.connect(self._on_image_pasted) + self.text_input.files_pasted.connect(self._on_files_dropped) # 🌟 文本/代码文件拖拽/粘贴 + # 3. 绑定左侧边栏按钮 (占位) + self.btn_new_chat.clicked.connect(self.on_new_chat_clicked) + self.collapse_btn.clicked.connect(self.toggle_sidebar) + self.collapse_expand_btn.clicked.connect(self.toggle_sidebar) + + # 🌟 自定义滚动条:前端页面滚动 → 滑块位置/长度镜像 + self.chat_bridge.scroll_changed.connect(self.web_scrollbar.set_scroll_info) + self.btn_skills.clicked.connect(lambda: print("\n[UI]: 点击了 -> Skill & Tools")) + self.btn_settings.clicked.connect(self.show_settings) # 或者你原来的写法 + self.btn_help.clicked.connect(lambda: print("\n[UI]: 点击了 -> 帮助")) + self.btn_about.clicked.connect(lambda: print("\n[UI]: 点击了 -> 关于")) + + # 4. 绑定顶部工具栏按钮 (占位) + self.btn_history.clicked.connect(lambda: print("\n[UI]: 点击了 -> 历史")) + + # 5. 绑定输入框下方的工具按钮 (占位) + self.btn_upload.clicked.connect(self._on_upload_clicked) + + # 6. 截图功能 (Alt+S 全局热键 + 按钮) + self.screenshot_overlay = ScreenCaptureOverlay() + self.screenshot_overlay.screenshot_captured.connect(self._on_image_pasted) + self.btn_screenshot.clicked.connect(self._start_screenshot) + # P1-04:平台路由 —— Windows RegisterHotKey / X11 XGrabKey / Wayland+offscreen 无全局热键 + _sess_kind = desktop_session.session_kind() + _hk_factory, _hk_msg = desktop_session.hotkey_plan(_sess_kind) + print(_hk_msg, flush=True) + self.hotkey_screenshot = _hk_factory() if _hk_factory else None + if self.hotkey_screenshot is not None: + self.hotkey_screenshot.triggered.connect(self._start_screenshot) + self.hotkey_screenshot.start() + # 非 Windows 平台回退到应用级快捷键(Wayland/offscreen 下窗口获焦时仍可用) + if sys.platform != "win32": + self.shortcut_screenshot = QtGui.QShortcut(QtGui.QKeySequence("Alt+S"), self) + self.shortcut_screenshot.activated.connect(self._start_screenshot) + + self.btn_web.clicked.connect(lambda: print("\n[UI]: 点击了 -> 联网搜索 🌐")) + self.btn_skill.clicked.connect(lambda: print("\n[UI]: 点击了 -> 动态技能 🛠️")) + self.btn_server.clicked.connect(lambda: print("\n[UI]: 点击了 -> 后台服务 ☁️")) + self.history_list.itemClicked.connect(self.on_sidebar_item_clicked) + self.history_list.order_changed.connect(self.on_session_order_changed) + + + def on_session_order_changed(self, ordered_ids): + self.db.update_session_order(ordered_ids) + print(f"[UI]: 会话顺序已更新 ({len(ordered_ids)} 条)") + + + def _on_upload_clicked(self): + """点击上传按钮:支持图片 + 文本/代码文件多选,按扩展名自动分类入库。""" + paths, _ = QtWidgets.QFileDialog.getOpenFileNames( + self, "选择文件(图片 / 文本 / 代码)", "", + "所有文件 (*);;图片 (*.png *.jpg *.jpeg *.webp *.bmp *.gif)" + ) + if not paths: + return + + # 按扩展名分类:图片走图片管线,其余交给文件管线(内部做二进制/编码探测) + image_paths, other_paths = [], [] + for path in paths: + ext = os.path.splitext(path)[1].lower() + (image_paths if ext in PasteAwareTextEdit.IMAGE_EXTS else other_paths).append(path) + + if image_paths: + self._on_image_pasted(image_paths) # 复用:拷贝到 data/attachments 并加图片附件 + if other_paths: + self._on_files_dropped(other_paths) # 复用:读取文本内容并加文件附件 + + + def _on_long_text_pasted(self, text: str): + """长文本粘贴,转为通用附件格式,然后显示在输入框上方""" + size_kb = round(len(text.encode('utf-8')) / 1024, 2) + line_count = text.count('\n') + 1 + att_data = { + "type": "text", + "content": text, + "size_kb": size_kb, + "lines": line_count + } + self._add_attachment_tag(att_data) + + + def _on_image_pasted(self, image_data): + """处理粘贴或拖拽的图片:保存到本地 data/attachments 并添加为附件""" + import os + import shutil + import uuid + + # 定位到项目根目录的 data/attachments + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.abspath(os.path.join(current_dir, '..', '..')) + attach_dir = os.path.join(root_dir, "data", "attachments") + os.makedirs(attach_dir, exist_ok=True) + + # 情况1:image_data 是 QImage 对象(剪切板截图 / 直接拖入的图片数据) + if isinstance(image_data, QtGui.QImage): + new_filename = f"img_{uuid.uuid4().hex[:8]}.png" + target_path = os.path.join(attach_dir, new_filename) + image_data.save(target_path, "PNG") + + size_kb = round(os.path.getsize(target_path) / 1024, 2) + att_data = { + "type": "image", + "name": f"clipboard_{new_filename}", + "size_kb": size_kb, + "mime": "image/png", + "local_path": f"data/attachments/{new_filename}" + } + self._add_attachment_tag(att_data) + + # 情况2:image_data 是文件路径列表(拖拽图片文件 / 剪切板文件 URL) + elif isinstance(image_data, list): + for file_path in image_data: + ext = os.path.splitext(file_path)[1].lower() + if ext == '.jpg': + ext = '.jpeg' + new_filename = f"img_{uuid.uuid4().hex[:8]}{ext}" + target_path = os.path.join(attach_dir, new_filename) + shutil.copy2(file_path, target_path) + + size_kb = round(os.path.getsize(target_path) / 1024, 2) + mime_type = f"image/{ext[1:]}" if ext else "image/jpeg" + att_data = { + "type": "image", + "name": os.path.basename(file_path), + "size_kb": size_kb, + "mime": mime_type, + "local_path": f"data/attachments/{new_filename}" + } + self._add_attachment_tag(att_data) + + # 截图确认后,若主窗口原处于后台(Alt+S 触发),拉回前端显示 + if getattr(self, '_screenshot_from_background', False): + self._screenshot_from_background = False + self.showNormal() # 从最小化恢复 + self.raise_() # 提到 Z 序顶层 + self.activateWindow() # 请求系统焦点 + + def _on_files_dropped(self, paths): + """处理拖拽/粘贴/选择的文件:PDF 走专用解析,其余按文本读取转为附件。 + 不支持的类型 / 二进制 / 超大文件静默跳过,不打扰用户。""" + for path in paths: + ext = os.path.splitext(path)[1].lower() + if ext == '.pdf': # 🌟 PDF 交给专用工具解析(文本/图片双模式) + self._on_pdf_dropped(path) + continue + if ext in BINARY_EXTS: + continue + try: + content, enc, size_kb, lines = read_text_file(path) + except (ValueError, OSError): + continue + self._add_attachment_tag({ + "type": "text", + "name": os.path.basename(path), + "content": content, + "size_kb": size_kb, + "lines": lines, + "encoding": enc, + }) + + def _on_pdf_dropped(self, path): + """PDF 专用导入:拷贝落地 + 用 pdf_reader 解析逐页文本,生成默认文本模式附件。 + 加密/损坏/超大 PDF 静默跳过。""" + import shutil + # 1. 拷贝到 data/attachments(像图片一样落地;图片模式需要原文件再次提取) + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.abspath(os.path.join(current_dir, '..', '..')) + attach_dir = os.path.join(root_dir, "data", "attachments") + os.makedirs(attach_dir, exist_ok=True) + new_filename = f"pdf_{uuid.uuid4().hex[:8]}.pdf" + target_path = os.path.join(attach_dir, new_filename) + try: + shutil.copy2(path, target_path) + # 2. 解析逐页结构化文本 + 总页数 + text_content, pages = extract_pdf_text(target_path) + except (ValueError, OSError): + return + self._add_attachment_tag({ + "type": "pdf", + "name": os.path.basename(path), + "size_kb": round(os.path.getsize(target_path) / 1024, 2), + "pages": pages, + "mode": "text", # 默认文本模式;候选栏里可切 "image" + "text_content": text_content, + "local_path": f"data/attachments/{new_filename}", + "images": [], # 切到图片模式时懒提取并缓存 + "selected_images": [], # 用户勾选的图片(图片模式发送用) + }) + + def _set_pdf_tag_style(self, tag_label: "ClickableLabel", mode: str) -> None: + """按当前模式给 PDF 附件标签设置外框颜色。 + + text 模式: 灰框, 悬停变蓝(与图片附件一致); image 模式: 持久红框。 + + Args: + tag_label: PDF 附件的标签控件。 + mode: "text" 或 "image"。 + """ + if mode == "image": + # 图片解析模式: 持久红框(悬停仅微调边框色, 不变蓝) + tag_label.setStyleSheet(""" + QLabel { background-color: #fff1f0; color: #cf1322; font-size: 13px; + padding: 8px 14px; border-radius: 6px; border: 1px solid #ff4d4f; } + QLabel:hover { border: 1px solid #ff7875; } + """) + else: + # 文本模式: 与图片/文本附件标签完全一致, 悬停变蓝 + tag_label.setStyleSheet(""" + QLabel { background-color: #f5f5f5; color: #555555; font-size: 13px; + padding: 8px 14px; border-radius: 6px; border: 1px solid #e5e5e5; } + QLabel:hover { color: #0066cc; border: 1px solid #b3d9ff; text-decoration: underline; } + """) + + def _show_pdf_popup(self, att_data: dict) -> None: + """在 PDF 附件标签上方弹出模式选择弹窗。 + + Args: + att_data: PDF 附件数据字典(含 _tag_label / images / mode 等)。 + """ + # 关闭上一个弹窗 + if getattr(self, "_pdf_popup", None) is not None: + try: + self._pdf_popup.close() + self._pdf_popup.deleteLater() + except Exception: + pass + self._pdf_popup = None + + tag_label = att_data.get("_tag_label") + if tag_label is None: + return + + popup = PdfModePopup(att_data, self.bg_widget) + popup.mode_changed.connect(lambda m, d=att_data: self._set_pdf_tag_style(d["_tag_label"], m)) + popup.preview_requested.connect(self._on_pdf_image_preview) + self._pdf_popup = popup + + # 弹窗高度由 setFixedHeight 固定, 可直接用来算标签上方的落点 + g = tag_label.mapToGlobal(QtCore.QPoint(0, 0)) + x = g.x() + y = g.y() - popup.height() - 5 + if y < 0: + y = g.y() + tag_label.height() + 5 + popup.show_with_animation(QtCore.QPoint(x, y)) + + def _on_pdf_image_preview(self, meta: dict, siblings: list, index: int, att_data: dict) -> None: + """预览 PDF 提取出的图片(复用附件图片预览覆盖层),支持同组左右翻页与选择。 + + Args: + meta: 当前图片元信息, 含 local_path / name / mime。 + siblings: 同组全部图片的 meta 列表(供翻页)。 + index: 当前图片在 siblings 中的下标。 + att_data: PDF 附件字典(供“选择”按钮回写 selected_images)。 + """ + overlay = AttachmentPreviewOverlay(meta, self.bg_widget, siblings=siblings, + index=index, att_data=att_data) + overlay.show() + + def _start_screenshot(self): + """启动截图(P1-04 平台路由:win32/x11 → 覆盖层;wayland → portal;unknown → 明确告知)""" + # 记录截图发起时主窗口是否处于非激活状态(如 Alt+S 热键触发) + self._screenshot_from_background = not self.isActiveWindow() + _kind = desktop_session.session_kind() + _mode, _msg = desktop_session.capture_plan(_kind) + if _msg: + print(_msg, flush=True) + return + if _mode == "portal": + self._start_portal_screenshot() + return + self.screenshot_overlay.start() + + def _start_portal_screenshot(self): + """Wayland:经 xdg-desktop-portal 交互截图(用户授权,不绕过 compositor)。""" + from ui.views.system_tools import portal_capture + if not hasattr(self, "_portal_worker"): + self._portal_worker = None + self._portal_worker = portal_capture.PortalScreenshotWorker(parent=self) + self._portal_worker.done.connect(self._on_portal_screenshot_done) + self._portal_worker.start() + + def _on_portal_screenshot_done(self, ok, path_or_reason): + """portal 截图结果回主线程:成功 → 进现有图片附件流程;失败 → 明确日志。""" + if ok: + self._on_image_pasted([path_or_reason]) + else: + print(f"[Screenshot] portal 截图未完成:{path_or_reason} → 当前环境截图能力不可用/已取消;" + f"聊天与其他功能不受影响", flush=True) + + + def _add_attachment_tag(self, att_data: dict): + """🌟 通用附件 UI 渲染(图片和长文本共用这一个精美样式)""" + index = len(self._folded_texts) + self._folded_texts.append(att_data) + + tag_widget = QtWidgets.QWidget() + tag_widget.setObjectName(f"attachment_tag_{index}") + tag_layout = QtWidgets.QHBoxLayout(tag_widget) + tag_layout.setContentsMargins(0, 0, 0, 0) + tag_layout.setSpacing(6) + + # 根据类型设置不同的文字 + if att_data["type"] == "text": + display_name = att_data.get("name", "长文本") + label_text = f"📄 附件 {index + 1}: {display_name} ({att_data['size_kb']} KB · {att_data['lines']} 行)" + elif att_data["type"] == "pdf": + label_text = f"📕 附件 {index + 1}: {att_data['name']} ({att_data.get('pages', 0)} 页 · {att_data['size_kb']} KB)" + else: + label_text = f"🖼️ 附件 {index + 1}: 图片 ({att_data['name']} · {att_data['size_kb']} KB)" + + label = ClickableLabel(label_text) + label.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + + if att_data["type"] == "pdf": + # 🌟 PDF:点击弹出解析模式候选栏;外框颜色随模式切换(文本=灰 / 图片=红) + att_data["_tag_label"] = label + self._set_pdf_tag_style(label, att_data.get("mode", "text")) + label.clicked.connect(lambda checked=False, d=att_data: self._show_pdf_popup(d)) + else: + label.setStyleSheet(""" + QLabel { background-color: #f5f5f5; color: #555555; font-size: 13px; + padding: 8px 14px; border-radius: 6px; border: 1px solid #e5e5e5; } + QLabel:hover { color: #0066cc; border: 1px solid #b3d9ff; text-decoration: underline; } + """) + label.clicked.connect(lambda checked=False, d=att_data: AttachmentPreviewOverlay(d, self.bg_widget)) + + remove_btn = QtWidgets.QPushButton("✕") + remove_btn.setFixedSize(26, 26) + remove_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + remove_btn.setStyleSheet(""" + QPushButton { background: #eeeeee; border: none; border-radius: 13px; color: #888888; font-size: 12px; font-weight: bold; } + QPushButton:hover { background: #dddddd; color: #333333; } + """) + remove_btn.clicked.connect(lambda checked, w=tag_widget, i=index: self.remove_attachment(w, i)) + + tag_layout.addWidget(label) + tag_layout.addWidget(remove_btn) + + self.attachment_area_layout.addWidget(tag_widget) + self.attachment_scroll_area.setVisible(True) + self.text_input.setPlaceholderText("可继续添加文件或输入补充说明...") + + # ==================== 重新回答发送逻辑 ==================== + def _on_regenerate_clicked(self, msg_id): + """前端点击“重新回答”按钮时触发 (完美兼容 User框、正常AI框、幽灵报错框)""" + if self.current_session_id in self._active_streams: + return + + # 获取当前时间线上的所有消息 + messages = self.db.get_message_chain(self.current_session_id) + target_msg = next((m for m in messages if m["id"] == msg_id), None) + + # 🌟 核心修复:精准路由指针! + if not target_msg: + # 如果在数据库找不到,说明点的是报错的幽灵气泡 + # 查小本本,拿到绝对正确的提问指针! + user_msg_id = self._ghost_msg_map.get(msg_id) + if not user_msg_id: + print(f"[UI]: 未知气泡 {msg_id},拒绝重试操作") + return # 万一没查到,安全退出,绝不乱指 + else: + # 如果是正常的历史气泡 + if target_msg["role"] == "user": + user_msg_id = target_msg["id"] + else: + user_msg_id = target_msg["parent_id"] + + # 备份当前指针 (时光倒流用,这部分你之前的修改已经做得很好了) + original_leaf_id = self.db.get_session_leaf(self.current_session_id) + + # 截断时间线,重新拉取 UI,并启动生成 + self.db.update_session_leaf(self.current_session_id, user_msg_id) + self.load_messages_to_web(self.current_session_id, show_loading=False) # 🌟 内部刷新,不触发加载层 + self._regenerate_from_user_msg(user_msg_id, original_leaf_id) + + + # 🌟 参数增加 original_leaf_id + def _regenerate_from_user_msg(self, user_msg_id, original_leaf_id=None): + import uuid + session_id = self.current_session_id + api_messages = self.build_api_context(session_id) + self._dbg(f"[send-regen] model={self.current_model} " + f"mode={self._get_current_mode()}") + + ai_msg_id = f"msg-{uuid.uuid4().hex}" + + siblings = self.db.get_branch_info(user_msg_id) + total_branches = len(siblings) + 1 + branch_info = {"current": total_branches, "total": total_branches} + + self.chat_bridge.create_message( + ai_msg_id, "assistant", "", + self.current_model or "Assistant", + branch_info=branch_info + ) + # 🆕 P1-01:重新回答占位进渲染窗口 + self._rw_note_live(ai_msg_id, session_id) + self._ghost_msg_map[ai_msg_id] = user_msg_id + self._active_streams[session_id] = { + "msg_id": ai_msg_id, + "content": "", + "reasoning": "", + "timeline": [], # 🌟 agent 时间线(思考/文本/工具 按事件顺序) + "tl_kind": None, # 时间线当前段类型(think/text/tool) + "parent_id": user_msg_id, + "branch_info": branch_info, + "worker": None, + "usage": None, # 🆕 P1: 本轮已收到的精确 usage(显示锚定) + "previous_leaf_id": original_leaf_id or user_msg_id # 🌟 核心:存入时光倒流锚点! + } + + # 🆕 模式分派:重新回答跟随会话已锁定的模式(老会话未锁定 → chat 普通聊天) + worker = self._create_stream_worker(api_messages, self._get_current_mode() or "chat") + self._active_streams[session_id]["worker"] = worker + + worker.chunk_received.connect(lambda token: self.on_chunk_received(session_id, token)) + worker.reasoning_received.connect(lambda token: self.on_reasoning_received(session_id, token)) + worker.error_occurred.connect(lambda err: self.on_error(session_id, err)) + worker.finished.connect(lambda: self.on_reply_finished(session_id)) + # 🆕 P1: 本轮精确 usage(chat/worker 都有此信号)→ 显示锚定 + worker.usage_updated.connect(lambda u: self._on_usage_updated(session_id, u)) + if isinstance(worker, AgentWorker): + worker.tool_execution_started.connect(lambda cid, name, args: self._on_tool_started(session_id, cid, name, args)) + worker.tool_execution_updated.connect(lambda cid, text: self._on_tool_updated(session_id, cid, text)) + worker.tool_execution_timed.connect(lambda cid, el, to: self._on_tool_timed(session_id, cid, el, to)) + worker.tool_execution_finished.connect(lambda cid, name, ok, text: self._on_tool_finished(session_id, cid, name, ok, text)) + worker.context_compacted.connect(lambda p: self._on_context_compacted(session_id, p)) + # 🆕 压缩开始 → 前端「执行中」动态气泡 + worker.compaction_started.connect(lambda p: self._on_compaction_started(session_id, p)) + # 🆕 M3: 重试提示(仅 agent 循环有重试) + worker.retry_scheduled.connect(lambda a, m, d, r: self._on_retry_scheduled(session_id, a, m, d, r)) + worker.start() + + self.set_send_button_state(True) + self.update_context_display() + + + def clear_fold_state(self): + """发送后清除所有附件""" + self._folded_texts.clear() + # 清空附件容器里的所有子 widget + while self.attachment_area_layout.count(): + item = self.attachment_area_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self.attachment_scroll_area.setVisible(False) + self.text_input.setPlaceholderText("给 haocode 发送消息...") + + + # ==================== 核心发送逻辑 ==================== + + # ==================== 🆕 模式切换(chat / worker,首条消息锁定) ==================== + def _get_current_mode(self): + # 读取当前会话的模式(chat/worker),未发送过返回 None + sid = self.current_session_id + if not sid: + return None + try: + return self.db.get_session_mode(sid) + except Exception: + return None + + def _mode_switch_enabled(self): + # 🆕 config.json "mode_switch"(默认 false):是否允许会话中途切换 chat↔worker + # false = 现状(首条消息锁定,不可再改);true = 随时可切,选择即生效 + try: + return bool(self.config_data.get("mode_switch", False)) + except Exception: + return False + + def _refresh_mode_button(self): + # 切换会话后刷新模式按钮(显示锁定状态或待选状态) + if getattr(self, "mode_popup", None) is not None and self.mode_popup.isVisible(): + self.mode_popup.close() + cur = self._get_current_mode() + if cur and not self._mode_switch_enabled(): + # 开关关(默认)且会话已有模式 → 锁定态 + self.btn_mode.setText("🔒 " + ("Chat" if cur == "chat" else "Worker")) + self.btn_mode.setToolTip("本会话已锁定模式(首条消息时锁定),新开会话可换") + self._mode_panel_selected = cur + else: + # 开关开(无锁)或新会话:会话已有模式时显示并同步该模式 + mode = cur or self.pending_mode + if cur and self._mode_switch_enabled(): + self.pending_mode = cur + self.btn_mode.setText(("💬 Chat" if mode == "chat" else "⚡ Worker") + " ▾") + self.btn_mode.setToolTip("切换聊天模式(随时可切换)" if self._mode_switch_enabled() + else "切换聊天模式(发送第一条消息后锁定)") + self._mode_panel_selected = mode + if getattr(self, "mode_popup", None) is not None: + self.mode_popup.mark_selected(self._mode_panel_selected) + + def init_mode_popup(self): + # 🆕 实例化模式选择浮动弹窗(与模型选择弹窗同款实现,先不显示) + self.mode_popup = SessionModePopup(self) + self.mode_popup.mode_selected.connect(self._select_mode) + + def show_mode_popup(self): + '''计算位置并执行动画弹出(模式按钮正上方右对齐)''' + if self._get_current_mode() is not None and not self._mode_switch_enabled(): + # 已锁定:在提示行闪一下 + self.hint_label.setText("本会话已锁定模式,新开会话可换") + QtCore.QTimer.singleShot(2500, self._restore_hint_label) + return + btn_pos = self.btn_mode.mapToGlobal(QtCore.QPoint(0, 0)) + x = btn_pos.x() + self.btn_mode.width() - self.mode_popup.width() + y = btn_pos.y() - self.mode_popup.height() - 5 + self.mode_popup.mark_selected(self._mode_panel_selected) + self.mode_popup.show_with_animation(QtCore.QPoint(x, y)) + + def _restore_hint_label(self): + self.hint_label.setText("AI 生成内容可能不准确。") + + def _select_mode(self, mode): + if self._get_current_mode() is not None and not self._mode_switch_enabled(): + return # 开关关且会话已锁定:不可改 + self.pending_mode = mode + self._mode_panel_selected = mode + self.btn_mode.setText(("💬 Chat" if mode == "chat" else "⚡ Worker") + " ▾") + self.mode_popup.mark_selected(mode) + self.mode_popup.close() + # 🆕 开关开:选择即落库,下一次发送/重新回答立即生效 + if self._mode_switch_enabled(): + sid = self.current_session_id + if sid: + try: + self.db.set_session_mode(sid, mode) + print(f"[UI]: 会话 {sid[:8]} 模式切换 -> {mode}") + except Exception as e: + print(f"[UI]: 模式切换落库失败: {e}") + + def _lock_session_mode(self): + # 首条消息发送时,记录当前会话模式(开关关=锁定不可再改;开关开=仅记录,之后仍可切换) + sid = self.current_session_id + if not sid or self._get_current_mode() is not None: + return + mode = self.pending_mode or "chat" + try: + self.db.set_session_mode(sid, mode) + if self._mode_switch_enabled(): + print(f"[UI]: 会话 {sid[:8]} 模式记录 -> {mode}") + else: + print(f"[UI]: 会话 {sid[:8]} 模式锁定 -> {mode}") + except Exception as e: + print(f"[UI]: 模式锁定失败: {e}") + self._refresh_mode_button() + + def _create_stream_worker(self, messages, mode): + # 按模式创建 worker:worker→AgentWorker(agent 循环) / chat→ChatWorker(普通聊天) + if mode == "worker": + return AgentWorker(self.current_provider, self.current_model, messages) + return ChatWorker(self.current_provider, self.current_model, messages) + + def _persist_interrupted_stream(self, session_id, stream_state): + """🆕 Fix B: 持久化进行中的部分回复(发送中断 / 关窗共用)。 + - 有内容/思考/时间线 → 以 assistant 消息挂到链表(parent 指向本轮提问) + - 完全为空 → 时光倒流:会话叶子回退到发送前 + """ + import json as _json + try: + if (stream_state.get("content") or stream_state.get("reasoning") + or stream_state.get("timeline")): + tl_json = (_json.dumps(stream_state["timeline"], ensure_ascii=False) + if stream_state.get("timeline") else None) + # 🆕 P1: 中断回复也保存精确 usage(供后续显示/压缩估算锚定) + _usage_json = None + try: + from core.agent import calculate_context_tokens as _calc_usage + if _calc_usage(stream_state.get("usage") or {}) > 0: + _usage_json = _json.dumps(stream_state.get("usage")) + except Exception: + pass + self.db.add_message( + session_id=session_id, + role="assistant", + content=stream_state.get("content", ""), + parent_id=stream_state.get("parent_id"), + reasoning=stream_state.get("reasoning", ""), + msg_id=stream_state.get("msg_id"), + timeline=tl_json, + usage=_usage_json + ) + print(f"[DB]: 中断内容已保存 " + f"({len(stream_state.get('content') or '')} 字) session={session_id[:8]}", flush=True) + else: + prev_leaf = stream_state.get("previous_leaf_id") + if prev_leaf: + self.db.update_session_leaf(session_id, prev_leaf) + print(f"[DB]: 中断时未生成内容,叶子节点已回退至: {prev_leaf}", flush=True) + except Exception as e: + print(f"[DB]: 中断内容保存失败: {e}", flush=True) + + def send_message(self, from_enter: bool = False): + import time + import uuid + import json + + # 🌟 P0-02:发送规则的单一实现(Enter 与按钮点击路径共用): + # 发送按钮禁用 → 一律不得发送; + # 当前会话正在流式生成 → Enter 路径直接 no-op(不参与停止/中断语义,防误触); + # 按钮点击路径保留原有中断行为(红色停止按钮),继续落入下方中断逻辑。 + if not self.btn_send.isEnabled(): + return + if from_enter and self.current_session_id in self._active_streams: + return + + current_time = time.time() + if current_time - self._last_click_time < 0.3: + return + self._last_click_time = current_time + + # 检查当前会话是否正在生成(处理中断请求) + if self.current_session_id in self._active_streams: + stream_state = self._active_streams[self.current_session_id] + worker = stream_state.get("worker") + if worker: + try: + worker.chunk_received.disconnect() + worker.error_occurred.disconnect() + worker.reasoning_received.disconnect() + worker.finished.disconnect() + if hasattr(worker, "tool_execution_started"): + worker.tool_execution_started.disconnect() + worker.tool_execution_updated.disconnect() + worker.tool_execution_finished.disconnect() + worker.context_compacted.disconnect() + worker.usage_updated.disconnect() + if hasattr(worker, "retry_scheduled"): + worker.retry_scheduled.disconnect() + except Exception: + pass + worker.abort() + worker.finished.connect(worker.deleteLater) + + msg_id = stream_state["msg_id"] + self.chat_bridge.show_error(msg_id, "🛑 已中断生成") + self.chat_bridge.finish_message(msg_id) + + # 🆕 Fix B: 中断部分内容的持久化收拢到公共方法(与 closeEvent 共用) + self._persist_interrupted_stream(self.current_session_id, stream_state) + + del self._active_streams[self.current_session_id] + self.set_send_button_state(False) + self.update_context_display() + + print("\n[系统]: 🛑 已中断。") + # 🆕 Fix C: 输入框里若有新提问(用户是边中断边点发送的), + # 不再 return 吞掉 —— 直接落入下方正常发送流程(中断 + 发送一步完成) + if not (self.text_input.toPlainText().strip() + or [t for t in self._folded_texts if t is not None]): + return + + # 正常发送消息流程 + extra_text = self.text_input.toPlainText().strip() + valid_attachments = [t for t in self._folded_texts if t is not None] + + if not extra_text and not valid_attachments: + return + + self.text_input.clear() + self.clear_fold_state() + self.set_send_button_state(True) + + user_msg_id = f"msg-{uuid.uuid4().hex}" + attachment_metadata_json = None + + # 🌟 分离文本附件和图片附件 (多模态支持) + text_attachments_content = [] + api_attachments_meta = [] + + if valid_attachments: + for att in valid_attachments: + if att["type"] == "text": + # 带文件名的附件在 prompt 里包一层来源标记,方便模型区分多文件 + if att.get("name"): + text_attachments_content.append(f"[文件: {att['name']}]\n{att['content']}") + else: + text_attachments_content.append(att["content"]) + meta_item = { + "type": "text", + "size_kb": att["size_kb"], + "lines": att["lines"], + "content": att["content"] # 🌟 补回 content 字段,防止前端 JS 截取预览时报错 + } + if att.get("name"): + meta_item["name"] = att["name"] # 🌟 文件名存入元数据,前端卡片与历史重载用 + api_attachments_meta.append(meta_item) + elif att["type"] == "image": + api_attachments_meta.append({ + "type": "image", + "name": att["name"], + "mime": att["mime"], + "local_path": att["local_path"], + "size_kb": att.get("size_kb", 0), # 🌟 补上大小,防止 JS 出现 undefined KB + "lines": 1, # 🌟 补上行数,防止 JS 出现 undefined 行 + "content": f"[图片文件: {att['name']}]" # 假 content 防报错 + }) + elif att["type"] == "pdf": + mode = att.get("mode", "text") + text_content = att.get("text_content", "") + if mode == "image": + # 图片解析模式: 文本与勾选的图片一起发送 + if text_content: + text_attachments_content.append(f"[文件: {att['name']}]\n{text_content}") + # 卡片①:图片(用户勾选的) + api_attachments_meta.append({ + "type": "pdf", + "mode": "image", + "name": att["name"], + "size_kb": att.get("size_kb", 0), + "pages": att.get("pages", 0), + "selected_images": att.get("selected_images", []), + "images": att.get("images", []), # 全部提取图片, 供清理物理文件 + "local_path": att.get("local_path"), # PDF 本体拷贝, 供清理 + "lines": len(att.get("selected_images", [])), + "content": f"[PDF 图片解析: {att['name']}]" + }) + # 卡片②:文本(与图片分开成独立气泡显示) + if text_content: + api_attachments_meta.append({ + "type": "pdf", + "mode": "text", + "name": att["name"], + "size_kb": att.get("size_kb", 0), + "pages": att.get("pages", 0), + "images": [], + "lines": text_content.count("\n") + 1, + "content": text_content + }) + else: + # 文本模式: 结构化文本作为文本附件注入 prompt + text_attachments_content.append(f"[文件: {att['name']}]\n{text_content}") + api_attachments_meta.append({ + "type": "pdf", + "mode": "text", + "name": att["name"], + "size_kb": att.get("size_kb", 0), + "pages": att.get("pages", 0), + "images": att.get("images", []), # 文本模式一般为空, 供清理 + "local_path": att.get("local_path"), + "lines": text_content.count("\n") + 1, + "content": text_content + }) + + # 前端渲染展示 (传 meta 过去) + self.chat_bridge.create_user_message_with_attachments(user_msg_id, extra_text, api_attachments_meta) + + attachment_metadata_json = json.dumps({ + "user_text": extra_text, + "attachments": api_attachments_meta + }, ensure_ascii=False) + + # 数据库里的 content 依然只存纯文本 + all_text_attachments = "\n\n---\n\n".join(text_attachments_content) + if extra_text and all_text_attachments: + llm_text = extra_text + "\n\n" + all_text_attachments + elif all_text_attachments: + llm_text = all_text_attachments + else: + llm_text = extra_text + else: + self.chat_bridge.create_message(user_msg_id, "user", extra_text, "You") + llm_text = extra_text + + # 🚀 1. 获取当前对话的尾巴节点 + current_leaf_id = self.db.get_session_leaf(self.current_session_id) + + # 🚀 2. 插入用户消息(它的父亲是旧的尾巴节点),插入后这只 User 成了新尾巴 + self.db.add_message( + session_id=self.current_session_id, + role="user", + content=llm_text, # 纯文本部分 + parent_id=current_leaf_id, # <--- 链表指针! + msg_id=user_msg_id, + attachment_metadata=attachment_metadata_json # 图片路径和文本元数据存入这里 + ) + self.db.mark_session_has_messages(self.current_session_id) + # 🆕 P1-01:用户消息进渲染窗口(已持久化,带真实链内下标) + self._rw_note_live(user_msg_id, self.current_session_id) + self._dbg(f"[send] model={self.current_model} mode={self._get_current_mode()}") + + # 组装给大模型的 API 上下文(build_api_context 内部会去解析图片 local_path 并转为 base64 发给 API) + messages = self.build_api_context(self.current_session_id) + + ai_msg_id = f"msg-{uuid.uuid4().hex}" + + # 🚀 正常发送产生的新回答,必定是这句提问的第 1 个分支 + branch_info = {"current": 1, "total": 1} + + self.chat_bridge.create_message( + ai_msg_id, "assistant", "", + self.current_model or "Assistant", + branch_info=branch_info # 🚀 传给前端 + ) + # 🆕 P1-01:助手占位进渲染窗口(未持久化 -1 降级;流式保护由 streaming 类自动生效) + self._rw_note_live(ai_msg_id, self.current_session_id) + self._ghost_msg_map[ai_msg_id] = user_msg_id + session_id = self.current_session_id + self._active_streams[session_id] = { + "msg_id": ai_msg_id, + "content": "", + "reasoning": "", + "timeline": [], # 🌟 agent 时间线(思考/文本/工具 按事件顺序) + "tl_kind": None, # 时间线当前段类型(think/text/tool) + "parent_id": user_msg_id, + "branch_info": branch_info, + "worker": None, + "usage": None, # 🆕 P1: 本轮已收到的精确 usage(显示锚定) + "previous_leaf_id": user_msg_id # 🌟 核心修复:新提问的锚点就是提问本身 + } + + # 创建 worker(只创建一次) + # 🆕 模式分派:worker → agent 循环(工具/重试/压缩);chat → 普通聊天 + mode = self._get_current_mode() or self.pending_mode + self._lock_session_mode() # 首条消息即锁定 + worker = self._create_stream_worker(messages, mode) + self._active_streams[session_id]["worker"] = worker + + # 用 lambda 绑定 session_id,让回调函数知道是哪个会话的数据 + worker.chunk_received.connect(lambda token: self.on_chunk_received(session_id, token)) + worker.reasoning_received.connect(lambda token: self.on_reasoning_received(session_id, token)) + worker.error_occurred.connect(lambda err: self.on_error(session_id, err)) + worker.finished.connect(lambda: self.on_reply_finished(session_id)) + # 🆕 P1: 本轮精确 usage(chat/worker 都有此信号)→ 显示锚定 + worker.usage_updated.connect(lambda u: self._on_usage_updated(session_id, u)) + if isinstance(worker, AgentWorker): + # 工具执行事件只有 agent 循环才有 + worker.tool_execution_started.connect(lambda cid, name, args: self._on_tool_started(session_id, cid, name, args)) + worker.tool_execution_updated.connect(lambda cid, text: self._on_tool_updated(session_id, cid, text)) + worker.tool_execution_timed.connect(lambda cid, el, to: self._on_tool_timed(session_id, cid, el, to)) + worker.tool_execution_finished.connect(lambda cid, name, ok, text: self._on_tool_finished(session_id, cid, name, ok, text)) + worker.context_compacted.connect(lambda p: self._on_context_compacted(session_id, p)) + # 🆕 压缩开始 → 前端「执行中」动态气泡 + worker.compaction_started.connect(lambda p: self._on_compaction_started(session_id, p)) + # 🆕 M3: 重试提示(仅 agent 循环有重试) + worker.retry_scheduled.connect(lambda a, m, d, r: self._on_retry_scheduled(session_id, a, m, d, r)) + print(f"[生命周期] 发送 → session={session_id[:8]} ai_msg={ai_msg_id} 模式={mode} 用户文本={len(extra_text)}c", flush=True) + worker.start() + + # 🌟 新增:发送时刷新上下文显示 + self.update_context_display() + + + + + def on_reply_finished(self, session_id): + """回复完成""" + if session_id not in self._active_streams: return + + stream_state = self._active_streams[session_id] + msg_id = stream_state["msg_id"] + parent_id = stream_state["parent_id"] # 取出父亲是谁 + + if session_id == self.current_session_id: + self.chat_bridge.finish_message(msg_id) + self.browser.page().runJavaScript("dumpDiag()", self._on_diag_dumped) + print("\n[系统]:✅ 回复完毕。") + else: + print(f"\n[系统]: ✅ 会话 {session_id[:8]} 回复完毕(后台)") + + # 人肉 debug:完成时刻的生命周期快照 + _tl0 = stream_state.get("timeline") or [] + print(f"[生命周期] 回复完成 session={session_id[:8]} mid={msg_id} " + f"正文={len(stream_state['content'])}c 思考={len(stream_state['reasoning'])}c 时间线={len(_tl0)}条", flush=True) + # 🆕 调试日志:完成时刻的精确 usage + 时间线条数 + try: + _du = stream_state.get("usage") or {} + self._dbg(f"[finish] usage_input={_du.get('input')} " + f"usage_output={_du.get('output')} timeline={len(_tl0)}条") + except Exception: + pass + # 完成时刻屏幕快照 + DOM 体检 + try: + self._shot_n = getattr(self, "_shot_n", 0) + 1 + import os as _os + _shot_path = _os.path.join( + _os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))), + f"diag_shot_{self._shot_n:02d}_FINISH.png") + self.browser.grab().save(_shot_path) + print(f"[画面] 完成快照 -> {_os.path.basename(_shot_path)}", flush=True) + except Exception as _e: + print(f"[画面] 完成截图失败: {_e}", flush=True) + def _finish_dom_probe(_res): + diag_log(f"FINISH_DOM {_res}") + print(f"[画面] 完成时刻 DOM 体检: {_res}", flush=True) + self.browser.page().runJavaScript(f"probeStream('{msg_id}')", _finish_dom_probe) + # 🚀 入库,自动成为时间线新叶子! + if (stream_state["content"] or stream_state["reasoning"] + or stream_state.get("timeline")): + tl_json = (json.dumps(stream_state["timeline"], ensure_ascii=False) + if stream_state.get("timeline") else None) + # 🆕 P1: 保存本轮精确 usage(供下次显示/压缩估算做 usage 锚定) + _usage_json = None + try: + from core.agent import calculate_context_tokens as _calc_usage + if _calc_usage(stream_state.get("usage") or {}) > 0: + _usage_json = json.dumps(stream_state.get("usage")) + except Exception: + pass + self.db.add_message( + session_id=session_id, + role="assistant", + content=stream_state["content"], + parent_id=parent_id, # <--- 链表指针! + reasoning=stream_state["reasoning"], + msg_id=msg_id, + timeline=tl_json, # 🌟 时间线持久化(思考/文本/工具 按序) + usage=_usage_json # 🆕 P1: usage 锚点 + ) + print(f"[生命周期] DB 写入完成 mid={msg_id} timeline={len(_tl0)}条", flush=True) + # 🆕 P1-01:入库后刷新链内下标(仅当前会话推送;JS 侧同名校验) + self._rw_note_live(msg_id, session_id) + + if self.db.check_session_needs_title(session_id): + self._generate_session_title(session_id) + + del self._active_streams[session_id] + + if session_id == self.current_session_id: + self.set_send_button_state(False) + self.update_context_display() + + def _generate_session_title(self, session_id): + """调用 LLM 生成会话标题""" + # 🌟 如果这个会话已经在生成标题了,跳过 + if session_id in self._title_workers: + return + + # 🚀 修复 Bug:使用新的链表查询方法 get_message_chain! + messages = self.db.get_message_chain(session_id) + + user_msg = next((m for m in messages if m["role"] == "user"), None) + assistant_msg = next((m for m in messages if m["role"] == "assistant"), None) + + if not user_msg or not assistant_msg: + return + + title_prompt = [ + {"role": "system", "content": "你是一个标题生成助手。根据对话内容,生成一个6个字以内的简洁标题。只返回标题文本,不要其他内容。"}, + {"role": "user", "content": f"用户:{user_msg['content'][:100]}\n助手:{assistant_msg['content'][:100]}"} + ] + + worker = TitleWorker(self.current_provider, self.current_model, title_prompt) + + # 🌟 用字典存储,以 session_id 为 key + self._title_workers[session_id] = { + "worker": worker, + "accumulator": "" + } + + # 🌟 用 lambda 绑定 session_id + worker.chunk_received.connect(lambda chunk: self._on_title_chunk(session_id, chunk)) + worker.finished.connect(lambda: self._on_title_finished(session_id)) + worker.start() + def _on_title_chunk(self, session_id, chunk): + """累积标题 token""" + if session_id in self._title_workers: + self._title_workers[session_id]["accumulator"] += chunk + + def _on_title_finished(self, session_id): + if session_id not in self._title_workers: + return + + title_state = self._title_workers[session_id] + new_title = title_state["accumulator"].strip()[:20] + + if new_title: + self.db.update_session_title(session_id, new_title) + + # 🔴 原来: item.setText(new_title) + # 🟢 改为: 通过 widget 更新 + for i in range(self.history_list.count()): + item = self.history_list.item(i) + if item.data(QtCore.Qt.ItemDataRole.UserRole) == session_id: + w = self.history_list.itemWidget(item) + if w and hasattr(w, 'set_title'): + w.set_title(new_title) + break + + print(f"[System]: ✅ 会话标题已生成 -> {new_title}") + + worker = title_state["worker"] + worker.deleteLater() + del self._title_workers[session_id] + + + def _on_title_generated(self, worker): + """标题生成完成的回调""" + # 从 worker 中提取生成的标题 + if hasattr(worker, '_accumulated_content'): + new_title = worker._accumulated_content.strip()[:20] # 最多 20 个字 + if new_title: + # 更新数据库 + self.db.update_session_title(self.current_session_id, new_title) + + # 更新侧边栏 UI + for i in range(self.history_list.count()): + item = self.history_list.item(i) + if item.data(QtCore.Qt.ItemDataRole.UserRole) == self.current_session_id: + item.setText(new_title) + break + + print(f"[System]: ✅ 会话标题已生成 -> {new_title}") + + worker.deleteLater() + + # ==================== 工具执行事件(pi tool_execution_*) ==================== + + def _on_tool_started(self, session_id, call_id, name, args): + """工具开始执行 → 前端在时间线当前位置插入执行 chip""" + print(f"[工具] 开始 name={name} call={call_id} session={session_id[:8]}", flush=True) + # 🆕 右侧任务面板(bash 才登记;只跟当前会话) + if session_id == self.current_session_id: + try: + self.bash_panel.on_started(call_id, name, args) + except Exception as _e: + print(f"[Warn]: 任务面板 on_started 失败: {_e}") + st = self._active_streams.get(session_id) + if not st: + return + if session_id == self.current_session_id: + self._schedule_context_refresh() # 🆕 Fix E: 工具显现 → 刷新上下文标签 + # 🌟 时间线:工具条目(跨会话也记录,供持久化) + st["tl_kind"] = "tool" + st["timeline"].append({"t": "tool", "id": call_id, "name": name, + "args": args, "ok": None, "result": ""}) + if session_id == self.current_session_id: + self.chat_bridge.tool_execution_started(st["msg_id"], call_id, name, args) + + def _on_tool_updated(self, session_id, call_id, text): + """工具执行中的增量输出""" + # 🆕 右侧任务面板:实时输出(独立缓冲,不受上游 4000 字符上限影响) + if session_id == self.current_session_id: + try: + self.bash_panel.on_output(call_id, text) + except Exception: + pass + st = self._active_streams.get(session_id) + if not st: + return + if session_id == self.current_session_id: + self._schedule_context_refresh() # 🆕 Fix E: 工具输出显现 → 刷新 + # 🌟 时间线:按 call_id 累积工具输出(上限 4000 字符) + for e in reversed(st["timeline"]): + if e.get("t") == "tool" and e.get("id") == call_id: + e["result"] = (e.get("result", "") + text)[-4000:] + break + if session_id == self.current_session_id: + self.chat_bridge.tool_execution_updated(st["msg_id"], call_id, text) + + def _on_tool_timed(self, session_id, call_id, elapsed, timeout): + """🆕 bash 运行中每秒读秒 → 前端气泡刷新 N/Ts(0/10s, 1/10s, ...)""" + # 🆕 右侧任务面板:读秒 + if session_id == self.current_session_id: + try: + self.bash_panel.on_timed(call_id, elapsed, timeout) + except Exception: + pass + if session_id == self.current_session_id: + st = self._active_streams.get(session_id) + if st: + self.chat_bridge.tool_execution_timed( + st["msg_id"], call_id, int(elapsed), int(timeout)) + + def _on_tool_finished(self, session_id, call_id, name, ok, text): + """工具执行结束 → chip 按 call_id 标记结果""" + _flat = " ".join((text or "").split()) + print(f"[工具] 完成 name={name} ok={ok} 结果={len(text or '')}c call={call_id} {_flat[:80]}", flush=True) + # 🆕 右侧任务面板:完成后原位转为「已完成」(输出 = 进入上下文的原文) + if session_id == self.current_session_id: + try: + self.bash_panel.on_finished(call_id, name, ok, text) + except Exception as _e: + print(f"[Warn]: 任务面板 on_finished 失败: {_e}") + st = self._active_streams.get(session_id) + if not st: + return + if session_id == self.current_session_id: + self._schedule_context_refresh() # 🆕 Fix E: 工具结果定格 → 刷新 + # 🌟 时间线:按 call_id 定格工具条目 + for e in reversed(st["timeline"]): + if e.get("t") == "tool" and e.get("id") == call_id: + e["ok"] = bool(ok) + e["result"] = text or e.get("result", "") + break + if session_id == self.current_session_id: + self.chat_bridge.tool_execution_finished(st["msg_id"], call_id, name, ok, text) + + def _on_compaction_started(self, session_id, path): + """🆕 压缩开始(摘要 LLM 调用中)→ 当前消息时间线内显示「上下文压缩」思考气泡""" + print(f"[压缩] 开始 session={session_id[:8]} path={path}", flush=True) + if session_id == self.current_session_id: + st = self._active_streams.get(session_id) + if st: + self.chat_bridge.compaction_started(st["msg_id"], str(path)) + + def _on_context_compacted(self, session_id, payload=None): + """agent 自动压缩了上下文(pi compaction)→ 同一气泡原地更新(含摘要全文)""" + payload = payload or {} + print(f"[压缩] 完成 session={session_id[:8]} path={payload.get('path')} " + f"before={payload.get('before')} after={payload.get('after')} " + f"duration_ms={payload.get('duration_ms')}", flush=True) + self._dbg(f"[compact] path={payload.get('path')} " + f"before={payload.get('before')} after={payload.get('after')}") + # 🆕 压缩持久化:切点齐备 → DB 链上插标记行(历史全保留,API 在标记处截断) + try: + _cb = payload.get("cut_before_id") + _fr = payload.get("first_retained_id") + if _cb and _fr: + _meta = json.dumps({"path": payload.get("path"), + "before": payload.get("before"), + "after": payload.get("after")}, ensure_ascii=False) + self.db.insert_compaction_mark( + session_id, payload.get("summary") or "", _cb, _fr, _meta) + else: + print("[压缩] 切点不完整(保留尾巴无 DB 行)→ 本轮仅轮内生效,不插标记", flush=True) + except Exception as _e: + print(f"[压缩] 标记入库失败: {_e}", flush=True) + if session_id == self.current_session_id: + st = self._active_streams.get(session_id) + if st: + self.chat_bridge.compaction_finished(st["msg_id"], payload) + self.update_context_display() + + def _on_usage_updated(self, session_id, usage): + """🆕 P1: 收到本轮精确 usage → 记录供显示锚定 + 防抖刷新""" + st = self._active_streams.get(session_id) + if st is not None: + st["usage"] = usage + self._dbg(f"[usage] input={usage.get('input')} output={usage.get('output')}") + if session_id == self.current_session_id: + self._schedule_context_refresh() + + # ==================== 🆕 调试窗口(独立于项目树;文件协议) ==================== + def _poll_debug_cmd(self): + """2s 轮询 data/debug_window.cmd:show/hide → 开/关独立调试窗口""" + try: + action = poll_debug_cmd() + if not action: + return + if action == "show": + if self.debug_window is None: + from ui.views.debug_window import DebugWindow + self.debug_window = DebugWindow() + self.debug_window.show() + self.debug_window.raise_() + self.debug_window.activateWindow() + elif action == "hide": + if self.debug_window is not None: + self.debug_window.hide() + except Exception: + pass + + def _dbg(self, msg): + """向调试会话日志注入一条 APP 事件(吞一切异常,绝不影响主流程)""" + try: + debug_log(f"sid={(self.current_session_id or '?')[:8]} {msg}", "APP") + except Exception: + pass + + def _on_retry_scheduled(self, session_id, attempt, max_attempts, delay_ms, reason): + """🆕 M3: 重试已调度(对照 pi onRetryScheduled)——UI 提示(同压缩提示风格)""" + if session_id == self.current_session_id: + self.chat_bridge.show_note( + f"请求异常,正在自动重试({attempt}/{max_attempts})…") + + def on_error(self, session_id, error_text: str): + """发生错误""" + if session_id not in self._active_streams: + return + + stream_state = self._active_streams[session_id] + msg_id = stream_state["msg_id"] + self._dbg(f"[error] {str(error_text)[:150]}") + + # 🆕 失败轮次持久化(对照 pi:出错也写会话)—— + # 不再"时光倒流"丢弃整轮:已跑完的工具结果 / 正文全部入库,叶子前进, + # 下次提问能接着干(回放取舍在 build_api_context 里定)。 + self._persist_failed_stream(session_id, stream_state, error_text) + + # 错误信息始终显示(即使不在当前会话) + if session_id == self.current_session_id: + self.chat_bridge.show_error(msg_id, f"❌ 请求失败: {error_text}") + self.chat_bridge.finish_message(msg_id) + # 清理该会话的流式状态 + del self._active_streams[session_id] + # 只有当前会话出错时,才更新按钮状态 + if session_id == self.current_session_id: + self.set_send_button_state(False) + self.update_context_display() + + + def _persist_failed_stream(self, session_id, stream_state, error_text): + """🆕 失败轮次持久化(对照 pi:message_end 无条件入库 → 出错也写会话)。 + + 与「用户中断」的**持久化策略一致**(本轮已完成的工具结果 + 正文 + 思考全入库), + 只在语义上标注 stop_reason="error";**不再回退叶子("时光倒流")**: + 否则 agent 跑了 200 个工具后第 201 步出错,整轮产出会被丢掉, + 下次提问模型完全不知情 → 只能从头重做。 + + 回放取舍(见 build_api_context): + · 有正文/工具 → is_ignored=0,正常进上下文 + · 完全空 → is_ignored=1,只在库里留痕,不回放(避免空 assistant 触发 400) + """ + import json as _json + try: + err = " ".join(str(error_text or "").split())[:500] + body = stream_state.get("content") or "" + # 用 Markdown 引用块 → 前端 marked 直接渲染成醒目提示条(无需改 JS) + note = f"> ⚠️ [本轮中断] {err}" if err else "> ⚠️ [本轮中断]" + content = (body + "\n\n" + note) if body.strip() else note + timeline = list(stream_state.get("timeline") or []) + # ⚠️ 关键:带 timeline 的行,回放走 timeline 分支(**不读 content**) + # → 中断说明必须额外作为一条 text 条目,否则模型看不到「上轮为何断」 + if timeline: + timeline.append({"t": "text", "text": "\n\n" + note}) + tools = [e for e in timeline if e.get("t") == "tool"] + has_real = (bool(body.strip()) or bool(tools) + or bool((stream_state.get("reasoning") or "").strip())) + tl_json = _json.dumps(timeline, ensure_ascii=False) if timeline else None + _usage_json = None + try: + from core.agent import calculate_context_tokens as _calc_usage + if _calc_usage(stream_state.get("usage") or {}) > 0: + _usage_json = _json.dumps(stream_state.get("usage")) + except Exception: + pass + self.db.add_message( + session_id=session_id, + role="assistant", + content=content, + parent_id=stream_state.get("parent_id"), + reasoning=stream_state.get("reasoning", ""), + msg_id=stream_state.get("msg_id"), + timeline=tl_json, + usage=_usage_json, + is_ignored=(not has_real), + stop_reason="error", + error_message=err, + ) + print(f"[DB]: 失败轮次已入库 正文={len(body)}c 工具={len(tools)}个 " + f"回放={'是' if has_real else '否(全空留痕)'} " + f"session={session_id[:8]} err={err[:60]}", flush=True) + except Exception as e: + print(f"[DB]: 失败轮次入库失败: {e}", flush=True) + prev_leaf = stream_state.get("previous_leaf_id") + if prev_leaf: + try: + self.db.update_session_leaf(session_id, prev_leaf) + except Exception: + pass + + def _render_watchdog_tick(self): + """渲染看门狗:当前会话有活跃流且已有内容 → 强制前端渲染一帧。 + 每 2s 附带一次 DOM 体检(PROBE)写入 stream_diag.log。""" + try: + st = self._active_streams.get(self.current_session_id) + if not st: + return + if not (st.get("timeline") or st.get("content") or st.get("reasoning")): + return + self.chat_bridge.run_js( + f"forceRenderNow('{st['msg_id']}');") + import time as _t + now = _t.time() + if not hasattr(self, "_last_probe_at"): + self._last_probe_at = 0 + if now - self._last_probe_at >= 2.0: + self._last_probe_at = now + self.browser.page().runJavaScript( + f"probeStream('{st['msg_id']}')", self._on_probe_result) + # 屏幕快照:默认关闭(grab() 强制出帧会阻塞渲染器主线程 1-2s, + # 与正文 token 处理互相干扰);需要时设环境变量 HAOCODE_SHOT=1 + if os.environ.get("HAOCODE_SHOT") == "1": + _tot = len(st.get("content") or "") + len(st.get("reasoning") or "") + if not hasattr(self, "_last_shot_len"): + self._last_shot_len = 0 + if _tot != getattr(self, "_last_shot_len", 0): + self._last_shot_len = _tot + try: + if not hasattr(self, "_shot_n"): + self._shot_n = 0 + self._shot_n += 1 + import os as _os + _shot_path = _os.path.join( + _os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))), + f"diag_shot_{self._shot_n:02d}.png") + self.browser.grab().save(_shot_path) + print(f"[画面] 流式快照 #{self._shot_n} 总长={_tot}c -> {_os.path.basename(_shot_path)}", flush=True) + except Exception as _e: + print(f"[画面] 截图失败: {_e}", flush=True) + except Exception: + pass + + def _on_probe_result(self, res): + diag_log(f"PROBE {str(res)[:800]}") + + def _jslog_drain_tick(self): + """每 500ms 抽取 JS console 缓冲 → Python 控制台""" + try: + self.browser.page().runJavaScript( + "window.__jslogDrain ? window.__jslogDrain() : ''", self._jslog_on_drain) + except Exception: + pass + + def _jslog_on_drain(self, res): + if not res: + return + for _line in str(res).split("\n"): + if _line.strip(): + print(_line, flush=True) # 行内已含标签 + + def on_chunk_received(self, session_id, chunk: str): + """接收到 Token""" + self._diag_chunk_n += 1 + _match = session_id == self.current_session_id + if self._diag_chunk_n <= 3 or self._diag_chunk_n % 50 == 0 or not _match: + _st0 = self._active_streams.get(session_id) + diag_log(f"CHUNK n={self._diag_chunk_n} match={_match} " + f"mid={(_st0 or {}).get('msg_id')} +{len(chunk)}c") + if session_id not in self._active_streams: + return + + # 🌟 累积内容到该会话的状态中(不再用全局变量) + self._active_streams[session_id]["content"] += chunk + if session_id == self.current_session_id: + self._schedule_context_refresh() # 🆕 Fix E: 正文显现 → 刷新上下文标签 + + # 🌟 时间线:文本段(与上一段不同则新开) + st = self._active_streams[session_id] + if st["tl_kind"] != "text": + st["tl_kind"] = "text" + st["timeline"].append({"t": "text", "text": chunk}) + else: + st["timeline"][-1]["text"] += chunk + + # 人肉 debug:每个正文 token 控制台打印 + try: + _mid = self._active_streams[session_id]["msg_id"] + _flat = " ".join(chunk.split()) + print(f"[正文] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:100]}", flush=True) + except Exception: + pass + + # 只有当前激活的会话才推送到前端显示 + if session_id == self.current_session_id: + msg_id = self._active_streams[session_id]["msg_id"] + self.chat_bridge.append_token(msg_id, chunk) + + + + from PyQt6 import QtWidgets, QtCore + + def add_session_item_to_sidebar(self, sess_data, at_top=False): + """渲染一条会话到侧边栏(使用自定义 widget)""" + item = QtWidgets.QListWidgetItem() + item.setData(QtCore.Qt.ItemDataRole.UserRole, sess_data["id"]) + item.setSizeHint(QtCore.QSize(0, 44)) + + widget = SessionItemWidget(sess_data["id"], sess_data["title"]) + widget.menu_requested.connect(self.show_session_context_menu) + + is_starred = sess_data.get("is_starred", 0) + + if at_top: + if is_starred: + self.history_list.insertItem(0, item) + else: + # 插到分隔线下方第一个位置 + sep_row = self.history_list._find_separator_row() + insert_row = (sep_row + 1) if sep_row >= 0 else 0 + self.history_list.insertItem(insert_row, item) + self.history_list.setItemWidget(item, widget) + self.history_list.setCurrentItem(item) + else: + self.history_list.addItem(item) + self.history_list.setItemWidget(item, widget) + + + def _rw_visible_chain(self, session_id): + """🆕 P1-01:会话的可见消息链(过滤 system/compaction),旧→新。""" + chain = self.db.get_message_chain(session_id) + return [m for m in chain if m["role"] not in ("system", "compaction")] + + def _rw_note_live(self, msg_id, expected_session=None): + """🆕 P1-01:把新生成的消息推进渲染窗口。 + 已持久化 → 带真实链内下标 + 链长;未持久化/查询失败 → -1(安全降级)。 + 非当前会话的后台完成不推送(JS 侧状态属于当前会话)。""" + session = self.current_session_id + gen = self._rw_generation + if not session or gen <= 0: + return + if expected_session and expected_session != session: + return + chain_index, chain_len = -1, -1 + try: + visible = self._rw_visible_chain(session) + chain_len = len(visible) + for i, m in enumerate(visible): + if m["id"] == msg_id: + chain_index = i + break + except Exception: + pass + self.chat_bridge.rw_note_live(session, gen, msg_id, chain_index, chain_len) + + def _render_history_one(self, msg): + """🆕 P1-01:渲染单条历史消息(自 load_messages_to_web 抽取;分页复用)。 + 渲染分发逻辑与原版完全一致(附件补齐/时间线/降级)。""" + import json + branch_info = None + if msg["role"] == "assistant" and msg.get("parent_id"): + siblings = self.db.get_branch_info(msg["parent_id"]) + if len(siblings) > 1: + try: + sibling_ids = [s["id"] for s in siblings] + idx = sibling_ids.index(msg["id"]) + 1 + branch_info = {"current": idx, "total": len(siblings)} + except (ValueError, KeyError): + branch_info = None + + if msg["role"] == "user" and msg.get("attachment_metadata"): + try: + metadata = json.loads(msg["attachment_metadata"]) + user_text = metadata.get("user_text", "") + attachments = metadata.get("attachments", []) + for att in attachments: + if "content" not in att: + if att.get("type") == "image": + att["content"] = f"[图片文件: {att.get('name', '未知图片')}]" + else: + att["content"] = "[附件内容]" + if "size_kb" not in att: + att["size_kb"] = 0 + if "lines" not in att: + att["lines"] = 1 + self.chat_bridge.create_user_message_with_attachments( + msg["id"], user_text, attachments) + except (json.JSONDecodeError, TypeError): + self.chat_bridge.render_history_message( + msg_id=msg["id"], + role=msg["role"], + content=msg["content"], + reasoning=msg.get("reasoning", ""), + branch_info=branch_info) + else: + tl_json = msg.get("timeline") if msg["role"] == "assistant" else None + if tl_json: + self.chat_bridge.create_message( + msg_id=msg["id"], role=msg["role"], + text=msg["content"], + sender_name=self.current_model or "Assistant", + branch_info=branch_info) + self.chat_bridge.render_timeline_history(msg["id"], tl_json) + self.chat_bridge.finish_message(msg["id"]) + else: + self.chat_bridge.render_history_message( + msg_id=msg["id"], + role=msg["role"], + content=msg["content"], + reasoning=msg.get("reasoning", ""), + branch_info=branch_info) + + def _on_window_page_request(self, session_id, direction, + boundary_msg_id, generation): + """🆕 P1-01:前端换页请求 → 按边界消息在 DB 切片 → 渲染页 → 推送响应。 + 边界失效(并发数据变更)→ 空页安全降级,计数仍正确收敛。""" + if self._rw_page_inflight: + return # 最多一页在途(JS 侧 pending 同效,双保险) + if session_id != self.current_session_id or generation != self._rw_generation: + return # 会话已切 / 代次已推进 → 丢弃 + if direction not in ("older", "newer"): + return + self._rw_page_inflight = True + try: + visible = self._rw_visible_chain(session_id) + chain_len = len(visible) + ids = [m["id"] for m in visible] + try: + bi = ids.index(boundary_msg_id) + except ValueError: + bi = -1 + + size = self._rw_page_size # 半窗页(非整窗):锚点消息必须留在窗口内 + if bi < 0: + page, page_indices = [], [] + elif direction == "older": + start = max(0, bi - size) + page = visible[start:bi] + page_indices = list(range(start, bi)) + else: + start = bi + 1 + page = visible[start:start + size] + page_indices = list(range(start, start + len(page))) + + # 批次渲染期间抑制 createMessage/finishMessage 的滚动副作用 + self.chat_bridge.run_js("window.__rwPageRendering = true;") + for m in page: + self._render_history_one(m) + self.chat_bridge.rw_page_response({ + "sessionId": session_id, + "generation": generation, + "boundaryId": boundary_msg_id, + "direction": direction, + "chainLen": chain_len, + "items": [{"id": m["id"], "chainIndex": ix} + for m, ix in zip(page, page_indices)], + }) + self.chat_bridge.run_js("window.__rwPageRendering = false;") + except Exception as e: + print(f"[RW] 分页处理异常: {e}") + self.chat_bridge.run_js("window.__rwPageRendering = false;") + finally: + self._rw_page_inflight = False + + def load_messages_to_web(self, session_id, show_loading=True): + """加载消息链到 WebView(🆕 P1-01:窗口化——初始仅渲染最新 size 条)""" + import json + # 🆕 P1-01:每次加载递增代次,旧会话的在途分页响应一律 stale + self._rw_generation += 1 + gen = self._rw_generation + self.current_session_id = session_id + self._refresh_mode_button() # 🆕 会话切换 → 刷新模式按钮 + # 🆕 右侧任务面板:跟随会话切换(范围永远是当前会话) + try: + self.bash_panel.set_session( + session_id, self.db, self._active_streams.get(session_id)) + except Exception as _e: + print(f"[Warn]: 任务面板刷新失败: {_e}") + # 🌟 统一加载界面:切换会话/启动时盖加载层;内部刷新(重新回答/分支/删除)不触发 + if show_loading: + self.chat_bridge.show_loading() + # 🆕 P1-01:rwBegin 内部执行 clearChat + (session, generation) 重同步 + visible = self._rw_visible_chain(session_id) + total = len(visible) + self.chat_bridge.rw_begin(session_id, gen, total) + + # 2. 处理空对话状态 + if not visible: + self.chat_bridge.show_welcome() + self._update_send_button_state() + if show_loading: + QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading()) + return + + # 3. 窗口化渲染:仅最新 size 条(🆕 P1-01);更早消息经换页按需加载 + window_items = visible[-self._rw_size:] if self._rw_size > 0 else visible + # 批次渲染期间抑制逐条滚底(初始窗口完成后由前端统一对齐底部) + self.chat_bridge.run_js("window.__rwPageRendering = true;") + for msg in window_items: + self._render_history_one(msg) + # 链内下标 = 窗口在链尾的偏移 + 窗口内序号(不是局部下标!) + offset = total - len(window_items) + self.chat_bridge.rw_init_window( + session_id, gen, total, + [(m["id"], offset + i) for i, m in enumerate(window_items)]) + self.chat_bridge.run_js("window.__rwPageRendering = false;") + + # 5. 恢复流式输出状态(处理页面刷新或切换回正在生成的会话) + #🌟 如果该会话有正在进行的流式输出,每次都重新推送 + if session_id in self._active_streams: + stream_state = self._active_streams[session_id] + msg_id = stream_state["msg_id"] + + # 🚀 修复点:恢复时也要带上缓存的 branch_info + branch_info = stream_state.get("branch_info") + + self.chat_bridge.create_message( + msg_id, "assistant", "", + self.current_model or "Assistant", + branch_info=branch_info # 🚀 传给前端 + ) + # 🆕 P1-01:切回复活的流式消息进渲染窗口(已入库则带真实下标) + self._rw_note_live(msg_id, session_id) + # 🌟 有 agent 时间线 → 按序恢复 思考/文本/工具 块(后续 token 无缝续流) + if stream_state.get("timeline"): + diag_log(f"RESTORE mid={msg_id} entries={len(stream_state['timeline'])} " + f"text={len(stream_state.get('content',''))}c think={len(stream_state.get('reasoning',''))}c") + self.chat_bridge.restore_streaming_timeline( + msg_id, json.dumps(stream_state["timeline"], ensure_ascii=False)) + else: + # 恢复已生成的推理内容 + if stream_state.get("reasoning"): + reasoning_text = stream_state["reasoning"] + for i in range(0, len(reasoning_text), 500): + self.chat_bridge.append_reasoning(msg_id, reasoning_text[i:i+500]) + + # 恢复已生成的正文内容 + if stream_state.get("content"): + content_text = stream_state["content"] + for i in range(0, len(content_text), 500): + self.chat_bridge.append_token(msg_id, content_text[i:i+500]) + + # 6. 更新 UI 状态 + self._update_send_button_state() + self.update_context_display() + + # 🌟 绘制完成后,渐变退场露出会话(图标扫描条 → 渐隐) + if show_loading: + QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading()) + + + def _update_send_button_state(self): + """根据当前会话是否在生成,更新发送按钮状态""" + is_generating = self.current_session_id in self._active_streams + self.set_send_button_state(is_generating) + + + def on_sidebar_item_clicked(self, item): + # 🌟 点击分隔线无效 + if self.history_list._is_separator(item): + return + session_id = item.data(QtCore.Qt.ItemDataRole.UserRole) + if not session_id: + return + print(f"[UI]: 切换到历史对话 -> {session_id}") + self.load_messages_to_web(session_id) + + + def init_model_popup(self): + # 1. 读取配置(P0-01 统一入口:缺失/损坏 → 可见警告 + 安全默认值) + from core.config_paths import load_config as _load_cfg + self.config_data = _load_cfg() or {"providers": {}} + + # 2. 实例化我们写的自定义弹窗 (先不显示) + self.model_popup = ModelSelectPopup(self, self.config_data) + + # 3. 绑定弹窗的选中信号 + self.model_popup.model_selected.connect(self.on_model_selected) + + # 4. 把原来的 model_selector 按钮点击事件绑定到显示弹窗的方法上 + # 注意:需要把按钮的 setMenu 去掉(如果你在 setup_ui 里写了的话) + self.model_selector.clicked.connect(self.show_model_popup) + self.model_selector.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + + # 5. 设置默认选中 + default_p = self.config_data.get("default_provider", "GPTBest") + default_m = self.config_data.get("default_model", "gemini-3.1-pro-preview-thinking-high") + self.on_model_selected(default_p, default_m) + + def show_model_popup(self): + """计算位置并执行动画弹出""" + # 获取按钮在屏幕上的全局坐标 + btn_pos = self.model_selector.mapToGlobal(QtCore.QPoint(0, 0)) + + # 计算弹窗最终应该停留的目标坐标 + x = btn_pos.x() + self.model_selector.width() - self.model_popup.width() + y = btn_pos.y() - self.model_popup.height() - 5 + + # 🌟 调用动画显示方法,传入目标坐标 + self.model_popup.show_with_animation(QtCore.QPoint(x, y)) + + + def on_model_selected(self, provider, model): + """当用户在弹窗中选择模型时触发""" + self.current_provider = provider + self.current_model = model + + # 截断过长的模型名字显示在按钮上 + display_name = model if len(model) < 20 else model[:18] + "..." + self.model_selector.setText(display_name) + + print(f"\n[系统]: 已切换引擎 -> 供应商: {provider} | 模型: {model}") + # 🌟 新增:处理思考过程的槽函数 + #新增:切换模型后刷新上下文显示(因为不同模型上限不同) + self.update_context_display() + + def on_reasoning_received(self, session_id, chunk: str): + """接收到思考过程""" + self._diag_think_n += 1 + _match = session_id == self.current_session_id + if self._diag_think_n <= 3 or self._diag_think_n % 50 == 0 or not _match: + _st0 = self._active_streams.get(session_id) + diag_log(f"THINK n={self._diag_think_n} match={_match} " + f"mid={(_st0 or {}).get('msg_id')} +{len(chunk)}c") + if session_id not in self._active_streams: + return + + # 🌟 累积思考过程到该会话的状态中 + self._active_streams[session_id]["reasoning"] += chunk + if session_id == self.current_session_id: + self._schedule_context_refresh() # 🆕 Fix E: 思考显现 → 刷新上下文标签 + + # 🌟 时间线:思考段(与上一段不同则新开) + st = self._active_streams[session_id] + if st["tl_kind"] != "think": + st["tl_kind"] = "think" + st["timeline"].append({"t": "think", "text": chunk}) + else: + st["timeline"][-1]["text"] += chunk + + # 人肉 debug:每个思考 token 控制台打印 + try: + _mid = self._active_streams[session_id]["msg_id"] + _flat = " ".join(chunk.split()) + print(f"[思考] mid={_mid[:8]} 匹配={_match} +{len(chunk)}c {_flat[:60]}", flush=True) + except Exception: + pass + + # 只有当前激活的会话才推送到前端显示 + if session_id == self.current_session_id: + msg_id = self._active_streams[session_id]["msg_id"] + self.chat_bridge.append_reasoning(msg_id, chunk) + + def _on_diag_dumped(self, res): + diag_log(f"JS_DIAG {str(res)[:3000]}") + + def on_web_load_finished(self, ok): + """网页加载完成后的回调""" + if not ok: + print("[Error]: 网页加载失败") + return + + try: + self.browser.page().runJavaScript( + "String(window.__APP_VER || 'unknown')", + lambda v: diag_log(f"FRONTEND_VER {v}")) + except Exception: + pass + print("[System]: 网页 HTML 加载完毕,等待 JS 引擎就绪...") + + # 🌟 开始轮询检测 JS 是否就绪 + if self.current_session_id: + self._check_js_ready() + + def _check_js_ready(self, retry_count=0): + """ + 递归检测 JS 引擎是否就绪 + 最多重试 20 次(2 秒),每次间隔 100ms + """ + if retry_count > 20: + print("[Error]: JS 引擎超时未就绪") + return + + # 检测 window.jsReady 标志 + self.browser.page().runJavaScript( + "typeof window.jsReady !== 'undefined' && window.jsReady === true", + lambda is_ready: self._on_js_ready_checked(is_ready, retry_count) + ) + + def _on_js_ready_checked(self, is_ready, retry_count): + """JS 就绪检测的回调""" + if is_ready: + print("[System]:✅ JS 引擎已就绪,正在加载历史记录...") + # 🆕 P1-01:先注入渲染窗口配置,再开始窗口化加载 + if not self._rw_config_pushed: + self._rw_config_pushed = True + self.chat_bridge.rw_config(self._rw_mode, self._rw_size) + self.load_messages_to_web(self.current_session_id) + # 🌟 触发前端上报一次初始滚动状态,同步自定义滚动条 + self.chat_bridge.run_js("setTimeout(reportWebScroll, 60);") + else: + # 还没就绪,100ms 后重试 + QtCore.QTimer.singleShot(100, lambda: self._check_js_ready(retry_count + 1)) + + + def on_new_chat_clicked(self): + # 🆕 P1-01:代次 +1(旧会话的在途分页响应失效) + self._rw_generation += 1 + new_sess = self.db.create_session(title="新对话") + print(f"[UI]: 新建对话 -> {new_sess['id']}") + + self.current_session_id = new_sess["id"] + self._refresh_mode_button() # 🆕 新建会话 → 未锁定,恢复可选状态 + + # 🌟 统一加载界面:新建会话同样走 加载 → 渐变 → 欢迎页 流程 + self.chat_bridge.show_loading() + + # 重建侧边栏(自动处理分隔线和插入位置) + self.rebuild_sidebar() + + self.chat_bridge.clear_chat() + self.chat_bridge.show_welcome() + self._update_send_button_state() + self.update_context_display() + QtCore.QTimer.singleShot(450, lambda: self.chat_bridge.hide_loading()) + + + def build_api_context(self, session_id): + """ + 🚀 构建上下文:读取本地图片文件,实时转为 Base64 发给大模型,彻底解放数据库! + """ + import json + import os + import base64 + + messages = self.db.get_message_chain(session_id) + api_messages = [] + + # 🆕 压缩持久化:链上最后一个 compaction 标记 = 切点。 + # 它之前的一切只以摘要形式发送(作为一条 user 消息,与轮内压缩 + # 的 to_openai_messages 渲染一致);标记保留在库中,UI 渲染照常。 + _mark_idx = -1 + for _i, _m in enumerate(messages): + if _m["role"] == "compaction": + _mark_idx = _i + if _mark_idx >= 0: + _mark = messages[_mark_idx] + api_messages.append({ + "role": "user", + "content": _mark["content"], + "_kind": "compaction_summary", + "_db_msg_id": _mark["id"], + # 🆕 Fix F: 摘要条目必须带时间戳(标记插入时刻,晚于全部保留行)。 + # 缺了它 P0 锚点时效校验失效:保留行里 assistant 的入库 usage 是 + # 压缩前快照(如 92.7k),显示/压缩判定会锚到过期值 → 标签虚高, + # 小窗口模型还会误触发二次压缩(摘要套摘要)。 + "timestamp": int(_mark.get("created_at", 0) or 0) * 1000, + }) + messages = messages[_mark_idx + 1:] + # 获取项目根目录 + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.abspath(os.path.join(current_dir, '..', '..')) + + for msg in messages: + if msg["is_ignored"]: + continue + + # 🆕 失败轮次·全空行:入库留痕但**不回放**(对照 pi transform-messages + # 跳过 error/aborted 的 assistant;空 assistant 内容会让个别服务商 400) + if msg.get("role") == "assistant": + _tl = (msg.get("timeline") or "").strip() + _has_tl = _tl not in ("", "[]", "null") + if (not (msg.get("content") or "").strip() and not _has_tl + and not (msg.get("reasoning") or "").strip()): + continue + + content_payload = msg["content"] + + # 🌟 核心:如果是用户消息且带有附件元数据,检查是否有图片 + if msg["role"] == "user" and msg.get("attachment_metadata"): + try: + meta = json.loads(msg["attachment_metadata"]) + # 收集普通图片附件 + PDF 图片解析模式下用户勾选的图片 + images = [] + for a in meta.get("attachments", []): + if a.get("type") == "image": + images.append(a) + elif a.get("type") == "pdf" and a.get("mode") == "image": + for sel in a.get("selected_images", []): + if sel.get("local_path"): + images.append({ + "local_path": sel["local_path"], + "mime": sel.get("mime", "image/png"), + }) + + if images: + # 发现图片!将 content 转换为 OpenAI 视觉接口要求的 List 格式 + content_payload = [{"type": "text", "text": msg["content"]}] + for img in images: + local_path = img.get('local_path') + if not local_path: continue + + # 拼接出图片的绝对路径并实时读取转码 + # 拼接出图片的绝对路径并实时读取转码 + abs_path = os.path.join(root_dir, local_path) + + if os.path.exists(abs_path): + with open(abs_path, "rb") as f: + b64_data = base64.b64encode(f.read()).decode('utf-8') + + content_payload.append({ + "type": "image_url", + "image_url": { + "url": f"data:{img['mime']};base64,{b64_data}" + } + }) + else: + print(f"[Warn]: 找不到图片文件,可能已被物理删除: {abs_path}") + except Exception as e: + print(f"[Warn]: 解析图片附件失败: {e}") + + # 🌟 AI 消息带 agent 时间线 → 重建完整上下文(含 tool_calls 与工具结果), + # 让模型跨轮次看得到自己用过的工具和输出(对照 pi 全量上下文语义) + if msg["role"] == "assistant" and msg.get("timeline"): + try: + tl = json.loads(msg["timeline"]) + except Exception: + tl = None + if isinstance(tl, list) and tl: + # 🆕 P0/P1: 回放本行时间戳(created_at 秒→毫秒)与 usage(锚点) + row_ts = int(msg.get("created_at", 0) or 0) * 1000 + row_usage = None + if msg.get("usage"): + try: + row_usage = json.loads(msg["usage"]) + except Exception: + row_usage = None + for e in tl: + et = e.get("t") + if et == "think": + continue + if et == "text": + api_messages.append( + {"role": "assistant", "content": e.get("text", ""), + "timestamp": row_ts, "_db_msg_id": msg["id"]}) + elif et == "tool": + tcid = e.get("id") or f"tl{len(api_messages)}" + if api_messages and api_messages[-1].get("role") == "assistant": + base = api_messages[-1] + else: + base = {"role": "assistant", "content": None} + api_messages.append(base) + base["timestamp"] = row_ts + base["_db_msg_id"] = msg["id"] + base.setdefault("tool_calls", []).append({ + "id": tcid, + "type": "function", + "function": {"name": e.get("name", ""), + "arguments": e.get("args") or "{}"}, + }) + _res = e.get("result", "") + if e.get("ok") is None: + # 🆕 孤儿工具调用(开始了但没拿到结果 = 本轮异常中断): + # 对照 pi insertSyntheticToolResults —— 补一条合成结果, + # 保证 tool_call/tool_result 成对,否则服务商直接 400 + _res = "(本轮请求异常中断,未收到该工具的结果)" + api_messages.append({ + "role": "tool", "tool_call_id": tcid, + # 长结果 UI 存 2 万字供展开;回灌模型限 4000 字防上下文爆炸 + "content": _res[:4000], + "timestamp": row_ts, "_db_msg_id": msg["id"], + }) + # 🆕 P1: 本行 usage 挂到本行最后一条 assistant 上(usage 锚点) + if row_usage: + for _entry in reversed(api_messages): + if _entry.get("role") == "assistant": + _entry["usage"] = row_usage + break + continue + + # P0 修复: 不再向 API 注入 reasoning 字段 + # (OpenAI 兼容接口不认该字段,部分服务端直接 400; + # 思考过程仅用于本地展示与入库) + entry = {"role": msg["role"], "content": content_payload, + # 🆕 P0: 回放本行时间戳(供 usage 锚点时效校验) + "timestamp": int(msg.get("created_at", 0) or 0) * 1000, + "_db_msg_id": msg["id"]} + # 🆕 P1: assistant 行有入库 usage → 回放(显示/agent 做 usage 锚定) + if msg["role"] == "assistant" and msg.get("usage"): + try: + entry["usage"] = json.loads(msg["usage"]) + except Exception: + pass + api_messages.append(entry) + + return api_messages + + + def closeEvent(self, event): + """窗口关闭前,清理所有正在运行的 worker 线程""" + print("[System]: 正在清理后台任务...") + + # 1. 停止所有正在运行的会话生成任务 + for session_id in list(self._active_streams.keys()): + stream_state = self._active_streams[session_id] + # 🆕 Fix B: 关窗 ≈ 中断,先把进行中的部分内容落库(防止蒸发) + self._persist_interrupted_stream(session_id, stream_state) + worker = stream_state.get("worker") + if worker: + try: + worker.chunk_received.disconnect() + worker.error_occurred.disconnect() + worker.reasoning_received.disconnect() + worker.finished.disconnect() + if hasattr(worker, "tool_execution_started"): + worker.tool_execution_started.disconnect() + worker.tool_execution_updated.disconnect() + worker.tool_execution_finished.disconnect() + worker.context_compacted.disconnect() + worker.usage_updated.disconnect() + if hasattr(worker, "retry_scheduled"): + worker.retry_scheduled.disconnect() + except Exception: + pass + worker.abort() + worker.wait(1000) + if worker.isRunning(): + worker.terminate() + worker.wait() + self._active_streams.clear() + + # 🌟 2. 停止所有标题生成任务 + for session_id in list(self._title_workers.keys()): + title_state = self._title_workers[session_id] + worker = title_state.get("worker") + if worker: + try: + worker.chunk_received.disconnect() + worker.finished.disconnect() + except Exception: + pass + worker.cancel() + worker.wait(1000) + if worker.isRunning(): + worker.terminate() + worker.wait() + self._title_workers.clear() + + # 🌟 停止全局热键监听线程(注销 Alt+S) + if hasattr(self, "hotkey_screenshot"): + self.hotkey_screenshot.stop() + + print("[System]: ✅ 清理完成") + event.accept() + def _get_current_max_context(self): + """获取当前选中模型的最大上下文 (token 数)""" + try: + provider_info = self.config_data["providers"][self.current_provider] + return provider_info.get("model_contexts", {}).get(self.current_model, 128000) + except (KeyError, TypeError): + return 128000 + def update_context_display(self): + max_tokens = self._get_current_max_context() + max_k = max_tokens // 1000 + + if not self.current_session_id: + self.context_label.setText(f"0 / {max_k}k") + self.context_label.setStyleSheet("color: #888; font-size: 12px; margin-right: 10px;") + return + + messages = self.build_api_context(self.current_session_id) + # 🆕 G3(替代 Fix F):统一「下一请求」口径 —— 与压缩判定(should_compact) + # 同一公式: + # - 有有效锚点(轮中内存 / 纯文本行)→ provider 实测值(含 system+tools) + # - 工具行的入库 usage 是内存快照(未截断)≠ 下一请求(4k 截断回放) + # → G1 自动失效其锚点 → 全量公式 + # - 全量公式计入 system 提示词 + 工具 schema(worker 模式), + # 度量下一请求真实载荷(旧 Fix F 漏了这 ~10k,标签 34.1k vs 真实 44.3k) + try: + from core.agent import (from_openai_messages as _fom, + estimate_context_tokens as _ect) + _sp, _tools = "", None + if self._get_current_mode() == "worker": + from core.llm_engine import load_system_prompt as _lsp + from core.agent.tools import default_tools as _dt + _sp, _tools = _lsp(), _dt() + tokens = _ect(_fom(messages), system_prompt=_sp, + tools=_tools).tokens + except Exception: + tokens = self._estimate_token_count(messages) + + if self.current_session_id in self._active_streams: + s = self._active_streams[self.current_session_id] + # 🆕 P1: 在飞请求的精确 usage 已到达 → usage 锚定(对照 pi 显示端: + # usage 精确覆盖 system+tools+历史+在飞输出,比字符估算准) + try: + from core.agent import calculate_context_tokens as _calc_usage + _u_tokens = _calc_usage(s.get("usage") or {}) + except Exception: + _u_tokens = 0 + if _u_tokens > 0: + tokens = _u_tokens + else: + tokens += int((len(s["content"]) + len(s["reasoning"])) / 2.5) + # 🆕 Fix E: 在飞未入库的工具输出也计入显示,标签随工具结果显现实时上涨 + for _e in (s.get("timeline") or []): + if _e.get("t") == "tool": + tokens += int(len(_e.get("result", "")) / 2.5) + + used_str = f"{tokens / 1000:.1f}k" if tokens >= 1000 else str(tokens) + self.context_label.setText(f"{used_str} / {max_k}k") + # 🆕 调试日志:标签值变化才记(避免 400ms 防抖每次刷新都写) + _ctx_dbg = f"{used_str} / {max_k}k" + if getattr(self, "_last_ctx_dbg", None) != _ctx_dbg: + self._last_ctx_dbg = _ctx_dbg + self._dbg(f"[ctx] {_ctx_dbg}") + + ratio = tokens / max_tokens if max_tokens > 0 else 0 + if ratio > 0.9: + color = "#e81123" + elif ratio > 0.7: + color = "#f5a623" + else: + color = "#888" + self.context_label.setStyleSheet(f"color: {color}; font-size: 12px; margin-right: 10px;") + def _schedule_context_refresh(self): + """🆕 Fix E: 上下文标签防抖刷新(400ms 内的高频事件合并为一次)""" + try: + if not self._ctx_refresh_timer.isActive(): + self._ctx_refresh_timer.start() + except Exception: + pass + + def _estimate_token_count(self, messages): + """ + CJK 感知 token 估算(core.agent.context 统一口径): + 中日韩字符 ×1,其余字符 /4,每条消息 +4 开销,每张图片 1600。 + 与 agent 内部钳制/压缩用同一估算器。 + """ + try: + from core.agent import from_openai_messages, estimate_context_tokens + agent_msgs = from_openai_messages(messages) + tokens = estimate_context_tokens(agent_msgs).tokens + return max(1, int(tokens)) + except Exception: + # 兜底:旧口径 + total_chars = sum( + (len(m.get("content", "")) if isinstance(m.get("content", ""), str) else 0) + + len(m.get("reasoning", "") or "") + for m in messages + ) + return int(total_chars / 2.5) + def rebuild_sidebar(self): + """完整重建侧边栏列表(含分隔线)""" + self.history_list.clear() + sessions = self.db.get_all_sessions() + + starred = [s for s in sessions if s.get("is_starred")] + normal = [s for s in sessions if not s.get("is_starred")] + + for sess in starred: + self.add_session_item_to_sidebar(sess) + + # 有星标才加分隔线 + if starred and normal: + sep_item = QtWidgets.QListWidgetItem() + sep_item.setData(DraggableHistoryList.SEPARATOR_ROLE, "separator") + sep_item.setFlags(QtCore.Qt.ItemFlag.NoItemFlags) + sep_item.setSizeHint(QtCore.QSize(0, 20)) + self.history_list.addItem(sep_item) + + sep_widget = QtWidgets.QFrame() + sep_widget.setFixedHeight(1) + sep_widget.setStyleSheet("background-color: #e0e0e0; margin: 0 16px;") + self.history_list.setItemWidget(sep_item, sep_widget) + + for sess in normal: + self.add_session_item_to_sidebar(sess) + + # 恢复选中态 + if self.current_session_id: + for i in range(self.history_list.count()): + it = self.history_list.item(i) + if it.data(QtCore.Qt.ItemDataRole.UserRole) == self.current_session_id: + self.history_list.setCurrentItem(it) + break + def show_session_context_menu(self, session_id, global_pos): + """显示会话右键菜单""" + is_starred = self.db.is_session_starred(session_id) + popup = SessionContextPopup(session_id, is_starred, self) + popup.action_triggered.connect(self.on_session_action) + popup.show_at(global_pos) + self._ctx_popup = popup # 防 GC + + def on_session_action(self, action, session_id): + """处理菜单动作""" + if action == "edit": + self._rename_session(session_id) + elif action == "star": + is_starred = self.db.is_session_starred(session_id) + self.db.update_session_star(session_id, not is_starred) + self.rebuild_sidebar() + elif action == "copy": + self._copy_session(session_id) + elif action == "delete": + self._delete_session(session_id) + + def _rename_session(self, session_id): + current_title = "" + for i in range(self.history_list.count()): + it = self.history_list.item(i) + if it.data(QtCore.Qt.ItemDataRole.UserRole) == session_id: + w = self.history_list.itemWidget(it) + if w and hasattr(w, 'title_label'): + current_title = w.title_label.text() + break + + overlay = RenameOverlay(current_title, self.bg_widget) + overlay.renamed.connect(lambda new_title: self._apply_rename(session_id, new_title)) + + def _apply_rename(self, session_id, new_title): + self.db.update_session_title(session_id, new_title) + for i in range(self.history_list.count()): + it = self.history_list.item(i) + if it.data(QtCore.Qt.ItemDataRole.UserRole) == session_id: + w = self.history_list.itemWidget(it) + if w and hasattr(w, 'set_title'): + w.set_title(new_title) + break + print(f"[UI]: 会话已重命名 -> {new_title}") + + def _copy_session(self, session_id): + """📋 复制会话:DB 层深度克隆(全部支线 + 压缩标记 + 附件文件),完成后切到副本。""" + # 生成中不允许复制:副本只会拿到半截记录,且切走后原会话的流回调会失效 + if session_id in self._active_streams: + try: + QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), + "该会话正在生成中,请稍后再复制") + except Exception: + pass + print("[UI]: 复制被拒绝(会话正在生成中)") + return + + try: + new_sess = self.db.copy_session(session_id) + except Exception as e: + print(f"[UI]: 复制会话失败 -> {e}") + return + if not new_sess: + print(f"[UI]: 复制失败,源会话不存在 -> {session_id}") + return + + new_id = new_sess["id"] + print(f"[UI]: 会话已复制 -> {new_id} ({new_sess.get('title')})") + + # 重建侧边栏(副本 sort_order 在顶部),选中并进入副本 + self.rebuild_sidebar() + for i in range(self.history_list.count()): + it = self.history_list.item(i) + if it.data(QtCore.Qt.ItemDataRole.UserRole) == new_id: + self.history_list.setCurrentItem(it) + break + self.load_messages_to_web(new_id) + + def _delete_session(self, session_id): + # P0 修复: 删除前必须停掉该会话的活动流, + # 否则后台 worker 会在删除后继续 append 到已不存在的会话。 + stream = self._active_streams.get(session_id) + if stream and stream.get("worker"): + try: + w = stream["worker"] + w.abort() + w.wait(3000) + except Exception: + pass + self._active_streams.pop(session_id, None) + title_state = self._title_workers.get(session_id) + if title_state and title_state.get("worker"): + try: + title_state["worker"].cancel() + title_state["worker"].wait(2000) + except Exception: + pass + self._title_workers.pop(session_id, None) + + # 1. 在数据库彻底删除前,先查出该会话下所有的图片元数据 + with self.db.get_connection() as conn: + rows = conn.execute("SELECT attachment_metadata FROM messages WHERE session_id = ?", (session_id,)).fetchall() + metadata_list = [r['attachment_metadata'] for r in rows if r['attachment_metadata']] + + # 2. 从数据库物理删除会话(SQLite 的 CASCADE 会自动删掉 messages 表的数据) + self.db.delete_session(session_id) + + # 3. 🌟 启动物理粉碎机,清理这个会话产生的所有本地图片 + if metadata_list: + self._cleanup_physical_files(metadata_list) + + # 从列表移除 + for i in range(self.history_list.count()): + it = self.history_list.item(i) + if it.data(QtCore.Qt.ItemDataRole.UserRole) == session_id: + self.history_list.takeItem(i) + break + + # 如果删的是当前会话,切到第一个可用的 + if session_id == self.current_session_id: + for i in range(self.history_list.count()): + it = self.history_list.item(i) + if not self.history_list._is_separator(it): + sid = it.data(QtCore.Qt.ItemDataRole.UserRole) + if sid: + self.history_list.setCurrentItem(it) + self.load_messages_to_web(sid) + return + # 没有任何会话了,新建一个 + self.on_new_chat_clicked() + + # 检查分隔线是否还需要 + self.rebuild_sidebar() + def _create_session_widget(self, session_id): + """为指定 session_id 创建 SessionItemWidget(供拖拽重排后重建用)""" + # 从 DB 拿标题 + sessions = self.db.get_all_sessions() + title = "新对话" + for s in sessions: + if s["id"] == session_id: + title = s["title"] + break + widget = SessionItemWidget(session_id, title) + widget.menu_requested.connect(self.show_session_context_menu) + return widget + + def show_settings(self): + if hasattr(self, '_settings_win') and self._settings_win is not None: + self._settings_win.deleteLater() + self._settings_win = None + + self._settings_win = SettingsWindow( + parent=self, + config_data=self.config_data + ) + self._settings_win.show_with_animation() + def _on_branch_switch(self, msg_id, direction): + """🌟 核心:处理用户点击左右箭头切换分支""" + # 如果当前有正在生成的消息,禁止乱跳时空,以免导致数据冲突 + if self.current_session_id in self._active_streams: + return + + with self.db.get_connection() as conn: + # 1. 查出这兄弟的父亲是谁 + msg = conn.execute("SELECT parent_id FROM messages WHERE id = ?", (msg_id,)).fetchone() + if not msg or not msg['parent_id']: + return + + parent_id = msg['parent_id'] + + # 2. 查出这父亲下面的所有子分支,按时间正序排 + siblings = conn.execute("SELECT id FROM messages WHERE parent_id = ? ORDER BY created_at ASC", (parent_id,)).fetchall() + sibling_ids = [s[0] for s in siblings] + + if len(sibling_ids) <= 1: + return + + try: + current_idx = sibling_ids.index(msg_id) + except ValueError: + return + + # 3. 计算目标分支索引,做边界保护 + target_idx = current_idx + direction + if target_idx < 0 or target_idx >= len(sibling_ids): + return + + target_msg_id = sibling_ids[target_idx] + + # 4. 🚀 关键魔法:找到这个目标分支一直延续下去的最末端叶子节点! + target_leaf_id = self.db.get_branch_leaf(target_msg_id) + + # 5. 更新会话,直接扭转时间线指针 + self.db.update_session_leaf(self.current_session_id, target_leaf_id) + + # 6. 重新渲染!暴力清除原页面,拉取新时间线,前端毫无闪烁,干脆利落 + self.load_messages_to_web(self.current_session_id, show_loading=False) # 🌟 分支切换不触发加载层 + def _on_delete_message(self, msg_id): + """处理剪枝请求""" + # 防止用户在生成文本时手滑点击删除,引发并发灾难 + if self.current_session_id in self._active_streams: + print("[UI]: 正在生成中,拒绝截断时间线") + return + + print(f"[UI]: 执行时空剪枝,原点 -> {msg_id}") + + # 1. 执行底层物理删除,并拿到被删掉的消息的附件元数据 + deleted_metadata = self.db.delete_message_branch(self.current_session_id, msg_id) + + # 2. 🌟 启动物理粉碎机,清理硬盘上的图片缓存 + if deleted_metadata: + self._cleanup_physical_files(deleted_metadata) + + # 3. 暴力美学:瞬间重灌整个页面的数据,坏死的树枝自动消失! + self.load_messages_to_web(self.current_session_id, show_loading=False) # 🌟 删除消息不触发加载层 + + def _cleanup_physical_files(self, metadata_list): + """🌟 物理粉碎机:传入元数据列表,自动清理硬盘上的图片""" + import json + import os + + # 定位到项目根目录 + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.abspath(os.path.join(current_dir, '..', '..')) + + for meta_str in metadata_list: + if not meta_str: + continue + try: + meta = json.loads(meta_str) + for att in meta.get("attachments", []): + att_type = att.get("type") + # 图片 / PDF 本体缓存 + if att_type in ("image", "pdf") and att.get("local_path"): + abs_path = os.path.join(root_dir, att["local_path"]) + if os.path.exists(abs_path): + os.remove(abs_path) + print(f"[System]: 随聊天记录清理关联附件 -> {att['local_path']}") + # PDF 图片解析模式提取出的图片 + if att_type == "pdf": + for im in att.get("images", []): + im_abs = im.get("abs_path") + if not im_abs and im.get("local_path"): + im_abs = os.path.join(root_dir, im["local_path"]) + if im_abs and os.path.exists(im_abs): + os.remove(im_abs) + print(f"[System]: 随聊天记录清理 PDF 提取图片 -> {im_abs}") + except Exception as e: + print(f"[Warn]: 解析/清理历史附件失败: {e}") + + + + + + + +if __name__ == "__main__": + app = QtWidgets.QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec()) diff --git a/ui/views/system_tools/__init__.py b/ui/views/system_tools/__init__.py new file mode 100644 index 0000000..7644509 --- /dev/null +++ b/ui/views/system_tools/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +""" +系统级工具集合 (system_tools) +- screen_capture: 屏幕截图覆盖层 +- global_hotkey: 全局热键监听线程 +- file_reader: 文本/代码文件读取(编码探测 + 二进制探测) +""" diff --git a/ui/views/system_tools/desktop_session.py b/ui/views/system_tools/desktop_session.py new file mode 100644 index 0000000..18557b3 --- /dev/null +++ b/ui/views/system_tools/desktop_session.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +"""P1-04:桌面会话探测 + 截图/全局热键能力路由(纯 stdlib,可在任何平台导入)。 + +契约(REPAIR_BACKLOG P1-04): +- Windows 保持现有行为(Win32 RegisterHotKey + Qt grabWindow 覆盖层)。 +- Linux X11:原生全局快捷键(XGrabKey,见 x11_hotkey.py)+ 原生屏幕捕获(Qt grabWindow,X11 可用)。 +- Linux Wayland:截图走 xdg-desktop-portal(用户授权,不绕过 compositor,见 portal_capture.py); + 全局热键依赖 compositor 桌面协议(ext-global-shortcut 等),本版本无免依赖实现 → + 明确告知不可用,仅保留应用内 Alt+S 快捷键,主程序其余功能不受影响。 +- offscreen/无显示(unknown):能力不可用要有明确日志,主程序仍可聊天。 +""" +import os +import sys + + +def session_kind() -> str: + """返回 "win32" / "x11" / "wayland" / "unknown"。 + + 判定顺序(Linux,综合 Qt 平台名与 XDG_SESSION_TYPE,见 PLATFORM_PLAN): + 1. QT_QPA_PLATFORM 以 offscreen 开头 → unknown(自动化离屏,无桌面能力) + 2. QT_QPA_PLATFORM 以 wayland 开头,或 WAYLAND_DISPLAY 已设置, + 或 XDG_SESSION_TYPE=wayland → wayland + 3. DISPLAY 已设置,或 XDG_SESSION_TYPE=x11 → x11 + 4. 其余(headless/无显示)→ unknown + """ + if sys.platform == "win32": + return "win32" + if sys.platform != "linux": + # 其他 POSIX(macOS 等)不在本任务支持矩阵内 + return "unknown" + qt_plat = os.environ.get("QT_QPA_PLATFORM", "").strip() + if qt_plat.startswith("offscreen"): + return "unknown" + xdg_type = os.environ.get("XDG_SESSION_TYPE", "").strip().lower() + if (qt_plat.startswith("wayland") + or os.environ.get("WAYLAND_DISPLAY", "").strip() + or xdg_type == "wayland"): + return "wayland" + if os.environ.get("DISPLAY", "").strip() or xdg_type == "x11": + return "x11" + return "unknown" + + +def hotkey_plan(kind: str): + """全局热键能力路由 → (thread_factory | None, message)。 + + thread_factory() 返回 QThread(带 triggered 信号);None 表示无全局热键能力 + (调用方应保留应用内 QShortcut 兜底)。message 需要打印以明确当前能力。 + """ + if kind == "win32": + from ui.views.system_tools.global_hotkey import GlobalHotkeyThread + return GlobalHotkeyThread, "[GlobalHotkey] Windows:系统级全局热键 Alt+S(RegisterHotKey)" + if kind == "x11": + from ui.views.system_tools.x11_hotkey import X11HotkeyThread + return X11HotkeyThread, "[GlobalHotkey] X11:原生全局热键 Alt+S(XGrabKey)" + if kind == "wayland": + return None, ("[GlobalHotkey] Wayland:全局热键需要 compositor 桌面协议" + "(ext-global-shortcut 等),本版本未启用 → 仅提供应用内 Alt+S 快捷键" + "(窗口获焦时生效)与截图按钮;其余功能不受影响") + return None, ("[GlobalHotkey] 当前环境无显示服务(offscreen/无 DISPLAY)→ 全局热键不可用;" + "应用内 Alt+S 快捷键同样不可用,可用截图按钮以外的全部功能") + + +def capture_plan(kind: str): + """截图能力路由 → (mode, message)。 + + mode: "overlay"(Qt grabWindow 覆盖层,win32/x11) + "portal"(xdg-desktop-portal 交互截图,wayland) + "unavailable"(unknown:明确告知,主程序其余功能不受影响) + """ + if kind in ("win32", "x11"): + return "overlay", None + if kind == "wayland": + return "portal", None + return "unavailable", ("[Screenshot] 当前环境无法获取屏幕画面(offscreen/无显示)→ " + "截图功能不可用;聊天与其他功能不受影响") diff --git a/ui/views/system_tools/file_reader.py b/ui/views/system_tools/file_reader.py new file mode 100644 index 0000000..2f8a0e6 --- /dev/null +++ b/ui/views/system_tools/file_reader.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +""" +文本/代码文件读取工具 (file_reader) +编码探测 + 二进制探测 + 大小守卫,供附件系统(拖拽/粘贴/上传文本与代码文件)复用。 +用法:read_text_file(path) -> (content, encoding, size_kb, lines),失败抛 ValueError。 +说明:纯函数模块,不依赖 Qt,便于单元测试。 +""" +import os + +# 明确拒绝的二进制/文档格式(Word/Excel/PPT/PDF/压缩包/可执行/媒体等) +BINARY_EXTS = frozenset({ + # Office 文档(明确排除 Word/Excel) + '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', + '.odt', '.ods', '.odp', '.rtf', + # 电子书(.pdf 已交由 tools/builtin_tools/pdf_reader.py 专门解析,不在此拦截) + '.epub', '.mobi', + # 压缩包 / 磁盘镜像 + '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.iso', '.dmg', + # 可执行 / 编译产物 + '.exe', '.dll', '.so', '.dylib', '.msi', '.bin', '.apk', '.jar', + '.class', '.pyc', '.pyd', '.o', '.a', + # 音视频 + '.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', + '.mp4', '.avi', '.mov', '.mkv', '.webm', '.wmv', + # 字体 + '.ttf', '.otf', '.woff', '.woff2', '.eot', + # 数据库 + '.db', '.sqlite', '.sqlite3', '.mdb', + # 设计稿 + '.psd', '.ai', '.sketch', '.fig', +}) + +# 单文件大小上限(字节):超过则拒绝,避免内存与上下文爆炸 +MAX_ATTACH_FILE_BYTES = 2 * 1024 * 1024 # 2 MB + +# 编码降级链:UTF-8(含BOM) → GB18030(⊇GBK/GB2312) → latin-1(永不失败) +_ENCODINGS = ('utf-8-sig', 'gb18030', 'latin-1') + + +def read_text_file(path, max_bytes=MAX_ATTACH_FILE_BYTES): + """读取一个文本/代码文件。 + + 返回 (content, encoding, size_kb, lines)。 + 文件过大或判定为二进制时抛出 ValueError(附带可读原因)。 + """ + size = os.path.getsize(path) + if size > max_bytes: + raise ValueError( + f"文件过大({size / 1024 / 1024:.1f} MB > {max_bytes / 1024 / 1024:.0f} MB)" + ) + + with open(path, 'rb') as f: + raw = f.read() + + # 二进制探测:前 8KB 含 NUL 字节即判定为二进制(git 同款启发式) + if b'\x00' in raw[:8192]: + raise ValueError("二进制文件,无法作为文本读取") + + text, used_enc = None, 'utf-8' + for enc in _ENCODINGS: + try: + text = raw.decode(enc) + used_enc = enc + break + except UnicodeDecodeError: + continue + if text is None: # 理论上 latin-1 兜底永不失败 + raise ValueError("无法识别文件编码") + + lines = text.count('\n') + 1 + return text, used_enc, round(size / 1024, 2), lines diff --git a/ui/views/system_tools/global_hotkey.py b/ui/views/system_tools/global_hotkey.py new file mode 100644 index 0000000..6d8b240 --- /dev/null +++ b/ui/views/system_tools/global_hotkey.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +""" +全局热键监听器 (Windows) +独立线程 + Win32 RegisterHotKey 消息循环,实现主窗口失焦也能触发的系统级全局快捷键。 + +用法: + hotkey = GlobalHotkeyThread() # 默认 Alt+S + hotkey.triggered.connect(on_triggered) # 跨线程信号,槽函数在主线程执行 + hotkey.start() # 启动线程 + hotkey.stop() # 注销热键并退出线程 +""" +import ctypes +import sys +import threading +from ctypes import wintypes + +from PyQt6 import QtCore + +# Win32 常量 +WM_HOTKEY = 0x0312 +WM_QUIT = 0x0012 +MOD_ALT = 0x0001 +MOD_CONTROL = 0x0002 +MOD_SHIFT = 0x0004 +MOD_NOREPEAT = 0x4000 # 按住不重复触发 (Windows 7+) +VK_S = 0x53 + +_is_windows = sys.platform == "win32" + +if _is_windows: + _user32 = ctypes.windll.user32 + _kernel32 = ctypes.windll.kernel32 + + # 显式声明函数签名,避免 ctypes 默认 int 截断指针/句柄 + _user32.RegisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int, wintypes.UINT, wintypes.UINT] + _user32.RegisterHotKey.restype = wintypes.BOOL + _user32.UnregisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int] + _user32.UnregisterHotKey.restype = wintypes.BOOL + _user32.GetMessageW.argtypes = [ + ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT + ] + _user32.GetMessageW.restype = wintypes.BOOL # >0 正常 / 0 WM_QUIT / -1 出错 + _user32.PostThreadMessageW.argtypes = [ + wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM + ] + _user32.PostThreadMessageW.restype = wintypes.BOOL + _kernel32.GetCurrentThreadId.restype = wintypes.DWORD + + +class GlobalHotkeyThread(QtCore.QThread): + """在独立线程注册系统级全局热键并运行 Win32 消息循环。 + + 收到 WM_HOTKEY 后通过 Qt 信号 triggered 通知主线程, + 信号槽机制保证槽函数在主线程执行,操作 Qt 控件安全。 + """ + + triggered = QtCore.pyqtSignal() + + def __init__(self, hotkey_id=9001, mod=MOD_ALT, vk=VK_S, parent=None): + super().__init__(parent) + self._hotkey_id = hotkey_id + self._mod = mod + self._vk = vk + self._thread_id = 0 + self._ready = threading.Event() # run() 记录线程 ID 后置位 + self._registered = False + + def run(self): + """线程入口:注册热键 -> 消息循环 -> 退出时注销""" + if not _is_windows: + self._ready.set() + return + + self._thread_id = _kernel32.GetCurrentThreadId() + ok = _user32.RegisterHotKey( + None, self._hotkey_id, self._mod | MOD_NOREPEAT, self._vk + ) + self._registered = bool(ok) + self._ready.set() + if not ok: + print( + f"[GlobalHotkey] 注册热键失败 (id={self._hotkey_id}, mod={self._mod:#x}, " + f"vk={self._vk:#x}),可能已被其他程序占用" + ) + return + + # Win32 消息循环:hwnd=None 取本线程所有消息 + msg = wintypes.MSG() + while _user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: + if msg.message == WM_HOTKEY and msg.wParam == self._hotkey_id: + self.triggered.emit() + + # 收到 WM_QUIT 退出循环 -> 注销热键 + _user32.UnregisterHotKey(None, self._hotkey_id) + self._registered = False + + def stop(self): + """请求线程退出:向线程消息队列投递 WM_QUIT,然后等待结束""" + if not _is_windows: + return + # 等 run() 至少记录好线程 ID(注册成功或失败都行) + self._ready.wait(timeout=2.0) + if self._thread_id: + _user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0) + self.wait(2000) diff --git a/ui/views/system_tools/portal_capture.py b/ui/views/system_tools/portal_capture.py new file mode 100644 index 0000000..6bc1793 --- /dev/null +++ b/ui/views/system_tools/portal_capture.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +""" +xdg-desktop-portal 截图(P1-04,Linux Wayland 专用窄适配器) + +Wayland compositor 不允许应用直接抓取屏幕画面(安全模型),截图必须走 +xdg-desktop-portal 的 org.freedesktop.portal.Screenshot: + 1. gdbus 调 Screenshot(handle, parent_window=/, {}) → 返回 request 对象路径; + 2. gdbus monitor 监听该 request 对象: + FilePicked(file_uri, ...) → 用户已授权并保存成功 → 返回文件路径; + Finished(无 FilePicked) → 用户取消/授权被拒 → 返回 denied; + 3. 全程有显式时间预算;超时 → timeout。 + +实现只用系统自带 CLI(gdbus,GLib 的一部分)+ 标准库,不新增 Python 依赖、 +不绕过 compositor、不伪造成功。portal/桌面环境不支持时返回明确的不可用原因。 +""" +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.parse + +from PyQt6 import QtCore + +PORTAL_DEST = "org.freedesktop.portal.Desktop" +PORTAL_PATH = "/org/freedesktop/portal/desktop" +SCREENSHOT_IFACE = "org.freedesktop.portal.Screenshot" +REQUEST_IFACE = "org.freedesktop.portal.Request" + + +def detect_portal(): + """探测 portal 截图能力 → (ok: bool, reason: str | None)。""" + if sys.platform != "linux": + return False, "portal 截图仅用于 Linux" + if not (os.environ.get("XDG_RUNTIME_DIR", "").strip() + or os.environ.get("DBUS_SESSION_BUS_ADDRESS", "").strip()): + return False, ("未检测到 D-Bus 会话(XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS 均缺失)" + " → portal 截图不可用") + if not shutil.which("gdbus"): + return False, ("未找到 gdbus CLI(GLib 组件)→ portal 截图不可用;" + "聊天与其他功能不受影响") + return True, None + + +def _gdbus_screenshot_request(handle: str, timeout_s: float): + """调 Screenshot 方法,返回 (request_path, stderr);失败抛 RuntimeError。""" + cmd = ["gdbus", "call", "--session", "--dest", PORTAL_DEST, + "--object-path", PORTAL_PATH, + f"--method={SCREENSHOT_IFACE}.Screenshot", + handle, "/", "{}"] + try: + p = subprocess.run(cmd, capture_output=True, text=True, + encoding="utf-8", errors="replace", + timeout=timeout_s, check=False) + except subprocess.TimeoutExpired: + raise RuntimeError(f"portal Screenshot 调用超时({timeout_s:.0f}s 预算)") + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip().splitlines() + raise RuntimeError("portal 不可用: " + (err[-1] if err else "未知错误")) + out = (p.stdout or "").strip() + # gdbus 输出形如:('/org/freedesktop/portal/desktop/request/1000/haocode/42', <>) + start = out.find("('") + end = out.find("',") if start != -1 else -1 + if start == -1 or end == -1: + raise RuntimeError(f"无法解析 Screenshot 返回: {out[:120]!r}") + return out[start + 2:end], (p.stderr or "").strip() + + +def _gdbus_monitor_request(request_path: str, budget_s: float): + """监听 request 对象直到 FilePicked / Finished / 预算耗尽。 + + 返回 (status, path_or_reason):status ∈ {"ok", "denied", "timeout"}。 + """ + cmd = ["gdbus", "monitor", "--session", "--object-path", request_path] + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", errors="replace") + deadline = time.monotonic() + budget_s + file_path = None + finished = False + try: + while time.monotonic() < deadline: + line = p.stdout.readline() + if not line: + if p.poll() is not None: + break + continue + line = line.strip() + if not line.startswith("{"): + continue + try: + ev = json.loads(line) + except Exception: + continue + member = ev.get("member", "") + body = ev.get("body", []) + if member == "FilePicked" and body: + uri = str(body[0]) + # 剥前缀 + unquote(比 urlparse 稳健:兼容 file:///tmp/x 与 file://D%3A%5Cx) + file_path = urllib.parse.unquote(uri[len("file://"):]) \ + if uri.startswith("file://") else uri + elif member == "Finished" and ev.get("interface", "") == REQUEST_IFACE: + finished = True + break + if file_path: + return "ok", file_path + if finished and time.monotonic() <= deadline: + return "denied", "用户在 portal 授权窗口取消/拒绝" + return "timeout", f"等待 portal 授权超时({budget_s:.0f}s 预算)" + finally: + try: + p.kill() + except Exception: + pass + try: + p.wait(timeout=3) + except Exception: + pass + + +def portal_screenshot_sync(handle: str = "haocode-shot", + request_timeout_s: float = 10.0, + wait_budget_s: float = 120.0): + """同步执行 portal 截图(调用方须保证不在 GUI 线程阻塞,或用 PortalScreenshotWorker)。 + + 返回 (ok: bool, path_or_reason: str)。 + """ + ok, reason = detect_portal() + if not ok: + return False, reason + try: + request_path, _err = _gdbus_screenshot_request(handle, request_timeout_s) + except RuntimeError as e: + return False, str(e) + status, result = _gdbus_monitor_request(request_path, wait_budget_s) + if status == "ok": + return True, result + if status == "denied": + return False, result + return False, result + + +class PortalScreenshotWorker(QtCore.QThread): + """GUI 线程安全的 portal 截图:done(ok, path_or_reason) 信号回主线程。""" + + done = QtCore.pyqtSignal(bool, str) + + def __init__(self, handle: str = "haocode-shot", + request_timeout_s: float = 10.0, + wait_budget_s: float = 120.0, parent=None): + super().__init__(parent) + self._handle = handle + self._request_timeout_s = request_timeout_s + self._wait_budget_s = wait_budget_s + + def run(self): + try: + ok, result = portal_screenshot_sync(self._handle, + self._request_timeout_s, + self._wait_budget_s) + except Exception as e: + ok, result = False, f"portal 截图异常({type(e).__name__}: {e})" + self.done.emit(ok, result) diff --git a/ui/views/system_tools/screen_capture.py b/ui/views/system_tools/screen_capture.py new file mode 100644 index 0000000..8d8ca07 --- /dev/null +++ b/ui/views/system_tools/screen_capture.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +""" +屏幕截图覆盖层 (ScreenCaptureOverlay) +全屏半透明遮罩 + 鼠标拖拽选区 + 确认/取消工具条 + 截图完成发射 QImage 信号 +用法:调用 start() 启动截图,监听 screenshot_captured 信号获取结果 +""" +from PyQt6 import QtCore, QtGui, QtWidgets +import os + + +class ScreenCaptureOverlay(QtWidgets.QWidget): + """全屏截图覆盖层:半透明遮罩 + 鼠标拖拽选区 + 确认/取消按钮""" + + screenshot_captured = QtCore.pyqtSignal(QtGui.QImage) + + # 按钮尺寸 + BTN_W = 36 + BTN_H = 30 + BTN_GAP = 4 + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowFlags( + QtCore.Qt.WindowType.FramelessWindowHint + | QtCore.Qt.WindowType.WindowStaysOnTopHint + | QtCore.Qt.WindowType.Tool + ) + self.setCursor(QtCore.Qt.CursorShape.CrossCursor) + + self._full_pixmap = None + self._start_pos = QtCore.QPoint() + self._end_pos = QtCore.QPoint() + self._is_drawing = False + self._has_selection = False + self._current_rect = QtCore.QRect() + + # SVG 图标路径(项目根目录下 svg/ 文件夹) + root_dir = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..") + ) + # 确认 / 取消按钮(使用 SVG 图标,灰白简约风背景) + self._btn_confirm = QtWidgets.QPushButton(self) + self._btn_cancel = QtWidgets.QPushButton(self) + self._btn_confirm.setIcon(QtGui.QIcon(os.path.join(root_dir, "svg", "check.svg"))) + self._btn_cancel.setIcon(QtGui.QIcon(os.path.join(root_dir, "svg", "cross.svg"))) + btn_style = ( + "QPushButton { background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 4px; }" + "QPushButton:hover { background-color: #eee; border-color: #ccc; }" + ) + for btn in (self._btn_confirm, self._btn_cancel): + btn.setFixedSize(self.BTN_W, self.BTN_H) + btn.setIconSize(QtCore.QSize(20, 20)) + btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + btn.setStyleSheet(btn_style) + btn.hide() + self._btn_confirm.clicked.connect(self._on_confirm) + self._btn_cancel.clicked.connect(self._on_cancel) + + def start(self): + """开始截图:抓取屏幕全图并显示覆盖层""" + screen = QtWidgets.QApplication.primaryScreen() + if not screen: + print("[Screenshot] 无主屏幕 → 截图不可用", flush=True) + return + self._full_pixmap = screen.grabWindow(0) + # P1-04:X11 某些 compositor/环境下 grabWindow 可能拿到空图(Wayland 已改走 portal) + if self._full_pixmap is None or self._full_pixmap.isNull() or self._full_pixmap.width() == 0: + print("[Screenshot] 屏幕抓取返回空画面(当前 compositor/环境限制)→ 本次截图取消;" + "聊天与其他功能不受影响", flush=True) + self._full_pixmap = None + return + self.setGeometry(screen.geometry()) + self.show() + self.activateWindow() + self.raise_() + + def paintEvent(self, event): + if not self._full_pixmap: + return + painter = QtGui.QPainter(self) + + # 1. 绘制屏幕截图作为背景 + painter.drawPixmap(0, 0, self._full_pixmap) + # 2. 半透明遮罩 + painter.fillRect(self.rect(), QtGui.QColor(0, 0, 0, 100)) + + # 绘制中 或 已有选区 时,绘制选区高亮 + if self._is_drawing or self._has_selection: + if self._is_drawing: + rect = QtCore.QRect(self._start_pos, self._end_pos).normalized() + else: + rect = self._current_rect + + if rect.width() > 0 and rect.height() > 0: + # 3. 选区内重绘原图(去掉遮罩,形成高亮效果) + painter.drawPixmap(rect, self._full_pixmap, rect) + # 4. 蓝色边框 + pen = QtGui.QPen(QtGui.QColor(0, 120, 215), 2) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.drawRect(rect) + # 5. 尺寸标注 + size_text = f"{rect.width()} x {rect.height()}" + font = painter.font() + font.setPointSize(9) + painter.setFont(font) + fm = painter.fontMetrics() + text_w = fm.horizontalAdvance(size_text) + text_h = fm.height() + text_x = rect.x() + text_y = rect.y() - text_h - 2 + if text_y < 0: + text_y = rect.bottom() + 2 + painter.fillRect(text_x, text_y, text_w + 10, text_h, QtGui.QColor(0, 120, 215)) + painter.setPen(QtGui.QColor(255, 255, 255)) + painter.drawText(text_x + 5, text_y + fm.ascent(), size_text) + + painter.end() + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.MouseButton.LeftButton: + # 开始新选区,隐藏按钮 + self._hide_buttons() + self._has_selection = False + self._start_pos = event.position().toPoint() + self._end_pos = self._start_pos + self._is_drawing = True + self.update() + elif event.button() == QtCore.Qt.MouseButton.RightButton: + self.close() + + def mouseMoveEvent(self, event): + if self._is_drawing: + self._end_pos = event.position().toPoint() + self.update() + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.MouseButton.LeftButton and self._is_drawing: + self._is_drawing = False + rect = QtCore.QRect(self._start_pos, self._end_pos).normalized() + if rect.width() > 5 and rect.height() > 5: + # 保留选区,显示确认/取消按钮 + self._current_rect = rect + self._has_selection = True + self._position_buttons(rect) + self._btn_confirm.show() + self._btn_cancel.show() + self.update() + else: + self.close() + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key.Key_Escape: + self.close() + elif event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter): + if self._has_selection: + self._on_confirm() + + def _position_buttons(self, rect): + """将确认/取消按钮定位到选区右下角""" + total_w = self.BTN_W * 2 + self.BTN_GAP + # 默认放在选区右下角外侧 + x = rect.right() - total_w + y = rect.bottom() + 4 + # 边界检测:超出屏幕底部时翻到选区内侧 + if y + self.BTN_H > self.height(): + y = rect.bottom() - self.BTN_H - 4 + if x < 0: + x = 0 + self._btn_confirm.move(x, y) + self._btn_cancel.move(x + self.BTN_W + self.BTN_GAP, y) + + def _hide_buttons(self): + self._btn_confirm.hide() + self._btn_cancel.hide() + + def _on_confirm(self): + """确认截图:裁剪并发射信号""" + if self._full_pixmap and self._current_rect.width() > 5 and self._current_rect.height() > 5: + dpr = self._full_pixmap.devicePixelRatio() + phys_rect = QtCore.QRect( + int(self._current_rect.x() * dpr), + int(self._current_rect.y() * dpr), + int(self._current_rect.width() * dpr), + int(self._current_rect.height() * dpr), + ) + captured = self._full_pixmap.toImage().copy(phys_rect) + self.screenshot_captured.emit(captured) + self.close() + + def _on_cancel(self): + """取消截图:直接关闭""" + self.close() + + def closeEvent(self, event): + self._is_drawing = False + self._has_selection = False + self._hide_buttons() + self._full_pixmap = None + super().closeEvent(event) diff --git a/ui/views/system_tools/x11_hotkey.py b/ui/views/system_tools/x11_hotkey.py new file mode 100644 index 0000000..dcbe7d3 --- /dev/null +++ b/ui/views/system_tools/x11_hotkey.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +""" +X11 原生全局热键(P1-04,Linux X11 专用窄适配器) + +用 ctypes 调 libX11:XOpenDisplay → XGrabKey(root, keycode, Mod1Mask) → +select(display_fd) + XNextEvent 消息循环,命中后发射 triggered 信号。 +与 GlobalHotkeyThread(Windows)同一公开面:triggered / start() / stop()。 + +约束: +- 只支持现有截图快捷键(Alt+S),不增加任意键监听/记录/重映射; +- 注册失败/无显示/无 libX11 → 打印明确"不可用"日志后安静退出,绝不影响主程序; +- 不引入任何新的 Python 依赖(libX11 是 X11 桌面必然存在的系统库)。 +""" +import ctypes +import ctypes.util +import select +import threading + +from PyQt6 import QtCore + +# ---- X11 常量 ---- +KeyPress = 2 +Mod1Mask = 0x0001 # Alt +KeyPressMask = 0x00002 # XSelectInput event mask +AnyModifier = 0xFFFFFF +DEFAULT_MOD = 1 # 与 global_hotkey.MOD_ALT 同值 +DEFAULT_VK = 0x53 # 与 global_hotkey.VK_S 同值('S') + +#: Win32 VK → X11 keysym 的窄映射(只覆盖现有快捷键;扩展需同步本表) +_VK_TO_KEYSYM = { + 0x53: 0x73, # S → 's' +} + +_XEVENT_FIELDS = [ + ("type", ctypes.c_int), + ("serial", ctypes.c_ulong), + ("send_event", ctypes.c_int), + ("display", ctypes.c_void_p), + ("window", ctypes.c_uint), + ("root", ctypes.c_uint), + ("subwindow", ctypes.c_uint), + ("time", ctypes.c_ulong), + ("x", ctypes.c_int), + ("y", ctypes.c_int), + ("root_x", ctypes.c_int), + ("root_y", ctypes.c_int), + ("state", ctypes.c_uint), + ("keycode", ctypes.c_uint), + ("same_screen", ctypes.c_int), +] + + +class XEvent(ctypes.Structure): + """XEvent union 的按键事件视图(字段布局与 xproto.h XKeyEvent 一致,64 位下 keycode 位于偏移 76)""" + _fields_ = _XEVENT_FIELDS + + +def _load_x11(): + """加载 libX11 并声明最小 API 原型。失败抛 OSError(调用方转成"不可用"日志)。""" + name = ctypes.util.find_library("X11") or "libX11.so.6" + x11 = ctypes.CDLL(name) + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + x11.XOpenDisplay.restype = ctypes.c_void_p + x11.XCloseDisplay.argtypes = [ctypes.c_void_p] + x11.XConnectionNumber.argtypes = [ctypes.c_void_p] + x11.XConnectionNumber.restype = ctypes.c_int + x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p] + x11.XDefaultRootWindow.restype = ctypes.c_uint + x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + x11.XKeysymToKeycode.restype = ctypes.c_int + x11.XSelectInput.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_long] + x11.XGrabKey.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.c_int] + x11.XGrabKey.restype = ctypes.c_int + x11.XUngrabKey.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint] + x11.XPending.argtypes = [ctypes.c_void_p] + x11.XPending.restype = ctypes.c_int + x11.XNextEvent.argtypes = [ctypes.c_void_p, ctypes.POINTER(XEvent)] + return x11 + + +def _open_x11(): + """可注入入口(测试用替身替换)。返回 (x11, display);display 为 0/None 表示无显示。""" + x11 = _load_x11() + disp = x11.XOpenDisplay(None) + return x11, disp + + +class X11HotkeyThread(QtCore.QThread): + """X11 全局热键线程(公开面与 GlobalHotkeyThread 一致:triggered/start/stop)。""" + + triggered = QtCore.pyqtSignal() + + def __init__(self, hotkey_id=9001, mod=DEFAULT_MOD, vk=DEFAULT_VK, parent=None): + super().__init__(parent) + self._hotkey_id = hotkey_id + self._mod = mod + self._vk = vk + self._ready = threading.Event() + self._stop_flag = False + self._ready_flag = False + self._registered = False + self._keycode = 0 + self._x11 = None + self._display = None + self._fd = None + + # ------------------------------------------------------------------ + def _setup(self): + """打开显示、注册热键。成功返回 True;失败打印明确日志并返回 False。""" + try: + keysym = _VK_TO_KEYSYM.get(self._vk) + if keysym is None or self._mod != DEFAULT_MOD: + print(f"[X11Hotkey] 暂不支持的快捷键组合 (mod={self._mod:#x}, vk={self._vk:#x})" + f" → 全局热键不可用(本适配器只覆盖现有 Alt+S)", flush=True) + return False + x11, disp = _open_x11() + if not disp: + print("[X11Hotkey] XOpenDisplay 失败(无 X11 显示服务)→ 全局热键不可用;" + "应用内快捷键/截图按钮以外的功能不受影响", flush=True) + return False + self._x11 = x11 + self._display = disp + self._fd = x11.XConnectionNumber(disp) + root = x11.XDefaultRootWindow(disp) + keycode = x11.XKeysymToKeycode(disp, keysym) + if not keycode: + print("[X11Hotkey] 键码解析失败 → 全局热键不可用", flush=True) + self._cleanup() + return False + self._keycode = keycode + x11.XSelectInput(disp, root, ctypes.c_long(KeyPressMask)) + if x11.XGrabKey(disp, keycode, Mod1Mask, root, 1) == 0: + # BadAccess:键已被其他程序抢占 + print(f"[X11Hotkey] XGrabKey 注册失败(Alt+S 可能已被其他程序占用)" + f" → 全局热键不可用;应用内快捷键/截图按钮以外的功能不受影响", flush=True) + self._cleanup() + return False + self._registered = True + return True + except Exception as e: + print(f"[X11Hotkey] 初始化失败({type(e).__name__}: {e})→ 全局热键不可用", flush=True) + self._cleanup() + return False + + def _cleanup(self): + if self._display and self._x11: + try: + self._x11.XUngrabKey(self._display, self._keycode, AnyModifier, + self._x11.XDefaultRootWindow(self._display)) + except Exception: + pass + try: + self._x11.XCloseDisplay(self._display) + except Exception: + pass + self._display = None + self._x11 = None + self._fd = None + self._registered = False + + # ------------------------------------------------------------------ + def run(self): + if not self._setup(): + self._ready.set() + return + self._ready.set() + try: + while not self._stop_flag: + if self._fd is None: + break + try: + r, _, _ = select.select([self._fd], [], [], 0.2) + except (OSError, ValueError): + break + if not r: + continue + if not self._x11.XPending(self._display): + continue + ev = XEvent() + self._x11.XNextEvent(self._display, ctypes.byref(ev)) + if ev.type == KeyPress and ev.keycode == self._keycode: + self.triggered.emit() + except BaseException as e: + # 绝不允许异常逃逸出 QThread.run()(PyQt6 会 abort 整个应用) + print(f"[X11Hotkey] 事件循环异常({type(e).__name__}: {e})→ 全局热键不可用;其余功能不受影响", + flush=True) + finally: + self._cleanup() + + def wait_ready(self, timeout_s: float = 2.0) -> bool: + """等待注册流程结束(成功或失败都算就绪;测试/诊断用)""" + return self._ready.wait(timeout_s) + + def stop(self): + """请求退出(select 0.2s 超时轮询 stop 标志 → 线程安全退出,无需向 X 连接发消息)""" + self._stop_flag = True diff --git a/ui/views/wv2_view.py b/ui/views/wv2_view.py new file mode 100644 index 0000000..edb646b --- /dev/null +++ b/ui/views/wv2_view.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +""" +WebView2View —— 外观兼容 QWebEngineView 的 QWidget(仅实现 main_window 用到的 API 面): + setUrl / loadFinished / page().runJavaScript / page().setBackgroundColor / grab +内部由 core.webview2.Wv2Session 驱动: + - 30ms timer + resizeEvent 双路 SetBoundsAndZoomFactor(slot 矩形 → 父窗客户区物理像素) + - WebMessageReceived → ChatBridge 的 JS→Python slot + - run_js → ExecuteScriptAsync(与 QWebChannel 路径生成的 JS 文本同构) +""" +import json +import ctypes +import ctypes.wintypes as wintypes + +from PyQt6 import QtCore, QtGui, QtWidgets +from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QUrl + +# JS → Python 消息名 → ChatBridge slot 名(与 QWebChannel 注册的对象方法一一对应) +_BRIDGE_METHODS = ( + "onRegenerateClicked", + "onBranchSwitch", + "onDeleteMessageClicked", + "onAttachmentClicked", + "onScrollChanged", + "onRequestWindowPage", # 🆕 P1-01 渲染窗口换页请求 +) + + +class _PageShim: + """QWebEnginePage 的最小替身""" + + def __init__(self, view): + self._view = view + + def runJavaScript(self, script, callback=None): + if callback is None: + self._view.run_js(script) + else: + self._view.execute_js_async(script, callback) + + def setBackgroundColor(self, color): + pass # WebView2 底色由页面 CSS 决定(页面本身就是白底) + + +class WebView2View(QtWidgets.QWidget): + loadFinished = pyqtSignal(bool) + + def __init__(self, session, parent=None): + super().__init__(parent) + self._session = session + self._page = _PageShim(self) + self._bridge = None + self._session.on_load_finished = self._on_page_loaded + self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True) + # 子窗口发现(异步创建) + self._child_timer = QTimer(self) + self._child_timer.setInterval(100) + self._child_timer.timeout.connect(self._try_find_child) + self._child_timer.start() + # bounds 同步兜底(resizeEvent 主路 + 30ms 兜底,与 P1 实测一致) + self._sync_timer = QTimer(self) + self._sync_timer.setInterval(30) + self._sync_timer.timeout.connect(self.sync_bounds) + self._sync_timer.start() + # 几何去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞) + self._last_sent_bounds = None + + # ---------- 页面加载完成 ---------- + def _on_page_loaded(self): + # 立即启动 JS 管线(子窗口全程可见,无节流/竞态)。 + # 首帧前的"透底色"问题由 Qt 侧白色 paintEvent 解决(透明子窗表面露出白底)。 + self.loadFinished.emit(True) + + def paintEvent(self, ev): + # 子窗口隐藏期间(启动首帧前)槽位必须自绘白底, + # 否则 WA_OpaquePaintEvent + 无 paintEvent = 未绘制表面 = 纯黑 + p = QtGui.QPainter(self) + p.fillRect(self.rect(), QtGui.QColor("#ffffff")) + p.end() + + # ---------- QWebEngineView 兼容 API ---------- + def page(self): + return self._page + + def setUrl(self, url): + # 接受 QUrl 或 str + s = url.toString() if isinstance(url, QUrl) else str(url) + self._session.navigate(s) + + def grab(self): + """子窗口区域截图(调试工具用)""" + h = self._session.child_hwnd + if not h: + return QtGui.QPixmap(self.size()) + r = wintypes.RECT() + if not ctypes.windll.user32.GetWindowRect(wintypes.HWND(h), ctypes.byref(r)): + return QtGui.QPixmap(self.size()) + w = max(1, r.right - r.left) + hgt = max(1, r.bottom - r.top) + screen_dc = ctypes.windll.user32.GetDC(0) + mem_dc = ctypes.windll.gdi32.CreateCompatibleDC(screen_dc) + bmp = ctypes.windll.gdi32.CreateCompatibleBitmap(screen_dc, w, hgt) + ctypes.windll.gdi32.SelectObject(mem_dc, bmp) + ctypes.windll.gdi32.BitBlt(mem_dc, 0, 0, w, hgt, screen_dc, r.left, r.top, 0x00CC0020) + # BITMAPINFO + class _BMI(ctypes.Structure): + _fields_ = [("biSize", wintypes.DWORD), ("biWidth", ctypes.c_long), + ("biHeight", ctypes.c_long), ("biPlanes", wintypes.WORD), + ("biBitCount", wintypes.WORD), ("biCompression", wintypes.DWORD), + ("biSizeImage", wintypes.DWORD), ("biXPelsPerMeter", ctypes.c_long), + ("biYPelsPerMeter", ctypes.c_long), ("biClrUsed", wintypes.DWORD), + ("biClrImportant", wintypes.DWORD)] + bmi = _BMI() + bmi.biSize = ctypes.sizeof(_BMI) + bmi.biWidth = w + bmi.biHeight = -hgt # 顶向下 + bmi.biPlanes = 1 + bmi.biBitCount = 32 + bmi.biCompression = 0 + buf = ctypes.create_string_buffer(w * hgt * 4) + ctypes.windll.gdi32.GetDIBits(mem_dc, bmp, 0, hgt, buf, ctypes.byref(bmi), 0) + ctypes.windll.gdi32.DeleteObject(bmp) + ctypes.windll.gdi32.DeleteDC(mem_dc) + ctypes.windll.user32.ReleaseDC(0, screen_dc) + img = QtGui.QImage(buf, w, hgt, w * 4, QtGui.QImage.Format.Format_RGBA8888).copy() + return QtGui.QPixmap.fromImage(img) + + # ---------- 桥接 ---------- + def attach_bridge(self, bridge): + self._bridge = bridge + self._session.on_message = self._dispatch_message + + def _dispatch_message(self, data): + if not isinstance(data, dict) or not self._bridge: + return + name = data.get("m") + args = data.get("a", []) + if name in _BRIDGE_METHODS: + fn = getattr(self._bridge, name, None) + if fn: + try: + fn(*args) + except Exception as ex: + print(f"[WV2] bridge {name} error:", ex) + + # ---------- JS → Python 用的执行通道 ---------- + def run_js(self, script): + self._session.execute_js(script) + + def execute_js_async(self, script, cb): + self._session.execute_js_async(script, cb) + + # ---------- 几何同步 ---------- + def _dpr(self): + try: + d = self.dpr() + return d if d > 0 else 1.0 + except Exception: + return 1.0 + + def sync_bounds(self): + if not self.isVisible() or not self._session.child_hwnd: + return + top = self.window() + p = self.mapTo(top, QtCore.QPoint(0, 0)) + d = self._dpr() + L, T = p.x() * d, p.y() * d + W, H = max(1, self.width() * d), max(1, self.height() * d) + # 去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞 → 整窗黑屏) + # 注:旧版 resize hold(放大时钳制旧尺寸→露白边)已移除 —— + # 拖动黑边的真凶是假异步 JS 泵(已修真异步),高频 SetBounds 实测 Chromium 完全跟得上; + # 用户实测:立即发送 = 缩小/放大都实时渲染、无阻塞。 + if self._last_sent_bounds == (L, T, W, H): + return + self._session.set_bounds(L, T, W, H) + self._last_sent_bounds = (L, T, W, H) + + def resizeEvent(self, ev): + super().resizeEvent(ev) + self.sync_bounds() + + def showEvent(self, ev): + super().showEvent(ev) + # 确保渲染层点亮(SDK 初始状态下控制器可能不可见) + self._session.set_visible(True) + self.sync_bounds() + + def _force_repaint(self): + """WebView2 子窗口创建会打断首帧合成 → 强制整窗重绘(治启动时控件渲染丢失)""" + top = self.window() + if top is None: + return + top.update() + QtWidgets.QApplication.processEvents() + + def _try_find_child(self): + if self._session.child_hwnd: + self._child_timer.stop() + self._session.set_visible(True) + self.sync_bounds() + self._force_repaint() + QtCore.QTimer.singleShot(300, self._force_repaint) + return + if self._session.find_child_once(): + self._child_timer.stop() + self._session.set_visible(True) + self.sync_bounds() + self._force_repaint() + QtCore.QTimer.singleShot(300, self._force_repaint) + + # ---------- 生命周期 ---------- + def closeEvent(self, ev): + try: + self._child_timer.stop() + self._sync_timer.stop() + self._session.close() + except Exception: + pass + super().closeEvent(ev) diff --git a/ui/web/app.js b/ui/web/app.js new file mode 100644 index 0000000..8548559 --- /dev/null +++ b/ui/web/app.js @@ -0,0 +1,2309 @@ +// ========== 诊断日志(排查流式显示问题的根本手段) ========== +// ========== JS 控制台桥:console.* 全部捕获进 window.__jslog,Python 每 500ms 抽到控制台 ========== +(function() { + try { + var realLog = console.log.bind(console); + var realWarn = console.warn.bind(console); + var realErr = console.error.bind(console); + window.__jslog = []; + function fmt(a) { + if (typeof a === 'string') return a; + if (typeof a === 'object' && a !== null) { + try { return JSON.stringify(a); } catch (e) { return String(a); } + } + return String(a); + } + function push(level, args) { + var str = ''; + for (var i = 0; i < args.length; i++) str += (i ? ' ' : '') + fmt(args[i]); + window.__jslog.push('[' + level + '] ' + str); + if (window.__jslog.length > 500) window.__jslog.splice(0, window.__jslog.length - 500); + } + console.log = function() { push('log', Array.prototype.slice.call(arguments)); realLog.apply(null, arguments); }; + console.warn = function() { push('warn', Array.prototype.slice.call(arguments)); realWarn.apply(null, arguments); }; + console.error = function() { push('err', Array.prototype.slice.call(arguments)); realErr.apply(null, arguments); }; + window.__jslogDrain = function() { + if (!window.__jslog || !window.__jslog.length) return ''; + var out = window.__jslog.join('\n'); + window.__jslog = []; + return out; + }; + } catch (e) {} +})(); +window.__APP_VER = '20260721-v7'; +console.log('[JS] ===== app.js 加载 ver=20260721-v7 ====='); +// 公式渲染依赖本地 KaTeX(离线);此处确认资源加载结果,缺失时打印告警便于定位 +console.log('[JS] KaTeX ' + (typeof katex !== 'undefined' ? katex.version + ' 就绪' : '缺失(公式将退化为纯文本)')); +// 渲染器主线程心跳:dt 异常大 = 渲染器被阻塞(截图/重绘/GPU 等) +(function() { + var _hbLast = Date.now(); + setInterval(function() { + var _now = Date.now(); + var _dt = _now - _hbLast; + _hbLast = _now; + if (_dt >= 1500) { + console.log('[JS] 心跳 dt=' + _dt + 'ms (渲染器主线程曾卡顿)'); + } + }, 1000); +})(); +window.__diag = { events: [], cap: 300, tokenN: 0, thinkN: 0 }; +function diagEvent(name, extra) { + try { + var d = window.__diag; + d.events.push({ t: Date.now() % 1000000, e: name, x: extra }); + if (d.events.length > d.cap) d.events.splice(0, d.events.length - d.cap); + } catch (e) {} +} +function dumpDiag() { + try { + var c = {}; + var last = {}; + var evs = window.__diag.events; + for (var i = 0; i < evs.length; i++) { + var e = evs[i].e; + c[e] = (c[e] || 0) + 1; + last[e] = evs[i].x; // 每类最后一条 + } + return JSON.stringify({ ver: window.__APP_VER, n: evs.length, + counts: c, last: last, + recent: evs.slice(-30) }); + } catch (e) { return '{}'; } +} + +// ★ 实时 DOM 体检:缓冲区字符 vs DOM 实际字符 vs 可见性 +// 返回每个正文段/思考段的 {b:缓冲, d:DOM, h:高度, op:透明度, dis:display} +function probeStream(msgId) { + try { + var out = { id: msgId, buf: false, segs: [], thinks: [], raf: 0, due: 0 }; + var buf = messageBuffer[msgId]; + if (!buf) return JSON.stringify(out); + out.buf = true; + out.raf = buf.raf ? 1 : 0; + out.due = buf.rafDue ? 1 : 0; + buf.textSegs = buf.textSegs || []; + buf.thinkSegs = buf.thinkSegs || []; + var i, el; + for (i = 0; i < buf.textSegs.length; i++) { + el = buf.textSegs[i]; + var cs = (el.ownerDocument.defaultView.getComputedStyle) + ? el.ownerDocument.defaultView.getComputedStyle(el) : null; + out.segs.push({ + b: (el.__buf || '').length, + d: (el.textContent || '').length, + h: el.offsetHeight, + op: cs ? cs.opacity : '?', + dis: cs ? cs.display : '?', + child: el.childNodes.length + }); + } + for (i = 0; i < buf.thinkSegs.length; i++) { + el = buf.thinkSegs[i]; + out.thinks.push({ + b: (el.__buf || '').length, + d: (el.textContent || '').length, + h: el.offsetHeight + }); + } + return JSON.stringify(out); + } catch (e) { + return JSON.stringify({ err: String(e) }); + } +} + +var chatContainer = document.getElementById('chat-container'); +var messageBuffer = {}; +var longTextStore = {}; +var finalContentStore = {}; +var attachmentMetaStore = {}; +function isNearBottom() { + return (window.innerHeight + window.scrollY) >= (document.body.scrollHeight - 150); +} + +// ==================== 自定义滚动条镜像上报 ==================== +function getScroller() { + return document.scrollingElement || document.documentElement; +} + +function webScrollTo(y) { + getScroller().scrollTop = y; +} + +function reportWebScroll() { + if (window.bridge && window.bridge.onScrollChanged) { + var se = getScroller(); + window.bridge.onScrollChanged(se.scrollTop, se.scrollHeight, se.clientHeight); + } +} +document.addEventListener('scroll', function() { + requestAnimationFrame(reportWebScroll); + // 🆕 P1-01:自动模式顶部自动加载旧页 / 双模式底部自动恢复新页 + if (typeof rwAutoCheck === 'function') requestAnimationFrame(rwAutoCheck); +}, { passive: true, capture: true }); +window.addEventListener('resize', function() { + requestAnimationFrame(reportWebScroll); +}); +document.addEventListener('DOMContentLoaded', function() { + setTimeout(reportWebScroll, 300); +}); + +// ==================== Markdown 渲染器配置 ==================== +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function safeHtml(html) { + if (typeof window !== 'undefined' && window.DOMPurify) { + return DOMPurify.sanitize(html, { ADD_TAGS: ['details', 'summary'] }); + } + return html; +} + +var renderer = new marked.Renderer(); + +renderer.code = function(obj) { + var code = (typeof obj === 'string') ? obj : (obj.text || ''); + var lang = (typeof obj === 'string') ? (arguments[1] || '') : (obj.lang || ''); + var langLabel = lang ? lang : 'code'; + var isSvg = String(lang || '').trim().toLowerCase() === 'svg'; + var svgBtnHtml = isSvg + ? '' + : ''; + var safeCode = escapeHtml(code); + var safeLang = escapeHtml(lang); + var safeLangLabel = escapeHtml(langLabel); + return '
' + + '
' + + '' + safeLangLabel + '' + + '
' + + '' + + '' + + svgBtnHtml + + '
' + + '
' + + '' + + '
'; +}; + +// 流式渲染是全量重渲,围栏未闭合的瞬态或回答中的无围栏 HTML 片段都会被打成活体 DOM。 +// 覆盖 renderer.html,把一切裸 HTML 转义为可见文本。 +renderer.html = function(html) { + return escapeHtml(html); +}; + +marked.setOptions({ renderer: renderer, breaks: true }); + +// ==================== 🌟 公式渲染(KaTeX + 部分供应商单括号 [...] 格式) ==================== +// +// 部分大模型提供商把块公式输出成单括号 [ ... ](非标准 $$...$$), +// 前端原先无任何公式渲染。统一管线(包在 marked.parse 里,17+ 个调用点 +// ——正文/思考/工具/压缩摘要——自动生效): +// extractMath: 冻结代码区 → 抽公式为占位符 → 还原代码 +// marked.parse: 正常 markdown 渲染(占位符当纯文本通过) +// restoreMath: 占位符 → KaTeX HTML(throwOnError=false,流式半截公式红色自愈) +// safeHtml(DOMPurify): 调用点原有逻辑,KaTeX 产物也过一遍消毒 +// +// 块定界符: $$...$$、\[...\]、[ ... ](供应商格式,严格启发式防误伤) +// 行内定界符: $...$(货币/空格守卫)、\(...\) + +var _mathItems = []; + +function _mathHasFeature(c) { + // 数学特征:\ 命令、上下标、等号、矩阵 &、希腊字母 + return /\\[a-zA-Z]/.test(c) || /[_^]/.test(c) || /=/.test(c) || /&/.test(c) + || /[\u0370-\u03FF]/.test(c); +} + +function _putMath(tex, display) { + _mathItems.push({ tex: tex, display: display }); + return (display ? '@@KBMA' : '@@KMIA') + (_mathItems.length - 1) + '@@'; +} + +function _katexRender(tex, display) { + if (typeof katex === 'undefined') return escapeHtml(tex); + try { + return katex.renderToString(tex, { displayMode: display, throwOnError: false, errorColor: '#cf1322' }); + } catch (e) { + return escapeHtml(tex); + } +} + +function extractMath(text) { + _mathItems = []; + // ---- 1) 冻结代码区(围栏含流式未闭合瞬态 + 行内代码):公式提取不碰代码 ---- + var codeFrozen = []; + var s = String(text).replace(/```[\s\S]*?(?:```|$)|`[^`\n]*`/g, function(m){ + codeFrozen.push(m); + return '@@KOCE' + (codeFrozen.length - 1) + '@@'; + }); + // ---- 2) 块 $$...$$ ---- + s = s.replace(/\$\$([\s\S]+?)\$\$/g, function(_, tex){ return _putMath(tex, true); }); + // ---- 3) 块 \[...\] ---- + s = s.replace(/\\\[[\s\S]+?\\\]/g, function(m){ return _putMath(m.slice(2, -2), true); }); + // ---- 4) 供应商单括号块 [ ... ](严格启发式)---- + // 判公式条件:行首 [ + 行尾 ] + 后不紧跟 ((排除链接)+ 不含 http + // + 多行 或 含数学特征;[1] 编号/[a, b] 列表/单行链接全部排除 + { + var out = '', pos = 0, n = s.length; + while (pos < n) { + var nl = s.indexOf('\n', pos); + var lineEnd = nl === -1 ? n : nl; + var line = s.substring(pos, lineEnd); + var m = /^(\s*)\[/.exec(line); + var consumed = false; + if (m) { + var searchFrom = pos + m[0].length; + var brPos = pos + m[1].length; // '[' 的位置(保留括号前空白,丢括号) + var closeIdx = -1; + var p2 = searchFrom; + while (p2 < n) { + var nl2 = s.indexOf('\n', p2); + var le2 = nl2 === -1 ? n : nl2; + var l2 = s.substring(p2, le2); + var cm = l2.match(/\](\s*)$/); + if (cm) { closeIdx = p2 + cm.index; break; } + p2 = (nl2 === -1) ? n : nl2 + 1; + } + if (closeIdx > searchFrom) { + var content = s.substring(searchFrom, closeIdx); + var ok = content.trim() !== '' + && s.charAt(closeIdx + 1) !== '(' + && content.indexOf('http') === -1 + && content.indexOf('@@KOCE') === -1 + && (content.indexOf('\n') !== -1 || _mathHasFeature(content.trim())); + if (ok) { + out += s.substring(pos, brPos) + _putMath(content, true); + pos = closeIdx + 1; + consumed = true; + } + } + } + if (!consumed) { + out += line + (nl === -1 ? '' : '\n'); + pos = nl === -1 ? n : nl + 1; + } + } + s = out; + } + // ---- 5) 行内 \(...\) ---- + s = s.replace(/\\\(([\s\S]+?)\\\)/g, function(_, tex){ return _putMath(tex, false); }); + // ---- 6) 行内 $...$(守卫:首尾无空格、非纯数字、含数学特征 或 纯 ASCII 单词如 $x$)---- + s = s.replace(/\$([^\n$]+?)\$/g, function(full, tex){ + if (/^\s|\s$/.test(tex)) return full; // 首尾空格 → 货币/普通 + if (/^[\d,.\s]+$/.test(tex)) return full; // 纯数字 → 货币 + if (!_mathHasFeature(tex) && !/^[A-Za-z][A-Za-z0-9]*$/.test(tex.trim())) return full; + return _putMath(tex, false); + }); + // ---- 7) 还原代码区 ---- + s = s.replace(/@@KOCE(\d+)@@/g, function(_, i){ return codeFrozen[+i]; }); + return { md: s, items: _mathItems }; +} + +function restoreMath(html, items) { + items = items || _mathItems; + for (var i = 0; i < items.length; i++) { + var tok = (items[i].display ? '@@KBMA' : '@@KMIA') + i + '@@'; + html = html.split(tok).join(_katexRender(items[i].tex, items[i].display)); + } + return html; +} + +// 🌟 统一拦截点:包 marked.parse,所有调用点(正文/流式/思考/工具/摘要)自动获得公式渲染 +var _markedParseRaw = marked.parse.bind(marked); +marked.parse = function(text, opts) { + if (typeof text !== 'string') return _markedParseRaw(text, opts); + // 快速路径:无任何公式特征字符直接过([...] 供应商格式也靠 [ 触发) + if (text.indexOf('$') === -1 && text.indexOf('\\[') === -1 + && text.indexOf('\\(') === -1 && text.indexOf('[') === -1) { + return _markedParseRaw(text, opts); + } + var ex = extractMath(text); + return restoreMath(_markedParseRaw(ex.md, opts), ex.items); +}; + +// 🌟 流式稳定前缀防切分:前缀内有未闭合公式起点 → 返回应回退到的位置(0=无) +// (启发式;误判只延迟一帧固化,无功能影响) +function findUnclosedMathFrom(s) { + var inFence = false, pos = 0, n = s.length; + var dollarOpen = -1, bsOpen = -1, parenOpen = -1, inlineOpen = -1, bracketOpen = -1; + while (pos < n) { + var nl = s.indexOf('\n', pos); + var lineEnd = nl === -1 ? n : nl + 1; + var line = s.substring(pos, lineEnd); + if (/^\s*(```|~~~)/.test(line)) inFence = !inFence; + if (!inFence) { + var i2 = 0; + while ((i2 = line.indexOf('$$', i2)) !== -1) { + dollarOpen = (dollarOpen === -1) ? (pos + i2) : -1; + i2 += 2; + } + var ib = line.indexOf('\\['); + if (ib !== -1) bsOpen = (bsOpen === -1) ? (pos + ib) : -1; + if (line.indexOf('\\]') !== -1) bsOpen = -1; + var ip = line.indexOf('\\('); + if (ip !== -1) parenOpen = (parenOpen === -1) ? (pos + ip) : -1; + if (line.indexOf('\\)') !== -1) parenOpen = -1; + for (var k = 0; k < line.length; k++) { + if (line.charAt(k) === '$' + && line.charAt(k + 1) !== '$' + && (k === 0 || line.charAt(k - 1) !== '$')) { + inlineOpen = (inlineOpen === -1) ? (pos + k) : -1; + } + } + if (bracketOpen !== -1) { + if (/\]\s*$/.test(line)) bracketOpen = -1; + } else { + var mb = /^(\s*)\[/.exec(line); + if (mb && line.indexOf('](') === -1 && !/\]\s*$/.test(line)) { + // 行首 [ 未同行闭合、非链接;裸 [ 行或含数学特征才视为公式开 + if (line.trim() === '[' || _mathHasFeature(line)) bracketOpen = pos; + } + } + } + pos = lineEnd; + } + var opens = []; + if (dollarOpen !== -1) opens.push(dollarOpen); + if (bsOpen !== -1) opens.push(bsOpen); + if (parenOpen !== -1) opens.push(parenOpen); + if (inlineOpen !== -1) opens.push(inlineOpen); + if (bracketOpen !== -1) opens.push(bracketOpen); + return opens.length ? Math.min.apply(null, opens) : 0; +} + +// ==================== 全局事件委托 ==================== +if (chatContainer) { + chatContainer.addEventListener('click', function(e) { + var target = e.target; + + // --- 1. 消息操作栏:复制整条消息 --- + var copyMsgBtn = target.closest('.copy-msg-btn'); + if (copyMsgBtn) { + var actionBar = copyMsgBtn.closest('.message-actions'); + if (actionBar) { + var targetMsgId = actionBar.getAttribute('data-msg-id'); + var rawContent = (typeof finalContentStore !== 'undefined' && finalContentStore[targetMsgId]) || ''; + copyToClipboard(rawContent).then(function() { + var iconCopy = copyMsgBtn.querySelector('.icon-copy'); + var iconCheck = copyMsgBtn.querySelector('.icon-check'); + if (iconCopy && iconCheck) { + iconCopy.style.display = 'none'; + iconCheck.style.display = 'inline'; + setTimeout(function() { + iconCopy.style.display = 'inline'; + iconCheck.style.display = 'none'; + }, 2000); + } + }).catch(function(err) { + console.error('复制失败:', err); + }); + } + return; + } + + // --- 2. 消息操作栏:重新回答 --- + var regenBtn = target.closest('.regenerate-btn'); + if (regenBtn) { + var actionBar = regenBtn.closest('.message-actions'); + if (actionBar) { + var targetMsgId = actionBar.getAttribute('data-msg-id'); + if (window.bridge && typeof window.bridge.onRegenerateClicked === 'function') { + window.bridge.onRegenerateClicked(targetMsgId); + } + } + return; + } + + // --- 3. 消息操作栏:压缩对话(预留) --- + if (target.closest('.compress-btn')) { + console.log('[JS]: 压缩对话功能预留'); + return; + } + + // --- 4. 代码块复制 --- + var copyBtn = target.closest('.copy-btn'); + if (copyBtn) { + var wrapper = copyBtn.closest('.code-block-wrapper'); + var codeElem = wrapper ? wrapper.querySelector('code') : null; + if (codeElem) { + var rawCode = codeElem.textContent; + copyToClipboard(rawCode).then(function() { + copyBtn.classList.add('copied'); + var iconCopy = copyBtn.querySelector('.icon-copy'); + var iconCheck = copyBtn.querySelector('.icon-check'); + var btnText = copyBtn.querySelector('.btn-text'); + if (iconCopy) iconCopy.style.display = 'none'; + if (iconCheck) iconCheck.style.display = 'inline'; + if (btnText) btnText.textContent = '已复制'; + setTimeout(function() { + copyBtn.classList.remove('copied'); + if (iconCopy) iconCopy.style.display = 'inline'; + if (iconCheck) iconCheck.style.display = 'none'; + if (btnText) btnText.textContent = '复制'; + }, 2000); + }).catch(function(err) { + console.error('代码复制失败:', err); + }); + } + return; + } + + // --- 5. 代码块折叠 --- + var foldBtn = target.closest('.fold-btn'); + if (foldBtn) { + var wrapper = foldBtn.closest('.code-block-wrapper'); + var pre = wrapper ? wrapper.querySelector('.code-body') : null; + if (pre) { + var isCollapsed = pre.classList.toggle('collapsed'); + foldBtn.textContent = isCollapsed ? '展开' : '收起'; + } + return; + } + + // --- 5.5 SVG 代码块:绘制/源码 切换 --- + var svgRenderBtn = target.closest('.svg-render-btn'); + if (svgRenderBtn) { + var svgWrapper = svgRenderBtn.closest('.code-block-wrapper'); + if (svgWrapper && typeof toggleSvgRender === 'function') { + toggleSvgRender(svgWrapper, svgRenderBtn); + } + return; + } + + // --- 6. 附件卡片点击 (呼叫 Python 原生窗口) --- + var card = target.closest('.long-text-card'); + if (card) { + var attId = card.getAttribute('data-msg-id'); + var meta = attachmentMetaStore[attId]; + if (meta && window.bridge && typeof window.bridge.onAttachmentClicked === 'function') { + window.bridge.onAttachmentClicked(JSON.stringify(meta)); + } else if (typeof showContentModal === 'function') { + showContentModal(attId); + } + return; + } + + // --- 7. 分支切换:左侧上一分支 --- + var prevBranchBtn = target.closest('.prev-branch'); + if (prevBranchBtn && !prevBranchBtn.disabled) { + var selector = prevBranchBtn.closest('.branch-selector'); + if (selector) { + var msgId = selector.getAttribute('data-msg-id'); + if (window.bridge && typeof window.bridge.onBranchSwitch === 'function') { + window.bridge.onBranchSwitch(msgId, -1); + } + } + return; + } + + // --- 8. 分支切换:右侧下一分支 --- + var nextBranchBtn = target.closest('.next-branch'); + if (nextBranchBtn && !nextBranchBtn.disabled) { + var selector = nextBranchBtn.closest('.branch-selector'); + if (selector) { + var msgId = selector.getAttribute('data-msg-id'); + if (window.bridge && typeof window.bridge.onBranchSwitch === 'function') { + window.bridge.onBranchSwitch(msgId, 1); + } + } + return; + } + + // --- 9. 优先检查:是否点击了“确认删除气泡”内部 --- + var confirmPopover = target.closest('.delete-confirm-popover'); + if (confirmPopover) { + var actionBar = confirmPopover.closest('.message-actions'); + if (actionBar) { + var targetMsgId = actionBar.getAttribute('data-msg-id'); + if (window.bridge && typeof window.bridge.onDeleteMessageClicked === 'function') { + window.bridge.onDeleteMessageClicked(targetMsgId); + } + } + confirmPopover.remove(); + e.stopPropagation(); + return; + } + + // --- 10. 垃圾桶按钮点击:弹出确认气泡 --- + var delBtn = target.closest('.delete-btn'); + if (delBtn) { + document.querySelectorAll('.delete-confirm-popover').forEach(function(el) { el.remove(); }); + var popover = document.createElement('div'); + popover.className = 'delete-confirm-popover'; + var warnText = delBtn.classList.contains('user-delete-btn') ? '将连带下方对话一并删除,确认?' : '确认删除当前回答?'; + popover.innerHTML = '' + warnText + ''; + delBtn.appendChild(popover); + e.stopPropagation(); + return; + } + + }); +} +document.addEventListener('click', function(e) { + if (e.target.closest('.modal-close-btn')) { closeContentModal(); return; } + var overlay = e.target.closest('.content-modal-overlay'); + if (overlay && e.target === overlay) { closeContentModal(); return; } + document.querySelectorAll('.delete-confirm-popover').forEach(function(el) { el.remove(); }); +}); + +// ==================== 剪贴板 ==================== +function copyToClipboard(text) { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + return new Promise(function(resolve, reject) { + try { document.execCommand('copy'); resolve(); } + catch (err) { reject(err); } + finally { ta.remove(); } + }); +} + +// ==================== 模态框 ==================== +function showContentModal(msgId) { + var text = longTextStore[msgId]; + if (!text) return; + closeContentModal(); + + var overlay = document.createElement('div'); + overlay.className = 'content-modal-overlay'; + + var modal = document.createElement('div'); + modal.className = 'content-modal'; + + var header = document.createElement('div'); + header.className = 'modal-header'; + var title = document.createElement('span'); + title.textContent = '文件内容'; + var closeBtn = document.createElement('button'); + closeBtn.className = 'modal-close-btn'; + closeBtn.innerHTML = '✕'; + header.appendChild(title); + header.appendChild(closeBtn); + + var body = document.createElement('pre'); + body.className = 'modal-body'; + body.textContent = text; + + modal.appendChild(header); + modal.appendChild(body); + overlay.appendChild(modal); + document.body.appendChild(overlay); + requestAnimationFrame(function() { overlay.classList.add('visible'); }); +} + +function closeContentModal() { + var overlay = document.querySelector('.content-modal-overlay'); + if (overlay) { + overlay.classList.remove('visible'); + setTimeout(function() { overlay.remove(); }, 200); + } +} + +// ==================== 消息操作栏(只定义一次) ==================== +function createActionBar(msgId) { + var bar = document.createElement('div'); + bar.className = 'message-actions'; + bar.setAttribute('data-msg-id', msgId); + + var regenBtn = document.createElement('button'); + regenBtn.className = 'action-btn regenerate-btn'; + regenBtn.title = '重新回答'; + regenBtn.innerHTML = '' + + ''; + + var copyMsgBtn = document.createElement('button'); + copyMsgBtn.className = 'action-btn copy-msg-btn'; + copyMsgBtn.title = '复制'; + copyMsgBtn.innerHTML = '' + + '' + + '' + + ''; + + var compressBtn = document.createElement('button'); + compressBtn.className = 'action-btn compress-btn'; + compressBtn.title = '压缩对话'; + compressBtn.innerHTML = '' + + '' + + ''; + + var deleteBtn = document.createElement('button'); + deleteBtn.className = 'action-btn delete-btn'; + deleteBtn.title = '删除'; + deleteBtn.innerHTML = ''; + deleteBtn.style.position = 'relative'; + + bar.appendChild(regenBtn); + bar.appendChild(copyMsgBtn); + bar.appendChild(compressBtn); + bar.appendChild(deleteBtn); + + return bar; +} + +// ==================== 用户消息专属操作栏 ==================== +function createUserActionBar(msgId) { + var bar = document.createElement('div'); + bar.className = 'message-actions user-actions'; + bar.setAttribute('data-msg-id', msgId); + bar.style.cssText = 'display: flex; justify-content: flex-end; margin-top: 8px; gap: 8px;'; + + function createBtn(title, svgContent, isDelete) { + var btn = document.createElement('button'); + btn.className = 'action-btn ' + (isDelete ? 'delete-btn user-delete-btn' : (title.includes('重') ? 'regenerate-btn' : 'copy-msg-btn')); + btn.title = title; + btn.innerHTML = svgContent; + btn.style.cssText = 'background: transparent; border: none; cursor: pointer; padding: 6px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; transition: background 0.2s;'; + btn.onmouseenter = function() { + this.style.backgroundColor = isDelete ? '#fee2e2' : '#eaeaea'; + var svgs = this.querySelectorAll('svg'); + svgs.forEach(function(s) { + if (!s.classList.contains('icon-check')) { + s.style.stroke = isDelete ? '#ef4444' : '#333333'; + } + }); + }; + btn.onmouseleave = function() { + this.style.backgroundColor = 'transparent'; + var svgs = this.querySelectorAll('svg'); + svgs.forEach(function(s) { + if (!s.classList.contains('icon-check')) { + s.style.stroke = '#999999'; + } + }); + }; + return btn; + } + + var regenSvg = ''; + var copySvg = ''; + var delSvg = ''; + + var regenBtn = createBtn('重新提问', regenSvg, false); + var copyMsgBtn = createBtn('复制提问', copySvg, false); + var deleteBtn = createBtn('删除提问', delSvg, true); + deleteBtn.style.position = 'relative'; + + bar.appendChild(regenBtn); + bar.appendChild(copyMsgBtn); + bar.appendChild(deleteBtn); + + return bar; +} +// ==================== 消息创建 ==================== +function createMessage(msgId, role, initialText, senderName, branchInfo) { + console.log('[JS] createMessage id=' + msgId + ' role=' + role); + initialText = initialText || ''; + senderName = senderName || ''; + var welcome = document.querySelector('.welcome-screen'); + if (welcome) { + welcome.style.display = 'none'; + } + var wrapper = document.createElement('div'); + wrapper.className = 'message-wrapper ' + role; + wrapper.id = msgId; + var avatar = document.createElement('div'); + avatar.className = 'avatar'; + avatar.innerText = role === 'user' ? 'U' : 'AI'; + var content = document.createElement('div'); + content.className = 'message-content'; + var nameLabel = document.createElement('div'); + nameLabel.className = 'sender-name'; + nameLabel.innerText = senderName || (role === 'user' ? 'You' : 'Assistant'); + + if (role === 'assistant' && branchInfo && branchInfo.total > 1) { + var branchUI = document.createElement('div'); + branchUI.className = 'branch-selector'; + branchUI.setAttribute('data-msg-id', msgId); + var prevBtn = document.createElement('button'); + prevBtn.className = 'branch-btn prev-branch'; + prevBtn.innerHTML = '❮'; + prevBtn.disabled = (branchInfo.current <= 1); + var textSpan = document.createElement('span'); + textSpan.className = 'branch-text'; + textSpan.innerText = branchInfo.current + ' / ' + branchInfo.total; + var nextBtn = document.createElement('button'); + nextBtn.className = 'branch-btn next-branch'; + nextBtn.innerHTML = '❯'; + nextBtn.disabled = (branchInfo.current >= branchInfo.total); + branchUI.appendChild(prevBtn); + branchUI.appendChild(textSpan); + branchUI.appendChild(nextBtn); + nameLabel.appendChild(branchUI); + } + content.appendChild(nameLabel); + var replyDiv = document.createElement('div'); + replyDiv.className = 'reply-content markdown-body'; + + if (initialText) { + if (role === 'user') { + replyDiv.innerText = initialText; + } else { + // 🆕 助手纯文本消息(无时间线历史路径)也包进 md-segment → 与时间线路径同样有浅灰气泡背景 + var _tHtml = typeof marked !== 'undefined' ? safeHtml(marked.parse(initialText)) : safeHtml(initialText); + replyDiv.innerHTML = '
' + _tHtml + '
'; + } + } + content.appendChild(replyDiv); + if (role === 'assistant') { + if (typeof createActionBar === 'function') { + content.appendChild(createActionBar(msgId)); + } + if (typeof finalContentStore !== 'undefined') { + finalContentStore[msgId] = initialText || ''; + } + } else if (role === 'user') { + if (typeof createUserActionBar === 'function') { + content.appendChild(createUserActionBar(msgId)); + } + if (typeof finalContentStore !== 'undefined') { + finalContentStore[msgId] = initialText || ''; + } + } + wrapper.appendChild(avatar); + wrapper.appendChild(content); + + if (typeof chatContainer !== 'undefined') { + chatContainer.appendChild(wrapper); + } else { + document.getElementById('chat-container').appendChild(wrapper); + } + if (typeof messageBuffer !== 'undefined') { + messageBuffer[msgId] = { reasoning: '', content: initialText, follow: true }; + } + if (role === 'assistant') { + wrapper.classList.add('streaming'); + } + diagEvent('createMessage', { id: msgId, role: role, stream: role === 'assistant' }); + if (typeof softScroll === 'function' && !window.__rwPageRendering) { + softScroll(); + } +} + +// ==================== 长文本消息 ==================== +function createLongMessage(msgId, role, fullText, senderName, sizeKb) { + senderName = senderName || 'You'; + var welcome = document.querySelector('.welcome-screen'); + if (welcome) welcome.style.display = 'none'; + + longTextStore[msgId] = fullText; + var lineCount = fullText.split('\n').length; + + var wrapper = document.createElement('div'); + wrapper.className = 'message-wrapper ' + role; + wrapper.id = msgId; + + var avatar = document.createElement('div'); + avatar.className = 'avatar'; + avatar.innerText = 'U'; + + var content = document.createElement('div'); + content.className = 'message-content'; + + var nameLabel = document.createElement('div'); + nameLabel.className = 'sender-name'; + nameLabel.innerText = senderName; + content.appendChild(nameLabel); + + content.appendChild(buildAttachmentCard(msgId, fullText, sizeKb, lineCount)); + + wrapper.appendChild(avatar); + wrapper.appendChild(content); + chatContainer.appendChild(wrapper); + + messageBuffer[msgId] = { reasoning: '', content: '', follow: true }; + if (!window.__rwPageRendering) softScroll(); +} + +// ==================== 带附件的用户消息 ==================== +function createUserMessageWithAttachments(msgId, plainText, attachments) { + var welcome = document.querySelector('.welcome-screen'); + if (welcome) welcome.style.display = 'none'; + + var wrapper = document.createElement('div'); + wrapper.className = 'message-wrapper user'; + wrapper.id = msgId; + + var avatar = document.createElement('div'); + avatar.className = 'avatar'; + avatar.innerText = 'U'; + + var content = document.createElement('div'); + content.className = 'message-content'; + + var nameLabel = document.createElement('div'); + nameLabel.className = 'sender-name'; + nameLabel.innerText = 'You'; + content.appendChild(nameLabel); + + var cardsContainer = document.createElement('div'); + cardsContainer.className = 'attachments-container'; + for (var i = 0; i < attachments.length; i++) { + var att = attachments[i]; + var attId = msgId + '-att-' + i; + longTextStore[attId] = att.content; + attachmentMetaStore[attId] = att; + cardsContainer.appendChild(buildAttachmentCard(attId, att.content, att.size_kb, att.lines, att.name, att.type, att.pages, att.mode)); + } + content.appendChild(cardsContainer); + + if (plainText && plainText.trim()) { + var replyDiv = document.createElement('div'); + replyDiv.className = 'reply-content markdown-body'; + replyDiv.innerText = plainText; + content.appendChild(replyDiv); + } + + content.appendChild(createUserActionBar(msgId)); + + wrapper.appendChild(avatar); + wrapper.appendChild(content); + chatContainer.appendChild(wrapper); + + messageBuffer[msgId] = { reasoning: '', content: '', follow: true }; + if (!window.__rwPageRendering) softScroll(); +} + +// ==================== 附件卡片构建器 ==================== +function buildAttachmentCard(id, text, sizeKb, lineCount, fileName, attType, pages, mode) { + var card = document.createElement('div'); + card.className = 'long-text-card'; + card.setAttribute('data-msg-id', id); + + var icon = document.createElement('div'); + icon.className = 'card-icon'; + icon.innerHTML = '' + + '' + + '' + + '' + + ''; + if (attType === 'pdf') { + icon.innerHTML = '' + + '' + + '' + + '' + + ''; + } + + var info = document.createElement('div'); + info.className = 'card-info'; + var cardTitle = document.createElement('div'); + cardTitle.className = 'card-title'; + if (fileName) { + cardTitle.textContent = fileName; + } else { + var preview = text.substring(0, 40).replace(/\n/g, ' '); + if (text.length > 40) preview += '...'; + cardTitle.textContent = preview; + } + var cardMeta = document.createElement('div'); + cardMeta.className = 'card-meta'; + if (attType === 'pdf') { + if (mode === 'image') { + cardMeta.textContent = 'PDF 图片 · ' + sizeKb + ' KB · ' + (pages || 0) + ' 页 · ' + (lineCount || 0) + ' 张'; + } else { + cardMeta.textContent = 'PDF 文本 · ' + sizeKb + ' KB · ' + (pages || 0) + ' 页'; + } + } else { + cardMeta.textContent = (fileName ? '' : 'TXT · ') + sizeKb + ' KB · ' + lineCount + ' 行'; + } + + info.appendChild(cardTitle); + info.appendChild(cardMeta); + card.appendChild(icon); + card.appendChild(info); + return card; +} + +// ==================== SVG 代码块:绘制/源码 切换 ==================== +function toggleSvgRender(wrapper, btn) { + var codeElem = wrapper.querySelector('.code-body code'); + var foldBtn = wrapper.querySelector('.fold-btn'); + var isRenderMode = wrapper.classList.contains('svg-mode'); + + if (isRenderMode) { + wrapper.classList.remove('svg-mode'); + btn.textContent = '绘制'; + btn.classList.remove('active'); + if (foldBtn) foldBtn.disabled = false; + var renderBody = wrapper.querySelector('.svg-render-body'); + if (renderBody) renderBody.remove(); + } else { + if (!codeElem) return; + var raw = codeElem.textContent; + var safe = ''; + if (typeof DOMPurify !== 'undefined') { + safe = DOMPurify.sanitize(raw, { USE_PROFILES: { svg: true, svgCustom: true } }); + } + if (!safe) return; + var body = document.createElement('div'); + body.className = 'svg-render-body'; + body.innerHTML = safe; + wrapper.appendChild(body); + wrapper.classList.add('svg-mode'); + btn.textContent = '源码'; + btn.classList.add('active'); + if (foldBtn) foldBtn.disabled = true; + } +} + +// ==================== 流式渲染(长上下文优化) ==================== +// 🌟 核心优化:稳定前缀增量渲染 + rAF 批量,替代「每个 token 全量 marked.parse」。 +// +// 原理: +// - 把已流式内容切成 [稳定前缀 | 尾部] 两段。 +// - 稳定前缀 = 可以独立解析的完整 markdown 块(段落以空行结束、 +// 代码围栏已闭合、行内代码反引号配对),解析一次后固化进 .md-stable, +// 之后永不再碰。 +// - 尾部(正在生长的那个块)每个 rAF 帧重渲一次。 +// - 多个 token 在一帧内到达时只渲一次(原来每 token 一次全量 parse → O(n²); +// 现在每帧只 parse「尾部小段」+ 偶尔 parse「新完成的块」→ 近似 O(n))。 +// +// function computeSafeLen(s, stableLen) { +// 返回 s 中最长「可固化前缀」长度(> stableLen 才算有进展)。 +// 规则: +// 1) 只算到最后一个完整行(末尾半行永远留尾部) +// 2) 代码围栏(```/~~~)闭合前,围栏及其所在块全部留尾部 +// 3) 块边界 = 围栏外的空行、或刚闭合的围栏之后 +// 4) 稳定区内行内代码反引号必须成对(不成对则回退到该行行首) +// } +function computeSafeLen(s, stableLen) { + var lastNl = s.lastIndexOf('\n'); + var limit = lastNl >= 0 ? lastNl + 1 : 0; + if (limit <= stableLen) return 0; + + var inFence = false; + var candidate = 0; // 最新的块边界位置(行首索引) + var pos = 0; + while (pos < limit) { + var nl = s.indexOf('\n', pos); + if (nl === -1 || nl >= limit) break; + var line = s.substring(pos, nl); + var next = nl + 1; + var fence = /^\s*(```|~~~)/.test(line); + if (!inFence && fence) { + inFence = true; + } else if (inFence && fence) { + inFence = false; + candidate = next; // 围栏闭合后是块边界 + } else if (!inFence && line.trim() === '') { + candidate = next; // 空行是块边界 + } + pos = next; + } + if (candidate > stableLen) { + var seg = s.substring(0, candidate); + var bt = (seg.match(/`/g) || []).length; + if (bt % 2 === 1) { + // 行内代码未闭合 → 回退到该段最后一行的行首 + var ln = seg.lastIndexOf('\n'); + candidate = ln >= 0 ? ln + 1 : 0; + } + // 🌟 公式感知:稳定区内有未闭合公式起点 → 回退到起点之前 + //(防公式前半被固化为纯文本、后半在尾部永远拼不上) + var unc = findUnclosedMathFrom(seg); + if (unc > 0) candidate = Math.min(candidate, unc); + } + return candidate > stableLen ? candidate : 0; +} + +// 初始化/获取某个容器 div 的增量渲染状态 +function mdStateOf(el) { + if (!el.__mdState) { + el.innerHTML = ''; + var stableEl = document.createElement('div'); + stableEl.className = 'md-stable'; + var tailEl = document.createElement('div'); + tailEl.className = 'md-tail'; + el.appendChild(stableEl); + el.appendChild(tailEl); + el.__mdState = { stableEl: stableEl, tailEl: tailEl, + stableLen: 0, lastTail: null, hlCount: 0 }; + } + return el.__mdState; +} + +// 对新固化进 .md-stable 的代码块做渐进高亮(只处理新增的) +function highlightNewCode(st) { + if (typeof hljs === 'undefined') return; + var codes = st.stableEl.querySelectorAll('pre code'); + for (var i = st.hlCount; i < codes.length; i++) { + try { hljs.highlightElement(codes[i]); } catch (e) {} + } + st.hlCount = codes.length; +} + +// 增量渲染:content 全量文本 → [固化段追加 + 尾部重渲] +function renderMarkdownStreaming(el, content) { + var st = mdStateOf(el); + var safe = computeSafeLen(content, st.stableLen); + if (safe > st.stableLen) { + var seg = content.slice(st.stableLen, safe); + st.stableEl.insertAdjacentHTML('beforeend', safeHtml(marked.parse(seg))); + st.stableLen = safe; + highlightNewCode(st); + } + var tail = content.slice(st.stableLen); + if (tail !== st.lastTail) { + st.tailEl.innerHTML = safeHtml(marked.parse(tail)); + st.lastTail = tail; + } +} + +// ==================== 时间线渲染(思考 / 工具 / 正文 按事件顺序穿插) ==================== +// +// 🌟 修复:原来「所有思考堆进顶部一个气泡、所有正文挤进一个块、 +// 工具气泡全堆在末尾」。现在 .reply-content 就是时间线容器, +// 思考块 / 正文段 / 工具 chip 按事件到达顺序插入: +// +// .reply-content +// .think-block ← 思考段 1 +// .tool-chip ← 工具调用 1 +// .md-segment ← 正文段 1(工具前模型说的话) +// .think-block ← 思考段 2 +// .md-segment ← 正文段 2(最终回答) +// .streaming-typing ← 光标(永远最后) + +var CHEVRON_SVG = ''; + +function makeChevron() { + var span = document.createElement('span'); + span.innerHTML = CHEVRON_SVG; + return span.firstElementChild; +} + +function makeThinkBlock(openNow, label) { + var block = document.createElement('details'); + block.className = 'think-block'; + block.open = !!openNow; + var summary = document.createElement('summary'); + summary.appendChild(makeChevron()); + var lab = document.createElement('span'); + lab.className = 'think-label'; + lab.textContent = label; + summary.appendChild(lab); + var tc = document.createElement('div'); + tc.className = 'think-content markdown-body'; + block.appendChild(summary); + block.appendChild(tc); + return block; +} + +// 惰性把消息的 .reply-content 变成时间线容器 +function ensureTimeline(wrapper, buf) { + var replyDiv = wrapper.querySelector('.reply-content'); + if (!replyDiv) return null; + if (!buf.timeline) { + buf.timeline = true; + buf.textSegs = []; + buf.thinkSegs = []; + var typing = document.createElement('span'); + typing.className = 'streaming-typing'; + typing.textContent = 'Chasing a shining star '; + var dotEl = document.createElement('span'); + dotEl.className = 'typing-dot'; + dotEl.textContent = '●'; + typing.appendChild(dotEl); + replyDiv.appendChild(typing); + buf.typingEl = typing; + } + return replyDiv; +} + +// ==================== 流式光标:Chasing a shining star ==================== +// 光标(.streaming-typing)自消息第一个时间线事件出现即显示并循环脉冲(同款蓝 #3b82f6), +// 思考/工具/压缩/正文全程保持;finishMessage 时移除。无休眠态。 + +// 「思考结束」时立即就地固化进行中的思考块(不等消息结束)—— 激发→结束的态转换点: +// 一旦来了非思考事件(正文 token / 工具开始 / 压缩开始),当前思考段立即: +// 最终渲染(未闭合代码围栏补围栏)→ 删 __buf/__mdState 冻结 DOM → 去 .streaming-think +// (蓝色脉冲消失)→ 折叠 → 标签「已完成深度思考」,并从 thinkSegs 摘除(渲染不再碰它)。 +// 与 finishTimelineMessage 的固化逻辑同款(幂等兑底保留)。 +function finalizeOpenThinkingBlocks(msgId) { + var buf = messageBuffer[msgId]; + if (!buf || !buf.thinkSegs) return; + for (var i = buf.thinkSegs.length - 1; i >= 0; i--) { + var tc = buf.thinkSegs[i]; + var block = tc.closest ? tc.closest('.think-block') : null; + if (!block || !block.classList.contains('streaming-think')) continue; + var c = tc.__buf || ''; + if ((c.match(/```/g) || []).length % 2 !== 0) { + tc.innerHTML = safeHtml('
' + + marked.parse(c + '\n```') + '
'); + } else if (c && tc.innerHTML === '') { + // rAF 还没跑过(极快结束)→ 一次性全量解析 + tc.innerHTML = safeHtml('
' + + marked.parse(c) + '
'); + } + delete tc.__mdState; + delete tc.__buf; + block.classList.remove('streaming-think'); + if (block.open) block.open = false; + var lab = block.querySelector('.think-label'); + if (lab) lab.textContent = '已完成深度思考'; + buf.thinkSegs.splice(i, 1); + } +} + +// rAF 批量:一帧内到达的所有 token 只触发一次渲染 +// 兜底:rAF 依赖合成器 BeginFrame(窗口隐藏/远程桌面/GPU 驱动异常时可能长时间 +// 不出帧)→ 40ms 后若 rAF 仍未触发,用 setTimeout 强制同步渲染(≈25fps 下限)。 +// 保证流式正文在任何渲染环境下都能实时显示。 +function scheduleStreamingRender(msgId) { + var buf = messageBuffer[msgId]; + if (!buf || buf.raf) return; + buf.raf = requestAnimationFrame(function() { + buf.raf = 0; + if (buf.rafDue) { clearTimeout(buf.rafDue); buf.rafDue = 0; } + buf.lastRenderAt = Date.now(); + doStreamingRender(msgId); + }); + buf.rafDue = setTimeout(function() { + if (buf.raf) { + buf.raf = 0; + buf.rafDue = 0; + buf.lastRenderAt = Date.now(); + doStreamingRender(msgId); + } + }, 40); +} + +// 若上一帧迟迟未触发(>60ms 无渲染)→ 放弃 rAF,本 token 直接同步渲染 +function forceRenderIfStale(msgId) { + var buf = messageBuffer[msgId]; + if (buf && buf.raf && buf.lastRenderAt && Date.now() - buf.lastRenderAt > 60) { + buf.raf = 0; + if (buf.rafDue) { clearTimeout(buf.rafDue); buf.rafDue = 0; } + buf.lastRenderAt = Date.now(); + doStreamingRender(msgId); + } +} + +// ★ 终极兜底:同步渲染节流(≈33fps 上限)。 +// 内容可见性不再依赖 rAF / 页面定时器 —— token 由 Python runJavaScript 送达, +// 本函数在同一 JS 任务内直接把内容写进 DOM。即使页面被判定隐藏 +// (rAF 停发 + timer 钳制 1Hz)也能逐 token 实时显示。 +var __lastSyncRender = {}; +function syncRenderThrottled(msgId, minGapMs) { + var now = Date.now(); + if (now - (__lastSyncRender[msgId] || 0) < (minGapMs == null ? 30 : minGapMs)) return; + __lastSyncRender[msgId] = now; + var buf = messageBuffer[msgId]; + if (!buf) return; + doStreamingRender(msgId); +} + +// Qt 看门狗入口:强制渲染(幂等,尾部无变化时开销极小) +function forceRenderNow(msgId) { + var _frNow = Date.now(); + if (!window.__frLast || _frNow - window.__frLast > 1000) { + window.__frLast = _frNow; + console.log('[JS] forceRenderNow id=' + msgId + ' (看门狗)'); + } + if (messageBuffer[msgId]) doStreamingRender(msgId); +} + +function cancelStreamingRender(msgId) { + var buf = messageBuffer[msgId]; + if (!buf) return; + if (buf.raf) { cancelAnimationFrame(buf.raf); buf.raf = 0; } + if (buf.rafDue) { clearTimeout(buf.rafDue); buf.rafDue = 0; } +} + +// 每帧一次:增量渲染时间线上所有段 + 跟随滚动 +// 兜底:任何渲染异常(marked 解析/HTML 注入)→ 对该段强制全量重解析, +// 保证流式正文永不因单次解析异常而永久空白。 +function _fullRenderSegment(el, c, isThink) { + if (!c) return; + if (isThink) { + el.innerHTML = safeHtml('
' + marked.parse(c) + '
'); + } else { + el.innerHTML = safeHtml(marked.parse(c)); + } + delete el.__mdState; // 增量状态作废,下一帧从零重建 +} +function doStreamingRender(msgId) { + window.__renderN = (window.__renderN || 0) + 1; + if (window.__renderN % 10 === 1) { + console.log('[JS] render#' + window.__renderN + ' 开始 id=' + msgId); + } + var buf = messageBuffer[msgId]; + if (!buf) return; + var wrapper = document.getElementById(msgId); + if (!wrapper) return; + var wasNearBottom = isNearBottom(); + + if (buf.timeline) { + var i, seg, tc; + for (i = 0; i < buf.textSegs.length; i++) { + seg = buf.textSegs[i]; + try { + renderMarkdownStreaming(seg, seg.__buf || ''); + } catch (e) { + try { _fullRenderSegment(seg, seg.__buf || '', false); } catch (e2) {} + } + } + for (i = 0; i < buf.thinkSegs.length; i++) { + tc = buf.thinkSegs[i]; + try { + renderMarkdownStreaming(tc, tc.__buf || ''); + tc.scrollTop = tc.scrollHeight; + } catch (e) { + try { _fullRenderSegment(tc, tc.__buf || '', true); } catch (e2) {} + } + } + } else if (buf.content) { + var replyDiv = wrapper.querySelector('.reply-content'); + if (replyDiv) { + try { + renderMarkdownStreaming(replyDiv, buf.content); + } catch (e) { + try { _fullRenderSegment(replyDiv, buf.content, false); } catch (e2) {} + } + } + } + + // 跟随滚动(每帧最多一次;用户上滚自动暂停跟随) + if (wasNearBottom && buf.follow !== false) { + var anchor = document.getElementById('scroll-anchor'); + if (anchor) anchor.scrollIntoView({ behavior: 'auto', block: 'end' }); + } +} + +function appendReasoning(msgId, token) { + if (!messageBuffer[msgId]) return; + var buf = messageBuffer[msgId]; + buf.reasoning += token; + var wrapper = document.getElementById(msgId); + if (!wrapper) { diagEvent('appendReasoning', 'NO_WRAPPER:' + msgId); return; } + window.__diag.thinkN++; + if (window.__diag.thinkN === 1 || window.__diag.thinkN % 25 === 0) { + diagEvent('appendReasoning', 'n=' + window.__diag.thinkN); + } + wrapper.classList.add('streaming'); + var tl = ensureTimeline(wrapper, buf); + if (!tl) return; + // 续接规则:紧邻上一个块是「进行中」的思考块才续写,否则新开一段 + var last = buf.typingEl.previousElementSibling; + var tc = (last && last.classList && last.classList.contains('streaming-think')) + ? last.querySelector('.think-content') : null; + if (!tc) { + // 默认收起;进行中由 .streaming-think 蓝色呼吸动画提示(CSS) + var block = makeThinkBlock(false, '深度思考'); + block.classList.add('streaming-think'); + tl.insertBefore(block, buf.typingEl); + tc = block.querySelector('.think-content'); + buf.thinkSegs.push(tc); + } + tc.__buf = (tc.__buf || '') + token; + console.log('[JS] think#' + window.__diag.thinkN + ' 段buf=' + tc.__buf.length + 'c dom=' + (tc.textContent || '').length + 'c h=' + tc.offsetHeight); + syncRenderThrottled(msgId); // ★ 同步通道 + scheduleStreamingRender(msgId); +} + +function appendToken(msgId, token) { + if (!messageBuffer[msgId]) return; + var buf = messageBuffer[msgId]; + buf.content += token; + var wrapper = document.getElementById(msgId); + if (!wrapper) { diagEvent('appendToken', 'NO_WRAPPER:' + msgId); return; } + window.__diag.tokenN++; + if (window.__diag.tokenN === 1 || window.__diag.tokenN % 25 === 0) { + diagEvent('appendToken', 'n=' + window.__diag.tokenN); + } + wrapper.classList.add('streaming'); + var tl = ensureTimeline(wrapper, buf); + if (!tl) return; + finalizeOpenThinkingBlocks(msgId); // 首个正文 token → 思考必然已结束:思考段就地定格「已完成深度思考」 + // 续接规则:紧邻上一个块是正文段才续写,否则新开一段 + var last = buf.typingEl.previousElementSibling; + var seg = (last && last.classList && last.classList.contains('md-segment')) + ? last : null; + if (!seg) { + seg = document.createElement('div'); + seg.className = 'md-segment markdown-body'; + buf.textSegs.push(seg); + seg.__buf = token; + // ★ 根因修复(2026-07):Chromium 布局失效 bug —— 空 .md-segment 进文档时 + // 匹配 :empty{display:none},随后写入内容不重排,盒永久 0x0(正文不可见, + // 切会话重渲因"带内容插入"而正常)。必须先同步渲染、带内容再插入。 + mdStateOf(seg); + renderMarkdownStreaming(seg, seg.__buf); + tl.insertBefore(seg, buf.typingEl); + } else { + seg.__buf = (seg.__buf || '') + token; + } + console.log('[JS] token#' + window.__diag.tokenN + ' 段buf=' + seg.__buf.length + 'c dom=' + (seg.textContent || '').length + 'c 段数=' + buf.textSegs.length + ' h=' + seg.offsetHeight); + if (window.__diag.tokenN === 1 || window.__diag.tokenN % 25 === 0) { + diagEvent('tokenDOM', { buf: seg.__buf.length, dom: (seg.textContent || '').length }); + } + syncRenderThrottled(msgId); // ★ 同步通道:token 到 → 内容必现 + scheduleStreamingRender(msgId); // rAF 通道:更平滑(环境允许时) +} + +// ==================== 完成消息 ==================== +function finishMessage(msgId) { + console.log('[JS] finishMessage id=' + msgId); + var wrapper = document.getElementById(msgId); + if (!wrapper) { delete messageBuffer[msgId]; diagEvent('finish', 'NO_WRAPPER:' + msgId); return; } + diagEvent('finish', { id: msgId }); + var buf = messageBuffer[msgId]; + cancelStreamingRender(msgId); + + if (buf && buf.timeline) { + // ---- 时间线路径:各段原地冻结(不重排、不重渲) ---- + finishTimelineMessage(wrapper, buf); + } else { + // ---- 静态路径(历史 / 非流式) ---- + // --- A. 修复未闭合 Markdown(围栏奇数 → 补围栏后全量重渲一次)--- + var content = buf ? buf.content : ''; + var replyDiv = wrapper.querySelector('.reply-content'); + var codeBlockCount = (content.match(/```/g) || []).length; + if (codeBlockCount % 2 !== 0) { + content += '\n```'; + if (buf) buf.content = content; + if (replyDiv) { + delete replyDiv.__mdState; + replyDiv.innerHTML = safeHtml(marked.parse(content)); + } + } else if (replyDiv && replyDiv.__mdState) { + // 正常路径:增量 DOM 已经完整,零全量重渲,保留用户的折叠状态 + delete replyDiv.__mdState; + } + + // --- D. 折叠思考框(压缩气泡 .compaction-think 有自己名字,不碰) --- + var thinkBlock = wrapper.querySelector('.think-block:not(.compaction-think)'); + if (thinkBlock && thinkBlock.open) { + thinkBlock.open = false; + var lab = thinkBlock.querySelector('.think-label'); + if (lab) lab.textContent = '已完成深度思考'; + } + if (thinkBlock) { + var tc = thinkBlock.querySelector('.think-content'); + if (tc && tc.__mdState) delete tc.__mdState; + } + } + + // --- B. 折叠代码块过渡 --- + var collapsedBlocks = wrapper.querySelectorAll('.code-body.collapsed'); + collapsedBlocks.forEach(function(block) { + block.classList.add('scroll-locked'); + block.scrollTop = block.scrollHeight; + }); + + // --- C. 移除 streaming(操作栏自动显示) --- + wrapper.classList.remove('streaming'); + // 🆕 P1-01:流结束 → 解除渲染窗口流式保护(恢复可裁剪) + if (typeof rwState !== 'undefined' && rwState && typeof RenderWindowState !== 'undefined') { + RenderWindowState.streamFinished(rwState, msgId); + } + + // --- E. 代码高亮(增量渲染已渐进高亮过的会被跳过?这里统一兜底一次) --- + wrapper.querySelectorAll('pre code').forEach(function(block) { + try { hljs.highlightElement(block); } catch(e) {} + }); + + // --- F. 等重排完成 --- + // 🆕 P1-01:在调度时刻捕获渲染批次标志(延迟回调触发时批次已结束,不能届时再读) + var rwBatchSuppressed = !!window.__rwPageRendering; + requestAnimationFrame(function() { + setTimeout(function() { + collapsedBlocks.forEach(function(block) { + if (block.classList.contains('collapsed')) { + block.classList.remove('scroll-locked'); + block.scrollTop = block.scrollHeight; + } + }); + // 🆕 P1-01:窗口换页/初始窗口批次渲染期间不自动滚底(锚点由 rw 控制器恢复) + if (!rwBatchSuppressed) { + var anchor = document.getElementById('scroll-anchor'); + if (anchor) anchor.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + }, 50); + }); + + // --- G. 更新最终内容存储 + 清理帧任务 --- + if (buf) { + finalContentStore[msgId] = buf.content; + if (buf.raf) { cancelAnimationFrame(buf.raf); buf.raf = 0; } + } + delete messageBuffer[msgId]; + + // --- H. 刷新自定义滚动条 --- + reportWebScroll(); +} + +// 时间线路径的收尾:逐段冻结 +function finishTimelineMessage(wrapper, buf) { + console.log('[JS] finishTimeline 正文段=' + (buf ? buf.textSegs.length : 0) + + ' 思考段=' + (buf ? buf.thinkSegs.length : 0) + + ' 正文总量=' + (buf && buf.content ? buf.content.length : 0) + 'c'); + // 收尾:取消未触发的 rAF / 兜底定时器 + if (buf.raf) { cancelAnimationFrame(buf.raf); buf.raf = 0; } + if (buf.rafDue) { clearTimeout(buf.rafDue); buf.rafDue = 0; } + // 正文段:围栏奇数 → 补围栏后重渲该段;偶数 → 增量 DOM 已完整,只固化尾部 + buf.textSegs.forEach(function(seg) { + var c = seg.__buf || ''; + if ((c.match(/```/g) || []).length % 2 !== 0) { + seg.innerHTML = safeHtml(marked.parse(c + '\n```')); + } else if (seg.__mdState) { + renderMarkdownStreaming(seg, c); + delete seg.__mdState; + } else if (c) { + // rAF 还没跑过(极快响应 / offscreen)→ 一次性全量解析 + seg.innerHTML = safeHtml(marked.parse(c)); + } + delete seg.__buf; + }); + // 思考块:固化 + 折叠 + buf.thinkSegs.forEach(function(tc) { + var c = tc.__buf || ''; + if ((c.match(/```/g) || []).length % 2 !== 0) { + tc.innerHTML = safeHtml(marked.parse(c + '\n```')); + } else if (c && tc.innerHTML === '') { + // rAF 还没跑过(极快响应 / offscreen)→ 一次性全量解析 + tc.innerHTML = safeHtml('
' + + marked.parse(c) + '
'); + } + delete tc.__mdState; + delete tc.__buf; + var block = tc.closest('.think-block'); + if (block) { + block.classList.remove('streaming-think'); + if (block.open) block.open = false; + var lab = block.querySelector('.think-label'); + if (lab) lab.textContent = '已完成深度思考'; + } + }); + // 未结束的工具 chip(中止/出错) + wrapper.querySelectorAll('.tool-chip.streaming').forEach(function(ch) { + ch.classList.remove('streaming'); + var st = ch.querySelector('.tool-chip-status'); + if (st && st.classList.contains('running')) { + st.classList.remove('running'); + st.classList.add('fail'); + st.textContent = '✗ 已中断'; + } + }); + // 移除流式光标 + if (buf.typingEl && buf.typingEl.parentNode) { + buf.typingEl.parentNode.removeChild(buf.typingEl); + } + // finish 后正文段体检(人肉 debug:确认正文盒是否可见) + buf.textSegs.forEach(function(seg, i) { + try { + var cs = window.getComputedStyle(seg); + var r = seg.getBoundingClientRect(); + console.log('[JS] finishAudit 正文段' + i + + ' h=' + seg.offsetHeight + ' connected=' + seg.isConnected + + ' rect=' + Math.round(r.top) + '/' + Math.round(r.height) + + ' display=' + cs.display + ' visibility=' + cs.visibility + + ' opacity=' + cs.opacity + + ' parent=' + (seg.parentElement ? seg.parentElement.className : 'NULL')); + } catch (e) { + console.log('[JS] finishAudit 正文段' + i + ' 异常 ' + e); + } + }); +} + +// ==================== 错误注入 ==================== +function showError(msgId, errorText) { + var wrapper = document.getElementById(msgId); + if (!wrapper) return; + var contentDiv = wrapper.querySelector('.message-content'); + + if (contentDiv.querySelector('.system-error')) return; + + var errDiv = document.createElement('div'); + errDiv.className = 'system-error'; + errDiv.innerText = errorText; + + var actionBar = contentDiv.querySelector('.message-actions'); + if (actionBar) { + contentDiv.insertBefore(errDiv, actionBar); + } else { + contentDiv.appendChild(errDiv); + } + + wrapper.classList.remove('streaming'); + + if (messageBuffer[msgId]) { + finalContentStore[msgId] = messageBuffer[msgId].content; + } + + softScroll(); +} + +// ==================== 工具执行气泡(pi tool_execution_* 事件,按 call_id 对号入座) ==================== +// 浅色气泡,类似思考气泡:默认收起,摘要只显示「执行了什么」;点开展开参数+输出 +function _toolBrief(name, args) { + // 从参数里提取一行人类可读的简述(如 bash 的 command、read 的 path) + try { + var a = JSON.parse(args); + var brief = a.command || a.path || a.file || a.pattern || a.query || ''; + if (typeof brief !== 'string') brief = JSON.stringify(brief); + brief = String(brief).replace(/\s+/g, ' ').trim(); + if (brief.length > 46) brief = brief.slice(0, 46) + '…'; + return brief; + } catch (e) { + return ''; + } +} + +function _findChip(wrapper, callId) { + if (callId) { + var byId = wrapper.querySelector('.tool-chip[data-call-id="' + callId + '"]'); + if (byId) return byId; + } + // 兜底:没带 call_id 的旧事件 → 最后一个还在流式中的 chip + var chips = wrapper.querySelectorAll('.tool-chip.streaming'); + return chips.length ? chips[chips.length - 1] : null; +} + +// 工具 chip 构造器(流式/恢复/历史 三种场景共用) +// opts: {streaming: bool, ok: null|true|false, result: str} +function buildToolChip(callId, name, args, opts) { + opts = opts || {}; + var chip = document.createElement('details'); + chip.className = 'tool-chip' + (opts.streaming ? ' streaming' : ''); + chip.setAttribute('data-tool', name); + chip.setAttribute('data-call-id', callId || ''); + chip.open = false; // 默认收起,只显示执行了什么 + + var sum = document.createElement('summary'); + sum.appendChild(makeChevron()); + var icon = document.createElement('span'); + icon.className = 'tool-chip-icon'; + icon.textContent = '⚙'; + var nameSpan = document.createElement('span'); + nameSpan.className = 'tool-chip-name'; + nameSpan.textContent = name; + var brief = document.createElement('span'); + brief.className = 'tool-chip-brief'; + brief.textContent = _toolBrief(name, args); + var status = document.createElement('span'); + if (opts.ok === null || opts.ok === undefined) { + status.className = 'tool-chip-status running'; + status.textContent = '执行中…'; + } else if (opts.ok) { + status.className = 'tool-chip-status ok'; + status.textContent = '✓ 完成'; + if (opts.result) { + var fl = String(opts.result).split('\n')[0].replace(/\s+/g, ' ').trim(); + if (fl.length > 60) fl = fl.slice(0, 60) + '…'; + if (fl) { brief.textContent = fl; brief.classList.add('done'); } + } + } else { + status.className = 'tool-chip-status fail'; + status.textContent = '✗ 失败'; + } + sum.appendChild(icon); + sum.appendChild(nameSpan); + if (brief.textContent) sum.appendChild(brief); + sum.appendChild(status); + + var body = document.createElement('div'); + body.className = 'tool-chip-body'; + if (args) { + var argsLabel = document.createElement('div'); + argsLabel.className = 'tool-chip-label'; + argsLabel.textContent = '参数'; + var argsPre = document.createElement('pre'); + argsPre.className = 'tool-chip-pre tool-chip-args'; + argsPre.textContent = String(args).length > 1500 ? String(args).slice(0, 1500) + '…' : String(args); + body.appendChild(argsLabel); + body.appendChild(argsPre); + } + var liveLabel = document.createElement('div'); + liveLabel.className = 'tool-chip-label'; + liveLabel.textContent = '输出'; + var live = document.createElement('pre'); + live.className = 'tool-chip-pre tool-chip-live'; + body.appendChild(liveLabel); + body.appendChild(live); + + chip.appendChild(sum); + chip.appendChild(body); + chip.__sum = sum; + chip.__liveBuf = ''; + if (opts.ok === null || opts.ok === undefined || !opts.result) { + // 运行中(或无结果):输出区暂隐 + live.textContent = '执行中…'; + live.style.display = 'none'; + liveLabel.style.display = 'none'; + } else { + // 已出结果:长结果默认尾部预览 + 「展开完整输出」按钮; + // 耗时/超时徽章进摘要行 + _renderToolResultBody(chip, String(opts.result)); + _updateChipTimingSummary(chip, String(opts.result)); + } + return chip; +} + +// 从 bash 结果解析耗时与命中超时: +// 成功: "$ cmd\n\n[exit 0] (1.2s)" 超时: "命令超时(>120s)已终止" +function _parseToolTiming(resultText) { + var s = String(resultText || ''); + var dur = null, timeout = null; + var m = /\[exit -?\d+\]\s*\((\d+(?:\.\d+)?)s\)/.exec(s); + if (m) dur = parseFloat(m[1]); + var t = /命令超时(>\s*(\d+)s)/.exec(s); // 工具输出用全角括号 + if (t) timeout = parseInt(t[1], 10); + return { dur: dur, timeout: timeout }; +} + +// 摘要行插入 耗时/超时 徽章(收起时也能看到执行了多久、是否超时) +function _updateChipTimingSummary(chip, resultText) { + var tm = _parseToolTiming(resultText); + if (tm.dur === null && tm.timeout === null) return; + var sum = chip.__sum || chip.querySelector('summary'); + if (!sum) return; + var status = sum.querySelector('.tool-chip-status'); + var insertAt = status || sum.lastElementChild; + if (tm.dur !== null) { + var timeEl = chip.querySelector('.tool-chip-time'); + if (!timeEl) { + timeEl = document.createElement('span'); + timeEl.className = 'tool-chip-time'; + sum.insertBefore(timeEl, insertAt); + } + timeEl.textContent = '⏱ ' + tm.dur + 's'; + } + if (tm.timeout !== null) { + var tmoEl = chip.querySelector('.tool-chip-timeout'); + if (!tmoEl) { + tmoEl = document.createElement('span'); + tmoEl.className = 'tool-chip-timeout'; + sum.insertBefore(tmoEl, insertAt); + } + tmoEl.textContent = '⏱ 超时 ' + tm.timeout + 's'; + } +} + +// 工具结果正文:短结果完整显示;长结果默认只显示尾部 4000 字 + +// 「展开完整输出」按钮(再点收起)。chip.__fullResult 保存全文。 +var TOOL_PREVIEW_CHARS = 4000; +function _updateExpandBtn(btn, full, expanded) { + btn.textContent = expanded + ? '⬇ 收起(回到尾部预览)' + : '⬆ 展开完整输出(共 ' + full.length + ' 字)'; +} +function _renderToolResultBody(chip, resultText) { + var live = chip.querySelector('.tool-chip-live'); + if (!live) return; + var label = live.previousElementSibling; + var body = live.parentElement; + var full = String(resultText || ''); + chip.__fullResult = full; + chip.__expanded = false; + var btn = chip.querySelector('.tool-expand-btn'); + if (!full.trim()) { + if (label) label.style.display = 'none'; + live.style.display = 'none'; + if (btn) btn.remove(); + return; + } + if (label) label.style.display = 'block'; + live.style.display = 'block'; + live.classList.remove('tool-chip-full'); + if (full.length > TOOL_PREVIEW_CHARS) { + live.textContent = '…\n' + full.slice(-TOOL_PREVIEW_CHARS); + if (!btn) { + btn = document.createElement('button'); + btn.className = 'tool-expand-btn'; + btn.type = 'button'; + body.insertBefore(btn, live); + } + _updateExpandBtn(btn, full, false); + btn.onclick = function() { + chip.__expanded = !chip.__expanded; + if (chip.__expanded) { + live.textContent = full; + live.classList.add('tool-chip-full'); + _updateExpandBtn(btn, full, true); + } else { + live.textContent = '…\n' + full.slice(-TOOL_PREVIEW_CHARS); + live.classList.remove('tool-chip-full'); + _updateExpandBtn(btn, full, false); + } + }; + } else { + if (btn) btn.remove(); + live.textContent = full; + } +} + +function toolExecutionStarted(msgId, callId, name, args) { + console.log('[JS] tool开始 name=' + name + ' call=' + callId + ' id=' + msgId); + var wrapper = document.getElementById(msgId); + if (!wrapper) return; + var contentDiv = wrapper.querySelector('.message-content'); + var buf = messageBuffer[msgId]; + + var chip = buildToolChip(callId, name, args, {streaming: true, ok: null}); + + // 🌟 插入到时间线当前位置(思考/正文之间,按事件顺序); + // 非流式消息(无 messageBuffer)退回气泡底部 + finalizeOpenThinkingBlocks(msgId); // 工具开始 → 思考段就地定格「已完成深度思考」 + var replyDiv = contentDiv.querySelector('.reply-content'); + if (replyDiv && buf) { + ensureTimeline(wrapper, buf); + replyDiv.insertBefore(chip, buf.typingEl); + } else { + var actionBar = contentDiv.querySelector('.message-actions'); + if (actionBar) contentDiv.insertBefore(chip, actionBar); + else contentDiv.appendChild(chip); + } + scheduleStreamingRender(msgId); // 触发一次滚动 +} + +function toolExecutionUpdated(msgId, callId, text) { + var wrapper = document.getElementById(msgId); + if (!wrapper) return; + var chip = _findChip(wrapper, callId); + if (!chip) return; + chip.__liveBuf = (chip.__liveBuf || '') + text; + if (chip.__liveBuf.length > 4000) { + chip.__liveBuf = chip.__liveBuf.slice(-4000); + } + // 只有展开时才实时刷新输出(收起时零 DOM 开销) + if (chip.open) { + var live = chip.querySelector('.tool-chip-live'); + live.style.display = 'block'; + live.textContent = chip.__liveBuf; + live.previousElementSibling.style.display = 'block'; + var body = chip.querySelector('.tool-chip-body'); + body.scrollTop = body.scrollHeight; + } +} + +function toolExecutionTimed(msgId, callId, elapsed, timeout) { + // 🆕 bash 运行中每秒读秒(Python 每秒推一次 = 唯一事实源): + // 摘要行显示 ⏱ 0/10s → 1/10s → 2/10s ... + var wrapper = document.getElementById(msgId); + if (!wrapper) return; + var chip = _findChip(wrapper, callId); + if (!chip) return; + var t = chip.querySelector('.tool-chip-livetimer'); + if (!t) { + t = document.createElement('span'); + t.className = 'tool-chip-livetimer'; + var sum = chip.__sum || chip.querySelector('summary'); + if (sum) sum.appendChild(t); + } + t.textContent = '\u23f1 ' + elapsed + '/' + timeout + 's'; +} + +function toolExecutionFinished(msgId, callId, name, ok, text) { + console.log('[JS] tool完成 name=' + name + ' ok=' + ok + ' 结果=' + (text ? text.length : 0) + 'c call=' + callId); + var wrapper = document.getElementById(msgId); + if (!wrapper) return; + var chip = _findChip(wrapper, callId); + if (!chip) return; + // 🆕 读秒停止:移除运行中计时,换成完成/超时徽章(_updateChipTimingSummary) + var _lt = chip.querySelector('.tool-chip-livetimer'); + if (_lt) _lt.remove(); + chip.classList.remove('streaming'); + var status = chip.querySelector('.tool-chip-status'); + var outText = text || chip.__liveBuf || ''; + if (status) { + status.classList.remove('running'); + status.classList.add(ok ? 'ok' : 'fail'); + status.textContent = ok ? '✓ 完成' : '✗ 失败'; + } + // 摘要显示结果首行(收起时也能看到「执行了什么 + 得到什么」) + var brief = chip.querySelector('.tool-chip-brief'); + if (ok && brief && outText) { + var firstLine = outText.split('\n')[0].replace(/\s+/g, ' ').trim(); + if (firstLine.length > 60) firstLine = firstLine.slice(0, 60) + '…'; + brief.textContent = firstLine; + brief.classList.add('done'); + } + // 完整结果入正文 —— 不再依赖 chip 当前是否展开(修复「结束后展开为空」bug); + // 长结果默认显示尾部 4000 字 + 「展开完整输出」按钮 + if (outText) { + _renderToolResultBody(chip, outText); + _updateChipTimingSummary(chip, outText); + } + scheduleStreamingRender(msgId); +} + +// ==================== 时间线恢复 / 历史渲染(持久化的 agent 时间线) ==================== +// entries: [{"t":"think","text"}, {"t":"text","text"}, +// {"t":"tool","id","name","args","ok":null|bool,"result"}] +// liveMode=true → 切回进行中的会话:块按流式状态建立,后续 token 无缝续流 +// liveMode=false → 历史消息:静态渲染(思考折叠 / chip 定格 / 文本完整解析) +function renderTimelineEntries(msgId, entries, liveMode) { + var wrapper = document.getElementById(msgId); + if (!wrapper) return; + var contentDiv = wrapper.querySelector('.message-content'); + var replyDiv = contentDiv.querySelector('.reply-content'); + if (!replyDiv) return; + var buf = messageBuffer[msgId]; + + if (liveMode && buf) { + ensureTimeline(wrapper, buf); + } else if (!liveMode) { + // 静态:清掉 createMessage 预先渲染的聚合文本(时间线接管展示) + replyDiv.innerHTML = ''; + } + + var textAll = '', thinkAll = ''; + entries.forEach(function(e) { + if (e.t === 'think') { + var block = makeThinkBlock(false, liveMode ? '深度思考' : '已完成深度思考'); + var tc = block.querySelector('.think-content'); + if (liveMode) { + block.classList.add('streaming-think'); + tc.__buf = e.text || ''; + buf.thinkSegs.push(tc); + replyDiv.insertBefore(block, buf.typingEl); + } else { + tc.innerHTML = safeHtml('
' + + marked.parse(e.text || '') + '
'); + replyDiv.appendChild(block); + } + thinkAll += e.text || ''; + } else if (e.t === 'text') { + var seg = document.createElement('div'); + seg.className = 'md-segment markdown-body'; + if (liveMode) { + seg.__buf = e.text || ''; + buf.textSegs.push(seg); + if (seg.__buf) { + // ★ 同 appendToken:带内容再插入,规避 :empty 布局失效 + mdStateOf(seg); + renderMarkdownStreaming(seg, seg.__buf); + } + replyDiv.insertBefore(seg, buf.typingEl); + } else { + seg.innerHTML = safeHtml(marked.parse(e.text || '')); + replyDiv.appendChild(seg); + } + textAll += e.text || ''; + } else if (e.t === 'tool') { + var chip = buildToolChip(e.id || '', e.name, e.args || '', { + streaming: liveMode && e.ok === null, + ok: e.ok === null ? null : !!e.ok, + result: e.result || '' + }); + if (liveMode) replyDiv.insertBefore(chip, buf.typingEl); + else replyDiv.appendChild(chip); + } + }); + + if (liveMode && buf) { + // 缓冲区对齐:后续流式事件在此基础上继续累积(复制/入库不丢内容) + buf.content = textAll; + buf.reasoning = thinkAll; + // ★ 恢复的内容立即同步上屏(不等待 rAF/timer) + syncRenderThrottled(msgId, 0); + scheduleStreamingRender(msgId); + } + diagEvent(liveMode ? 'restore' : 'history', + { id: msgId, entries: entries.length, text: textAll.length, + think: thinkAll.length, buf: !!buf }); +} + +// 切回进行中的会话:恢复时间线(后续 token 无缝续流) +function restoreStreamingTimeline(msgId, timelineJson) { + try { console.log('[JS] restoreTimeline id=' + msgId + ' 条目=' + (JSON.parse(timelineJson) || []).length); } catch (e) {} + try { + var entries = JSON.parse(timelineJson); + if (Array.isArray(entries) && entries.length) { + renderTimelineEntries(msgId, entries, true); + } + } catch (e) { + console.error('[JS]: restoreStreamingTimeline 解析失败', e); + } +} + +// 历史消息:静态时间线渲染 +function renderTimelineHistory(msgId, timelineJson) { + try { console.log('[JS] historyTimeline id=' + msgId + ' 条目=' + (JSON.parse(timelineJson) || []).length); } catch (e) {} + try { + var entries = JSON.parse(timelineJson); + if (Array.isArray(entries) && entries.length) { + renderTimelineEntries(msgId, entries, false); + // 删除 buffer → finishMessage 不再全量重渲(避免破坏时间线 DOM), + // 但仍会执行代码高亮/滚动等收尾 + delete messageBuffer[msgId]; + } + } catch (e) { + console.error('[JS]: renderTimelineHistory 解析失败', e); + } +} + +// ==================== 上下文压缩气泡(照抄深度思考气泡类配置,仅改名字和显示内容) ==================== +// 🌟 不再自写结构 —— 复用 makeThinkBlock / .think-block / .streaming-think: +// 执行中:蓝色呼吸 + 三点动画(与深度思考完全一致) +// 位置:当前消息的 .reply-content 时间线内(流式光标之前)—— 时间线位置,不锁底 +// 限高:.think-content max-height 400px + 滚动(CSS 已做) +// 完成:原地折叠,label 显示 前→后 token,内容为摘要全文(.think-inner,同深度思考固化) +function _fmtK(n) { + if (n === null || n === undefined) return '?'; + return (n / 1000).toFixed(1) + 'k'; +} + +function compactionStarted(msgId, path) { + var wrapper = document.getElementById(msgId); + if (!wrapper) return; + wrapper.classList.add('streaming'); + var buf = messageBuffer[msgId] || null; + var tl, typingEl; + if (buf) { + tl = ensureTimeline(wrapper, buf); // 惰性把 .reply-content 变时间线(建流式光标) + typingEl = buf.typingEl; + } else { + tl = wrapper.querySelector('.reply-content'); + typingEl = tl ? tl.querySelector('.streaming-typing') : null; + } + if (!tl) return; + finalizeOpenThinkingBlocks(msgId); // 防御:压缩开始 → 若有进行中的思考段也先定格 + // —— 与 appendReasoning 新建思考块完全同款 —— + var block = makeThinkBlock(false, '上下文压缩'); + block.classList.add('streaming-think', 'compaction-think'); + var inner = document.createElement('div'); + inner.className = 'think-inner'; + inner.textContent = '正在生成摘要…'; + block.querySelector('.think-content').appendChild(inner); + if (typingEl && typingEl.parentNode === tl) { + tl.insertBefore(block, typingEl); // 时间线位置:流式光标之前 + } else { + tl.appendChild(block); + } + reportWebScroll(); +} + +function compactionFinished(msgId, payload) { + payload = payload || {}; + var wrapper = document.getElementById(msgId); + if (!wrapper) return; + // 精确锁定「仍在执行中」的压缩气泡(最后一条)——一轮内多次压缩时 + // 不会误定/重复定格旧气泡;都已完成时回退到最后一条(幂等) + var blocks = wrapper.querySelectorAll('.compaction-think'); + var block = null; + for (var bi = blocks.length - 1; bi >= 0; bi--) { + if (blocks[bi].classList.contains('streaming-think')) { block = blocks[bi]; break; } + } + if (!block && blocks.length) block = blocks[blocks.length - 1]; + if (!block) return; // 气泡未建(非激活会话)→ 丢弃 + // —— 与 finishTimelineMessage 固化思考块同款 —— + block.classList.remove('streaming-think'); + if (block.open) block.open = false; + var lab = block.querySelector('.think-label'); + var dur = (payload.duration_ms !== null && payload.duration_ms !== undefined) + ? ' · ' + (payload.duration_ms / 1000).toFixed(1) + 's' : ''; + if (lab) { + lab.textContent = payload.failed + ? '上下文压缩 · 未执行' + dur + : '已完成上下文压缩 · ' + _fmtK(payload.before) + + ' → ' + _fmtK(payload.after) + dur; + } + var tc = block.querySelector('.think-content'); + if (tc) { + tc.innerHTML = safeHtml('
' + + marked.parse(payload.summary || '(无摘要内容)') + '
'); + delete tc.__mdState; + } + reportWebScroll(); +} + +// ==================== 系统提示(居中单行) ==================== +function showSystemNote(text) { + var note = document.createElement('div'); + note.className = 'system-note'; + note.textContent = text; + var anchor = document.getElementById('scroll-anchor'); + if (anchor && anchor.parentNode === chatContainer) { + chatContainer.insertBefore(note, anchor); + } else { + chatContainer.appendChild(note); + } + reportWebScroll(); +} + +// ==================== 滚动控制 ==================== +var _softScrollPending = 0; +function softScroll() { + // 🌟 rAF 节流:历史记录批量渲染时每帧最多滚一次 + if (_softScrollPending) return; + _softScrollPending = requestAnimationFrame(function() { + _softScrollPending = 0; + var anchor = document.getElementById('scroll-anchor'); + if (anchor) { + anchor.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + }); +} + +// ==================== 登录/会话加载界面 ==================== +function showLoadingOverlay() { + var el = document.getElementById('session-loading'); + if (!el) return; + el.classList.remove('hide'); + el.style.display = 'flex'; +} +function hideLoadingOverlay() { + var el = document.getElementById('session-loading'); + if (!el) return; + el.classList.add('hide'); + setTimeout(function() { el.style.display = 'none'; }, 480); +} + +// ==================== 历史记录与视图控制 ==================== +function clearChat() { + var bubbles = chatContainer.querySelectorAll('.message-wrapper, .system-note'); + bubbles.forEach(function(b) { b.remove(); }); + + // 🌟 修复:attachmentMetaStore 之前漏清,切会话后附件卡片会指向旧元数据 + messageBuffer = {}; + longTextStore = {}; + finalContentStore = {}; + attachmentMetaStore = {}; + + // 🆕 P1-01:清渲染窗口状态机(游标/缓存/未决请求/代次;配置模式与大小保留) + if (typeof rwState !== 'undefined' && rwState && typeof RenderWindowState !== 'undefined') { + RenderWindowState.clear(rwState); + rwUpdateLoadButtons(); + } + + var welcome = document.querySelector('.welcome-screen'); + if (welcome) welcome.style.display = 'none'; + reportWebScroll(); +} + +function deleteMessage(msgId) { + var wrapper = document.getElementById(msgId); + if (wrapper) { + wrapper.style.transition = "opacity 0.3s ease, transform 0.3s ease"; + wrapper.style.opacity = "0"; + wrapper.style.transform = "translateY(-10px)"; + setTimeout(function() { wrapper.remove(); }, 300); + } + delete messageBuffer[msgId]; + delete longTextStore[msgId]; + delete finalContentStore[msgId]; + setTimeout(reportWebScroll, 350); +} + +function showWelcome() { + var welcome = document.querySelector('.welcome-screen'); + if (welcome) welcome.style.display = 'flex'; + reportWebScroll(); +} + +// ==================== 历史记录专用:插入思考块 ==================== +function insertThinkBlock(msgId, reasoningText) { + var wrapper = document.getElementById(msgId); + if (!wrapper) { + console.error('[JS]: 找不到消息气泡 ' + msgId); + return; + } + + var contentDiv = wrapper.querySelector('.message-content'); + var replyDiv = contentDiv.querySelector('.reply-content'); + + var thinkBlock = makeThinkBlock(false, '已完成深度思考'); + var thinkContent = thinkBlock.querySelector('.think-content'); + thinkContent.innerHTML = safeHtml('
' + marked.parse(reasoningText) + '
'); + + contentDiv.insertBefore(thinkBlock, replyDiv); +} + +// ==================== 🆕 P1-01 双向渲染窗口(DOM 层;状态机见 render_window.js) ==================== +var rwState = null; // RenderWindowState 实例(rwApplyConfig 注入配置后创建) + +function rwApplyConfig(cfg) { + rwState = RenderWindowState.create(cfg || {}); +} + +/* 一轮窗口化加载开始:清屏 + 重同步 (session, generation)。 */ +function rwBegin(sessionId, generation, total) { + clearChat(); + if (!rwState) rwState = RenderWindowState.create({}); + RenderWindowState.clear(rwState); + rwState.sessionId = sessionId; + rwState.generation = (generation | 0); + if (total <= 0) rwUpdateLoadButtons(); +} + +/* 初始窗口就绪:状态机登记 + 入口按钮 + 对齐底部(与既开开会话在最新处的行为一致)。 */ +function rwInitWindow(payload) { + if (!rwState || !payload) return; + RenderWindowState.initFullChain(rwState, payload); + rwUpdateLoadButtons(); + var anchor = document.getElementById('scroll-anchor'); + if (anchor) anchor.scrollIntoView({ behavior: 'auto', block: 'end' }); + reportWebScroll(); +} + +/* 分页响应:state 机校验 (session, generation, 未决请求);stale 丢弃并清理已渲染节点。 */ +function rwPageResponse(payload) { + if (!rwState || !payload) return; + var req = { direction: payload.direction, boundaryId: payload.boundaryId }; + var res = RenderWindowState.applyPage(rwState, req, payload); + if (res.stale) { + (payload.items || []).forEach(function(it) { rwRemoveMessageDom(it.id); }); + rwUpdateLoadButtons(); + return; + } + if (res.side === 'older') rwApplyOlder(res); + else rwApplyNewer(res); +} + +/* 向上换页:锚点捕获 → 新页移到头部 → 裁尾 → 锚点恢复(误差≤2px)→ 入口更新。 */ +function rwApplyOlder(res) { + var anchor = rwCaptureAnchor(); + var oldScroll = getScroller().scrollTop; + var firstExisting = chatContainer.querySelector('.message-wrapper'); + res.addedIds.forEach(function(id) { + var el = document.getElementById(id); + if (el) chatContainer.insertBefore(el, firstExisting); + }); + res.removedIds.forEach(rwRemoveMessageDom); + if (anchor) { + var el = document.getElementById(anchor.msgId); + if (el) { + var newTop = el.getBoundingClientRect().top + window.scrollY; + if (oldScroll <= 1) { + // 绝对顶部:停在顶部露出新页(自动模式会沿顶部继续补页) + getScroller().scrollTop = 0; + } else { + getScroller().scrollTop = oldScroll + (newTop - anchor.docTop); + } + } + } + rwUpdateLoadButtons(); + reportWebScroll(); + // 自动模式:停在顶部且还有旧页 → 沿顶部继续补页(pending 防并发,逐页推进) + if (rwState && rwState.mode === 'auto' && getScroller().scrollTop <= 1 && + RenderWindowState.canRequest(rwState, 'older')) { + setTimeout(function() { if (typeof rwAutoCheck === 'function') rwAutoCheck(); }, 60); + } +} + +/* 向下换页:新页已在尾部 → 裁头 → 锚点恢复(无跳动)。 */ +function rwApplyNewer(res) { + var anchor = rwCaptureAnchor(); + var oldScroll = getScroller().scrollTop; + res.removedIds.forEach(rwRemoveMessageDom); + if (anchor) { + var el = document.getElementById(anchor.msgId); + if (el) { + var newTop = el.getBoundingClientRect().top + window.scrollY; + getScroller().scrollTop = oldScroll + (newTop - anchor.docTop); + } + } + rwUpdateLoadButtons(); + reportWebScroll(); +} + +/* 新消息追加进窗口(发送用户消息 / 助手占位 / 切回续流);超限裁旧端。 + * chainIndex/chainLen 为 -1 表示未知(安全降级)。带 streaming 类的消息自动接管流式保护。 */ +function rwNoteLive(sessionId, generation, msgId, chainIndex, chainLen) { + if (!rwState || rwState.sessionId !== sessionId || rwState.generation !== generation) return; + if (chainLen !== undefined && chainLen !== null && chainLen >= 0) { + rwState.chainLen = chainLen | 0; + } + var ix = (chainIndex === undefined || chainIndex === null || chainIndex < 0) ? -1 : (chainIndex | 0); + var r = RenderWindowState.noteLive(rwState, msgId, ix); + var el = document.getElementById(msgId); + if (el && el.classList.contains('streaming')) { + RenderWindowState.noteStream(rwState, msgId); + } + (r.removedIds || []).forEach(rwRemoveMessageDom); + rwUpdateLoadButtons(); +} + +/* 发起换页请求(双引擎同构:QtWebChannel slot / WebView2 postMessage 同名方法)。 */ +function rwRequestPage(direction) { + if (!rwState) return; + var req = RenderWindowState.beginRequest(rwState, direction); + if (!req) return; + if (window.bridge && typeof window.bridge.onRequestWindowPage === 'function') { + window.bridge.onRequestWindowPage(rwState.sessionId, req.direction, req.boundaryId, rwState.generation); + } +} + +/* 滚动驱动:双模式底部自动恢复新页;自动模式顶部自动加载旧页。 */ +function rwAutoCheck() { + if (!rwState) return; + if (RenderWindowState.canRequest(rwState, 'newer') && isNearBottom()) { + rwRequestPage('newer'); + } else if (rwState.mode === 'auto' && getScroller().scrollTop <= 1 && + RenderWindowState.canRequest(rwState, 'older')) { + rwRequestPage('older'); + } +} + +/* 锚点:换页前首个可见消息的 id + 文档坐标 top。 */ +function rwCaptureAnchor() { + var top = getScroller().scrollTop; + var bot = top + window.innerHeight; + var els = chatContainer.querySelectorAll('.message-wrapper'); + for (var i = 0; i < els.length; i++) { + var r = els[i].getBoundingClientRect(); + if (r.bottom > top && r.top < bot) { + return { msgId: els[i].id, docTop: r.top + window.scrollY, offset: Math.max(0, top - r.top) }; + } + } + return null; +} + +/* 从 DOM 移除一条消息并清理全部相关存储(活动流不受裁剪,状态机保证)。 */ +function rwRemoveMessageDom(msgId) { + var wrapper = document.getElementById(msgId); + if (wrapper) wrapper.remove(); + delete messageBuffer[msgId]; + delete longTextStore[msgId]; + delete finalContentStore[msgId]; + delete attachmentMetaStore[msgId]; +} + +/* 加载入口:固定在 chat-container 首/尾;无更多消息时隐藏。 */ +function rwEnsureLoadButtons() { + if (!document.getElementById('load-older')) { + var b = document.createElement('div'); + b.id = 'load-older'; + b.className = 'load-window-btn'; + b.hidden = true; + b.innerText = '⌃ 加载更早消息'; + b.addEventListener('click', function() { rwRequestPage('older'); }); + chatContainer.appendChild(b); + } + if (!document.getElementById('load-newer')) { + var n = document.createElement('div'); + n.id = 'load-newer'; + n.className = 'load-window-btn'; + n.hidden = true; + n.innerText = '⌄ 加载更新消息'; + n.addEventListener('click', function() { rwRequestPage('newer'); }); + chatContainer.appendChild(n); + } + chatContainer.insertBefore(document.getElementById('load-older'), chatContainer.firstChild); + chatContainer.appendChild(document.getElementById('load-newer')); +} + +function rwUpdateLoadButtons() { + if (!rwState) return; + rwEnsureLoadButtons(); + document.getElementById('load-older').hidden = !rwState.hasMoreOlder; + document.getElementById('load-newer').hidden = !rwState.hasMoreNewer; +} + +// ==================== JS 引擎就绪标志 ==================== +window.jsReady = true; +console.log('[JS]: 引擎已就绪'); diff --git a/ui/web/dompurify.min.js b/ui/web/dompurify.min.js new file mode 100644 index 0000000..b472a86 --- /dev/null +++ b/ui/web/dompurify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.2.4 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.4/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=R(Array.prototype.forEach),m=R(Array.prototype.lastIndexOf),p=R(Array.prototype.pop),f=R(Array.prototype.push),d=R(Array.prototype.splice),h=R(String.prototype.toLowerCase),g=R(String.prototype.toString),T=R(String.prototype.match),y=R(String.prototype.replace),E=R(String.prototype.indexOf),A=R(String.prototype.trim),_=R(Object.prototype.hasOwnProperty),S=R(RegExp.prototype.test),b=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:h;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function O(e){for(let t=0;t/gm),G=a(/\$\{[\w\W]*/gm),Y=a(/^data-[\-\w.\u00B7-\uFFFF]+$/),j=a(/^aria-[\-\w]+$/),X=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),q=a(/^(?:\w+script|data):/i),$=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),K=a(/^html$/i),V=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var Z=Object.freeze({__proto__:null,ARIA_ATTR:j,ATTR_WHITESPACE:$,CUSTOM_ELEMENT:V,DATA_ATTR:Y,DOCTYPE_NAME:K,ERB_EXPR:W,IS_ALLOWED_URI:X,IS_SCRIPT_OR_DATA:q,MUSTACHE_EXPR:B,TMPLIT_EXPR:G});const J=1,Q=3,ee=7,te=8,ne=9,oe=function(){return"undefined"==typeof window?null:window};var re=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:oe();const o=e=>t(e);if(o.version="3.2.4",o.removed=[],!n||!n.document||n.document.nodeType!==ne||!n.Element)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:R,Element:O,NodeFilter:B,NamedNodeMap:W=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:G,DOMParser:Y,trustedTypes:j}=n,q=O.prototype,$=v(q,"cloneNode"),V=v(q,"remove"),re=v(q,"nextSibling"),ie=v(q,"childNodes"),ae=v(q,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let le,ce="";const{implementation:se,createNodeIterator:ue,createDocumentFragment:me,getElementsByTagName:pe}=r,{importNode:fe}=a;let de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof e&&"function"==typeof ae&&se&&void 0!==se.createHTMLDocument;const{MUSTACHE_EXPR:he,ERB_EXPR:ge,TMPLIT_EXPR:Te,DATA_ATTR:ye,ARIA_ATTR:Ee,IS_SCRIPT_OR_DATA:Ae,ATTR_WHITESPACE:_e,CUSTOM_ELEMENT:Se}=Z;let{IS_ALLOWED_URI:be}=Z,Ne=null;const Re=w({},[...L,...C,...x,...k,...U]);let we=null;const Oe=w({},[...z,...P,...H,...F]);let De=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ve=null,Le=null,Ce=!0,xe=!0,Me=!1,ke=!0,Ie=!1,Ue=!0,ze=!1,Pe=!1,He=!1,Fe=!1,Be=!1,We=!1,Ge=!0,Ye=!1,je=!0,Xe=!1,qe={},$e=null;const Ke=w({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ve=null;const Ze=w({},["audio","video","img","source","image","track"]);let Je=null;const Qe=w({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),et="http://www.w3.org/1998/Math/MathML",tt="http://www.w3.org/2000/svg",nt="http://www.w3.org/1999/xhtml";let ot=nt,rt=!1,it=null;const at=w({},[et,tt,nt],g);let lt=w({},["mi","mo","mn","ms","mtext"]),ct=w({},["annotation-xml"]);const st=w({},["title","style","font","a","script"]);let ut=null;const mt=["application/xhtml+xml","text/html"];let pt=null,ft=null;const dt=r.createElement("form"),ht=function(e){return e instanceof RegExp||e instanceof Function},gt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ft||ft!==e){if(e&&"object"==typeof e||(e={}),e=D(e),ut=-1===mt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,pt="application/xhtml+xml"===ut?g:h,Ne=_(e,"ALLOWED_TAGS")?w({},e.ALLOWED_TAGS,pt):Re,we=_(e,"ALLOWED_ATTR")?w({},e.ALLOWED_ATTR,pt):Oe,it=_(e,"ALLOWED_NAMESPACES")?w({},e.ALLOWED_NAMESPACES,g):at,Je=_(e,"ADD_URI_SAFE_ATTR")?w(D(Qe),e.ADD_URI_SAFE_ATTR,pt):Qe,Ve=_(e,"ADD_DATA_URI_TAGS")?w(D(Ze),e.ADD_DATA_URI_TAGS,pt):Ze,$e=_(e,"FORBID_CONTENTS")?w({},e.FORBID_CONTENTS,pt):Ke,ve=_(e,"FORBID_TAGS")?w({},e.FORBID_TAGS,pt):{},Le=_(e,"FORBID_ATTR")?w({},e.FORBID_ATTR,pt):{},qe=!!_(e,"USE_PROFILES")&&e.USE_PROFILES,Ce=!1!==e.ALLOW_ARIA_ATTR,xe=!1!==e.ALLOW_DATA_ATTR,Me=e.ALLOW_UNKNOWN_PROTOCOLS||!1,ke=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,Ie=e.SAFE_FOR_TEMPLATES||!1,Ue=!1!==e.SAFE_FOR_XML,ze=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,Be=e.RETURN_DOM_FRAGMENT||!1,We=e.RETURN_TRUSTED_TYPE||!1,He=e.FORCE_BODY||!1,Ge=!1!==e.SANITIZE_DOM,Ye=e.SANITIZE_NAMED_PROPS||!1,je=!1!==e.KEEP_CONTENT,Xe=e.IN_PLACE||!1,be=e.ALLOWED_URI_REGEXP||X,ot=e.NAMESPACE||nt,lt=e.MATHML_TEXT_INTEGRATION_POINTS||lt,ct=e.HTML_INTEGRATION_POINTS||ct,De=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(De.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(De.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(De.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),Ie&&(xe=!1),Be&&(Fe=!0),qe&&(Ne=w({},U),we=[],!0===qe.html&&(w(Ne,L),w(we,z)),!0===qe.svg&&(w(Ne,C),w(we,P),w(we,F)),!0===qe.svgFilters&&(w(Ne,x),w(we,P),w(we,F)),!0===qe.mathMl&&(w(Ne,k),w(we,H),w(we,F))),e.ADD_TAGS&&(Ne===Re&&(Ne=D(Ne)),w(Ne,e.ADD_TAGS,pt)),e.ADD_ATTR&&(we===Oe&&(we=D(we)),w(we,e.ADD_ATTR,pt)),e.ADD_URI_SAFE_ATTR&&w(Je,e.ADD_URI_SAFE_ATTR,pt),e.FORBID_CONTENTS&&($e===Ke&&($e=D($e)),w($e,e.FORBID_CONTENTS,pt)),je&&(Ne["#text"]=!0),ze&&w(Ne,["html","head","body"]),Ne.table&&(w(Ne,["tbody"]),delete ve.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');le=e.TRUSTED_TYPES_POLICY,ce=le.createHTML("")}else void 0===le&&(le=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(j,c)),null!==le&&"string"==typeof ce&&(ce=le.createHTML(""));i&&i(e),ft=e}},Tt=w({},[...C,...x,...M]),yt=w({},[...k,...I]),Et=function(e){f(o.removed,{element:e});try{ae(e).removeChild(e)}catch(t){V(e)}},At=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(Fe||Be)try{Et(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},_t=function(e){let t=null,n=null;if(He)e=""+e;else{const t=T(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===ut&&ot===nt&&(e=''+e+"");const o=le?le.createHTML(e):e;if(ot===nt)try{t=(new Y).parseFromString(o,ut)}catch(e){}if(!t||!t.documentElement){t=se.createDocument(ot,"template",null);try{t.documentElement.innerHTML=rt?ce:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),ot===nt?pe.call(t,ze?"html":"body")[0]:ze?t.documentElement:i},St=function(e){return ue.call(e.ownerDocument||e,e,B.SHOW_ELEMENT|B.SHOW_COMMENT|B.SHOW_TEXT|B.SHOW_PROCESSING_INSTRUCTION|B.SHOW_CDATA_SECTION,null)},bt=function(e){return e instanceof G&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof W)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof R&&e instanceof R};function Rt(e,t,n){u(e,(e=>{e.call(o,t,n,ft)}))}const wt=function(e){let t=null;if(Rt(de.beforeSanitizeElements,e,null),bt(e))return Et(e),!0;const n=pt(e.nodeName);if(Rt(de.uponSanitizeElement,e,{tagName:n,allowedTags:Ne}),e.hasChildNodes()&&!Nt(e.firstElementChild)&&S(/<[/\w]/g,e.innerHTML)&&S(/<[/\w]/g,e.textContent))return Et(e),!0;if(e.nodeType===ee)return Et(e),!0;if(Ue&&e.nodeType===te&&S(/<[/\w]/g,e.data))return Et(e),!0;if(!Ne[n]||ve[n]){if(!ve[n]&&Dt(n)){if(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n))return!1;if(De.tagNameCheck instanceof Function&&De.tagNameCheck(n))return!1}if(je&&!$e[n]){const t=ae(e)||e.parentNode,n=ie(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=$(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,re(e))}}}return Et(e),!0}return e instanceof O&&!function(e){let t=ae(e);t&&t.tagName||(t={namespaceURI:ot,tagName:"template"});const n=h(e.tagName),o=h(t.tagName);return!!it[e.namespaceURI]&&(e.namespaceURI===tt?t.namespaceURI===nt?"svg"===n:t.namespaceURI===et?"svg"===n&&("annotation-xml"===o||lt[o]):Boolean(Tt[n]):e.namespaceURI===et?t.namespaceURI===nt?"math"===n:t.namespaceURI===tt?"math"===n&&ct[o]:Boolean(yt[n]):e.namespaceURI===nt?!(t.namespaceURI===tt&&!ct[o])&&!(t.namespaceURI===et&&!lt[o])&&!yt[n]&&(st[n]||!Tt[n]):!("application/xhtml+xml"!==ut||!it[e.namespaceURI]))}(e)?(Et(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!S(/<\/no(script|embed|frames)/i,e.innerHTML)?(Ie&&e.nodeType===Q&&(t=e.textContent,u([he,ge,Te],(e=>{t=y(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),Rt(de.afterSanitizeElements,e,null),!1):(Et(e),!0)},Ot=function(e,t,n){if(Ge&&("id"===t||"name"===t)&&(n in r||n in dt))return!1;if(xe&&!Le[t]&&S(ye,t));else if(Ce&&S(Ee,t));else if(!we[t]||Le[t]){if(!(Dt(e)&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,e)||De.tagNameCheck instanceof Function&&De.tagNameCheck(e))&&(De.attributeNameCheck instanceof RegExp&&S(De.attributeNameCheck,t)||De.attributeNameCheck instanceof Function&&De.attributeNameCheck(t))||"is"===t&&De.allowCustomizedBuiltInElements&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n)||De.tagNameCheck instanceof Function&&De.tagNameCheck(n))))return!1}else if(Je[t]);else if(S(be,y(n,_e,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==E(n,"data:")||!Ve[e]){if(Me&&!S(Ae,y(n,_e,"")));else if(n)return!1}else;return!0},Dt=function(e){return"annotation-xml"!==e&&T(e,Se)},vt=function(e){Rt(de.beforeSanitizeAttributes,e,null);const{attributes:t}=e;if(!t||bt(e))return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:we,forceKeepAttr:void 0};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=pt(a);let m="value"===a?c:A(c);if(n.attrName=s,n.attrValue=m,n.keepAttr=!0,n.forceKeepAttr=void 0,Rt(de.uponSanitizeAttribute,e,n),m=n.attrValue,!Ye||"id"!==s&&"name"!==s||(At(a,e),m="user-content-"+m),Ue&&S(/((--!?|])>)|<\/(style|title)/i,m)){At(a,e);continue}if(n.forceKeepAttr)continue;if(At(a,e),!n.keepAttr)continue;if(!ke&&S(/\/>/i,m)){At(a,e);continue}Ie&&u([he,ge,Te],(e=>{m=y(m,e," ")}));const f=pt(e.nodeName);if(Ot(f,s,m)){if(le&&"object"==typeof j&&"function"==typeof j.getAttributeType)if(l);else switch(j.getAttributeType(f,s)){case"TrustedHTML":m=le.createHTML(m);break;case"TrustedScriptURL":m=le.createScriptURL(m)}try{l?e.setAttributeNS(l,a,m):e.setAttribute(a,m),bt(e)?Et(e):p(o.removed)}catch(e){}}}Rt(de.afterSanitizeAttributes,e,null)},Lt=function e(t){let n=null;const o=St(t);for(Rt(de.beforeSanitizeShadowDOM,t,null);n=o.nextNode();)Rt(de.uponSanitizeShadowNode,n,null),wt(n),vt(n),n.content instanceof s&&e(n.content);Rt(de.afterSanitizeShadowDOM,t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(rt=!e,rt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw b("toString is not a function");if("string"!=typeof(e=e.toString()))throw b("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Pe||gt(t),o.removed=[],"string"==typeof e&&(Xe=!1),Xe){if(e.nodeName){const t=pt(e.nodeName);if(!Ne[t]||ve[t])throw b("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof R)n=_t("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===J&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!Ie&&!ze&&-1===e.indexOf("<"))return le&&We?le.createHTML(e):e;if(n=_t(e),!n)return Fe?null:We?ce:""}n&&He&&Et(n.firstChild);const c=St(Xe?e:n);for(;i=c.nextNode();)wt(i),vt(i),i.content instanceof s&&Lt(i.content);if(Xe)return e;if(Fe){if(Be)for(l=me.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(we.shadowroot||we.shadowrootmode)&&(l=fe.call(a,l,!0)),l}let m=ze?n.outerHTML:n.innerHTML;return ze&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&S(K,n.ownerDocument.doctype.name)&&(m="\n"+m),Ie&&u([he,ge,Te],(e=>{m=y(m,e," ")})),le&&We?le.createHTML(m):m},o.setConfig=function(){gt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Pe=!0},o.clearConfig=function(){ft=null,Pe=!1},o.isValidAttribute=function(e,t,n){ft||gt({});const o=pt(e),r=pt(t);return Ot(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&f(de[e],t)},o.removeHook=function(e,t){if(void 0!==t){const n=m(de[e],t);return-1===n?void 0:d(de[e],n,1)[0]}return p(de[e])},o.removeHooks=function(e){de[e]=[]},o.removeAllHooks=function(){de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return re})); +//# sourceMappingURL=purify.min.js.map diff --git a/ui/web/highlight/atom-one-dark.min.css b/ui/web/highlight/atom-one-dark.min.css new file mode 100644 index 0000000..5344ee3 --- /dev/null +++ b/ui/web/highlight/atom-one-dark.min.css @@ -0,0 +1 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline} \ No newline at end of file diff --git a/ui/web/highlight/highlight.min.js b/ui/web/highlight/highlight.min.js new file mode 100644 index 0000000..5d699ae --- /dev/null +++ b/ui/web/highlight/highlight.min.js @@ -0,0 +1,1213 @@ +/*! + Highlight.js v11.9.0 (git: f47103d4f1) + (c) 2006-2023 undefined and other contributors + License: BSD-3-Clause + */ +var hljs=function(){"use strict";function e(n){ +return n instanceof Map?n.clear=n.delete=n.set=()=>{ +throw Error("map is read-only")}:n instanceof Set&&(n.add=n.clear=n.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach((t=>{ +const a=n[t],i=typeof a;"object"!==i&&"function"!==i||Object.isFrozen(a)||e(a) +})),n}class n{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function t(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function a(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] +;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const i=e=>!!e.scope +;class r{constructor(e,n){ +this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ +this.buffer+=t(e)}openNode(e){if(!i(e))return;const n=((e,{prefix:n})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const t=e.split(".") +;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") +}return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)} +closeNode(e){i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const s=(e={})=>{const n={children:[]} +;return Object.assign(n,e),n};class o{constructor(){ +this.rootNode=s(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const n=s({scope:e}) +;this.add(n),this.stack.push(n)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ +return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), +n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +o._collapse(e)})))}}class l extends o{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,n){const t=e.root +;n&&(t.scope="language:"+n),this.add(t)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function c(e){ +return e?"string"==typeof e?e:e.source:null}function d(e){return b("(?=",e,")")} +function g(e){return b("(?:",e,")*")}function u(e){return b("(?:",e,")?")} +function b(...e){return e.map((e=>c(e))).join("")}function m(...e){const n=(e=>{ +const n=e[e.length-1] +;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} +})(e);return"("+(n.capture?"":"?:")+e.map((e=>c(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t +;let a=c(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} +i+=a.substring(0,e.index), +a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], +"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} +const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",N="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",w="\\b(0b[01]+)",v={ +begin:"\\\\[\\s\\S]",relevance:0},O={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[v]},k={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[v]},x=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, +contains:[]},t);i.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return i.contains.push({begin:b(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i +},M=x("//","$"),S=x("/\\*","\\*/"),A=x("#","$");var C=Object.freeze({ +__proto__:null,APOS_STRING_MODE:O,BACKSLASH_ESCAPE:v,BINARY_NUMBER_MODE:{ +scope:"number",begin:w,relevance:0},BINARY_NUMBER_RE:w,COMMENT:x, +C_BLOCK_COMMENT_MODE:S,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", +begin:N,relevance:0},C_NUMBER_RE:N,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:A,IDENT_RE:f, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+E,relevance:0}, +NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:k,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[v,{begin:/\[/,end:/\]/,relevance:0,contains:[v]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const n=/^#![ ]*\// +;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),a({scope:"meta",begin:n, +end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:f,relevance:0},UNDERSCORE_IDENT_RE:E, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:E,relevance:0}});function T(e,n){ +"."===e.input[e.index-1]&&n.ignoreMatch()}function R(e,n){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function D(e,n){ +n&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=T,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function I(e,n){ +Array.isArray(e.illegal)&&(e.illegal=m(...e.illegal))}function L(e,n){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function B(e,n){ +void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] +})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ +relevance:0,contains:[Object.assign(t,{endsParent:!0})] +},e.relevance=0,delete t.beforeMatch +},z=["of","and","for","in","not","or","if","then","parent","list","value"],F="keyword" +;function U(e,n,t=F){const a=Object.create(null) +;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ +Object.assign(a,U(e[t],n,t))})),a;function i(e,t){ +n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") +;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ +return n?Number(n):(e=>z.includes(e.toLowerCase()))(e)?0:1}const P={},K=e=>{ +console.error(e)},H=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ +P[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),P[`${e}/${n}`]=!0) +},G=Error();function Z(e,n,{key:t}){let a=0;const i=e[t],r={},s={} +;for(let e=1;e<=n.length;e++)s[e+a]=i[e],r[e+a]=!0,a+=p(n[e-1]) +;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function W(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw K("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +G +;if("object"!=typeof e.beginScope||null===e.beginScope)throw K("beginScope must be object"), +G;Z(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw K("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +G +;if("object"!=typeof e.endScope||null===e.endScope)throw K("endScope must be object"), +G;Z(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function Q(e){ +function n(n,t){ +return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) +}class t{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,n){ +n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const n=this.matcherRe.exec(e);if(!n)return null +;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] +;return n.splice(0,t),Object.assign(n,a)}}class i{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t +;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), +n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ +this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ +const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex +;let t=n.exec(e) +;if(this.resumingScanAtSamePosition())if(t&&t.index===this.lastIndex);else{ +const n=this.getMatcher(0);n.lastIndex=this.lastIndex+1,t=n.exec(e)} +return t&&(this.regexIndex+=t.position+1, +this.regexIndex===this.count&&this.considerAll()),t}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r +;if(r.isCompiled)return o +;[R,L,W,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), +r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +l=r.keywords.$pattern, +delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=U(r.keywords,e.case_insensitive)), +o.keywordPatternRe=n(l,!0), +s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(o.endRe=n(o.end)), +o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), +r.illegal&&(o.illegalRe=n(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ +variants:null},n)))),e.cachedVariants?e.cachedVariants:X(e)?a(e,{ +starts:e.starts?a(e.starts):null +}):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) +})),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i +;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function X(e){ +return!!e&&(e.endsWithParent||X(e.starts))}class V extends Error{ +constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} +const J=t,Y=a,ee=Symbol("nomatch"),ne=t=>{ +const a=Object.create(null),i=Object.create(null),r=[];let s=!0 +;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:l};function _(e){ +return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" +;"object"==typeof n?(a=e, +t=n.ignoreIllegals,i=n.language):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), +q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};x("before:highlight",r) +;const s=r.result?r.result:f(r.language,r.code,t) +;return s.code=r.code,x("after:highlight",s),s}function f(e,t,i,r){ +const l=Object.create(null);function c(){if(!x.keywords)return void S.addText(A) +;let e=0;x.keywordPatternRe.lastIndex=0;let n=x.keywordPatternRe.exec(A),t="" +;for(;n;){t+=A.substring(e,n.index) +;const i=w.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,x.keywords[a]);if(r){ +const[e,a]=r +;if(S.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ +const t=w.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] +;e=x.keywordPatternRe.lastIndex,n=x.keywordPatternRe.exec(A)}var a +;t+=A.substring(e),S.addText(t)}function d(){null!=x.subLanguage?(()=>{ +if(""===A)return;let e=null;if("string"==typeof x.subLanguage){ +if(!a[x.subLanguage])return void S.addText(A) +;e=f(x.subLanguage,A,!0,M[x.subLanguage]),M[x.subLanguage]=e._top +}else e=E(A,x.subLanguage.length?x.subLanguage:null) +;x.relevance>0&&(C+=e.relevance),S.__addSublanguage(e._emitter,e.language) +})():c(),A=""}function g(e,n){ +""!==e&&(S.startScope(n),S.addText(e),S.endScope())}function u(e,n){let t=1 +;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} +const a=w.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(A=i,c(),A=""),t++}} +function b(e,n){ +return e.scope&&"string"==typeof e.scope&&S.openNode(w.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(g(A,w.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +A=""):e.beginScope._multi&&(u(e.beginScope,n),A="")),x=Object.create(e,{parent:{ +value:x}}),x}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) +;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ +return 0===x.matcher.regexIndex?(A+=e[0],1):(D=!0,0)}function h(e){ +const n=e[0],a=t.substring(e.index),i=m(x,e,a);if(!i)return ee;const r=x +;x.endScope&&x.endScope._wrap?(d(), +g(n,x.endScope._wrap)):x.endScope&&x.endScope._multi?(d(), +u(x.endScope,e)):r.skip?A+=n:(r.returnEnd||r.excludeEnd||(A+=n), +d(),r.excludeEnd&&(A=n));do{ +x.scope&&S.closeNode(),x.skip||x.subLanguage||(C+=x.relevance),x=x.parent +}while(x!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} +let y={};function N(a,r){const o=r&&r[0];if(A+=a,null==o)return d(),0 +;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ +if(A+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +;throw n.languageName=e,n.badRule=y.rule,n}return 1} +if(y=r,"begin"===r.type)return(e=>{ +const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] +;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) +;return a.skip?A+=t:(a.excludeBegin&&(A+=t), +d(),a.returnBegin||a.excludeBegin||(A=t)),b(a,e),a.returnBegin?0:t.length})(r) +;if("illegal"===r.type&&!i){ +const e=Error('Illegal lexeme "'+o+'" for mode "'+(x.scope||"")+'"') +;throw e.mode=x,e}if("end"===r.type){const e=h(r);if(e!==ee)return e} +if("illegal"===r.type&&""===o)return 1 +;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return A+=o,o.length}const w=v(e) +;if(!w)throw K(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const O=Q(w);let k="",x=r||O;const M={},S=new p.__emitter(p);(()=>{const e=[] +;for(let n=x;n!==w;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach((e=>S.openNode(e)))})();let A="",C=0,T=0,R=0,D=!1;try{ +if(w.__emitTokens)w.__emitTokens(t,S);else{for(x.matcher.considerAll();;){ +R++,D?D=!1:x.matcher.considerAll(),x.matcher.lastIndex=T +;const e=x.matcher.exec(t);if(!e)break;const n=N(t.substring(T,e.index),e) +;T=e.index+n}N(t.substring(T))}return S.finalize(),k=S.toHTML(),{language:e, +value:k,relevance:C,illegal:!1,_emitter:S,_top:x}}catch(n){ +if(n.message&&n.message.includes("Illegal"))return{language:e,value:J(t), +illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, +context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:k},_emitter:S};if(s)return{ +language:e,value:J(t),illegal:!1,relevance:0,errorRaised:n,_emitter:S,_top:x} +;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ +const n={value:J(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(v).filter(k).map((n=>f(n,e,!1))) +;i.unshift(t);const r=i.sort(((e,n)=>{ +if(e.relevance!==n.relevance)return n.relevance-e.relevance +;if(e.language&&n.language){if(v(e.language).supersetOf===n.language)return 1 +;if(v(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s +;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ +let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" +;const t=p.languageDetectRe.exec(n);if(t){const n=v(t[1]) +;return n||(H(o.replace("{}",t[1])), +H("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} +return n.split(/\s+/).find((e=>_(e)||v(e)))})(e);if(_(t))return +;if(x("before:highlightElement",{el:e,language:t +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new V("One of your code blocks includes unescaped HTML.",e.innerHTML) +;n=e;const a=n.textContent,r=t?h(a,{language:t,ignoreIllegals:!0}):E(a) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,n,t)=>{const a=n&&i[n]||t +;e.classList.add("hljs"),e.classList.add("language-"+a) +})(e,t,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),x("after:highlightElement",{el:e,result:r,text:a})}let N=!1;function w(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(y):N=!0 +}function v(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} +function O(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +i[e.toLowerCase()]=n}))}function k(e){const n=v(e) +;return n&&!n.disableAutodetect}function x(e,n){const t=e;r.forEach((e=>{ +e[t]&&e[t](n)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +N&&w()}),!1),Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:w, +highlightElement:y, +highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), +q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=Y(p,e)}, +initHighlighting:()=>{ +w(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +w(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ +if(K("Language definition for '{}' could not be registered.".replace("{}",e)), +!s)throw n;K(n),i=c} +i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&O(i.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete a[e] +;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, +listLanguages:()=>Object.keys(a),getLanguage:v,registerAliases:O, +autoDetection:k,inherit:Y,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ +e["before:highlightBlock"](Object.assign({block:n.el},n)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ +e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, +removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.9.0",t.regex={concat:b, +lookahead:d,either:m,optional:u,anyNumberOfTimes:g} +;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t +},te=ne({});te.newInstance=()=>ne({});var ae=te;const ie=e=>({IMPORTANT:{ +scope:"meta",begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ +scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, +FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, +ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}),re=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],se=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],oe=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],le=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],ce=["align-content","align-items","align-self","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","isolation","justify-content","left","letter-spacing","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","resize","rest","rest-after","rest-before","right","row-gap","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","speak","speak-as","src","tab-size","table-layout","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","z-index"].reverse(),de=oe.concat(le) +;var ge="[0-9](_*[0-9])*",ue=`\\.(${ge})`,be="[0-9a-fA-F](_*[0-9a-fA-F])*",me={ +className:"number",variants:[{ +begin:`(\\b(${ge})((${ue})|\\.)?|(${ue}))[eE][+-]?(${ge})[fFdD]?\\b`},{ +begin:`\\b(${ge})((${ue})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${ue})[fFdD]?\\b`},{begin:`\\b(${ge})[fFdD]\\b`},{ +begin:`\\b0[xX]((${be})\\.?|(${be})?\\.(${be}))[pP][+-]?(${ge})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${be})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function pe(e,n,t){return-1===t?"":e.replace(n,(a=>pe(e,n,t-1)))} +const _e="[A-Za-z$_][0-9A-Za-z$_]*",he=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],fe=["true","false","null","undefined","NaN","Infinity"],Ee=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],ye=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ne=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],we=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],ve=[].concat(Ne,Ee,ye) +;function Oe(e){const n=e.regex,t=_e,a={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const t=e[0].length+e.index,a=e.input[t] +;if("<"===a||","===a)return void n.ignoreMatch();let i +;">"===a&&(((e,{after:n})=>{const t="",M={ +match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(x)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[f]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{ +PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-z])/, +contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ +className:"attr",begin:t+n.lookahead(":"),relevance:0},M,{ +begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ +className:"function",begin:x,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0, +excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0},{match:/\s+/, +relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:a.begin, +"on:begin":a.isTrulyOpeningTag,end:a.end}],subLanguage:"xml",contains:[{ +begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},N,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[f,e.inherit(e.TITLE_MODE,{begin:t, +className:"title.function"})]},{match:/\.\.\./,relevance:0},O,{match:"\\$"+t, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[f]},w,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},E,k,{match:/\$[(.]/}]}} +const ke=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),xe=["Protocol","Type"].map(ke),Me=["init","self"].map(ke),Se=["Any","Self"],Ae=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ce=["false","nil","true"],Te=["assignment","associativity","higherThan","left","lowerThan","none","right"],Re=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],De=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Ie=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),Le=m(Ie,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Be=b(Ie,Le,"*"),$e=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),ze=m($e,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),Fe=b($e,ze,"*"),Ue=b(/[A-Z]/,ze,"*"),je=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,Fe,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],Pe=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;var Ke=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} +;Object.assign(t,{className:"variable",variants:[{ +begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},r={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},s={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(s);const o={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),c={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[l,e.SHEBANG(),c,o,e.HASH_COMMENT_MODE,r,{match:/(\/[a-z._-]+)+/},s,{ +match:/\\"/},{className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}, +grmr_c:e=>{const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ +match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal128","const","static","complex","bool","imaginary"], +literal:"true false NULL", +built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" +},b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:b.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:b.concat(["self"]),relevance:0}]),relevance:0},p={ +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, +keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/, +end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s] +}]},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, +disableAutodetect:!0,illegal:"=]/,contains:[{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, +strings:o,keywords:u}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="(?!struct)("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], +keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], +literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +},b={className:"function.dispatch",relevance:0,keywords:{ +_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] +}, +begin:n.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,n.lookahead(/(<[^<>]+>|)\s*\(/)) +},m=[b,c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],p={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:m.concat(["self"]),relevance:0}]),relevance:0},_={className:"function", +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{ +begin:/:/,endsWithParent:!0,contains:[o,l]},{relevance:0,match:/,/},{ +className:"params",begin:/\(/,end:/\)/,keywords:u,relevance:0, +contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,end:/\)/,keywords:u, +relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] +},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C++", +aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"",keywords:u,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:u},{ +match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], +className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]), +built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], +literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ +begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ +begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},r=e.inherit(i,{illegal:/\n/}),s={className:"subst",begin:/\{/,end:/\}/, +keywords:n},o=e.inherit(s,{illegal:/\n/}),l={className:"string",begin:/\$"/, +end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ +},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) +;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], +o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const g={variants:[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},u={begin:"<",end:">",contains:[{beginKeywords:"in out"},t] +},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ +begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], +keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, +contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ +begin:"\x3c!--|--\x3e"},{begin:""}]}] +}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", +end:"$",keywords:{ +keyword:"if else elif endif define undef warning error line region endregion pragma checksum" +}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" +},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ +className:"string",begin:/"/,end:/"/}]},{ +beginKeywords:"new return throw await else",relevance:0},{className:"function", +begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ +beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", +relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", +begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, +contains:[g,a,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ +const n=e.regex,t=ie(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ +keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, +contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ +},t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 +},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 +},t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ +begin:":("+oe.join("|")+")"},{begin:":(:)?("+le.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b"},{ +begin:/:/,end:/[;}{]/, +contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,...a,{ +begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" +},contains:[...a,{className:"string",begin:/[^)]/,endsWithParent:!0, +excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", +relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ +},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:se.join(" ")},contains:[{ +begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ +className:"selector-tag",begin:"\\b("+re.join("|")+")\\b"}]}},grmr_diff:e=>{ +const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ +className:"meta",relevance:10, +match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) +},{className:"comment",variants:[{ +begin:n.either(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/), +end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{ +className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, +end:/$/}]}},grmr_go:e=>{const n={ +keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"], +type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], +literal:["true","false","iota","nil"], +built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] +};return{name:"Go",aliases:["golang"],keywords:n,illegal:"{const n=e.regex;return{name:"GraphQL",aliases:["gql"], +case_insensitive:!0,disableAutodetect:!1,keywords:{ +keyword:["query","mutation","subscription","type","input","schema","directive","interface","union","scalar","fragment","enum","on"], +literal:["true","false","null"]}, +contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{ +scope:"punctuation",match:/[.]{3}/,relevance:0},{scope:"punctuation", +begin:/[\!\(\)\:\=\[\]\{\|\}]{1}/,relevance:0},{scope:"variable",begin:/\$/, +end:/\W/,excludeEnd:!0,relevance:0},{scope:"meta",match:/@\w+/,excludeEnd:!0},{ +scope:"symbol",begin:n.concat(/[_A-Za-z][_0-9A-Za-z]*/,n.lookahead(/\s*:/)), +relevance:0}],illegal:[/[;<']/,/BEGIN/]}},grmr_ini:e=>{const n=e.regex,t={ +className:"number",relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{ +begin:e.NUMBER_RE}]},a=e.COMMENT();a.variants=[{begin:/;/,end:/$/},{begin:/#/, +end:/$/}];const i={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{ +begin:/\$\{(.*?)\}/}]},r={className:"literal", +begin:/\bon|off|true|false|yes|no\b/},s={className:"string", +contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ +begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] +},o={begin:/\[/,end:/\]/,contains:[a,r,i,s,t,"self"],relevance:0 +},l=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ +name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, +contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ +begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), +className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+pe("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits"], +literal:["false","true","null"], +type:["char","boolean","long","float","int","byte","short","double"], +built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:i,illegal:/<\/|#/, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, +relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ +begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/, +className:"string",contains:[e.BACKSLASH_ESCAPE] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ +match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,t],className:{ +1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ +begin:[n.concat(/(?!else)/,t),/\s+/,t,/\s+/,/=(?!=)/],className:{1:"type", +3:"variable",5:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", +3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"new throw return else",relevance:0},{ +begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ +2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0, +contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,me,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},me,r]}},grmr_javascript:Oe, +grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", +beginKeywords:n.join(" ")};return{name:"JSON",keywords:{literal:n},contains:[{ +className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{ +match:/[{}[\],:]/,className:"punctuation",relevance:0 +},e.QUOTE_STRING_MODE,t,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +illegal:"\\S"}},grmr_kotlin:e=>{const n={ +keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", +built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", +literal:"true false null"},t={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" +},a={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},i={ +className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", +variants:[{begin:'"""',end:'"""(?=[^"])',contains:[i,a]},{begin:"'",end:"'", +illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, +contains:[e.BACKSLASH_ESCAPE,i,a]}]};a.contains.push(r);const s={ +className:"meta", +begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" +},o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, +end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] +},l=me,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, +contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], +{name:"Kotlin",aliases:["kt","kts"],keywords:n, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", +begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", +begin:/@\w+/}]}},t,s,o,{className:"function",beginKeywords:"fun",end:"[(]|$", +returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ +begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, +contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, +keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, +endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 +},e.C_LINE_COMMENT_MODE,c,s,o,r,e.C_NUMBER_MODE]},c]},{ +begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ +3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, +illegal:"extends implements",contains:[{ +beginKeywords:"public protected internal private constructor" +},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, +excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, +excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", +end:"$",illegal:"\n"},l]}},grmr_less:e=>{ +const n=ie(e),t=de,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, +relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", +attribute:se.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +relevance:0} +;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ +begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", +excludeEnd:!0} +},n.HEXCOLOR,d,l("variable","@@?"+a,10),l("variable","@\\{"+a+"\\}"),l("built_in","~?`[^`]*?`"),{ +className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 +},n.IMPORTANT,{beginKeywords:"and not"},n.FUNCTION_DISPATCH);const g=s.concat({ +begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, +contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", +returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b", +end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] +},m={className:"keyword", +begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", +starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:s,relevance:0}},p={ +className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a +}],starts:{end:"[;}]",returnEnd:!0,contains:g}},_={variants:[{ +begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, +returnEnd:!0,illegal:"[<='$\"]",relevance:0, +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ +begin:"\\b("+re.join("|")+")\\b",className:"selector-tag" +},n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ +className:"selector-pseudo",begin:":("+oe.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+le.join("|")+")"},{begin:/\(/, +end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ +begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} +;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), +{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:r}}, +grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] +},i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 +})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE, +literal:"true false nil", +keyword:"and break do else elseif end for goto if in local not or repeat return then until while", +built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" +},contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", +contains:[e.inherit(e.TITLE_MODE,{ +begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", +begin:"\\(",endsWithParent:!0,contains:i}].concat(i) +},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", +begin:n,end:t,contains:[a],relevance:5}])}},grmr_makefile:e=>{const n={ +className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%{ +const n={begin:/<\/?[A-Za-z_]/,end:">",subLanguage:"xml",relevance:0},t={ +variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0},{ +begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, +relevance:2},{ +begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), +relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ +begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/ +},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, +returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", +excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", +end:"\\]",excludeBegin:!0,excludeEnd:!0}]},a={className:"strong",contains:[], +variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] +},i={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ +begin:/_(?![_\s])/,end:/_/,relevance:0}]},r=e.inherit(a,{contains:[] +}),s=e.inherit(i,{contains:[]});a.contains.push(s),i.contains.push(r) +;let o=[n,t];return[a,i,r,s].forEach((e=>{e.contains=e.contains.concat(o) +})),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:o},{ +begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", +contains:o}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", +end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:o, +end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ +begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ +begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ +className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}},grmr_objectivec:e=>{ +const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, +keyword:["@interface","@class","@protocol","@implementation"]};return{ +name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], +keywords:{"variable.language":["this","super"],$pattern:n, +keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], +literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], +built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"], +type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"] +},illegal:"/,end:/$/,illegal:"\\n" +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", +begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, +contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, +relevance:0}]}},grmr_perl:e=>{const n=e.regex,t=/[dualxmsipngr]{0,12}/,a={ +$pattern:/[\w.]+/, +keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" +},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:a},r={begin:/->\{/, +end:/\}/},s={variants:[{begin:/\$\d/},{ +begin:n.concat(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") +},{begin:/[$%@][^\s\w{]/,relevance:0}] +},o=[e.BACKSLASH_ESCAPE,i,s],l=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],c=(e,a,i="\\1")=>{ +const r="\\1"===i?i:n.concat(i,a) +;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) +},d=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),g=[s,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ +endsWithParent:!0}),r,{className:"string",contains:o,variants:[{ +begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", +end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ +begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", +relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", +contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ +begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number", +begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b", +relevance:0},{ +begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", +keywords:"split return print reverse grep",relevance:0, +contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ +begin:c("s|tr|y",n.either(...l,{capture:!0}))},{begin:c("s|tr|y","\\(","\\)")},{ +begin:c("s|tr|y","\\[","\\]")},{begin:c("s|tr|y","\\{","\\}")}],relevance:2},{ +className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ +begin:d("(?:m|qr)?",/\//,/\//)},{begin:d("m|qr",n.either(...l,{capture:!0 +}),/\1/)},{begin:d("m|qr",/\(/,/\)/)},{begin:d("m|qr",/\[/,/\]/)},{ +begin:d("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub", +end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{ +begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$", +subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}] +}];return i.contains=g,r.contains=g,{name:"Perl",aliases:["pl","pm"],keywords:a, +contains:g}},grmr_php:e=>{ +const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r={ +scope:"variable",match:"\\$+"+a},s={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},o=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),l="[ \t\n]",c={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(s)}),o,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(s),"on:begin":(e,n)=>{ +n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},d={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},g=["false","null","true"],u=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],b=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],m={ +keyword:u,literal:(e=>{const n=[];return e.forEach((e=>{ +n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) +})),n})(g),built_in:b},p=e=>e.map((e=>e.replace(/\|\d+$/,""))),_={variants:[{ +match:[/new/,n.concat(l,"+"),n.concat("(?!",p(b).join("\\b|"),"\\b)"),i],scope:{ +1:"keyword",4:"title.class"}}]},h=n.concat(a,"\\b(?!\\()"),f={variants:[{ +match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{1:"title.class", +3:"variable.constant"}},{match:[i,n.concat("::",n.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[i,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},E={scope:"attr", +match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},y={relevance:0, +begin:/\(/,end:/\)/,keywords:m,contains:[E,r,f,e.C_BLOCK_COMMENT_MODE,c,d,_] +},N={relevance:0, +match:[/\b/,n.concat("(?!fn\\b|function\\b|",p(u).join("\\b|"),"|",p(b).join("\\b|"),"\\b)"),a,n.concat(l,"*"),n.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[y]};y.contains.push(N) +;const w=[E,f,e.C_BLOCK_COMMENT_MODE,c,d,_];return{case_insensitive:!1, +keywords:m,contains:[{begin:n.concat(/#\[\s*/,i),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:g,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:g,keyword:["new","array"]}, +contains:["self",...w]},...w,{scope:"meta",match:i}] +},e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{contains:[{ +scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},r,N,f,{ +match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},_,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:m, +contains:["self",r,f,e.C_BLOCK_COMMENT_MODE,c,d]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},c,d]} +},grmr_php_template:e=>({name:"PHP template",subLanguage:"xml",contains:[{ +begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*", +end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 +},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null, +skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null, +contains:null,skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text", +aliases:["text","txt"],disableAutodetect:!0}),grmr_python:e=>{ +const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ +$pattern:/[A-Za-z]\w+|__\w+__/,keyword:a, +built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], +type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] +},r={className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/, +end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},l={ +className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([uU]|[rR])'/,end:/'/, +relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ +begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, +end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,o,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},c="[0-9](_?[0-9])*",d=`(\\b(${c}))?\\.(${c})|\\b(${c})\\.`,g="\\b|"+a.join("|"),u={ +className:"number",relevance:0,variants:[{ +begin:`(\\b(${c})|(${d}))[eE][+-]?(${c})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ +begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${g})`},{ +begin:`\\b0[bB](_?[01])+[lL]?(?=${g})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${g})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${c})[jJ](?=${g})` +}]},b={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i, +contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ +className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, +contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ +name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, +illegal:/(<\/|\?)|=>/,contains:[r,u,{begin:/\bself\b/},{beginKeywords:"if", +relevance:0},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{ +1:"keyword",3:"title.function"},contains:[m]},{variants:[{ +match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], +scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, +grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta.prompt", +starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ +begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ +const n=e.regex,t=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,a=n.either(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),i=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,r=n.either(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/) +;return{name:"R",keywords:{$pattern:t, +keyword:"function if in break next repeat else for while", +literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", +built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm" +},contains:[e.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",match:/@examples/, +starts:{end:n.lookahead(n.either(/\n^#'\s*(?=@[a-zA-Z]+)/,/\n^(?!#')/)), +endsParent:!0}},{scope:"doctag",begin:"@param",end:/$/,contains:[{ +scope:"variable",variants:[{match:t},{match:/`(?:\\.|[^`\\])+`/}],endsParent:!0 +}]},{scope:"doctag",match:/@[a-zA-Z]+/},{scope:"keyword",match:/\\[a-zA-Z]+/}] +}),e.HASH_COMMENT_MODE,{scope:"string",contains:[e.BACKSLASH_ESCAPE], +variants:[e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', +relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{ +1:"operator",2:"number"},match:[i,a]},{scope:{1:"operator",2:"number"}, +match:[/%[^%]*%/,a]},{scope:{1:"punctuation",2:"number"},match:[r,a]},{scope:{ +2:"number"},match:[/[^a-zA-Z0-9._]|^/,a]}]},{scope:{3:"operator"}, +match:[t,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:i},{ +match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:r},{begin:"`",end:"`", +contains:[{begin:/\\./}]}]}},grmr_ruby:e=>{ +const n=e.regex,t="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",a=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(a,/(::\w+)*/),r={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},s={className:"doctag",begin:"@[A-Za-z]+"},o={ +begin:"#<",end:">"},l=[e.COMMENT("#","$",{contains:[s] +}),e.COMMENT("^=begin","^=end",{contains:[s],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/, +end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,c], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",u={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},b={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:r}]},m=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:r},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:a,scope:"title.class"},{ +match:[/def/,/\s+/,t],scope:{1:"keyword",3:"title.function"},contains:[b]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(o,l),relevance:0}].concat(o,l) +;c.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, +grmr_rust:e=>{const n=e.regex,t={className:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,e.IDENT_RE,n.lookahead(/\s*\(/)) +},a="([ui](8|16|32|64|128|size)|f(32|64))?",i=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],r=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:r, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:i},illegal:""},t]}}, +grmr_scss:e=>{const n=ie(e),t=le,a=oe,i="@[a-z-]+",r={className:"variable", +begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", +case_insensitive:!0,illegal:"[=/|']", +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ +className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ +className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 +},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", +begin:"\\b("+re.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:":("+a.join("|")+")"},{className:"selector-pseudo", +begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, +contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", +begin:"\\b("+ce.join("|")+")\\b"},{ +begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" +},{begin:/:/,end:/[;}{]/,relevance:0, +contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] +},{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ +begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, +keyword:"and or not only",attribute:se.join(" ")},contains:[{begin:i, +className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" +},r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] +},n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", +aliases:["console","shellsession"],contains:[{className:"meta.prompt", +begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +subLanguage:"bash"}}]}),grmr_sql:e=>{ +const n=e.regex,t=e.COMMENT("--","$"),a=["true","false","unknown"],i=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],r=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],s=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],o=r,l=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!r.includes(e))),c={ +begin:n.concat(/\b/,n.either(...o),/\s*\(/),relevance:0,keywords:{built_in:o}} +;return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t +;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) +})(l,{when:e=>e.length<3}),literal:a,type:i, +built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] +},contains:[{begin:n.either(...s),relevance:0,keywords:{$pattern:/[\w\.]+/, +keyword:l.concat(s),literal:a,type:i}},{className:"type", +begin:n.either("double precision","large object","with timezone","without timezone") +},c,{className:"variable",begin:/@[a-z0-9][a-z0-9_]*/},{className:"string", +variants:[{begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/, +contains:[{begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{ +className:"operator",begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/, +relevance:0}]}},grmr_swift:e=>{const n={match:/\s+/,relevance:0 +},t=e.COMMENT("/\\*","\\*/",{contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={ +match:[/\./,m(...xe,...Me)],className:{2:"keyword"}},r={match:b(/\./,m(...Ae)), +relevance:0},s=Ae.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +className:"keyword", +match:m(...Ae.filter((e=>"string"!=typeof e)).concat(Se).map(ke),...Me)}]},l={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Re),literal:Ce},c=[i,r,o],g=[{ +match:b(/\./,m(...De)),relevance:0},{className:"built_in", +match:b(/\b/,m(...De),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Be},{match:`\\.(\\.|${Le})+`}] +}],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, +variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ +match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ +},{match:/\b0b([01]_*)+\b/}]},E=(e="")=>({className:"subst",variants:[{ +match:b(/\\/,e,/[0\\tnr"']/)},{match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] +}),y=(e="")=>({className:"subst",match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) +}),N=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ +}),w=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),N(e)] +}),v=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),N(e)]}),O={ +className:"string", +variants:[w(),w("#"),w("##"),w("###"),v(),v("#"),v("##"),v("###")] +},k=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],x={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:k},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, +contains:[...k,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},S={ +scope:"regexp",variants:[M("###"),M("##"),M("#"),x]},A={match:b(/`/,Fe,/`/) +},C=[A,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${ze}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:Pe,contains:[...p,f,O]}]}},{ +scope:"keyword",match:b(/@/,m(...je))},{scope:"meta",match:b(/@/,Fe)}],R={ +match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,ze,"+") +},{className:"type",match:Ue,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Ue)),relevance:0}]},D={ +begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) +;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ +match:b(Fe,/\s*:/),keywords:"_|0",relevance:0 +},...a,S,...c,...g,...p,f,O,...C,...T,R]},L={begin://, +keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, +contains:[{begin:m(d(b(Fe,/\s*:/)),d(b(Fe,/\s+/,Fe,/\s*:/))),end:/:/, +relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", +match:Fe}]},...a,...c,...p,f,O,...T,R,I],endsParent:!0,illegal:/["']/},$={ +match:[/(func|macro)/,/\s+/,m(A.match,Fe,Be)],className:{1:"keyword", +3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},z={ +match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, +contains:[L,B,n],illegal:/\[|%/},F={match:[/operator/,/\s+/,Be],className:{ +1:"keyword",3:"title"}},U={begin:[/precedencegroup/,/\s+/,Ue],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...Te,...Ce],end:/}/} +;for(const e of O.variants){const n=e.contains.find((e=>"interpol"===e.label)) +;n.keywords=l;const t=[...c,...g,...p,f,O,...C];n.contains=[...t,{begin:/\(/, +end:/\)/,contains:["self",...t]}]}return{name:"Swift",keywords:l, +contains:[...a,$,z,{beginKeywords:"struct protocol class extension enum actor", +end:"\\{",excludeEnd:!0,keywords:l,contains:[e.inherit(e.TITLE_MODE,{ +className:"title.class",begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...c] +},F,U,{beginKeywords:"import",end:/$/,contains:[...a],relevance:0 +},S,...c,...g,...p,f,O,...C,...T,R,I]}},grmr_typescript:e=>{ +const n=Oe(e),t=_e,a=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],i={ +beginKeywords:"namespace",end:/\{/,excludeEnd:!0, +contains:[n.exports.CLASS_REFERENCE]},r={beginKeywords:"interface",end:/\{/, +excludeEnd:!0,keywords:{keyword:"interface extends",built_in:a}, +contains:[n.exports.CLASS_REFERENCE]},s={$pattern:_e, +keyword:he.concat(["type","namespace","interface","public","private","protected","implements","declare","abstract","readonly","enum","override"]), +literal:fe,built_in:ve.concat(a),"variable.language":we},o={className:"meta", +begin:"@"+t},l=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} +;return Object.assign(n.keywords,s), +n.exports.PARAMS_CONTAINS.push(o),n.contains=n.contains.concat([o,i,r]), +l(n,"shebang",e.SHEBANG()),l(n,"use_strict",{className:"meta",relevance:10, +begin:/^\s*['"]use strict['"]/ +}),n.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(n,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),n},grmr_vbnet:e=>{ +const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ +className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ +begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ +begin:n.concat(/# */,n.either(a,t),/ +/,n.either(i,r),/ *#/)}] +},o=e.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}] +}),l=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) +;return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0, +classNameAliases:{label:"symbol"},keywords:{ +keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", +built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort", +type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", +literal:"true false nothing"}, +illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ +className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, +end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, +variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ +},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ +begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ +className:"label",begin:/^\w+:/},o,l,{className:"meta", +begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, +end:/$/,keywords:{ +keyword:"const disable else elseif enable end externalsource if region then"}, +contains:[l]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) +;return n.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/, +keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"] +},contains:[e.COMMENT(/;;/,/$/),n,{match:[/(?:offset|align)/,/\s*/,/=/], +className:{1:"keyword",3:"operator"}},{className:"variable",begin:/\$[\w_]+/},{ +match:/(\((?!;)|\))+/,className:"punctuation",relevance:0},{ +begin:[/(?:func|call|call_indirect)/,/\s+/,/\$[^\s)]+/],className:{1:"keyword", +3:"title.function"}},e.QUOTE_STRING_MODE,{match:/(i32|i64|f32|f64)(?!\.)/, +className:"type"},{className:"keyword", +match:/\b(f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|nearest|neg?|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|store(?:8|16|32)?|sqrt|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))\b/ +},{className:"number",relevance:0, +match:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/ +}]}},grmr_xml:e=>{ +const n=e.regex,t=n.concat(/[\p{L}_]/u,n.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),a={ +className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},i={begin:/\s/, +contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] +},r=e.inherit(i,{begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{ +className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),l={ +endsWithParent:!0,illegal:/`]+/}]}]}]};return{ +name:"HTML, XML", +aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], +case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[i,o,s,r,{begin:/\[/,end:/\]/,contains:[{ +className:"meta",begin://,contains:[i,r,o,s]}]}] +},e.COMMENT(//,{relevance:10}),{begin://, +relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, +relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ +className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:n.concat(//,/>/,/\s/)))), +end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ +className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ +className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} +},grmr_yaml:e=>{ +const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ +className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ +},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", +variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{ +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),r={ +end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},s={begin:/\{/, +end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={begin:"\\[",end:"\\]", +contains:[r],illegal:"\\n",relevance:0},l=[{className:"attr",variants:[{ +begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{ +begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$", +relevance:10},{className:"string", +begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ +begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, +relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", +begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t +},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", +begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", +relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ +className:"number", +begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,a],c=[...l] +;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:l}}});const He=ae;for(const e of Object.keys(Ke)){ +const n=e.replace("grmr_","").replace("_","-");He.registerLanguage(n,Ke[e])} +return He}() +;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); \ No newline at end of file diff --git a/ui/web/index.html b/ui/web/index.html new file mode 100644 index 0000000..7c09433 --- /dev/null +++ b/ui/web/index.html @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ haocode +

haocode

+

探索未至之境

+
+
+ + +
+ +
+ + + + + + + + + + diff --git a/ui/web/katex/fonts/KaTeX_AMS-Regular.woff2 b/ui/web/katex/fonts/KaTeX_AMS-Regular.woff2 new file mode 100644 index 0000000..0acaaff Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_AMS-Regular.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Caligraphic-Bold.woff2 b/ui/web/katex/fonts/KaTeX_Caligraphic-Bold.woff2 new file mode 100644 index 0000000..f390922 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Caligraphic-Bold.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Caligraphic-Regular.woff2 b/ui/web/katex/fonts/KaTeX_Caligraphic-Regular.woff2 new file mode 100644 index 0000000..75344a1 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Caligraphic-Regular.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Fraktur-Bold.woff2 b/ui/web/katex/fonts/KaTeX_Fraktur-Bold.woff2 new file mode 100644 index 0000000..395f28b Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Fraktur-Bold.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Fraktur-Regular.woff2 b/ui/web/katex/fonts/KaTeX_Fraktur-Regular.woff2 new file mode 100644 index 0000000..735f694 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Fraktur-Regular.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Main-Bold.woff2 b/ui/web/katex/fonts/KaTeX_Main-Bold.woff2 new file mode 100644 index 0000000..ab2ad21 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Main-Bold.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Main-BoldItalic.woff2 b/ui/web/katex/fonts/KaTeX_Main-BoldItalic.woff2 new file mode 100644 index 0000000..5931794 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Main-BoldItalic.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Main-Italic.woff2 b/ui/web/katex/fonts/KaTeX_Main-Italic.woff2 new file mode 100644 index 0000000..b50920e Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Main-Italic.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Main-Regular.woff2 b/ui/web/katex/fonts/KaTeX_Main-Regular.woff2 new file mode 100644 index 0000000..eb24a7b Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Main-Regular.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Math-BoldItalic.woff2 b/ui/web/katex/fonts/KaTeX_Math-BoldItalic.woff2 new file mode 100644 index 0000000..2965702 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Math-BoldItalic.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Math-Italic.woff2 b/ui/web/katex/fonts/KaTeX_Math-Italic.woff2 new file mode 100644 index 0000000..215c143 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Math-Italic.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_SansSerif-Bold.woff2 b/ui/web/katex/fonts/KaTeX_SansSerif-Bold.woff2 new file mode 100644 index 0000000..cfaa3bd Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_SansSerif-Bold.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_SansSerif-Italic.woff2 b/ui/web/katex/fonts/KaTeX_SansSerif-Italic.woff2 new file mode 100644 index 0000000..349c06d Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_SansSerif-Italic.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_SansSerif-Regular.woff2 b/ui/web/katex/fonts/KaTeX_SansSerif-Regular.woff2 new file mode 100644 index 0000000..a90eea8 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_SansSerif-Regular.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Script-Regular.woff2 b/ui/web/katex/fonts/KaTeX_Script-Regular.woff2 new file mode 100644 index 0000000..b3048fc Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Script-Regular.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Size1-Regular.woff2 b/ui/web/katex/fonts/KaTeX_Size1-Regular.woff2 new file mode 100644 index 0000000..c5a8462 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Size1-Regular.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Size2-Regular.woff2 b/ui/web/katex/fonts/KaTeX_Size2-Regular.woff2 new file mode 100644 index 0000000..e1bccfe Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Size2-Regular.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Size3-Regular.woff2 b/ui/web/katex/fonts/KaTeX_Size3-Regular.woff2 new file mode 100644 index 0000000..249a286 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Size3-Regular.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Size4-Regular.woff2 b/ui/web/katex/fonts/KaTeX_Size4-Regular.woff2 new file mode 100644 index 0000000..680c130 Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Size4-Regular.woff2 differ diff --git a/ui/web/katex/fonts/KaTeX_Typewriter-Regular.woff2 b/ui/web/katex/fonts/KaTeX_Typewriter-Regular.woff2 new file mode 100644 index 0000000..771f1af Binary files /dev/null and b/ui/web/katex/fonts/KaTeX_Typewriter-Regular.woff2 differ diff --git a/ui/web/katex/katex.min.css b/ui/web/katex/katex.min.css new file mode 100644 index 0000000..975b828 --- /dev/null +++ b/ui/web/katex/katex.min.css @@ -0,0 +1 @@ +@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.11"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.katex .katex-html>.newline{display:block}.katex .base{position:relative;white-space:nowrap;width:-webkit-min-content;width:-moz-min-content;width:min-content}.katex .base,.katex .strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-style:italic;font-weight:700}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathboldfrak,.katex .textboldfrak{font-family:KaTeX_Fraktur;font-weight:700}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{border-collapse:collapse;display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;position:relative;vertical-align:bottom}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;font-size:1px;min-width:2px;vertical-align:bottom;width:2px}.katex .vbox{align-items:baseline;display:inline-flex;flex-direction:column}.katex .hbox{width:100%}.katex .hbox,.katex .thinbox{display:inline-flex;flex-direction:row}.katex .thinbox{max-width:0;width:0}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline,.katex .hline,.katex .mfrac .frac-line,.katex .overline .overline-line,.katex .rule,.katex .underline .underline-line{min-height:1px}.katex .mspace{display:inline-block}.katex .clap,.katex .llap,.katex .rlap{position:relative;width:0}.katex .clap>.inner,.katex .llap>.inner,.katex .rlap>.inner{position:absolute}.katex .clap>.fix,.katex .llap>.fix,.katex .rlap>.fix{display:inline-block}.katex .llap>.inner{right:0}.katex .clap>.inner,.katex .rlap>.inner{left:0}.katex .clap>.inner>span{margin-left:-50%;margin-right:50%}.katex .rule{border:0 solid;display:inline-block;position:relative}.katex .hline,.katex .overline .overline-line,.katex .underline .underline-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline{border-bottom-style:dashed;display:inline-block;width:100%}.katex .sqrt>.root{margin-left:.2777777778em;margin-right:-.5555555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .sizing.reset-size2.size1{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .sizing.reset-size2.size3{font-size:1.1666666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .sizing.reset-size2.size4{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .sizing.reset-size2.size6{font-size:1.6666666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .sizing.reset-size2.size10{font-size:3.4566666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .sizing.reset-size2.size11{font-size:4.1466666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .sizing.reset-size3.size1{font-size:.7142857143em}.katex .fontsize-ensurer.reset-size3.size2,.katex .sizing.reset-size3.size2{font-size:.8571428571em}.katex .fontsize-ensurer.reset-size3.size3,.katex .sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .sizing.reset-size3.size4{font-size:1.1428571429em}.katex .fontsize-ensurer.reset-size3.size5,.katex .sizing.reset-size3.size5{font-size:1.2857142857em}.katex .fontsize-ensurer.reset-size3.size6,.katex .sizing.reset-size3.size6{font-size:1.4285714286em}.katex .fontsize-ensurer.reset-size3.size7,.katex .sizing.reset-size3.size7{font-size:1.7142857143em}.katex .fontsize-ensurer.reset-size3.size8,.katex .sizing.reset-size3.size8{font-size:2.0571428571em}.katex .fontsize-ensurer.reset-size3.size9,.katex .sizing.reset-size3.size9{font-size:2.4685714286em}.katex .fontsize-ensurer.reset-size3.size10,.katex .sizing.reset-size3.size10{font-size:2.9628571429em}.katex .fontsize-ensurer.reset-size3.size11,.katex .sizing.reset-size3.size11{font-size:3.5542857143em}.katex .fontsize-ensurer.reset-size4.size1,.katex .sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .sizing.reset-size5.size1{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .sizing.reset-size5.size2{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .sizing.reset-size5.size3{font-size:.7777777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .sizing.reset-size5.size4{font-size:.8888888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .sizing.reset-size5.size6{font-size:1.1111111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .sizing.reset-size5.size7{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .sizing.reset-size5.size10{font-size:2.3044444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .sizing.reset-size5.size11{font-size:2.7644444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .sizing.reset-size7.size1{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .sizing.reset-size7.size3{font-size:.5833333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .sizing.reset-size7.size4{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .sizing.reset-size7.size6{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .sizing.reset-size7.size10{font-size:1.7283333333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .sizing.reset-size7.size11{font-size:2.0733333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .sizing.reset-size8.size1{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .sizing.reset-size8.size2{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .sizing.reset-size8.size3{font-size:.4861111111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .sizing.reset-size8.size4{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .sizing.reset-size8.size6{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .sizing.reset-size8.size7{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .sizing.reset-size8.size10{font-size:1.4402777778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .sizing.reset-size8.size11{font-size:1.7277777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .sizing.reset-size9.size1{font-size:.2893518519em}.katex .fontsize-ensurer.reset-size9.size2,.katex .sizing.reset-size9.size2{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .sizing.reset-size9.size3{font-size:.4050925926em}.katex .fontsize-ensurer.reset-size9.size4,.katex .sizing.reset-size9.size4{font-size:.462962963em}.katex .fontsize-ensurer.reset-size9.size5,.katex .sizing.reset-size9.size5{font-size:.5208333333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .sizing.reset-size9.size6{font-size:.5787037037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .sizing.reset-size9.size7{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .sizing.reset-size9.size8{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .sizing.reset-size9.size10{font-size:1.2002314815em}.katex .fontsize-ensurer.reset-size9.size11,.katex .sizing.reset-size9.size11{font-size:1.4398148148em}.katex .fontsize-ensurer.reset-size10.size1,.katex .sizing.reset-size10.size1{font-size:.2410800386em}.katex .fontsize-ensurer.reset-size10.size2,.katex .sizing.reset-size10.size2{font-size:.2892960463em}.katex .fontsize-ensurer.reset-size10.size3,.katex .sizing.reset-size10.size3{font-size:.337512054em}.katex .fontsize-ensurer.reset-size10.size4,.katex .sizing.reset-size10.size4{font-size:.3857280617em}.katex .fontsize-ensurer.reset-size10.size5,.katex .sizing.reset-size10.size5{font-size:.4339440694em}.katex .fontsize-ensurer.reset-size10.size6,.katex .sizing.reset-size10.size6{font-size:.4821600771em}.katex .fontsize-ensurer.reset-size10.size7,.katex .sizing.reset-size10.size7{font-size:.5785920926em}.katex .fontsize-ensurer.reset-size10.size8,.katex .sizing.reset-size10.size8{font-size:.6943105111em}.katex .fontsize-ensurer.reset-size10.size9,.katex .sizing.reset-size10.size9{font-size:.8331726133em}.katex .fontsize-ensurer.reset-size10.size10,.katex .sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .sizing.reset-size10.size11{font-size:1.1996142719em}.katex .fontsize-ensurer.reset-size11.size1,.katex .sizing.reset-size11.size1{font-size:.2009646302em}.katex .fontsize-ensurer.reset-size11.size2,.katex .sizing.reset-size11.size2{font-size:.2411575563em}.katex .fontsize-ensurer.reset-size11.size3,.katex .sizing.reset-size11.size3{font-size:.2813504823em}.katex .fontsize-ensurer.reset-size11.size4,.katex .sizing.reset-size11.size4{font-size:.3215434084em}.katex .fontsize-ensurer.reset-size11.size5,.katex .sizing.reset-size11.size5{font-size:.3617363344em}.katex .fontsize-ensurer.reset-size11.size6,.katex .sizing.reset-size11.size6{font-size:.4019292605em}.katex .fontsize-ensurer.reset-size11.size7,.katex .sizing.reset-size11.size7{font-size:.4823151125em}.katex .fontsize-ensurer.reset-size11.size8,.katex .sizing.reset-size11.size8{font-size:.578778135em}.katex .fontsize-ensurer.reset-size11.size9,.katex .sizing.reset-size11.size9{font-size:.6945337621em}.katex .fontsize-ensurer.reset-size11.size10,.katex .sizing.reset-size11.size10{font-size:.8336012862em}.katex .fontsize-ensurer.reset-size11.size11,.katex .sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .accent>.vlist-t,.katex .op-limits>.vlist-t{text-align:center}.katex .accent .accent-body{position:relative}.katex .accent .accent-body:not(.accent-full){width:0}.katex .overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{fill:currentColor;stroke:currentColor;fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;display:block;height:inherit;position:absolute;width:100%}.katex svg path{stroke:none}.katex img{border-style:none;max-height:none;max-width:none;min-height:0;min-width:0}.katex .stretchy{display:block;overflow:hidden;position:relative;width:100%}.katex .stretchy:after,.katex .stretchy:before{content:""}.katex .hide-tail{overflow:hidden;position:relative;width:100%}.katex .halfarrow-left{left:0;overflow:hidden;position:absolute;width:50.2%}.katex .halfarrow-right{overflow:hidden;position:absolute;right:0;width:50.2%}.katex .brace-left{left:0;overflow:hidden;position:absolute;width:25.1%}.katex .brace-center{left:25%;overflow:hidden;position:absolute;width:50%}.katex .brace-right{overflow:hidden;position:absolute;right:0;width:25.1%}.katex .x-arrow-pad{padding:0 .5em}.katex .cd-arrow-pad{padding:0 .55556em 0 .27778em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{border:.04em solid;box-sizing:border-box}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .sout{border-bottom-style:solid;border-bottom-width:.08em}.katex .angl{border-right:.049em solid;border-top:.049em solid;box-sizing:border-box;margin-right:.03889em}.katex .anglpad{padding:0 .03889em}.katex .eqn-num:before{content:"(" counter(katexEqnNo) ")";counter-increment:katexEqnNo}.katex .mml-eqn-num:before{content:"(" counter(mmlEqnNo) ")";counter-increment:mmlEqnNo}.katex .mtr-glue{width:50%}.katex .cd-vert-arrow{display:inline-block;position:relative}.katex .cd-label-left{display:inline-block;position:absolute;right:calc(50% + .3em);text-align:left}.katex .cd-label-right{display:inline-block;left:calc(50% + .3em);position:absolute;text-align:right}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.tag{left:0;right:auto}.katex-display.fleqn>.katex{padding-left:2em;text-align:left}body{counter-reset:katexEqnNo mmlEqnNo} diff --git a/ui/web/katex/katex.min.js b/ui/web/katex/katex.min.js new file mode 100644 index 0000000..b69360c --- /dev/null +++ b/ui/web/katex/katex.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Yn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15":">","<":"<",'"':""","'":"'"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format "},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color ",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro ",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness ",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size ",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand ",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,e=e||{};for(const t in h)if(h.hasOwnProperty(t)){const r=h[t];this[t]=void 0!==e[t]?r.processor?r.processor(e[t]):e[t]:c(r)}}reportNonstrict(e,t,r){let o=this.strict;if("function"==typeof o&&(o=o(e,t,r)),o&&"ignore"!==o){if(!0===o||"error"===o)throw new n("LaTeX-incompatible input and strict mode is set to 'error': "+t+" ["+e+"]",r);"warn"===o?"undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+t+" ["+e+"]"):"undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to unrecognized '"+o+"': "+t+" ["+e+"]")}}useStrictBehavior(e,t,r){let n=this.strict;if("function"==typeof n)try{n=n(e,t,r)}catch(e){n="error"}return!(!n||"ignore"===n)&&(!0===n||"error"===n||("warn"===n?("undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+t+" ["+e+"]"),!1):("undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to unrecognized '"+n+"': "+t+" ["+e+"]"),!1)))}isTrusted(e){if(e.url&&!e.protocol){const t=l.protocolFromUrl(e.url);if(null==t)return!1;e.protocol=t}const t="function"==typeof this.trust?this.trust(e):this.trust;return Boolean(t)}}class p{constructor(e,t,r){this.id=void 0,this.size=void 0,this.cramped=void 0,this.id=e,this.size=t,this.cramped=r}sup(){return u[d[this.id]]}sub(){return u[g[this.id]]}fracNum(){return u[f[this.id]]}fracDen(){return u[b[this.id]]}cramp(){return u[y[this.id]]}text(){return u[x[this.id]]}isTight(){return this.size>=2}}const u=[new p(0,0,!1),new p(1,0,!0),new p(2,1,!1),new p(3,1,!0),new p(4,2,!1),new p(5,2,!0),new p(6,3,!1),new p(7,3,!0)],d=[4,5,4,5,6,7,6,7],g=[5,5,5,5,7,7,7,7],f=[2,3,4,5,6,7,6,7],b=[3,3,5,5,7,7,7,7],y=[1,1,3,3,5,5,7,7],x=[0,1,2,3,2,3,2,3];var w={DISPLAY:u[0],TEXT:u[2],SCRIPT:u[4],SCRIPTSCRIPT:u[6]};const v=[{name:"latin",blocks:[[256,591],[768,879]]},{name:"cyrillic",blocks:[[1024,1279]]},{name:"armenian",blocks:[[1328,1423]]},{name:"brahmic",blocks:[[2304,4255]]},{name:"georgian",blocks:[[4256,4351]]},{name:"cjk",blocks:[[12288,12543],[19968,40879],[65280,65376]]},{name:"hangul",blocks:[[44032,55215]]}];const k=[];function S(e){for(let t=0;t=k[t]&&e<=k[t+1])return!0;return!1}v.forEach((e=>e.blocks.forEach((e=>k.push(...e)))));const M=80,z={doubleleftarrow:"M262 157\nl10-10c34-36 62.7-77 86-123 3.3-8 5-13.3 5-16 0-5.3-6.7-8-20-8-7.3\n 0-12.2.5-14.5 1.5-2.3 1-4.8 4.5-7.5 10.5-49.3 97.3-121.7 169.3-217 216-28\n 14-57.3 25-88 33-6.7 2-11 3.8-13 5.5-2 1.7-3 4.2-3 7.5s1 5.8 3 7.5\nc2 1.7 6.3 3.5 13 5.5 68 17.3 128.2 47.8 180.5 91.5 52.3 43.7 93.8 96.2 124.5\n 157.5 9.3 8 15.3 12.3 18 13h6c12-.7 18-4 18-10 0-2-1.7-7-5-15-23.3-46-52-87\n-86-123l-10-10h399738v-40H218c328 0 0 0 0 0l-10-8c-26.7-20-65.7-43-117-69 2.7\n-2 6-3.7 10-5 36.7-16 72.3-37.3 107-64l10-8h399782v-40z\nm8 0v40h399730v-40zm0 194v40h399730v-40z",doublerightarrow:"M399738 392l\n-10 10c-34 36-62.7 77-86 123-3.3 8-5 13.3-5 16 0 5.3 6.7 8 20 8 7.3 0 12.2-.5\n 14.5-1.5 2.3-1 4.8-4.5 7.5-10.5 49.3-97.3 121.7-169.3 217-216 28-14 57.3-25 88\n-33 6.7-2 11-3.8 13-5.5 2-1.7 3-4.2 3-7.5s-1-5.8-3-7.5c-2-1.7-6.3-3.5-13-5.5-68\n-17.3-128.2-47.8-180.5-91.5-52.3-43.7-93.8-96.2-124.5-157.5-9.3-8-15.3-12.3-18\n-13h-6c-12 .7-18 4-18 10 0 2 1.7 7 5 15 23.3 46 52 87 86 123l10 10H0v40h399782\nc-328 0 0 0 0 0l10 8c26.7 20 65.7 43 117 69-2.7 2-6 3.7-10 5-36.7 16-72.3 37.3\n-107 64l-10 8H0v40zM0 157v40h399730v-40zm0 194v40h399730v-40z",leftarrow:"M400000 241H110l3-3c68.7-52.7 113.7-120\n 135-202 4-14.7 6-23 6-25 0-7.3-7-11-21-11-8 0-13.2.8-15.5 2.5-2.3 1.7-4.2 5.8\n-5.5 12.5-1.3 4.7-2.7 10.3-4 17-12 48.7-34.8 92-68.5 130S65.3 228.3 18 247\nc-10 4-16 7.7-18 11 0 8.7 6 14.3 18 17 47.3 18.7 87.8 47 121.5 85S196 441.3 208\n 490c.7 2 1.3 5 2 9s1.2 6.7 1.5 8c.3 1.3 1 3.3 2 6s2.2 4.5 3.5 5.5c1.3 1 3.3\n 1.8 6 2.5s6 1 10 1c14 0 21-3.7 21-11 0-2-2-10.3-6-25-20-79.3-65-146.7-135-202\n l-3-3h399890zM100 241v40h399900v-40z",leftbrace:"M6 548l-6-6v-35l6-11c56-104 135.3-181.3 238-232 57.3-28.7 117\n-45 179-50h399577v120H403c-43.3 7-81 15-113 26-100.7 33-179.7 91-237 174-2.7\n 5-6 9-10 13-.7 1-7.3 1-20 1H6z",leftbraceunder:"M0 6l6-6h17c12.688 0 19.313.3 20 1 4 4 7.313 8.3 10 13\n 35.313 51.3 80.813 93.8 136.5 127.5 55.688 33.7 117.188 55.8 184.5 66.5.688\n 0 2 .3 4 1 18.688 2.7 76 4.3 172 5h399450v120H429l-6-1c-124.688-8-235-61.7\n-331-161C60.687 138.7 32.312 99.3 7 54L0 41V6z",leftgroup:"M400000 80\nH435C64 80 168.3 229.4 21 260c-5.9 1.2-18 0-18 0-2 0-3-1-3-3v-38C76 61 257 0\n 435 0h399565z",leftgroupunder:"M400000 262\nH435C64 262 168.3 112.6 21 82c-5.9-1.2-18 0-18 0-2 0-3 1-3 3v38c76 158 257 219\n 435 219h399565z",leftharpoon:"M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3\n-3.3 10.2-9.5 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5\n-18.3 3-21-1.3-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7\n-196 228-6.7 4.7-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40z",leftharpoonplus:"M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3-3.3 10.2-9.5\n 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5-18.3 3-21-1.3\n-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7-196 228-6.7 4.7\n-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40zM0 435v40h400000v-40z\nm0 0v40h400000v-40z",leftharpoondown:"M7 241c-4 4-6.333 8.667-7 14 0 5.333.667 9 2 11s5.333\n 5.333 12 10c90.667 54 156 130 196 228 3.333 10.667 6.333 16.333 9 17 2 .667 5\n 1 9 1h5c10.667 0 16.667-2 18-6 2-2.667 1-9.667-3-21-32-87.333-82.667-157.667\n-152-211l-3-3h399907v-40zM93 281 H400000 v-40L7 241z",leftharpoondownplus:"M7 435c-4 4-6.3 8.7-7 14 0 5.3.7 9 2 11s5.3 5.3 12\n 10c90.7 54 156 130 196 228 3.3 10.7 6.3 16.3 9 17 2 .7 5 1 9 1h5c10.7 0 16.7\n-2 18-6 2-2.7 1-9.7-3-21-32-87.3-82.7-157.7-152-211l-3-3h399907v-40H7zm93 0\nv40h399900v-40zM0 241v40h399900v-40zm0 0v40h399900v-40z",lefthook:"M400000 281 H103s-33-11.2-61-33.5S0 197.3 0 164s14.2-61.2 42.5\n-83.5C70.8 58.2 104 47 142 47 c16.7 0 25 6.7 25 20 0 12-8.7 18.7-26 20-40 3.3\n-68.7 15.7-86 37-10 12-15 25.3-15 40 0 22.7 9.8 40.7 29.5 54 19.7 13.3 43.5 21\n 71.5 23h399859zM103 281v-40h399897v40z",leftlinesegment:"M40 281 V428 H0 V94 H40 V241 H400000 v40z\nM40 281 V428 H0 V94 H40 V241 H400000 v40z",leftmapsto:"M40 281 V448H0V74H40V241H400000v40z\nM40 281 V448H0V74H40V241H400000v40z",leftToFrom:"M0 147h400000v40H0zm0 214c68 40 115.7 95.7 143 167h22c15.3 0 23\n-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69-70-101l-7-8h399905v-40H95l7-8\nc28.7-32 52-65.7 70-101 10.7-23.3 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 265.3\n 68 321 0 361zm0-174v-40h399900v40zm100 154v40h399900v-40z",longequal:"M0 50 h400000 v40H0z m0 194h40000v40H0z\nM0 50 h400000 v40H0z m0 194h40000v40H0z",midbrace:"M200428 334\nc-100.7-8.3-195.3-44-280-108-55.3-42-101.7-93-139-153l-9-14c-2.7 4-5.7 8.7-9 14\n-53.3 86.7-123.7 153-211 199-66.7 36-137.3 56.3-212 62H0V214h199568c178.3-11.7\n 311.7-78.3 403-201 6-8 9.7-12 11-12 .7-.7 6.7-1 18-1s17.3.3 18 1c1.3 0 5 4 11\n 12 44.7 59.3 101.3 106.3 170 141s145.3 54.3 229 60h199572v120z",midbraceunder:"M199572 214\nc100.7 8.3 195.3 44 280 108 55.3 42 101.7 93 139 153l9 14c2.7-4 5.7-8.7 9-14\n 53.3-86.7 123.7-153 211-199 66.7-36 137.3-56.3 212-62h199568v120H200432c-178.3\n 11.7-311.7 78.3-403 201-6 8-9.7 12-11 12-.7.7-6.7 1-18 1s-17.3-.3-18-1c-1.3 0\n-5-4-11-12-44.7-59.3-101.3-106.3-170-141s-145.3-54.3-229-60H0V214z",oiintSize1:"M512.6 71.6c272.6 0 320.3 106.8 320.3 178.2 0 70.8-47.7 177.6\n-320.3 177.6S193.1 320.6 193.1 249.8c0-71.4 46.9-178.2 319.5-178.2z\nm368.1 178.2c0-86.4-60.9-215.4-368.1-215.4-306.4 0-367.3 129-367.3 215.4 0 85.8\n60.9 214.8 367.3 214.8 307.2 0 368.1-129 368.1-214.8z",oiintSize2:"M757.8 100.1c384.7 0 451.1 137.6 451.1 230 0 91.3-66.4 228.8\n-451.1 228.8-386.3 0-452.7-137.5-452.7-228.8 0-92.4 66.4-230 452.7-230z\nm502.4 230c0-111.2-82.4-277.2-502.4-277.2s-504 166-504 277.2\nc0 110 84 276 504 276s502.4-166 502.4-276z",oiiintSize1:"M681.4 71.6c408.9 0 480.5 106.8 480.5 178.2 0 70.8-71.6 177.6\n-480.5 177.6S202.1 320.6 202.1 249.8c0-71.4 70.5-178.2 479.3-178.2z\nm525.8 178.2c0-86.4-86.8-215.4-525.7-215.4-437.9 0-524.7 129-524.7 215.4 0\n85.8 86.8 214.8 524.7 214.8 438.9 0 525.7-129 525.7-214.8z",oiiintSize2:"M1021.2 53c603.6 0 707.8 165.8 707.8 277.2 0 110-104.2 275.8\n-707.8 275.8-606 0-710.2-165.8-710.2-275.8C311 218.8 415.2 53 1021.2 53z\nm770.4 277.1c0-131.2-126.4-327.6-770.5-327.6S248.4 198.9 248.4 330.1\nc0 130 128.8 326.4 772.7 326.4s770.5-196.4 770.5-326.4z",rightarrow:"M0 241v40h399891c-47.3 35.3-84 78-110 128\n-16.7 32-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20\n 11 8 0 13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7\n 39-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85\n-40.5-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5\n-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67\n 151.7 139 205zm0 0v40h399900v-40z",rightbrace:"M400000 542l\n-6 6h-17c-12.7 0-19.3-.3-20-1-4-4-7.3-8.3-10-13-35.3-51.3-80.8-93.8-136.5-127.5\ns-117.2-55.8-184.5-66.5c-.7 0-2-.3-4-1-18.7-2.7-76-4.3-172-5H0V214h399571l6 1\nc124.7 8 235 61.7 331 161 31.3 33.3 59.7 72.7 85 118l7 13v35z",rightbraceunder:"M399994 0l6 6v35l-6 11c-56 104-135.3 181.3-238 232-57.3\n 28.7-117 45-179 50H-300V214h399897c43.3-7 81-15 113-26 100.7-33 179.7-91 237\n-174 2.7-5 6-9 10-13 .7-1 7.3-1 20-1h17z",rightgroup:"M0 80h399565c371 0 266.7 149.4 414 180 5.9 1.2 18 0 18 0 2 0\n 3-1 3-3v-38c-76-158-257-219-435-219H0z",rightgroupunder:"M0 262h399565c371 0 266.7-149.4 414-180 5.9-1.2 18 0 18\n 0 2 0 3 1 3 3v38c-76 158-257 219-435 219H0z",rightharpoon:"M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3\n-3.7-15.3-11-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2\n-10.7 0-16.7 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58\n 69.2 92 94.5zm0 0v40h399900v-40z",rightharpoonplus:"M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3-3.7-15.3-11\n-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2-10.7 0-16.7\n 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 69.2 92 94.5z\nm0 0v40h399900v-40z m100 194v40h399900v-40zm0 0v40h399900v-40z",rightharpoondown:"M399747 511c0 7.3 6.7 11 20 11 8 0 13-.8 15-2.5s4.7-6.8\n 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 8.5-5.8 9.5\n-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3-64.7 57-92 95\n-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 241v40h399900v-40z",rightharpoondownplus:"M399747 705c0 7.3 6.7 11 20 11 8 0 13-.8\n 15-2.5s4.7-6.8 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3\n 8.5-5.8 9.5-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3\n-64.7 57-92 95-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 435v40h399900v-40z\nm0-194v40h400000v-40zm0 0v40h400000v-40z",righthook:"M399859 241c-764 0 0 0 0 0 40-3.3 68.7-15.7 86-37 10-12 15-25.3\n 15-40 0-22.7-9.8-40.7-29.5-54-19.7-13.3-43.5-21-71.5-23-17.3-1.3-26-8-26-20 0\n-13.3 8.7-20 26-20 38 0 71 11.2 99 33.5 0 0 7 5.6 21 16.7 14 11.2 21 33.5 21\n 66.8s-14 61.2-42 83.5c-28 22.3-61 33.5-99 33.5L0 241z M0 281v-40h399859v40z",rightlinesegment:"M399960 241 V94 h40 V428 h-40 V281 H0 v-40z\nM399960 241 V94 h40 V428 h-40 V281 H0 v-40z",rightToFrom:"M400000 167c-70.7-42-118-97.7-142-167h-23c-15.3 0-23 .3-23\n 1 0 1.3 5.3 13.7 16 37 18 35.3 41.3 69 70 101l7 8H0v40h399905l-7 8c-28.7 32\n-52 65.7-70 101-10.7 23.3-16 35.7-16 37 0 .7 7.7 1 23 1h23c24-69.3 71.3-125 142\n-167z M100 147v40h399900v-40zM0 341v40h399900v-40z",twoheadleftarrow:"M0 167c68 40\n 115.7 95.7 143 167h22c15.3 0 23-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69\n-70-101l-7-8h125l9 7c50.7 39.3 85 86 103 140h46c0-4.7-6.3-18.7-19-42-18-35.3\n-40-67.3-66-96l-9-9h399716v-40H284l9-9c26-28.7 48-60.7 66-96 12.7-23.333 19\n-37.333 19-42h-46c-18 54-52.3 100.7-103 140l-9 7H95l7-8c28.7-32 52-65.7 70-101\n 10.7-23.333 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 71.3 68 127 0 167z",twoheadrightarrow:"M400000 167\nc-68-40-115.7-95.7-143-167h-22c-15.3 0-23 .3-23 1 0 1.3 5.3 13.7 16 37 18 35.3\n 41.3 69 70 101l7 8h-125l-9-7c-50.7-39.3-85-86-103-140h-46c0 4.7 6.3 18.7 19 42\n 18 35.3 40 67.3 66 96l9 9H0v40h399716l-9 9c-26 28.7-48 60.7-66 96-12.7 23.333\n-19 37.333-19 42h46c18-54 52.3-100.7 103-140l9-7h125l-7 8c-28.7 32-52 65.7-70\n 101-10.7 23.333-16 35.7-16 37 0 .7 7.7 1 23 1h22c27.3-71.3 75-127 143-167z",tilde1:"M200 55.538c-77 0-168 73.953-177 73.953-3 0-7\n-2.175-9-5.437L2 97c-1-2-2-4-2-6 0-4 2-7 5-9l20-12C116 12 171 0 207 0c86 0\n 114 68 191 68 78 0 168-68 177-68 4 0 7 2 9 5l12 19c1 2.175 2 4.35 2 6.525 0\n 4.35-2 7.613-5 9.788l-19 13.05c-92 63.077-116.937 75.308-183 76.128\n-68.267.847-113-73.952-191-73.952z",tilde2:"M344 55.266c-142 0-300.638 81.316-311.5 86.418\n-8.01 3.762-22.5 10.91-23.5 5.562L1 120c-1-2-1-3-1-4 0-5 3-9 8-10l18.4-9C160.9\n 31.9 283 0 358 0c148 0 188 122 331 122s314-97 326-97c4 0 8 2 10 7l7 21.114\nc1 2.14 1 3.21 1 4.28 0 5.347-3 9.626-7 10.696l-22.3 12.622C852.6 158.372 751\n 181.476 676 181.476c-149 0-189-126.21-332-126.21z",tilde3:"M786 59C457 59 32 175.242 13 175.242c-6 0-10-3.457\n-11-10.37L.15 138c-1-7 3-12 10-13l19.2-6.4C378.4 40.7 634.3 0 804.3 0c337 0\n 411.8 157 746.8 157 328 0 754-112 773-112 5 0 10 3 11 9l1 14.075c1 8.066-.697\n 16.595-6.697 17.492l-21.052 7.31c-367.9 98.146-609.15 122.696-778.15 122.696\n -338 0-409-156.573-744-156.573z",tilde4:"M786 58C457 58 32 177.487 13 177.487c-6 0-10-3.345\n-11-10.035L.15 143c-1-7 3-12 10-13l22-6.7C381.2 35 637.15 0 807.15 0c337 0 409\n 177 744 177 328 0 754-127 773-127 5 0 10 3 11 9l1 14.794c1 7.805-3 13.38-9\n 14.495l-20.7 5.574c-366.85 99.79-607.3 139.372-776.3 139.372-338 0-409\n -175.236-744-175.236z",vec:"M377 20c0-5.333 1.833-10 5.5-14S391 0 397 0c4.667 0 8.667 1.667 12 5\n3.333 2.667 6.667 9 10 19 6.667 24.667 20.333 43.667 41 57 7.333 4.667 11\n10.667 11 18 0 6-1 10-3 12s-6.667 5-14 9c-28.667 14.667-53.667 35.667-75 63\n-1.333 1.333-3.167 3.5-5.5 6.5s-4 4.833-5 5.5c-1 .667-2.5 1.333-4.5 2s-4.333 1\n-7 1c-4.667 0-9.167-1.833-13.5-5.5S337 184 337 178c0-12.667 15.667-32.333 47-59\nH213l-171-1c-8.667-6-13-12.333-13-19 0-4.667 4.333-11.333 13-20h359\nc-16-25.333-24-45-24-59z",widehat1:"M529 0h5l519 115c5 1 9 5 9 10 0 1-1 2-1 3l-4 22\nc-1 5-5 9-11 9h-2L532 67 19 159h-2c-5 0-9-4-11-9l-5-22c-1-6 2-12 8-13z",widehat2:"M1181 0h2l1171 176c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 220h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widehat3:"M1181 0h2l1171 236c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 280h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widehat4:"M1181 0h2l1171 296c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 340h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widecheck1:"M529,159h5l519,-115c5,-1,9,-5,9,-10c0,-1,-1,-2,-1,-3l-4,-22c-1,\n-5,-5,-9,-11,-9h-2l-512,92l-513,-92h-2c-5,0,-9,4,-11,9l-5,22c-1,6,2,12,8,13z",widecheck2:"M1181,220h2l1171,-176c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,153l-1167,-153h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",widecheck3:"M1181,280h2l1171,-236c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,213l-1167,-213h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",widecheck4:"M1181,340h2l1171,-296c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,273l-1167,-273h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",baraboveleftarrow:"M400000 620h-399890l3 -3c68.7 -52.7 113.7 -120 135 -202\nc4 -14.7 6 -23 6 -25c0 -7.3 -7 -11 -21 -11c-8 0 -13.2 0.8 -15.5 2.5\nc-2.3 1.7 -4.2 5.8 -5.5 12.5c-1.3 4.7 -2.7 10.3 -4 17c-12 48.7 -34.8 92 -68.5 130\ns-74.2 66.3 -121.5 85c-10 4 -16 7.7 -18 11c0 8.7 6 14.3 18 17c47.3 18.7 87.8 47\n121.5 85s56.5 81.3 68.5 130c0.7 2 1.3 5 2 9s1.2 6.7 1.5 8c0.3 1.3 1 3.3 2 6\ns2.2 4.5 3.5 5.5c1.3 1 3.3 1.8 6 2.5s6 1 10 1c14 0 21 -3.7 21 -11\nc0 -2 -2 -10.3 -6 -25c-20 -79.3 -65 -146.7 -135 -202l-3 -3h399890z\nM100 620v40h399900v-40z M0 241v40h399900v-40zM0 241v40h399900v-40z",rightarrowabovebar:"M0 241v40h399891c-47.3 35.3-84 78-110 128-16.7 32\n-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 11 8 0\n13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 39\n-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85-40.5\n-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5\n-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67\n151.7 139 205zm96 379h399894v40H0zm0 0h399904v40H0z",baraboveshortleftharpoon:"M507,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11\nc1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17\nc2,0.7,5,1,9,1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21\nc-32,-87.3,-82.7,-157.7,-152,-211c0,0,-3,-3,-3,-3l399351,0l0,-40\nc-398570,0,-399437,0,-399437,0z M593 435 v40 H399500 v-40z\nM0 281 v-40 H399908 v40z M0 281 v-40 H399908 v40z",rightharpoonaboveshortbar:"M0,241 l0,40c399126,0,399993,0,399993,0\nc4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199,\n-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6\nc-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z\nM0 241 v40 H399908 v-40z M0 475 v-40 H399500 v40z M0 475 v-40 H399500 v40z",shortbaraboveleftharpoon:"M7,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11\nc1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17c2,0.7,5,1,9,\n1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21c-32,-87.3,-82.7,-157.7,\n-152,-211c0,0,-3,-3,-3,-3l399907,0l0,-40c-399126,0,-399993,0,-399993,0z\nM93 435 v40 H400000 v-40z M500 241 v40 H400000 v-40z M500 241 v40 H400000 v-40z",shortrightharpoonabovebar:"M53,241l0,40c398570,0,399437,0,399437,0\nc4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199,\n-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6\nc-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z\nM500 241 v40 H399408 v-40z M500 435 v40 H400000 v-40z"};class A{constructor(e){this.children=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.children=e,this.classes=[],this.height=0,this.depth=0,this.maxFontSize=0,this.style={}}hasClass(e){return l.contains(this.classes,e)}toNode(){const e=document.createDocumentFragment();for(let t=0;te.toText())).join("")}}var T={"AMS-Regular":{32:[0,0,0,0,.25],65:[0,.68889,0,0,.72222],66:[0,.68889,0,0,.66667],67:[0,.68889,0,0,.72222],68:[0,.68889,0,0,.72222],69:[0,.68889,0,0,.66667],70:[0,.68889,0,0,.61111],71:[0,.68889,0,0,.77778],72:[0,.68889,0,0,.77778],73:[0,.68889,0,0,.38889],74:[.16667,.68889,0,0,.5],75:[0,.68889,0,0,.77778],76:[0,.68889,0,0,.66667],77:[0,.68889,0,0,.94445],78:[0,.68889,0,0,.72222],79:[.16667,.68889,0,0,.77778],80:[0,.68889,0,0,.61111],81:[.16667,.68889,0,0,.77778],82:[0,.68889,0,0,.72222],83:[0,.68889,0,0,.55556],84:[0,.68889,0,0,.66667],85:[0,.68889,0,0,.72222],86:[0,.68889,0,0,.72222],87:[0,.68889,0,0,1],88:[0,.68889,0,0,.72222],89:[0,.68889,0,0,.72222],90:[0,.68889,0,0,.66667],107:[0,.68889,0,0,.55556],160:[0,0,0,0,.25],165:[0,.675,.025,0,.75],174:[.15559,.69224,0,0,.94666],240:[0,.68889,0,0,.55556],295:[0,.68889,0,0,.54028],710:[0,.825,0,0,2.33334],732:[0,.9,0,0,2.33334],770:[0,.825,0,0,2.33334],771:[0,.9,0,0,2.33334],989:[.08167,.58167,0,0,.77778],1008:[0,.43056,.04028,0,.66667],8245:[0,.54986,0,0,.275],8463:[0,.68889,0,0,.54028],8487:[0,.68889,0,0,.72222],8498:[0,.68889,0,0,.55556],8502:[0,.68889,0,0,.66667],8503:[0,.68889,0,0,.44445],8504:[0,.68889,0,0,.66667],8513:[0,.68889,0,0,.63889],8592:[-.03598,.46402,0,0,.5],8594:[-.03598,.46402,0,0,.5],8602:[-.13313,.36687,0,0,1],8603:[-.13313,.36687,0,0,1],8606:[.01354,.52239,0,0,1],8608:[.01354,.52239,0,0,1],8610:[.01354,.52239,0,0,1.11111],8611:[.01354,.52239,0,0,1.11111],8619:[0,.54986,0,0,1],8620:[0,.54986,0,0,1],8621:[-.13313,.37788,0,0,1.38889],8622:[-.13313,.36687,0,0,1],8624:[0,.69224,0,0,.5],8625:[0,.69224,0,0,.5],8630:[0,.43056,0,0,1],8631:[0,.43056,0,0,1],8634:[.08198,.58198,0,0,.77778],8635:[.08198,.58198,0,0,.77778],8638:[.19444,.69224,0,0,.41667],8639:[.19444,.69224,0,0,.41667],8642:[.19444,.69224,0,0,.41667],8643:[.19444,.69224,0,0,.41667],8644:[.1808,.675,0,0,1],8646:[.1808,.675,0,0,1],8647:[.1808,.675,0,0,1],8648:[.19444,.69224,0,0,.83334],8649:[.1808,.675,0,0,1],8650:[.19444,.69224,0,0,.83334],8651:[.01354,.52239,0,0,1],8652:[.01354,.52239,0,0,1],8653:[-.13313,.36687,0,0,1],8654:[-.13313,.36687,0,0,1],8655:[-.13313,.36687,0,0,1],8666:[.13667,.63667,0,0,1],8667:[.13667,.63667,0,0,1],8669:[-.13313,.37788,0,0,1],8672:[-.064,.437,0,0,1.334],8674:[-.064,.437,0,0,1.334],8705:[0,.825,0,0,.5],8708:[0,.68889,0,0,.55556],8709:[.08167,.58167,0,0,.77778],8717:[0,.43056,0,0,.42917],8722:[-.03598,.46402,0,0,.5],8724:[.08198,.69224,0,0,.77778],8726:[.08167,.58167,0,0,.77778],8733:[0,.69224,0,0,.77778],8736:[0,.69224,0,0,.72222],8737:[0,.69224,0,0,.72222],8738:[.03517,.52239,0,0,.72222],8739:[.08167,.58167,0,0,.22222],8740:[.25142,.74111,0,0,.27778],8741:[.08167,.58167,0,0,.38889],8742:[.25142,.74111,0,0,.5],8756:[0,.69224,0,0,.66667],8757:[0,.69224,0,0,.66667],8764:[-.13313,.36687,0,0,.77778],8765:[-.13313,.37788,0,0,.77778],8769:[-.13313,.36687,0,0,.77778],8770:[-.03625,.46375,0,0,.77778],8774:[.30274,.79383,0,0,.77778],8776:[-.01688,.48312,0,0,.77778],8778:[.08167,.58167,0,0,.77778],8782:[.06062,.54986,0,0,.77778],8783:[.06062,.54986,0,0,.77778],8785:[.08198,.58198,0,0,.77778],8786:[.08198,.58198,0,0,.77778],8787:[.08198,.58198,0,0,.77778],8790:[0,.69224,0,0,.77778],8791:[.22958,.72958,0,0,.77778],8796:[.08198,.91667,0,0,.77778],8806:[.25583,.75583,0,0,.77778],8807:[.25583,.75583,0,0,.77778],8808:[.25142,.75726,0,0,.77778],8809:[.25142,.75726,0,0,.77778],8812:[.25583,.75583,0,0,.5],8814:[.20576,.70576,0,0,.77778],8815:[.20576,.70576,0,0,.77778],8816:[.30274,.79383,0,0,.77778],8817:[.30274,.79383,0,0,.77778],8818:[.22958,.72958,0,0,.77778],8819:[.22958,.72958,0,0,.77778],8822:[.1808,.675,0,0,.77778],8823:[.1808,.675,0,0,.77778],8828:[.13667,.63667,0,0,.77778],8829:[.13667,.63667,0,0,.77778],8830:[.22958,.72958,0,0,.77778],8831:[.22958,.72958,0,0,.77778],8832:[.20576,.70576,0,0,.77778],8833:[.20576,.70576,0,0,.77778],8840:[.30274,.79383,0,0,.77778],8841:[.30274,.79383,0,0,.77778],8842:[.13597,.63597,0,0,.77778],8843:[.13597,.63597,0,0,.77778],8847:[.03517,.54986,0,0,.77778],8848:[.03517,.54986,0,0,.77778],8858:[.08198,.58198,0,0,.77778],8859:[.08198,.58198,0,0,.77778],8861:[.08198,.58198,0,0,.77778],8862:[0,.675,0,0,.77778],8863:[0,.675,0,0,.77778],8864:[0,.675,0,0,.77778],8865:[0,.675,0,0,.77778],8872:[0,.69224,0,0,.61111],8873:[0,.69224,0,0,.72222],8874:[0,.69224,0,0,.88889],8876:[0,.68889,0,0,.61111],8877:[0,.68889,0,0,.61111],8878:[0,.68889,0,0,.72222],8879:[0,.68889,0,0,.72222],8882:[.03517,.54986,0,0,.77778],8883:[.03517,.54986,0,0,.77778],8884:[.13667,.63667,0,0,.77778],8885:[.13667,.63667,0,0,.77778],8888:[0,.54986,0,0,1.11111],8890:[.19444,.43056,0,0,.55556],8891:[.19444,.69224,0,0,.61111],8892:[.19444,.69224,0,0,.61111],8901:[0,.54986,0,0,.27778],8903:[.08167,.58167,0,0,.77778],8905:[.08167,.58167,0,0,.77778],8906:[.08167,.58167,0,0,.77778],8907:[0,.69224,0,0,.77778],8908:[0,.69224,0,0,.77778],8909:[-.03598,.46402,0,0,.77778],8910:[0,.54986,0,0,.76042],8911:[0,.54986,0,0,.76042],8912:[.03517,.54986,0,0,.77778],8913:[.03517,.54986,0,0,.77778],8914:[0,.54986,0,0,.66667],8915:[0,.54986,0,0,.66667],8916:[0,.69224,0,0,.66667],8918:[.0391,.5391,0,0,.77778],8919:[.0391,.5391,0,0,.77778],8920:[.03517,.54986,0,0,1.33334],8921:[.03517,.54986,0,0,1.33334],8922:[.38569,.88569,0,0,.77778],8923:[.38569,.88569,0,0,.77778],8926:[.13667,.63667,0,0,.77778],8927:[.13667,.63667,0,0,.77778],8928:[.30274,.79383,0,0,.77778],8929:[.30274,.79383,0,0,.77778],8934:[.23222,.74111,0,0,.77778],8935:[.23222,.74111,0,0,.77778],8936:[.23222,.74111,0,0,.77778],8937:[.23222,.74111,0,0,.77778],8938:[.20576,.70576,0,0,.77778],8939:[.20576,.70576,0,0,.77778],8940:[.30274,.79383,0,0,.77778],8941:[.30274,.79383,0,0,.77778],8994:[.19444,.69224,0,0,.77778],8995:[.19444,.69224,0,0,.77778],9416:[.15559,.69224,0,0,.90222],9484:[0,.69224,0,0,.5],9488:[0,.69224,0,0,.5],9492:[0,.37788,0,0,.5],9496:[0,.37788,0,0,.5],9585:[.19444,.68889,0,0,.88889],9586:[.19444,.74111,0,0,.88889],9632:[0,.675,0,0,.77778],9633:[0,.675,0,0,.77778],9650:[0,.54986,0,0,.72222],9651:[0,.54986,0,0,.72222],9654:[.03517,.54986,0,0,.77778],9660:[0,.54986,0,0,.72222],9661:[0,.54986,0,0,.72222],9664:[.03517,.54986,0,0,.77778],9674:[.11111,.69224,0,0,.66667],9733:[.19444,.69224,0,0,.94445],10003:[0,.69224,0,0,.83334],10016:[0,.69224,0,0,.83334],10731:[.11111,.69224,0,0,.66667],10846:[.19444,.75583,0,0,.61111],10877:[.13667,.63667,0,0,.77778],10878:[.13667,.63667,0,0,.77778],10885:[.25583,.75583,0,0,.77778],10886:[.25583,.75583,0,0,.77778],10887:[.13597,.63597,0,0,.77778],10888:[.13597,.63597,0,0,.77778],10889:[.26167,.75726,0,0,.77778],10890:[.26167,.75726,0,0,.77778],10891:[.48256,.98256,0,0,.77778],10892:[.48256,.98256,0,0,.77778],10901:[.13667,.63667,0,0,.77778],10902:[.13667,.63667,0,0,.77778],10933:[.25142,.75726,0,0,.77778],10934:[.25142,.75726,0,0,.77778],10935:[.26167,.75726,0,0,.77778],10936:[.26167,.75726,0,0,.77778],10937:[.26167,.75726,0,0,.77778],10938:[.26167,.75726,0,0,.77778],10949:[.25583,.75583,0,0,.77778],10950:[.25583,.75583,0,0,.77778],10955:[.28481,.79383,0,0,.77778],10956:[.28481,.79383,0,0,.77778],57350:[.08167,.58167,0,0,.22222],57351:[.08167,.58167,0,0,.38889],57352:[.08167,.58167,0,0,.77778],57353:[0,.43056,.04028,0,.66667],57356:[.25142,.75726,0,0,.77778],57357:[.25142,.75726,0,0,.77778],57358:[.41951,.91951,0,0,.77778],57359:[.30274,.79383,0,0,.77778],57360:[.30274,.79383,0,0,.77778],57361:[.41951,.91951,0,0,.77778],57366:[.25142,.75726,0,0,.77778],57367:[.25142,.75726,0,0,.77778],57368:[.25142,.75726,0,0,.77778],57369:[.25142,.75726,0,0,.77778],57370:[.13597,.63597,0,0,.77778],57371:[.13597,.63597,0,0,.77778]},"Caligraphic-Regular":{32:[0,0,0,0,.25],65:[0,.68333,0,.19445,.79847],66:[0,.68333,.03041,.13889,.65681],67:[0,.68333,.05834,.13889,.52653],68:[0,.68333,.02778,.08334,.77139],69:[0,.68333,.08944,.11111,.52778],70:[0,.68333,.09931,.11111,.71875],71:[.09722,.68333,.0593,.11111,.59487],72:[0,.68333,.00965,.11111,.84452],73:[0,.68333,.07382,0,.54452],74:[.09722,.68333,.18472,.16667,.67778],75:[0,.68333,.01445,.05556,.76195],76:[0,.68333,0,.13889,.68972],77:[0,.68333,0,.13889,1.2009],78:[0,.68333,.14736,.08334,.82049],79:[0,.68333,.02778,.11111,.79611],80:[0,.68333,.08222,.08334,.69556],81:[.09722,.68333,0,.11111,.81667],82:[0,.68333,0,.08334,.8475],83:[0,.68333,.075,.13889,.60556],84:[0,.68333,.25417,0,.54464],85:[0,.68333,.09931,.08334,.62583],86:[0,.68333,.08222,0,.61278],87:[0,.68333,.08222,.08334,.98778],88:[0,.68333,.14643,.13889,.7133],89:[.09722,.68333,.08222,.08334,.66834],90:[0,.68333,.07944,.13889,.72473],160:[0,0,0,0,.25]},"Fraktur-Regular":{32:[0,0,0,0,.25],33:[0,.69141,0,0,.29574],34:[0,.69141,0,0,.21471],38:[0,.69141,0,0,.73786],39:[0,.69141,0,0,.21201],40:[.24982,.74947,0,0,.38865],41:[.24982,.74947,0,0,.38865],42:[0,.62119,0,0,.27764],43:[.08319,.58283,0,0,.75623],44:[0,.10803,0,0,.27764],45:[.08319,.58283,0,0,.75623],46:[0,.10803,0,0,.27764],47:[.24982,.74947,0,0,.50181],48:[0,.47534,0,0,.50181],49:[0,.47534,0,0,.50181],50:[0,.47534,0,0,.50181],51:[.18906,.47534,0,0,.50181],52:[.18906,.47534,0,0,.50181],53:[.18906,.47534,0,0,.50181],54:[0,.69141,0,0,.50181],55:[.18906,.47534,0,0,.50181],56:[0,.69141,0,0,.50181],57:[.18906,.47534,0,0,.50181],58:[0,.47534,0,0,.21606],59:[.12604,.47534,0,0,.21606],61:[-.13099,.36866,0,0,.75623],63:[0,.69141,0,0,.36245],65:[0,.69141,0,0,.7176],66:[0,.69141,0,0,.88397],67:[0,.69141,0,0,.61254],68:[0,.69141,0,0,.83158],69:[0,.69141,0,0,.66278],70:[.12604,.69141,0,0,.61119],71:[0,.69141,0,0,.78539],72:[.06302,.69141,0,0,.7203],73:[0,.69141,0,0,.55448],74:[.12604,.69141,0,0,.55231],75:[0,.69141,0,0,.66845],76:[0,.69141,0,0,.66602],77:[0,.69141,0,0,1.04953],78:[0,.69141,0,0,.83212],79:[0,.69141,0,0,.82699],80:[.18906,.69141,0,0,.82753],81:[.03781,.69141,0,0,.82699],82:[0,.69141,0,0,.82807],83:[0,.69141,0,0,.82861],84:[0,.69141,0,0,.66899],85:[0,.69141,0,0,.64576],86:[0,.69141,0,0,.83131],87:[0,.69141,0,0,1.04602],88:[0,.69141,0,0,.71922],89:[.18906,.69141,0,0,.83293],90:[.12604,.69141,0,0,.60201],91:[.24982,.74947,0,0,.27764],93:[.24982,.74947,0,0,.27764],94:[0,.69141,0,0,.49965],97:[0,.47534,0,0,.50046],98:[0,.69141,0,0,.51315],99:[0,.47534,0,0,.38946],100:[0,.62119,0,0,.49857],101:[0,.47534,0,0,.40053],102:[.18906,.69141,0,0,.32626],103:[.18906,.47534,0,0,.5037],104:[.18906,.69141,0,0,.52126],105:[0,.69141,0,0,.27899],106:[0,.69141,0,0,.28088],107:[0,.69141,0,0,.38946],108:[0,.69141,0,0,.27953],109:[0,.47534,0,0,.76676],110:[0,.47534,0,0,.52666],111:[0,.47534,0,0,.48885],112:[.18906,.52396,0,0,.50046],113:[.18906,.47534,0,0,.48912],114:[0,.47534,0,0,.38919],115:[0,.47534,0,0,.44266],116:[0,.62119,0,0,.33301],117:[0,.47534,0,0,.5172],118:[0,.52396,0,0,.5118],119:[0,.52396,0,0,.77351],120:[.18906,.47534,0,0,.38865],121:[.18906,.47534,0,0,.49884],122:[.18906,.47534,0,0,.39054],160:[0,0,0,0,.25],8216:[0,.69141,0,0,.21471],8217:[0,.69141,0,0,.21471],58112:[0,.62119,0,0,.49749],58113:[0,.62119,0,0,.4983],58114:[.18906,.69141,0,0,.33328],58115:[.18906,.69141,0,0,.32923],58116:[.18906,.47534,0,0,.50343],58117:[0,.69141,0,0,.33301],58118:[0,.62119,0,0,.33409],58119:[0,.47534,0,0,.50073]},"Main-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.35],34:[0,.69444,0,0,.60278],35:[.19444,.69444,0,0,.95833],36:[.05556,.75,0,0,.575],37:[.05556,.75,0,0,.95833],38:[0,.69444,0,0,.89444],39:[0,.69444,0,0,.31944],40:[.25,.75,0,0,.44722],41:[.25,.75,0,0,.44722],42:[0,.75,0,0,.575],43:[.13333,.63333,0,0,.89444],44:[.19444,.15556,0,0,.31944],45:[0,.44444,0,0,.38333],46:[0,.15556,0,0,.31944],47:[.25,.75,0,0,.575],48:[0,.64444,0,0,.575],49:[0,.64444,0,0,.575],50:[0,.64444,0,0,.575],51:[0,.64444,0,0,.575],52:[0,.64444,0,0,.575],53:[0,.64444,0,0,.575],54:[0,.64444,0,0,.575],55:[0,.64444,0,0,.575],56:[0,.64444,0,0,.575],57:[0,.64444,0,0,.575],58:[0,.44444,0,0,.31944],59:[.19444,.44444,0,0,.31944],60:[.08556,.58556,0,0,.89444],61:[-.10889,.39111,0,0,.89444],62:[.08556,.58556,0,0,.89444],63:[0,.69444,0,0,.54305],64:[0,.69444,0,0,.89444],65:[0,.68611,0,0,.86944],66:[0,.68611,0,0,.81805],67:[0,.68611,0,0,.83055],68:[0,.68611,0,0,.88194],69:[0,.68611,0,0,.75555],70:[0,.68611,0,0,.72361],71:[0,.68611,0,0,.90416],72:[0,.68611,0,0,.9],73:[0,.68611,0,0,.43611],74:[0,.68611,0,0,.59444],75:[0,.68611,0,0,.90138],76:[0,.68611,0,0,.69166],77:[0,.68611,0,0,1.09166],78:[0,.68611,0,0,.9],79:[0,.68611,0,0,.86388],80:[0,.68611,0,0,.78611],81:[.19444,.68611,0,0,.86388],82:[0,.68611,0,0,.8625],83:[0,.68611,0,0,.63889],84:[0,.68611,0,0,.8],85:[0,.68611,0,0,.88472],86:[0,.68611,.01597,0,.86944],87:[0,.68611,.01597,0,1.18888],88:[0,.68611,0,0,.86944],89:[0,.68611,.02875,0,.86944],90:[0,.68611,0,0,.70277],91:[.25,.75,0,0,.31944],92:[.25,.75,0,0,.575],93:[.25,.75,0,0,.31944],94:[0,.69444,0,0,.575],95:[.31,.13444,.03194,0,.575],97:[0,.44444,0,0,.55902],98:[0,.69444,0,0,.63889],99:[0,.44444,0,0,.51111],100:[0,.69444,0,0,.63889],101:[0,.44444,0,0,.52708],102:[0,.69444,.10903,0,.35139],103:[.19444,.44444,.01597,0,.575],104:[0,.69444,0,0,.63889],105:[0,.69444,0,0,.31944],106:[.19444,.69444,0,0,.35139],107:[0,.69444,0,0,.60694],108:[0,.69444,0,0,.31944],109:[0,.44444,0,0,.95833],110:[0,.44444,0,0,.63889],111:[0,.44444,0,0,.575],112:[.19444,.44444,0,0,.63889],113:[.19444,.44444,0,0,.60694],114:[0,.44444,0,0,.47361],115:[0,.44444,0,0,.45361],116:[0,.63492,0,0,.44722],117:[0,.44444,0,0,.63889],118:[0,.44444,.01597,0,.60694],119:[0,.44444,.01597,0,.83055],120:[0,.44444,0,0,.60694],121:[.19444,.44444,.01597,0,.60694],122:[0,.44444,0,0,.51111],123:[.25,.75,0,0,.575],124:[.25,.75,0,0,.31944],125:[.25,.75,0,0,.575],126:[.35,.34444,0,0,.575],160:[0,0,0,0,.25],163:[0,.69444,0,0,.86853],168:[0,.69444,0,0,.575],172:[0,.44444,0,0,.76666],176:[0,.69444,0,0,.86944],177:[.13333,.63333,0,0,.89444],184:[.17014,0,0,0,.51111],198:[0,.68611,0,0,1.04166],215:[.13333,.63333,0,0,.89444],216:[.04861,.73472,0,0,.89444],223:[0,.69444,0,0,.59722],230:[0,.44444,0,0,.83055],247:[.13333,.63333,0,0,.89444],248:[.09722,.54167,0,0,.575],305:[0,.44444,0,0,.31944],338:[0,.68611,0,0,1.16944],339:[0,.44444,0,0,.89444],567:[.19444,.44444,0,0,.35139],710:[0,.69444,0,0,.575],711:[0,.63194,0,0,.575],713:[0,.59611,0,0,.575],714:[0,.69444,0,0,.575],715:[0,.69444,0,0,.575],728:[0,.69444,0,0,.575],729:[0,.69444,0,0,.31944],730:[0,.69444,0,0,.86944],732:[0,.69444,0,0,.575],733:[0,.69444,0,0,.575],915:[0,.68611,0,0,.69166],916:[0,.68611,0,0,.95833],920:[0,.68611,0,0,.89444],923:[0,.68611,0,0,.80555],926:[0,.68611,0,0,.76666],928:[0,.68611,0,0,.9],931:[0,.68611,0,0,.83055],933:[0,.68611,0,0,.89444],934:[0,.68611,0,0,.83055],936:[0,.68611,0,0,.89444],937:[0,.68611,0,0,.83055],8211:[0,.44444,.03194,0,.575],8212:[0,.44444,.03194,0,1.14999],8216:[0,.69444,0,0,.31944],8217:[0,.69444,0,0,.31944],8220:[0,.69444,0,0,.60278],8221:[0,.69444,0,0,.60278],8224:[.19444,.69444,0,0,.51111],8225:[.19444,.69444,0,0,.51111],8242:[0,.55556,0,0,.34444],8407:[0,.72444,.15486,0,.575],8463:[0,.69444,0,0,.66759],8465:[0,.69444,0,0,.83055],8467:[0,.69444,0,0,.47361],8472:[.19444,.44444,0,0,.74027],8476:[0,.69444,0,0,.83055],8501:[0,.69444,0,0,.70277],8592:[-.10889,.39111,0,0,1.14999],8593:[.19444,.69444,0,0,.575],8594:[-.10889,.39111,0,0,1.14999],8595:[.19444,.69444,0,0,.575],8596:[-.10889,.39111,0,0,1.14999],8597:[.25,.75,0,0,.575],8598:[.19444,.69444,0,0,1.14999],8599:[.19444,.69444,0,0,1.14999],8600:[.19444,.69444,0,0,1.14999],8601:[.19444,.69444,0,0,1.14999],8636:[-.10889,.39111,0,0,1.14999],8637:[-.10889,.39111,0,0,1.14999],8640:[-.10889,.39111,0,0,1.14999],8641:[-.10889,.39111,0,0,1.14999],8656:[-.10889,.39111,0,0,1.14999],8657:[.19444,.69444,0,0,.70277],8658:[-.10889,.39111,0,0,1.14999],8659:[.19444,.69444,0,0,.70277],8660:[-.10889,.39111,0,0,1.14999],8661:[.25,.75,0,0,.70277],8704:[0,.69444,0,0,.63889],8706:[0,.69444,.06389,0,.62847],8707:[0,.69444,0,0,.63889],8709:[.05556,.75,0,0,.575],8711:[0,.68611,0,0,.95833],8712:[.08556,.58556,0,0,.76666],8715:[.08556,.58556,0,0,.76666],8722:[.13333,.63333,0,0,.89444],8723:[.13333,.63333,0,0,.89444],8725:[.25,.75,0,0,.575],8726:[.25,.75,0,0,.575],8727:[-.02778,.47222,0,0,.575],8728:[-.02639,.47361,0,0,.575],8729:[-.02639,.47361,0,0,.575],8730:[.18,.82,0,0,.95833],8733:[0,.44444,0,0,.89444],8734:[0,.44444,0,0,1.14999],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.31944],8741:[.25,.75,0,0,.575],8743:[0,.55556,0,0,.76666],8744:[0,.55556,0,0,.76666],8745:[0,.55556,0,0,.76666],8746:[0,.55556,0,0,.76666],8747:[.19444,.69444,.12778,0,.56875],8764:[-.10889,.39111,0,0,.89444],8768:[.19444,.69444,0,0,.31944],8771:[.00222,.50222,0,0,.89444],8773:[.027,.638,0,0,.894],8776:[.02444,.52444,0,0,.89444],8781:[.00222,.50222,0,0,.89444],8801:[.00222,.50222,0,0,.89444],8804:[.19667,.69667,0,0,.89444],8805:[.19667,.69667,0,0,.89444],8810:[.08556,.58556,0,0,1.14999],8811:[.08556,.58556,0,0,1.14999],8826:[.08556,.58556,0,0,.89444],8827:[.08556,.58556,0,0,.89444],8834:[.08556,.58556,0,0,.89444],8835:[.08556,.58556,0,0,.89444],8838:[.19667,.69667,0,0,.89444],8839:[.19667,.69667,0,0,.89444],8846:[0,.55556,0,0,.76666],8849:[.19667,.69667,0,0,.89444],8850:[.19667,.69667,0,0,.89444],8851:[0,.55556,0,0,.76666],8852:[0,.55556,0,0,.76666],8853:[.13333,.63333,0,0,.89444],8854:[.13333,.63333,0,0,.89444],8855:[.13333,.63333,0,0,.89444],8856:[.13333,.63333,0,0,.89444],8857:[.13333,.63333,0,0,.89444],8866:[0,.69444,0,0,.70277],8867:[0,.69444,0,0,.70277],8868:[0,.69444,0,0,.89444],8869:[0,.69444,0,0,.89444],8900:[-.02639,.47361,0,0,.575],8901:[-.02639,.47361,0,0,.31944],8902:[-.02778,.47222,0,0,.575],8968:[.25,.75,0,0,.51111],8969:[.25,.75,0,0,.51111],8970:[.25,.75,0,0,.51111],8971:[.25,.75,0,0,.51111],8994:[-.13889,.36111,0,0,1.14999],8995:[-.13889,.36111,0,0,1.14999],9651:[.19444,.69444,0,0,1.02222],9657:[-.02778,.47222,0,0,.575],9661:[.19444,.69444,0,0,1.02222],9667:[-.02778,.47222,0,0,.575],9711:[.19444,.69444,0,0,1.14999],9824:[.12963,.69444,0,0,.89444],9825:[.12963,.69444,0,0,.89444],9826:[.12963,.69444,0,0,.89444],9827:[.12963,.69444,0,0,.89444],9837:[0,.75,0,0,.44722],9838:[.19444,.69444,0,0,.44722],9839:[.19444,.69444,0,0,.44722],10216:[.25,.75,0,0,.44722],10217:[.25,.75,0,0,.44722],10815:[0,.68611,0,0,.9],10927:[.19667,.69667,0,0,.89444],10928:[.19667,.69667,0,0,.89444],57376:[.19444,.69444,0,0,0]},"Main-BoldItalic":{32:[0,0,0,0,.25],33:[0,.69444,.11417,0,.38611],34:[0,.69444,.07939,0,.62055],35:[.19444,.69444,.06833,0,.94444],37:[.05556,.75,.12861,0,.94444],38:[0,.69444,.08528,0,.88555],39:[0,.69444,.12945,0,.35555],40:[.25,.75,.15806,0,.47333],41:[.25,.75,.03306,0,.47333],42:[0,.75,.14333,0,.59111],43:[.10333,.60333,.03306,0,.88555],44:[.19444,.14722,0,0,.35555],45:[0,.44444,.02611,0,.41444],46:[0,.14722,0,0,.35555],47:[.25,.75,.15806,0,.59111],48:[0,.64444,.13167,0,.59111],49:[0,.64444,.13167,0,.59111],50:[0,.64444,.13167,0,.59111],51:[0,.64444,.13167,0,.59111],52:[.19444,.64444,.13167,0,.59111],53:[0,.64444,.13167,0,.59111],54:[0,.64444,.13167,0,.59111],55:[.19444,.64444,.13167,0,.59111],56:[0,.64444,.13167,0,.59111],57:[0,.64444,.13167,0,.59111],58:[0,.44444,.06695,0,.35555],59:[.19444,.44444,.06695,0,.35555],61:[-.10889,.39111,.06833,0,.88555],63:[0,.69444,.11472,0,.59111],64:[0,.69444,.09208,0,.88555],65:[0,.68611,0,0,.86555],66:[0,.68611,.0992,0,.81666],67:[0,.68611,.14208,0,.82666],68:[0,.68611,.09062,0,.87555],69:[0,.68611,.11431,0,.75666],70:[0,.68611,.12903,0,.72722],71:[0,.68611,.07347,0,.89527],72:[0,.68611,.17208,0,.8961],73:[0,.68611,.15681,0,.47166],74:[0,.68611,.145,0,.61055],75:[0,.68611,.14208,0,.89499],76:[0,.68611,0,0,.69777],77:[0,.68611,.17208,0,1.07277],78:[0,.68611,.17208,0,.8961],79:[0,.68611,.09062,0,.85499],80:[0,.68611,.0992,0,.78721],81:[.19444,.68611,.09062,0,.85499],82:[0,.68611,.02559,0,.85944],83:[0,.68611,.11264,0,.64999],84:[0,.68611,.12903,0,.7961],85:[0,.68611,.17208,0,.88083],86:[0,.68611,.18625,0,.86555],87:[0,.68611,.18625,0,1.15999],88:[0,.68611,.15681,0,.86555],89:[0,.68611,.19803,0,.86555],90:[0,.68611,.14208,0,.70888],91:[.25,.75,.1875,0,.35611],93:[.25,.75,.09972,0,.35611],94:[0,.69444,.06709,0,.59111],95:[.31,.13444,.09811,0,.59111],97:[0,.44444,.09426,0,.59111],98:[0,.69444,.07861,0,.53222],99:[0,.44444,.05222,0,.53222],100:[0,.69444,.10861,0,.59111],101:[0,.44444,.085,0,.53222],102:[.19444,.69444,.21778,0,.4],103:[.19444,.44444,.105,0,.53222],104:[0,.69444,.09426,0,.59111],105:[0,.69326,.11387,0,.35555],106:[.19444,.69326,.1672,0,.35555],107:[0,.69444,.11111,0,.53222],108:[0,.69444,.10861,0,.29666],109:[0,.44444,.09426,0,.94444],110:[0,.44444,.09426,0,.64999],111:[0,.44444,.07861,0,.59111],112:[.19444,.44444,.07861,0,.59111],113:[.19444,.44444,.105,0,.53222],114:[0,.44444,.11111,0,.50167],115:[0,.44444,.08167,0,.48694],116:[0,.63492,.09639,0,.385],117:[0,.44444,.09426,0,.62055],118:[0,.44444,.11111,0,.53222],119:[0,.44444,.11111,0,.76777],120:[0,.44444,.12583,0,.56055],121:[.19444,.44444,.105,0,.56166],122:[0,.44444,.13889,0,.49055],126:[.35,.34444,.11472,0,.59111],160:[0,0,0,0,.25],168:[0,.69444,.11473,0,.59111],176:[0,.69444,0,0,.94888],184:[.17014,0,0,0,.53222],198:[0,.68611,.11431,0,1.02277],216:[.04861,.73472,.09062,0,.88555],223:[.19444,.69444,.09736,0,.665],230:[0,.44444,.085,0,.82666],248:[.09722,.54167,.09458,0,.59111],305:[0,.44444,.09426,0,.35555],338:[0,.68611,.11431,0,1.14054],339:[0,.44444,.085,0,.82666],567:[.19444,.44444,.04611,0,.385],710:[0,.69444,.06709,0,.59111],711:[0,.63194,.08271,0,.59111],713:[0,.59444,.10444,0,.59111],714:[0,.69444,.08528,0,.59111],715:[0,.69444,0,0,.59111],728:[0,.69444,.10333,0,.59111],729:[0,.69444,.12945,0,.35555],730:[0,.69444,0,0,.94888],732:[0,.69444,.11472,0,.59111],733:[0,.69444,.11472,0,.59111],915:[0,.68611,.12903,0,.69777],916:[0,.68611,0,0,.94444],920:[0,.68611,.09062,0,.88555],923:[0,.68611,0,0,.80666],926:[0,.68611,.15092,0,.76777],928:[0,.68611,.17208,0,.8961],931:[0,.68611,.11431,0,.82666],933:[0,.68611,.10778,0,.88555],934:[0,.68611,.05632,0,.82666],936:[0,.68611,.10778,0,.88555],937:[0,.68611,.0992,0,.82666],8211:[0,.44444,.09811,0,.59111],8212:[0,.44444,.09811,0,1.18221],8216:[0,.69444,.12945,0,.35555],8217:[0,.69444,.12945,0,.35555],8220:[0,.69444,.16772,0,.62055],8221:[0,.69444,.07939,0,.62055]},"Main-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.12417,0,.30667],34:[0,.69444,.06961,0,.51444],35:[.19444,.69444,.06616,0,.81777],37:[.05556,.75,.13639,0,.81777],38:[0,.69444,.09694,0,.76666],39:[0,.69444,.12417,0,.30667],40:[.25,.75,.16194,0,.40889],41:[.25,.75,.03694,0,.40889],42:[0,.75,.14917,0,.51111],43:[.05667,.56167,.03694,0,.76666],44:[.19444,.10556,0,0,.30667],45:[0,.43056,.02826,0,.35778],46:[0,.10556,0,0,.30667],47:[.25,.75,.16194,0,.51111],48:[0,.64444,.13556,0,.51111],49:[0,.64444,.13556,0,.51111],50:[0,.64444,.13556,0,.51111],51:[0,.64444,.13556,0,.51111],52:[.19444,.64444,.13556,0,.51111],53:[0,.64444,.13556,0,.51111],54:[0,.64444,.13556,0,.51111],55:[.19444,.64444,.13556,0,.51111],56:[0,.64444,.13556,0,.51111],57:[0,.64444,.13556,0,.51111],58:[0,.43056,.0582,0,.30667],59:[.19444,.43056,.0582,0,.30667],61:[-.13313,.36687,.06616,0,.76666],63:[0,.69444,.1225,0,.51111],64:[0,.69444,.09597,0,.76666],65:[0,.68333,0,0,.74333],66:[0,.68333,.10257,0,.70389],67:[0,.68333,.14528,0,.71555],68:[0,.68333,.09403,0,.755],69:[0,.68333,.12028,0,.67833],70:[0,.68333,.13305,0,.65277],71:[0,.68333,.08722,0,.77361],72:[0,.68333,.16389,0,.74333],73:[0,.68333,.15806,0,.38555],74:[0,.68333,.14028,0,.525],75:[0,.68333,.14528,0,.76888],76:[0,.68333,0,0,.62722],77:[0,.68333,.16389,0,.89666],78:[0,.68333,.16389,0,.74333],79:[0,.68333,.09403,0,.76666],80:[0,.68333,.10257,0,.67833],81:[.19444,.68333,.09403,0,.76666],82:[0,.68333,.03868,0,.72944],83:[0,.68333,.11972,0,.56222],84:[0,.68333,.13305,0,.71555],85:[0,.68333,.16389,0,.74333],86:[0,.68333,.18361,0,.74333],87:[0,.68333,.18361,0,.99888],88:[0,.68333,.15806,0,.74333],89:[0,.68333,.19383,0,.74333],90:[0,.68333,.14528,0,.61333],91:[.25,.75,.1875,0,.30667],93:[.25,.75,.10528,0,.30667],94:[0,.69444,.06646,0,.51111],95:[.31,.12056,.09208,0,.51111],97:[0,.43056,.07671,0,.51111],98:[0,.69444,.06312,0,.46],99:[0,.43056,.05653,0,.46],100:[0,.69444,.10333,0,.51111],101:[0,.43056,.07514,0,.46],102:[.19444,.69444,.21194,0,.30667],103:[.19444,.43056,.08847,0,.46],104:[0,.69444,.07671,0,.51111],105:[0,.65536,.1019,0,.30667],106:[.19444,.65536,.14467,0,.30667],107:[0,.69444,.10764,0,.46],108:[0,.69444,.10333,0,.25555],109:[0,.43056,.07671,0,.81777],110:[0,.43056,.07671,0,.56222],111:[0,.43056,.06312,0,.51111],112:[.19444,.43056,.06312,0,.51111],113:[.19444,.43056,.08847,0,.46],114:[0,.43056,.10764,0,.42166],115:[0,.43056,.08208,0,.40889],116:[0,.61508,.09486,0,.33222],117:[0,.43056,.07671,0,.53666],118:[0,.43056,.10764,0,.46],119:[0,.43056,.10764,0,.66444],120:[0,.43056,.12042,0,.46389],121:[.19444,.43056,.08847,0,.48555],122:[0,.43056,.12292,0,.40889],126:[.35,.31786,.11585,0,.51111],160:[0,0,0,0,.25],168:[0,.66786,.10474,0,.51111],176:[0,.69444,0,0,.83129],184:[.17014,0,0,0,.46],198:[0,.68333,.12028,0,.88277],216:[.04861,.73194,.09403,0,.76666],223:[.19444,.69444,.10514,0,.53666],230:[0,.43056,.07514,0,.71555],248:[.09722,.52778,.09194,0,.51111],338:[0,.68333,.12028,0,.98499],339:[0,.43056,.07514,0,.71555],710:[0,.69444,.06646,0,.51111],711:[0,.62847,.08295,0,.51111],713:[0,.56167,.10333,0,.51111],714:[0,.69444,.09694,0,.51111],715:[0,.69444,0,0,.51111],728:[0,.69444,.10806,0,.51111],729:[0,.66786,.11752,0,.30667],730:[0,.69444,0,0,.83129],732:[0,.66786,.11585,0,.51111],733:[0,.69444,.1225,0,.51111],915:[0,.68333,.13305,0,.62722],916:[0,.68333,0,0,.81777],920:[0,.68333,.09403,0,.76666],923:[0,.68333,0,0,.69222],926:[0,.68333,.15294,0,.66444],928:[0,.68333,.16389,0,.74333],931:[0,.68333,.12028,0,.71555],933:[0,.68333,.11111,0,.76666],934:[0,.68333,.05986,0,.71555],936:[0,.68333,.11111,0,.76666],937:[0,.68333,.10257,0,.71555],8211:[0,.43056,.09208,0,.51111],8212:[0,.43056,.09208,0,1.02222],8216:[0,.69444,.12417,0,.30667],8217:[0,.69444,.12417,0,.30667],8220:[0,.69444,.1685,0,.51444],8221:[0,.69444,.06961,0,.51444],8463:[0,.68889,0,0,.54028]},"Main-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.27778],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.77778],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.19444,.10556,0,0,.27778],45:[0,.43056,0,0,.33333],46:[0,.10556,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.64444,0,0,.5],49:[0,.64444,0,0,.5],50:[0,.64444,0,0,.5],51:[0,.64444,0,0,.5],52:[0,.64444,0,0,.5],53:[0,.64444,0,0,.5],54:[0,.64444,0,0,.5],55:[0,.64444,0,0,.5],56:[0,.64444,0,0,.5],57:[0,.64444,0,0,.5],58:[0,.43056,0,0,.27778],59:[.19444,.43056,0,0,.27778],60:[.0391,.5391,0,0,.77778],61:[-.13313,.36687,0,0,.77778],62:[.0391,.5391,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.77778],65:[0,.68333,0,0,.75],66:[0,.68333,0,0,.70834],67:[0,.68333,0,0,.72222],68:[0,.68333,0,0,.76389],69:[0,.68333,0,0,.68056],70:[0,.68333,0,0,.65278],71:[0,.68333,0,0,.78472],72:[0,.68333,0,0,.75],73:[0,.68333,0,0,.36111],74:[0,.68333,0,0,.51389],75:[0,.68333,0,0,.77778],76:[0,.68333,0,0,.625],77:[0,.68333,0,0,.91667],78:[0,.68333,0,0,.75],79:[0,.68333,0,0,.77778],80:[0,.68333,0,0,.68056],81:[.19444,.68333,0,0,.77778],82:[0,.68333,0,0,.73611],83:[0,.68333,0,0,.55556],84:[0,.68333,0,0,.72222],85:[0,.68333,0,0,.75],86:[0,.68333,.01389,0,.75],87:[0,.68333,.01389,0,1.02778],88:[0,.68333,0,0,.75],89:[0,.68333,.025,0,.75],90:[0,.68333,0,0,.61111],91:[.25,.75,0,0,.27778],92:[.25,.75,0,0,.5],93:[.25,.75,0,0,.27778],94:[0,.69444,0,0,.5],95:[.31,.12056,.02778,0,.5],97:[0,.43056,0,0,.5],98:[0,.69444,0,0,.55556],99:[0,.43056,0,0,.44445],100:[0,.69444,0,0,.55556],101:[0,.43056,0,0,.44445],102:[0,.69444,.07778,0,.30556],103:[.19444,.43056,.01389,0,.5],104:[0,.69444,0,0,.55556],105:[0,.66786,0,0,.27778],106:[.19444,.66786,0,0,.30556],107:[0,.69444,0,0,.52778],108:[0,.69444,0,0,.27778],109:[0,.43056,0,0,.83334],110:[0,.43056,0,0,.55556],111:[0,.43056,0,0,.5],112:[.19444,.43056,0,0,.55556],113:[.19444,.43056,0,0,.52778],114:[0,.43056,0,0,.39167],115:[0,.43056,0,0,.39445],116:[0,.61508,0,0,.38889],117:[0,.43056,0,0,.55556],118:[0,.43056,.01389,0,.52778],119:[0,.43056,.01389,0,.72222],120:[0,.43056,0,0,.52778],121:[.19444,.43056,.01389,0,.52778],122:[0,.43056,0,0,.44445],123:[.25,.75,0,0,.5],124:[.25,.75,0,0,.27778],125:[.25,.75,0,0,.5],126:[.35,.31786,0,0,.5],160:[0,0,0,0,.25],163:[0,.69444,0,0,.76909],167:[.19444,.69444,0,0,.44445],168:[0,.66786,0,0,.5],172:[0,.43056,0,0,.66667],176:[0,.69444,0,0,.75],177:[.08333,.58333,0,0,.77778],182:[.19444,.69444,0,0,.61111],184:[.17014,0,0,0,.44445],198:[0,.68333,0,0,.90278],215:[.08333,.58333,0,0,.77778],216:[.04861,.73194,0,0,.77778],223:[0,.69444,0,0,.5],230:[0,.43056,0,0,.72222],247:[.08333,.58333,0,0,.77778],248:[.09722,.52778,0,0,.5],305:[0,.43056,0,0,.27778],338:[0,.68333,0,0,1.01389],339:[0,.43056,0,0,.77778],567:[.19444,.43056,0,0,.30556],710:[0,.69444,0,0,.5],711:[0,.62847,0,0,.5],713:[0,.56778,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.66786,0,0,.27778],730:[0,.69444,0,0,.75],732:[0,.66786,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.68333,0,0,.625],916:[0,.68333,0,0,.83334],920:[0,.68333,0,0,.77778],923:[0,.68333,0,0,.69445],926:[0,.68333,0,0,.66667],928:[0,.68333,0,0,.75],931:[0,.68333,0,0,.72222],933:[0,.68333,0,0,.77778],934:[0,.68333,0,0,.72222],936:[0,.68333,0,0,.77778],937:[0,.68333,0,0,.72222],8211:[0,.43056,.02778,0,.5],8212:[0,.43056,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5],8224:[.19444,.69444,0,0,.44445],8225:[.19444,.69444,0,0,.44445],8230:[0,.123,0,0,1.172],8242:[0,.55556,0,0,.275],8407:[0,.71444,.15382,0,.5],8463:[0,.68889,0,0,.54028],8465:[0,.69444,0,0,.72222],8467:[0,.69444,0,.11111,.41667],8472:[.19444,.43056,0,.11111,.63646],8476:[0,.69444,0,0,.72222],8501:[0,.69444,0,0,.61111],8592:[-.13313,.36687,0,0,1],8593:[.19444,.69444,0,0,.5],8594:[-.13313,.36687,0,0,1],8595:[.19444,.69444,0,0,.5],8596:[-.13313,.36687,0,0,1],8597:[.25,.75,0,0,.5],8598:[.19444,.69444,0,0,1],8599:[.19444,.69444,0,0,1],8600:[.19444,.69444,0,0,1],8601:[.19444,.69444,0,0,1],8614:[.011,.511,0,0,1],8617:[.011,.511,0,0,1.126],8618:[.011,.511,0,0,1.126],8636:[-.13313,.36687,0,0,1],8637:[-.13313,.36687,0,0,1],8640:[-.13313,.36687,0,0,1],8641:[-.13313,.36687,0,0,1],8652:[.011,.671,0,0,1],8656:[-.13313,.36687,0,0,1],8657:[.19444,.69444,0,0,.61111],8658:[-.13313,.36687,0,0,1],8659:[.19444,.69444,0,0,.61111],8660:[-.13313,.36687,0,0,1],8661:[.25,.75,0,0,.61111],8704:[0,.69444,0,0,.55556],8706:[0,.69444,.05556,.08334,.5309],8707:[0,.69444,0,0,.55556],8709:[.05556,.75,0,0,.5],8711:[0,.68333,0,0,.83334],8712:[.0391,.5391,0,0,.66667],8715:[.0391,.5391,0,0,.66667],8722:[.08333,.58333,0,0,.77778],8723:[.08333,.58333,0,0,.77778],8725:[.25,.75,0,0,.5],8726:[.25,.75,0,0,.5],8727:[-.03472,.46528,0,0,.5],8728:[-.05555,.44445,0,0,.5],8729:[-.05555,.44445,0,0,.5],8730:[.2,.8,0,0,.83334],8733:[0,.43056,0,0,.77778],8734:[0,.43056,0,0,1],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.27778],8741:[.25,.75,0,0,.5],8743:[0,.55556,0,0,.66667],8744:[0,.55556,0,0,.66667],8745:[0,.55556,0,0,.66667],8746:[0,.55556,0,0,.66667],8747:[.19444,.69444,.11111,0,.41667],8764:[-.13313,.36687,0,0,.77778],8768:[.19444,.69444,0,0,.27778],8771:[-.03625,.46375,0,0,.77778],8773:[-.022,.589,0,0,.778],8776:[-.01688,.48312,0,0,.77778],8781:[-.03625,.46375,0,0,.77778],8784:[-.133,.673,0,0,.778],8801:[-.03625,.46375,0,0,.77778],8804:[.13597,.63597,0,0,.77778],8805:[.13597,.63597,0,0,.77778],8810:[.0391,.5391,0,0,1],8811:[.0391,.5391,0,0,1],8826:[.0391,.5391,0,0,.77778],8827:[.0391,.5391,0,0,.77778],8834:[.0391,.5391,0,0,.77778],8835:[.0391,.5391,0,0,.77778],8838:[.13597,.63597,0,0,.77778],8839:[.13597,.63597,0,0,.77778],8846:[0,.55556,0,0,.66667],8849:[.13597,.63597,0,0,.77778],8850:[.13597,.63597,0,0,.77778],8851:[0,.55556,0,0,.66667],8852:[0,.55556,0,0,.66667],8853:[.08333,.58333,0,0,.77778],8854:[.08333,.58333,0,0,.77778],8855:[.08333,.58333,0,0,.77778],8856:[.08333,.58333,0,0,.77778],8857:[.08333,.58333,0,0,.77778],8866:[0,.69444,0,0,.61111],8867:[0,.69444,0,0,.61111],8868:[0,.69444,0,0,.77778],8869:[0,.69444,0,0,.77778],8872:[.249,.75,0,0,.867],8900:[-.05555,.44445,0,0,.5],8901:[-.05555,.44445,0,0,.27778],8902:[-.03472,.46528,0,0,.5],8904:[.005,.505,0,0,.9],8942:[.03,.903,0,0,.278],8943:[-.19,.313,0,0,1.172],8945:[-.1,.823,0,0,1.282],8968:[.25,.75,0,0,.44445],8969:[.25,.75,0,0,.44445],8970:[.25,.75,0,0,.44445],8971:[.25,.75,0,0,.44445],8994:[-.14236,.35764,0,0,1],8995:[-.14236,.35764,0,0,1],9136:[.244,.744,0,0,.412],9137:[.244,.745,0,0,.412],9651:[.19444,.69444,0,0,.88889],9657:[-.03472,.46528,0,0,.5],9661:[.19444,.69444,0,0,.88889],9667:[-.03472,.46528,0,0,.5],9711:[.19444,.69444,0,0,1],9824:[.12963,.69444,0,0,.77778],9825:[.12963,.69444,0,0,.77778],9826:[.12963,.69444,0,0,.77778],9827:[.12963,.69444,0,0,.77778],9837:[0,.75,0,0,.38889],9838:[.19444,.69444,0,0,.38889],9839:[.19444,.69444,0,0,.38889],10216:[.25,.75,0,0,.38889],10217:[.25,.75,0,0,.38889],10222:[.244,.744,0,0,.412],10223:[.244,.745,0,0,.412],10229:[.011,.511,0,0,1.609],10230:[.011,.511,0,0,1.638],10231:[.011,.511,0,0,1.859],10232:[.024,.525,0,0,1.609],10233:[.024,.525,0,0,1.638],10234:[.024,.525,0,0,1.858],10236:[.011,.511,0,0,1.638],10815:[0,.68333,0,0,.75],10927:[.13597,.63597,0,0,.77778],10928:[.13597,.63597,0,0,.77778],57376:[.19444,.69444,0,0,0]},"Math-BoldItalic":{32:[0,0,0,0,.25],48:[0,.44444,0,0,.575],49:[0,.44444,0,0,.575],50:[0,.44444,0,0,.575],51:[.19444,.44444,0,0,.575],52:[.19444,.44444,0,0,.575],53:[.19444,.44444,0,0,.575],54:[0,.64444,0,0,.575],55:[.19444,.44444,0,0,.575],56:[0,.64444,0,0,.575],57:[.19444,.44444,0,0,.575],65:[0,.68611,0,0,.86944],66:[0,.68611,.04835,0,.8664],67:[0,.68611,.06979,0,.81694],68:[0,.68611,.03194,0,.93812],69:[0,.68611,.05451,0,.81007],70:[0,.68611,.15972,0,.68889],71:[0,.68611,0,0,.88673],72:[0,.68611,.08229,0,.98229],73:[0,.68611,.07778,0,.51111],74:[0,.68611,.10069,0,.63125],75:[0,.68611,.06979,0,.97118],76:[0,.68611,0,0,.75555],77:[0,.68611,.11424,0,1.14201],78:[0,.68611,.11424,0,.95034],79:[0,.68611,.03194,0,.83666],80:[0,.68611,.15972,0,.72309],81:[.19444,.68611,0,0,.86861],82:[0,.68611,.00421,0,.87235],83:[0,.68611,.05382,0,.69271],84:[0,.68611,.15972,0,.63663],85:[0,.68611,.11424,0,.80027],86:[0,.68611,.25555,0,.67778],87:[0,.68611,.15972,0,1.09305],88:[0,.68611,.07778,0,.94722],89:[0,.68611,.25555,0,.67458],90:[0,.68611,.06979,0,.77257],97:[0,.44444,0,0,.63287],98:[0,.69444,0,0,.52083],99:[0,.44444,0,0,.51342],100:[0,.69444,0,0,.60972],101:[0,.44444,0,0,.55361],102:[.19444,.69444,.11042,0,.56806],103:[.19444,.44444,.03704,0,.5449],104:[0,.69444,0,0,.66759],105:[0,.69326,0,0,.4048],106:[.19444,.69326,.0622,0,.47083],107:[0,.69444,.01852,0,.6037],108:[0,.69444,.0088,0,.34815],109:[0,.44444,0,0,1.0324],110:[0,.44444,0,0,.71296],111:[0,.44444,0,0,.58472],112:[.19444,.44444,0,0,.60092],113:[.19444,.44444,.03704,0,.54213],114:[0,.44444,.03194,0,.5287],115:[0,.44444,0,0,.53125],116:[0,.63492,0,0,.41528],117:[0,.44444,0,0,.68102],118:[0,.44444,.03704,0,.56666],119:[0,.44444,.02778,0,.83148],120:[0,.44444,0,0,.65903],121:[.19444,.44444,.03704,0,.59028],122:[0,.44444,.04213,0,.55509],160:[0,0,0,0,.25],915:[0,.68611,.15972,0,.65694],916:[0,.68611,0,0,.95833],920:[0,.68611,.03194,0,.86722],923:[0,.68611,0,0,.80555],926:[0,.68611,.07458,0,.84125],928:[0,.68611,.08229,0,.98229],931:[0,.68611,.05451,0,.88507],933:[0,.68611,.15972,0,.67083],934:[0,.68611,0,0,.76666],936:[0,.68611,.11653,0,.71402],937:[0,.68611,.04835,0,.8789],945:[0,.44444,0,0,.76064],946:[.19444,.69444,.03403,0,.65972],947:[.19444,.44444,.06389,0,.59003],948:[0,.69444,.03819,0,.52222],949:[0,.44444,0,0,.52882],950:[.19444,.69444,.06215,0,.50833],951:[.19444,.44444,.03704,0,.6],952:[0,.69444,.03194,0,.5618],953:[0,.44444,0,0,.41204],954:[0,.44444,0,0,.66759],955:[0,.69444,0,0,.67083],956:[.19444,.44444,0,0,.70787],957:[0,.44444,.06898,0,.57685],958:[.19444,.69444,.03021,0,.50833],959:[0,.44444,0,0,.58472],960:[0,.44444,.03704,0,.68241],961:[.19444,.44444,0,0,.6118],962:[.09722,.44444,.07917,0,.42361],963:[0,.44444,.03704,0,.68588],964:[0,.44444,.13472,0,.52083],965:[0,.44444,.03704,0,.63055],966:[.19444,.44444,0,0,.74722],967:[.19444,.44444,0,0,.71805],968:[.19444,.69444,.03704,0,.75833],969:[0,.44444,.03704,0,.71782],977:[0,.69444,0,0,.69155],981:[.19444,.69444,0,0,.7125],982:[0,.44444,.03194,0,.975],1009:[.19444,.44444,0,0,.6118],1013:[0,.44444,0,0,.48333],57649:[0,.44444,0,0,.39352],57911:[.19444,.44444,0,0,.43889]},"Math-Italic":{32:[0,0,0,0,.25],48:[0,.43056,0,0,.5],49:[0,.43056,0,0,.5],50:[0,.43056,0,0,.5],51:[.19444,.43056,0,0,.5],52:[.19444,.43056,0,0,.5],53:[.19444,.43056,0,0,.5],54:[0,.64444,0,0,.5],55:[.19444,.43056,0,0,.5],56:[0,.64444,0,0,.5],57:[.19444,.43056,0,0,.5],65:[0,.68333,0,.13889,.75],66:[0,.68333,.05017,.08334,.75851],67:[0,.68333,.07153,.08334,.71472],68:[0,.68333,.02778,.05556,.82792],69:[0,.68333,.05764,.08334,.7382],70:[0,.68333,.13889,.08334,.64306],71:[0,.68333,0,.08334,.78625],72:[0,.68333,.08125,.05556,.83125],73:[0,.68333,.07847,.11111,.43958],74:[0,.68333,.09618,.16667,.55451],75:[0,.68333,.07153,.05556,.84931],76:[0,.68333,0,.02778,.68056],77:[0,.68333,.10903,.08334,.97014],78:[0,.68333,.10903,.08334,.80347],79:[0,.68333,.02778,.08334,.76278],80:[0,.68333,.13889,.08334,.64201],81:[.19444,.68333,0,.08334,.79056],82:[0,.68333,.00773,.08334,.75929],83:[0,.68333,.05764,.08334,.6132],84:[0,.68333,.13889,.08334,.58438],85:[0,.68333,.10903,.02778,.68278],86:[0,.68333,.22222,0,.58333],87:[0,.68333,.13889,0,.94445],88:[0,.68333,.07847,.08334,.82847],89:[0,.68333,.22222,0,.58056],90:[0,.68333,.07153,.08334,.68264],97:[0,.43056,0,0,.52859],98:[0,.69444,0,0,.42917],99:[0,.43056,0,.05556,.43276],100:[0,.69444,0,.16667,.52049],101:[0,.43056,0,.05556,.46563],102:[.19444,.69444,.10764,.16667,.48959],103:[.19444,.43056,.03588,.02778,.47697],104:[0,.69444,0,0,.57616],105:[0,.65952,0,0,.34451],106:[.19444,.65952,.05724,0,.41181],107:[0,.69444,.03148,0,.5206],108:[0,.69444,.01968,.08334,.29838],109:[0,.43056,0,0,.87801],110:[0,.43056,0,0,.60023],111:[0,.43056,0,.05556,.48472],112:[.19444,.43056,0,.08334,.50313],113:[.19444,.43056,.03588,.08334,.44641],114:[0,.43056,.02778,.05556,.45116],115:[0,.43056,0,.05556,.46875],116:[0,.61508,0,.08334,.36111],117:[0,.43056,0,.02778,.57246],118:[0,.43056,.03588,.02778,.48472],119:[0,.43056,.02691,.08334,.71592],120:[0,.43056,0,.02778,.57153],121:[.19444,.43056,.03588,.05556,.49028],122:[0,.43056,.04398,.05556,.46505],160:[0,0,0,0,.25],915:[0,.68333,.13889,.08334,.61528],916:[0,.68333,0,.16667,.83334],920:[0,.68333,.02778,.08334,.76278],923:[0,.68333,0,.16667,.69445],926:[0,.68333,.07569,.08334,.74236],928:[0,.68333,.08125,.05556,.83125],931:[0,.68333,.05764,.08334,.77986],933:[0,.68333,.13889,.05556,.58333],934:[0,.68333,0,.08334,.66667],936:[0,.68333,.11,.05556,.61222],937:[0,.68333,.05017,.08334,.7724],945:[0,.43056,.0037,.02778,.6397],946:[.19444,.69444,.05278,.08334,.56563],947:[.19444,.43056,.05556,0,.51773],948:[0,.69444,.03785,.05556,.44444],949:[0,.43056,0,.08334,.46632],950:[.19444,.69444,.07378,.08334,.4375],951:[.19444,.43056,.03588,.05556,.49653],952:[0,.69444,.02778,.08334,.46944],953:[0,.43056,0,.05556,.35394],954:[0,.43056,0,0,.57616],955:[0,.69444,0,0,.58334],956:[.19444,.43056,0,.02778,.60255],957:[0,.43056,.06366,.02778,.49398],958:[.19444,.69444,.04601,.11111,.4375],959:[0,.43056,0,.05556,.48472],960:[0,.43056,.03588,0,.57003],961:[.19444,.43056,0,.08334,.51702],962:[.09722,.43056,.07986,.08334,.36285],963:[0,.43056,.03588,0,.57141],964:[0,.43056,.1132,.02778,.43715],965:[0,.43056,.03588,.02778,.54028],966:[.19444,.43056,0,.08334,.65417],967:[.19444,.43056,0,.05556,.62569],968:[.19444,.69444,.03588,.11111,.65139],969:[0,.43056,.03588,0,.62245],977:[0,.69444,0,.08334,.59144],981:[.19444,.69444,0,.08334,.59583],982:[0,.43056,.02778,0,.82813],1009:[.19444,.43056,0,.08334,.51702],1013:[0,.43056,0,.05556,.4059],57649:[0,.43056,0,.02778,.32246],57911:[.19444,.43056,0,.08334,.38403]},"SansSerif-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.36667],34:[0,.69444,0,0,.55834],35:[.19444,.69444,0,0,.91667],36:[.05556,.75,0,0,.55],37:[.05556,.75,0,0,1.02912],38:[0,.69444,0,0,.83056],39:[0,.69444,0,0,.30556],40:[.25,.75,0,0,.42778],41:[.25,.75,0,0,.42778],42:[0,.75,0,0,.55],43:[.11667,.61667,0,0,.85556],44:[.10556,.13056,0,0,.30556],45:[0,.45833,0,0,.36667],46:[0,.13056,0,0,.30556],47:[.25,.75,0,0,.55],48:[0,.69444,0,0,.55],49:[0,.69444,0,0,.55],50:[0,.69444,0,0,.55],51:[0,.69444,0,0,.55],52:[0,.69444,0,0,.55],53:[0,.69444,0,0,.55],54:[0,.69444,0,0,.55],55:[0,.69444,0,0,.55],56:[0,.69444,0,0,.55],57:[0,.69444,0,0,.55],58:[0,.45833,0,0,.30556],59:[.10556,.45833,0,0,.30556],61:[-.09375,.40625,0,0,.85556],63:[0,.69444,0,0,.51945],64:[0,.69444,0,0,.73334],65:[0,.69444,0,0,.73334],66:[0,.69444,0,0,.73334],67:[0,.69444,0,0,.70278],68:[0,.69444,0,0,.79445],69:[0,.69444,0,0,.64167],70:[0,.69444,0,0,.61111],71:[0,.69444,0,0,.73334],72:[0,.69444,0,0,.79445],73:[0,.69444,0,0,.33056],74:[0,.69444,0,0,.51945],75:[0,.69444,0,0,.76389],76:[0,.69444,0,0,.58056],77:[0,.69444,0,0,.97778],78:[0,.69444,0,0,.79445],79:[0,.69444,0,0,.79445],80:[0,.69444,0,0,.70278],81:[.10556,.69444,0,0,.79445],82:[0,.69444,0,0,.70278],83:[0,.69444,0,0,.61111],84:[0,.69444,0,0,.73334],85:[0,.69444,0,0,.76389],86:[0,.69444,.01528,0,.73334],87:[0,.69444,.01528,0,1.03889],88:[0,.69444,0,0,.73334],89:[0,.69444,.0275,0,.73334],90:[0,.69444,0,0,.67223],91:[.25,.75,0,0,.34306],93:[.25,.75,0,0,.34306],94:[0,.69444,0,0,.55],95:[.35,.10833,.03056,0,.55],97:[0,.45833,0,0,.525],98:[0,.69444,0,0,.56111],99:[0,.45833,0,0,.48889],100:[0,.69444,0,0,.56111],101:[0,.45833,0,0,.51111],102:[0,.69444,.07639,0,.33611],103:[.19444,.45833,.01528,0,.55],104:[0,.69444,0,0,.56111],105:[0,.69444,0,0,.25556],106:[.19444,.69444,0,0,.28611],107:[0,.69444,0,0,.53056],108:[0,.69444,0,0,.25556],109:[0,.45833,0,0,.86667],110:[0,.45833,0,0,.56111],111:[0,.45833,0,0,.55],112:[.19444,.45833,0,0,.56111],113:[.19444,.45833,0,0,.56111],114:[0,.45833,.01528,0,.37222],115:[0,.45833,0,0,.42167],116:[0,.58929,0,0,.40417],117:[0,.45833,0,0,.56111],118:[0,.45833,.01528,0,.5],119:[0,.45833,.01528,0,.74445],120:[0,.45833,0,0,.5],121:[.19444,.45833,.01528,0,.5],122:[0,.45833,0,0,.47639],126:[.35,.34444,0,0,.55],160:[0,0,0,0,.25],168:[0,.69444,0,0,.55],176:[0,.69444,0,0,.73334],180:[0,.69444,0,0,.55],184:[.17014,0,0,0,.48889],305:[0,.45833,0,0,.25556],567:[.19444,.45833,0,0,.28611],710:[0,.69444,0,0,.55],711:[0,.63542,0,0,.55],713:[0,.63778,0,0,.55],728:[0,.69444,0,0,.55],729:[0,.69444,0,0,.30556],730:[0,.69444,0,0,.73334],732:[0,.69444,0,0,.55],733:[0,.69444,0,0,.55],915:[0,.69444,0,0,.58056],916:[0,.69444,0,0,.91667],920:[0,.69444,0,0,.85556],923:[0,.69444,0,0,.67223],926:[0,.69444,0,0,.73334],928:[0,.69444,0,0,.79445],931:[0,.69444,0,0,.79445],933:[0,.69444,0,0,.85556],934:[0,.69444,0,0,.79445],936:[0,.69444,0,0,.85556],937:[0,.69444,0,0,.79445],8211:[0,.45833,.03056,0,.55],8212:[0,.45833,.03056,0,1.10001],8216:[0,.69444,0,0,.30556],8217:[0,.69444,0,0,.30556],8220:[0,.69444,0,0,.55834],8221:[0,.69444,0,0,.55834]},"SansSerif-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.05733,0,.31945],34:[0,.69444,.00316,0,.5],35:[.19444,.69444,.05087,0,.83334],36:[.05556,.75,.11156,0,.5],37:[.05556,.75,.03126,0,.83334],38:[0,.69444,.03058,0,.75834],39:[0,.69444,.07816,0,.27778],40:[.25,.75,.13164,0,.38889],41:[.25,.75,.02536,0,.38889],42:[0,.75,.11775,0,.5],43:[.08333,.58333,.02536,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,.01946,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,.13164,0,.5],48:[0,.65556,.11156,0,.5],49:[0,.65556,.11156,0,.5],50:[0,.65556,.11156,0,.5],51:[0,.65556,.11156,0,.5],52:[0,.65556,.11156,0,.5],53:[0,.65556,.11156,0,.5],54:[0,.65556,.11156,0,.5],55:[0,.65556,.11156,0,.5],56:[0,.65556,.11156,0,.5],57:[0,.65556,.11156,0,.5],58:[0,.44444,.02502,0,.27778],59:[.125,.44444,.02502,0,.27778],61:[-.13,.37,.05087,0,.77778],63:[0,.69444,.11809,0,.47222],64:[0,.69444,.07555,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,.08293,0,.66667],67:[0,.69444,.11983,0,.63889],68:[0,.69444,.07555,0,.72223],69:[0,.69444,.11983,0,.59722],70:[0,.69444,.13372,0,.56945],71:[0,.69444,.11983,0,.66667],72:[0,.69444,.08094,0,.70834],73:[0,.69444,.13372,0,.27778],74:[0,.69444,.08094,0,.47222],75:[0,.69444,.11983,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,.08094,0,.875],78:[0,.69444,.08094,0,.70834],79:[0,.69444,.07555,0,.73611],80:[0,.69444,.08293,0,.63889],81:[.125,.69444,.07555,0,.73611],82:[0,.69444,.08293,0,.64584],83:[0,.69444,.09205,0,.55556],84:[0,.69444,.13372,0,.68056],85:[0,.69444,.08094,0,.6875],86:[0,.69444,.1615,0,.66667],87:[0,.69444,.1615,0,.94445],88:[0,.69444,.13372,0,.66667],89:[0,.69444,.17261,0,.66667],90:[0,.69444,.11983,0,.61111],91:[.25,.75,.15942,0,.28889],93:[.25,.75,.08719,0,.28889],94:[0,.69444,.0799,0,.5],95:[.35,.09444,.08616,0,.5],97:[0,.44444,.00981,0,.48056],98:[0,.69444,.03057,0,.51667],99:[0,.44444,.08336,0,.44445],100:[0,.69444,.09483,0,.51667],101:[0,.44444,.06778,0,.44445],102:[0,.69444,.21705,0,.30556],103:[.19444,.44444,.10836,0,.5],104:[0,.69444,.01778,0,.51667],105:[0,.67937,.09718,0,.23889],106:[.19444,.67937,.09162,0,.26667],107:[0,.69444,.08336,0,.48889],108:[0,.69444,.09483,0,.23889],109:[0,.44444,.01778,0,.79445],110:[0,.44444,.01778,0,.51667],111:[0,.44444,.06613,0,.5],112:[.19444,.44444,.0389,0,.51667],113:[.19444,.44444,.04169,0,.51667],114:[0,.44444,.10836,0,.34167],115:[0,.44444,.0778,0,.38333],116:[0,.57143,.07225,0,.36111],117:[0,.44444,.04169,0,.51667],118:[0,.44444,.10836,0,.46111],119:[0,.44444,.10836,0,.68334],120:[0,.44444,.09169,0,.46111],121:[.19444,.44444,.10836,0,.46111],122:[0,.44444,.08752,0,.43472],126:[.35,.32659,.08826,0,.5],160:[0,0,0,0,.25],168:[0,.67937,.06385,0,.5],176:[0,.69444,0,0,.73752],184:[.17014,0,0,0,.44445],305:[0,.44444,.04169,0,.23889],567:[.19444,.44444,.04169,0,.26667],710:[0,.69444,.0799,0,.5],711:[0,.63194,.08432,0,.5],713:[0,.60889,.08776,0,.5],714:[0,.69444,.09205,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,.09483,0,.5],729:[0,.67937,.07774,0,.27778],730:[0,.69444,0,0,.73752],732:[0,.67659,.08826,0,.5],733:[0,.69444,.09205,0,.5],915:[0,.69444,.13372,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,.07555,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,.12816,0,.66667],928:[0,.69444,.08094,0,.70834],931:[0,.69444,.11983,0,.72222],933:[0,.69444,.09031,0,.77778],934:[0,.69444,.04603,0,.72222],936:[0,.69444,.09031,0,.77778],937:[0,.69444,.08293,0,.72222],8211:[0,.44444,.08616,0,.5],8212:[0,.44444,.08616,0,1],8216:[0,.69444,.07816,0,.27778],8217:[0,.69444,.07816,0,.27778],8220:[0,.69444,.14205,0,.5],8221:[0,.69444,.00316,0,.5]},"SansSerif-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.31945],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.75834],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,0,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.65556,0,0,.5],49:[0,.65556,0,0,.5],50:[0,.65556,0,0,.5],51:[0,.65556,0,0,.5],52:[0,.65556,0,0,.5],53:[0,.65556,0,0,.5],54:[0,.65556,0,0,.5],55:[0,.65556,0,0,.5],56:[0,.65556,0,0,.5],57:[0,.65556,0,0,.5],58:[0,.44444,0,0,.27778],59:[.125,.44444,0,0,.27778],61:[-.13,.37,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,0,0,.66667],67:[0,.69444,0,0,.63889],68:[0,.69444,0,0,.72223],69:[0,.69444,0,0,.59722],70:[0,.69444,0,0,.56945],71:[0,.69444,0,0,.66667],72:[0,.69444,0,0,.70834],73:[0,.69444,0,0,.27778],74:[0,.69444,0,0,.47222],75:[0,.69444,0,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,0,0,.875],78:[0,.69444,0,0,.70834],79:[0,.69444,0,0,.73611],80:[0,.69444,0,0,.63889],81:[.125,.69444,0,0,.73611],82:[0,.69444,0,0,.64584],83:[0,.69444,0,0,.55556],84:[0,.69444,0,0,.68056],85:[0,.69444,0,0,.6875],86:[0,.69444,.01389,0,.66667],87:[0,.69444,.01389,0,.94445],88:[0,.69444,0,0,.66667],89:[0,.69444,.025,0,.66667],90:[0,.69444,0,0,.61111],91:[.25,.75,0,0,.28889],93:[.25,.75,0,0,.28889],94:[0,.69444,0,0,.5],95:[.35,.09444,.02778,0,.5],97:[0,.44444,0,0,.48056],98:[0,.69444,0,0,.51667],99:[0,.44444,0,0,.44445],100:[0,.69444,0,0,.51667],101:[0,.44444,0,0,.44445],102:[0,.69444,.06944,0,.30556],103:[.19444,.44444,.01389,0,.5],104:[0,.69444,0,0,.51667],105:[0,.67937,0,0,.23889],106:[.19444,.67937,0,0,.26667],107:[0,.69444,0,0,.48889],108:[0,.69444,0,0,.23889],109:[0,.44444,0,0,.79445],110:[0,.44444,0,0,.51667],111:[0,.44444,0,0,.5],112:[.19444,.44444,0,0,.51667],113:[.19444,.44444,0,0,.51667],114:[0,.44444,.01389,0,.34167],115:[0,.44444,0,0,.38333],116:[0,.57143,0,0,.36111],117:[0,.44444,0,0,.51667],118:[0,.44444,.01389,0,.46111],119:[0,.44444,.01389,0,.68334],120:[0,.44444,0,0,.46111],121:[.19444,.44444,.01389,0,.46111],122:[0,.44444,0,0,.43472],126:[.35,.32659,0,0,.5],160:[0,0,0,0,.25],168:[0,.67937,0,0,.5],176:[0,.69444,0,0,.66667],184:[.17014,0,0,0,.44445],305:[0,.44444,0,0,.23889],567:[.19444,.44444,0,0,.26667],710:[0,.69444,0,0,.5],711:[0,.63194,0,0,.5],713:[0,.60889,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.67937,0,0,.27778],730:[0,.69444,0,0,.66667],732:[0,.67659,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.69444,0,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,0,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,0,0,.66667],928:[0,.69444,0,0,.70834],931:[0,.69444,0,0,.72222],933:[0,.69444,0,0,.77778],934:[0,.69444,0,0,.72222],936:[0,.69444,0,0,.77778],937:[0,.69444,0,0,.72222],8211:[0,.44444,.02778,0,.5],8212:[0,.44444,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5]},"Script-Regular":{32:[0,0,0,0,.25],65:[0,.7,.22925,0,.80253],66:[0,.7,.04087,0,.90757],67:[0,.7,.1689,0,.66619],68:[0,.7,.09371,0,.77443],69:[0,.7,.18583,0,.56162],70:[0,.7,.13634,0,.89544],71:[0,.7,.17322,0,.60961],72:[0,.7,.29694,0,.96919],73:[0,.7,.19189,0,.80907],74:[.27778,.7,.19189,0,1.05159],75:[0,.7,.31259,0,.91364],76:[0,.7,.19189,0,.87373],77:[0,.7,.15981,0,1.08031],78:[0,.7,.3525,0,.9015],79:[0,.7,.08078,0,.73787],80:[0,.7,.08078,0,1.01262],81:[0,.7,.03305,0,.88282],82:[0,.7,.06259,0,.85],83:[0,.7,.19189,0,.86767],84:[0,.7,.29087,0,.74697],85:[0,.7,.25815,0,.79996],86:[0,.7,.27523,0,.62204],87:[0,.7,.27523,0,.80532],88:[0,.7,.26006,0,.94445],89:[0,.7,.2939,0,.70961],90:[0,.7,.24037,0,.8212],160:[0,0,0,0,.25]},"Size1-Regular":{32:[0,0,0,0,.25],40:[.35001,.85,0,0,.45834],41:[.35001,.85,0,0,.45834],47:[.35001,.85,0,0,.57778],91:[.35001,.85,0,0,.41667],92:[.35001,.85,0,0,.57778],93:[.35001,.85,0,0,.41667],123:[.35001,.85,0,0,.58334],125:[.35001,.85,0,0,.58334],160:[0,0,0,0,.25],710:[0,.72222,0,0,.55556],732:[0,.72222,0,0,.55556],770:[0,.72222,0,0,.55556],771:[0,.72222,0,0,.55556],8214:[-99e-5,.601,0,0,.77778],8593:[1e-5,.6,0,0,.66667],8595:[1e-5,.6,0,0,.66667],8657:[1e-5,.6,0,0,.77778],8659:[1e-5,.6,0,0,.77778],8719:[.25001,.75,0,0,.94445],8720:[.25001,.75,0,0,.94445],8721:[.25001,.75,0,0,1.05556],8730:[.35001,.85,0,0,1],8739:[-.00599,.606,0,0,.33333],8741:[-.00599,.606,0,0,.55556],8747:[.30612,.805,.19445,0,.47222],8748:[.306,.805,.19445,0,.47222],8749:[.306,.805,.19445,0,.47222],8750:[.30612,.805,.19445,0,.47222],8896:[.25001,.75,0,0,.83334],8897:[.25001,.75,0,0,.83334],8898:[.25001,.75,0,0,.83334],8899:[.25001,.75,0,0,.83334],8968:[.35001,.85,0,0,.47222],8969:[.35001,.85,0,0,.47222],8970:[.35001,.85,0,0,.47222],8971:[.35001,.85,0,0,.47222],9168:[-99e-5,.601,0,0,.66667],10216:[.35001,.85,0,0,.47222],10217:[.35001,.85,0,0,.47222],10752:[.25001,.75,0,0,1.11111],10753:[.25001,.75,0,0,1.11111],10754:[.25001,.75,0,0,1.11111],10756:[.25001,.75,0,0,.83334],10758:[.25001,.75,0,0,.83334]},"Size2-Regular":{32:[0,0,0,0,.25],40:[.65002,1.15,0,0,.59722],41:[.65002,1.15,0,0,.59722],47:[.65002,1.15,0,0,.81111],91:[.65002,1.15,0,0,.47222],92:[.65002,1.15,0,0,.81111],93:[.65002,1.15,0,0,.47222],123:[.65002,1.15,0,0,.66667],125:[.65002,1.15,0,0,.66667],160:[0,0,0,0,.25],710:[0,.75,0,0,1],732:[0,.75,0,0,1],770:[0,.75,0,0,1],771:[0,.75,0,0,1],8719:[.55001,1.05,0,0,1.27778],8720:[.55001,1.05,0,0,1.27778],8721:[.55001,1.05,0,0,1.44445],8730:[.65002,1.15,0,0,1],8747:[.86225,1.36,.44445,0,.55556],8748:[.862,1.36,.44445,0,.55556],8749:[.862,1.36,.44445,0,.55556],8750:[.86225,1.36,.44445,0,.55556],8896:[.55001,1.05,0,0,1.11111],8897:[.55001,1.05,0,0,1.11111],8898:[.55001,1.05,0,0,1.11111],8899:[.55001,1.05,0,0,1.11111],8968:[.65002,1.15,0,0,.52778],8969:[.65002,1.15,0,0,.52778],8970:[.65002,1.15,0,0,.52778],8971:[.65002,1.15,0,0,.52778],10216:[.65002,1.15,0,0,.61111],10217:[.65002,1.15,0,0,.61111],10752:[.55001,1.05,0,0,1.51112],10753:[.55001,1.05,0,0,1.51112],10754:[.55001,1.05,0,0,1.51112],10756:[.55001,1.05,0,0,1.11111],10758:[.55001,1.05,0,0,1.11111]},"Size3-Regular":{32:[0,0,0,0,.25],40:[.95003,1.45,0,0,.73611],41:[.95003,1.45,0,0,.73611],47:[.95003,1.45,0,0,1.04445],91:[.95003,1.45,0,0,.52778],92:[.95003,1.45,0,0,1.04445],93:[.95003,1.45,0,0,.52778],123:[.95003,1.45,0,0,.75],125:[.95003,1.45,0,0,.75],160:[0,0,0,0,.25],710:[0,.75,0,0,1.44445],732:[0,.75,0,0,1.44445],770:[0,.75,0,0,1.44445],771:[0,.75,0,0,1.44445],8730:[.95003,1.45,0,0,1],8968:[.95003,1.45,0,0,.58334],8969:[.95003,1.45,0,0,.58334],8970:[.95003,1.45,0,0,.58334],8971:[.95003,1.45,0,0,.58334],10216:[.95003,1.45,0,0,.75],10217:[.95003,1.45,0,0,.75]},"Size4-Regular":{32:[0,0,0,0,.25],40:[1.25003,1.75,0,0,.79167],41:[1.25003,1.75,0,0,.79167],47:[1.25003,1.75,0,0,1.27778],91:[1.25003,1.75,0,0,.58334],92:[1.25003,1.75,0,0,1.27778],93:[1.25003,1.75,0,0,.58334],123:[1.25003,1.75,0,0,.80556],125:[1.25003,1.75,0,0,.80556],160:[0,0,0,0,.25],710:[0,.825,0,0,1.8889],732:[0,.825,0,0,1.8889],770:[0,.825,0,0,1.8889],771:[0,.825,0,0,1.8889],8730:[1.25003,1.75,0,0,1],8968:[1.25003,1.75,0,0,.63889],8969:[1.25003,1.75,0,0,.63889],8970:[1.25003,1.75,0,0,.63889],8971:[1.25003,1.75,0,0,.63889],9115:[.64502,1.155,0,0,.875],9116:[1e-5,.6,0,0,.875],9117:[.64502,1.155,0,0,.875],9118:[.64502,1.155,0,0,.875],9119:[1e-5,.6,0,0,.875],9120:[.64502,1.155,0,0,.875],9121:[.64502,1.155,0,0,.66667],9122:[-99e-5,.601,0,0,.66667],9123:[.64502,1.155,0,0,.66667],9124:[.64502,1.155,0,0,.66667],9125:[-99e-5,.601,0,0,.66667],9126:[.64502,1.155,0,0,.66667],9127:[1e-5,.9,0,0,.88889],9128:[.65002,1.15,0,0,.88889],9129:[.90001,0,0,0,.88889],9130:[0,.3,0,0,.88889],9131:[1e-5,.9,0,0,.88889],9132:[.65002,1.15,0,0,.88889],9133:[.90001,0,0,0,.88889],9143:[.88502,.915,0,0,1.05556],10216:[1.25003,1.75,0,0,.80556],10217:[1.25003,1.75,0,0,.80556],57344:[-.00499,.605,0,0,1.05556],57345:[-.00499,.605,0,0,1.05556],57680:[0,.12,0,0,.45],57681:[0,.12,0,0,.45],57682:[0,.12,0,0,.45],57683:[0,.12,0,0,.45]},"Typewriter-Regular":{32:[0,0,0,0,.525],33:[0,.61111,0,0,.525],34:[0,.61111,0,0,.525],35:[0,.61111,0,0,.525],36:[.08333,.69444,0,0,.525],37:[.08333,.69444,0,0,.525],38:[0,.61111,0,0,.525],39:[0,.61111,0,0,.525],40:[.08333,.69444,0,0,.525],41:[.08333,.69444,0,0,.525],42:[0,.52083,0,0,.525],43:[-.08056,.53055,0,0,.525],44:[.13889,.125,0,0,.525],45:[-.08056,.53055,0,0,.525],46:[0,.125,0,0,.525],47:[.08333,.69444,0,0,.525],48:[0,.61111,0,0,.525],49:[0,.61111,0,0,.525],50:[0,.61111,0,0,.525],51:[0,.61111,0,0,.525],52:[0,.61111,0,0,.525],53:[0,.61111,0,0,.525],54:[0,.61111,0,0,.525],55:[0,.61111,0,0,.525],56:[0,.61111,0,0,.525],57:[0,.61111,0,0,.525],58:[0,.43056,0,0,.525],59:[.13889,.43056,0,0,.525],60:[-.05556,.55556,0,0,.525],61:[-.19549,.41562,0,0,.525],62:[-.05556,.55556,0,0,.525],63:[0,.61111,0,0,.525],64:[0,.61111,0,0,.525],65:[0,.61111,0,0,.525],66:[0,.61111,0,0,.525],67:[0,.61111,0,0,.525],68:[0,.61111,0,0,.525],69:[0,.61111,0,0,.525],70:[0,.61111,0,0,.525],71:[0,.61111,0,0,.525],72:[0,.61111,0,0,.525],73:[0,.61111,0,0,.525],74:[0,.61111,0,0,.525],75:[0,.61111,0,0,.525],76:[0,.61111,0,0,.525],77:[0,.61111,0,0,.525],78:[0,.61111,0,0,.525],79:[0,.61111,0,0,.525],80:[0,.61111,0,0,.525],81:[.13889,.61111,0,0,.525],82:[0,.61111,0,0,.525],83:[0,.61111,0,0,.525],84:[0,.61111,0,0,.525],85:[0,.61111,0,0,.525],86:[0,.61111,0,0,.525],87:[0,.61111,0,0,.525],88:[0,.61111,0,0,.525],89:[0,.61111,0,0,.525],90:[0,.61111,0,0,.525],91:[.08333,.69444,0,0,.525],92:[.08333,.69444,0,0,.525],93:[.08333,.69444,0,0,.525],94:[0,.61111,0,0,.525],95:[.09514,0,0,0,.525],96:[0,.61111,0,0,.525],97:[0,.43056,0,0,.525],98:[0,.61111,0,0,.525],99:[0,.43056,0,0,.525],100:[0,.61111,0,0,.525],101:[0,.43056,0,0,.525],102:[0,.61111,0,0,.525],103:[.22222,.43056,0,0,.525],104:[0,.61111,0,0,.525],105:[0,.61111,0,0,.525],106:[.22222,.61111,0,0,.525],107:[0,.61111,0,0,.525],108:[0,.61111,0,0,.525],109:[0,.43056,0,0,.525],110:[0,.43056,0,0,.525],111:[0,.43056,0,0,.525],112:[.22222,.43056,0,0,.525],113:[.22222,.43056,0,0,.525],114:[0,.43056,0,0,.525],115:[0,.43056,0,0,.525],116:[0,.55358,0,0,.525],117:[0,.43056,0,0,.525],118:[0,.43056,0,0,.525],119:[0,.43056,0,0,.525],120:[0,.43056,0,0,.525],121:[.22222,.43056,0,0,.525],122:[0,.43056,0,0,.525],123:[.08333,.69444,0,0,.525],124:[.08333,.69444,0,0,.525],125:[.08333,.69444,0,0,.525],126:[0,.61111,0,0,.525],127:[0,.61111,0,0,.525],160:[0,0,0,0,.525],176:[0,.61111,0,0,.525],184:[.19445,0,0,0,.525],305:[0,.43056,0,0,.525],567:[.22222,.43056,0,0,.525],711:[0,.56597,0,0,.525],713:[0,.56555,0,0,.525],714:[0,.61111,0,0,.525],715:[0,.61111,0,0,.525],728:[0,.61111,0,0,.525],730:[0,.61111,0,0,.525],770:[0,.61111,0,0,.525],771:[0,.61111,0,0,.525],776:[0,.61111,0,0,.525],915:[0,.61111,0,0,.525],916:[0,.61111,0,0,.525],920:[0,.61111,0,0,.525],923:[0,.61111,0,0,.525],926:[0,.61111,0,0,.525],928:[0,.61111,0,0,.525],931:[0,.61111,0,0,.525],933:[0,.61111,0,0,.525],934:[0,.61111,0,0,.525],936:[0,.61111,0,0,.525],937:[0,.61111,0,0,.525],8216:[0,.61111,0,0,.525],8217:[0,.61111,0,0,.525],8242:[0,.61111,0,0,.525],9251:[.11111,.21944,0,0,.525]}};const B={slant:[.25,.25,.25],space:[0,0,0],stretch:[0,0,0],shrink:[0,0,0],xHeight:[.431,.431,.431],quad:[1,1.171,1.472],extraSpace:[0,0,0],num1:[.677,.732,.925],num2:[.394,.384,.387],num3:[.444,.471,.504],denom1:[.686,.752,1.025],denom2:[.345,.344,.532],sup1:[.413,.503,.504],sup2:[.363,.431,.404],sup3:[.289,.286,.294],sub1:[.15,.143,.2],sub2:[.247,.286,.4],supDrop:[.386,.353,.494],subDrop:[.05,.071,.1],delim1:[2.39,1.7,1.98],delim2:[1.01,1.157,1.42],axisHeight:[.25,.25,.25],defaultRuleThickness:[.04,.049,.049],bigOpSpacing1:[.111,.111,.111],bigOpSpacing2:[.166,.166,.166],bigOpSpacing3:[.2,.2,.2],bigOpSpacing4:[.6,.611,.611],bigOpSpacing5:[.1,.143,.143],sqrtRuleThickness:[.04,.04,.04],ptPerEm:[10,10,10],doubleRuleSep:[.2,.2,.2],arrayRuleWidth:[.04,.04,.04],fboxsep:[.3,.3,.3],fboxrule:[.04,.04,.04]},C={"\xc5":"A","\xd0":"D","\xde":"o","\xe5":"a","\xf0":"d","\xfe":"o","\u0410":"A","\u0411":"B","\u0412":"B","\u0413":"F","\u0414":"A","\u0415":"E","\u0416":"K","\u0417":"3","\u0418":"N","\u0419":"N","\u041a":"K","\u041b":"N","\u041c":"M","\u041d":"H","\u041e":"O","\u041f":"N","\u0420":"P","\u0421":"C","\u0422":"T","\u0423":"y","\u0424":"O","\u0425":"X","\u0426":"U","\u0427":"h","\u0428":"W","\u0429":"W","\u042a":"B","\u042b":"X","\u042c":"B","\u042d":"3","\u042e":"X","\u042f":"R","\u0430":"a","\u0431":"b","\u0432":"a","\u0433":"r","\u0434":"y","\u0435":"e","\u0436":"m","\u0437":"e","\u0438":"n","\u0439":"n","\u043a":"n","\u043b":"n","\u043c":"m","\u043d":"n","\u043e":"o","\u043f":"n","\u0440":"p","\u0441":"c","\u0442":"o","\u0443":"y","\u0444":"b","\u0445":"x","\u0446":"n","\u0447":"n","\u0448":"w","\u0449":"w","\u044a":"a","\u044b":"m","\u044c":"a","\u044d":"e","\u044e":"m","\u044f":"r"};function N(e,t,r){if(!T[t])throw new Error("Font metrics not found for font: "+t+".");let n=e.charCodeAt(0),o=T[t][n];if(!o&&e[0]in C&&(n=C[e[0]].charCodeAt(0),o=T[t][n]),o||"text"!==r||S(n)&&(o=T[t][77]),o)return{depth:o[0],height:o[1],italic:o[2],skew:o[3],width:o[4]}}const q={};const I=[[1,1,1],[2,1,1],[3,1,1],[4,2,1],[5,2,1],[6,3,1],[7,4,2],[8,6,3],[9,7,6],[10,8,7],[11,10,9]],R=[.5,.6,.7,.8,.9,1,1.2,1.44,1.728,2.074,2.488],H=function(e,t){return t.size<2?e:I[e-1][t.size-1]};class O{constructor(e){this.style=void 0,this.color=void 0,this.size=void 0,this.textSize=void 0,this.phantom=void 0,this.font=void 0,this.fontFamily=void 0,this.fontWeight=void 0,this.fontShape=void 0,this.sizeMultiplier=void 0,this.maxSize=void 0,this.minRuleThickness=void 0,this._fontMetrics=void 0,this.style=e.style,this.color=e.color,this.size=e.size||O.BASESIZE,this.textSize=e.textSize||this.size,this.phantom=!!e.phantom,this.font=e.font||"",this.fontFamily=e.fontFamily||"",this.fontWeight=e.fontWeight||"",this.fontShape=e.fontShape||"",this.sizeMultiplier=R[this.size-1],this.maxSize=e.maxSize,this.minRuleThickness=e.minRuleThickness,this._fontMetrics=void 0}extend(e){const t={style:this.style,size:this.size,textSize:this.textSize,color:this.color,phantom:this.phantom,font:this.font,fontFamily:this.fontFamily,fontWeight:this.fontWeight,fontShape:this.fontShape,maxSize:this.maxSize,minRuleThickness:this.minRuleThickness};for(const r in e)e.hasOwnProperty(r)&&(t[r]=e[r]);return new O(t)}havingStyle(e){return this.style===e?this:this.extend({style:e,size:H(this.textSize,e)})}havingCrampedStyle(){return this.havingStyle(this.style.cramp())}havingSize(e){return this.size===e&&this.textSize===e?this:this.extend({style:this.style.text(),size:e,textSize:e,sizeMultiplier:R[e-1]})}havingBaseStyle(e){e=e||this.style.text();const t=H(O.BASESIZE,e);return this.size===t&&this.textSize===O.BASESIZE&&this.style===e?this:this.extend({style:e,size:t})}havingBaseSizing(){let e;switch(this.style.id){case 4:case 5:e=3;break;case 6:case 7:e=1;break;default:e=6}return this.extend({style:this.style.text(),size:e})}withColor(e){return this.extend({color:e})}withPhantom(){return this.extend({phantom:!0})}withFont(e){return this.extend({font:e})}withTextFontFamily(e){return this.extend({fontFamily:e,font:""})}withTextFontWeight(e){return this.extend({fontWeight:e,font:""})}withTextFontShape(e){return this.extend({fontShape:e,font:""})}sizingClasses(e){return e.size!==this.size?["sizing","reset-size"+e.size,"size"+this.size]:[]}baseSizingClasses(){return this.size!==O.BASESIZE?["sizing","reset-size"+this.size,"size"+O.BASESIZE]:[]}fontMetrics(){return this._fontMetrics||(this._fontMetrics=function(e){let t;if(t=e>=5?0:e>=3?1:2,!q[t]){const e=q[t]={cssEmPerMu:B.quad[t]/18};for(const r in B)B.hasOwnProperty(r)&&(e[r]=B[r][t])}return q[t]}(this.size)),this._fontMetrics}getColor(){return this.phantom?"transparent":this.color}}O.BASESIZE=6;var E=O;const L={pt:1,mm:7227/2540,cm:7227/254,in:72.27,bp:1.00375,pc:12,dd:1238/1157,cc:14856/1157,nd:685/642,nc:1370/107,sp:1/65536,px:1.00375},D={ex:!0,em:!0,mu:!0},V=function(e){return"string"!=typeof e&&(e=e.unit),e in L||e in D||"ex"===e},P=function(e,t){let r;if(e.unit in L)r=L[e.unit]/t.fontMetrics().ptPerEm/t.sizeMultiplier;else if("mu"===e.unit)r=t.fontMetrics().cssEmPerMu;else{let o;if(o=t.style.isTight()?t.havingStyle(t.style.text()):t,"ex"===e.unit)r=o.fontMetrics().xHeight;else{if("em"!==e.unit)throw new n("Invalid unit: '"+e.unit+"'");r=o.fontMetrics().quad}o!==t&&(r*=o.sizeMultiplier/t.sizeMultiplier)}return Math.min(e.number*r,t.maxSize)},F=function(e){return+e.toFixed(4)+"em"},G=function(e){return e.filter((e=>e)).join(" ")},U=function(e,t,r){if(this.classes=e||[],this.attributes={},this.height=0,this.depth=0,this.maxFontSize=0,this.style=r||{},t){t.style.isTight()&&this.classes.push("mtight");const e=t.getColor();e&&(this.style.color=e)}},Y=function(e){const t=document.createElement(e);t.className=G(this.classes);for(const e in this.style)this.style.hasOwnProperty(e)&&(t.style[e]=this.style[e]);for(const e in this.attributes)this.attributes.hasOwnProperty(e)&&t.setAttribute(e,this.attributes[e]);for(let e=0;e",t};class W{constructor(e,t,r,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.width=void 0,this.maxFontSize=void 0,this.style=void 0,U.call(this,e,r,n),this.children=t||[]}setAttribute(e,t){this.attributes[e]=t}hasClass(e){return l.contains(this.classes,e)}toNode(){return Y.call(this,"span")}toMarkup(){return X.call(this,"span")}}class _{constructor(e,t,r,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,U.call(this,t,n),this.children=r||[],this.setAttribute("href",e)}setAttribute(e,t){this.attributes[e]=t}hasClass(e){return l.contains(this.classes,e)}toNode(){return Y.call(this,"a")}toMarkup(){return X.call(this,"a")}}class j{constructor(e,t,r){this.src=void 0,this.alt=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.alt=t,this.src=e,this.classes=["mord"],this.style=r}hasClass(e){return l.contains(this.classes,e)}toNode(){const e=document.createElement("img");e.src=this.src,e.alt=this.alt,e.className="mord";for(const t in this.style)this.style.hasOwnProperty(t)&&(e.style[t]=this.style[t]);return e}toMarkup(){let e=''+l.escape(this.alt)+'=n[0]&&e<=n[1])return r.name}}return null}(this.text.charCodeAt(0));l&&this.classes.push(l+"_fallback"),/[\xee\xef\xed\xec]/.test(this.text)&&(this.text=$[this.text])}hasClass(e){return l.contains(this.classes,e)}toNode(){const e=document.createTextNode(this.text);let t=null;this.italic>0&&(t=document.createElement("span"),t.style.marginRight=F(this.italic)),this.classes.length>0&&(t=t||document.createElement("span"),t.className=G(this.classes));for(const e in this.style)this.style.hasOwnProperty(e)&&(t=t||document.createElement("span"),t.style[e]=this.style[e]);return t?(t.appendChild(e),t):e}toMarkup(){let e=!1,t="0&&(r+="margin-right:"+this.italic+"em;");for(const e in this.style)this.style.hasOwnProperty(e)&&(r+=l.hyphenate(e)+":"+this.style[e]+";");r&&(e=!0,t+=' style="'+l.escape(r)+'"');const n=l.escape(this.text);return e?(t+=">",t+=n,t+="
",t):n}}class K{constructor(e,t){this.children=void 0,this.attributes=void 0,this.children=e||[],this.attributes=t||{}}toNode(){const e=document.createElementNS("http://www.w3.org/2000/svg","svg");for(const t in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,t)&&e.setAttribute(t,this.attributes[t]);for(let t=0;t':''}}class Q{constructor(e){this.attributes=void 0,this.attributes=e||{}}toNode(){const e=document.createElementNS("http://www.w3.org/2000/svg","line");for(const t in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,t)&&e.setAttribute(t,this.attributes[t]);return e}toMarkup(){let e="","\\gt",!0),se(ie,le,ye,"\u2208","\\in",!0),se(ie,le,ye,"\ue020","\\@not"),se(ie,le,ye,"\u2282","\\subset",!0),se(ie,le,ye,"\u2283","\\supset",!0),se(ie,le,ye,"\u2286","\\subseteq",!0),se(ie,le,ye,"\u2287","\\supseteq",!0),se(ie,he,ye,"\u2288","\\nsubseteq",!0),se(ie,he,ye,"\u2289","\\nsupseteq",!0),se(ie,le,ye,"\u22a8","\\models"),se(ie,le,ye,"\u2190","\\leftarrow",!0),se(ie,le,ye,"\u2264","\\le"),se(ie,le,ye,"\u2264","\\leq",!0),se(ie,le,ye,"<","\\lt",!0),se(ie,le,ye,"\u2192","\\rightarrow",!0),se(ie,le,ye,"\u2192","\\to"),se(ie,he,ye,"\u2271","\\ngeq",!0),se(ie,he,ye,"\u2270","\\nleq",!0),se(ie,le,xe,"\xa0","\\ "),se(ie,le,xe,"\xa0","\\space"),se(ie,le,xe,"\xa0","\\nobreakspace"),se(ae,le,xe,"\xa0","\\ "),se(ae,le,xe,"\xa0"," "),se(ae,le,xe,"\xa0","\\space"),se(ae,le,xe,"\xa0","\\nobreakspace"),se(ie,le,xe,null,"\\nobreak"),se(ie,le,xe,null,"\\allowbreak"),se(ie,le,be,",",","),se(ie,le,be,";",";"),se(ie,he,me,"\u22bc","\\barwedge",!0),se(ie,he,me,"\u22bb","\\veebar",!0),se(ie,le,me,"\u2299","\\odot",!0),se(ie,le,me,"\u2295","\\oplus",!0),se(ie,le,me,"\u2297","\\otimes",!0),se(ie,le,we,"\u2202","\\partial",!0),se(ie,le,me,"\u2298","\\oslash",!0),se(ie,he,me,"\u229a","\\circledcirc",!0),se(ie,he,me,"\u22a1","\\boxdot",!0),se(ie,le,me,"\u25b3","\\bigtriangleup"),se(ie,le,me,"\u25bd","\\bigtriangledown"),se(ie,le,me,"\u2020","\\dagger"),se(ie,le,me,"\u22c4","\\diamond"),se(ie,le,me,"\u22c6","\\star"),se(ie,le,me,"\u25c3","\\triangleleft"),se(ie,le,me,"\u25b9","\\triangleright"),se(ie,le,fe,"{","\\{"),se(ae,le,we,"{","\\{"),se(ae,le,we,"{","\\textbraceleft"),se(ie,le,pe,"}","\\}"),se(ae,le,we,"}","\\}"),se(ae,le,we,"}","\\textbraceright"),se(ie,le,fe,"{","\\lbrace"),se(ie,le,pe,"}","\\rbrace"),se(ie,le,fe,"[","\\lbrack",!0),se(ae,le,we,"[","\\lbrack",!0),se(ie,le,pe,"]","\\rbrack",!0),se(ae,le,we,"]","\\rbrack",!0),se(ie,le,fe,"(","\\lparen",!0),se(ie,le,pe,")","\\rparen",!0),se(ae,le,we,"<","\\textless",!0),se(ae,le,we,">","\\textgreater",!0),se(ie,le,fe,"\u230a","\\lfloor",!0),se(ie,le,pe,"\u230b","\\rfloor",!0),se(ie,le,fe,"\u2308","\\lceil",!0),se(ie,le,pe,"\u2309","\\rceil",!0),se(ie,le,we,"\\","\\backslash"),se(ie,le,we,"\u2223","|"),se(ie,le,we,"\u2223","\\vert"),se(ae,le,we,"|","\\textbar",!0),se(ie,le,we,"\u2225","\\|"),se(ie,le,we,"\u2225","\\Vert"),se(ae,le,we,"\u2225","\\textbardbl"),se(ae,le,we,"~","\\textasciitilde"),se(ae,le,we,"\\","\\textbackslash"),se(ae,le,we,"^","\\textasciicircum"),se(ie,le,ye,"\u2191","\\uparrow",!0),se(ie,le,ye,"\u21d1","\\Uparrow",!0),se(ie,le,ye,"\u2193","\\downarrow",!0),se(ie,le,ye,"\u21d3","\\Downarrow",!0),se(ie,le,ye,"\u2195","\\updownarrow",!0),se(ie,le,ye,"\u21d5","\\Updownarrow",!0),se(ie,le,ge,"\u2210","\\coprod"),se(ie,le,ge,"\u22c1","\\bigvee"),se(ie,le,ge,"\u22c0","\\bigwedge"),se(ie,le,ge,"\u2a04","\\biguplus"),se(ie,le,ge,"\u22c2","\\bigcap"),se(ie,le,ge,"\u22c3","\\bigcup"),se(ie,le,ge,"\u222b","\\int"),se(ie,le,ge,"\u222b","\\intop"),se(ie,le,ge,"\u222c","\\iint"),se(ie,le,ge,"\u222d","\\iiint"),se(ie,le,ge,"\u220f","\\prod"),se(ie,le,ge,"\u2211","\\sum"),se(ie,le,ge,"\u2a02","\\bigotimes"),se(ie,le,ge,"\u2a01","\\bigoplus"),se(ie,le,ge,"\u2a00","\\bigodot"),se(ie,le,ge,"\u222e","\\oint"),se(ie,le,ge,"\u222f","\\oiint"),se(ie,le,ge,"\u2230","\\oiiint"),se(ie,le,ge,"\u2a06","\\bigsqcup"),se(ie,le,ge,"\u222b","\\smallint"),se(ae,le,ue,"\u2026","\\textellipsis"),se(ie,le,ue,"\u2026","\\mathellipsis"),se(ae,le,ue,"\u2026","\\ldots",!0),se(ie,le,ue,"\u2026","\\ldots",!0),se(ie,le,ue,"\u22ef","\\@cdots",!0),se(ie,le,ue,"\u22f1","\\ddots",!0),se(ie,le,we,"\u22ee","\\varvdots"),se(ie,le,ce,"\u02ca","\\acute"),se(ie,le,ce,"\u02cb","\\grave"),se(ie,le,ce,"\xa8","\\ddot"),se(ie,le,ce,"~","\\tilde"),se(ie,le,ce,"\u02c9","\\bar"),se(ie,le,ce,"\u02d8","\\breve"),se(ie,le,ce,"\u02c7","\\check"),se(ie,le,ce,"^","\\hat"),se(ie,le,ce,"\u20d7","\\vec"),se(ie,le,ce,"\u02d9","\\dot"),se(ie,le,ce,"\u02da","\\mathring"),se(ie,le,de,"\ue131","\\@imath"),se(ie,le,de,"\ue237","\\@jmath"),se(ie,le,we,"\u0131","\u0131"),se(ie,le,we,"\u0237","\u0237"),se(ae,le,we,"\u0131","\\i",!0),se(ae,le,we,"\u0237","\\j",!0),se(ae,le,we,"\xdf","\\ss",!0),se(ae,le,we,"\xe6","\\ae",!0),se(ae,le,we,"\u0153","\\oe",!0),se(ae,le,we,"\xf8","\\o",!0),se(ae,le,we,"\xc6","\\AE",!0),se(ae,le,we,"\u0152","\\OE",!0),se(ae,le,we,"\xd8","\\O",!0),se(ae,le,ce,"\u02ca","\\'"),se(ae,le,ce,"\u02cb","\\`"),se(ae,le,ce,"\u02c6","\\^"),se(ae,le,ce,"\u02dc","\\~"),se(ae,le,ce,"\u02c9","\\="),se(ae,le,ce,"\u02d8","\\u"),se(ae,le,ce,"\u02d9","\\."),se(ae,le,ce,"\xb8","\\c"),se(ae,le,ce,"\u02da","\\r"),se(ae,le,ce,"\u02c7","\\v"),se(ae,le,ce,"\xa8",'\\"'),se(ae,le,ce,"\u02dd","\\H"),se(ae,le,ce,"\u25ef","\\textcircled");const ve={"--":!0,"---":!0,"``":!0,"''":!0};se(ae,le,we,"\u2013","--",!0),se(ae,le,we,"\u2013","\\textendash"),se(ae,le,we,"\u2014","---",!0),se(ae,le,we,"\u2014","\\textemdash"),se(ae,le,we,"\u2018","`",!0),se(ae,le,we,"\u2018","\\textquoteleft"),se(ae,le,we,"\u2019","'",!0),se(ae,le,we,"\u2019","\\textquoteright"),se(ae,le,we,"\u201c","``",!0),se(ae,le,we,"\u201c","\\textquotedblleft"),se(ae,le,we,"\u201d","''",!0),se(ae,le,we,"\u201d","\\textquotedblright"),se(ie,le,we,"\xb0","\\degree",!0),se(ae,le,we,"\xb0","\\degree"),se(ae,le,we,"\xb0","\\textdegree",!0),se(ie,le,we,"\xa3","\\pounds"),se(ie,le,we,"\xa3","\\mathsterling",!0),se(ae,le,we,"\xa3","\\pounds"),se(ae,le,we,"\xa3","\\textsterling",!0),se(ie,he,we,"\u2720","\\maltese"),se(ae,he,we,"\u2720","\\maltese");const ke='0123456789/@."';for(let e=0;e{if(G(e.classes)!==G(t.classes)||e.skew!==t.skew||e.maxFontSize!==t.maxFontSize)return!1;if(1===e.classes.length){const t=e.classes[0];if("mbin"===t||"mord"===t)return!1}for(const r in e.style)if(e.style.hasOwnProperty(r)&&e.style[r]!==t.style[r])return!1;for(const r in t.style)if(t.style.hasOwnProperty(r)&&e.style[r]!==t.style[r])return!1;return!0},Ie=function(e){let t=0,r=0,n=0;for(let o=0;ot&&(t=s.height),s.depth>r&&(r=s.depth),s.maxFontSize>n&&(n=s.maxFontSize)}e.height=t,e.depth=r,e.maxFontSize=n},Re=function(e,t,r,n){const o=new W(e,t,r,n);return Ie(o),o},He=(e,t,r,n)=>new W(e,t,r,n),Oe=function(e){const t=new A(e);return Ie(t),t},Ee=function(e,t,r){let n,o="";switch(e){case"amsrm":o="AMS";break;case"textrm":o="Main";break;case"textsf":o="SansSerif";break;case"texttt":o="Typewriter";break;default:o=e}return n="textbf"===t&&"textit"===r?"BoldItalic":"textbf"===t?"Bold":"textit"===t?"Italic":"Regular",o+"-"+n},Le={mathbf:{variant:"bold",fontName:"Main-Bold"},mathrm:{variant:"normal",fontName:"Main-Regular"},textit:{variant:"italic",fontName:"Main-Italic"},mathit:{variant:"italic",fontName:"Main-Italic"},mathnormal:{variant:"italic",fontName:"Math-Italic"},mathbb:{variant:"double-struck",fontName:"AMS-Regular"},mathcal:{variant:"script",fontName:"Caligraphic-Regular"},mathfrak:{variant:"fraktur",fontName:"Fraktur-Regular"},mathscr:{variant:"script",fontName:"Script-Regular"},mathsf:{variant:"sans-serif",fontName:"SansSerif-Regular"},mathtt:{variant:"monospace",fontName:"Typewriter-Regular"}},De={vec:["vec",.471,.714],oiintSize1:["oiintSize1",.957,.499],oiintSize2:["oiintSize2",1.472,.659],oiiintSize1:["oiiintSize1",1.304,.499],oiiintSize2:["oiiintSize2",1.98,.659]};var Ve={fontMap:Le,makeSymbol:Ne,mathsym:function(e,t,r,n){return void 0===n&&(n=[]),"boldsymbol"===r.font&&Ce(e,"Main-Bold",t).metrics?Ne(e,"Main-Bold",t,r,n.concat(["mathbf"])):"\\"===e||"main"===oe[t][e].font?Ne(e,"Main-Regular",t,r,n):Ne(e,"AMS-Regular",t,r,n.concat(["amsrm"]))},makeSpan:Re,makeSvgSpan:He,makeLineSpan:function(e,t,r){const n=Re([e],[],t);return n.height=Math.max(r||t.fontMetrics().defaultRuleThickness,t.minRuleThickness),n.style.borderBottomWidth=F(n.height),n.maxFontSize=1,n},makeAnchor:function(e,t,r,n){const o=new _(e,t,r,n);return Ie(o),o},makeFragment:Oe,wrapFragment:function(e,t){return e instanceof A?Re([],[e],t):e},makeVList:function(e,t){const{children:r,depth:n}=function(e){if("individualShift"===e.positionType){const t=e.children,r=[t[0]],n=-t[0].shift-t[0].elem.depth;let o=n;for(let e=1;e0)return Ne(s,h,o,t,i.concat(c));if(l){let e,n;if("boldsymbol"===l){const t=function(e,t,r,n,o){return"textord"!==o&&Ce(e,"Math-BoldItalic",t).metrics?{fontName:"Math-BoldItalic",fontClass:"boldsymbol"}:{fontName:"Main-Bold",fontClass:"mathbf"}}(s,o,0,0,r);e=t.fontName,n=[t.fontClass]}else a?(e=Le[l].fontName,n=[l]):(e=Ee(l,t.fontWeight,t.fontShape),n=[l,t.fontWeight,t.fontShape]);if(Ce(s,e,o).metrics)return Ne(s,e,o,t,i.concat(n));if(ve.hasOwnProperty(s)&&"Typewriter"===e.slice(0,10)){const r=[];for(let a=0;a{const r=Re(["mspace"],[],t),n=P(e,t);return r.style.marginRight=F(n),r},staticSvg:function(e,t){const[r,n,o]=De[e],s=new J(r),i=new K([s],{width:F(n),height:F(o),style:"width:"+F(n),viewBox:"0 0 "+1e3*n+" "+1e3*o,preserveAspectRatio:"xMinYMin"}),a=He(["overlay"],[i],t);return a.height=o,a.style.height=F(o),a.style.width=F(n),a},svgData:De,tryCombineChars:e=>{for(let t=0;t{const r=t.classes[0],n=e.classes[0];"mbin"===r&&l.contains(et,n)?t.classes[0]="mord":"mbin"===n&&l.contains(Qe,r)&&(e.classes[0]="mord")}),{node:i},a,h),ot(o,((e,t)=>{const r=at(t),n=at(e),o=r&&n?e.hasClass("mtight")?Ye[r][n]:Ue[r][n]:null;if(o)return Ve.makeGlue(o,s)}),{node:i},a,h),o},ot=function(e,t,r,n,o){n&&e.push(n);let s=0;for(;sr=>{e.splice(t+1,0,r),s++})(s)}n&&e.pop()},st=function(e){return e instanceof A||e instanceof _||e instanceof W&&e.hasClass("enclosing")?e:null},it=function(e,t){const r=st(e);if(r){const e=r.children;if(e.length){if("right"===t)return it(e[e.length-1],"right");if("left"===t)return it(e[0],"left")}}return e},at=function(e,t){return e?(t&&(e=it(e,t)),rt[e.classes[0]]||null):null},lt=function(e,t){const r=["nulldelimiter"].concat(e.baseSizingClasses());return Je(t.concat(r))},ht=function(e,t,r){if(!e)return Je();if(We[e.type]){let n=We[e.type](e,t);if(r&&t.size!==r.size){n=Je(t.sizingClasses(r),[n],t);const e=t.sizeMultiplier/r.sizeMultiplier;n.height*=e,n.depth*=e}return n}throw new n("Got group of unknown type: '"+e.type+"'")};function ct(e,t){const r=Je(["base"],e,t),n=Je(["strut"]);return n.style.height=F(r.height+r.depth),r.depth&&(n.style.verticalAlign=F(-r.depth)),r.children.unshift(n),r}function mt(e,t){let r=null;1===e.length&&"tag"===e[0].type&&(r=e[0].tag,e=e[0].body);const n=nt(e,t,"root");let o;2===n.length&&n[1].hasClass("tag")&&(o=n.pop());const s=[];let i,a=[];for(let e=0;e0&&(s.push(ct(a,t)),a=[]),s.push(n[e]));a.length>0&&s.push(ct(a,t)),r?(i=ct(nt(r,t,!0)),i.classes=["tag"],s.push(i)):o&&s.push(o);const l=Je(["katex-html"],s);if(l.setAttribute("aria-hidden","true"),i){const e=i.children[0];e.style.height=F(l.height+l.depth),l.depth&&(e.style.verticalAlign=F(-l.depth))}return l}function pt(e){return new A(e)}class ut{constructor(e,t,r){this.type=void 0,this.attributes=void 0,this.children=void 0,this.classes=void 0,this.type=e,this.attributes={},this.children=t||[],this.classes=r||[]}setAttribute(e,t){this.attributes[e]=t}getAttribute(e){return this.attributes[e]}toNode(){const e=document.createElementNS("http://www.w3.org/1998/Math/MathML",this.type);for(const t in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,t)&&e.setAttribute(t,this.attributes[t]);this.classes.length>0&&(e.className=G(this.classes));for(let t=0;t0&&(e+=' class ="'+l.escape(G(this.classes))+'"'),e+=">";for(let t=0;t",e}toText(){return this.children.map((e=>e.toText())).join("")}}class dt{constructor(e){this.text=void 0,this.text=e}toNode(){return document.createTextNode(this.text)}toMarkup(){return l.escape(this.toText())}toText(){return this.text}}var gt={MathNode:ut,TextNode:dt,SpaceNode:class{constructor(e){this.width=void 0,this.character=void 0,this.width=e,this.character=e>=.05555&&e<=.05556?"\u200a":e>=.1666&&e<=.1667?"\u2009":e>=.2222&&e<=.2223?"\u2005":e>=.2777&&e<=.2778?"\u2005\u200a":e>=-.05556&&e<=-.05555?"\u200a\u2063":e>=-.1667&&e<=-.1666?"\u2009\u2063":e>=-.2223&&e<=-.2222?"\u205f\u2063":e>=-.2778&&e<=-.2777?"\u2005\u2063":null}toNode(){if(this.character)return document.createTextNode(this.character);{const e=document.createElementNS("http://www.w3.org/1998/Math/MathML","mspace");return e.setAttribute("width",F(this.width)),e}}toMarkup(){return this.character?""+this.character+"":''}toText(){return this.character?this.character:" "}},newDocumentFragment:pt};const ft=function(e,t,r){return!oe[t][e]||!oe[t][e].replace||55349===e.charCodeAt(0)||ve.hasOwnProperty(e)&&r&&(r.fontFamily&&"tt"===r.fontFamily.slice(4,6)||r.font&&"tt"===r.font.slice(4,6))||(e=oe[t][e].replace),new gt.TextNode(e)},bt=function(e){return 1===e.length?e[0]:new gt.MathNode("mrow",e)},yt=function(e,t){if("texttt"===t.fontFamily)return"monospace";if("textsf"===t.fontFamily)return"textit"===t.fontShape&&"textbf"===t.fontWeight?"sans-serif-bold-italic":"textit"===t.fontShape?"sans-serif-italic":"textbf"===t.fontWeight?"bold-sans-serif":"sans-serif";if("textit"===t.fontShape&&"textbf"===t.fontWeight)return"bold-italic";if("textit"===t.fontShape)return"italic";if("textbf"===t.fontWeight)return"bold";const r=t.font;if(!r||"mathnormal"===r)return null;const n=e.mode;if("mathit"===r)return"italic";if("boldsymbol"===r)return"textord"===e.type?"bold":"bold-italic";if("mathbf"===r)return"bold";if("mathbb"===r)return"double-struck";if("mathfrak"===r)return"fraktur";if("mathscr"===r||"mathcal"===r)return"script";if("mathsf"===r)return"sans-serif";if("mathtt"===r)return"monospace";let o=e.text;if(l.contains(["\\imath","\\jmath"],o))return null;oe[n][o]&&oe[n][o].replace&&(o=oe[n][o].replace);return N(o,Ve.fontMap[r].fontName,n)?Ve.fontMap[r].variant:null},xt=function(e,t,r){if(1===e.length){const n=vt(e[0],t);return r&&n instanceof ut&&"mo"===n.type&&(n.setAttribute("lspace","0em"),n.setAttribute("rspace","0em")),[n]}const n=[];let o;for(let r=0;r0&&(e.text=e.text.slice(0,1)+"\u0338"+e.text.slice(1),n.pop())}}}n.push(s),o=s}return n},wt=function(e,t,r){return bt(xt(e,t,r))},vt=function(e,t){if(!e)return new gt.MathNode("mrow");if(_e[e.type]){return _e[e.type](e,t)}throw new n("Got group of unknown type: '"+e.type+"'")};function kt(e,t,r,n,o){const s=xt(e,r);let i;i=1===s.length&&s[0]instanceof ut&&l.contains(["mrow","mtable"],s[0].type)?s[0]:new gt.MathNode("mrow",s);const a=new gt.MathNode("annotation",[new gt.TextNode(t)]);a.setAttribute("encoding","application/x-tex");const h=new gt.MathNode("semantics",[i,a]),c=new gt.MathNode("math",[h]);c.setAttribute("xmlns","http://www.w3.org/1998/Math/MathML"),n&&c.setAttribute("display","block");const m=o?"katex":"katex-mathml";return Ve.makeSpan([m],[c])}const St=function(e){return new E({style:e.displayMode?w.DISPLAY:w.TEXT,maxSize:e.maxSize,minRuleThickness:e.minRuleThickness})},Mt=function(e,t){if(t.displayMode){const r=["katex-display"];t.leqno&&r.push("leqno"),t.fleqn&&r.push("fleqn"),e=Ve.makeSpan(r,[e])}return e},zt=function(e,t,r){const n=St(r);let o;if("mathml"===r.output)return kt(e,t,n,r.displayMode,!0);if("html"===r.output){const t=mt(e,n);o=Ve.makeSpan(["katex"],[t])}else{const s=kt(e,t,n,r.displayMode,!1),i=mt(e,n);o=Ve.makeSpan(["katex"],[s,i])}return Mt(o,r)};const At={widehat:"^",widecheck:"\u02c7",widetilde:"~",utilde:"~",overleftarrow:"\u2190",underleftarrow:"\u2190",xleftarrow:"\u2190",overrightarrow:"\u2192",underrightarrow:"\u2192",xrightarrow:"\u2192",underbrace:"\u23df",overbrace:"\u23de",overgroup:"\u23e0",undergroup:"\u23e1",overleftrightarrow:"\u2194",underleftrightarrow:"\u2194",xleftrightarrow:"\u2194",Overrightarrow:"\u21d2",xRightarrow:"\u21d2",overleftharpoon:"\u21bc",xleftharpoonup:"\u21bc",overrightharpoon:"\u21c0",xrightharpoonup:"\u21c0",xLeftarrow:"\u21d0",xLeftrightarrow:"\u21d4",xhookleftarrow:"\u21a9",xhookrightarrow:"\u21aa",xmapsto:"\u21a6",xrightharpoondown:"\u21c1",xleftharpoondown:"\u21bd",xrightleftharpoons:"\u21cc",xleftrightharpoons:"\u21cb",xtwoheadleftarrow:"\u219e",xtwoheadrightarrow:"\u21a0",xlongequal:"=",xtofrom:"\u21c4",xrightleftarrows:"\u21c4",xrightequilibrium:"\u21cc",xleftequilibrium:"\u21cb","\\cdrightarrow":"\u2192","\\cdleftarrow":"\u2190","\\cdlongequal":"="},Tt={overrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],overleftarrow:[["leftarrow"],.888,522,"xMinYMin"],underrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],underleftarrow:[["leftarrow"],.888,522,"xMinYMin"],xrightarrow:[["rightarrow"],1.469,522,"xMaxYMin"],"\\cdrightarrow":[["rightarrow"],3,522,"xMaxYMin"],xleftarrow:[["leftarrow"],1.469,522,"xMinYMin"],"\\cdleftarrow":[["leftarrow"],3,522,"xMinYMin"],Overrightarrow:[["doublerightarrow"],.888,560,"xMaxYMin"],xRightarrow:[["doublerightarrow"],1.526,560,"xMaxYMin"],xLeftarrow:[["doubleleftarrow"],1.526,560,"xMinYMin"],overleftharpoon:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoonup:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoondown:[["leftharpoondown"],.888,522,"xMinYMin"],overrightharpoon:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoonup:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoondown:[["rightharpoondown"],.888,522,"xMaxYMin"],xlongequal:[["longequal"],.888,334,"xMinYMin"],"\\cdlongequal":[["longequal"],3,334,"xMinYMin"],xtwoheadleftarrow:[["twoheadleftarrow"],.888,334,"xMinYMin"],xtwoheadrightarrow:[["twoheadrightarrow"],.888,334,"xMaxYMin"],overleftrightarrow:[["leftarrow","rightarrow"],.888,522],overbrace:[["leftbrace","midbrace","rightbrace"],1.6,548],underbrace:[["leftbraceunder","midbraceunder","rightbraceunder"],1.6,548],underleftrightarrow:[["leftarrow","rightarrow"],.888,522],xleftrightarrow:[["leftarrow","rightarrow"],1.75,522],xLeftrightarrow:[["doubleleftarrow","doublerightarrow"],1.75,560],xrightleftharpoons:[["leftharpoondownplus","rightharpoonplus"],1.75,716],xleftrightharpoons:[["leftharpoonplus","rightharpoondownplus"],1.75,716],xhookleftarrow:[["leftarrow","righthook"],1.08,522],xhookrightarrow:[["lefthook","rightarrow"],1.08,522],overlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],underlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],overgroup:[["leftgroup","rightgroup"],.888,342],undergroup:[["leftgroupunder","rightgroupunder"],.888,342],xmapsto:[["leftmapsto","rightarrow"],1.5,522],xtofrom:[["leftToFrom","rightToFrom"],1.75,528],xrightleftarrows:[["baraboveleftarrow","rightarrowabovebar"],1.75,901],xrightequilibrium:[["baraboveshortleftharpoon","rightharpoonaboveshortbar"],1.75,716],xleftequilibrium:[["shortbaraboveleftharpoon","shortrightharpoonabovebar"],1.75,716]};var Bt=function(e,t,r,n,o){let s;const i=e.height+e.depth+r+n;if(/fbox|color|angl/.test(t)){if(s=Ve.makeSpan(["stretchy",t],[],o),"fbox"===t){const e=o.color&&o.getColor();e&&(s.style.borderColor=e)}}else{const e=[];/^[bx]cancel$/.test(t)&&e.push(new Q({x1:"0",y1:"0",x2:"100%",y2:"100%","stroke-width":"0.046em"})),/^x?cancel$/.test(t)&&e.push(new Q({x1:"0",y1:"100%",x2:"100%",y2:"0","stroke-width":"0.046em"}));const r=new K(e,{width:"100%",height:F(i)});s=Ve.makeSvgSpan([],[r],o)}return s.height=i,s.style.height=F(i),s},Ct=function(e){const t=new gt.MathNode("mo",[new gt.TextNode(At[e.replace(/^\\/,"")])]);return t.setAttribute("stretchy","true"),t},Nt=function(e,t){const{span:r,minWidth:n,height:o}=function(){let r=4e5;const n=e.label.slice(1);if(l.contains(["widehat","widecheck","widetilde","utilde"],n)){const s="ordgroup"===(o=e.base).type?o.body.length:1;let i,a,l;if(s>5)"widehat"===n||"widecheck"===n?(i=420,r=2364,l=.42,a=n+"4"):(i=312,r=2340,l=.34,a="tilde4");else{const e=[1,1,2,2,3,3][s];"widehat"===n||"widecheck"===n?(r=[0,1062,2364,2364,2364][e],i=[0,239,300,360,420][e],l=[0,.24,.3,.3,.36,.42][e],a=n+e):(r=[0,600,1033,2339,2340][e],i=[0,260,286,306,312][e],l=[0,.26,.286,.3,.306,.34][e],a="tilde"+e)}const h=new J(a),c=new K([h],{width:"100%",height:F(l),viewBox:"0 0 "+r+" "+i,preserveAspectRatio:"none"});return{span:Ve.makeSvgSpan([],[c],t),minWidth:0,height:l}}{const e=[],o=Tt[n],[s,i,a]=o,l=a/1e3,h=s.length;let c,m;if(1===h){c=["hide-tail"],m=[o[3]]}else if(2===h)c=["halfarrow-left","halfarrow-right"],m=["xMinYMin","xMaxYMin"];else{if(3!==h)throw new Error("Correct katexImagesData or update code here to support\n "+h+" children.");c=["brace-left","brace-center","brace-right"],m=["xMinYMin","xMidYMin","xMaxYMin"]}for(let n=0;n0&&(r.style.minWidth=F(n)),r};function qt(e,t){if(!e||e.type!==t)throw new Error("Expected node of type "+t+", but got "+(e?"node of type "+e.type:String(e)));return e}function It(e){const t=Rt(e);if(!t)throw new Error("Expected node of symbol group type, but got "+(e?"node of type "+e.type:String(e)));return t}function Rt(e){return e&&("atom"===e.type||re.hasOwnProperty(e.type))?e:null}const Ht=(e,t)=>{let r,n,o;e&&"supsub"===e.type?(n=qt(e.base,"accent"),r=n.base,e.base=r,o=function(e){if(e instanceof W)return e;throw new Error("Expected span but got "+String(e)+".")}(ht(e,t)),e.base=n):(n=qt(e,"accent"),r=n.base);const s=ht(r,t.havingCrampedStyle());let i=0;if(n.isShifty&&l.isCharacterBox(r)){const e=l.getBaseElem(r);i=ee(ht(e,t.havingCrampedStyle())).skew}const a="\\c"===n.label;let h,c=a?s.height+s.depth:Math.min(s.height,t.fontMetrics().xHeight);if(n.isStretchy)h=Nt(n,t),h=Ve.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"elem",elem:h,wrapperClasses:["svg-align"],wrapperStyle:i>0?{width:"calc(100% - "+F(2*i)+")",marginLeft:F(2*i)}:void 0}]},t);else{let e,r;"\\vec"===n.label?(e=Ve.staticSvg("vec",t),r=Ve.svgData.vec[1]):(e=Ve.makeOrd({mode:n.mode,text:n.label},t,"textord"),e=ee(e),e.italic=0,r=e.width,a&&(c+=e.depth)),h=Ve.makeSpan(["accent-body"],[e]);const o="\\textcircled"===n.label;o&&(h.classes.push("accent-full"),c=s.height);let l=i;o||(l-=r/2),h.style.left=F(l),"\\textcircled"===n.label&&(h.style.top=".2em"),h=Ve.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"kern",size:-c},{type:"elem",elem:h}]},t)}const m=Ve.makeSpan(["mord","accent"],[h],t);return o?(o.children[0]=m,o.height=Math.max(m.height,o.height),o.classes[0]="mord",o):m},Ot=(e,t)=>{const r=e.isStretchy?Ct(e.label):new gt.MathNode("mo",[ft(e.label,e.mode)]),n=new gt.MathNode("mover",[vt(e.base,t),r]);return n.setAttribute("accent","true"),n},Et=new RegExp(["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring"].map((e=>"\\"+e)).join("|"));je({type:"accent",names:["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring","\\widecheck","\\widehat","\\widetilde","\\overrightarrow","\\overleftarrow","\\Overrightarrow","\\overleftrightarrow","\\overgroup","\\overlinesegment","\\overleftharpoon","\\overrightharpoon"],props:{numArgs:1},handler:(e,t)=>{const r=Ze(t[0]),n=!Et.test(e.funcName),o=!n||"\\widehat"===e.funcName||"\\widetilde"===e.funcName||"\\widecheck"===e.funcName;return{type:"accent",mode:e.parser.mode,label:e.funcName,isStretchy:n,isShifty:o,base:r}},htmlBuilder:Ht,mathmlBuilder:Ot}),je({type:"accent",names:["\\'","\\`","\\^","\\~","\\=","\\u","\\.",'\\"',"\\c","\\r","\\H","\\v","\\textcircled"],props:{numArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["primitive"]},handler:(e,t)=>{const r=t[0];let n=e.parser.mode;return"math"===n&&(e.parser.settings.reportNonstrict("mathVsTextAccents","LaTeX's accent "+e.funcName+" works only in text mode"),n="text"),{type:"accent",mode:n,label:e.funcName,isStretchy:!1,isShifty:!0,base:r}},htmlBuilder:Ht,mathmlBuilder:Ot}),je({type:"accentUnder",names:["\\underleftarrow","\\underrightarrow","\\underleftrightarrow","\\undergroup","\\underlinesegment","\\utilde"],props:{numArgs:1},handler:(e,t)=>{let{parser:r,funcName:n}=e;const o=t[0];return{type:"accentUnder",mode:r.mode,label:n,base:o}},htmlBuilder:(e,t)=>{const r=ht(e.base,t),n=Nt(e,t),o="\\utilde"===e.label?.12:0,s=Ve.makeVList({positionType:"top",positionData:r.height,children:[{type:"elem",elem:n,wrapperClasses:["svg-align"]},{type:"kern",size:o},{type:"elem",elem:r}]},t);return Ve.makeSpan(["mord","accentunder"],[s],t)},mathmlBuilder:(e,t)=>{const r=Ct(e.label),n=new gt.MathNode("munder",[vt(e.base,t),r]);return n.setAttribute("accentunder","true"),n}});const Lt=e=>{const t=new gt.MathNode("mpadded",e?[e]:[]);return t.setAttribute("width","+0.6em"),t.setAttribute("lspace","0.3em"),t};je({type:"xArrow",names:["\\xleftarrow","\\xrightarrow","\\xLeftarrow","\\xRightarrow","\\xleftrightarrow","\\xLeftrightarrow","\\xhookleftarrow","\\xhookrightarrow","\\xmapsto","\\xrightharpoondown","\\xrightharpoonup","\\xleftharpoondown","\\xleftharpoonup","\\xrightleftharpoons","\\xleftrightharpoons","\\xlongequal","\\xtwoheadrightarrow","\\xtwoheadleftarrow","\\xtofrom","\\xrightleftarrows","\\xrightequilibrium","\\xleftequilibrium","\\\\cdrightarrow","\\\\cdleftarrow","\\\\cdlongequal"],props:{numArgs:1,numOptionalArgs:1},handler(e,t,r){let{parser:n,funcName:o}=e;return{type:"xArrow",mode:n.mode,label:o,body:t[0],below:r[0]}},htmlBuilder(e,t){const r=t.style;let n=t.havingStyle(r.sup());const o=Ve.wrapFragment(ht(e.body,n,t),t),s="\\x"===e.label.slice(0,2)?"x":"cd";let i;o.classes.push(s+"-arrow-pad"),e.below&&(n=t.havingStyle(r.sub()),i=Ve.wrapFragment(ht(e.below,n,t),t),i.classes.push(s+"-arrow-pad"));const a=Nt(e,t),l=-t.fontMetrics().axisHeight+.5*a.height;let h,c=-t.fontMetrics().axisHeight-.5*a.height-.111;if((o.depth>.25||"\\xleftequilibrium"===e.label)&&(c-=o.depth),i){const e=-t.fontMetrics().axisHeight+i.height+.5*a.height+.111;h=Ve.makeVList({positionType:"individualShift",children:[{type:"elem",elem:o,shift:c},{type:"elem",elem:a,shift:l},{type:"elem",elem:i,shift:e}]},t)}else h=Ve.makeVList({positionType:"individualShift",children:[{type:"elem",elem:o,shift:c},{type:"elem",elem:a,shift:l}]},t);return h.children[0].children[0].children[1].classes.push("svg-align"),Ve.makeSpan(["mrel","x-arrow"],[h],t)},mathmlBuilder(e,t){const r=Ct(e.label);let n;if(r.setAttribute("minsize","x"===e.label.charAt(0)?"1.75em":"3.0em"),e.body){const o=Lt(vt(e.body,t));if(e.below){const s=Lt(vt(e.below,t));n=new gt.MathNode("munderover",[r,s,o])}else n=new gt.MathNode("mover",[r,o])}else if(e.below){const o=Lt(vt(e.below,t));n=new gt.MathNode("munder",[r,o])}else n=Lt(),n=new gt.MathNode("mover",[r,n]);return n}});const Dt=Ve.makeSpan;function Vt(e,t){const r=nt(e.body,t,!0);return Dt([e.mclass],r,t)}function Pt(e,t){let r;const n=xt(e.body,t);return"minner"===e.mclass?r=new gt.MathNode("mpadded",n):"mord"===e.mclass?e.isCharacterBox?(r=n[0],r.type="mi"):r=new gt.MathNode("mi",n):(e.isCharacterBox?(r=n[0],r.type="mo"):r=new gt.MathNode("mo",n),"mbin"===e.mclass?(r.attributes.lspace="0.22em",r.attributes.rspace="0.22em"):"mpunct"===e.mclass?(r.attributes.lspace="0em",r.attributes.rspace="0.17em"):"mopen"===e.mclass||"mclose"===e.mclass?(r.attributes.lspace="0em",r.attributes.rspace="0em"):"minner"===e.mclass&&(r.attributes.lspace="0.0556em",r.attributes.width="+0.1111em")),r}je({type:"mclass",names:["\\mathord","\\mathbin","\\mathrel","\\mathopen","\\mathclose","\\mathpunct","\\mathinner"],props:{numArgs:1,primitive:!0},handler(e,t){let{parser:r,funcName:n}=e;const o=t[0];return{type:"mclass",mode:r.mode,mclass:"m"+n.slice(5),body:Ke(o),isCharacterBox:l.isCharacterBox(o)}},htmlBuilder:Vt,mathmlBuilder:Pt});const Ft=e=>{const t="ordgroup"===e.type&&e.body.length?e.body[0]:e;return"atom"!==t.type||"bin"!==t.family&&"rel"!==t.family?"mord":"m"+t.family};je({type:"mclass",names:["\\@binrel"],props:{numArgs:2},handler(e,t){let{parser:r}=e;return{type:"mclass",mode:r.mode,mclass:Ft(t[0]),body:Ke(t[1]),isCharacterBox:l.isCharacterBox(t[1])}}}),je({type:"mclass",names:["\\stackrel","\\overset","\\underset"],props:{numArgs:2},handler(e,t){let{parser:r,funcName:n}=e;const o=t[1],s=t[0];let i;i="\\stackrel"!==n?Ft(o):"mrel";const a={type:"op",mode:o.mode,limits:!0,alwaysHandleSupSub:!0,parentIsSupSub:!1,symbol:!1,suppressBaseShift:"\\stackrel"!==n,body:Ke(o)},h={type:"supsub",mode:s.mode,base:a,sup:"\\underset"===n?null:s,sub:"\\underset"===n?s:null};return{type:"mclass",mode:r.mode,mclass:i,body:[h],isCharacterBox:l.isCharacterBox(h)}},htmlBuilder:Vt,mathmlBuilder:Pt}),je({type:"pmb",names:["\\pmb"],props:{numArgs:1,allowedInText:!0},handler(e,t){let{parser:r}=e;return{type:"pmb",mode:r.mode,mclass:Ft(t[0]),body:Ke(t[0])}},htmlBuilder(e,t){const r=nt(e.body,t,!0),n=Ve.makeSpan([e.mclass],r,t);return n.style.textShadow="0.02em 0.01em 0.04px",n},mathmlBuilder(e,t){const r=xt(e.body,t),n=new gt.MathNode("mstyle",r);return n.setAttribute("style","text-shadow: 0.02em 0.01em 0.04px"),n}});const Gt={">":"\\\\cdrightarrow","<":"\\\\cdleftarrow","=":"\\\\cdlongequal",A:"\\uparrow",V:"\\downarrow","|":"\\Vert",".":"no arrow"},Ut=e=>"textord"===e.type&&"@"===e.text;function Yt(e,t,r){const n=Gt[e];switch(n){case"\\\\cdrightarrow":case"\\\\cdleftarrow":return r.callFunction(n,[t[0]],[t[1]]);case"\\uparrow":case"\\downarrow":{const e={type:"atom",text:n,mode:"math",family:"rel"},o={type:"ordgroup",mode:"math",body:[r.callFunction("\\\\cdleft",[t[0]],[]),r.callFunction("\\Big",[e],[]),r.callFunction("\\\\cdright",[t[1]],[])]};return r.callFunction("\\\\cdparent",[o],[])}case"\\\\cdlongequal":return r.callFunction("\\\\cdlongequal",[],[]);case"\\Vert":{const e={type:"textord",text:"\\Vert",mode:"math"};return r.callFunction("\\Big",[e],[])}default:return{type:"textord",text:" ",mode:"math"}}}je({type:"cdlabel",names:["\\\\cdleft","\\\\cdright"],props:{numArgs:1},handler(e,t){let{parser:r,funcName:n}=e;return{type:"cdlabel",mode:r.mode,side:n.slice(4),label:t[0]}},htmlBuilder(e,t){const r=t.havingStyle(t.style.sup()),n=Ve.wrapFragment(ht(e.label,r,t),t);return n.classes.push("cd-label-"+e.side),n.style.bottom=F(.8-n.depth),n.height=0,n.depth=0,n},mathmlBuilder(e,t){let r=new gt.MathNode("mrow",[vt(e.label,t)]);return r=new gt.MathNode("mpadded",[r]),r.setAttribute("width","0"),"left"===e.side&&r.setAttribute("lspace","-1width"),r.setAttribute("voffset","0.7em"),r=new gt.MathNode("mstyle",[r]),r.setAttribute("displaystyle","false"),r.setAttribute("scriptlevel","1"),r}}),je({type:"cdlabelparent",names:["\\\\cdparent"],props:{numArgs:1},handler(e,t){let{parser:r}=e;return{type:"cdlabelparent",mode:r.mode,fragment:t[0]}},htmlBuilder(e,t){const r=Ve.wrapFragment(ht(e.fragment,t),t);return r.classes.push("cd-vert-arrow"),r},mathmlBuilder(e,t){return new gt.MathNode("mrow",[vt(e.fragment,t)])}}),je({type:"textord",names:["\\@char"],props:{numArgs:1,allowedInText:!0},handler(e,t){let{parser:r}=e;const o=qt(t[0],"ordgroup").body;let s="";for(let e=0;e=1114111)throw new n("\\@char with invalid code point "+s);return a<=65535?i=String.fromCharCode(a):(a-=65536,i=String.fromCharCode(55296+(a>>10),56320+(1023&a))),{type:"textord",mode:r.mode,text:i}}});const Xt=(e,t)=>{const r=nt(e.body,t.withColor(e.color),!1);return Ve.makeFragment(r)},Wt=(e,t)=>{const r=xt(e.body,t.withColor(e.color)),n=new gt.MathNode("mstyle",r);return n.setAttribute("mathcolor",e.color),n};je({type:"color",names:["\\textcolor"],props:{numArgs:2,allowedInText:!0,argTypes:["color","original"]},handler(e,t){let{parser:r}=e;const n=qt(t[0],"color-token").color,o=t[1];return{type:"color",mode:r.mode,color:n,body:Ke(o)}},htmlBuilder:Xt,mathmlBuilder:Wt}),je({type:"color",names:["\\color"],props:{numArgs:1,allowedInText:!0,argTypes:["color"]},handler(e,t){let{parser:r,breakOnTokenText:n}=e;const o=qt(t[0],"color-token").color;r.gullet.macros.set("\\current@color",o);const s=r.parseExpression(!0,n);return{type:"color",mode:r.mode,color:o,body:s}},htmlBuilder:Xt,mathmlBuilder:Wt}),je({type:"cr",names:["\\\\"],props:{numArgs:0,numOptionalArgs:0,allowedInText:!0},handler(e,t,r){let{parser:n}=e;const o="["===n.gullet.future().text?n.parseSizeGroup(!0):null,s=!n.settings.displayMode||!n.settings.useStrictBehavior("newLineInDisplayMode","In LaTeX, \\\\ or \\newline does nothing in display mode");return{type:"cr",mode:n.mode,newLine:s,size:o&&qt(o,"size").value}},htmlBuilder(e,t){const r=Ve.makeSpan(["mspace"],[],t);return e.newLine&&(r.classes.push("newline"),e.size&&(r.style.marginTop=F(P(e.size,t)))),r},mathmlBuilder(e,t){const r=new gt.MathNode("mspace");return e.newLine&&(r.setAttribute("linebreak","newline"),e.size&&r.setAttribute("height",F(P(e.size,t)))),r}});const _t={"\\global":"\\global","\\long":"\\\\globallong","\\\\globallong":"\\\\globallong","\\def":"\\gdef","\\gdef":"\\gdef","\\edef":"\\xdef","\\xdef":"\\xdef","\\let":"\\\\globallet","\\futurelet":"\\\\globalfuture"},jt=e=>{const t=e.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(t))throw new n("Expected a control sequence",e);return t},$t=(e,t,r,n)=>{let o=e.gullet.macros.get(r.text);null==o&&(r.noexpand=!0,o={tokens:[r],numArgs:0,unexpandable:!e.gullet.isExpandable(r.text)}),e.gullet.macros.set(t,o,n)};je({type:"internal",names:["\\global","\\long","\\\\globallong"],props:{numArgs:0,allowedInText:!0},handler(e){let{parser:t,funcName:r}=e;t.consumeSpaces();const o=t.fetch();if(_t[o.text])return"\\global"!==r&&"\\\\globallong"!==r||(o.text=_t[o.text]),qt(t.parseFunction(),"internal");throw new n("Invalid token after macro prefix",o)}}),je({type:"internal",names:["\\def","\\gdef","\\edef","\\xdef"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(e){let{parser:t,funcName:r}=e,o=t.gullet.popToken();const s=o.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(s))throw new n("Expected a control sequence",o);let i,a=0;const l=[[]];for(;"{"!==t.gullet.future().text;)if(o=t.gullet.popToken(),"#"===o.text){if("{"===t.gullet.future().text){i=t.gullet.future(),l[a].push("{");break}if(o=t.gullet.popToken(),!/^[1-9]$/.test(o.text))throw new n('Invalid argument number "'+o.text+'"');if(parseInt(o.text)!==a+1)throw new n('Argument number "'+o.text+'" out of order');a++,l.push([])}else{if("EOF"===o.text)throw new n("Expected a macro definition");l[a].push(o.text)}let{tokens:h}=t.gullet.consumeArg();return i&&h.unshift(i),"\\edef"!==r&&"\\xdef"!==r||(h=t.gullet.expandTokens(h),h.reverse()),t.gullet.macros.set(s,{tokens:h,numArgs:a,delimiters:l},r===_t[r]),{type:"internal",mode:t.mode}}}),je({type:"internal",names:["\\let","\\\\globallet"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(e){let{parser:t,funcName:r}=e;const n=jt(t.gullet.popToken());t.gullet.consumeSpaces();const o=(e=>{let t=e.gullet.popToken();return"="===t.text&&(t=e.gullet.popToken()," "===t.text&&(t=e.gullet.popToken())),t})(t);return $t(t,n,o,"\\\\globallet"===r),{type:"internal",mode:t.mode}}}),je({type:"internal",names:["\\futurelet","\\\\globalfuture"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(e){let{parser:t,funcName:r}=e;const n=jt(t.gullet.popToken()),o=t.gullet.popToken(),s=t.gullet.popToken();return $t(t,n,s,"\\\\globalfuture"===r),t.gullet.pushToken(s),t.gullet.pushToken(o),{type:"internal",mode:t.mode}}});const Zt=function(e,t,r){const n=N(oe.math[e]&&oe.math[e].replace||e,t,r);if(!n)throw new Error("Unsupported symbol "+e+" and font size "+t+".");return n},Kt=function(e,t,r,n){const o=r.havingBaseStyle(t),s=Ve.makeSpan(n.concat(o.sizingClasses(r)),[e],r),i=o.sizeMultiplier/r.sizeMultiplier;return s.height*=i,s.depth*=i,s.maxFontSize=o.sizeMultiplier,s},Jt=function(e,t,r){const n=t.havingBaseStyle(r),o=(1-t.sizeMultiplier/n.sizeMultiplier)*t.fontMetrics().axisHeight;e.classes.push("delimcenter"),e.style.top=F(o),e.height-=o,e.depth+=o},Qt=function(e,t,r,n,o,s){const i=function(e,t,r,n){return Ve.makeSymbol(e,"Size"+t+"-Regular",r,n)}(e,t,o,n),a=Kt(Ve.makeSpan(["delimsizing","size"+t],[i],n),w.TEXT,n,s);return r&&Jt(a,n,w.TEXT),a},er=function(e,t,r){let n;n="Size1-Regular"===t?"delim-size1":"delim-size4";return{type:"elem",elem:Ve.makeSpan(["delimsizinginner",n],[Ve.makeSpan([],[Ve.makeSymbol(e,t,r)])])}},tr=function(e,t,r){const n=T["Size4-Regular"][e.charCodeAt(0)]?T["Size4-Regular"][e.charCodeAt(0)][4]:T["Size1-Regular"][e.charCodeAt(0)][4],o=new J("inner",function(e,t){switch(e){case"\u239c":return"M291 0 H417 V"+t+" H291z M291 0 H417 V"+t+" H291z";case"\u2223":return"M145 0 H188 V"+t+" H145z M145 0 H188 V"+t+" H145z";case"\u2225":return"M145 0 H188 V"+t+" H145z M145 0 H188 V"+t+" H145zM367 0 H410 V"+t+" H367z M367 0 H410 V"+t+" H367z";case"\u239f":return"M457 0 H583 V"+t+" H457z M457 0 H583 V"+t+" H457z";case"\u23a2":return"M319 0 H403 V"+t+" H319z M319 0 H403 V"+t+" H319z";case"\u23a5":return"M263 0 H347 V"+t+" H263z M263 0 H347 V"+t+" H263z";case"\u23aa":return"M384 0 H504 V"+t+" H384z M384 0 H504 V"+t+" H384z";case"\u23d0":return"M312 0 H355 V"+t+" H312z M312 0 H355 V"+t+" H312z";case"\u2016":return"M257 0 H300 V"+t+" H257z M257 0 H300 V"+t+" H257zM478 0 H521 V"+t+" H478z M478 0 H521 V"+t+" H478z";default:return""}}(e,Math.round(1e3*t))),s=new K([o],{width:F(n),height:F(t),style:"width:"+F(n),viewBox:"0 0 "+1e3*n+" "+Math.round(1e3*t),preserveAspectRatio:"xMinYMin"}),i=Ve.makeSvgSpan([],[s],r);return i.height=t,i.style.height=F(t),i.style.width=F(n),{type:"elem",elem:i}},rr={type:"kern",size:-.008},nr=["|","\\lvert","\\rvert","\\vert"],or=["\\|","\\lVert","\\rVert","\\Vert"],sr=function(e,t,r,n,o,s){let i,a,h,c,m="",p=0;i=h=c=e,a=null;let u="Size1-Regular";"\\uparrow"===e?h=c="\u23d0":"\\Uparrow"===e?h=c="\u2016":"\\downarrow"===e?i=h="\u23d0":"\\Downarrow"===e?i=h="\u2016":"\\updownarrow"===e?(i="\\uparrow",h="\u23d0",c="\\downarrow"):"\\Updownarrow"===e?(i="\\Uparrow",h="\u2016",c="\\Downarrow"):l.contains(nr,e)?(h="\u2223",m="vert",p=333):l.contains(or,e)?(h="\u2225",m="doublevert",p=556):"["===e||"\\lbrack"===e?(i="\u23a1",h="\u23a2",c="\u23a3",u="Size4-Regular",m="lbrack",p=667):"]"===e||"\\rbrack"===e?(i="\u23a4",h="\u23a5",c="\u23a6",u="Size4-Regular",m="rbrack",p=667):"\\lfloor"===e||"\u230a"===e?(h=i="\u23a2",c="\u23a3",u="Size4-Regular",m="lfloor",p=667):"\\lceil"===e||"\u2308"===e?(i="\u23a1",h=c="\u23a2",u="Size4-Regular",m="lceil",p=667):"\\rfloor"===e||"\u230b"===e?(h=i="\u23a5",c="\u23a6",u="Size4-Regular",m="rfloor",p=667):"\\rceil"===e||"\u2309"===e?(i="\u23a4",h=c="\u23a5",u="Size4-Regular",m="rceil",p=667):"("===e||"\\lparen"===e?(i="\u239b",h="\u239c",c="\u239d",u="Size4-Regular",m="lparen",p=875):")"===e||"\\rparen"===e?(i="\u239e",h="\u239f",c="\u23a0",u="Size4-Regular",m="rparen",p=875):"\\{"===e||"\\lbrace"===e?(i="\u23a7",a="\u23a8",c="\u23a9",h="\u23aa",u="Size4-Regular"):"\\}"===e||"\\rbrace"===e?(i="\u23ab",a="\u23ac",c="\u23ad",h="\u23aa",u="Size4-Regular"):"\\lgroup"===e||"\u27ee"===e?(i="\u23a7",c="\u23a9",h="\u23aa",u="Size4-Regular"):"\\rgroup"===e||"\u27ef"===e?(i="\u23ab",c="\u23ad",h="\u23aa",u="Size4-Regular"):"\\lmoustache"===e||"\u23b0"===e?(i="\u23a7",c="\u23ad",h="\u23aa",u="Size4-Regular"):"\\rmoustache"!==e&&"\u23b1"!==e||(i="\u23ab",c="\u23a9",h="\u23aa",u="Size4-Regular");const d=Zt(i,u,o),g=d.height+d.depth,f=Zt(h,u,o),b=f.height+f.depth,y=Zt(c,u,o),x=y.height+y.depth;let v=0,k=1;if(null!==a){const e=Zt(a,u,o);v=e.height+e.depth,k=2}const S=g+x+v,M=S+Math.max(0,Math.ceil((t-S)/(k*b)))*k*b;let z=n.fontMetrics().axisHeight;r&&(z*=n.sizeMultiplier);const A=M/2-z,T=[];if(m.length>0){const e=M-g-x,t=Math.round(1e3*M),r=function(e,t){switch(e){case"lbrack":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+" v1759 h347 v-84\nH403z M403 1759 V0 H319 V1759 v"+t+" v1759 h84z";case"rbrack":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+" v1759 H0 v84 H347z\nM347 1759 V0 H263 V1759 v"+t+" v1759 h84z";case"vert":return"M145 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M188 15 H145 v585 v"+t+" v585 h43z";case"doublevert":return"M145 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M188 15 H145 v585 v"+t+" v585 h43z\nM367 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M410 15 H367 v585 v"+t+" v585 h43z";case"lfloor":return"M319 602 V0 H403 V602 v"+t+" v1715 h263 v84 H319z\nMM319 602 V0 H403 V602 v"+t+" v1715 H319z";case"rfloor":return"M319 602 V0 H403 V602 v"+t+" v1799 H0 v-84 H319z\nMM319 602 V0 H403 V602 v"+t+" v1715 H319z";case"lceil":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+" v602 h84z\nM403 1759 V0 H319 V1759 v"+t+" v602 h84z";case"rceil":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+" v602 h84z\nM347 1759 V0 h-84 V1759 v"+t+" v602 h84z";case"lparen":return"M863,9c0,-2,-2,-5,-6,-9c0,0,-17,0,-17,0c-12.7,0,-19.3,0.3,-20,1\nc-5.3,5.3,-10.3,11,-15,17c-242.7,294.7,-395.3,682,-458,1162c-21.3,163.3,-33.3,349,\n-36,557 l0,"+(t+84)+"c0.2,6,0,26,0,60c2,159.3,10,310.7,24,454c53.3,528,210,\n949.7,470,1265c4.7,6,9.7,11.7,15,17c0.7,0.7,7,1,19,1c0,0,18,0,18,0c4,-4,6,-7,6,-9\nc0,-2.7,-3.3,-8.7,-10,-18c-135.3,-192.7,-235.5,-414.3,-300.5,-665c-65,-250.7,-102.5,\n-544.7,-112.5,-882c-2,-104,-3,-167,-3,-189\nl0,-"+(t+92)+"c0,-162.7,5.7,-314,17,-454c20.7,-272,63.7,-513,129,-723c65.3,\n-210,155.3,-396.3,270,-559c6.7,-9.3,10,-15.3,10,-18z";case"rparen":return"M76,0c-16.7,0,-25,3,-25,9c0,2,2,6.3,6,13c21.3,28.7,42.3,60.3,\n63,95c96.7,156.7,172.8,332.5,228.5,527.5c55.7,195,92.8,416.5,111.5,664.5\nc11.3,139.3,17,290.7,17,454c0,28,1.7,43,3.3,45l0,"+(t+9)+"\nc-3,4,-3.3,16.7,-3.3,38c0,162,-5.7,313.7,-17,455c-18.7,248,-55.8,469.3,-111.5,664\nc-55.7,194.7,-131.8,370.3,-228.5,527c-20.7,34.7,-41.7,66.3,-63,95c-2,3.3,-4,7,-6,11\nc0,7.3,5.7,11,17,11c0,0,11,0,11,0c9.3,0,14.3,-0.3,15,-1c5.3,-5.3,10.3,-11,15,-17\nc242.7,-294.7,395.3,-681.7,458,-1161c21.3,-164.7,33.3,-350.7,36,-558\nl0,-"+(t+144)+"c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7,\n-470,-1265c-4.7,-6,-9.7,-11.7,-15,-17c-0.7,-0.7,-6.7,-1,-18,-1z";default:throw new Error("Unknown stretchy delimiter.")}}(m,Math.round(1e3*e)),o=new J(m,r),s=(p/1e3).toFixed(3)+"em",i=(t/1e3).toFixed(3)+"em",a=new K([o],{width:s,height:i,viewBox:"0 0 "+p+" "+t}),l=Ve.makeSvgSpan([],[a],n);l.height=t/1e3,l.style.width=s,l.style.height=i,T.push({type:"elem",elem:l})}else{if(T.push(er(c,u,o)),T.push(rr),null===a){const e=M-g-x+.016;T.push(tr(h,e,n))}else{const e=(M-g-x-v)/2+.016;T.push(tr(h,e,n)),T.push(rr),T.push(er(a,u,o)),T.push(rr),T.push(tr(h,e,n))}T.push(rr),T.push(er(i,u,o))}const B=n.havingBaseStyle(w.TEXT),C=Ve.makeVList({positionType:"bottom",positionData:A,children:T},B);return Kt(Ve.makeSpan(["delimsizing","mult"],[C],B),w.TEXT,n,s)},ir=.08,ar=function(e,t,r,n,o){const s=function(e,t,r){t*=1e3;let n="";switch(e){case"sqrtMain":n=function(e,t){return"M95,"+(622+e+t)+"\nc-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14\nc0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54\nc44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10\ns173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429\nc69,-144,104.5,-217.7,106.5,-221\nl"+e/2.075+" -"+e+"\nc5.3,-9.3,12,-14,20,-14\nH400000v"+(40+e)+"H845.2724\ns-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7\nc-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z\nM"+(834+e)+" "+t+"h400000v"+(40+e)+"h-400000z"}(t,M);break;case"sqrtSize1":n=function(e,t){return"M263,"+(601+e+t)+"c0.7,0,18,39.7,52,119\nc34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120\nc340,-704.7,510.7,-1060.3,512,-1067\nl"+e/2.084+" -"+e+"\nc4.7,-7.3,11,-11,19,-11\nH40000v"+(40+e)+"H1012.3\ns-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232\nc-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1\ns-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26\nc-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z\nM"+(1001+e)+" "+t+"h400000v"+(40+e)+"h-400000z"}(t,M);break;case"sqrtSize2":n=function(e,t){return"M983 "+(10+e+t)+"\nl"+e/3.13+" -"+e+"\nc4,-6.7,10,-10,18,-10 H400000v"+(40+e)+"\nH1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7\ns-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744\nc-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30\nc26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722\nc56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5\nc53.7,-170.3,84.5,-266.8,92.5,-289.5z\nM"+(1001+e)+" "+t+"h400000v"+(40+e)+"h-400000z"}(t,M);break;case"sqrtSize3":n=function(e,t){return"M424,"+(2398+e+t)+"\nc-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514\nc0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20\ns-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121\ns209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081\nl"+e/4.223+" -"+e+"c4,-6.7,10,-10,18,-10 H400000\nv"+(40+e)+"H1014.6\ns-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185\nc-2,6,-10,9,-24,9\nc-8,0,-12,-0.7,-12,-2z M"+(1001+e)+" "+t+"\nh400000v"+(40+e)+"h-400000z"}(t,M);break;case"sqrtSize4":n=function(e,t){return"M473,"+(2713+e+t)+"\nc339.3,-1799.3,509.3,-2700,510,-2702 l"+e/5.298+" -"+e+"\nc3.3,-7.3,9.3,-11,18,-11 H400000v"+(40+e)+"H1017.7\ns-90.5,478,-276.2,1466c-185.7,988,-279.5,1483,-281.5,1485c-2,6,-10,9,-24,9\nc-8,0,-12,-0.7,-12,-2c0,-1.3,-5.3,-32,-16,-92c-50.7,-293.3,-119.7,-693.3,-207,-1200\nc0,-1.3,-5.3,8.7,-16,30c-10.7,21.3,-21.3,42.7,-32,64s-16,33,-16,33s-26,-26,-26,-26\ns76,-153,76,-153s77,-151,77,-151c0.7,0.7,35.7,202,105,604c67.3,400.7,102,602.7,104,\n606zM"+(1001+e)+" "+t+"h400000v"+(40+e)+"H1017.7z"}(t,M);break;case"sqrtTall":n=function(e,t,r){return"M702 "+(e+t)+"H400000"+(40+e)+"\nH742v"+(r-54-t-e)+"l-4 4-4 4c-.667.7 -2 1.5-4 2.5s-4.167 1.833-6.5 2.5-5.5 1-9.5 1\nh-12l-28-84c-16.667-52-96.667 -294.333-240-727l-212 -643 -85 170\nc-4-3.333-8.333-7.667-13 -13l-13-13l77-155 77-156c66 199.333 139 419.667\n219 661 l218 661zM702 "+t+"H400000v"+(40+e)+"H742z"}(t,M,r)}return n}(e,n,r),i=new J(e,s),a=new K([i],{width:"400em",height:F(t),viewBox:"0 0 400000 "+r,preserveAspectRatio:"xMinYMin slice"});return Ve.makeSvgSpan(["hide-tail"],[a],o)},lr=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230a","\u230b","\\lceil","\\rceil","\u2308","\u2309","\\surd"],hr=["\\uparrow","\\downarrow","\\updownarrow","\\Uparrow","\\Downarrow","\\Updownarrow","|","\\|","\\vert","\\Vert","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27ee","\u27ef","\\lmoustache","\\rmoustache","\u23b0","\u23b1"],cr=["<",">","\\langle","\\rangle","/","\\backslash","\\lt","\\gt"],mr=[0,1.2,1.8,2.4,3],pr=[{type:"small",style:w.SCRIPTSCRIPT},{type:"small",style:w.SCRIPT},{type:"small",style:w.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4}],ur=[{type:"small",style:w.SCRIPTSCRIPT},{type:"small",style:w.SCRIPT},{type:"small",style:w.TEXT},{type:"stack"}],dr=[{type:"small",style:w.SCRIPTSCRIPT},{type:"small",style:w.SCRIPT},{type:"small",style:w.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4},{type:"stack"}],gr=function(e){if("small"===e.type)return"Main-Regular";if("large"===e.type)return"Size"+e.size+"-Regular";if("stack"===e.type)return"Size4-Regular";throw new Error("Add support for delim type '"+e.type+"' here.")},fr=function(e,t,r,n){for(let o=Math.min(2,3-n.style.size);ot)return r[o]}return r[r.length-1]},br=function(e,t,r,n,o,s){let i;"<"===e||"\\lt"===e||"\u27e8"===e?e="\\langle":">"!==e&&"\\gt"!==e&&"\u27e9"!==e||(e="\\rangle"),i=l.contains(cr,e)?pr:l.contains(lr,e)?dr:ur;const a=fr(e,t,i,n);return"small"===a.type?function(e,t,r,n,o,s){const i=Ve.makeSymbol(e,"Main-Regular",o,n),a=Kt(i,t,n,s);return r&&Jt(a,n,t),a}(e,a.style,r,n,o,s):"large"===a.type?Qt(e,a.size,r,n,o,s):sr(e,t,r,n,o,s)};var yr={sqrtImage:function(e,t){const r=t.havingBaseSizing(),n=fr("\\surd",e*r.sizeMultiplier,dr,r);let o=r.sizeMultiplier;const s=Math.max(0,t.minRuleThickness-t.fontMetrics().sqrtRuleThickness);let i,a,l=0,h=0,c=0;return"small"===n.type?(c=1e3+1e3*s+80,e<1?o=1:e<1.4&&(o=.7),l=(1+s+ir)/o,h=(1+s)/o,i=ar("sqrtMain",l,c,s,t),i.style.minWidth="0.853em",a=.833/o):"large"===n.type?(c=1080*mr[n.size],h=(mr[n.size]+s)/o,l=(mr[n.size]+s+ir)/o,i=ar("sqrtSize"+n.size,l,c,s,t),i.style.minWidth="1.02em",a=1/o):(l=e+s+ir,h=e+s,c=Math.floor(1e3*e+s)+80,i=ar("sqrtTall",l,c,s,t),i.style.minWidth="0.742em",a=1.056),i.height=h,i.style.height=F(l),{span:i,advanceWidth:a,ruleWidth:(t.fontMetrics().sqrtRuleThickness+s)*o}},sizedDelim:function(e,t,r,o,s){if("<"===e||"\\lt"===e||"\u27e8"===e?e="\\langle":">"!==e&&"\\gt"!==e&&"\u27e9"!==e||(e="\\rangle"),l.contains(lr,e)||l.contains(cr,e))return Qt(e,t,!1,r,o,s);if(l.contains(hr,e))return sr(e,mr[t],!1,r,o,s);throw new n("Illegal delimiter: '"+e+"'")},sizeToMaxHeight:mr,customSizedDelim:br,leftRightDelim:function(e,t,r,n,o,s){const i=n.fontMetrics().axisHeight*n.sizeMultiplier,a=5/n.fontMetrics().ptPerEm,l=Math.max(t-i,r+i),h=Math.max(l/500*901,2*l-a);return br(e,h,!0,n,o,s)}};const xr={"\\bigl":{mclass:"mopen",size:1},"\\Bigl":{mclass:"mopen",size:2},"\\biggl":{mclass:"mopen",size:3},"\\Biggl":{mclass:"mopen",size:4},"\\bigr":{mclass:"mclose",size:1},"\\Bigr":{mclass:"mclose",size:2},"\\biggr":{mclass:"mclose",size:3},"\\Biggr":{mclass:"mclose",size:4},"\\bigm":{mclass:"mrel",size:1},"\\Bigm":{mclass:"mrel",size:2},"\\biggm":{mclass:"mrel",size:3},"\\Biggm":{mclass:"mrel",size:4},"\\big":{mclass:"mord",size:1},"\\Big":{mclass:"mord",size:2},"\\bigg":{mclass:"mord",size:3},"\\Bigg":{mclass:"mord",size:4}},wr=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230a","\u230b","\\lceil","\\rceil","\u2308","\u2309","<",">","\\langle","\u27e8","\\rangle","\u27e9","\\lt","\\gt","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27ee","\u27ef","\\lmoustache","\\rmoustache","\u23b0","\u23b1","/","\\backslash","|","\\vert","\\|","\\Vert","\\uparrow","\\Uparrow","\\downarrow","\\Downarrow","\\updownarrow","\\Updownarrow","."];function vr(e,t){const r=Rt(e);if(r&&l.contains(wr,r.text))return r;throw new n(r?"Invalid delimiter '"+r.text+"' after '"+t.funcName+"'":"Invalid delimiter type '"+e.type+"'",e)}function kr(e){if(!e.body)throw new Error("Bug: The leftright ParseNode wasn't fully parsed.")}je({type:"delimsizing",names:["\\bigl","\\Bigl","\\biggl","\\Biggl","\\bigr","\\Bigr","\\biggr","\\Biggr","\\bigm","\\Bigm","\\biggm","\\Biggm","\\big","\\Big","\\bigg","\\Bigg"],props:{numArgs:1,argTypes:["primitive"]},handler:(e,t)=>{const r=vr(t[0],e);return{type:"delimsizing",mode:e.parser.mode,size:xr[e.funcName].size,mclass:xr[e.funcName].mclass,delim:r.text}},htmlBuilder:(e,t)=>"."===e.delim?Ve.makeSpan([e.mclass]):yr.sizedDelim(e.delim,e.size,t,e.mode,[e.mclass]),mathmlBuilder:e=>{const t=[];"."!==e.delim&&t.push(ft(e.delim,e.mode));const r=new gt.MathNode("mo",t);"mopen"===e.mclass||"mclose"===e.mclass?r.setAttribute("fence","true"):r.setAttribute("fence","false"),r.setAttribute("stretchy","true");const n=F(yr.sizeToMaxHeight[e.size]);return r.setAttribute("minsize",n),r.setAttribute("maxsize",n),r}}),je({type:"leftright-right",names:["\\right"],props:{numArgs:1,primitive:!0},handler:(e,t)=>{const r=e.parser.gullet.macros.get("\\current@color");if(r&&"string"!=typeof r)throw new n("\\current@color set to non-string in \\right");return{type:"leftright-right",mode:e.parser.mode,delim:vr(t[0],e).text,color:r}}}),je({type:"leftright",names:["\\left"],props:{numArgs:1,primitive:!0},handler:(e,t)=>{const r=vr(t[0],e),n=e.parser;++n.leftrightDepth;const o=n.parseExpression(!1);--n.leftrightDepth,n.expect("\\right",!1);const s=qt(n.parseFunction(),"leftright-right");return{type:"leftright",mode:n.mode,body:o,left:r.text,right:s.delim,rightColor:s.color}},htmlBuilder:(e,t)=>{kr(e);const r=nt(e.body,t,!0,["mopen","mclose"]);let n,o,s=0,i=0,a=!1;for(let e=0;e{kr(e);const r=xt(e.body,t);if("."!==e.left){const t=new gt.MathNode("mo",[ft(e.left,e.mode)]);t.setAttribute("fence","true"),r.unshift(t)}if("."!==e.right){const t=new gt.MathNode("mo",[ft(e.right,e.mode)]);t.setAttribute("fence","true"),e.rightColor&&t.setAttribute("mathcolor",e.rightColor),r.push(t)}return bt(r)}}),je({type:"middle",names:["\\middle"],props:{numArgs:1,primitive:!0},handler:(e,t)=>{const r=vr(t[0],e);if(!e.parser.leftrightDepth)throw new n("\\middle without preceding \\left",r);return{type:"middle",mode:e.parser.mode,delim:r.text}},htmlBuilder:(e,t)=>{let r;if("."===e.delim)r=lt(t,[]);else{r=yr.sizedDelim(e.delim,1,t,e.mode,[]);const n={delim:e.delim,options:t};r.isMiddle=n}return r},mathmlBuilder:(e,t)=>{const r="\\vert"===e.delim||"|"===e.delim?ft("|","text"):ft(e.delim,e.mode),n=new gt.MathNode("mo",[r]);return n.setAttribute("fence","true"),n.setAttribute("lspace","0.05em"),n.setAttribute("rspace","0.05em"),n}});const Sr=(e,t)=>{const r=Ve.wrapFragment(ht(e.body,t),t),n=e.label.slice(1);let o,s=t.sizeMultiplier,i=0;const a=l.isCharacterBox(e.body);if("sout"===n)o=Ve.makeSpan(["stretchy","sout"]),o.height=t.fontMetrics().defaultRuleThickness/s,i=-.5*t.fontMetrics().xHeight;else if("phase"===n){const e=P({number:.6,unit:"pt"},t),n=P({number:.35,unit:"ex"},t);s/=t.havingBaseSizing().sizeMultiplier;const a=r.height+r.depth+e+n;r.style.paddingLeft=F(a/2+e);const l=Math.floor(1e3*a*s),c="M400000 "+(h=l)+" H0 L"+h/2+" 0 l65 45 L145 "+(h-80)+" H400000z",m=new K([new J("phase",c)],{width:"400em",height:F(l/1e3),viewBox:"0 0 400000 "+l,preserveAspectRatio:"xMinYMin slice"});o=Ve.makeSvgSpan(["hide-tail"],[m],t),o.style.height=F(a),i=r.depth+e+n}else{/cancel/.test(n)?a||r.classes.push("cancel-pad"):"angl"===n?r.classes.push("anglpad"):r.classes.push("boxpad");let s=0,l=0,h=0;/box/.test(n)?(h=Math.max(t.fontMetrics().fboxrule,t.minRuleThickness),s=t.fontMetrics().fboxsep+("colorbox"===n?0:h),l=s):"angl"===n?(h=Math.max(t.fontMetrics().defaultRuleThickness,t.minRuleThickness),s=4*h,l=Math.max(0,.25-r.depth)):(s=a?.2:0,l=s),o=Bt(r,n,s,l,t),/fbox|boxed|fcolorbox/.test(n)?(o.style.borderStyle="solid",o.style.borderWidth=F(h)):"angl"===n&&.049!==h&&(o.style.borderTopWidth=F(h),o.style.borderRightWidth=F(h)),i=r.depth+l,e.backgroundColor&&(o.style.backgroundColor=e.backgroundColor,e.borderColor&&(o.style.borderColor=e.borderColor))}var h;let c;if(e.backgroundColor)c=Ve.makeVList({positionType:"individualShift",children:[{type:"elem",elem:o,shift:i},{type:"elem",elem:r,shift:0}]},t);else{const e=/cancel|phase/.test(n)?["svg-align"]:[];c=Ve.makeVList({positionType:"individualShift",children:[{type:"elem",elem:r,shift:0},{type:"elem",elem:o,shift:i,wrapperClasses:e}]},t)}return/cancel/.test(n)&&(c.height=r.height,c.depth=r.depth),/cancel/.test(n)&&!a?Ve.makeSpan(["mord","cancel-lap"],[c],t):Ve.makeSpan(["mord"],[c],t)},Mr=(e,t)=>{let r=0;const n=new gt.MathNode(e.label.indexOf("colorbox")>-1?"mpadded":"menclose",[vt(e.body,t)]);switch(e.label){case"\\cancel":n.setAttribute("notation","updiagonalstrike");break;case"\\bcancel":n.setAttribute("notation","downdiagonalstrike");break;case"\\phase":n.setAttribute("notation","phasorangle");break;case"\\sout":n.setAttribute("notation","horizontalstrike");break;case"\\fbox":n.setAttribute("notation","box");break;case"\\angl":n.setAttribute("notation","actuarial");break;case"\\fcolorbox":case"\\colorbox":if(r=t.fontMetrics().fboxsep*t.fontMetrics().ptPerEm,n.setAttribute("width","+"+2*r+"pt"),n.setAttribute("height","+"+2*r+"pt"),n.setAttribute("lspace",r+"pt"),n.setAttribute("voffset",r+"pt"),"\\fcolorbox"===e.label){const r=Math.max(t.fontMetrics().fboxrule,t.minRuleThickness);n.setAttribute("style","border: "+r+"em solid "+String(e.borderColor))}break;case"\\xcancel":n.setAttribute("notation","updiagonalstrike downdiagonalstrike")}return e.backgroundColor&&n.setAttribute("mathbackground",e.backgroundColor),n};je({type:"enclose",names:["\\colorbox"],props:{numArgs:2,allowedInText:!0,argTypes:["color","text"]},handler(e,t,r){let{parser:n,funcName:o}=e;const s=qt(t[0],"color-token").color,i=t[1];return{type:"enclose",mode:n.mode,label:o,backgroundColor:s,body:i}},htmlBuilder:Sr,mathmlBuilder:Mr}),je({type:"enclose",names:["\\fcolorbox"],props:{numArgs:3,allowedInText:!0,argTypes:["color","color","text"]},handler(e,t,r){let{parser:n,funcName:o}=e;const s=qt(t[0],"color-token").color,i=qt(t[1],"color-token").color,a=t[2];return{type:"enclose",mode:n.mode,label:o,backgroundColor:i,borderColor:s,body:a}},htmlBuilder:Sr,mathmlBuilder:Mr}),je({type:"enclose",names:["\\fbox"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!0},handler(e,t){let{parser:r}=e;return{type:"enclose",mode:r.mode,label:"\\fbox",body:t[0]}}}),je({type:"enclose",names:["\\cancel","\\bcancel","\\xcancel","\\sout","\\phase"],props:{numArgs:1},handler(e,t){let{parser:r,funcName:n}=e;const o=t[0];return{type:"enclose",mode:r.mode,label:n,body:o}},htmlBuilder:Sr,mathmlBuilder:Mr}),je({type:"enclose",names:["\\angl"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!1},handler(e,t){let{parser:r}=e;return{type:"enclose",mode:r.mode,label:"\\angl",body:t[0]}}});const zr={};function Ar(e){let{type:t,names:r,props:n,handler:o,htmlBuilder:s,mathmlBuilder:i}=e;const a={type:t,numArgs:n.numArgs||0,allowedInText:!1,numOptionalArgs:0,handler:o};for(let e=0;e{if(!e.parser.settings.displayMode)throw new n("{"+e.envName+"} can be used only in display mode.")};function Rr(e){if(-1===e.indexOf("ed"))return-1===e.indexOf("*")}function Hr(e,t,r){let{hskipBeforeAndAfter:o,addJot:s,cols:i,arraystretch:a,colSeparationType:l,autoTag:h,singleRow:c,emptySingleRow:m,maxNumCols:p,leqno:u}=t;if(e.gullet.beginGroup(),c||e.gullet.macros.set("\\cr","\\\\\\relax"),!a){const t=e.gullet.expandMacroAsText("\\arraystretch");if(null==t)a=1;else if(a=parseFloat(t),!a||a<0)throw new n("Invalid \\arraystretch: "+t)}e.gullet.beginGroup();let d=[];const g=[d],f=[],b=[],y=null!=h?[]:void 0;function x(){h&&e.gullet.macros.set("\\@eqnsw","1",!0)}function w(){y&&(e.gullet.macros.get("\\df@tag")?(y.push(e.subparse([new Nr("\\df@tag")])),e.gullet.macros.set("\\df@tag",void 0,!0)):y.push(Boolean(h)&&"1"===e.gullet.macros.get("\\@eqnsw")))}for(x(),b.push(qr(e));;){let t=e.parseExpression(!1,c?"\\end":"\\\\");e.gullet.endGroup(),e.gullet.beginGroup(),t={type:"ordgroup",mode:e.mode,body:t},r&&(t={type:"styling",mode:e.mode,style:r,body:[t]}),d.push(t);const o=e.fetch().text;if("&"===o){if(p&&d.length===p){if(c||l)throw new n("Too many tab characters: &",e.nextToken);e.settings.reportNonstrict("textEnv","Too few columns specified in the {array} column argument.")}e.consume()}else{if("\\end"===o){w(),1===d.length&&"styling"===t.type&&0===t.body[0].body.length&&(g.length>1||!m)&&g.pop(),b.length0&&(x+=.25),c.push({pos:x,isDashed:e[t]})}for(v(i[0]),r=0;r0&&(p+=y,le)))for(r=0;r=a)continue;(o>0||e.hskipBeforeAndAfter)&&(i=l.deflt(c.pregap,u),0!==i&&(z=Ve.makeSpan(["arraycolsep"],[]),z.style.width=F(i),M.push(z)));let d=[];for(r=0;r0){const e=Ve.makeLineSpan("hline",t,m),r=Ve.makeLineSpan("hdashline",t,m),n=[{type:"elem",elem:h,shift:0}];for(;c.length>0;){const t=c.pop(),o=t.pos-k;t.isDashed?n.push({type:"elem",elem:r,shift:o}):n.push({type:"elem",elem:e,shift:o})}h=Ve.makeVList({positionType:"individualShift",children:n},t)}if(0===T.length)return Ve.makeSpan(["mord"],[h],t);{let e=Ve.makeVList({positionType:"individualShift",children:T},t);return e=Ve.makeSpan(["tag"],[e],t),Ve.makeFragment([h,e])}},Lr={c:"center ",l:"left ",r:"right "},Dr=function(e,t){const r=[],n=new gt.MathNode("mtd",[],["mtr-glue"]),o=new gt.MathNode("mtd",[],["mml-eqn-num"]);for(let s=0;s0){const t=e.cols;let r="",n=!1,o=0,i=t.length;"separator"===t[0].type&&(a+="top ",o=1),"separator"===t[t.length-1].type&&(a+="bottom ",i-=1);for(let e=o;e0?"left ":"",a+=c[c.length-1].length>0?"right ":"";for(let e=1;e-1?"alignat":"align",s="split"===e.envName,i=Hr(e.parser,{cols:r,addJot:!0,autoTag:s?void 0:Rr(e.envName),emptySingleRow:!0,colSeparationType:o,maxNumCols:s?2:void 0,leqno:e.parser.settings.leqno},"display");let a,l=0;const h={type:"ordgroup",mode:e.mode,body:[]};if(t[0]&&"ordgroup"===t[0].type){let e="";for(let r=0;r0&&c&&(n=1),r[e]={type:"align",align:t,pregap:n,postgap:0}}return i.colSeparationType=c?"align":"alignat",i};Ar({type:"array",names:["array","darray"],props:{numArgs:1},handler(e,t){const r=(Rt(t[0])?[t[0]]:qt(t[0],"ordgroup").body).map((function(e){const t=It(e).text;if(-1!=="lcr".indexOf(t))return{type:"align",align:t};if("|"===t)return{type:"separator",separator:"|"};if(":"===t)return{type:"separator",separator:":"};throw new n("Unknown column alignment: "+t,e)})),o={cols:r,hskipBeforeAndAfter:!0,maxNumCols:r.length};return Hr(e.parser,o,Or(e.envName))},htmlBuilder:Er,mathmlBuilder:Dr}),Ar({type:"array",names:["matrix","pmatrix","bmatrix","Bmatrix","vmatrix","Vmatrix","matrix*","pmatrix*","bmatrix*","Bmatrix*","vmatrix*","Vmatrix*"],props:{numArgs:0},handler(e){const t={matrix:null,pmatrix:["(",")"],bmatrix:["[","]"],Bmatrix:["\\{","\\}"],vmatrix:["|","|"],Vmatrix:["\\Vert","\\Vert"]}[e.envName.replace("*","")];let r="c";const o={hskipBeforeAndAfter:!1,cols:[{type:"align",align:r}]};if("*"===e.envName.charAt(e.envName.length-1)){const t=e.parser;if(t.consumeSpaces(),"["===t.fetch().text){if(t.consume(),t.consumeSpaces(),r=t.fetch().text,-1==="lcr".indexOf(r))throw new n("Expected l or c or r",t.nextToken);t.consume(),t.consumeSpaces(),t.expect("]"),t.consume(),o.cols=[{type:"align",align:r}]}}const s=Hr(e.parser,o,Or(e.envName)),i=Math.max(0,...s.body.map((e=>e.length)));return s.cols=new Array(i).fill({type:"align",align:r}),t?{type:"leftright",mode:e.mode,body:[s],left:t[0],right:t[1],rightColor:void 0}:s},htmlBuilder:Er,mathmlBuilder:Dr}),Ar({type:"array",names:["smallmatrix"],props:{numArgs:0},handler(e){const t=Hr(e.parser,{arraystretch:.5},"script");return t.colSeparationType="small",t},htmlBuilder:Er,mathmlBuilder:Dr}),Ar({type:"array",names:["subarray"],props:{numArgs:1},handler(e,t){const r=(Rt(t[0])?[t[0]]:qt(t[0],"ordgroup").body).map((function(e){const t=It(e).text;if(-1!=="lc".indexOf(t))return{type:"align",align:t};throw new n("Unknown column alignment: "+t,e)}));if(r.length>1)throw new n("{subarray} can contain only one column");let o={cols:r,hskipBeforeAndAfter:!1,arraystretch:.5};if(o=Hr(e.parser,o,"script"),o.body.length>0&&o.body[0].length>1)throw new n("{subarray} can contain only one column");return o},htmlBuilder:Er,mathmlBuilder:Dr}),Ar({type:"array",names:["cases","dcases","rcases","drcases"],props:{numArgs:0},handler(e){const t=Hr(e.parser,{arraystretch:1.2,cols:[{type:"align",align:"l",pregap:0,postgap:1},{type:"align",align:"l",pregap:0,postgap:0}]},Or(e.envName));return{type:"leftright",mode:e.mode,body:[t],left:e.envName.indexOf("r")>-1?".":"\\{",right:e.envName.indexOf("r")>-1?"\\}":".",rightColor:void 0}},htmlBuilder:Er,mathmlBuilder:Dr}),Ar({type:"array",names:["align","align*","aligned","split"],props:{numArgs:0},handler:Vr,htmlBuilder:Er,mathmlBuilder:Dr}),Ar({type:"array",names:["gathered","gather","gather*"],props:{numArgs:0},handler(e){l.contains(["gather","gather*"],e.envName)&&Ir(e);const t={cols:[{type:"align",align:"c"}],addJot:!0,colSeparationType:"gather",autoTag:Rr(e.envName),emptySingleRow:!0,leqno:e.parser.settings.leqno};return Hr(e.parser,t,"display")},htmlBuilder:Er,mathmlBuilder:Dr}),Ar({type:"array",names:["alignat","alignat*","alignedat"],props:{numArgs:1},handler:Vr,htmlBuilder:Er,mathmlBuilder:Dr}),Ar({type:"array",names:["equation","equation*"],props:{numArgs:0},handler(e){Ir(e);const t={autoTag:Rr(e.envName),emptySingleRow:!0,singleRow:!0,maxNumCols:1,leqno:e.parser.settings.leqno};return Hr(e.parser,t,"display")},htmlBuilder:Er,mathmlBuilder:Dr}),Ar({type:"array",names:["CD"],props:{numArgs:0},handler(e){return Ir(e),function(e){const t=[];for(e.gullet.beginGroup(),e.gullet.macros.set("\\cr","\\\\\\relax"),e.gullet.beginGroup();;){t.push(e.parseExpression(!1,"\\\\")),e.gullet.endGroup(),e.gullet.beginGroup();const r=e.fetch().text;if("&"!==r&&"\\\\"!==r){if("\\end"===r){0===t[t.length-1].length&&t.pop();break}throw new n("Expected \\\\ or \\cr or \\end",e.nextToken)}e.consume()}let r=[];const o=[r];for(let a=0;a-1);else{if(!("<>AV".indexOf(o)>-1))throw new n('Expected one of "<>AV=|." after @',l[t]);for(let e=0;e<2;e++){let r=!0;for(let h=t+1;h{const r=e.font,n=t.withFont(r);return ht(e.body,n)},Gr=(e,t)=>{const r=e.font,n=t.withFont(r);return vt(e.body,n)},Ur={"\\Bbb":"\\mathbb","\\bold":"\\mathbf","\\frak":"\\mathfrak","\\bm":"\\boldsymbol"};je({type:"font",names:["\\mathrm","\\mathit","\\mathbf","\\mathnormal","\\mathbb","\\mathcal","\\mathfrak","\\mathscr","\\mathsf","\\mathtt","\\Bbb","\\bold","\\frak"],props:{numArgs:1,allowedInArgument:!0},handler:(e,t)=>{let{parser:r,funcName:n}=e;const o=Ze(t[0]);let s=n;return s in Ur&&(s=Ur[s]),{type:"font",mode:r.mode,font:s.slice(1),body:o}},htmlBuilder:Fr,mathmlBuilder:Gr}),je({type:"mclass",names:["\\boldsymbol","\\bm"],props:{numArgs:1},handler:(e,t)=>{let{parser:r}=e;const n=t[0],o=l.isCharacterBox(n);return{type:"mclass",mode:r.mode,mclass:Ft(n),body:[{type:"font",mode:r.mode,font:"boldsymbol",body:n}],isCharacterBox:o}}}),je({type:"font",names:["\\rm","\\sf","\\tt","\\bf","\\it","\\cal"],props:{numArgs:0,allowedInText:!0},handler:(e,t)=>{let{parser:r,funcName:n,breakOnTokenText:o}=e;const{mode:s}=r,i=r.parseExpression(!0,o);return{type:"font",mode:s,font:"math"+n.slice(1),body:{type:"ordgroup",mode:r.mode,body:i}}},htmlBuilder:Fr,mathmlBuilder:Gr});const Yr=(e,t)=>{let r=t;return"display"===e?r=r.id>=w.SCRIPT.id?r.text():w.DISPLAY:"text"===e&&r.size===w.DISPLAY.size?r=w.TEXT:"script"===e?r=w.SCRIPT:"scriptscript"===e&&(r=w.SCRIPTSCRIPT),r},Xr=(e,t)=>{const r=Yr(e.size,t.style),n=r.fracNum(),o=r.fracDen();let s;s=t.havingStyle(n);const i=ht(e.numer,s,t);if(e.continued){const e=8.5/t.fontMetrics().ptPerEm,r=3.5/t.fontMetrics().ptPerEm;i.height=i.height0?3*c:7*c,u=t.fontMetrics().denom1):(h>0?(m=t.fontMetrics().num2,p=c):(m=t.fontMetrics().num3,p=3*c),u=t.fontMetrics().denom2),l){const e=t.fontMetrics().axisHeight;m-i.depth-(e+.5*h){let r=new gt.MathNode("mfrac",[vt(e.numer,t),vt(e.denom,t)]);if(e.hasBarLine){if(e.barSize){const n=P(e.barSize,t);r.setAttribute("linethickness",F(n))}}else r.setAttribute("linethickness","0px");const n=Yr(e.size,t.style);if(n.size!==t.style.size){r=new gt.MathNode("mstyle",[r]);const e=n.size===w.DISPLAY.size?"true":"false";r.setAttribute("displaystyle",e),r.setAttribute("scriptlevel","0")}if(null!=e.leftDelim||null!=e.rightDelim){const t=[];if(null!=e.leftDelim){const r=new gt.MathNode("mo",[new gt.TextNode(e.leftDelim.replace("\\",""))]);r.setAttribute("fence","true"),t.push(r)}if(t.push(r),null!=e.rightDelim){const r=new gt.MathNode("mo",[new gt.TextNode(e.rightDelim.replace("\\",""))]);r.setAttribute("fence","true"),t.push(r)}return bt(t)}return r};je({type:"genfrac",names:["\\dfrac","\\frac","\\tfrac","\\dbinom","\\binom","\\tbinom","\\\\atopfrac","\\\\bracefrac","\\\\brackfrac"],props:{numArgs:2,allowedInArgument:!0},handler:(e,t)=>{let{parser:r,funcName:n}=e;const o=t[0],s=t[1];let i,a=null,l=null,h="auto";switch(n){case"\\dfrac":case"\\frac":case"\\tfrac":i=!0;break;case"\\\\atopfrac":i=!1;break;case"\\dbinom":case"\\binom":case"\\tbinom":i=!1,a="(",l=")";break;case"\\\\bracefrac":i=!1,a="\\{",l="\\}";break;case"\\\\brackfrac":i=!1,a="[",l="]";break;default:throw new Error("Unrecognized genfrac command")}switch(n){case"\\dfrac":case"\\dbinom":h="display";break;case"\\tfrac":case"\\tbinom":h="text"}return{type:"genfrac",mode:r.mode,continued:!1,numer:o,denom:s,hasBarLine:i,leftDelim:a,rightDelim:l,size:h,barSize:null}},htmlBuilder:Xr,mathmlBuilder:Wr}),je({type:"genfrac",names:["\\cfrac"],props:{numArgs:2},handler:(e,t)=>{let{parser:r,funcName:n}=e;const o=t[0],s=t[1];return{type:"genfrac",mode:r.mode,continued:!0,numer:o,denom:s,hasBarLine:!0,leftDelim:null,rightDelim:null,size:"display",barSize:null}}}),je({type:"infix",names:["\\over","\\choose","\\atop","\\brace","\\brack"],props:{numArgs:0,infix:!0},handler(e){let t,{parser:r,funcName:n,token:o}=e;switch(n){case"\\over":t="\\frac";break;case"\\choose":t="\\binom";break;case"\\atop":t="\\\\atopfrac";break;case"\\brace":t="\\\\bracefrac";break;case"\\brack":t="\\\\brackfrac";break;default:throw new Error("Unrecognized infix genfrac command")}return{type:"infix",mode:r.mode,replaceWith:t,token:o}}});const _r=["display","text","script","scriptscript"],jr=function(e){let t=null;return e.length>0&&(t=e,t="."===t?null:t),t};je({type:"genfrac",names:["\\genfrac"],props:{numArgs:6,allowedInArgument:!0,argTypes:["math","math","size","text","math","math"]},handler(e,t){let{parser:r}=e;const n=t[4],o=t[5],s=Ze(t[0]),i="atom"===s.type&&"open"===s.family?jr(s.text):null,a=Ze(t[1]),l="atom"===a.type&&"close"===a.family?jr(a.text):null,h=qt(t[2],"size");let c,m=null;h.isBlank?c=!0:(m=h.value,c=m.number>0);let p="auto",u=t[3];if("ordgroup"===u.type){if(u.body.length>0){const e=qt(u.body[0],"textord");p=_r[Number(e.text)]}}else u=qt(u,"textord"),p=_r[Number(u.text)];return{type:"genfrac",mode:r.mode,numer:n,denom:o,continued:!1,hasBarLine:c,barSize:m,leftDelim:i,rightDelim:l,size:p}},htmlBuilder:Xr,mathmlBuilder:Wr}),je({type:"infix",names:["\\above"],props:{numArgs:1,argTypes:["size"],infix:!0},handler(e,t){let{parser:r,funcName:n,token:o}=e;return{type:"infix",mode:r.mode,replaceWith:"\\\\abovefrac",size:qt(t[0],"size").value,token:o}}}),je({type:"genfrac",names:["\\\\abovefrac"],props:{numArgs:3,argTypes:["math","size","math"]},handler:(e,t)=>{let{parser:r,funcName:n}=e;const o=t[0],s=function(e){if(!e)throw new Error("Expected non-null, but got "+String(e));return e}(qt(t[1],"infix").size),i=t[2],a=s.number>0;return{type:"genfrac",mode:r.mode,numer:o,denom:i,continued:!1,hasBarLine:a,barSize:s,leftDelim:null,rightDelim:null,size:"auto"}},htmlBuilder:Xr,mathmlBuilder:Wr});const $r=(e,t)=>{const r=t.style;let n,o;"supsub"===e.type?(n=e.sup?ht(e.sup,t.havingStyle(r.sup()),t):ht(e.sub,t.havingStyle(r.sub()),t),o=qt(e.base,"horizBrace")):o=qt(e,"horizBrace");const s=ht(o.base,t.havingBaseStyle(w.DISPLAY)),i=Nt(o,t);let a;if(o.isOver?(a=Ve.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"kern",size:.1},{type:"elem",elem:i}]},t),a.children[0].children[0].children[1].classes.push("svg-align")):(a=Ve.makeVList({positionType:"bottom",positionData:s.depth+.1+i.height,children:[{type:"elem",elem:i},{type:"kern",size:.1},{type:"elem",elem:s}]},t),a.children[0].children[0].children[0].classes.push("svg-align")),n){const e=Ve.makeSpan(["mord",o.isOver?"mover":"munder"],[a],t);a=o.isOver?Ve.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:e},{type:"kern",size:.2},{type:"elem",elem:n}]},t):Ve.makeVList({positionType:"bottom",positionData:e.depth+.2+n.height+n.depth,children:[{type:"elem",elem:n},{type:"kern",size:.2},{type:"elem",elem:e}]},t)}return Ve.makeSpan(["mord",o.isOver?"mover":"munder"],[a],t)};je({type:"horizBrace",names:["\\overbrace","\\underbrace"],props:{numArgs:1},handler(e,t){let{parser:r,funcName:n}=e;return{type:"horizBrace",mode:r.mode,label:n,isOver:/^\\over/.test(n),base:t[0]}},htmlBuilder:$r,mathmlBuilder:(e,t)=>{const r=Ct(e.label);return new gt.MathNode(e.isOver?"mover":"munder",[vt(e.base,t),r])}}),je({type:"href",names:["\\href"],props:{numArgs:2,argTypes:["url","original"],allowedInText:!0},handler:(e,t)=>{let{parser:r}=e;const n=t[1],o=qt(t[0],"url").url;return r.settings.isTrusted({command:"\\href",url:o})?{type:"href",mode:r.mode,href:o,body:Ke(n)}:r.formatUnsupportedCmd("\\href")},htmlBuilder:(e,t)=>{const r=nt(e.body,t,!1);return Ve.makeAnchor(e.href,[],r,t)},mathmlBuilder:(e,t)=>{let r=wt(e.body,t);return r instanceof ut||(r=new ut("mrow",[r])),r.setAttribute("href",e.href),r}}),je({type:"href",names:["\\url"],props:{numArgs:1,argTypes:["url"],allowedInText:!0},handler:(e,t)=>{let{parser:r}=e;const n=qt(t[0],"url").url;if(!r.settings.isTrusted({command:"\\url",url:n}))return r.formatUnsupportedCmd("\\url");const o=[];for(let e=0;e{let{parser:r,funcName:o,token:s}=e;const i=qt(t[0],"raw").string,a=t[1];let l;r.settings.strict&&r.settings.reportNonstrict("htmlExtension","HTML extension is disabled on strict mode");const h={};switch(o){case"\\htmlClass":h.class=i,l={command:"\\htmlClass",class:i};break;case"\\htmlId":h.id=i,l={command:"\\htmlId",id:i};break;case"\\htmlStyle":h.style=i,l={command:"\\htmlStyle",style:i};break;case"\\htmlData":{const e=i.split(",");for(let t=0;t{const r=nt(e.body,t,!1),n=["enclosing"];e.attributes.class&&n.push(...e.attributes.class.trim().split(/\s+/));const o=Ve.makeSpan(n,r,t);for(const t in e.attributes)"class"!==t&&e.attributes.hasOwnProperty(t)&&o.setAttribute(t,e.attributes[t]);return o},mathmlBuilder:(e,t)=>wt(e.body,t)}),je({type:"htmlmathml",names:["\\html@mathml"],props:{numArgs:2,allowedInText:!0},handler:(e,t)=>{let{parser:r}=e;return{type:"htmlmathml",mode:r.mode,html:Ke(t[0]),mathml:Ke(t[1])}},htmlBuilder:(e,t)=>{const r=nt(e.html,t,!1);return Ve.makeFragment(r)},mathmlBuilder:(e,t)=>wt(e.mathml,t)});const Zr=function(e){if(/^[-+]? *(\d+(\.\d*)?|\.\d+)$/.test(e))return{number:+e,unit:"bp"};{const t=/([-+]?) *(\d+(?:\.\d*)?|\.\d+) *([a-z]{2})/.exec(e);if(!t)throw new n("Invalid size: '"+e+"' in \\includegraphics");const r={number:+(t[1]+t[2]),unit:t[3]};if(!V(r))throw new n("Invalid unit: '"+r.unit+"' in \\includegraphics.");return r}};je({type:"includegraphics",names:["\\includegraphics"],props:{numArgs:1,numOptionalArgs:1,argTypes:["raw","url"],allowedInText:!1},handler:(e,t,r)=>{let{parser:o}=e,s={number:0,unit:"em"},i={number:.9,unit:"em"},a={number:0,unit:"em"},l="";if(r[0]){const e=qt(r[0],"raw").string.split(",");for(let t=0;t{const r=P(e.height,t);let n=0;e.totalheight.number>0&&(n=P(e.totalheight,t)-r);let o=0;e.width.number>0&&(o=P(e.width,t));const s={height:F(r+n)};o>0&&(s.width=F(o)),n>0&&(s.verticalAlign=F(-n));const i=new j(e.src,e.alt,s);return i.height=r,i.depth=n,i},mathmlBuilder:(e,t)=>{const r=new gt.MathNode("mglyph",[]);r.setAttribute("alt",e.alt);const n=P(e.height,t);let o=0;if(e.totalheight.number>0&&(o=P(e.totalheight,t)-n,r.setAttribute("valign",F(-o))),r.setAttribute("height",F(n+o)),e.width.number>0){const n=P(e.width,t);r.setAttribute("width",F(n))}return r.setAttribute("src",e.src),r}}),je({type:"kern",names:["\\kern","\\mkern","\\hskip","\\mskip"],props:{numArgs:1,argTypes:["size"],primitive:!0,allowedInText:!0},handler(e,t){let{parser:r,funcName:n}=e;const o=qt(t[0],"size");if(r.settings.strict){const e="m"===n[1],t="mu"===o.value.unit;e?(t||r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" supports only mu units, not "+o.value.unit+" units"),"math"!==r.mode&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" works only in math mode")):t&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" doesn't support mu units")}return{type:"kern",mode:r.mode,dimension:o.value}},htmlBuilder(e,t){return Ve.makeGlue(e.dimension,t)},mathmlBuilder(e,t){const r=P(e.dimension,t);return new gt.SpaceNode(r)}}),je({type:"lap",names:["\\mathllap","\\mathrlap","\\mathclap"],props:{numArgs:1,allowedInText:!0},handler:(e,t)=>{let{parser:r,funcName:n}=e;const o=t[0];return{type:"lap",mode:r.mode,alignment:n.slice(5),body:o}},htmlBuilder:(e,t)=>{let r;"clap"===e.alignment?(r=Ve.makeSpan([],[ht(e.body,t)]),r=Ve.makeSpan(["inner"],[r],t)):r=Ve.makeSpan(["inner"],[ht(e.body,t)]);const n=Ve.makeSpan(["fix"],[]);let o=Ve.makeSpan([e.alignment],[r,n],t);const s=Ve.makeSpan(["strut"]);return s.style.height=F(o.height+o.depth),o.depth&&(s.style.verticalAlign=F(-o.depth)),o.children.unshift(s),o=Ve.makeSpan(["thinbox"],[o],t),Ve.makeSpan(["mord","vbox"],[o],t)},mathmlBuilder:(e,t)=>{const r=new gt.MathNode("mpadded",[vt(e.body,t)]);if("rlap"!==e.alignment){const t="llap"===e.alignment?"-1":"-0.5";r.setAttribute("lspace",t+"width")}return r.setAttribute("width","0px"),r}}),je({type:"styling",names:["\\(","$"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(e,t){let{funcName:r,parser:n}=e;const o=n.mode;n.switchMode("math");const s="\\("===r?"\\)":"$",i=n.parseExpression(!1,s);return n.expect(s),n.switchMode(o),{type:"styling",mode:n.mode,style:"text",body:i}}}),je({type:"text",names:["\\)","\\]"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(e,t){throw new n("Mismatched "+e.funcName)}});const Kr=(e,t)=>{switch(t.style.size){case w.DISPLAY.size:return e.display;case w.TEXT.size:return e.text;case w.SCRIPT.size:return e.script;case w.SCRIPTSCRIPT.size:return e.scriptscript;default:return e.text}};je({type:"mathchoice",names:["\\mathchoice"],props:{numArgs:4,primitive:!0},handler:(e,t)=>{let{parser:r}=e;return{type:"mathchoice",mode:r.mode,display:Ke(t[0]),text:Ke(t[1]),script:Ke(t[2]),scriptscript:Ke(t[3])}},htmlBuilder:(e,t)=>{const r=Kr(e,t),n=nt(r,t,!1);return Ve.makeFragment(n)},mathmlBuilder:(e,t)=>{const r=Kr(e,t);return wt(r,t)}});const Jr=(e,t,r,n,o,s,i)=>{e=Ve.makeSpan([],[e]);const a=r&&l.isCharacterBox(r);let h,c,m;if(t){const e=ht(t,n.havingStyle(o.sup()),n);c={elem:e,kern:Math.max(n.fontMetrics().bigOpSpacing1,n.fontMetrics().bigOpSpacing3-e.depth)}}if(r){const e=ht(r,n.havingStyle(o.sub()),n);h={elem:e,kern:Math.max(n.fontMetrics().bigOpSpacing2,n.fontMetrics().bigOpSpacing4-e.height)}}if(c&&h){const t=n.fontMetrics().bigOpSpacing5+h.elem.height+h.elem.depth+h.kern+e.depth+i;m=Ve.makeVList({positionType:"bottom",positionData:t,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:h.elem,marginLeft:F(-s)},{type:"kern",size:h.kern},{type:"elem",elem:e},{type:"kern",size:c.kern},{type:"elem",elem:c.elem,marginLeft:F(s)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else if(h){const t=e.height-i;m=Ve.makeVList({positionType:"top",positionData:t,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:h.elem,marginLeft:F(-s)},{type:"kern",size:h.kern},{type:"elem",elem:e}]},n)}else{if(!c)return e;{const t=e.depth+i;m=Ve.makeVList({positionType:"bottom",positionData:t,children:[{type:"elem",elem:e},{type:"kern",size:c.kern},{type:"elem",elem:c.elem,marginLeft:F(s)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}}const p=[m];if(h&&0!==s&&!a){const e=Ve.makeSpan(["mspace"],[],n);e.style.marginRight=F(s),p.unshift(e)}return Ve.makeSpan(["mop","op-limits"],p,n)},Qr=["\\smallint"],en=(e,t)=>{let r,n,o,s=!1;"supsub"===e.type?(r=e.sup,n=e.sub,o=qt(e.base,"op"),s=!0):o=qt(e,"op");const i=t.style;let a,h=!1;if(i.size===w.DISPLAY.size&&o.symbol&&!l.contains(Qr,o.name)&&(h=!0),o.symbol){const e=h?"Size2-Regular":"Size1-Regular";let r="";if("\\oiint"!==o.name&&"\\oiiint"!==o.name||(r=o.name.slice(1),o.name="oiint"===r?"\\iint":"\\iiint"),a=Ve.makeSymbol(o.name,e,"math",t,["mop","op-symbol",h?"large-op":"small-op"]),r.length>0){const e=a.italic,n=Ve.staticSvg(r+"Size"+(h?"2":"1"),t);a=Ve.makeVList({positionType:"individualShift",children:[{type:"elem",elem:a,shift:0},{type:"elem",elem:n,shift:h?.08:0}]},t),o.name="\\"+r,a.classes.unshift("mop"),a.italic=e}}else if(o.body){const e=nt(o.body,t,!0);1===e.length&&e[0]instanceof Z?(a=e[0],a.classes[0]="mop"):a=Ve.makeSpan(["mop"],e,t)}else{const e=[];for(let r=1;r{let r;if(e.symbol)r=new ut("mo",[ft(e.name,e.mode)]),l.contains(Qr,e.name)&&r.setAttribute("largeop","false");else if(e.body)r=new ut("mo",xt(e.body,t));else{r=new ut("mi",[new dt(e.name.slice(1))]);const t=new ut("mo",[ft("\u2061","text")]);r=e.parentIsSupSub?new ut("mrow",[r,t]):pt([r,t])}return r},rn={"\u220f":"\\prod","\u2210":"\\coprod","\u2211":"\\sum","\u22c0":"\\bigwedge","\u22c1":"\\bigvee","\u22c2":"\\bigcap","\u22c3":"\\bigcup","\u2a00":"\\bigodot","\u2a01":"\\bigoplus","\u2a02":"\\bigotimes","\u2a04":"\\biguplus","\u2a06":"\\bigsqcup"};je({type:"op",names:["\\coprod","\\bigvee","\\bigwedge","\\biguplus","\\bigcap","\\bigcup","\\intop","\\prod","\\sum","\\bigotimes","\\bigoplus","\\bigodot","\\bigsqcup","\\smallint","\u220f","\u2210","\u2211","\u22c0","\u22c1","\u22c2","\u22c3","\u2a00","\u2a01","\u2a02","\u2a04","\u2a06"],props:{numArgs:0},handler:(e,t)=>{let{parser:r,funcName:n}=e,o=n;return 1===o.length&&(o=rn[o]),{type:"op",mode:r.mode,limits:!0,parentIsSupSub:!1,symbol:!0,name:o}},htmlBuilder:en,mathmlBuilder:tn}),je({type:"op",names:["\\mathop"],props:{numArgs:1,primitive:!0},handler:(e,t)=>{let{parser:r}=e;const n=t[0];return{type:"op",mode:r.mode,limits:!1,parentIsSupSub:!1,symbol:!1,body:Ke(n)}},htmlBuilder:en,mathmlBuilder:tn});const nn={"\u222b":"\\int","\u222c":"\\iint","\u222d":"\\iiint","\u222e":"\\oint","\u222f":"\\oiint","\u2230":"\\oiiint"};je({type:"op",names:["\\arcsin","\\arccos","\\arctan","\\arctg","\\arcctg","\\arg","\\ch","\\cos","\\cosec","\\cosh","\\cot","\\cotg","\\coth","\\csc","\\ctg","\\cth","\\deg","\\dim","\\exp","\\hom","\\ker","\\lg","\\ln","\\log","\\sec","\\sin","\\sinh","\\sh","\\tan","\\tanh","\\tg","\\th"],props:{numArgs:0},handler(e){let{parser:t,funcName:r}=e;return{type:"op",mode:t.mode,limits:!1,parentIsSupSub:!1,symbol:!1,name:r}},htmlBuilder:en,mathmlBuilder:tn}),je({type:"op",names:["\\det","\\gcd","\\inf","\\lim","\\max","\\min","\\Pr","\\sup"],props:{numArgs:0},handler(e){let{parser:t,funcName:r}=e;return{type:"op",mode:t.mode,limits:!0,parentIsSupSub:!1,symbol:!1,name:r}},htmlBuilder:en,mathmlBuilder:tn}),je({type:"op",names:["\\int","\\iint","\\iiint","\\oint","\\oiint","\\oiiint","\u222b","\u222c","\u222d","\u222e","\u222f","\u2230"],props:{numArgs:0},handler(e){let{parser:t,funcName:r}=e,n=r;return 1===n.length&&(n=nn[n]),{type:"op",mode:t.mode,limits:!1,parentIsSupSub:!1,symbol:!0,name:n}},htmlBuilder:en,mathmlBuilder:tn});const on=(e,t)=>{let r,n,o,s,i=!1;if("supsub"===e.type?(r=e.sup,n=e.sub,o=qt(e.base,"operatorname"),i=!0):o=qt(e,"operatorname"),o.body.length>0){const e=o.body.map((e=>{const t=e.text;return"string"==typeof t?{type:"textord",mode:e.mode,text:t}:e})),r=nt(e,t.withFont("mathrm"),!0);for(let e=0;e{let{parser:r,funcName:n}=e;const o=t[0];return{type:"operatorname",mode:r.mode,body:Ke(o),alwaysHandleSupSub:"\\operatornamewithlimits"===n,limits:!1,parentIsSupSub:!1}},htmlBuilder:on,mathmlBuilder:(e,t)=>{let r=xt(e.body,t.withFont("mathrm")),n=!0;for(let e=0;ee.toText())).join("");r=[new gt.TextNode(e)]}const o=new gt.MathNode("mi",r);o.setAttribute("mathvariant","normal");const s=new gt.MathNode("mo",[ft("\u2061","text")]);return e.parentIsSupSub?new gt.MathNode("mrow",[o,s]):gt.newDocumentFragment([o,s])}}),Br("\\operatorname","\\@ifstar\\operatornamewithlimits\\operatorname@"),$e({type:"ordgroup",htmlBuilder(e,t){return e.semisimple?Ve.makeFragment(nt(e.body,t,!1)):Ve.makeSpan(["mord"],nt(e.body,t,!0),t)},mathmlBuilder(e,t){return wt(e.body,t,!0)}}),je({type:"overline",names:["\\overline"],props:{numArgs:1},handler(e,t){let{parser:r}=e;const n=t[0];return{type:"overline",mode:r.mode,body:n}},htmlBuilder(e,t){const r=ht(e.body,t.havingCrampedStyle()),n=Ve.makeLineSpan("overline-line",t),o=t.fontMetrics().defaultRuleThickness,s=Ve.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r},{type:"kern",size:3*o},{type:"elem",elem:n},{type:"kern",size:o}]},t);return Ve.makeSpan(["mord","overline"],[s],t)},mathmlBuilder(e,t){const r=new gt.MathNode("mo",[new gt.TextNode("\u203e")]);r.setAttribute("stretchy","true");const n=new gt.MathNode("mover",[vt(e.body,t),r]);return n.setAttribute("accent","true"),n}}),je({type:"phantom",names:["\\phantom"],props:{numArgs:1,allowedInText:!0},handler:(e,t)=>{let{parser:r}=e;const n=t[0];return{type:"phantom",mode:r.mode,body:Ke(n)}},htmlBuilder:(e,t)=>{const r=nt(e.body,t.withPhantom(),!1);return Ve.makeFragment(r)},mathmlBuilder:(e,t)=>{const r=xt(e.body,t);return new gt.MathNode("mphantom",r)}}),je({type:"hphantom",names:["\\hphantom"],props:{numArgs:1,allowedInText:!0},handler:(e,t)=>{let{parser:r}=e;const n=t[0];return{type:"hphantom",mode:r.mode,body:n}},htmlBuilder:(e,t)=>{let r=Ve.makeSpan([],[ht(e.body,t.withPhantom())]);if(r.height=0,r.depth=0,r.children)for(let e=0;e{const r=xt(Ke(e.body),t),n=new gt.MathNode("mphantom",r),o=new gt.MathNode("mpadded",[n]);return o.setAttribute("height","0px"),o.setAttribute("depth","0px"),o}}),je({type:"vphantom",names:["\\vphantom"],props:{numArgs:1,allowedInText:!0},handler:(e,t)=>{let{parser:r}=e;const n=t[0];return{type:"vphantom",mode:r.mode,body:n}},htmlBuilder:(e,t)=>{const r=Ve.makeSpan(["inner"],[ht(e.body,t.withPhantom())]),n=Ve.makeSpan(["fix"],[]);return Ve.makeSpan(["mord","rlap"],[r,n],t)},mathmlBuilder:(e,t)=>{const r=xt(Ke(e.body),t),n=new gt.MathNode("mphantom",r),o=new gt.MathNode("mpadded",[n]);return o.setAttribute("width","0px"),o}}),je({type:"raisebox",names:["\\raisebox"],props:{numArgs:2,argTypes:["size","hbox"],allowedInText:!0},handler(e,t){let{parser:r}=e;const n=qt(t[0],"size").value,o=t[1];return{type:"raisebox",mode:r.mode,dy:n,body:o}},htmlBuilder(e,t){const r=ht(e.body,t),n=P(e.dy,t);return Ve.makeVList({positionType:"shift",positionData:-n,children:[{type:"elem",elem:r}]},t)},mathmlBuilder(e,t){const r=new gt.MathNode("mpadded",[vt(e.body,t)]),n=e.dy.number+e.dy.unit;return r.setAttribute("voffset",n),r}}),je({type:"internal",names:["\\relax"],props:{numArgs:0,allowedInText:!0},handler(e){let{parser:t}=e;return{type:"internal",mode:t.mode}}}),je({type:"rule",names:["\\rule"],props:{numArgs:2,numOptionalArgs:1,argTypes:["size","size","size"]},handler(e,t,r){let{parser:n}=e;const o=r[0],s=qt(t[0],"size"),i=qt(t[1],"size");return{type:"rule",mode:n.mode,shift:o&&qt(o,"size").value,width:s.value,height:i.value}},htmlBuilder(e,t){const r=Ve.makeSpan(["mord","rule"],[],t),n=P(e.width,t),o=P(e.height,t),s=e.shift?P(e.shift,t):0;return r.style.borderRightWidth=F(n),r.style.borderTopWidth=F(o),r.style.bottom=F(s),r.width=n,r.height=o+s,r.depth=-s,r.maxFontSize=1.125*o*t.sizeMultiplier,r},mathmlBuilder(e,t){const r=P(e.width,t),n=P(e.height,t),o=e.shift?P(e.shift,t):0,s=t.color&&t.getColor()||"black",i=new gt.MathNode("mspace");i.setAttribute("mathbackground",s),i.setAttribute("width",F(r)),i.setAttribute("height",F(n));const a=new gt.MathNode("mpadded",[i]);return o>=0?a.setAttribute("height",F(o)):(a.setAttribute("height",F(o)),a.setAttribute("depth",F(-o))),a.setAttribute("voffset",F(o)),a}});const an=["\\tiny","\\sixptsize","\\scriptsize","\\footnotesize","\\small","\\normalsize","\\large","\\Large","\\LARGE","\\huge","\\Huge"];je({type:"sizing",names:an,props:{numArgs:0,allowedInText:!0},handler:(e,t)=>{let{breakOnTokenText:r,funcName:n,parser:o}=e;const s=o.parseExpression(!1,r);return{type:"sizing",mode:o.mode,size:an.indexOf(n)+1,body:s}},htmlBuilder:(e,t)=>{const r=t.havingSize(e.size);return sn(e.body,r,t)},mathmlBuilder:(e,t)=>{const r=t.havingSize(e.size),n=xt(e.body,r),o=new gt.MathNode("mstyle",n);return o.setAttribute("mathsize",F(r.sizeMultiplier)),o}}),je({type:"smash",names:["\\smash"],props:{numArgs:1,numOptionalArgs:1,allowedInText:!0},handler:(e,t,r)=>{let{parser:n}=e,o=!1,s=!1;const i=r[0]&&qt(r[0],"ordgroup");if(i){let e="";for(let t=0;t{const r=Ve.makeSpan([],[ht(e.body,t)]);if(!e.smashHeight&&!e.smashDepth)return r;if(e.smashHeight&&(r.height=0,r.children))for(let e=0;e{const r=new gt.MathNode("mpadded",[vt(e.body,t)]);return e.smashHeight&&r.setAttribute("height","0px"),e.smashDepth&&r.setAttribute("depth","0px"),r}}),je({type:"sqrt",names:["\\sqrt"],props:{numArgs:1,numOptionalArgs:1},handler(e,t,r){let{parser:n}=e;const o=r[0],s=t[0];return{type:"sqrt",mode:n.mode,body:s,index:o}},htmlBuilder(e,t){let r=ht(e.body,t.havingCrampedStyle());0===r.height&&(r.height=t.fontMetrics().xHeight),r=Ve.wrapFragment(r,t);const n=t.fontMetrics().defaultRuleThickness;let o=n;t.style.idr.height+r.depth+s&&(s=(s+c-r.height-r.depth)/2);const m=a.height-r.height-s-l;r.style.paddingLeft=F(h);const p=Ve.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r,wrapperClasses:["svg-align"]},{type:"kern",size:-(r.height+m)},{type:"elem",elem:a},{type:"kern",size:l}]},t);if(e.index){const r=t.havingStyle(w.SCRIPTSCRIPT),n=ht(e.index,r,t),o=.6*(p.height-p.depth),s=Ve.makeVList({positionType:"shift",positionData:-o,children:[{type:"elem",elem:n}]},t),i=Ve.makeSpan(["root"],[s]);return Ve.makeSpan(["mord","sqrt"],[i,p],t)}return Ve.makeSpan(["mord","sqrt"],[p],t)},mathmlBuilder(e,t){const{body:r,index:n}=e;return n?new gt.MathNode("mroot",[vt(r,t),vt(n,t)]):new gt.MathNode("msqrt",[vt(r,t)])}});const ln={display:w.DISPLAY,text:w.TEXT,script:w.SCRIPT,scriptscript:w.SCRIPTSCRIPT};je({type:"styling",names:["\\displaystyle","\\textstyle","\\scriptstyle","\\scriptscriptstyle"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(e,t){let{breakOnTokenText:r,funcName:n,parser:o}=e;const s=o.parseExpression(!0,r),i=n.slice(1,n.length-5);return{type:"styling",mode:o.mode,style:i,body:s}},htmlBuilder(e,t){const r=ln[e.style],n=t.havingStyle(r).withFont("");return sn(e.body,n,t)},mathmlBuilder(e,t){const r=ln[e.style],n=t.havingStyle(r),o=xt(e.body,n),s=new gt.MathNode("mstyle",o),i={display:["0","true"],text:["0","false"],script:["1","false"],scriptscript:["2","false"]}[e.style];return s.setAttribute("scriptlevel",i[0]),s.setAttribute("displaystyle",i[1]),s}});$e({type:"supsub",htmlBuilder(e,t){const r=function(e,t){const r=e.base;if(r)return"op"===r.type?r.limits&&(t.style.size===w.DISPLAY.size||r.alwaysHandleSupSub)?en:null:"operatorname"===r.type?r.alwaysHandleSupSub&&(t.style.size===w.DISPLAY.size||r.limits)?on:null:"accent"===r.type?l.isCharacterBox(r.base)?Ht:null:"horizBrace"===r.type&&!e.sub===r.isOver?$r:null;return null}(e,t);if(r)return r(e,t);const{base:n,sup:o,sub:s}=e,i=ht(n,t);let a,h;const c=t.fontMetrics();let m=0,p=0;const u=n&&l.isCharacterBox(n);if(o){const e=t.havingStyle(t.style.sup());a=ht(o,e,t),u||(m=i.height-e.fontMetrics().supDrop*e.sizeMultiplier/t.sizeMultiplier)}if(s){const e=t.havingStyle(t.style.sub());h=ht(s,e,t),u||(p=i.depth+e.fontMetrics().subDrop*e.sizeMultiplier/t.sizeMultiplier)}let d;d=t.style===w.DISPLAY?c.sup1:t.style.cramped?c.sup3:c.sup2;const g=t.sizeMultiplier,f=F(.5/c.ptPerEm/g);let b,y=null;if(h){const t=e.base&&"op"===e.base.type&&e.base.name&&("\\oiint"===e.base.name||"\\oiiint"===e.base.name);(i instanceof Z||t)&&(y=F(-i.italic))}if(a&&h){m=Math.max(m,d,a.depth+.25*c.xHeight),p=Math.max(p,c.sub2);const e=4*c.defaultRuleThickness;if(m-a.depth-(h.height-p)0&&(m+=t,p-=t)}const r=[{type:"elem",elem:h,shift:p,marginRight:f,marginLeft:y},{type:"elem",elem:a,shift:-m,marginRight:f}];b=Ve.makeVList({positionType:"individualShift",children:r},t)}else if(h){p=Math.max(p,c.sub1,h.height-.8*c.xHeight);const e=[{type:"elem",elem:h,marginLeft:y,marginRight:f}];b=Ve.makeVList({positionType:"shift",positionData:p,children:e},t)}else{if(!a)throw new Error("supsub must have either sup or sub.");m=Math.max(m,d,a.depth+.25*c.xHeight),b=Ve.makeVList({positionType:"shift",positionData:-m,children:[{type:"elem",elem:a,marginRight:f}]},t)}const x=at(i,"right")||"mord";return Ve.makeSpan([x],[i,Ve.makeSpan(["msupsub"],[b])],t)},mathmlBuilder(e,t){let r,n,o=!1;e.base&&"horizBrace"===e.base.type&&(n=!!e.sup,n===e.base.isOver&&(o=!0,r=e.base.isOver)),!e.base||"op"!==e.base.type&&"operatorname"!==e.base.type||(e.base.parentIsSupSub=!0);const s=[vt(e.base,t)];let i;if(e.sub&&s.push(vt(e.sub,t)),e.sup&&s.push(vt(e.sup,t)),o)i=r?"mover":"munder";else if(e.sub)if(e.sup){const r=e.base;i=r&&"op"===r.type&&r.limits&&t.style===w.DISPLAY||r&&"operatorname"===r.type&&r.alwaysHandleSupSub&&(t.style===w.DISPLAY||r.limits)?"munderover":"msubsup"}else{const r=e.base;i=r&&"op"===r.type&&r.limits&&(t.style===w.DISPLAY||r.alwaysHandleSupSub)||r&&"operatorname"===r.type&&r.alwaysHandleSupSub&&(r.limits||t.style===w.DISPLAY)?"munder":"msub"}else{const r=e.base;i=r&&"op"===r.type&&r.limits&&(t.style===w.DISPLAY||r.alwaysHandleSupSub)||r&&"operatorname"===r.type&&r.alwaysHandleSupSub&&(r.limits||t.style===w.DISPLAY)?"mover":"msup"}return new gt.MathNode(i,s)}}),$e({type:"atom",htmlBuilder(e,t){return Ve.mathsym(e.text,e.mode,t,["m"+e.family])},mathmlBuilder(e,t){const r=new gt.MathNode("mo",[ft(e.text,e.mode)]);if("bin"===e.family){const n=yt(e,t);"bold-italic"===n&&r.setAttribute("mathvariant",n)}else"punct"===e.family?r.setAttribute("separator","true"):"open"!==e.family&&"close"!==e.family||r.setAttribute("stretchy","false");return r}});const hn={mi:"italic",mn:"normal",mtext:"normal"};$e({type:"mathord",htmlBuilder(e,t){return Ve.makeOrd(e,t,"mathord")},mathmlBuilder(e,t){const r=new gt.MathNode("mi",[ft(e.text,e.mode,t)]),n=yt(e,t)||"italic";return n!==hn[r.type]&&r.setAttribute("mathvariant",n),r}}),$e({type:"textord",htmlBuilder(e,t){return Ve.makeOrd(e,t,"textord")},mathmlBuilder(e,t){const r=ft(e.text,e.mode,t),n=yt(e,t)||"normal";let o;return o="text"===e.mode?new gt.MathNode("mtext",[r]):/[0-9]/.test(e.text)?new gt.MathNode("mn",[r]):"\\prime"===e.text?new gt.MathNode("mo",[r]):new gt.MathNode("mi",[r]),n!==hn[o.type]&&o.setAttribute("mathvariant",n),o}});const cn={"\\nobreak":"nobreak","\\allowbreak":"allowbreak"},mn={" ":{},"\\ ":{},"~":{className:"nobreak"},"\\space":{},"\\nobreakspace":{className:"nobreak"}};$e({type:"spacing",htmlBuilder(e,t){if(mn.hasOwnProperty(e.text)){const r=mn[e.text].className||"";if("text"===e.mode){const n=Ve.makeOrd(e,t,"textord");return n.classes.push(r),n}return Ve.makeSpan(["mspace",r],[Ve.mathsym(e.text,e.mode,t)],t)}if(cn.hasOwnProperty(e.text))return Ve.makeSpan(["mspace",cn[e.text]],[],t);throw new n('Unknown type of space "'+e.text+'"')},mathmlBuilder(e,t){let r;if(!mn.hasOwnProperty(e.text)){if(cn.hasOwnProperty(e.text))return new gt.MathNode("mspace");throw new n('Unknown type of space "'+e.text+'"')}return r=new gt.MathNode("mtext",[new gt.TextNode("\xa0")]),r}});const pn=()=>{const e=new gt.MathNode("mtd",[]);return e.setAttribute("width","50%"),e};$e({type:"tag",mathmlBuilder(e,t){const r=new gt.MathNode("mtable",[new gt.MathNode("mtr",[pn(),new gt.MathNode("mtd",[wt(e.body,t)]),pn(),new gt.MathNode("mtd",[wt(e.tag,t)])])]);return r.setAttribute("width","100%"),r}});const un={"\\text":void 0,"\\textrm":"textrm","\\textsf":"textsf","\\texttt":"texttt","\\textnormal":"textrm"},dn={"\\textbf":"textbf","\\textmd":"textmd"},gn={"\\textit":"textit","\\textup":"textup"},fn=(e,t)=>{const r=e.font;return r?un[r]?t.withTextFontFamily(un[r]):dn[r]?t.withTextFontWeight(dn[r]):"\\emph"===r?"textit"===t.fontShape?t.withTextFontShape("textup"):t.withTextFontShape("textit"):t.withTextFontShape(gn[r]):t};je({type:"text",names:["\\text","\\textrm","\\textsf","\\texttt","\\textnormal","\\textbf","\\textmd","\\textit","\\textup","\\emph"],props:{numArgs:1,argTypes:["text"],allowedInArgument:!0,allowedInText:!0},handler(e,t){let{parser:r,funcName:n}=e;const o=t[0];return{type:"text",mode:r.mode,body:Ke(o),font:n}},htmlBuilder(e,t){const r=fn(e,t),n=nt(e.body,r,!0);return Ve.makeSpan(["mord","text"],n,r)},mathmlBuilder(e,t){const r=fn(e,t);return wt(e.body,r)}}),je({type:"underline",names:["\\underline"],props:{numArgs:1,allowedInText:!0},handler(e,t){let{parser:r}=e;return{type:"underline",mode:r.mode,body:t[0]}},htmlBuilder(e,t){const r=ht(e.body,t),n=Ve.makeLineSpan("underline-line",t),o=t.fontMetrics().defaultRuleThickness,s=Ve.makeVList({positionType:"top",positionData:r.height,children:[{type:"kern",size:o},{type:"elem",elem:n},{type:"kern",size:3*o},{type:"elem",elem:r}]},t);return Ve.makeSpan(["mord","underline"],[s],t)},mathmlBuilder(e,t){const r=new gt.MathNode("mo",[new gt.TextNode("\u203e")]);r.setAttribute("stretchy","true");const n=new gt.MathNode("munder",[vt(e.body,t),r]);return n.setAttribute("accentunder","true"),n}}),je({type:"vcenter",names:["\\vcenter"],props:{numArgs:1,argTypes:["original"],allowedInText:!1},handler(e,t){let{parser:r}=e;return{type:"vcenter",mode:r.mode,body:t[0]}},htmlBuilder(e,t){const r=ht(e.body,t),n=t.fontMetrics().axisHeight,o=.5*(r.height-n-(r.depth+n));return Ve.makeVList({positionType:"shift",positionData:o,children:[{type:"elem",elem:r}]},t)},mathmlBuilder(e,t){return new gt.MathNode("mpadded",[vt(e.body,t)],["vcenter"])}}),je({type:"verb",names:["\\verb"],props:{numArgs:0,allowedInText:!0},handler(e,t,r){throw new n("\\verb ended by end of line instead of matching delimiter")},htmlBuilder(e,t){const r=bn(e),n=[],o=t.havingStyle(t.style.text());for(let t=0;te.body.replace(/ /g,e.star?"\u2423":"\xa0");var yn=Xe;const xn="[ \r\n\t]",wn="(\\\\[a-zA-Z@]+)"+xn+"*",vn="[\u0300-\u036f]",kn=new RegExp(vn+"+$"),Sn="("+xn+"+)|\\\\(\n|[ \r\t]+\n?)[ \r\t]*|([!-\\[\\]-\u2027\u202a-\ud7ff\uf900-\uffff]"+vn+"*|[\ud800-\udbff][\udc00-\udfff]"+vn+"*|\\\\verb\\*([^]).*?\\4|\\\\verb([^*a-zA-Z]).*?\\5|"+wn+"|\\\\[^\ud800-\udfff])";class Mn{constructor(e,t){this.input=void 0,this.settings=void 0,this.tokenRegex=void 0,this.catcodes=void 0,this.input=e,this.settings=t,this.tokenRegex=new RegExp(Sn,"g"),this.catcodes={"%":14,"~":13}}setCatcode(e,t){this.catcodes[e]=t}lex(){const e=this.input,t=this.tokenRegex.lastIndex;if(t===e.length)return new Nr("EOF",new Cr(this,t,t));const r=this.tokenRegex.exec(e);if(null===r||r.index!==t)throw new n("Unexpected character: '"+e[t]+"'",new Nr(e[t],new Cr(this,t,t+1)));const o=r[6]||r[3]||(r[2]?"\\ ":" ");if(14===this.catcodes[o]){const t=e.indexOf("\n",this.tokenRegex.lastIndex);return-1===t?(this.tokenRegex.lastIndex=e.length,this.settings.reportNonstrict("commentAtEnd","% comment has no terminating newline; LaTeX would fail because of commenting the end of math mode (e.g. $)")):this.tokenRegex.lastIndex=t+1,this.lex()}return new Nr(o,new Cr(this,t,this.tokenRegex.lastIndex))}}class zn{constructor(e,t){void 0===e&&(e={}),void 0===t&&(t={}),this.current=void 0,this.builtins=void 0,this.undefStack=void 0,this.current=t,this.builtins=e,this.undefStack=[]}beginGroup(){this.undefStack.push({})}endGroup(){if(0===this.undefStack.length)throw new n("Unbalanced namespace destruction: attempt to pop global namespace; please report this as a bug");const e=this.undefStack.pop();for(const t in e)e.hasOwnProperty(t)&&(null==e[t]?delete this.current[t]:this.current[t]=e[t])}endGroups(){for(;this.undefStack.length>0;)this.endGroup()}has(e){return this.current.hasOwnProperty(e)||this.builtins.hasOwnProperty(e)}get(e){return this.current.hasOwnProperty(e)?this.current[e]:this.builtins[e]}set(e,t,r){if(void 0===r&&(r=!1),r){for(let t=0;t0&&(this.undefStack[this.undefStack.length-1][e]=t)}else{const t=this.undefStack[this.undefStack.length-1];t&&!t.hasOwnProperty(e)&&(t[e]=this.current[e])}null==t?delete this.current[e]:this.current[e]=t}}var An=Tr;Br("\\noexpand",(function(e){const t=e.popToken();return e.isExpandable(t.text)&&(t.noexpand=!0,t.treatAsRelax=!0),{tokens:[t],numArgs:0}})),Br("\\expandafter",(function(e){const t=e.popToken();return e.expandOnce(!0),{tokens:[t],numArgs:0}})),Br("\\@firstoftwo",(function(e){return{tokens:e.consumeArgs(2)[0],numArgs:0}})),Br("\\@secondoftwo",(function(e){return{tokens:e.consumeArgs(2)[1],numArgs:0}})),Br("\\@ifnextchar",(function(e){const t=e.consumeArgs(3);e.consumeSpaces();const r=e.future();return 1===t[0].length&&t[0][0].text===r.text?{tokens:t[1],numArgs:0}:{tokens:t[2],numArgs:0}})),Br("\\@ifstar","\\@ifnextchar *{\\@firstoftwo{#1}}"),Br("\\TextOrMath",(function(e){const t=e.consumeArgs(2);return"text"===e.mode?{tokens:t[0],numArgs:0}:{tokens:t[1],numArgs:0}}));const Tn={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15};Br("\\char",(function(e){let t,r=e.popToken(),o="";if("'"===r.text)t=8,r=e.popToken();else if('"'===r.text)t=16,r=e.popToken();else if("`"===r.text)if(r=e.popToken(),"\\"===r.text[0])o=r.text.charCodeAt(1);else{if("EOF"===r.text)throw new n("\\char` missing argument");o=r.text.charCodeAt(0)}else t=10;if(t){if(o=Tn[r.text],null==o||o>=t)throw new n("Invalid base-"+t+" digit "+r.text);let s;for(;null!=(s=Tn[e.future().text])&&s{let o=e.consumeArg().tokens;if(1!==o.length)throw new n("\\newcommand's first argument must be a macro name");const s=o[0].text,i=e.isDefined(s);if(i&&!t)throw new n("\\newcommand{"+s+"} attempting to redefine "+s+"; use \\renewcommand");if(!i&&!r)throw new n("\\renewcommand{"+s+"} when command "+s+" does not yet exist; use \\newcommand");let a=0;if(o=e.consumeArg().tokens,1===o.length&&"["===o[0].text){let t="",r=e.expandNextToken();for(;"]"!==r.text&&"EOF"!==r.text;)t+=r.text,r=e.expandNextToken();if(!t.match(/^\s*[0-9]+\s*$/))throw new n("Invalid number of arguments: "+t);a=parseInt(t),o=e.consumeArg().tokens}return e.macros.set(s,{tokens:o,numArgs:a}),""};Br("\\newcommand",(e=>Bn(e,!1,!0))),Br("\\renewcommand",(e=>Bn(e,!0,!1))),Br("\\providecommand",(e=>Bn(e,!0,!0))),Br("\\message",(e=>{const t=e.consumeArgs(1)[0];return console.log(t.reverse().map((e=>e.text)).join("")),""})),Br("\\errmessage",(e=>{const t=e.consumeArgs(1)[0];return console.error(t.reverse().map((e=>e.text)).join("")),""})),Br("\\show",(e=>{const t=e.popToken(),r=t.text;return console.log(t,e.macros.get(r),yn[r],oe.math[r],oe.text[r]),""})),Br("\\bgroup","{"),Br("\\egroup","}"),Br("~","\\nobreakspace"),Br("\\lq","`"),Br("\\rq","'"),Br("\\aa","\\r a"),Br("\\AA","\\r A"),Br("\\textcopyright","\\html@mathml{\\textcircled{c}}{\\char`\xa9}"),Br("\\copyright","\\TextOrMath{\\textcopyright}{\\text{\\textcopyright}}"),Br("\\textregistered","\\html@mathml{\\textcircled{\\scriptsize R}}{\\char`\xae}"),Br("\u212c","\\mathscr{B}"),Br("\u2130","\\mathscr{E}"),Br("\u2131","\\mathscr{F}"),Br("\u210b","\\mathscr{H}"),Br("\u2110","\\mathscr{I}"),Br("\u2112","\\mathscr{L}"),Br("\u2133","\\mathscr{M}"),Br("\u211b","\\mathscr{R}"),Br("\u212d","\\mathfrak{C}"),Br("\u210c","\\mathfrak{H}"),Br("\u2128","\\mathfrak{Z}"),Br("\\Bbbk","\\Bbb{k}"),Br("\xb7","\\cdotp"),Br("\\llap","\\mathllap{\\textrm{#1}}"),Br("\\rlap","\\mathrlap{\\textrm{#1}}"),Br("\\clap","\\mathclap{\\textrm{#1}}"),Br("\\mathstrut","\\vphantom{(}"),Br("\\underbar","\\underline{\\text{#1}}"),Br("\\not",'\\html@mathml{\\mathrel{\\mathrlap\\@not}}{\\char"338}'),Br("\\neq","\\html@mathml{\\mathrel{\\not=}}{\\mathrel{\\char`\u2260}}"),Br("\\ne","\\neq"),Br("\u2260","\\neq"),Br("\\notin","\\html@mathml{\\mathrel{{\\in}\\mathllap{/\\mskip1mu}}}{\\mathrel{\\char`\u2209}}"),Br("\u2209","\\notin"),Br("\u2258","\\html@mathml{\\mathrel{=\\kern{-1em}\\raisebox{0.4em}{$\\scriptsize\\frown$}}}{\\mathrel{\\char`\u2258}}"),Br("\u2259","\\html@mathml{\\stackrel{\\tiny\\wedge}{=}}{\\mathrel{\\char`\u2258}}"),Br("\u225a","\\html@mathml{\\stackrel{\\tiny\\vee}{=}}{\\mathrel{\\char`\u225a}}"),Br("\u225b","\\html@mathml{\\stackrel{\\scriptsize\\star}{=}}{\\mathrel{\\char`\u225b}}"),Br("\u225d","\\html@mathml{\\stackrel{\\tiny\\mathrm{def}}{=}}{\\mathrel{\\char`\u225d}}"),Br("\u225e","\\html@mathml{\\stackrel{\\tiny\\mathrm{m}}{=}}{\\mathrel{\\char`\u225e}}"),Br("\u225f","\\html@mathml{\\stackrel{\\tiny?}{=}}{\\mathrel{\\char`\u225f}}"),Br("\u27c2","\\perp"),Br("\u203c","\\mathclose{!\\mkern-0.8mu!}"),Br("\u220c","\\notni"),Br("\u231c","\\ulcorner"),Br("\u231d","\\urcorner"),Br("\u231e","\\llcorner"),Br("\u231f","\\lrcorner"),Br("\xa9","\\copyright"),Br("\xae","\\textregistered"),Br("\ufe0f","\\textregistered"),Br("\\ulcorner",'\\html@mathml{\\@ulcorner}{\\mathop{\\char"231c}}'),Br("\\urcorner",'\\html@mathml{\\@urcorner}{\\mathop{\\char"231d}}'),Br("\\llcorner",'\\html@mathml{\\@llcorner}{\\mathop{\\char"231e}}'),Br("\\lrcorner",'\\html@mathml{\\@lrcorner}{\\mathop{\\char"231f}}'),Br("\\vdots","\\mathord{\\varvdots\\rule{0pt}{15pt}}"),Br("\u22ee","\\vdots"),Br("\\varGamma","\\mathit{\\Gamma}"),Br("\\varDelta","\\mathit{\\Delta}"),Br("\\varTheta","\\mathit{\\Theta}"),Br("\\varLambda","\\mathit{\\Lambda}"),Br("\\varXi","\\mathit{\\Xi}"),Br("\\varPi","\\mathit{\\Pi}"),Br("\\varSigma","\\mathit{\\Sigma}"),Br("\\varUpsilon","\\mathit{\\Upsilon}"),Br("\\varPhi","\\mathit{\\Phi}"),Br("\\varPsi","\\mathit{\\Psi}"),Br("\\varOmega","\\mathit{\\Omega}"),Br("\\substack","\\begin{subarray}{c}#1\\end{subarray}"),Br("\\colon","\\nobreak\\mskip2mu\\mathpunct{}\\mathchoice{\\mkern-3mu}{\\mkern-3mu}{}{}{:}\\mskip6mu\\relax"),Br("\\boxed","\\fbox{$\\displaystyle{#1}$}"),Br("\\iff","\\DOTSB\\;\\Longleftrightarrow\\;"),Br("\\implies","\\DOTSB\\;\\Longrightarrow\\;"),Br("\\impliedby","\\DOTSB\\;\\Longleftarrow\\;");const Cn={",":"\\dotsc","\\not":"\\dotsb","+":"\\dotsb","=":"\\dotsb","<":"\\dotsb",">":"\\dotsb","-":"\\dotsb","*":"\\dotsb",":":"\\dotsb","\\DOTSB":"\\dotsb","\\coprod":"\\dotsb","\\bigvee":"\\dotsb","\\bigwedge":"\\dotsb","\\biguplus":"\\dotsb","\\bigcap":"\\dotsb","\\bigcup":"\\dotsb","\\prod":"\\dotsb","\\sum":"\\dotsb","\\bigotimes":"\\dotsb","\\bigoplus":"\\dotsb","\\bigodot":"\\dotsb","\\bigsqcup":"\\dotsb","\\And":"\\dotsb","\\longrightarrow":"\\dotsb","\\Longrightarrow":"\\dotsb","\\longleftarrow":"\\dotsb","\\Longleftarrow":"\\dotsb","\\longleftrightarrow":"\\dotsb","\\Longleftrightarrow":"\\dotsb","\\mapsto":"\\dotsb","\\longmapsto":"\\dotsb","\\hookrightarrow":"\\dotsb","\\doteq":"\\dotsb","\\mathbin":"\\dotsb","\\mathrel":"\\dotsb","\\relbar":"\\dotsb","\\Relbar":"\\dotsb","\\xrightarrow":"\\dotsb","\\xleftarrow":"\\dotsb","\\DOTSI":"\\dotsi","\\int":"\\dotsi","\\oint":"\\dotsi","\\iint":"\\dotsi","\\iiint":"\\dotsi","\\iiiint":"\\dotsi","\\idotsint":"\\dotsi","\\DOTSX":"\\dotsx"};Br("\\dots",(function(e){let t="\\dotso";const r=e.expandAfterFuture().text;return r in Cn?t=Cn[r]:("\\not"===r.slice(0,4)||r in oe.math&&l.contains(["bin","rel"],oe.math[r].group))&&(t="\\dotsb"),t}));const Nn={")":!0,"]":!0,"\\rbrack":!0,"\\}":!0,"\\rbrace":!0,"\\rangle":!0,"\\rceil":!0,"\\rfloor":!0,"\\rgroup":!0,"\\rmoustache":!0,"\\right":!0,"\\bigr":!0,"\\biggr":!0,"\\Bigr":!0,"\\Biggr":!0,$:!0,";":!0,".":!0,",":!0};Br("\\dotso",(function(e){return e.future().text in Nn?"\\ldots\\,":"\\ldots"})),Br("\\dotsc",(function(e){const t=e.future().text;return t in Nn&&","!==t?"\\ldots\\,":"\\ldots"})),Br("\\cdots",(function(e){return e.future().text in Nn?"\\@cdots\\,":"\\@cdots"})),Br("\\dotsb","\\cdots"),Br("\\dotsm","\\cdots"),Br("\\dotsi","\\!\\cdots"),Br("\\dotsx","\\ldots\\,"),Br("\\DOTSI","\\relax"),Br("\\DOTSB","\\relax"),Br("\\DOTSX","\\relax"),Br("\\tmspace","\\TextOrMath{\\kern#1#3}{\\mskip#1#2}\\relax"),Br("\\,","\\tmspace+{3mu}{.1667em}"),Br("\\thinspace","\\,"),Br("\\>","\\mskip{4mu}"),Br("\\:","\\tmspace+{4mu}{.2222em}"),Br("\\medspace","\\:"),Br("\\;","\\tmspace+{5mu}{.2777em}"),Br("\\thickspace","\\;"),Br("\\!","\\tmspace-{3mu}{.1667em}"),Br("\\negthinspace","\\!"),Br("\\negmedspace","\\tmspace-{4mu}{.2222em}"),Br("\\negthickspace","\\tmspace-{5mu}{.277em}"),Br("\\enspace","\\kern.5em "),Br("\\enskip","\\hskip.5em\\relax"),Br("\\quad","\\hskip1em\\relax"),Br("\\qquad","\\hskip2em\\relax"),Br("\\tag","\\@ifstar\\tag@literal\\tag@paren"),Br("\\tag@paren","\\tag@literal{({#1})}"),Br("\\tag@literal",(e=>{if(e.macros.get("\\df@tag"))throw new n("Multiple \\tag");return"\\gdef\\df@tag{\\text{#1}}"})),Br("\\bmod","\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}\\mathbin{\\rm mod}\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}"),Br("\\pod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern8mu}{\\mkern8mu}{\\mkern8mu}(#1)"),Br("\\pmod","\\pod{{\\rm mod}\\mkern6mu#1}"),Br("\\mod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern12mu}{\\mkern12mu}{\\mkern12mu}{\\rm mod}\\,\\,#1"),Br("\\newline","\\\\\\relax"),Br("\\TeX","\\textrm{\\html@mathml{T\\kern-.1667em\\raisebox{-.5ex}{E}\\kern-.125emX}{TeX}}");const qn=F(T["Main-Regular"]["T".charCodeAt(0)][1]-.7*T["Main-Regular"]["A".charCodeAt(0)][1]);Br("\\LaTeX","\\textrm{\\html@mathml{L\\kern-.36em\\raisebox{"+qn+"}{\\scriptstyle A}\\kern-.15em\\TeX}{LaTeX}}"),Br("\\KaTeX","\\textrm{\\html@mathml{K\\kern-.17em\\raisebox{"+qn+"}{\\scriptstyle A}\\kern-.15em\\TeX}{KaTeX}}"),Br("\\hspace","\\@ifstar\\@hspacer\\@hspace"),Br("\\@hspace","\\hskip #1\\relax"),Br("\\@hspacer","\\rule{0pt}{0pt}\\hskip #1\\relax"),Br("\\ordinarycolon",":"),Br("\\vcentcolon","\\mathrel{\\mathop\\ordinarycolon}"),Br("\\dblcolon",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-.9mu}\\vcentcolon}}{\\mathop{\\char"2237}}'),Br("\\coloneqq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2254}}'),Br("\\Coloneqq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2237\\char"3d}}'),Br("\\coloneq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"3a\\char"2212}}'),Br("\\Coloneq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"2237\\char"2212}}'),Br("\\eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2255}}'),Br("\\Eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"3d\\char"2237}}'),Br("\\eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2239}}'),Br("\\Eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"2212\\char"2237}}'),Br("\\colonapprox",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"3a\\char"2248}}'),Br("\\Colonapprox",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"2237\\char"2248}}'),Br("\\colonsim",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"3a\\char"223c}}'),Br("\\Colonsim",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"2237\\char"223c}}'),Br("\u2237","\\dblcolon"),Br("\u2239","\\eqcolon"),Br("\u2254","\\coloneqq"),Br("\u2255","\\eqqcolon"),Br("\u2a74","\\Coloneqq"),Br("\\ratio","\\vcentcolon"),Br("\\coloncolon","\\dblcolon"),Br("\\colonequals","\\coloneqq"),Br("\\coloncolonequals","\\Coloneqq"),Br("\\equalscolon","\\eqqcolon"),Br("\\equalscoloncolon","\\Eqqcolon"),Br("\\colonminus","\\coloneq"),Br("\\coloncolonminus","\\Coloneq"),Br("\\minuscolon","\\eqcolon"),Br("\\minuscoloncolon","\\Eqcolon"),Br("\\coloncolonapprox","\\Colonapprox"),Br("\\coloncolonsim","\\Colonsim"),Br("\\simcolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\vcentcolon}"),Br("\\simcoloncolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\dblcolon}"),Br("\\approxcolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\vcentcolon}"),Br("\\approxcoloncolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\dblcolon}"),Br("\\notni","\\html@mathml{\\not\\ni}{\\mathrel{\\char`\u220c}}"),Br("\\limsup","\\DOTSB\\operatorname*{lim\\,sup}"),Br("\\liminf","\\DOTSB\\operatorname*{lim\\,inf}"),Br("\\injlim","\\DOTSB\\operatorname*{inj\\,lim}"),Br("\\projlim","\\DOTSB\\operatorname*{proj\\,lim}"),Br("\\varlimsup","\\DOTSB\\operatorname*{\\overline{lim}}"),Br("\\varliminf","\\DOTSB\\operatorname*{\\underline{lim}}"),Br("\\varinjlim","\\DOTSB\\operatorname*{\\underrightarrow{lim}}"),Br("\\varprojlim","\\DOTSB\\operatorname*{\\underleftarrow{lim}}"),Br("\\gvertneqq","\\html@mathml{\\@gvertneqq}{\u2269}"),Br("\\lvertneqq","\\html@mathml{\\@lvertneqq}{\u2268}"),Br("\\ngeqq","\\html@mathml{\\@ngeqq}{\u2271}"),Br("\\ngeqslant","\\html@mathml{\\@ngeqslant}{\u2271}"),Br("\\nleqq","\\html@mathml{\\@nleqq}{\u2270}"),Br("\\nleqslant","\\html@mathml{\\@nleqslant}{\u2270}"),Br("\\nshortmid","\\html@mathml{\\@nshortmid}{\u2224}"),Br("\\nshortparallel","\\html@mathml{\\@nshortparallel}{\u2226}"),Br("\\nsubseteqq","\\html@mathml{\\@nsubseteqq}{\u2288}"),Br("\\nsupseteqq","\\html@mathml{\\@nsupseteqq}{\u2289}"),Br("\\varsubsetneq","\\html@mathml{\\@varsubsetneq}{\u228a}"),Br("\\varsubsetneqq","\\html@mathml{\\@varsubsetneqq}{\u2acb}"),Br("\\varsupsetneq","\\html@mathml{\\@varsupsetneq}{\u228b}"),Br("\\varsupsetneqq","\\html@mathml{\\@varsupsetneqq}{\u2acc}"),Br("\\imath","\\html@mathml{\\@imath}{\u0131}"),Br("\\jmath","\\html@mathml{\\@jmath}{\u0237}"),Br("\\llbracket","\\html@mathml{\\mathopen{[\\mkern-3.2mu[}}{\\mathopen{\\char`\u27e6}}"),Br("\\rrbracket","\\html@mathml{\\mathclose{]\\mkern-3.2mu]}}{\\mathclose{\\char`\u27e7}}"),Br("\u27e6","\\llbracket"),Br("\u27e7","\\rrbracket"),Br("\\lBrace","\\html@mathml{\\mathopen{\\{\\mkern-3.2mu[}}{\\mathopen{\\char`\u2983}}"),Br("\\rBrace","\\html@mathml{\\mathclose{]\\mkern-3.2mu\\}}}{\\mathclose{\\char`\u2984}}"),Br("\u2983","\\lBrace"),Br("\u2984","\\rBrace"),Br("\\minuso","\\mathbin{\\html@mathml{{\\mathrlap{\\mathchoice{\\kern{0.145em}}{\\kern{0.145em}}{\\kern{0.1015em}}{\\kern{0.0725em}}\\circ}{-}}}{\\char`\u29b5}}"),Br("\u29b5","\\minuso"),Br("\\darr","\\downarrow"),Br("\\dArr","\\Downarrow"),Br("\\Darr","\\Downarrow"),Br("\\lang","\\langle"),Br("\\rang","\\rangle"),Br("\\uarr","\\uparrow"),Br("\\uArr","\\Uparrow"),Br("\\Uarr","\\Uparrow"),Br("\\N","\\mathbb{N}"),Br("\\R","\\mathbb{R}"),Br("\\Z","\\mathbb{Z}"),Br("\\alef","\\aleph"),Br("\\alefsym","\\aleph"),Br("\\Alpha","\\mathrm{A}"),Br("\\Beta","\\mathrm{B}"),Br("\\bull","\\bullet"),Br("\\Chi","\\mathrm{X}"),Br("\\clubs","\\clubsuit"),Br("\\cnums","\\mathbb{C}"),Br("\\Complex","\\mathbb{C}"),Br("\\Dagger","\\ddagger"),Br("\\diamonds","\\diamondsuit"),Br("\\empty","\\emptyset"),Br("\\Epsilon","\\mathrm{E}"),Br("\\Eta","\\mathrm{H}"),Br("\\exist","\\exists"),Br("\\harr","\\leftrightarrow"),Br("\\hArr","\\Leftrightarrow"),Br("\\Harr","\\Leftrightarrow"),Br("\\hearts","\\heartsuit"),Br("\\image","\\Im"),Br("\\infin","\\infty"),Br("\\Iota","\\mathrm{I}"),Br("\\isin","\\in"),Br("\\Kappa","\\mathrm{K}"),Br("\\larr","\\leftarrow"),Br("\\lArr","\\Leftarrow"),Br("\\Larr","\\Leftarrow"),Br("\\lrarr","\\leftrightarrow"),Br("\\lrArr","\\Leftrightarrow"),Br("\\Lrarr","\\Leftrightarrow"),Br("\\Mu","\\mathrm{M}"),Br("\\natnums","\\mathbb{N}"),Br("\\Nu","\\mathrm{N}"),Br("\\Omicron","\\mathrm{O}"),Br("\\plusmn","\\pm"),Br("\\rarr","\\rightarrow"),Br("\\rArr","\\Rightarrow"),Br("\\Rarr","\\Rightarrow"),Br("\\real","\\Re"),Br("\\reals","\\mathbb{R}"),Br("\\Reals","\\mathbb{R}"),Br("\\Rho","\\mathrm{P}"),Br("\\sdot","\\cdot"),Br("\\sect","\\S"),Br("\\spades","\\spadesuit"),Br("\\sub","\\subset"),Br("\\sube","\\subseteq"),Br("\\supe","\\supseteq"),Br("\\Tau","\\mathrm{T}"),Br("\\thetasym","\\vartheta"),Br("\\weierp","\\wp"),Br("\\Zeta","\\mathrm{Z}"),Br("\\argmin","\\DOTSB\\operatorname*{arg\\,min}"),Br("\\argmax","\\DOTSB\\operatorname*{arg\\,max}"),Br("\\plim","\\DOTSB\\mathop{\\operatorname{plim}}\\limits"),Br("\\bra","\\mathinner{\\langle{#1}|}"),Br("\\ket","\\mathinner{|{#1}\\rangle}"),Br("\\braket","\\mathinner{\\langle{#1}\\rangle}"),Br("\\Bra","\\left\\langle#1\\right|"),Br("\\Ket","\\left|#1\\right\\rangle");const In=e=>t=>{const r=t.consumeArg().tokens,n=t.consumeArg().tokens,o=t.consumeArg().tokens,s=t.consumeArg().tokens,i=t.macros.get("|"),a=t.macros.get("\\|");t.macros.beginGroup();const l=t=>r=>{e&&(r.macros.set("|",i),o.length&&r.macros.set("\\|",a));let s=t;if(!t&&o.length){"|"===r.future().text&&(r.popToken(),s=!0)}return{tokens:s?o:n,numArgs:0}};t.macros.set("|",l(!1)),o.length&&t.macros.set("\\|",l(!0));const h=t.consumeArg().tokens,c=t.expandTokens([...s,...h,...r]);return t.macros.endGroup(),{tokens:c.reverse(),numArgs:0}};Br("\\bra@ket",In(!1)),Br("\\bra@set",In(!0)),Br("\\Braket","\\bra@ket{\\left\\langle}{\\,\\middle\\vert\\,}{\\,\\middle\\vert\\,}{\\right\\rangle}"),Br("\\Set","\\bra@set{\\left\\{\\:}{\\;\\middle\\vert\\;}{\\;\\middle\\Vert\\;}{\\:\\right\\}}"),Br("\\set","\\bra@set{\\{\\,}{\\mid}{}{\\,\\}}"),Br("\\angln","{\\angl n}"),Br("\\blue","\\textcolor{##6495ed}{#1}"),Br("\\orange","\\textcolor{##ffa500}{#1}"),Br("\\pink","\\textcolor{##ff00af}{#1}"),Br("\\red","\\textcolor{##df0030}{#1}"),Br("\\green","\\textcolor{##28ae7b}{#1}"),Br("\\gray","\\textcolor{gray}{#1}"),Br("\\purple","\\textcolor{##9d38bd}{#1}"),Br("\\blueA","\\textcolor{##ccfaff}{#1}"),Br("\\blueB","\\textcolor{##80f6ff}{#1}"),Br("\\blueC","\\textcolor{##63d9ea}{#1}"),Br("\\blueD","\\textcolor{##11accd}{#1}"),Br("\\blueE","\\textcolor{##0c7f99}{#1}"),Br("\\tealA","\\textcolor{##94fff5}{#1}"),Br("\\tealB","\\textcolor{##26edd5}{#1}"),Br("\\tealC","\\textcolor{##01d1c1}{#1}"),Br("\\tealD","\\textcolor{##01a995}{#1}"),Br("\\tealE","\\textcolor{##208170}{#1}"),Br("\\greenA","\\textcolor{##b6ffb0}{#1}"),Br("\\greenB","\\textcolor{##8af281}{#1}"),Br("\\greenC","\\textcolor{##74cf70}{#1}"),Br("\\greenD","\\textcolor{##1fab54}{#1}"),Br("\\greenE","\\textcolor{##0d923f}{#1}"),Br("\\goldA","\\textcolor{##ffd0a9}{#1}"),Br("\\goldB","\\textcolor{##ffbb71}{#1}"),Br("\\goldC","\\textcolor{##ff9c39}{#1}"),Br("\\goldD","\\textcolor{##e07d10}{#1}"),Br("\\goldE","\\textcolor{##a75a05}{#1}"),Br("\\redA","\\textcolor{##fca9a9}{#1}"),Br("\\redB","\\textcolor{##ff8482}{#1}"),Br("\\redC","\\textcolor{##f9685d}{#1}"),Br("\\redD","\\textcolor{##e84d39}{#1}"),Br("\\redE","\\textcolor{##bc2612}{#1}"),Br("\\maroonA","\\textcolor{##ffbde0}{#1}"),Br("\\maroonB","\\textcolor{##ff92c6}{#1}"),Br("\\maroonC","\\textcolor{##ed5fa6}{#1}"),Br("\\maroonD","\\textcolor{##ca337c}{#1}"),Br("\\maroonE","\\textcolor{##9e034e}{#1}"),Br("\\purpleA","\\textcolor{##ddd7ff}{#1}"),Br("\\purpleB","\\textcolor{##c6b9fc}{#1}"),Br("\\purpleC","\\textcolor{##aa87ff}{#1}"),Br("\\purpleD","\\textcolor{##7854ab}{#1}"),Br("\\purpleE","\\textcolor{##543b78}{#1}"),Br("\\mintA","\\textcolor{##f5f9e8}{#1}"),Br("\\mintB","\\textcolor{##edf2df}{#1}"),Br("\\mintC","\\textcolor{##e0e5cc}{#1}"),Br("\\grayA","\\textcolor{##f6f7f7}{#1}"),Br("\\grayB","\\textcolor{##f0f1f2}{#1}"),Br("\\grayC","\\textcolor{##e3e5e6}{#1}"),Br("\\grayD","\\textcolor{##d6d8da}{#1}"),Br("\\grayE","\\textcolor{##babec2}{#1}"),Br("\\grayF","\\textcolor{##888d93}{#1}"),Br("\\grayG","\\textcolor{##626569}{#1}"),Br("\\grayH","\\textcolor{##3b3e40}{#1}"),Br("\\grayI","\\textcolor{##21242c}{#1}"),Br("\\kaBlue","\\textcolor{##314453}{#1}"),Br("\\kaGreen","\\textcolor{##71B307}{#1}");const Rn={"^":!0,_:!0,"\\limits":!0,"\\nolimits":!0};class Hn{constructor(e,t,r){this.settings=void 0,this.expansionCount=void 0,this.lexer=void 0,this.macros=void 0,this.stack=void 0,this.mode=void 0,this.settings=t,this.expansionCount=0,this.feed(e),this.macros=new zn(An,t.macros),this.mode=r,this.stack=[]}feed(e){this.lexer=new Mn(e,this.settings)}switchMode(e){this.mode=e}beginGroup(){this.macros.beginGroup()}endGroup(){this.macros.endGroup()}endGroups(){this.macros.endGroups()}future(){return 0===this.stack.length&&this.pushToken(this.lexer.lex()),this.stack[this.stack.length-1]}popToken(){return this.future(),this.stack.pop()}pushToken(e){this.stack.push(e)}pushTokens(e){this.stack.push(...e)}scanArgument(e){let t,r,n;if(e){if(this.consumeSpaces(),"["!==this.future().text)return null;t=this.popToken(),({tokens:n,end:r}=this.consumeArg(["]"]))}else({tokens:n,start:t,end:r}=this.consumeArg());return this.pushToken(new Nr("EOF",r.loc)),this.pushTokens(n),t.range(r,"")}consumeSpaces(){for(;;){if(" "!==this.future().text)break;this.stack.pop()}}consumeArg(e){const t=[],r=e&&e.length>0;r||this.consumeSpaces();const o=this.future();let s,i=0,a=0;do{if(s=this.popToken(),t.push(s),"{"===s.text)++i;else if("}"===s.text){if(--i,-1===i)throw new n("Extra }",s)}else if("EOF"===s.text)throw new n("Unexpected end of input in a macro argument, expected '"+(e&&r?e[a]:"}")+"'",s);if(e&&r)if((0===i||1===i&&"{"===e[a])&&s.text===e[a]){if(++a,a===e.length){t.splice(-a,a);break}}else a=0}while(0!==i||r);return"{"===o.text&&"}"===t[t.length-1].text&&(t.pop(),t.shift()),t.reverse(),{tokens:t,start:o,end:s}}consumeArgs(e,t){if(t){if(t.length!==e+1)throw new n("The length of delimiters doesn't match the number of args!");const r=t[0];for(let e=0;ethis.settings.maxExpand)throw new n("Too many expansions: infinite loop or need to increase maxExpand setting")}expandOnce(e){const t=this.popToken(),r=t.text,o=t.noexpand?null:this._getExpansion(r);if(null==o||e&&o.unexpandable){if(e&&null==o&&"\\"===r[0]&&!this.isDefined(r))throw new n("Undefined control sequence: "+r);return this.pushToken(t),!1}this.countExpansion(1);let s=o.tokens;const i=this.consumeArgs(o.numArgs,o.delimiters);if(o.numArgs){s=s.slice();for(let e=s.length-1;e>=0;--e){let t=s[e];if("#"===t.text){if(0===e)throw new n("Incomplete placeholder at end of macro body",t);if(t=s[--e],"#"===t.text)s.splice(e+1,1);else{if(!/^[1-9]$/.test(t.text))throw new n("Not a valid argument number",t);s.splice(e,2,...i[+t.text-1])}}}}return this.pushTokens(s),s.length}expandAfterFuture(){return this.expandOnce(),this.future()}expandNextToken(){for(;;)if(!1===this.expandOnce()){const e=this.stack.pop();return e.treatAsRelax&&(e.text="\\relax"),e}throw new Error}expandMacro(e){return this.macros.has(e)?this.expandTokens([new Nr(e)]):void 0}expandTokens(e){const t=[],r=this.stack.length;for(this.pushTokens(e);this.stack.length>r;)if(!1===this.expandOnce(!0)){const e=this.stack.pop();e.treatAsRelax&&(e.noexpand=!1,e.treatAsRelax=!1),t.push(e)}return this.countExpansion(t.length),t}expandMacroAsText(e){const t=this.expandMacro(e);return t?t.map((e=>e.text)).join(""):t}_getExpansion(e){const t=this.macros.get(e);if(null==t)return t;if(1===e.length){const t=this.lexer.catcodes[e];if(null!=t&&13!==t)return}const r="function"==typeof t?t(this):t;if("string"==typeof r){let e=0;if(-1!==r.indexOf("#")){const t=r.replace(/##/g,"");for(;-1!==t.indexOf("#"+(e+1));)++e}const t=new Mn(r,this.settings),n=[];let o=t.lex();for(;"EOF"!==o.text;)n.push(o),o=t.lex();n.reverse();return{tokens:n,numArgs:e}}return r}isDefined(e){return this.macros.has(e)||yn.hasOwnProperty(e)||oe.math.hasOwnProperty(e)||oe.text.hasOwnProperty(e)||Rn.hasOwnProperty(e)}isExpandable(e){const t=this.macros.get(e);return null!=t?"string"==typeof t||"function"==typeof t||!t.unexpandable:yn.hasOwnProperty(e)&&!yn[e].primitive}}const On=/^[\u208a\u208b\u208c\u208d\u208e\u2080\u2081\u2082\u2083\u2084\u2085\u2086\u2087\u2088\u2089\u2090\u2091\u2095\u1d62\u2c7c\u2096\u2097\u2098\u2099\u2092\u209a\u1d63\u209b\u209c\u1d64\u1d65\u2093\u1d66\u1d67\u1d68\u1d69\u1d6a]/,En=Object.freeze({"\u208a":"+","\u208b":"-","\u208c":"=","\u208d":"(","\u208e":")","\u2080":"0","\u2081":"1","\u2082":"2","\u2083":"3","\u2084":"4","\u2085":"5","\u2086":"6","\u2087":"7","\u2088":"8","\u2089":"9","\u2090":"a","\u2091":"e","\u2095":"h","\u1d62":"i","\u2c7c":"j","\u2096":"k","\u2097":"l","\u2098":"m","\u2099":"n","\u2092":"o","\u209a":"p","\u1d63":"r","\u209b":"s","\u209c":"t","\u1d64":"u","\u1d65":"v","\u2093":"x","\u1d66":"\u03b2","\u1d67":"\u03b3","\u1d68":"\u03c1","\u1d69":"\u03d5","\u1d6a":"\u03c7","\u207a":"+","\u207b":"-","\u207c":"=","\u207d":"(","\u207e":")","\u2070":"0","\xb9":"1","\xb2":"2","\xb3":"3","\u2074":"4","\u2075":"5","\u2076":"6","\u2077":"7","\u2078":"8","\u2079":"9","\u1d2c":"A","\u1d2e":"B","\u1d30":"D","\u1d31":"E","\u1d33":"G","\u1d34":"H","\u1d35":"I","\u1d36":"J","\u1d37":"K","\u1d38":"L","\u1d39":"M","\u1d3a":"N","\u1d3c":"O","\u1d3e":"P","\u1d3f":"R","\u1d40":"T","\u1d41":"U","\u2c7d":"V","\u1d42":"W","\u1d43":"a","\u1d47":"b","\u1d9c":"c","\u1d48":"d","\u1d49":"e","\u1da0":"f","\u1d4d":"g","\u02b0":"h","\u2071":"i","\u02b2":"j","\u1d4f":"k","\u02e1":"l","\u1d50":"m","\u207f":"n","\u1d52":"o","\u1d56":"p","\u02b3":"r","\u02e2":"s","\u1d57":"t","\u1d58":"u","\u1d5b":"v","\u02b7":"w","\u02e3":"x","\u02b8":"y","\u1dbb":"z","\u1d5d":"\u03b2","\u1d5e":"\u03b3","\u1d5f":"\u03b4","\u1d60":"\u03d5","\u1d61":"\u03c7","\u1dbf":"\u03b8"}),Ln={"\u0301":{text:"\\'",math:"\\acute"},"\u0300":{text:"\\`",math:"\\grave"},"\u0308":{text:'\\"',math:"\\ddot"},"\u0303":{text:"\\~",math:"\\tilde"},"\u0304":{text:"\\=",math:"\\bar"},"\u0306":{text:"\\u",math:"\\breve"},"\u030c":{text:"\\v",math:"\\check"},"\u0302":{text:"\\^",math:"\\hat"},"\u0307":{text:"\\.",math:"\\dot"},"\u030a":{text:"\\r",math:"\\mathring"},"\u030b":{text:"\\H"},"\u0327":{text:"\\c"}},Dn={"\xe1":"a\u0301","\xe0":"a\u0300","\xe4":"a\u0308","\u01df":"a\u0308\u0304","\xe3":"a\u0303","\u0101":"a\u0304","\u0103":"a\u0306","\u1eaf":"a\u0306\u0301","\u1eb1":"a\u0306\u0300","\u1eb5":"a\u0306\u0303","\u01ce":"a\u030c","\xe2":"a\u0302","\u1ea5":"a\u0302\u0301","\u1ea7":"a\u0302\u0300","\u1eab":"a\u0302\u0303","\u0227":"a\u0307","\u01e1":"a\u0307\u0304","\xe5":"a\u030a","\u01fb":"a\u030a\u0301","\u1e03":"b\u0307","\u0107":"c\u0301","\u1e09":"c\u0327\u0301","\u010d":"c\u030c","\u0109":"c\u0302","\u010b":"c\u0307","\xe7":"c\u0327","\u010f":"d\u030c","\u1e0b":"d\u0307","\u1e11":"d\u0327","\xe9":"e\u0301","\xe8":"e\u0300","\xeb":"e\u0308","\u1ebd":"e\u0303","\u0113":"e\u0304","\u1e17":"e\u0304\u0301","\u1e15":"e\u0304\u0300","\u0115":"e\u0306","\u1e1d":"e\u0327\u0306","\u011b":"e\u030c","\xea":"e\u0302","\u1ebf":"e\u0302\u0301","\u1ec1":"e\u0302\u0300","\u1ec5":"e\u0302\u0303","\u0117":"e\u0307","\u0229":"e\u0327","\u1e1f":"f\u0307","\u01f5":"g\u0301","\u1e21":"g\u0304","\u011f":"g\u0306","\u01e7":"g\u030c","\u011d":"g\u0302","\u0121":"g\u0307","\u0123":"g\u0327","\u1e27":"h\u0308","\u021f":"h\u030c","\u0125":"h\u0302","\u1e23":"h\u0307","\u1e29":"h\u0327","\xed":"i\u0301","\xec":"i\u0300","\xef":"i\u0308","\u1e2f":"i\u0308\u0301","\u0129":"i\u0303","\u012b":"i\u0304","\u012d":"i\u0306","\u01d0":"i\u030c","\xee":"i\u0302","\u01f0":"j\u030c","\u0135":"j\u0302","\u1e31":"k\u0301","\u01e9":"k\u030c","\u0137":"k\u0327","\u013a":"l\u0301","\u013e":"l\u030c","\u013c":"l\u0327","\u1e3f":"m\u0301","\u1e41":"m\u0307","\u0144":"n\u0301","\u01f9":"n\u0300","\xf1":"n\u0303","\u0148":"n\u030c","\u1e45":"n\u0307","\u0146":"n\u0327","\xf3":"o\u0301","\xf2":"o\u0300","\xf6":"o\u0308","\u022b":"o\u0308\u0304","\xf5":"o\u0303","\u1e4d":"o\u0303\u0301","\u1e4f":"o\u0303\u0308","\u022d":"o\u0303\u0304","\u014d":"o\u0304","\u1e53":"o\u0304\u0301","\u1e51":"o\u0304\u0300","\u014f":"o\u0306","\u01d2":"o\u030c","\xf4":"o\u0302","\u1ed1":"o\u0302\u0301","\u1ed3":"o\u0302\u0300","\u1ed7":"o\u0302\u0303","\u022f":"o\u0307","\u0231":"o\u0307\u0304","\u0151":"o\u030b","\u1e55":"p\u0301","\u1e57":"p\u0307","\u0155":"r\u0301","\u0159":"r\u030c","\u1e59":"r\u0307","\u0157":"r\u0327","\u015b":"s\u0301","\u1e65":"s\u0301\u0307","\u0161":"s\u030c","\u1e67":"s\u030c\u0307","\u015d":"s\u0302","\u1e61":"s\u0307","\u015f":"s\u0327","\u1e97":"t\u0308","\u0165":"t\u030c","\u1e6b":"t\u0307","\u0163":"t\u0327","\xfa":"u\u0301","\xf9":"u\u0300","\xfc":"u\u0308","\u01d8":"u\u0308\u0301","\u01dc":"u\u0308\u0300","\u01d6":"u\u0308\u0304","\u01da":"u\u0308\u030c","\u0169":"u\u0303","\u1e79":"u\u0303\u0301","\u016b":"u\u0304","\u1e7b":"u\u0304\u0308","\u016d":"u\u0306","\u01d4":"u\u030c","\xfb":"u\u0302","\u016f":"u\u030a","\u0171":"u\u030b","\u1e7d":"v\u0303","\u1e83":"w\u0301","\u1e81":"w\u0300","\u1e85":"w\u0308","\u0175":"w\u0302","\u1e87":"w\u0307","\u1e98":"w\u030a","\u1e8d":"x\u0308","\u1e8b":"x\u0307","\xfd":"y\u0301","\u1ef3":"y\u0300","\xff":"y\u0308","\u1ef9":"y\u0303","\u0233":"y\u0304","\u0177":"y\u0302","\u1e8f":"y\u0307","\u1e99":"y\u030a","\u017a":"z\u0301","\u017e":"z\u030c","\u1e91":"z\u0302","\u017c":"z\u0307","\xc1":"A\u0301","\xc0":"A\u0300","\xc4":"A\u0308","\u01de":"A\u0308\u0304","\xc3":"A\u0303","\u0100":"A\u0304","\u0102":"A\u0306","\u1eae":"A\u0306\u0301","\u1eb0":"A\u0306\u0300","\u1eb4":"A\u0306\u0303","\u01cd":"A\u030c","\xc2":"A\u0302","\u1ea4":"A\u0302\u0301","\u1ea6":"A\u0302\u0300","\u1eaa":"A\u0302\u0303","\u0226":"A\u0307","\u01e0":"A\u0307\u0304","\xc5":"A\u030a","\u01fa":"A\u030a\u0301","\u1e02":"B\u0307","\u0106":"C\u0301","\u1e08":"C\u0327\u0301","\u010c":"C\u030c","\u0108":"C\u0302","\u010a":"C\u0307","\xc7":"C\u0327","\u010e":"D\u030c","\u1e0a":"D\u0307","\u1e10":"D\u0327","\xc9":"E\u0301","\xc8":"E\u0300","\xcb":"E\u0308","\u1ebc":"E\u0303","\u0112":"E\u0304","\u1e16":"E\u0304\u0301","\u1e14":"E\u0304\u0300","\u0114":"E\u0306","\u1e1c":"E\u0327\u0306","\u011a":"E\u030c","\xca":"E\u0302","\u1ebe":"E\u0302\u0301","\u1ec0":"E\u0302\u0300","\u1ec4":"E\u0302\u0303","\u0116":"E\u0307","\u0228":"E\u0327","\u1e1e":"F\u0307","\u01f4":"G\u0301","\u1e20":"G\u0304","\u011e":"G\u0306","\u01e6":"G\u030c","\u011c":"G\u0302","\u0120":"G\u0307","\u0122":"G\u0327","\u1e26":"H\u0308","\u021e":"H\u030c","\u0124":"H\u0302","\u1e22":"H\u0307","\u1e28":"H\u0327","\xcd":"I\u0301","\xcc":"I\u0300","\xcf":"I\u0308","\u1e2e":"I\u0308\u0301","\u0128":"I\u0303","\u012a":"I\u0304","\u012c":"I\u0306","\u01cf":"I\u030c","\xce":"I\u0302","\u0130":"I\u0307","\u0134":"J\u0302","\u1e30":"K\u0301","\u01e8":"K\u030c","\u0136":"K\u0327","\u0139":"L\u0301","\u013d":"L\u030c","\u013b":"L\u0327","\u1e3e":"M\u0301","\u1e40":"M\u0307","\u0143":"N\u0301","\u01f8":"N\u0300","\xd1":"N\u0303","\u0147":"N\u030c","\u1e44":"N\u0307","\u0145":"N\u0327","\xd3":"O\u0301","\xd2":"O\u0300","\xd6":"O\u0308","\u022a":"O\u0308\u0304","\xd5":"O\u0303","\u1e4c":"O\u0303\u0301","\u1e4e":"O\u0303\u0308","\u022c":"O\u0303\u0304","\u014c":"O\u0304","\u1e52":"O\u0304\u0301","\u1e50":"O\u0304\u0300","\u014e":"O\u0306","\u01d1":"O\u030c","\xd4":"O\u0302","\u1ed0":"O\u0302\u0301","\u1ed2":"O\u0302\u0300","\u1ed6":"O\u0302\u0303","\u022e":"O\u0307","\u0230":"O\u0307\u0304","\u0150":"O\u030b","\u1e54":"P\u0301","\u1e56":"P\u0307","\u0154":"R\u0301","\u0158":"R\u030c","\u1e58":"R\u0307","\u0156":"R\u0327","\u015a":"S\u0301","\u1e64":"S\u0301\u0307","\u0160":"S\u030c","\u1e66":"S\u030c\u0307","\u015c":"S\u0302","\u1e60":"S\u0307","\u015e":"S\u0327","\u0164":"T\u030c","\u1e6a":"T\u0307","\u0162":"T\u0327","\xda":"U\u0301","\xd9":"U\u0300","\xdc":"U\u0308","\u01d7":"U\u0308\u0301","\u01db":"U\u0308\u0300","\u01d5":"U\u0308\u0304","\u01d9":"U\u0308\u030c","\u0168":"U\u0303","\u1e78":"U\u0303\u0301","\u016a":"U\u0304","\u1e7a":"U\u0304\u0308","\u016c":"U\u0306","\u01d3":"U\u030c","\xdb":"U\u0302","\u016e":"U\u030a","\u0170":"U\u030b","\u1e7c":"V\u0303","\u1e82":"W\u0301","\u1e80":"W\u0300","\u1e84":"W\u0308","\u0174":"W\u0302","\u1e86":"W\u0307","\u1e8c":"X\u0308","\u1e8a":"X\u0307","\xdd":"Y\u0301","\u1ef2":"Y\u0300","\u0178":"Y\u0308","\u1ef8":"Y\u0303","\u0232":"Y\u0304","\u0176":"Y\u0302","\u1e8e":"Y\u0307","\u0179":"Z\u0301","\u017d":"Z\u030c","\u1e90":"Z\u0302","\u017b":"Z\u0307","\u03ac":"\u03b1\u0301","\u1f70":"\u03b1\u0300","\u1fb1":"\u03b1\u0304","\u1fb0":"\u03b1\u0306","\u03ad":"\u03b5\u0301","\u1f72":"\u03b5\u0300","\u03ae":"\u03b7\u0301","\u1f74":"\u03b7\u0300","\u03af":"\u03b9\u0301","\u1f76":"\u03b9\u0300","\u03ca":"\u03b9\u0308","\u0390":"\u03b9\u0308\u0301","\u1fd2":"\u03b9\u0308\u0300","\u1fd1":"\u03b9\u0304","\u1fd0":"\u03b9\u0306","\u03cc":"\u03bf\u0301","\u1f78":"\u03bf\u0300","\u03cd":"\u03c5\u0301","\u1f7a":"\u03c5\u0300","\u03cb":"\u03c5\u0308","\u03b0":"\u03c5\u0308\u0301","\u1fe2":"\u03c5\u0308\u0300","\u1fe1":"\u03c5\u0304","\u1fe0":"\u03c5\u0306","\u03ce":"\u03c9\u0301","\u1f7c":"\u03c9\u0300","\u038e":"\u03a5\u0301","\u1fea":"\u03a5\u0300","\u03ab":"\u03a5\u0308","\u1fe9":"\u03a5\u0304","\u1fe8":"\u03a5\u0306","\u038f":"\u03a9\u0301","\u1ffa":"\u03a9\u0300"};class Vn{constructor(e,t){this.mode=void 0,this.gullet=void 0,this.settings=void 0,this.leftrightDepth=void 0,this.nextToken=void 0,this.mode="math",this.gullet=new Hn(e,t,this.mode),this.settings=t,this.leftrightDepth=0}expect(e,t){if(void 0===t&&(t=!0),this.fetch().text!==e)throw new n("Expected '"+e+"', got '"+this.fetch().text+"'",this.fetch());t&&this.consume()}consume(){this.nextToken=null}fetch(){return null==this.nextToken&&(this.nextToken=this.gullet.expandNextToken()),this.nextToken}switchMode(e){this.mode=e,this.gullet.switchMode(e)}parse(){this.settings.globalGroup||this.gullet.beginGroup(),this.settings.colorIsTextColor&&this.gullet.macros.set("\\color","\\textcolor");try{const e=this.parseExpression(!1);return this.expect("EOF"),this.settings.globalGroup||this.gullet.endGroup(),e}finally{this.gullet.endGroups()}}subparse(e){const t=this.nextToken;this.consume(),this.gullet.pushToken(new Nr("}")),this.gullet.pushTokens(e);const r=this.parseExpression(!1);return this.expect("}"),this.nextToken=t,r}parseExpression(e,t){const r=[];for(;;){"math"===this.mode&&this.consumeSpaces();const n=this.fetch();if(-1!==Vn.endOfExpression.indexOf(n.text))break;if(t&&n.text===t)break;if(e&&yn[n.text]&&yn[n.text].infix)break;const o=this.parseAtom(t);if(!o)break;"internal"!==o.type&&r.push(o)}return"text"===this.mode&&this.formLigatures(r),this.handleInfixNodes(r)}handleInfixNodes(e){let t,r=-1;for(let o=0;o=0&&this.settings.reportNonstrict("unicodeTextInMathMode",'Latin-1/Unicode text character "'+t[0]+'" used in math mode',e);const r=oe[this.mode][t].group,n=Cr.range(e);let s;if(te.hasOwnProperty(r)){const e=r;s={type:"atom",mode:this.mode,family:e,loc:n,text:t}}else s={type:r,mode:this.mode,loc:n,text:t};o=s}else{if(!(t.charCodeAt(0)>=128))return null;this.settings.strict&&(S(t.charCodeAt(0))?"math"===this.mode&&this.settings.reportNonstrict("unicodeTextInMathMode",'Unicode text character "'+t[0]+'" used in math mode',e):this.settings.reportNonstrict("unknownSymbol",'Unrecognized Unicode character "'+t[0]+'" ('+t.charCodeAt(0)+")",e)),o={type:"textord",mode:"text",loc:Cr.range(e),text:t}}if(this.consume(),r)for(let t=0;te.length)&&(t=e.length);for(var u=0,n=new Array(t);u=e.length?{done:!0}:{done:!1,value:e[u++]}};throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function e(){return{async:!1,baseUrl:null,breaks:!1,extensions:null,gfm:!0,headerIds:!0,headerPrefix:"",highlight:null,hooks:null,langPrefix:"language-",mangle:!0,pedantic:!1,renderer:null,sanitize:!1,sanitizer:null,silent:!1,smartypants:!1,tokenizer:null,walkTokens:null,xhtml:!1}}r.defaults=e();function u(e){return t[e]}var n=/[&<>"']/,l=new RegExp(n.source,"g"),o=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,a=new RegExp(o.source,"g"),t={"&":"&","<":"<",">":">",'"':""","'":"'"};function A(e,t){if(t){if(n.test(e))return e.replace(l,u)}else if(o.test(e))return e.replace(a,u);return e}var c=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function x(e){return e.replace(c,function(e,t){return"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""})}var h=/(^|[^\[])\^/g;function p(u,e){u="string"==typeof u?u:u.source,e=e||"";var n={replace:function(e,t){return t=(t=t.source||t).replace(h,"$1"),u=u.replace(e,t),n},getRegex:function(){return new RegExp(u,e)}};return n}var Z=/[^\w:]/g,O=/^$|^[a-z][a-z0-9+.-]*:|^[?#]/i;function f(e,t,u){if(e){try{n=decodeURIComponent(x(u)).replace(Z,"").toLowerCase()}catch(e){return null}if(0===n.indexOf("javascript:")||0===n.indexOf("vbscript:")||0===n.indexOf("data:"))return null}var n;t&&!O.test(u)&&(e=u,g[" "+(n=t)]||(q.test(n)?g[" "+n]=n+"/":g[" "+n]=C(n,"/",!0)),t=-1===(n=g[" "+n]).indexOf(":"),u="//"===e.substring(0,2)?t?e:n.replace(j,"$1")+e:"/"===e.charAt(0)?t?e:n.replace(P,"$1")+e:n+e);try{u=encodeURI(u).replace(/%25/g,"%")}catch(e){return null}return u}var g={},q=/^[^:]+:\/*[^/]*$/,j=/^([^:]+:)[\s\S]*$/,P=/^([^:]+:\/*[^/]*)[\s\S]*$/;var k={exec:function(){}};function d(e,t){var u=e.replace(/\|/g,function(e,t,u){for(var n=!1,r=t;0<=--r&&"\\"===u[r];)n=!n;return n?"|":" |"}).split(/ \|/),n=0;if(u[0].trim()||u.shift(),0t)u.splice(t);else for(;u.length>=1,e+=e;return u+e}function m(e,t,u,n){var r=t.href,t=t.title?A(t.title):null,i=e[1].replace(/\\([\[\]])/g,"$1");return"!"!==e[0].charAt(0)?(n.state.inLink=!0,e={type:"link",raw:u,href:r,title:t,text:i,tokens:n.inlineTokens(i)},n.state.inLink=!1,e):{type:"image",raw:u,href:r,title:t,text:A(i)}}var b=function(){function e(e){this.options=e||r.defaults}var t=e.prototype;return t.space=function(e){e=this.rules.block.newline.exec(e);if(e&&0=r.length?e.slice(r.length):e}).join("\n")),{type:"code",raw:t,lang:e[2]&&e[2].trim().replace(this.rules.inline._escapes,"$1"),text:u}},t.heading=function(e){var t,u,e=this.rules.block.heading.exec(e);if(e)return t=e[2].trim(),/#$/.test(t)&&(u=C(t,"#"),!this.options.pedantic&&u&&!/ $/.test(u)||(t=u.trim())),{type:"heading",raw:e[0],depth:e[1].length,text:t,tokens:this.lexer.inline(t)}},t.hr=function(e){e=this.rules.block.hr.exec(e);if(e)return{type:"hr",raw:e[0]}},t.blockquote=function(e){var t,u,n,e=this.rules.block.blockquote.exec(e);if(e)return t=e[0].replace(/^ *>[ \t]?/gm,""),u=this.lexer.state.top,this.lexer.state.top=!0,n=this.lexer.blockTokens(t),this.lexer.state.top=u,{type:"blockquote",raw:e[0],tokens:n,text:t}},t.list=function(e){var t=this.rules.block.list.exec(e);if(t){var u,n,r,i,s,l,o,a,D,c,h,p=1<(g=t[1].trim()).length,f={type:"list",raw:"",ordered:p,start:p?+g.slice(0,-1):"",loose:!1,items:[]},g=p?"\\d{1,9}\\"+g.slice(-1):"\\"+g;this.options.pedantic&&(g=p?g:"[*+-]");for(var F=new RegExp("^( {0,3}"+g+")((?:[\t ][^\\n]*)?(?:\\n|$))");e&&(h=!1,t=F.exec(e))&&!this.rules.block.hr.test(e);){if(u=t[0],e=e.substring(u.length),o=t[2].split("\n",1)[0].replace(/^\t+/,function(e){return" ".repeat(3*e.length)}),a=e.split("\n",1)[0],this.options.pedantic?(i=2,c=o.trimLeft()):(i=t[2].search(/[^ ]/),c=o.slice(i=4=i||!a.trim())c+="\n"+a.slice(i);else{if(s)break;if(4<=o.search(/[^ ]/))break;if(d.test(o))break;if(C.test(o))break;if(k.test(o))break;c+="\n"+a}s||a.trim()||(s=!0),u+=D+"\n",e=e.substring(D.length+1),o=a.slice(i)}f.loose||(l?f.loose=!0:/\n *\n *$/.test(u)&&(l=!0)),this.options.gfm&&(n=/^\[[ xX]\] /.exec(c))&&(r="[ ] "!==n[0],c=c.replace(/^\[[ xX]\] +/,"")),f.items.push({type:"list_item",raw:u,task:!!n,checked:r,loose:!1,text:c}),f.raw+=u}f.items[f.items.length-1].raw=u.trimRight(),f.items[f.items.length-1].text=c.trimRight(),f.raw=f.raw.trimRight();for(var E,x=f.items.length,m=0;m$/,"$1").replace(this.rules.inline._escapes,"$1"):"",n=e[3]&&e[3].substring(1,e[3].length-1).replace(this.rules.inline._escapes,"$1"),{type:"def",tag:t,raw:e[0],href:u,title:n}},t.table=function(e){e=this.rules.block.table.exec(e);if(e){var t={type:"table",header:d(e[1]).map(function(e){return{text:e}}),align:e[2].replace(/^ *|\| *$/g,"").split(/ *\| */),rows:e[3]&&e[3].trim()?e[3].replace(/\n[ \t]*$/,"").split("\n"):[]};if(t.header.length===t.align.length){t.raw=e[0];for(var u,n,r,i=t.align.length,s=0;s/i.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:this.options.sanitize?"text":"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,text:this.options.sanitize?this.options.sanitizer?this.options.sanitizer(e[0]):A(e[0]):e[0]}},t.link=function(e){e=this.rules.inline.link.exec(e);if(e){var t=e[2].trim();if(!this.options.pedantic&&/^$/.test(t))return;var u=C(t.slice(0,-1),"\\");if((t.length-u.length)%2==0)return}else{u=function(e,t){if(-1!==e.indexOf(t[1]))for(var u=e.length,n=0,r=0;r$/.test(t)?u.slice(1):u.slice(1,-1):u)&&u.replace(this.rules.inline._escapes,"$1"),title:r&&r.replace(this.rules.inline._escapes,"$1")},e[0],this.lexer)}},t.reflink=function(e,t){var u;if(u=(u=this.rules.inline.reflink.exec(e))||this.rules.inline.nolink.exec(e))return(e=t[(e=(u[2]||u[1]).replace(/\s+/g," ")).toLowerCase()])?m(u,e,u[0],this.lexer):{type:"text",raw:t=u[0].charAt(0),text:t}},t.emStrong=function(e,t,u){void 0===u&&(u="");var n=this.rules.inline.emStrong.lDelim.exec(e);if(n&&(!n[3]||!u.match(/(?:[0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])/))){var r=n[1]||n[2]||"";if(!r||""===u||this.rules.inline.punctuation.exec(u)){var i=n[0].length-1,s=i,l=0,o="*"===n[0][0]?this.rules.inline.emStrong.rDelimAst:this.rules.inline.emStrong.rDelimUnd;for(o.lastIndex=0,t=t.slice(-1*e.length+i);null!=(n=o.exec(t));){var a,D=n[1]||n[2]||n[3]||n[4]||n[5]||n[6];if(D)if(a=D.length,n[3]||n[4])s+=a;else if((n[5]||n[6])&&i%3&&!((i+a)%3))l+=a;else if(!(0<(s-=a)))return a=Math.min(a,a+s+l),D=e.slice(0,i+n.index+(n[0].length-D.length)+a),Math.min(i,a)%2?(a=D.slice(1,-1),{type:"em",raw:D,text:a,tokens:this.lexer.inlineTokens(a)}):(a=D.slice(2,-2),{type:"strong",raw:D,text:a,tokens:this.lexer.inlineTokens(a)})}}}},t.codespan=function(e){var t,u,n,e=this.rules.inline.code.exec(e);if(e)return n=e[2].replace(/\n/g," "),t=/[^ ]/.test(n),u=/^ /.test(n)&&/ $/.test(n),n=A(n=t&&u?n.substring(1,n.length-1):n,!0),{type:"codespan",raw:e[0],text:n}},t.br=function(e){e=this.rules.inline.br.exec(e);if(e)return{type:"br",raw:e[0]}},t.del=function(e){e=this.rules.inline.del.exec(e);if(e)return{type:"del",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2])}},t.autolink=function(e,t){var u,e=this.rules.inline.autolink.exec(e);if(e)return t="@"===e[2]?"mailto:"+(u=A(this.options.mangle?t(e[1]):e[1])):u=A(e[1]),{type:"link",raw:e[0],text:u,href:t,tokens:[{type:"text",raw:u,text:u}]}},t.url=function(e,t){var u,n,r,i;if(u=this.rules.inline.url.exec(e)){if("@"===u[2])r="mailto:"+(n=A(this.options.mangle?t(u[0]):u[0]));else{for(;i=u[0],u[0]=this.rules.inline._backpedal.exec(u[0])[0],i!==u[0];);n=A(u[0]),r="www."===u[1]?"http://"+u[0]:u[0]}return{type:"link",raw:u[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}},t.inlineText=function(e,t){e=this.rules.inline.text.exec(e);if(e)return t=this.lexer.state.inRawBlock?this.options.sanitize?this.options.sanitizer?this.options.sanitizer(e[0]):A(e[0]):e[0]:A(this.options.smartypants?t(e[0]):e[0]),{type:"text",raw:e[0],text:t}},e}(),B={newline:/^(?: *(?:\n|$))+/,code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,hr:/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,blockquote:/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,list:/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/,html:"^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))",def:/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/,table:k,lheading:/^((?:.|\n(?!\n))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,_paragraph:/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,text:/^[^\n]+/,_label:/(?!\s*\])(?:\\.|[^\[\]\\])+/,_title:/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/},w=(B.def=p(B.def).replace("label",B._label).replace("title",B._title).getRegex(),B.bullet=/(?:[*+-]|\d{1,9}[.)])/,B.listItemStart=p(/^( *)(bull) */).replace("bull",B.bullet).getRegex(),B.list=p(B.list).replace(/bull/g,B.bullet).replace("hr","\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))").replace("def","\\n+(?="+B.def.source+")").getRegex(),B._tag="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",B._comment=/|$)/,B.html=p(B.html,"i").replace("comment",B._comment).replace("tag",B._tag).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),B.paragraph=p(B._paragraph).replace("hr",B.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",B._tag).getRegex(),B.blockquote=p(B.blockquote).replace("paragraph",B.paragraph).getRegex(),B.normal=F({},B),B.gfm=F({},B.normal,{table:"^ *([^\\n ].*\\|.*)\\n {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)"}),B.gfm.table=p(B.gfm.table).replace("hr",B.hr).replace("heading"," {0,3}#{1,6} ").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",B._tag).getRegex(),B.gfm.paragraph=p(B._paragraph).replace("hr",B.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("table",B.gfm.table).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",B._tag).getRegex(),B.pedantic=F({},B.normal,{html:p("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",B._comment).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:k,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:p(B.normal._paragraph).replace("hr",B.hr).replace("heading"," *#{1,6} *[^\n]").replace("lheading",B.lheading).replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").getRegex()}),{escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,autolink:/^<(scheme:[^\s\x00-\x1f<>]*|email)>/,url:k,tag:"^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^",link:/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,reflink:/^!?\[(label)\]\[(ref)\]/,nolink:/^!?\[(ref)\](?:\[\])?/,reflinkSearch:"reflink|nolink(?!\\()",emStrong:{lDelim:/^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/,rDelimAst:/^(?:[^_*\\]|\\.)*?\_\_(?:[^_*\\]|\\.)*?\*(?:[^_*\\]|\\.)*?(?=\_\_)|(?:[^*\\]|\\.)+(?=[^*])|[punct_](\*+)(?=[\s]|$)|(?:[^punct*_\s\\]|\\.)(\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|(?:[^punct*_\s\\]|\\.)(\*+)(?=[^punct*_\s])/,rDelimUnd:/^(?:[^_*\\]|\\.)*?\*\*(?:[^_*\\]|\\.)*?\_(?:[^_*\\]|\\.)*?(?=\*\*)|(?:[^_\\]|\\.)+(?=[^_])|[punct*](\_+)(?=[\s]|$)|(?:[^punct*_\s\\]|\\.)(\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/},code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,br:/^( {2,}|\\)\n(?!\s*$)/,del:k,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\?@\\[\\]`^{|}~",w.punctuation=p(w.punctuation).replace(/punctuation/g,w._punctuation).getRegex(),w.blockSkip=/\[[^\]]*?\]\([^\)]*?\)|`[^`]*?`|<[^>]*?>/g,w.escapedEmSt=/(?:^|[^\\])(?:\\\\)*\\[*_]/g,w._comment=p(B._comment).replace("(?:--\x3e|$)","--\x3e").getRegex(),w.emStrong.lDelim=p(w.emStrong.lDelim).replace(/punct/g,w._punctuation).getRegex(),w.emStrong.rDelimAst=p(w.emStrong.rDelimAst,"g").replace(/punct/g,w._punctuation).getRegex(),w.emStrong.rDelimUnd=p(w.emStrong.rDelimUnd,"g").replace(/punct/g,w._punctuation).getRegex(),w._escapes=/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g,w._scheme=/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/,w._email=/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/,w.autolink=p(w.autolink).replace("scheme",w._scheme).replace("email",w._email).getRegex(),w._attribute=/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/,w.tag=p(w.tag).replace("comment",w._comment).replace("attribute",w._attribute).getRegex(),w._label=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,w._href=/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/,w._title=/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/,w.link=p(w.link).replace("label",w._label).replace("href",w._href).replace("title",w._title).getRegex(),w.reflink=p(w.reflink).replace("label",w._label).replace("ref",B._label).getRegex(),w.nolink=p(w.nolink).replace("ref",B._label).getRegex(),w.reflinkSearch=p(w.reflinkSearch,"g").replace("reflink",w.reflink).replace("nolink",w.nolink).getRegex(),w.normal=F({},w),w.pedantic=F({},w.normal,{strong:{start:/^__|\*\*/,middle:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,endAst:/\*\*(?!\*)/g,endUnd:/__(?!_)/g},em:{start:/^_|\*/,middle:/^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/,endAst:/\*(?!\*)/g,endUnd:/_(?!_)/g},link:p(/^!?\[(label)\]\((.*?)\)/).replace("label",w._label).getRegex(),reflink:p(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",w._label).getRegex()}),w.gfm=F({},w.normal,{escape:p(w.escape).replace("])","~|])").getRegex(),_extended_email:/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,url:/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\'+(u?e:A(e,!0))+"\n":"
"+(u?e:A(e,!0))+"
\n"},t.blockquote=function(e){return"
\n"+e+"
\n"},t.html=function(e){return e},t.heading=function(e,t,u,n){return this.options.headerIds?"'+e+"\n":""+e+"\n"},t.hr=function(){return this.options.xhtml?"
\n":"
\n"},t.list=function(e,t,u){var n=t?"ol":"ul";return"<"+n+(t&&1!==u?' start="'+u+'"':"")+">\n"+e+"\n"},t.listitem=function(e){return"
  • "+e+"
  • \n"},t.checkbox=function(e){return" "},t.paragraph=function(e){return"

    "+e+"

    \n"},t.table=function(e,t){return"\n\n"+e+"\n"+(t=t&&""+t+"")+"
    \n"},t.tablerow=function(e){return"\n"+e+"\n"},t.tablecell=function(e,t){var u=t.header?"th":"td";return(t.align?"<"+u+' align="'+t.align+'">':"<"+u+">")+e+"\n"},t.strong=function(e){return""+e+""},t.em=function(e){return""+e+""},t.codespan=function(e){return""+e+""},t.br=function(){return this.options.xhtml?"
    ":"
    "},t.del=function(e){return""+e+""},t.link=function(e,t,u){return null===(e=f(this.options.sanitize,this.options.baseUrl,e))?u:(e='"+u+"")},t.image=function(e,t,u){return null===(e=f(this.options.sanitize,this.options.baseUrl,e))?u:(e=''+u+'":">"))},t.text=function(e){return e},e}(),z=function(){function e(){}var t=e.prototype;return t.strong=function(e){return e},t.em=function(e){return e},t.codespan=function(e){return e},t.del=function(e){return e},t.html=function(e){return e},t.text=function(e){return e},t.link=function(e,t,u){return""+u},t.image=function(e,t,u){return""+u},t.br=function(){return""},e}(),$=function(){function e(){this.seen={}}var t=e.prototype;return t.serialize=function(e){return e.toLowerCase().trim().replace(/<[!\/a-z].*?>/gi,"").replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g,"").replace(/\s/g,"-")},t.getNextSafeSlug=function(e,t){var u=e,n=0;if(this.seen.hasOwnProperty(u))for(n=this.seen[e];u=e+"-"+ ++n,this.seen.hasOwnProperty(u););return t||(this.seen[e]=n,this.seen[u]=0),u},t.slug=function(e,t){void 0===t&&(t={});e=this.serialize(e);return this.getNextSafeSlug(e,t.dryrun)},e}(),S=function(){function u(e){this.options=e||r.defaults,this.options.renderer=this.options.renderer||new _,this.renderer=this.options.renderer,this.renderer.options=this.options,this.textRenderer=new z,this.slugger=new $}u.parse=function(e,t){return new u(t).parse(e)},u.parseInline=function(e,t){return new u(t).parseInline(e)};var e=u.prototype;return e.parse=function(e,t){void 0===t&&(t=!0);for(var u,n,r,i,s,l,o,a,D,c,h,p,f,g,F,A,k="",d=e.length,C=0;C",i?Promise.resolve(t):s?void s(null,t):t;if(i)return Promise.reject(e);if(!s)throw e;s(e)});if(null==e)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof e)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));if((t=u)&&t.sanitize&&!t.silent&&console.warn("marked(): sanitize and sanitizer parameters are deprecated since version 0.7.0, should not be used and will be removed in the future. Read more here: https://marked.js.org/#/USING_ADVANCED.md#options"),u.hooks&&(u.hooks.options=u),n){var o,a=u.highlight;try{u.hooks&&(e=u.hooks.preprocess(e)),o=f(e,u)}catch(e){return l(e)}var D,c=function(t){var e;if(!t)try{u.walkTokens&&I.walkTokens(o,u.walkTokens),e=g(o,u),u.hooks&&(e=u.hooks.postprocess(e))}catch(e){t=e}return u.highlight=a,t?l(t):n(null,e)};return!a||a.length<3?c():(delete u.highlight,o.length?(D=0,I.walkTokens(o,function(u){"code"===u.type&&(D++,setTimeout(function(){a(u.text,u.lang,function(e,t){if(e)return c(e);null!=t&&t!==u.text&&(u.text=t,u.escaped=!0),0===--D&&c()})},0))}),void(0===D&&c())):c())}if(u.async)return Promise.resolve(u.hooks?u.hooks.preprocess(e):e).then(function(e){return f(e,u)}).then(function(e){return u.walkTokens?Promise.all(I.walkTokens(e,u.walkTokens)).then(function(){return e}):e}).then(function(e){return g(e,u)}).then(function(e){return u.hooks?u.hooks.postprocess(e):e}).catch(l);try{u.hooks&&(e=u.hooks.preprocess(e));var h=f(e,u),p=(u.walkTokens&&I.walkTokens(h,u.walkTokens),g(h,u));return p=u.hooks?u.hooks.postprocess(p):p}catch(e){return l(e)}}}function I(e,t,u){return R(v.lex,S.parse)(e,t,u)}T.passThroughHooks=new Set(["preprocess","postprocess"]),I.options=I.setOptions=function(e){return I.defaults=F({},I.defaults,e),e=I.defaults,r.defaults=e,I},I.getDefaults=e,I.defaults=r.defaults,I.use=function(){for(var D=I.defaults.extensions||{renderers:{},childTokens:{}},e=arguments.length,t=new Array(e),u=0;u= MIN_SIZE && v <= MAX_SIZE; + } + + function normalizeConfig(cfg) { + var c = (cfg && typeof cfg === 'object') ? cfg : {}; + var size = validSize(c.render_window_size) ? c.render_window_size : DEFAULT_SIZE; + var mode = (c.render_window_mode === 'auto' || c.render_window_mode === 'manual') + ? c.render_window_mode : 'auto'; + return { mode: mode, size: size }; + } + + function create(cfg) { + var c = normalizeConfig(cfg); + return { + mode: c.mode, + size: c.size, + sessionId: null, + generation: 0, + order: [], // 窗口内 id(旧 → 新),长度 ≤ size + indexById: {}, // id → 链内下标(-1 = 未持久化) + chainLen: 0, // 最近一次载荷给出的可见链长度 + hiddenOlder: 0, // 窗口之上链中消息数 + hiddenNewer: 0, // 窗口之下链中消息数 + hasMoreOlder: false, + hasMoreNewer: false, + pending: null, // {direction, boundaryId, generation} | null + activeStreamId: null, // 活动流式消息 id(计入上限、永不裁剪) + followBottom: false + }; + } + + function windowIds(st) { return st.order.slice(); } + + function isFull(st) { return st.order.length >= st.size; } + + function canRequest(st, direction) { + if (!st || st.pending || st.order.length === 0) return false; + return direction === 'older' ? st.hasMoreOlder : st.hasMoreNewer; + } + + /* 登记一次换页请求,返回 {direction, boundaryId};boundary 为窗口对应端的消息 id。 */ + function beginRequest(st, direction) { + if (!canRequest(st, direction)) return null; + var boundaryId = direction === 'older' ? st.order[0] : st.order[st.order.length - 1]; + st.pending = { direction: direction, boundaryId: boundaryId, generation: st.generation }; + return { direction: direction, boundaryId: boundaryId }; + } + + function num(v) { return (isInt(v) && v >= 0) ? v : 0; } + + /* 按链内下标推导两端隐藏计数。 + * 未持久化消息(index=-1)只可能出现在较新一端: + * hiddenOlder = 窗口最旧 id 的下标(最旧端必为已持久化消息) + * hiddenNewer = chainLen - 1 - 已知最大下标 - 其后的未持久化条数 */ + function recompute(st) { + var minI = null, maxI = null, maxPos = -1, unknownAfterMax = 0; + for (var i = 0; i < st.order.length; i++) { + var ix = st.indexById[st.order[i]]; + if (ix === undefined || ix < 0) continue; + if (minI === null || ix < minI) minI = ix; + if (maxI === null || ix > maxI) { maxI = ix; maxPos = i; } + } + for (var j = maxPos + 1; j < st.order.length; j++) { + var jx = st.indexById[st.order[j]]; + if (jx === undefined || jx < 0) unknownAfterMax++; + } + if (minI === null) { + st.hiddenOlder = 0; + st.hiddenNewer = 0; + st.hasMoreOlder = false; + st.hasMoreNewer = false; + return; + } + st.hiddenOlder = minI; + st.hiddenNewer = Math.max(0, st.chainLen - 1 - maxI - unknownAfterMax); + st.hasMoreOlder = st.hiddenOlder > 0; + st.hasMoreNewer = st.hiddenNewer > 0; + } + + /* 从较新一端裁剪,直到长度 ≤ size;活动流永不裁剪。 */ + function trimTail(st, removed) { + while (st.order.length > st.size) { + var i = st.order.length - 1; + if (st.order[i] === st.activeStreamId) { i--; } // 跳过尾部活动流,裁次新一条 + if (i < 0) break; // 病态兜底:整窗都是活动流(不可能,仅一条流) + removed.push(st.order.splice(i, 1)[0]); + delete st.indexById[removed[removed.length - 1]]; + } + } + + /* 从较旧一端裁剪,直到长度 ≤ size;活动流永不裁剪。 */ + function trimHead(st, removed) { + while (st.order.length > st.size) { + var i = 0; + if (st.order[i] === st.activeStreamId) { i = 1; } + if (i >= st.order.length) break; + removed.push(st.order.splice(i, 1)[0]); + delete st.indexById[removed[removed.length - 1]]; + } + } + + function absorb(st, items) { + var added = []; + for (var i = 0; i < items.length; i++) { + var d = items[i]; + if (!d || !d.id || st.order.indexOf(d.id) >= 0) continue; + st.order.push(d.id); + st.indexById[d.id] = (d.chainIndex === undefined || d.chainIndex < 0) ? -1 : d.chainIndex; + added.push(d.id); + } + return added; + } + + /* 初始窗口:最新 size 条(Python 负责截取)。 + * payload = {sessionId, generation, chainLen, items:[{id, chainIndex}]} + * 用 Python 代次重同步(覆盖 clear() 的本地自增)。 */ + function initFullChain(st, payload) { + st.sessionId = payload.sessionId; + st.generation = num(payload.generation); + st.order = []; + st.indexById = {}; + st.pending = null; + st.activeStreamId = null; + st.followBottom = false; + st.chainLen = num(payload.chainLen); + absorb(st, payload.items || []); + trimTail(st, []); // 防御:载荷超过 size 时保留最新端 + recompute(st); + return { window: windowIds(st), hasMoreOlder: st.hasMoreOlder }; + } + + /* 收到 Python 页载荷。 + * req = beginRequest 的返回值(或 {direction, boundaryId}) + * payload = {sessionId, generation, boundaryId, direction, chainLen, + * items:[{id, chainIndex}]} + * 返回 {stale:true} 或 + * {side, addedIds, removedIds, hiddenOlder, hiddenNewer, + * hasMoreOlder, hasMoreNewer} + * (DOM 层:addedIds 已渲染、加到 side 端;removedIds 从 DOM 移除; + * 加载入口可见性按 hasMore* 更新) */ + function applyPage(st, req, payload) { + if (!st || !req || !payload) return { stale: true }; + if (payload.sessionId !== st.sessionId || + payload.generation !== st.generation) { + st.pending = null; + return { stale: true }; + } + if (!st.pending || + st.pending.direction !== req.direction || + st.pending.boundaryId !== req.boundaryId) { + // 无匹配的未决请求(代次已推进/窗口已变/重复投递)→ 丢弃 + st.pending = null; + return { stale: true }; + } + st.pending = null; + + var items = payload.items || []; + var removedIds = []; + var addedIds = absorb(st, items); + if (req.direction === 'older') { + // absorb 追加在尾部,这里把新页挪到头部(保持 旧→新 顺序) + var head = st.order.splice(st.order.length - addedIds.length, addedIds.length); + st.order = head.concat(st.order); + trimTail(st, removedIds); + } else { + trimHead(st, removedIds); + } + st.chainLen = num(payload.chainLen); + recompute(st); + return { + side: req.direction, + addedIds: addedIds, + removedIds: removedIds, + hiddenOlder: st.hiddenOlder, + hiddenNewer: st.hiddenNewer, + hasMoreOlder: st.hasMoreOlder, + hasMoreNewer: st.hasMoreNewer + }; + } + + /* 切会话 / 清屏 / 切分支:游标、缓存、未决请求、代次全部清空(本地自增使 + * 旧载荷失效);已注入的配置模式与大小保持不变。下一次 initFullChain + * 用 Python 代次重同步。 */ + function clear(st) { + st.generation += 1; + st.sessionId = null; + st.order = []; + st.indexById = {}; + st.chainLen = 0; + st.hiddenOlder = 0; + st.hiddenNewer = 0; + st.hasMoreOlder = false; + st.hasMoreNewer = false; + st.pending = null; + st.activeStreamId = null; + st.followBottom = false; + return st; + } + + /* 活动流开始:该消息计入上限、永不被裁剪。 */ + function noteStream(st, msgId) { + st.activeStreamId = msgId; + st.followBottom = true; + } + + function streamFinished(st, msgId) { + if (st.activeStreamId === msgId) { + st.activeStreamId = null; + st.followBottom = false; + } + } + + /* 新消息追加到窗口较新一端(发送用户消息 / 助手占位 / 切回续流)。 + * 超出 size 时从较旧一端裁剪(活动流除外),流式消息计入上限。 */ + function noteLive(st, msgId, chainIndex) { + if (!st || st.sessionId === null || !msgId) return { added: false }; + if (st.order.indexOf(msgId) >= 0) { + if (chainIndex !== undefined && chainIndex >= 0) st.indexById[msgId] = chainIndex; + recompute(st); + return { added: false }; + } + st.order.push(msgId); + st.indexById[msgId] = (chainIndex === undefined || chainIndex < 0) ? -1 : chainIndex; + var removed = []; + trimHead(st, removed); + recompute(st); + return { added: true, removedIds: removed }; + } + + /* ---------- 锚点几何(纯数学;DOM 层传入测量值) ---------- */ + + /* 首个可见消息 + 像素偏移。 + * entries: [{id, top, height}](文档坐标,DOM 顺序 旧→新) + * viewportTop/viewportBottom: 视口在文档坐标中的范围 + * 返回 {msgId, offset};无可见消息 → null */ + function computeAnchor(entries, viewportTop, viewportBottom) { + for (var i = 0; i < entries.length; i++) { + var e = entries[i]; + if (e.top + e.height > viewportTop && e.top < viewportBottom) { + return { msgId: e.id, offset: Math.max(0, viewportTop - e.top) }; + } + } + return null; + } + + /* 换页后的滚动增量:把换页前记录的 anchor 文档 top(rectTopBefore) + * 对齐到换页后实测的 top(rectTopAfter)。DOM 层用两次真实测量, + * 误差只来自亚像素取整,≤ 2 px。 */ + function scrollDeltaFromRects(rectTopBefore, rectTopAfter) { + return rectTopAfter - rectTopBefore; + } + + return { + MIN_SIZE: MIN_SIZE, + MAX_SIZE: MAX_SIZE, + DEFAULT_SIZE: DEFAULT_SIZE, + normalizeConfig: normalizeConfig, + create: create, + windowIds: windowIds, + isFull: isFull, + canRequest: canRequest, + beginRequest: beginRequest, + applyPage: applyPage, + initFullChain: initFullChain, + clear: clear, + noteStream: noteStream, + streamFinished: streamFinished, + noteLive: noteLive, + computeAnchor: computeAnchor, + scrollDeltaFromRects: scrollDeltaFromRects + }; +})); diff --git a/ui/web/style.css b/ui/web/style.css new file mode 100644 index 0000000..608b3a9 --- /dev/null +++ b/ui/web/style.css @@ -0,0 +1,932 @@ +/* ==================== 1. 全局 ==================== */ +body, html { + margin: 0; padding: 0; + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif; + background-color: #ffffff; + color: #333; + overflow-x: hidden; + /* 🌟 不再给 html/body 设 height:100%(会让 body 退化为内部滚动容器, + 导致 window.scrollY/scrollTo 失效;视口高度由 #chat-container 的 min-height:100vh 承担) */ +} + +#chat-container { + max-width: 95%; + margin: 0 auto; + padding: 20px 20px 20px 20px; + min-height: 100vh; /* 🌟 至少占满整个视口高度 */ +} + +/* ==================== 隐藏 WebEngine 原生滚动条 ==================== */ +/* 改用右侧自定义 Qt 滚动条(WebScrollBar),避免内置滚动条高速滑动时的重绘延迟观感 */ +::-webkit-scrollbar { + width: 0px; + height: 0px; + background: transparent; +} + +/* ==================== 会话加载界面 ==================== */ +.session-loading { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + z-index: 999; + display: flex; + align-items: center; + justify-content: center; + background-color: #ffffff; + opacity: 1; + transition: opacity 0.45s ease; /* 结束后的渐变退场 */ +} +.session-loading.hide { + opacity: 0; + pointer-events: none; +} +.session-loading-box { + display: flex; + flex-direction: column; + align-items: center; +} +.session-loading-icon { + width: 72px; + height: 72px; +} +.session-loading-bar { + width: 190px; + height: 4px; + margin-top: 26px; + background: #eceef2; + border-radius: 2px; + overflow: hidden; + position: relative; +} +.session-loading-bar-fill { + position: absolute; + top: 0; left: 0; + height: 100%; + width: 36%; + border-radius: 2px; + background: linear-gradient(90deg, rgba(59,130,246,0) 0%, #3b82f6 50%, rgba(59,130,246,0) 100%); + animation: loadingSweep 1.2s ease-in-out infinite; /* 从左到右扫描,非进度条 */ +} +@keyframes loadingSweep { + 0% { transform: translateX(-110%); } + 100% { transform: translateX(300%); } +} + +/* ==================== 欢迎界面 ==================== */ +.welcome-screen { + display: flex; + justify-content: center; + align-items: center; + position: fixed; /* 🌟 固定定位,脱离文档流 */ + top: 0; + left: 0; + right: 0; + bottom: 0; + padding: 60px 20px; + pointer-events: none; /* 🌟 允许点击穿透到下方的输入框 */ +} + +.welcome-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + pointer-events: auto; /* 🌟 但内容本身可以交互 */ + /* 旧的渐入动画已移除:会话切换统一由加载界面完成过渡 */ +} + +/* 欢迎页图标:main.svg */ +.welcome-icon { + width: 56px; + height: 56px; + margin-bottom: 12px; +} + +.brand-name { + font-size: 40px; + font-weight: 700; + margin: 0; + letter-spacing: 6px; + font-family: "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", "Segoe UI", sans-serif; + background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + text-fill-color: transparent; +} + +.brand-tagline { + font-size: 15px; + color: #8a94a6; + margin: 14px 0 0 0; + font-weight: 400; + letter-spacing: 5px; +} + + +/* ==================== 2. 消息气泡 ==================== */ +.message-wrapper { + display: flex; + align-items: flex-start; + margin-bottom: 30px; +} +.message-wrapper.user { justify-content: flex-end; } + +/* ==================== 3. 头像 ==================== */ +.avatar { + width: 32px; height: 32px; + border-radius: 6px; + display: flex; justify-content: center; align-items: center; + font-weight: bold; font-size: 14px; + flex-shrink: 0; + margin-top: 20px; +} +.assistant .avatar { background-color: #4a90d9; color: white; margin-right: 15px; } +.user .avatar { background-color: #e3e3e3; color: #555; margin-left: 15px; order: 2; } + +/* ==================== 4. 消息内容区 ==================== */ +.message-content { + max-width: 85%; + display: flex; + flex-direction: column; +} +.assistant .message-content { + align-items: flex-start; + /* 🆕 助手气泡恒定宽度:输出中/完成后一律 85%,不随内容伸缩(流式特效不受影响) */ + width: 85%; +} +/* 🆕 内层同步撑满:.reply-content 基类是 fit-content(贴内容缩), + 可见气泡(md-segment 背景/思考卡/工具卡)都在它里面 → 必须 100% 才真正恒定 */ +.assistant .reply-content { width: 100%; } +.user .message-content { align-items: flex-end; } + +.sender-name { + font-size: 11px; color: #aaa; + margin-bottom: 6px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.5px; +} + +.reply-content { + line-height: 1.6; font-size: 15px; + width: fit-content; max-width: 100%; +} +.user .reply-content { + background-color: #f4f4f4; + padding: 10px 18px; + border-radius: 18px; border-top-right-radius: 4px; + color: #333; text-align: left; +} + +/* ==================== 5. 思考过程 ==================== */ +.think-block { + margin-bottom: 12px; border-radius: 8px; + background-color: #ffffff; border: 1px solid #eaeaea; + overflow: hidden; transition: all 0.3s ease; width: 100%; +} +.think-block summary { + padding: 8px 14px; font-size: 13px; color: #888; + cursor: pointer; user-select: none; + background-color: #fafafa; list-style: none; + display: flex; align-items: center; font-weight: 500; +} +.think-block summary .chev { color: #b8b8b8; margin-right: 8px; } +.think-block[open] summary { border-bottom: 1px dashed #eaeaea; } +.think-content { + padding: 12px 14px; font-size: 13px; color: #777; + background-color: #fff; max-height: 400px; overflow-y: auto; +} + +/* 🌟 流式输出中:flex 锁底,展示最新思考内容 */ +/* 🌟 流式输出中:思考框保持可滚动,JS 控制锁底 */ +.message-wrapper.streaming .think-content { + overflow-y: auto !important; +} + + +.think-inner { + flex-shrink: 0; +} + + +.think-content.markdown-body p { color: #777; margin-bottom: 8px; } + +/* ============ 思考/压缩气泡内:代码朴素渲染 ============ + 压缩摘要充满代码:浅色语法色(#a626a4 紫)+ code-block-wrapper 边框/语言栏, + 让定格后的气泡观感"紫色带边框"。此处统一朴素灰、无边框无头部; + 正文气泡的代码块不受影响。 */ +.think-content .code-block-wrapper { + border: none; + border-radius: 0; + background: transparent; + min-width: 0; + margin: 8px 0; +} +.think-content .code-header { display: none; } +.think-content .code-block-wrapper pre, +.think-content .code-block-wrapper pre code, +.think-content .code-block-wrapper .hljs, +.think-content .code-block-wrapper .hljs * { + color: #777 !important; + background: transparent !important; +} +.think-content code, +.think-content.markdown-body p code { + color: #777; + background-color: #f6f7f9; +} + +/* ==================== 6. Markdown 基础 ==================== */ +.markdown-body p { margin-top: 0; margin-bottom: 10px; } +.markdown-body code { + font-family: 'Consolas', 'Courier New', monospace; font-size: 13px; +} +.markdown-body p code { + background-color: #f0f0f0; color: #e83e8c; + padding: 2px 5px; border-radius: 4px; +} + +/* ==================== 6b. 🌟 KaTeX 公式渲染 ==================== */ +.katex-display { + margin: 10px 0; padding: 4px 2px; + overflow-x: auto; overflow-y: hidden; +} +.katex-display::-webkit-scrollbar { height: 6px; } +.katex-display::-webkit-scrollbar-thumb { background: #d0d0d0; border-radius: 3px; } +.markdown-body .katex { font-size: 1.06em; } +.markdown-body .katex-display { font-size: 1.08em; } + +/* ==================== 7. 代码块 ==================== */ +.code-block-wrapper { + position: relative; + border: 1px solid #e5e5e5; border-radius: 8px; + margin: 12px 0; overflow: hidden; + background-color: #ffffff; + min-width: 500px; /* 短代码也保持基本宽度,避免气泡过窄难看 */ +} + +.code-header { + display: flex; justify-content: space-between; align-items: center; + background-color: #fafafa; padding: 4px 10px; + border-bottom: 1px solid #eee; +} +.code-lang-label { + font-size: 11px; color: #aaa; font-weight: 500; + text-transform: lowercase; letter-spacing: 0.3px; +} +.code-header-actions { display: flex; align-items: center; gap: 4px; } + +.code-header button { + background: transparent; border: 1px solid transparent; + color: #999; cursor: pointer; + display: flex; align-items: center; gap: 4px; + font-size: 12px; padding: 3px 8px; border-radius: 4px; + transition: all 0.15s ease; font-family: inherit; outline: none; +} +.code-header button:hover { + background-color: #f0f0f0; border-color: #e0e0e0; color: #555; +} +.copy-btn.copied { + color: #52c41a !important; + border-color: rgba(82, 196, 26, 0.3) !important; + background-color: rgba(82, 196, 26, 0.06) !important; +} + +/* 代码主体 */ +.code-body { + margin: 0; padding: 14px; + background-color: #fafafa !important; + overflow-x: auto; + max-height: 5000px; +} + +/* 修改折叠态:允许滚动但隐藏滚动条 */ +.code-body.collapsed { + max-height: 140px !important; + overflow-y: auto !important; /* ← 从 hidden 改为 auto */ + scrollbar-width: none; /* Firefox */ +} +.code-body.collapsed::-webkit-scrollbar { + display: none; /* Chrome/Safari */ +} + +/* 🌟 流式输出中:用 flex 布局天然展示底部代码,不依赖 JS scrollTop */ +.message-wrapper.streaming .code-body.collapsed { + display: flex !important; + flex-direction: column !important; + justify-content: flex-end !important; + overflow: hidden !important; +} + + +/* 默认折叠遮罩:底部渐隐(展示顶部代码) */ +.code-block-wrapper:has(.code-body.collapsed)::after { + content: ''; + position: absolute; + bottom: 0; left: 0; right: 0; + height: 40px; + background: linear-gradient(transparent, #fafafa); + pointer-events: none; + border-radius: 0 0 8px 8px; +} + +/* 🌟 流式输出中遮罩:顶部渐隐(展示底部最新代码) */ +.message-wrapper.streaming .code-block-wrapper:has(.code-body.collapsed)::after { + top: 36px; bottom: auto; + border-radius: 0; + background: linear-gradient(#fafafa, transparent); +} + +/* 强制覆盖 highlight.js 深色背景 */ +.code-block-wrapper pre, +.code-block-wrapper pre code, +.code-block-wrapper .hljs { + background-color: #fafafa !important; + color: #383a42 !important; +} + +/* 浅色语法高亮 */ +.code-block-wrapper .hljs-keyword, +.code-block-wrapper .hljs-selector-tag, +.code-block-wrapper .hljs-title, +.code-block-wrapper .hljs-section, +.code-block-wrapper .hljs-doctag, +.code-block-wrapper .hljs-name, +.code-block-wrapper .hljs-strong { color: #a626a4; } +.code-block-wrapper .hljs-string, +.code-block-wrapper .hljs-attr { color: #50a14f; } +.code-block-wrapper .hljs-number, +.code-block-wrapper .hljs-literal, +.code-block-wrapper .hljs-variable, +.code-block-wrapper .hljs-template-variable, +.code-block-wrapper .hljs-tag .hljs-attr { color: #986801; } +.code-block-wrapper .hljs-comment, +.code-block-wrapper .hljs-quote { color: #a0a1a7; font-style: italic; } +.code-block-wrapper .hljs-built_in, +.code-block-wrapper .hljs-builtin-name { color: #c18401; } +.code-block-wrapper .hljs-function .hljs-title, +.code-block-wrapper .hljs-class .hljs-title { color: #4078f2; } +.code-block-wrapper .hljs-type, +.code-block-wrapper .hljs-params { color: #c18401; } +.code-block-wrapper .hljs-meta { color: #4078f2; } +.code-block-wrapper code { + font-family: 'Consolas', 'Courier New', monospace; font-size: 13px; +} + +/* ==================== 7.1 SVG 代码块:绘制/源码 切换 ==================== */ +/* 渲染容器:透明背景、居中,防超大 SVG 撑破布局 */ +.svg-render-body { + display: flex; + align-items: center; + justify-content: center; + padding: 14px; + background: transparent; + overflow: auto; + max-height: 5000px; +} +.svg-render-body svg { + min-width: 360px; /* 小图适当放大,避免渲染出来太小看不清 */ + max-width: 100%; + height: auto; +} +/* 渲染态:隐藏代码体与折叠渐隐遮罩 */ +.code-block-wrapper.svg-mode .code-body { display: none !important; } +.code-block-wrapper.svg-mode:has(.code-body.collapsed)::after { display: none; } +/* 渲染态按钮高亮(此时按钮文案为「源码」) */ +.code-header .svg-render-btn.active { + color: #10a37f; + border-color: rgba(16, 163, 127, 0.3); + background-color: rgba(16, 163, 127, 0.06); +} +/* 折叠按钮被锁定时(处于渲染态) */ +.code-header .fold-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} +/* 流式输出中隐藏「绘制」按钮(与复制按钮同等待遇,避免重渲冲掉切换状态) */ +.message-wrapper.streaming .svg-render-btn { + display: none !important; +} + +/* ==================== 8. 长文本附件卡片 ==================== */ +.long-text-card { + display: flex; align-items: center; gap: 12px; + padding: 12px 16px; background-color: #f8f8f8; + border: 1px solid #e8e8e8; border-radius: 12px; + cursor: pointer; transition: all 0.2s ease; max-width: 320px; +} +.long-text-card:hover { + background-color: #f0f0f0; border-color: #d0d0d0; + box-shadow: 0 2px 8px rgba(0,0,0,0.06); +} +.card-icon { + flex-shrink: 0; width: 40px; height: 40px; + display: flex; align-items: center; justify-content: center; + background-color: #fff; border-radius: 8px; border: 1px solid #eee; +} +.card-info { overflow: hidden; } +.card-title { + font-size: 13px; color: #333; font-weight: 500; + white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; max-width: 220px; +} +.card-meta { font-size: 11px; color: #aaa; margin-top: 3px; } + +/* ==================== 9. 模态框 ==================== */ +.content-modal-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background-color: rgba(0,0,0,0); + display: flex; justify-content: center; align-items: center; + z-index: 9999; transition: background-color 0.2s ease; +} +.content-modal-overlay.visible { background-color: rgba(0,0,0,0.4); } +.content-modal { + background-color: #fff; border-radius: 12px; + width: 70%; max-width: 700px; max-height: 75vh; + display: flex; flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.15); + transform: scale(0.95); opacity: 0; + transition: all 0.2s ease; overflow: hidden; +} +.content-modal-overlay.visible .content-modal { + transform: scale(1); opacity: 1; +} +.modal-header { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 20px; border-bottom: 1px solid #eee; + font-size: 14px; font-weight: 600; color: #333; +} +.modal-close-btn { + background: transparent; border: none; font-size: 18px; + color: #999; cursor: pointer; padding: 4px 8px; + border-radius: 4px; transition: all 0.15s ease; line-height: 1; +} +.modal-close-btn:hover { background-color: #f0f0f0; color: #333; } +.modal-body { + padding: 16px 20px; overflow-y: auto; + font-family: 'Consolas', 'Courier New', monospace; + font-size: 13px; line-height: 1.6; color: #444; + white-space: pre-wrap; word-break: break-all; + margin: 0; background-color: #fafafa; +} + +/* ==================== 10. 错误与动画 ==================== */ +.system-error { + background-color: #fef0f0; border: 1px solid #fde2e2; + color: #f56c6c; padding: 10px 15px; border-radius: 8px; + margin-top: 10px; font-size: 13px; font-weight: bold; + display: inline-block; +} + +/* ==================== 11. 多附件容器 ==================== */ +.attachments-container { + display: flex; flex-direction: column; + gap: 8px; margin-bottom: 8px; +} +.attachments-container .long-text-card { max-width: 300px; } +.user .attachments-container + .reply-content { margin-top: 4px; } +/* ==================== 12. 流式输出状态(纯 CSS 控制,不闪烁) ==================== */ +.message-wrapper.streaming .copy-btn { + display: none !important; +} + +.message-wrapper.streaming .fold-btn { + font-size: 0 !important; + pointer-events: none; + position: relative; +} + +.message-wrapper.streaming .fold-btn::after { + content: '输出中...'; + font-size: 12px; + color: #f5a623; +} +/* 过渡态:从 flex-end 切换到 scroll 模式,保持显示底部 */ +.code-body.collapsed.scroll-locked { + display: block !important; + overflow-y: auto !important; + justify-content: initial !important; + flex-direction: initial !important; + scrollbar-width: none; +} +.code-body.collapsed.scroll-locked::-webkit-scrollbar { + display: none; +} +/* ==================== 13. 消息操作栏 ==================== */ +.message-actions { + display: flex; + align-items: center; + gap: 2px; + margin-top: 8px; +} + +.action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + color: #999; + transition: all 0.15s ease; + padding: 0; +} + +.action-btn:hover { + background-color: #f0f0f0; + color: #555; +} + +.action-btn svg { + pointer-events: none; +} + +/* 流式输出中隐藏操作栏 */ +.message-wrapper.streaming .message-actions { + display: none !important; +} +/* ================= 分支选择器 ================= */ +.branch-selector { + display: inline-flex; + align-items: center; + margin-left: 10px; + background-color: #f0f0f0; + border-radius: 12px; + padding: 2px 6px; + font-size: 11px; + color: #666; + user-select: none; +} + +/* 暗色模式的话可以加这句,如果是纯亮色就不用 */ +@media (prefers-color-scheme: dark) { + .branch-selector { background-color: #333; color: #aaa; } +} + +.branch-btn { + background: transparent; + border: none; + cursor: pointer; + padding: 0 4px; + color: #888; + font-size: 10px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; +} + +.branch-btn:hover:not(:disabled) { + color: #111; +} + +.branch-btn:disabled { + color: #ccc; + cursor: not-allowed; +} + +.branch-text { + margin: 0 4px; + font-family: monospace; +} +/* ================= 删除确认气泡 ================= */ +.delete-confirm-popover { + position: absolute; + bottom: 120%; /* 浮在按钮上方 */ + left: 50%; + transform: translateX(-50%); + background-color: #fee2e2; /* 淡红色警告背景 */ + border: 1px solid #f87171; + border-radius: 6px; + padding: 6px 10px; + display: flex; + align-items: center; + gap: 4px; + box-shadow: 0 4px 10px rgba(239, 68, 68, 0.2); + z-index: 100; + cursor: pointer; + white-space: nowrap; + width: max-content; /* 🌟 核心修复:保证气泡背景宽度根据文字自适应,不被按钮挤压 */ +} +.delete-confirm-popover:hover { + background-color: #fecaca; +} +.delete-confirm-text { + color: #ef4444; + font-size: 12px; + font-weight: bold; +} +.delete-confirm-popover svg { + stroke: #ef4444; +} + +/* ================= 用户消息专属操作栏 ================= */ +.user-actions { + display: flex; + justify-content: flex-end; /* 靠右对齐 */ + margin-top: 4px; + opacity: 1; /* 默认隐藏,悬浮显示 */ + transition: opacity 0.2s ease-in-out; +} + +.message-wrapper.user:hover .user-actions { + opacity: 1; /* 鼠标悬浮气泡时,显示操作栏 */ +} + +/* 强制指定按钮的图标颜色、大小和边距,打破继承魔咒 */ +.user-actions .action-btn { + background: transparent; + border: none; + color: #999999; /* 强制指定为高级灰,让 SVG 显形 */ + padding: 6px; + margin-left: 4px; + border-radius: 6px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.user-actions .action-btn:hover { + background-color: #eaeaea; + color: #333333; /* 悬浮时图标变深 */ +} + +.user-actions .user-delete-btn:hover { + background-color: #fee2e2; + color: #ef4444; +} + +/* 🌟 用户侧靠右对齐 */ +.user-actions .delete-confirm-popover { + left: auto; + right: 0; + transform: none; +} + +/* ⚠️ 注意:删除原本文件最底部的 "确认删除弹窗基础样式" 这一大段重复代码,避免样式冲突 */ + +/* ================= 确认删除弹窗基础样式 ================= */ +.delete-confirm-popover { + position: absolute; + bottom: 120%; + left: 50%; + transform: translateX(-50%); + background-color: #fee2e2; + border: 1px solid #f87171; + border-radius: 6px; + padding: 6px 10px; + display: flex; + align-items: center; + gap: 4px; + box-shadow: 0 4px 10px rgba(239, 68, 68, 0.2); + z-index: 100; + cursor: pointer; + white-space: nowrap; +} + +.delete-confirm-popover:hover { + background-color: #fecaca; +} + +.delete-confirm-text { + color: #ef4444; + font-size: 12px; + font-weight: bold; +} + +.delete-confirm-popover svg { + stroke: #ef4444; +} +/* ==================== 工具执行气泡(pi tool_execution_* 事件,浅色·对齐思考气泡) ==================== */ +.tool-chip { + margin: 10px 0 4px 0; border-radius: 8px; + background-color: #ffffff; border: 1px solid #eaeaea; + overflow: hidden; transition: all 0.3s ease; width: 100%; +} +.tool-chip.streaming { + border-color: #d6e4ff; + background-color: #fbfdff; +} +.tool-chip summary { + padding: 8px 14px; font-size: 12.5px; color: #888; + cursor: pointer; user-select: none; + background-color: #fafafa; list-style: none; + display: flex; align-items: center; gap: 8px; font-weight: 500; +} +.tool-chip summary::-webkit-details-marker { display: none; } +.tool-chip summary .chev { color: #b8b8b8; } +.tool-chip[open] summary { border-bottom: 1px dashed #eaeaea; } +.tool-chip-icon { font-size: 13px; color: #bbb; } +.tool-chip-name { + font-family: Consolas, Monaco, monospace; + font-weight: 600; color: #666; font-size: 12px; +} +.tool-chip-brief { + font-family: Consolas, Monaco, monospace; + color: #aaa; font-size: 12px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; +} +.tool-chip-brief.done { color: #52c41a; } +.tool-chip-status { + margin-left: auto; flex-shrink: 0; + font-size: 11px; padding: 1px 8px; border-radius: 10px; +} +.tool-chip-status.running { + color: #4a7fd4; background: #eef4ff; + animation: toolPulse 1.2s ease-in-out infinite; +} +.tool-chip-status.ok { color: #52c41a; background: #f6ffed; } +.tool-chip-status.fail { color: #cf1322; background: #fff1f0; } +@keyframes toolPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} +.tool-chip-body { + padding: 10px 14px 12px 14px; + max-height: 260px; overflow: auto; background-color: #fff; +} +.tool-chip-label { + font-size: 11px; color: #bbb; font-weight: 600; + margin: 6px 0 4px 0; letter-spacing: 0.5px; +} +.tool-chip-label:first-child { margin-top: 0; } +.tool-chip-pre { + margin: 0 0 6px 0; padding: 8px 10px; + background-color: #f6f7f9; color: #555; + border: 1px solid #eef0f3; border-radius: 6px; + font-family: Consolas, Monaco, monospace; + font-size: 11.5px; line-height: 1.55; + white-space: pre-wrap; word-break: break-all; + max-height: 150px; overflow: auto; +} + +/* ==================== 系统提示(如:已自动压缩上下文) ==================== */ +.system-note { + text-align: center; + font-size: 12px; + color: #6b7280; + background: #f3f4f6; + border-radius: 10px; + padding: 6px 14px; + margin: 10px auto; + max-width: 70%; +} + +/* ==================== 增量流式渲染容器 ==================== */ +/* 注意:不能用 display:contents —— 本机 Chromium 下父盒(.md-segment)高度坍缩为 0, + 导致流式正文整段不可见(切会话重渲走无包裹路径所以可见)。用 block 规避。 */ +.md-stable, .md-tail { + display: block; +} + +/* ==================== 浅色 SVG 箭头(替代文字字形) ==================== */ +.chev { + flex: none; display: inline-block; + transition: transform 0.2s ease-in-out; +} +.think-block[open] .chev, .tool-chip[open] .chev { transform: rotate(90deg); } +.think-label { color: inherit; } + +/* ==================== 深度思考:默认收起 + 进行中蓝色呼吸动画 ==================== */ +.think-block.streaming-think > summary .think-label { + color: #3b82f6; + font-weight: 600; + animation: think-breathe 1.6s ease-in-out infinite; +} +.think-block.streaming-think > summary .think-label::after { + content: ''; + display: inline-block; + width: 1.2em; + text-align: left; + animation: think-dots 1.4s infinite; +} +@keyframes think-breathe { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +@keyframes think-dots { + 0% { content: ''; } + 25% { content: '.'; } + 50% { content: '..'; } + 75% { content: '...'; } +} + +/* ==================== 工具 chip:耗时 / 超时徽章 ==================== */ +.tool-chip-time { + font-size: 11px; + color: #9ca3af; + font-variant-numeric: tabular-nums; + margin-left: 2px; +} +.tool-chip-timeout { + font-size: 11px; + color: #dc2626; + font-weight: 600; + margin-left: 2px; +} +.tool-chip-livetimer { + /* 🆕 bash 运行中每秒读秒(N/Ts):琥珀色 + 等宽数字,不跳动 */ + font-size: 11px; + color: #d97706; + font-weight: 600; + font-variant-numeric: tabular-nums; + margin-left: 2px; +} + +/* ==================== 工具结果:长输出展开按钮 ==================== */ +.tool-expand-btn { + display: block; + width: 100%; + margin: 4px 0 0; + padding: 3px 8px; + font-size: 11px; + line-height: 1.5; + color: #3b82f6; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + cursor: pointer; + text-align: center; + font-family: inherit; +} +.tool-expand-btn:hover { background: #dbeafe; } +.tool-chip-pre.tool-chip-full { + max-height: 360px; + overflow: auto; +} + +/* ==================== 时间线:正文段 / 流式光标 ==================== */ +.md-segment:empty { display: none; } +.md-segment > :first-child { margin-top: 0; } +.md-segment > :last-child { margin-bottom: 0; } +/* block 包裹下,额外压平内层首尾段落的边距,保持气泡观感 */ +.md-segment > :last-child > :last-child { margin-bottom: 0; } +/* 光标 "Chasing a shining star":文字顶住不动,只有 ● 缩放闪烁(星光脉冲无限循环)。 + 生命周期=原版:消息第一个时间线事件出现即显示 → finishMessage 移除, + 思考/工具/压缩/正文全程保持(无休眠态) */ +.streaming-typing { + display: inline-block; margin-left: 4px; color: #3b82f6; + font-size: 13px; + font-family: "Cascadia Mono", "Cascadia Code", Consolas, "Lucida Console", monospace; +} +.streaming-typing .typing-dot { + display: inline-block; + animation: cursor-star 1.6s ease-in-out infinite; +} +@keyframes cursor-star { + 0%, 100% { + opacity: .25; + transform: scale(.85); + text-shadow: none; + } + 50% { + opacity: 1; + transform: scale(1.15); + text-shadow: 0 0 6px rgba(59, 130, 246, .8), 0 0 12px rgba(59, 130, 246, .45); + } +} + + +/* ========== 助手正文透明气泡(流式中/完成后一致) ========== + 🐛 修复:原选择器 .message.assistant 是死代码(JS 只挂 message-wrapper 类),背景从未渲染 */ +/* 🆕 用户决定:助手正文气泡【透明】——不要背景/边框/圆角/内边距(2026-07 像素取证调试后明确: + 原死选择器 P0 修复带来的浅灰底不是想要的效果),正文直接裸排在 85% 定宽列内 */ +.message-wrapper.assistant .reply-content .md-segment + .md-segment { + margin-top: 6px; +} + +/* ==================== 🆕 P1-01 渲染窗口:加载入口按钮 ==================== */ +/* 上/下两端的「加载更多」入口:无边框浅灰胶囊,居中;无更多消息时由 JS 隐藏 */ +.load-window-btn { + display: flex; + align-items: center; + justify-content: center; + width: 220px; + margin: 14px auto; + padding: 6px 14px; + font-size: 12px; + color: #666; + background: #f4f4f6; + border: 1px solid #e3e3e8; + border-radius: 999px; + cursor: pointer; + user-select: none; + transition: background 0.15s ease, color 0.15s ease; +} +.load-window-btn:hover { + background: #eaeaf0; + color: #333; +} +.load-window-btn[hidden] { + display: none; +} +/* 底部入口与 scroll-anchor 的间距(scroll-anchor 自带 150px margin) */ +#load-newer { + margin-bottom: 4px; +} diff --git a/vendor/webview2/net462_Microsoft.Web.WebView2.Core.dll b/vendor/webview2/net462_Microsoft.Web.WebView2.Core.dll new file mode 100644 index 0000000..23bfe7a Binary files /dev/null and b/vendor/webview2/net462_Microsoft.Web.WebView2.Core.dll differ diff --git a/vendor/webview2/sdk.nupkg b/vendor/webview2/sdk.nupkg new file mode 100644 index 0000000..16a2cb8 Binary files /dev/null and b/vendor/webview2/sdk.nupkg differ diff --git a/vendor/webview2/webview2loader_x64.dll b/vendor/webview2/webview2loader_x64.dll new file mode 100644 index 0000000..965aed4 Binary files /dev/null and b/vendor/webview2/webview2loader_x64.dll differ