Compare commits

..

No commits in common. "e03a1ee7eb2a1303ed50a51ddb62c31dba79480f" and "0cd5b071245bbaa420ac7167fe6003cc44522bbb" have entirely different histories.

10 changed files with 17 additions and 138 deletions

View File

@ -5,12 +5,6 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.59.4 — 2026-07-24
- 对话里直接说「把这份资料放进我的知识库」现在可靠了:即使你还没建过任何库,助手也会帮你建库并入库(此前会被误存成「记忆」);同时更明确了分工——成篇资料进知识库,关于你本人的偏好等短事实才记进记忆。
- 知识库支持在线查看原件:已入库条目、单篇正文页、待入库文件都新增「查看」入口,点开即弹预览窗(PDF / Word / Excel / 图片直接看,PPT 自动转 PDF 预览),不用先下载。
- 知识库已入库列表更清爽:每条精简为标题 + 一行摘要,完整摘要和关键词悬停即可查看。
## 0.59.3 — 2026-07-23
- 知识库支持下载文件:已入库的每篇文档可下载原始文件(⬇ 按钮),打开单篇正文后还可下载整理好的 Markdown 文字版;等待入库的文件也能随时取回。

View File

@ -95,7 +95,7 @@ Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM
- **做成机制而非 skill**(判据:有独立于会话的持久状态需用户管理 → 机制):落盘 `user_root/.kb/<库名>/`(INDEX.md + docs/ 转换后 md + sources/ 原件)。**"已入库"判据 = INDEX.md 有条目**,sources 有而 INDEX 无 = 待入库 → 入库幂等、崩溃可恢复、零 migration。dotfile 命名同 `.memory` 双向防呆,GET /v1/files 天然隐藏。
- **入库管线**(`core/kb_ingest.py`,上传即触发 + 手动兜底):markitdown Python API 转 md → 扫描件 PDF(文本近零)走方舟文档理解 OCR 兜底(§8.13 同通道)→ deepseek flash 单次 chat 写 标题/摘要/关键词(失败降级文件名+正文开头,不阻塞)→ 追加 INDEX 行。编排照定时执行器:create_task + to_thread + per-(user,库) 内存锁去重;进度存内存供前端轮询,崩了靠 FS 判据续跑。
- **agent 侧零新工具**:`kb_block`(照 memory_block)把 INDEX 全文 + 契约(主动查阅无需点名 / INDEX 行格式 / 答题标来源)注 prompt;**零库时注极简冷启动契约**(建库步骤 + INDEX 行格式 + "成篇资料进 KB / 短事实进记忆"分工,~百 token)——原"有库才注入"省 token,但零注入让模型不知道 KB 机制存在,用户说"放进知识库"被就近写进 `.memory/`(2026-07-24 真实事故),与 memory 空契约常驻是同一课:教会第一次,建库落盘后下轮 build_agent 自然切全量。fs 工具在 user_root 内可读写、docker 沙箱整 user_root bind → `.kb` 天然可达。INDEX 行格式是对话内手动入库与后台产出的同一契约。
- **agent 侧零新工具**:`kb_block`(照 memory_block)把 INDEX 全文 + 契约(主动查阅无需点名 / INDEX 行格式 / 答题标来源)注 prompt,**用户有库才注入**;fs 工具在 user_root 内可读写、docker 沙箱整 user_root bind → `.kb` 天然可达。INDEX 行格式是对话内手动入库与后台产出的同一契约。
- **API 薄壳**(`/v1/kb*` 8 端点):列/建/删库、详情(带入库进度)、上传即入库、手动 ingest、看/删单篇。**不设 HTTP 检索端点**——检索是 agent 的事。前端两栏 modal(kb.js)管上传/删除,查询全走对话。
- **记账**:`usage_events` kind="kb_ingest"(OCR 那笔走 kind="vision"),无 task 上下文 → 0022 放宽 task_id 可 NULL,溯源靠 units JSONB `{"kb", "source"}`

View File

@ -23,7 +23,6 @@
### 2026-07
- **07-24 / 0.59.4 / kb 冷启动契约(修「放进知识库」误存记忆)+ 查看原件 + 条目精简**:①真实用户事故——说「放进我的知识库」被 agent 写进 `.memory/`:根因 `kb_block` 零库返回空串,模型不知道 KB 机制存在(memory 空契约常驻恰是为解冷启动,kb 当时没照抄这课)。修 = 零库注 ~百 token 冷启动契约(建库步骤 / INDEX 行格式 / 「成篇资料进 KB、短事实进记忆」分工;仅用户明确要求才建库),建库落盘后下轮 build_agent 自然切全量注入;skill 方案再否(召回同为概率召回还多一层间接,且违背「机制而非 skill」定稿)。DESIGN §3.8 取舍随 commit 更新,`tests/test_kb_block.py` 4 用例锁两分支。②前端「查看原件」三入口(已入库条目行 👁 / 单篇页头 / 待入库列表),复用 `openFilePreview``.kb/<库>/sources/` 相对路径零后端;关键层级修正:file-preview-modal z 90→114 盖过 kb/skills/memory 等 112 层 modal(仍低于 app-dialog 130,删除确认在预览上),fp-open chat-form 95→115、mini 96→116,main.js Esc 关栈顺序同步先关预览再关 modal。「像文件目录浏览 .kb」提议对齐后定维持现状:.kb 继续对文件面板隐藏,守住删单篇「doc+原件+INDEX 行」三件联动判据。③已入库条目压两行:标题 + 单行省略摘要(原 `.sk-desc` 省略规则只覆盖左栏 `.sk-item`,右栏整段换行铺开),关键词挪进悬停 title 不丢信息。
- **07-23 / 0.59.3 / 知识库下载入口(原件 + 解析 Markdown,纯前端)**:kb 面板此前只能在线看不能取回。关键发现:`.kb/` 就在 user_root 下且 `/v1/files/download` 的 safe_join 只挡越界不挡 dotfile → 零后端改动,`kb.js` 复用 `media.js::downloadFile`(Bearer + blob)拼 `.kb/<库>/{sources,docs}/…` 路径即可。入口三处:已入库条目行「⬇ 下载原件」(agent 手写无原件的条目不显示)、单篇查看页头「下载原件 / 下载 Markdown」(doc→source 映射渲染详情时记 `_docSources`)、待入库列表逐项「下载」。弃选方案:新增 `/v1/kb/*/sources` 专属下载端点(与 files 下载全量重复,公测期白养一个对外契约)。改动仅 kb.js + dev.html 三行 CSS。
- **07-23 / 0.59.2 / 移动端对话附件入口(📎 拍照 / 照片库 / 选文件)**:chat 输入区此前仅粘贴 / 拖拽两条附件路(移动端双双不可用,手机没有任何传文件入口)。加 📎 按钮:触屏(`pointer: coarse`)弹 showMenu 三项菜单——拍照(`capture="environment"` 直起后置相机)/ 照片库(`accept="image/*,video/*"`)/ 选取文件(无 accept);桌面端点击直开系统文件选择器(拖拽 / 粘贴照旧)。三个隐藏 input 的 change 统一走既有 `uploadAttachFiles()`(chip 托盘 / 发送注入行零改动);change 后清 value 允许连选同一文件;微信 / 企业微信只读镜像随 `applyChannelComposerLock` 禁用按钮 + 点击侧 `_composerLocked()` 双保险。纯前端(dev.html / chat.js),无后端改动。裸 file input 方案(靠 iOS 原生三选单)因 Android 行为不确定被弃,三项菜单跨平台确定。
- **07-23 / 0.59.1 / 架构审查落地:15 commit 内部重构大扫除(对外行为零变化)**:全库审查(web/核心循环/工具渲染/数据层四路并行探查)后按优先级逐项执行。①**web 拆分**——app.py 3972→197 行:73 路由迁 11 个 `web/routers/*`(register 范式同 admin.py),lifespan 后台协程按域析出(`background`/`scheduler_runner`/`wechat_runner`),schemas/model_gate/runs/userfiles/common 各归位;②**计费收口**——units JSONB 读侧唯一出口 `core/storage/usage_report.py`(web 层清零 cast),写侧拆 `telemetry.py`(四类 cost=0 留痕与真计费分家,kind 常量成 loop↔toolfail 契约单一事实源);③**loop 拆分**——传输健壮性层(畸形/吐空检测、留痕、非流式降级重试)析出 `core/llm_transport.py`(loop 1158→812),`_execute_tool_call` 148 行拆 4 个正交方法,测试打桩缝(`_collect_stream_once`/`_nonstream_once`)原样保留;④**工具注册声明式化**——`core/tool_registry.py` (组名,gate,factory) 表收敛四种 env-gate 写法(§7.5 #7 获代码强制),32 工具挂载逐名比对不变;⑤**媒体工具去重**——五工具同构(配额闸/落盘命名/记账兜底/超时重试/递归找 URL)收 `tools/media_common.py`,净减 122 行,banner/轮询等刻意差异不动;⑥**渲染统一**——块收集器(fence 闭合/表格/blockquote/段落并合)与 brief inline 切分下沉 `rendering/common`,**golden 基线**(四 profile 渲富 fixture、document.xml 逐字节对账,`tests/golden/`)零 diff 验收,分派状态机(in_refs 等)留 profile;⑦**测试 286→351**——scheduler 服务层、usage 口径、media 原语、wecom 加解密(自造密文覆盖失败路径)、路由两套(无 DB 面:11 router 鉴权门 19 端点 + kb/skills/memory/files FS;DB 面:tasks CRUD 全链 + files 顶层目录 DB-aware 四分支 + upload);测试库基建 = docker PG@5433 + `ZCBOT_TEST_DB_URL` 显式门控(RUN.md 一键命令)。**修 2 个真 bug**:restore 恢复真软删过的任务必 500(server-side onupdate 列 flush 后 expired → DetachedInstanceError,幂等路径掩盖多时,路由测试抓出);DB 测试曾沿 .env 连到生产隧道(127.0.0.1:6012)——插入的到点 job 被生产 green 调度守护真跑,门控收紧为只认 ZCBOT_TEST_DB_URL + connect_timeout=5 兜挂库,产物已全量清理。附带:scripts 50→29(已结案诊断/backfill 归档 `scripts/archive/`)、`tools/output.py`(输出处理自 base.py 析出)、export_docx 复用 rendering.common 字体助手(rendering 保持无 core 依赖的沙箱可用性,修正审查"迁 rendering"建议)。
@ -253,7 +252,7 @@ web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
web/broker.py / sinks.py / pptx_render.py / admin.py
web/static/dev.html + js/*.js ← dev SPA 零构建 ES module(main.js 入口;skills.js=技能 modal;kb.js=知识库 modal)
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
tests/ 355 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控)
tests/ 351 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控)
─────────────────────────────────
Python 合计 ~5.5 万行(core+web+tools+rendering+tests+scripts;+ dev SPA + vendor 1MB)
```

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.59.4"
__version__ = "0.59.3"

View File

@ -334,8 +334,7 @@ def _build_system_prompt(
user_root(workspace_dir, user_id) / ".memory"
)
prompt += memory_block(workspace_dir, user_id, mem_dir_display)
# 个人知识库 .kb/:路径换算同 .memory(docker 给容器路径);零库时注冷启动契约
# (否则用户说「放进知识库」会被就近写进记忆 —— 模型不知道 KB 机制存在)。
# 个人知识库 .kb/:路径换算同 .memory(docker 给容器路径);用户没建库时注空串零成本。
kb_dir_display = "/workspace/.kb" if is_docker else str(
user_root(workspace_dir, user_id) / ".kb"
)

View File

@ -239,25 +239,6 @@ def save_source(workspace_dir: Path, user_id: UUID, name: str, filename: str, da
# ── prompt 注入(照 memory_block 范式) ────────────────────────────────
# 零库时的冷启动契约:曾因零库零注入,用户说「放进知识库」被 agent 就近写进
# .memory/(2026-07-24)—— 模型根本不知道 KB 机制存在。与 memory 空契约常驻
# 同一课:教会第一次即可,建库落盘后下轮 build_agent 自然切到全量注入。
_KB_COLD_START = """\
## 个人知识库 (user 级,跨 task 共享)
用户还没有任何知识库(`{base}/` 下无库)当用户明确要求把资料**放进知识库 /
长期保存备查**,不要写进记忆 直接用 fs 工具建库入库(无需专用工具):
- **建库**:创建 `{base}/<库名>/docs/` `{base}/<库名>/sources/` 两个目录,
`{base}/<库名>/INDEX.md`(首行 `# <库名>`)。库名用中文/字母/数字。
- **入库**:原件放 `sources/`(对话里没有原件就跳过);正文转成 markdown
`docs/<文件名>.md`;再往 INDEX.md 追加一行,**格式必须是**:
`{fmt}`
(摘要写准 它是下次召回的依据)
- **分工**:成篇资料(文档/规范/报告)进知识库;关于用户本人的短事实才写记忆
仅用户明确表达"存知识库/长期保存"时才建库,临时讨论材料不要自动入库"""
_KB_CONTRACT = """\
用法规矩:
- **主动查阅**:回答前先扫一眼下方各库条目,标题 / 摘要 / 关键词与用户问题相关就先
@ -276,7 +257,7 @@ def kb_block(
user_id: UUID,
kb_dir_display: Optional[str] = None,
) -> str:
"""构造注入 system prompt 的知识库段;零库时注极简冷启动契约(~百 token)。
"""构造注入 system prompt 的知识库段;用户没有任何库时返回空串(零成本)。
kb_dir_display: `.kb/` agent 视角下的路径前缀(docker `/workspace/.kb`,
host None 宿主绝对路径) memory_block mem_dir_display 同款约定
@ -284,10 +265,10 @@ def kb_block(
description 逻辑),几十篇的个人库体量注得起;正文仍按需 read
"""
kbs = list_kbs(workspace_dir, user_id)
if not kbs:
return ""
root = kb_root(workspace_dir, user_id)
base = (kb_dir_display if kb_dir_display is not None else str(root)).rstrip("/")
if not kbs:
return _KB_COLD_START.replace("{base}", base).replace("{fmt}", INDEX_LINE_FORMAT)
parts = ["\n\n## 个人知识库 (user 级,跨 task 共享)\n"]
parts.append(_KB_CONTRACT.replace("{fmt}", INDEX_LINE_FORMAT))

View File

@ -1,77 +0,0 @@
"""kb_block 注入契约:零库冷启动 vs 有库全量。
背景(2026-07-24):零库时 kb_block 返回空串,agent 不知道 KB 机制存在,用户说
放进我的知识库被就近写进 .memory/修复 = 零库注极简冷启动契约(建库步骤 +
INDEX 行格式 + 知识库/记忆分工), memory 空契约常驻同一课本测试锁住两个分支
"""
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
from uuid import UUID
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from core.kb import ( # noqa: E402
INDEX_LINE_FORMAT,
create_kb,
format_index_line,
kb_block,
)
_UID = UUID("6b14d2ab-7a6e-4d0b-8562-ea62a801e32c")
class TestKbBlock(unittest.TestCase):
def setUp(self):
self.ws = Path(tempfile.mkdtemp()) / "workspace"
def test_cold_start_injects_minimal_contract(self):
block = kb_block(self.ws, _UID, "/workspace/.kb")
# 机制存在 + 建库/入库指引 + 行格式契约都在
self.assertIn("个人知识库", block)
self.assertIn("还没有任何知识库", block)
self.assertIn("/workspace/.kb/<库名>/docs/", block)
self.assertIn(INDEX_LINE_FORMAT, block)
# 与记忆的分工写明(误写 .memory 正是要防的失败模式)
self.assertIn("短事实才写记忆", block)
# 冷启动块不含任何库清单
self.assertNotIn("### 库「", block)
def test_cold_start_host_path_display(self):
block = kb_block(self.ws, _UID, None)
root = self.ws / "users" / str(_UID) / ".kb"
self.assertIn(str(root), block)
def test_with_kb_injects_full_index(self):
d = create_kb(self.ws, _UID, "标准库")
assert d is not None
line = format_index_line(
title="水泥胶砂强度检验方法",
doc="docs/gbt17671.md",
source="sources/gbt17671.pdf",
summary="ISO 法测定水泥胶砂抗压抗折强度。",
keywords="水泥,强度,GB/T 17671",
)
(d / "INDEX.md").write_text(f"# 标准库\n\n{line}\n", encoding="utf-8")
block = kb_block(self.ws, _UID, "/workspace/.kb")
# 全量分支:库清单 + 条目 + 完整契约(对话内入库段)
self.assertIn("### 库「标准库」", block)
self.assertIn("水泥胶砂强度检验方法", block)
self.assertIn("对话内入库", block)
# 冷启动文案不应出现
self.assertNotIn("还没有任何知识库", block)
def test_empty_kb_counts_as_having_kb(self):
# 建了库但零文档:走全量分支(注库名 + 空库提示),不再是冷启动
create_kb(self.ws, _UID, "新库")
block = kb_block(self.ws, _UID, "/workspace/.kb")
self.assertIn("### 库「新库」", block)
self.assertIn("空库", block)
self.assertNotIn("还没有任何知识库", block)
if __name__ == "__main__":
unittest.main()

View File

@ -508,10 +508,6 @@
.kb-pending { padding: 3px 0; font-size: 12px; color: var(--muted); }
.kb-entry { display: flex; align-items: flex-start; gap: 6px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
.kb-entry-main { flex: 1; min-width: 0; cursor: pointer; }
/* 标题/摘要各压一行省略(sk-item 的省略规则只覆盖左栏,这里单列);全文在悬停 title */
.kb-entry-main .sk-name, .kb-entry-main .sk-desc {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.kb-entry-main:hover .sk-name { text-decoration: underline; }
.kb-entry .kb-doc-del, .kb-entry .kb-src-dl { flex-shrink: 0; opacity: .55; }
.kb-entry:hover .kb-doc-del, .kb-entry:hover .kb-src-dl { opacity: 1; }
@ -1199,15 +1195,13 @@
padding-bottom 让出 chat-form 高度(--preview-bottom-inset 由 JS 按需写),
使居中卡片停在输入区之上、不遮挡;输入区本身由 body.fp-open 抬到遮罩之上继续可用。 */
#file-preview-modal {
background: rgba(0,0,0,0.45); z-index: 114;
background: rgba(0,0,0,0.45); z-index: 90;
padding-bottom: var(--preview-bottom-inset, 0);
}
/* z=114:预览要能从 kb / skills / memory 等 112 层 modal 内打开并盖在其上
(kb「查看原件」),同时低于 app-dialog(130,删除确认仍在预览之上)。 */
/* 预览开启时:仅把输入区上浮成遮罩之上的"悬浮活动条"(实底 + 上向阴影),
其余底部区域(左栏入口 / 右栏 / 状态)随整屏遮罩统一压暗,不再是一条通亮的带。 */
body.fp-open #chat-form {
position: relative; z-index: 115;
position: relative; z-index: 95;
box-shadow: 0 -10px 28px rgba(0,0,0,.22);
}
#file-preview-modal .card {
@ -1269,7 +1263,7 @@
#file-preview-modal .body .xlsx-sheet td, #file-preview-modal .body .xlsx-sheet th {
border: 1px solid var(--border); padding: 4px 8px; white-space: nowrap;
}
#mini-preview-modal { background: rgba(0,0,0,0.18); z-index: 116; align-items: flex-start; justify-content: flex-end; padding: 56px 18px 0 0; }
#mini-preview-modal { background: rgba(0,0,0,0.18); z-index: 96; align-items: flex-start; justify-content: flex-end; padding: 56px 18px 0 0; }
#mini-preview-modal .card {
width: min(520px, 92vw); height: min(420px, 72vh);
display: flex; flex-direction: column; position: relative;

View File

@ -9,7 +9,6 @@ import { escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
import { downloadFile } from "./media.js";
import { openFilePreview } from "./preview.js";
const PLACEHOLDER = '<div class="sk-empty">← 选一个库查看,或「+ 新建库」</div>';
const POLL_MS = 3000;
@ -122,7 +121,6 @@ function renderDetail(d) {
if (pending.length) {
html += `<div class="sk-group-title" style="margin-top:10px;">待入库 (${pending.length})</div>`;
html += pending.map((n) => `<div class="kb-pending">${escapeHtml(n)}
<button class="small kb-src-view" data-src="${escapeHtml(n)}" title="查看原件">查看</button>
<button class="small kb-src-dl" data-src="${escapeHtml(n)}" title="下载原件">下载</button></div>`).join("");
if (!ing.running) {
html += `<div style="padding:6px 0;"><button id="kb-reingest" class="small">重试入库</button></div>`;
@ -135,15 +133,12 @@ function renderDetail(d) {
const doc = e.doc.replace(/^docs\//, "");
const src = (e.source || "").replace(/^sources\//, "");
if (src) _docSources[doc] = src;
// 行内只留标题 + 单行摘要(CSS 省略);完整摘要/关键词进 title 悬停提示,不占行高
const tip = e.summary + (e.keywords ? "\n关键词: " + e.keywords : "");
return `<div class="kb-entry">
<div class="kb-entry-main" data-doc="${escapeHtml(doc)}" title="${escapeHtml(tip)}">
<div class="kb-entry-main" data-doc="${escapeHtml(doc)}">
<div class="sk-name">${escapeHtml(e.title)}</div>
<div class="sk-desc">${escapeHtml(e.summary)}</div>
<div class="sk-desc">${escapeHtml(e.summary)}${e.keywords ? " " + escapeHtml(e.keywords) : ""}</div>
</div>
${src ? `<button class="small kb-src-view" data-src="${escapeHtml(src)}" title="查看原件">👁</button>
<button class="small kb-src-dl" data-src="${escapeHtml(src)}" title="下载原件"></button>` : ""}
${src ? `<button class="small kb-src-dl" data-src="${escapeHtml(src)}" title="下载原件">⬇</button>` : ""}
<button class="small danger kb-doc-del" data-doc="${escapeHtml(doc)}" title="删除该篇(含原件)"></button>
</div>`;
}).join("")
@ -167,8 +162,7 @@ async function showDoc(filename) {
`<div class="sk-d-head"><button id="kb-back" class="small">← 返回</button>
<span class="sk-d-name">${escapeHtml(filename)}</span>
<span class="spacer"></span>
${src ? `<button id="kb-doc-src-view" class="small" data-src="${escapeHtml(src)}">查看原件</button>
<button id="kb-doc-src-dl" class="small" data-src="${escapeHtml(src)}">下载原件</button>` : ""}
${src ? `<button id="kb-doc-src-dl" class="small" data-src="${escapeHtml(src)}">下载原件</button>` : ""}
<button id="kb-doc-md-dl" class="small" data-doc="${escapeHtml(filename)}">下载 Markdown</button></div>` +
`<div class="sk-detail-md">${renderMd(data.content)}</div>`;
highlightIn(detail);
@ -220,10 +214,6 @@ $("kb-detail").addEventListener("click", async (e) => {
if (e.target.id === "kb-back") { showKb(_current, null); return; }
if (e.target.id === "kb-doc-md-dl") { downloadFile(kbRel("docs/" + e.target.getAttribute("data-doc"))); return; }
if (e.target.id === "kb-doc-src-dl") { downloadFile(kbRel("sources/" + e.target.getAttribute("data-src"))); return; }
// 查看原件:复用主预览弹框(preview.js,z 114 盖在 kb modal 之上),路径同下载走 .kb 相对路径
if (e.target.id === "kb-doc-src-view") { openFilePreview(kbRel("sources/" + e.target.getAttribute("data-src"))); return; }
const srcView = e.target.closest(".kb-src-view");
if (srcView) { openFilePreview(kbRel("sources/" + srcView.getAttribute("data-src"))); return; }
const srcDl = e.target.closest(".kb-src-dl");
if (srcDl) { downloadFile(kbRel("sources/" + srcDl.getAttribute("data-src"))); return; }
if (e.target.id === "kb-reingest") {

View File

@ -107,10 +107,7 @@ async function loadVersion() {
// ───── Esc 关弹窗栈(跨模块协调:chpw/选入/文件预览/小预览)─────
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
// 多模态共存:优先关靠前栈顶 — 小预览(z 116)→ 文件预览(z 114,可从 kb 等
// 112 层 modal 内打开,须先于它们关)→ 各 112 层 modal → 选入(z 95)→ 新任务(z 80)
if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; }
if ($("skills-modal").classList.contains("show")) { closeSkillsModal(); return; }
if ($("memory-modal").classList.contains("show")) { closeMemoryModal(); return; }
@ -118,7 +115,9 @@ document.addEventListener("keydown", (e) => {
if ($("crons-modal").classList.contains("show")) { closeCronsModal(); return; }
if ($("wechat-modal").classList.contains("show")) { closeWechatModal(); return; }
if ($("changelog-modal").classList.contains("show")) { closeChangelogModal(); return; }
if ($("mini-preview-modal").classList.contains("show")) { closeMiniPreview(); return; }
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
});