From a7412824e0c7bd33ae8787034720efef00313d04 Mon Sep 17 00:00:00 2001 From: sorrow404null Date: Thu, 17 Sep 2026 16:40:01 +0800 Subject: [PATCH] chore: import original project baseline Import the pre-repair source tree as the history baseline. Runtime data (data/), virtualenvs, bytecode caches and logs are gitignored so local secrets and user state stay out of the repo. --- .gitignore | 10 + ARCHITECTURE.md | 235 + Frame.md | 288 + SYSTEM_PROMPT.md | 175 + WebView2Loader.dll | Bin 0 -> 163680 bytes core/__init__.py | 0 core/agent/__init__.py | 58 + core/agent/agent.py | 204 + core/agent/compaction.py | 637 ++ core/agent/context.py | 341 + core/agent/loop.py | 482 ++ core/agent/recovery.py | 519 ++ core/agent/stream_fn.py | 438 ++ core/agent/tools.py | 873 +++ core/agent/types.py | 331 + core/db_manager.py | 715 ++ core/debug_log.py | 78 + core/llm_engine.py | 446 ++ core/webview2.py | 406 ++ main.py | 111 + readme.md | 301 + requirements.txt | 45 + svg/check.svg | 3 + svg/chevron_down.svg | 4 + svg/chevron_right.svg | 4 + svg/cross.svg | 3 + svg/main.svg | 1 + svg/mode_chat.svg | 1 + svg/mode_worker.svg | 1 + svg/model.svg | 1 + svg/panel.svg | 4 + svg/panel_right.svg | 4 + svg/provider.svg | 1 + svg/provider2.svg | 1 + svg/provider3.svg | 1 + svg/send.svg | 3 + svg/send_white.svg | 3 + svg/stop.svg | 3 + svg/stop_white.svg | 3 + svg/upload.svg | 3 + tests/__init__.py | 0 tests/check_db_migration.py | 77 + tests/debug_inject.py | 48 + tests/diag_live_agent.py | 85 + tests/diag_live_onscreen.py | 186 + tests/diag_live_text.py | 187 + tests/diag_panel_scrollbar.py | 75 + tests/diag_rename_overlay.py | 74 + tests/diag_render_scale.py | 90 + tests/inject_math_demo.py | 118 + tests/run_tests.py | 33 + tests/smoke_bash_panel.py | 614 ++ tests/smoke_copy_session.py | 156 + tests/smoke_live_guard.py | 280 + tests/smoke_manual.py | 42 + tests/smoke_midswitch.py | 199 + tests/smoke_mode.py | 94 + tests/smoke_offscreen.py | 201 + tests/smoke_persist.py | 297 + tests/smoke_probe.py | 64 + tests/smoke_repro_real.py | 127 + tests/smoke_timeline.py | 191 + tests/test_agent_core.py | 1319 ++++ tests/test_bash_stream.py | 197 + tests/test_compaction_persist.py | 287 + tests/test_copy_session.py | 336 + tests/test_debug_window.py | 139 + tests/test_error_persist.py | 368 + tests/test_file_attach.py | 87 + tests/test_math_extract.js | 123 + tests/test_pdf_reader.py | 92 + tests/test_think_code_neutral.py | 115 + tests/test_tool_params.py | 255 + tests/test_wv2_guard.py | 129 + tests/tune_mode_popup.py | 186 + tests/tune_model_popup.py | 399 ++ tests/verify_math_render.py | 151 + tests/verify_onscreen.py | 84 + tools/__init__.py | 0 tools/builtin_tools/__init__.py | 0 tools/builtin_tools/pdf_reader.py | 161 + ui/views/__init__.py | 0 ui/views/bash_panel.py | 944 +++ ui/views/chat_bridge.py | 188 + ui/views/custom_web_page.py | 66 + ui/views/debug_window.py | 214 + ui/views/main_window.py | 6019 +++++++++++++++++ ui/views/system_tools/__init__.py | 7 + ui/views/system_tools/file_reader.py | 71 + ui/views/system_tools/global_hotkey.py | 105 + ui/views/system_tools/screen_capture.py | 193 + ui/views/wv2_view.py | 217 + ui/web/app.js | 2111 ++++++ ui/web/dompurify.min.js | 3 + ui/web/highlight/atom-one-dark.min.css | 1 + ui/web/highlight/highlight.min.js | 1213 ++++ ui/web/index.html | 82 + ui/web/katex/fonts/KaTeX_AMS-Regular.woff2 | Bin 0 -> 28076 bytes .../katex/fonts/KaTeX_Caligraphic-Bold.woff2 | Bin 0 -> 6912 bytes .../fonts/KaTeX_Caligraphic-Regular.woff2 | Bin 0 -> 6908 bytes ui/web/katex/fonts/KaTeX_Fraktur-Bold.woff2 | Bin 0 -> 11348 bytes .../katex/fonts/KaTeX_Fraktur-Regular.woff2 | Bin 0 -> 11316 bytes ui/web/katex/fonts/KaTeX_Main-Bold.woff2 | Bin 0 -> 25324 bytes .../katex/fonts/KaTeX_Main-BoldItalic.woff2 | Bin 0 -> 16780 bytes ui/web/katex/fonts/KaTeX_Main-Italic.woff2 | Bin 0 -> 16988 bytes ui/web/katex/fonts/KaTeX_Main-Regular.woff2 | Bin 0 -> 26272 bytes .../katex/fonts/KaTeX_Math-BoldItalic.woff2 | Bin 0 -> 16400 bytes ui/web/katex/fonts/KaTeX_Math-Italic.woff2 | Bin 0 -> 16440 bytes ui/web/katex/fonts/KaTeX_SansSerif-Bold.woff2 | Bin 0 -> 12216 bytes .../katex/fonts/KaTeX_SansSerif-Italic.woff2 | Bin 0 -> 12028 bytes .../katex/fonts/KaTeX_SansSerif-Regular.woff2 | Bin 0 -> 10344 bytes ui/web/katex/fonts/KaTeX_Script-Regular.woff2 | Bin 0 -> 9644 bytes ui/web/katex/fonts/KaTeX_Size1-Regular.woff2 | Bin 0 -> 5468 bytes ui/web/katex/fonts/KaTeX_Size2-Regular.woff2 | Bin 0 -> 5208 bytes ui/web/katex/fonts/KaTeX_Size3-Regular.woff2 | Bin 0 -> 3624 bytes ui/web/katex/fonts/KaTeX_Size4-Regular.woff2 | Bin 0 -> 4928 bytes .../fonts/KaTeX_Typewriter-Regular.woff2 | Bin 0 -> 13568 bytes ui/web/katex/katex.min.css | 1 + ui/web/katex/katex.min.js | 1 + ui/web/marked.min.js | 6 + ui/web/style.css | 902 +++ .../net462_Microsoft.Web.WebView2.Core.dll | Bin 0 -> 698208 bytes vendor/webview2/sdk.nupkg | Bin 0 -> 9259926 bytes vendor/webview2/webview2loader_x64.dll | Bin 0 -> 163680 bytes 124 files changed, 26747 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 Frame.md create mode 100644 SYSTEM_PROMPT.md create mode 100644 WebView2Loader.dll create mode 100644 core/__init__.py create mode 100644 core/agent/__init__.py create mode 100644 core/agent/agent.py create mode 100644 core/agent/compaction.py create mode 100644 core/agent/context.py create mode 100644 core/agent/loop.py create mode 100644 core/agent/recovery.py create mode 100644 core/agent/stream_fn.py create mode 100644 core/agent/tools.py create mode 100644 core/agent/types.py create mode 100644 core/db_manager.py create mode 100644 core/debug_log.py create mode 100644 core/llm_engine.py create mode 100644 core/webview2.py create mode 100644 main.py create mode 100644 readme.md create mode 100644 requirements.txt create mode 100644 svg/check.svg create mode 100644 svg/chevron_down.svg create mode 100644 svg/chevron_right.svg create mode 100644 svg/cross.svg create mode 100644 svg/main.svg create mode 100644 svg/mode_chat.svg create mode 100644 svg/mode_worker.svg create mode 100644 svg/model.svg create mode 100644 svg/panel.svg create mode 100644 svg/panel_right.svg create mode 100644 svg/provider.svg create mode 100644 svg/provider2.svg create mode 100644 svg/provider3.svg create mode 100644 svg/send.svg create mode 100644 svg/send_white.svg create mode 100644 svg/stop.svg create mode 100644 svg/stop_white.svg create mode 100644 svg/upload.svg create mode 100644 tests/__init__.py create mode 100644 tests/check_db_migration.py create mode 100644 tests/debug_inject.py create mode 100644 tests/diag_live_agent.py create mode 100644 tests/diag_live_onscreen.py create mode 100644 tests/diag_live_text.py create mode 100644 tests/diag_panel_scrollbar.py create mode 100644 tests/diag_rename_overlay.py create mode 100644 tests/diag_render_scale.py create mode 100644 tests/inject_math_demo.py create mode 100644 tests/run_tests.py create mode 100644 tests/smoke_bash_panel.py create mode 100644 tests/smoke_copy_session.py create mode 100644 tests/smoke_live_guard.py create mode 100644 tests/smoke_manual.py create mode 100644 tests/smoke_midswitch.py create mode 100644 tests/smoke_mode.py create mode 100644 tests/smoke_offscreen.py create mode 100644 tests/smoke_persist.py create mode 100644 tests/smoke_probe.py create mode 100644 tests/smoke_repro_real.py create mode 100644 tests/smoke_timeline.py create mode 100644 tests/test_agent_core.py create mode 100644 tests/test_bash_stream.py create mode 100644 tests/test_compaction_persist.py create mode 100644 tests/test_copy_session.py create mode 100644 tests/test_debug_window.py create mode 100644 tests/test_error_persist.py create mode 100644 tests/test_file_attach.py create mode 100644 tests/test_math_extract.js create mode 100644 tests/test_pdf_reader.py create mode 100644 tests/test_think_code_neutral.py create mode 100644 tests/test_tool_params.py create mode 100644 tests/test_wv2_guard.py create mode 100644 tests/tune_mode_popup.py create mode 100644 tests/tune_model_popup.py create mode 100644 tests/verify_math_render.py create mode 100644 tests/verify_onscreen.py create mode 100644 tools/__init__.py create mode 100644 tools/builtin_tools/__init__.py create mode 100644 tools/builtin_tools/pdf_reader.py create mode 100644 ui/views/__init__.py create mode 100644 ui/views/bash_panel.py create mode 100644 ui/views/chat_bridge.py create mode 100644 ui/views/custom_web_page.py create mode 100644 ui/views/debug_window.py create mode 100644 ui/views/main_window.py create mode 100644 ui/views/system_tools/__init__.py create mode 100644 ui/views/system_tools/file_reader.py create mode 100644 ui/views/system_tools/global_hotkey.py create mode 100644 ui/views/system_tools/screen_capture.py create mode 100644 ui/views/wv2_view.py create mode 100644 ui/web/app.js create mode 100644 ui/web/dompurify.min.js create mode 100644 ui/web/highlight/atom-one-dark.min.css create mode 100644 ui/web/highlight/highlight.min.js create mode 100644 ui/web/index.html create mode 100644 ui/web/katex/fonts/KaTeX_AMS-Regular.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Caligraphic-Bold.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Caligraphic-Regular.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Fraktur-Bold.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Fraktur-Regular.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Main-Bold.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Main-BoldItalic.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Main-Italic.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Main-Regular.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Math-BoldItalic.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Math-Italic.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_SansSerif-Bold.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_SansSerif-Italic.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_SansSerif-Regular.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Script-Regular.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Size1-Regular.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Size2-Regular.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Size3-Regular.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Size4-Regular.woff2 create mode 100644 ui/web/katex/fonts/KaTeX_Typewriter-Regular.woff2 create mode 100644 ui/web/katex/katex.min.css create mode 100644 ui/web/katex/katex.min.js create mode 100644 ui/web/marked.min.js create mode 100644 ui/web/style.css create mode 100644 vendor/webview2/net462_Microsoft.Web.WebView2.Core.dll create mode 100644 vendor/webview2/sdk.nupkg create mode 100644 vendor/webview2/webview2loader_x64.dll diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..767ba80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python +__pycache__/ +*.pyc +.venv/ + +# Runtime data: config with API keys, database, attachments, locks. Never commit. +data/ + +# Diagnostics / logs +*.log diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..bd00d3b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,235 @@ +# 项目文件架构 + +> 仅描述目录与文件的基础组织,不涉及具体实现细节。 + +``` +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..eaa23e6 --- /dev/null +++ b/SYSTEM_PROMPT.md @@ -0,0 +1,175 @@ +# haocode Agent 系统提示词 + +## 0. 身份 + +你是 **haocode**(worker 模式)的本地智能体,运行在用户本机的 PyQt6 桌面应用内, +通过内嵌浏览器界面与用户对话。你具备代码理解与修改能力,并能调用工具直接操作 +本机文件与 shell。你的目标是把用户交代的任务**真正做完**(改文件、跑命令、验证结果), +而不是只给出建议或描述该怎么做。 + +- 回答语言:**简体中文**;代码、命令、路径、标识符保持原文。 +- 你在一个多轮循环里工作:可以连续调用工具、观察结果、再决定下一步, + 直到任务完成或确实需要用户决策为止。 + +## 1. 运行环境 + +- 操作系统:Windows。文件路径形如 `C:\Users\14890\Desktop\haocode`。 +- 当前工作目录:**haocode 项目根目录**。所有相对路径都相对它解析; + 每条 bash 命令都以它作为工作目录启动。 +- Python:conda 环境 `haocode`(Python 3.10,PyQt6、openai 已装),直接用 `python`。 +- 前端是本地网页(`ui/web/`),改动前端文件后需重启应用才生效。 + +### 1.1 shell 真相(重要:直接决定命令能不能跑对) + +命令经 **cmd.exe** 执行(不是 git-bash)。但 `C:\Program Files\Git\usr\bin` 在 PATH 上, +所以 `ls` `grep` `cat` `head` `tail` `wc` `rm` `sed` `awk` 都能直接用, +管道 `|`、重定向 `>` `2>&1`、`&&` 也都可用。 + +⚠️ 下列写法会**静默出错**或报错,务必按右列的写法: + +| ❌ 不要写 | ✅ 改成 | 原因 | +|---|---|---| +| `echo a; echo b` | `echo a && echo b`(或分两行写) | cmd 不认 `;`,会把 `; echo b` 当参数原样输出 | +| `echo $HOME` | `echo %USERPROFILE%` | cmd 用 `%VAR%`;`$VAR` 不会被展开 | +| `for i in 1 2 3; do ...; done` | `bash -c "for i in 1 2 3; do ...; done"` | bash 语法必须显式调用 bash | +| 单独一条 `cd core` | `cd core && <命令>` | **每次调用都是新进程,cd 不会跨调用保留** | +| `echo 'x'` | `echo x` | cmd 内建命令不剥单引号(`grep 'x'` 等 msys 程序会正常剥) | + +多条命令用**换行**分隔最稳(实测可用)。需要 `$(...)`、单引号、`[ ]` 测试等真实 +bash 语义时,一律写成 `bash -c "..."`。 + +## 2. 可用工具 + +| 工具 | 用途 | 关键约束 | +|---|---|---| +| `read` | 读取**文本**文件(带行号) | 单次 ≤2000 行 / 50KB;大文件用 `offset`/`limit` 分页;**不要用于图片或二进制** | +| `bash` | 执行 shell 命令 | 经 cmd.exe;默认 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 | ✅ | — | 要执行的命令(cmd.exe 语法,可用 Git 的 unix 工具) | +| `timeout` | number | | 120 | 超时秒数,**上限 600**(传更大按 600) | + +- 返回值:`$ 命令` + stdout + `[stderr]` + `[exit N] (耗时)`;**退出码非 0 时结果视为失败**。 +- 超时:到期会**杀掉整棵进程树**并返回 `命令超时(>Ns)已终止`。 + 长任务(全量测试、构建、下载)请显式传 `timeout`;短查询不必传。 +- 输出超过 50KB 会被截断 → 用 `-n` / `head` / `findstr` 或更精确的命令收窄输出后再逐步放宽。 +- 需要等待的场景直接跑命令并设好 `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` 的 `dir` / `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` / `dir`)→ 精读(`read`)→ 改动(`edit` / `write`)→ 验证(`bash`)。 +5. **范围克制**:只做用户要求的事;不顺手重构、不批量格式化、不改无关文件。 +6. **不谎报**:没跑过的命令不说「已运行」;没读到的内容不说「文件里是…」;失败就照实说失败。 +7. **输出克制**:结论先行、简洁;长内容用列表/表格;不复述用户原话;涉及文件时写清路径。 +8. **连续行动**:一个任务需要多步就直接连续调用工具,中间不必停下来征求同意。 +9. **该停就停**:遇到破坏性操作、路径歧义、多种方案取舍等需要用户决策的情况,先问再做。 + +## 7. 安全红线(必须遵守) + +- **禁止**删除或覆盖项目外的文件;**禁止** `rm -rf`、`del /s`、格式化等破坏性命令 + (除非用户在本轮明确要求并给出路径)。 +- **禁止**读取或输出 `data/config.json` 中的 API 密钥等敏感内容 + (可以确认文件存在,但不要展示内容)。 +- **禁止**向 conda 环境 `haocode` 安装或卸载包;**禁止**修改系统目录、注册表、环境变量。 +- 网络请求只允许用户已配置的 API 端点;不要主动上传数据或抓取外部内容。 +- 涉及用户数据(`data/*.db`)默认只读;除用户明确要求,不要写入或迁移数据。 + +## 8. 会话与上下文 + +- **上下文可能被自动压缩**:压缩后你看到的是摘要加最近若干轮。以摘要为准继续, + 不要假装记得已被压缩掉的细节;需要时重新用工具查证。 +- **输出可能被截断**:若收到「未执行(length)」类提示,说明上一轮输出触达长度上限, + 请缩短回答或把任务拆成更小的步骤。 +- **工具调用与结果会留在历史里**:不要重复已经做过的探查,也不要凭「上一轮我大概做过什么」 + 下结论而不看返回结果。 +- 用户可能随时中止(`操作已中止`):如实说明当前进度,不要继续动作。 diff --git a/WebView2Loader.dll b/WebView2Loader.dll new file mode 100644 index 0000000000000000000000000000000000000000..965aed4e5e448a9920a53fdae3d774df4a8f8e44 GIT binary patch literal 163680 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 z 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 literal 0 HcmV?d00001 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..8947eef --- /dev/null +++ b/core/agent/compaction.py @@ -0,0 +1,637 @@ +""" +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) + settings: CompactionSettings = 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..714b246 --- /dev/null +++ b/core/agent/tools.py @@ -0,0 +1,873 @@ +""" +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) + +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() + # 对照 pi bash 工具:命令始终经 shell 解释(支持管道/别名/内置命令) + use_shell = ctx.get("shell", True) + 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 上默认 kill 只杀 cmd 壳,孤儿子进程继续 + 持管道 → 假超时(设定10s 实际20s)。taskkill /T 整树杀。""" + try: + if os.name == "nt": + subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True, timeout=10) + else: + proc.kill() + except Exception: + try: + proc.kill() + except Exception: + pass + + # 🐛 修复: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( + command, shell=use_shell, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", errors="replace", + cwd=cwd, env=_env, + ) + 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/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..2ae8102 --- /dev/null +++ b/core/llm_engine.py @@ -0,0 +1,446 @@ +""" +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 + +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "config.json") +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;缺失时用兜底短提示词。""" + try: + with open(SYSTEM_PROMPT_FILE, "r", encoding="utf-8") as f: + text = f.read().strip() + if text: + return text + except FileNotFoundError: + pass + return _FALLBACK_SYSTEM_PROMPT + + +def _load_config() -> dict: + if os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f"[llm_engine] 读取配置失败: {e}") + return {} + + +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/webview2.py b/core/webview2.py new file mode 100644 index 0000000..25cf765 --- /dev/null +++ b/core/webview2.py @@ -0,0 +1,406 @@ +# -*- 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 + clr.AddReference(CORE_DLL) + 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 Exception as ex: + _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/main.py b/main.py new file mode 100644 index 0000000..521408e --- /dev/null +++ b/main.py @@ -0,0 +1,111 @@ +# 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__))) + +# 导入主窗口类 +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..7cafecd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,45 @@ +# 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)+ 其加载器 ---- +pythonnet==3.1.0 +clr_loader==0.3.1 + +# ---- PDF 附件解析(ui/views/main_window.py → tools/builtin_tools/pdf_reader.py)---- +PyMuPDF==1.28.0 + +# =========================================================================== +# 以下仅在需要时安装 +# =========================================================================== + +# ---- 打包 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/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..aa7670b --- /dev/null +++ b/tests/diag_panel_scrollbar.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""问题 3 取证:右侧栏 bash 层「参数/输出」框的滚动条 QSS 现状(离屏截图 + 几何/样式打印)""" +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +import core.db_manager as _dbm # noqa: E402 +_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q3_{os.getpid()}.db") +_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q3_{os.getpid()}.json") +open(_cfg, "w", encoding="utf-8").write('{"providers": {}}') +os.environ["HAOCODE_CONFIG_FILE"] = _cfg + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtTest import QTest # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 + +app = QApplication(sys.argv) +w = MainWindow() +w.resize(1400, 950) +w.show() +for _ in range(40): + app.processEvents() + QTest.qWait(20) + +p = w.bash_panel +p.expand_btn.click() +for _ in range(30): + app.processEvents() + QTest.qWait(20) + +# 造一个「已完成 + 长输出」的层(长输出会把垂直/水平滚动条都逼出来) +long_cmd = "python train.py --epochs 300 --batch-size 64 --lr 0.0001 --data /data/detr/annotations.json" +long_out = "\n".join( + f"[{i:04d}] epoch loss=0.{i % 9}{i % 7} lr=0.0001 mAP=0.{40 + i % 20} " + f"very_long_tail_to_force_horizontal_scrollbar_{i}" + for i in range(120)) +p.on_started("q3_lay", "bash", {"command": long_cmd}) +p.on_finished("q3_lay", "bash", True, long_out) +for _ in range(20): + app.processEvents() + QTest.qWait(20) + +lay = p._layers["q3_lay"] +lay.toggle() # 展开层 → 显示「参数」「输出」两块 +for _ in range(20): + app.processEvents() + QTest.qWait(20) + +for nm, box in (("参数 arg_box", lay.arg_box), ("输出 out_box", lay.out_box)): + vb = box.verticalScrollBar() + hb = box.horizontalScrollBar() + print(f"--- {nm}") + print(f" objectName={box.objectName()} 尺寸={box.width()}x{box.height()}") + print(f" 垂直滚动条: 可见={vb.isVisible()} 宽={vb.width()} 需要={vb.maximum() > 0}") + print(f" 水平滚动条: 可见={hb.isVisible()} 高={hb.height()} 需要={hb.maximum() > 0}") + print(f" box 自身 stylesheet = {box.styleSheet()!r}") + print(f" box 背景角色 = {box.palette().base().color().name()}") + +img = lay.grab().toImage() +out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q3_layer.png") +img.save(out) +print("\n层截图 ->", out, img.width(), "x", img.height()) + +# 再抓取面板整块(看滚动条在面板里的观感) +img2 = p.grab().toImage() +out2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q3_panel.png") +img2.save(out2) +print("面板截图 ->", out2, img2.width(), "x", img2.height()) + +os.remove(_cfg) diff --git a/tests/diag_rename_overlay.py b/tests/diag_rename_overlay.py new file mode 100644 index 0000000..8aa27ef --- /dev/null +++ b/tests/diag_rename_overlay.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""问题 5 取证:会话改名覆盖层(RenameOverlay)的"聚光灯"遮罩现状""" +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +import core.db_manager as _dbm # noqa: E402 +_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q5_{os.getpid()}.db") +_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q5_{os.getpid()}.json") +open(_cfg, "w", encoding="utf-8").write('{"providers": {}}') +os.environ["HAOCODE_CONFIG_FILE"] = _cfg + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtTest import QTest # noqa: E402 +from ui.views.main_window import MainWindow, RenameOverlay, SessionContextPopup # noqa: E402 + +app = QApplication(sys.argv) +w = MainWindow() +w.resize(1300, 900) +w.show() +for _ in range(40): + app.processEvents() + QTest.qWait(20) + +sid = w.db.create_session("聚光灯测试会话")["id"] +w.load_messages_to_web(sid) +for _ in range(20): + app.processEvents() + QTest.qWait(20) + +print("=== SessionContextPopup 是否为独立顶层窗口 ===") +print(" windowFlags 含 Qt.Popup ?", bool(SessionContextPopup(sid, "", w).windowFlags() & 0x80000000)) +pop = SessionContextPopup(sid, "聚光灯测试会话", w) +print(" isWindow() =", pop.isWindow(), " windowType =", pop.windowFlags()) + +print() +print("=== RenameOverlay 现状 ===") +w._rename_session(sid) +for _ in range(25): + app.processEvents() + QTest.qWait(20) + +ovs = [c for c in w.children() if isinstance(c, RenameOverlay)] +if not ovs: + print(" ✗ 没找到 RenameOverlay") +else: + ov = ovs[0] + print(" 父对象 =", ov.parent().__class__.__name__) + print(" isWindow() =", ov.isWindow(), " (False = 主窗口的子控件,只覆盖客户区)") + print(" 几何 =", ov.geometry()) + print(" 主窗口 rect =", w.rect(), " 主窗口 frameGeometry =", w.frameGeometry()) + print(" WA_TranslucentBackground =", ov.testAttribute( + __import__('PyQt6.QtCore', fromlist=['Qt']).Qt.WidgetAttribute.WA_TranslucentBackground)) + print(" graphicsEffect =", type(ov.graphicsEffect()).__name__ if ov.graphicsEffect() else None) + print(" 遮罩色 =", ov._overlay_color.getRgb()) + print(" form 几何 =", ov.form.geometry(), " form 是否在遮罩内 =", + ov.rect().contains(ov.form.geometry())) + # 看遮罩是否盖住了标题栏 / 是否超出窗口 + print(" 遮罩 top-left(全局) =", ov.mapToGlobal(ov.rect().topLeft()), + " bottom-right(全局) =", ov.mapToGlobal(ov.rect().bottomRight())) + print(" 主窗口(全局) =", w.mapToGlobal(w.rect().topLeft()), w.mapToGlobal(w.rect().bottomRight())) + out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q5_overlay.png") + ov.grab().save(out) + print(" 覆盖层截图 ->", out, ov.width(), "x", ov.height()) + out2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q5_window.png") + w.grab().save(out2) + print(" 主窗口截图 ->", out2, w.width(), "x", w.height()) + +os.remove(_cfg) diff --git a/tests/diag_render_scale.py b/tests/diag_render_scale.py new file mode 100644 index 0000000..9b084f1 --- /dev/null +++ b/tests/diag_render_scale.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +"""问题 1 取证:长会话渲染的真实 DOM 规模与耗时(离屏,临时库)""" +import os +import sys +import time +import json +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +import core.db_manager as _dbm # noqa: E402 +_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q1_{os.getpid()}.db") +_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q1_{os.getpid()}.json") +open(_cfg, "w", encoding="utf-8").write('{"providers": {}}') +os.environ["HAOCODE_CONFIG_FILE"] = _cfg + +from PyQt6.QtWidgets import QApplication # noqa: E402 +from PyQt6.QtTest import QTest # noqa: E402 +from ui.views.main_window import MainWindow # noqa: E402 + +N = int(sys.argv[1]) if len(sys.argv) > 1 else 200 + +app = QApplication(sys.argv) +w = MainWindow() +w.resize(1200, 850) +w.show() +for _ in range(30): + app.processEvents() + QTest.qWait(20) + +sid = w.db.create_session(f"长会话渲染取证 {N} 条")["id"] +parent = None +# 造 N 轮(每轮 user + assistant,assistant 带代码块 + 工具时间线,贴近真实负载) +for i in range(N // 2): + parent = w.db.add_message(session_id=sid, role="user", + content=f"第 {i} 个问题:帮我看看这段代码\n```python\nprint({i})\n```", + parent_id=parent)["id"] + tl = json.dumps([ + {"t": "think", "text": "分析中…" * 20}, + {"t": "text", "text": f"### 回答 {i}\n\n要点如下:\n\n- 第一条\n- 第二条\n\n```python\nfor j in range(10):\n print(j)\n```\n"}, + {"t": "tool", "id": f"c{i}", "name": "bash", "args": '{"command":"echo hi"}', + "ok": True, "result": "hi\n" * 30}, + ], ensure_ascii=False) + parent = w.db.add_message(session_id=sid, role="assistant", + content=f"回答 {i}:见代码块与工具结果。", + parent_id=parent, timeline=tl)["id"] + +chain = w.db.get_message_chain(sid) +print(f"库内消息条数 = {len(chain)}") + +t0 = time.time() +w.load_messages_to_web(sid) +for _ in range(80): + app.processEvents() + QTest.qWait(25) +dt = time.time() - t0 + +holder = {} +done = [] + + +def got(res): + holder["dom"] = res + done.append(1) + + +w.browser.page().runJavaScript( + "JSON.stringify({wrappers: document.querySelectorAll('.message-wrapper').length," + " nodes: document.getElementsByTagName('*').length," + " height: document.scrollingElement.scrollHeight," + " codeBlocks: document.querySelectorAll('.code-block-wrapper').length," + " katex: document.querySelectorAll('.katex').length})", got) +for _ in range(60): + app.processEvents() + QTest.qWait(25) + if done: + break + +print(f"渲染耗时 ≈ {dt:.2f}s") +print("DOM 统计 =", holder.get("dom")) +if holder.get("dom"): + d = json.loads(holder["dom"]) + print(f" → 消息节点 {d['wrappers']} 个 / 全 DOM 节点 {d['nodes']} 个 / " + f"页面总高 {d['height']}px / 代码块 {d['codeBlocks']} 个") + print(f" → 平均每条消息 {d['nodes'] // max(1, d['wrappers'])} 个 DOM 节点") + +os.remove(_cfg) 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_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..8a61edb --- /dev/null +++ b/tests/smoke_bash_panel.py @@ -0,0 +1,614 @@ +# -*- 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") + +# 铁律:测试不得污染真实 data/chat_history.db +import core.db_manager as _dbm # noqa: E402 +_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_bashpanel_{os.getpid()}.db") +if os.path.exists(_DB_TMP): + os.remove(_DB_TMP) +_dbm._DEFAULT_DB = _DB_TMP + +# 铁律:宽度记录会写 config.json → 测试指向临时配置,绝不碰真实 data/config.json +_CFG_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_cfg_{os.getpid()}.json") +with open(_CFG_TMP, "w", encoding="utf-8") as _f: + _f.write('{"providers": {}, "mode_switch": true}') +os.environ["HAOCODE_CONFIG_FILE"] = _CFG_TMP + +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)) + +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) +sys.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..e9aad7c --- /dev/null +++ b/tests/smoke_midswitch.py @@ -0,0 +1,199 @@ +# -*- 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 + +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..78103c2 --- /dev/null +++ b/tests/smoke_offscreen.py @@ -0,0 +1,201 @@ +"""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") + +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..9d5964e --- /dev/null +++ b/tests/smoke_timeline.py @@ -0,0 +1,191 @@ +# -*- 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 + +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..3f98140 --- /dev/null +++ b/tests/test_agent_core.py @@ -0,0 +1,1319 @@ +""" +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__), "..")) + +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..70a2949 --- /dev/null +++ b/tests/test_compaction_persist.py @@ -0,0 +1,287 @@ +# -*- 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() +check("T9.渲染过滤含 compaction", 'msg["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_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_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..0d5eb82 --- /dev/null +++ b/tests/test_error_persist.py @@ -0,0 +1,368 @@ +# -*- 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") + +# 铁律:测试不得污染真实 data/chat_history.db +import core.db_manager as _dbm # noqa: E402 +_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_errpersist_{os.getpid()}.db") +if os.path.exists(_DB_TMP): + os.remove(_DB_TMP) +_dbm._DEFAULT_DB = _DB_TMP + +# 铁律:不得污染真实 data/config.json +_CFG_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_errcfg_{os.getpid()}.json") +with open(_CFG_TMP, "w", encoding="utf-8") as _f: + _f.write('{"providers": {}}') +os.environ["HAOCODE_CONFIG_FILE"] = _CFG_TMP + +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_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_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..a3b1ebe --- /dev/null +++ b/ui/views/bash_panel.py @@ -0,0 +1,944 @@ +# -*- coding: utf-8 -*- +"""右侧任务面板:本次会话的 bash 任务监控(运行中 / 已完成) + +统一口径(与用户确认): + · 两栏 = 「运行中」/「已完成」(按状态分);两栏【同时可见、不互斥】, + 中间是可拖动分隔线(260px 宽若真做左右并排,每栏仅 ~130px, + 展开区放不下「参数 + 输出」两块列表,故用上下两栏)。 + 若要严格左右并排:把 _SPLIT_ORIENTATION 改成 Horizontal 即可。 + · 一次 bash = 一层(BashLayer),自上而下按执行先后排列 + · 单击层头 = 展开/收起;展开后分「参数」「输出」两块 + · 运行中:输出为 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" +_CFG_PATH = os.path.join(_ROOT, "data", "config.json") + + +def _cfg_path() -> str: + """配置文件路径(测试可用 HAOCODE_CONFIG_FILE 指向临时文件 → 绝不污染真配置)""" + return os.environ.get("HAOCODE_CONFIG_FILE") or _CFG_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 = [] # 执行先后 + 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): + 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 run_ids]) + shown = done_ids[-LAYER_LIMIT:] + self.sec_done.set_layers([self._layers[c] for c in 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"): + if which == "running": + return [c for c in self._order if c in self._running] + if which == "done": + return [c for c in self._order if c in self._done] + return list(self._order) diff --git a/ui/views/chat_bridge.py b/ui/views/chat_bridge.py new file mode 100644 index 0000000..c24db1e --- /dev/null +++ b/ui/views/chat_bridge.py @@ -0,0 +1,188 @@ +# 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 + 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 + 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();") + + # ---------- 交互 Slot (JS -> Python) ---------- + + @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..781d16b --- /dev/null +++ b/ui/views/custom_web_page.py @@ -0,0 +1,66 @@ +from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineScript +from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QDesktopServices + +class CustomWebPage(QWebEnginePage): + """ + 自定义网页类,集中约束内置 QtWebEngine 的不合规浏览器行为: + + 1. 拦截所有外部链接跳转,改用系统默认浏览器打开; + 2. 禁用 Ctrl/Meta + 滚轮 及 Ctrl + +/-/0 快捷键的页面缩放 + (浏览器式缩放对桌面聊天工具无意义且易误触)。 + """ + def __init__(self, parent=None): + super().__init__(parent) + 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..2b115e0 --- /dev/null +++ b/ui/views/main_window.py @@ -0,0 +1,6019 @@ +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 # 🌟 新增:引入桥接器 +# 🆕 WebView2 后端(A′ 双轨:Windows 首选,任何失败自动回落 QtWebEngine) +try: + from core import webview2 as _wv2mod +except Exception as _wv2_imp_err: # pythonnet 缺失 / 非 Windows 等 + _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 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): + """无边框重命名 — 主窗口内模态覆盖层""" + renamed = QtCore.pyqtSignal(str) # 发射新标题 + + def __init__(self, current_title, parent=None): + super().__init__(parent) + # 覆盖整个主窗口 + self.setGeometry(parent.rect()) + self.setStyleSheet("background: transparent;") + + # 半透明灰色遮罩 + 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; + } + """) + self._center_form() + + 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.show() + self.input.setFocus() + self._animate_in() + + 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): + self._opacity_effect = QtWidgets.QGraphicsOpacityEffect(self) + self.setGraphicsEffect(self._opacity_effect) + self._anim = QtCore.QPropertyAnimation(self._opacity_effect, b"opacity") + 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() + + # ---- 表单拖拽(限制在主窗口内)---- + 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 + + # ---- 主窗口 resize 时跟随 ---- + def resizeEvent(self, event): + self.setGeometry(self.parentWidget().rect()) + self._center_form() + + def confirm(self): + title = self.input.text().strip() + if title: + self._result = title + self.renamed.emit(title) + self.close_overlay() + + def close_overlay(self): + 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) + self._wv2_session = None + if _wv2mod is not None and sys.platform == "win32": + _backend_pref = "auto" + try: + _root_cfg = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) + with open(os.path.join(_root_cfg, "data", "config.json"), "r", encoding="utf-8") as _f: + _backend_pref = json.load(_f).get("webview_backend", "auto") + except Exception: + pass + if _backend_pref in ("auto", "webview2"): + _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 路径(原逻辑,保持可用) ---- + self.browser = QWebEngineView() + # 🌟 关键修改:使用自定义的 WebPage + custom_page = CustomWebPage(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): + """初始化浏览器设置、信号绑定及页面加载""" + + # 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) + # 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; } + #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) + + + # ==================== 事件拦截 ==================== + def eventFilter(self, obj, event): + 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 + else: + # 🌟 修改:只有在非生成状态下,按回车才会发送;生成时按回车无效,防止误触 + # 回车发送检查改为: + if self.current_session_id not in self._active_streams: + self.send_message() + 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) + # Alt+S 全局热键:独立线程注册系统级热键,窗口失焦也能触发 + self.hotkey_screenshot = GlobalHotkeyThread() + self.hotkey_screenshot.triggered.connect(self._start_screenshot) + self.hotkey_screenshot.start() + # 非 Windows 平台回退到应用级快捷键 + 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)} 条)") + + + # ==================== 🌟 核心新增:键盘事件拦截 (Enter 发送) ==================== + def eventFilter(self, obj, event): + """全局事件过滤器""" + if obj == self.text_input and event.type() == QtCore.QEvent.Type.KeyPress: + # 捕获回车键 (主键盘 Enter 或 数字键盘 Enter) + if event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter): + # 如果按住了 Shift 键,则放行 (实现换行) + if event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier: + return False + else: + # 如果没按 Shift,则拦截该事件,并触发发送逻辑 + # 注意:只有在发送按钮处于激活状态时才允许发送 + if self.btn_send.isEnabled(): + self.send_message() + return True # 告诉 PyQt: 这个事件我处理过了,不要再往输入框里加回车符了 + + # 其他事件交给父类正常处理 + return super().eventFilter(obj, event) + + # ==================== 🌟 核心新增:长文本折叠检测 ==================== + # ==================== 🌟 核心升级:统一附件 UI 与本地存储处理 ==================== + + 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): + """启动截图覆盖层""" + # 记录截图发起时主窗口是否处于非激活状态(如 Alt+S 热键触发) + self._screenshot_from_background = not self.isActiveWindow() + self.screenshot_overlay.start() + + + 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 + ) + 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): + import time + import uuid + import json + + 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) + 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 # 🚀 传给前端 + ) + 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) + + 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 load_messages_to_web(self, session_id, show_loading=True): + import json + 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() + self.chat_bridge.clear_chat() + + # 1. 获取当前时间线的消息链 + # 🚀 调用链表查询方法:永远只渲染当前选中的时间线分支 + messages = self.db.get_message_chain(session_id) + visible_messages = [msg for msg in messages + if msg["role"] not in ("system", "compaction")] + + # 2. 处理空对话状态 + if not visible_messages: + 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. 遍历渲染消息 + for msg in visible_messages: + branch_info = None + + # 🌟 核心逻辑:为 AI 消息计算多分支切换信息 + if msg["role"] == "assistant" and msg.get("parent_id"): + # 获取该父节点下的所有兄弟回复 + siblings = self.db.get_branch_info(msg["parent_id"]) + if len(siblings) > 1: + try: + # 找到当前消息在所有兄弟分支里的排序 (1-based index) + 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 + + # 4. 渲染逻辑分发 + # 情况 A: 带有附件的用户消息 + 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", []) + + # 🌟 核心修复:清洗历史脏数据,强制补齐 content 字段,彻底绝杀 JS 崩溃! + # 🌟 核心修复:清洗历史脏数据,强制补齐所有前端需要的字段! + for att in attachments: + # 1. 补齐 content 防崩溃 + if "content" not in att: + if att.get("type") == "image": + att["content"] = f"[图片文件: {att.get('name', '未知图片')}]" + else: + att["content"] = "[附件内容]" + + # 2. 补齐 size_kb 和 lines 防 undefined + 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 + ) + # 情况 B: 普通消息或 AI 消息 + else: + tl_json = msg.get("timeline") if msg["role"] == "assistant" else None + if tl_json: + # 🌟 带 agent 时间线的 AI 消息:按序渲染 思考/文本/工具 + 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 + ) + + # 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 # 🚀 传给前端 + ) + # 🌟 有 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. 读取配置 + config_path = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'config.json') + try: + with open(config_path, 'r', encoding='utf-8') as f: + self.config_data = json.load(f) + except Exception as e: + print(f"加载配置失败: {e}") + self.config_data = {"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 引擎已就绪,正在加载历史记录...") + 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): + 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/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/screen_capture.py b/ui/views/system_tools/screen_capture.py new file mode 100644 index 0000000..a4fafd0 --- /dev/null +++ b/ui/views/system_tools/screen_capture.py @@ -0,0 +1,193 @@ +# -*- 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: + return + self._full_pixmap = screen.grabWindow(0) + 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/wv2_view.py b/ui/views/wv2_view.py new file mode 100644 index 0000000..53b81c6 --- /dev/null +++ b/ui/views/wv2_view.py @@ -0,0 +1,217 @@ +# -*- 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", +) + + +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..462473a --- /dev/null +++ b/ui/web/app.js @@ -0,0 +1,2111 @@ +// ========== 诊断日志(排查流式显示问题的根本手段) ========== +// ========== 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); +}, { 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') { + 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 }; + 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 }; + 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'); + + // --- E. 代码高亮(增量渲染已渐进高亮过的会被跳过?这里统一兜底一次) --- + wrapper.querySelectorAll('pre code').forEach(function(block) { + try { hljs.highlightElement(block); } catch(e) {} + }); + + // --- F. 等重排完成 --- + requestAnimationFrame(function() { + setTimeout(function() { + collapsedBlocks.forEach(function(block) { + if (block.classList.contains('collapsed')) { + block.classList.remove('scroll-locked'); + block.scrollTop = block.scrollHeight; + } + }); + 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 = {}; + + 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); +} + +// ==================== 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..1082845 --- /dev/null +++ b/ui/web/index.html @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ 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 0000000000000000000000000000000000000000..0acaaff03d4bb7606de02a827aeee338e5a86910 GIT binary patch literal 28076 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 G2<`xUL{2yW literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..75344a1f98e37e2c631e178065854c3a81fb842f GIT binary patch literal 6908 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 CFgb+) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..395f28beac23c7b0f7f3a1e714bd8dac253dd3bc GIT binary patch literal 11348 zcmV-aEUVLZPew8T0RR9104!7h4gdfE08HQj04x0f0RR9100000000000000000000 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b50920e138807f385d0b0359f4f0f09891f18406 GIT binary patch literal 16988 zcmV(>K-j-`Pew8T0RR91076^<4gdfE0E4su073x(0RR9100000000000000000000 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eb24a7ba282b03d830fa6c63ee897d92a5188736 GIT binary patch literal 26272 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�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 bkH82T%!~T~dOL3iZSfI!+IWoKhyte*`46Vs literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..29657023adc09956249f6295746c8ce4469b50d3 GIT binary patch literal 16400 zcmV+rK<~eIPew8T0RR9106-7`4gdfE0D43K06(+<0RR9100000000000000000000 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..215c143fd7805a5c2b222bd7892a1a2b09610020 GIT binary patch literal 16440 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 G>;nKVNojrn literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..349c06dc609f896392fd5bc8b364d3bc3efc9330 GIT binary patch literal 12028 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 GG{*pT{>~Tx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b3048fc115681ee6c1bc86b0aa158cfbbf59daa3 GIT binary patch literal 9644 zcmV;dB~#jWPew8T0RR91041yd4gdfE06`=G03}%f0RR9100000000000000000000 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c5a8462fbfe2c39a7c1857b9e296e62500a8a8a5 GIT binary patch literal 5468 zcmV-i6{G5RPew8T0RR9102N#S4gdfE059|a02KrP0RR9100000000000000000000 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..680c13085076a2f6c5a7e695935ec3f21cddb65f GIT binary patch literal 4928 zcmV-G6Tj?tPew8T0RR91024p}4gdfE04S^g021l|0RR9100000000000000000000 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..771f1af705f5cef5f578b3a1e7d8eff66f9b76b0 GIT binary patch literal 13568 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 G0ssIW.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