feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读
笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致: 标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增, 否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并 LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与 关窗都做二次确认,取消关闭必须回报主进程复位 closePending, 否则窗口再也关不掉而看门狗仍会销毁未保存内容。 AI 助手支持多轮会话:会话独立落盘,先取历史再写提问, 历史只发文本不重发图像,失败与取消都保留已流出的残片。 新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线), 补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md, 但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。 书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底, 修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框 去掉衬底色块,恢复原生外观。 其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、 GitHub Pages 站点。
This commit is contained in:
+351
-6
@@ -307,8 +307,14 @@ test('AI 上下文提供无需选中的当前页与全文范围', () => {
|
||||
|
||||
// 全文必须提示可能超限,并且始终弹确认框
|
||||
assert.match(shell, /可能超过模型限制/);
|
||||
assert.match(shell, /全文可能超过模型的上下文限制/);
|
||||
assert.match(shell, /scope !== 'document' && chars <= CONFIRM_CHARS/);
|
||||
// 正文不再本地截断,文案必须如实说明"完整发送 + 超限由接口报错",
|
||||
// 否则界面显示的字数与实际外发字数不一致(实测 40 页只发出 8 页)
|
||||
assert.match(shell, /全文将完整发送/);
|
||||
assert.doesNotMatch(shell, /保留首尾并截断/);
|
||||
const client = fs.readFileSync(path.join(__dirname, '..', 'reader', 'ai-client.js'), 'utf8');
|
||||
assert.doesNotMatch(client, /let body = clipContext\(text\)/);
|
||||
assert.match(client, /上下文超出模型窗口/);
|
||||
|
||||
// 旧设置迁移,避免升级后回落成 selection
|
||||
assert.match(shell, /storedScope === 'chapter' \? 'document' : storedScope/);
|
||||
@@ -374,12 +380,13 @@ test('设置关于页与 README 列出书库和内置阅读格式', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const readme = fs.readFileSync(path.join(__dirname, '..', '..', 'README.md'), 'utf8');
|
||||
assert.match(html, /关于 PeopleLib/);
|
||||
assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3/);
|
||||
assert.match(html, /书库导入与管理[\s\S]*TXT、DJVU、FB2、CBZ、CBR/);
|
||||
assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3、TXT、MD/);
|
||||
assert.match(html, /书库导入与管理[\s\S]*TXT、MD、DJVU、FB2、CBZ、CBR/);
|
||||
assert.match(html, /Foliate[\s\S]*MOBI\/KF7\/KF8/);
|
||||
assert.match(readme, /## 支持格式/);
|
||||
assert.match(readme, /MOBI \/ AZW \/ AZW3[\s\S]*Foliate/);
|
||||
assert.match(readme, /TXT \/ DJVU \/ FB2 \/ CBZ \/ CBR/);
|
||||
assert.match(readme, /TXT \/ MD[\s\S]*Markdown/);
|
||||
assert.match(readme, /DJVU \/ FB2 \/ CBZ \/ CBR/);
|
||||
});
|
||||
|
||||
test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
|
||||
@@ -389,7 +396,7 @@ test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器
|
||||
const adapter = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'mobi-adapter.mjs'), 'utf8');
|
||||
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
|
||||
assert.strictEqual(pkg.dependencies['foliate-js'], '1.0.1');
|
||||
assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/);
|
||||
assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/);
|
||||
assert.match(shell, /mobi:\s*mobi\.createMobiAdapter/);
|
||||
assert.match(shell, /azw3:\s*mobi\.createMobiAdapter/);
|
||||
assert.match(adapter, /from '\.\.\/\.\.\/\.\.\/node_modules\/foliate-js\/mobi\.js'/);
|
||||
@@ -495,6 +502,66 @@ test('读书与画布笔记分型创建、分类展示并支持受管 PDF 底版
|
||||
assert.ok(snapshotAt > functionAt && snapshotAt < awaitAt, '下载元数据未在首次 await 前快照');
|
||||
});
|
||||
|
||||
test('笔记表单控件样式不外溢到工具栏,关联下拉框限宽', () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
const noteWindowCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note-window.css'), 'utf8');
|
||||
const fieldRule = css.match(/\.note-edit-form select[^{]*\{([^}]*margin-top[^}]*)\}/);
|
||||
assert.ok(fieldRule, '找不到笔记表单控件规则');
|
||||
// 画布工具栏与 Quill 工具栏都是 .note-edit-form 的后代,
|
||||
// 漏掉任一 :not() 就会把 margin-top / width:100% 灌进工具栏,
|
||||
// 表现为工具栏凭空高出一截、分组高度对不齐
|
||||
assert.match(fieldRule[0], /:not\(\.canvas-note-root select\)/);
|
||||
assert.match(fieldRule[0], /:not\(\.ql-toolbar select\)/);
|
||||
const capRule = css.match(/\.note-edit-form select[^{]*\{([^}]*max-width[^}]*)\}/);
|
||||
assert.ok(capRule, '关联书籍下拉框没有限宽');
|
||||
assert.match(capRule[1], /max-width:\s*320px/);
|
||||
assert.match(capRule[1], /min-width:\s*0/);
|
||||
assert.match(capRule[0], /:not\(\.canvas-note-root select\)/);
|
||||
const metaRule = noteWindowCss.match(/\.note-window-meta select[\s\S]*?\{([^}]*)\}/);
|
||||
assert.ok(metaRule, '笔记窗口下拉框没有限宽规则');
|
||||
assert.match(metaRule[1], /max-width:\s*260px/);
|
||||
assert.match(metaRule[1], /min-width:\s*0/);
|
||||
});
|
||||
|
||||
test('笔记窗口标题栏只有品牌名,没有副标题', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note.html'), 'utf8');
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'note-shell.js'), 'utf8');
|
||||
const titlebar = html.match(/<div class="titlebar">[\s\S]*?<div class="titlebar-spacer">/);
|
||||
assert.ok(titlebar, '找不到笔记窗口标题栏');
|
||||
// style.css 的 .titlebar-left 不是 flex(只有 reader.css 是),
|
||||
// 放同级的 brand-sub 会掉到品牌名下面一行,把标题栏顶高
|
||||
assert.doesNotMatch(titlebar[0], /brand-sub/);
|
||||
assert.doesNotMatch(html, /noteWindowSubtitle/);
|
||||
assert.doesNotMatch(shell, /noteWindowSubtitle|subtitle/);
|
||||
assert.match(titlebar[0], /<span>笔记<\/span>/);
|
||||
});
|
||||
|
||||
test('笔记窗口多标签:自带标签条样式,非激活视图隐藏,存活编辑器有上限', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note.html'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note-window.css'), 'utf8');
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'note-shell.js'), 'utf8');
|
||||
assert.match(html, /class="doctabs"/, '缺少标签条');
|
||||
assert.match(html, /id="noteDirtyModal"/, '缺少未保存确认框');
|
||||
// note.html 不加载 reader.css,标签条样式必须在 note-window.css 里自带一份
|
||||
assert.doesNotMatch(html, /reader\.css/);
|
||||
assert.match(css, /\.doctabs\s*\{/, '标签条样式缺失,标签会退化成竖排文字');
|
||||
assert.match(css, /\.note-tab-view\.inactive[\s\S]*?display:\s*none/);
|
||||
// 只留一个可见视图,否则多个 Quill/画布实例同时可见会互相抢焦点
|
||||
assert.match(shell, /MAX_LIVE_EDITORS\s*=\s*\d+/);
|
||||
// 取消关闭必须真的把 cancelClose 发出去:主进程的 closePending 不复位,
|
||||
// 下次点关闭会被当成"正在处理"忽略,而看门狗仍会销毁带未保存内容的窗口
|
||||
const abortAt = shell.indexOf('async function abortClose');
|
||||
assert.ok(abortAt > 0, '缺少 abortClose');
|
||||
const abortBody = shell.slice(abortAt, shell.indexOf('\n}', abortAt));
|
||||
assert.match(abortBody, /await api\.notes\.cancelClose\(\)/);
|
||||
assert.doesNotMatch(abortBody, /if\s*\(\s*true\s*\)\s*return/);
|
||||
assert.match(abortBody, /closing = false/);
|
||||
// 脏判定必须比对序列化内容:靠 keydown/pointerdown 之类的交互事件会误报,
|
||||
// 画布加载时的 1→2 版本归一化本身就会改一次内容
|
||||
assert.match(shell, /baselineKey/);
|
||||
assert.doesNotMatch(shell, /addEventListener\('pointerdown'[\s\S]{0,120}dirty/);
|
||||
});
|
||||
|
||||
test('书架操作对键盘焦点可见', () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
assert.match(css, /\.library-shelf-row:focus-within \.library-shelf-actions/);
|
||||
@@ -550,7 +617,124 @@ test('书库多选提供全选、批量整理与批量移除', () => {
|
||||
// 批量移除沿用单本移除的两个可选项
|
||||
assert.match(library, /id="bulkDelFiles"/);
|
||||
assert.match(library, /id="bulkDelReadingData"/);
|
||||
assert.match(library, /window\.api\.library\.remove\(item\.id, choice\)/);
|
||||
// 一次 IPC 提交整批,不再逐条 remove:书库索引是整体重写的,循环会造成写放大
|
||||
assert.match(library, /window\.api\.library\.removeMany\(targets\.map\(\(item\) => item\.id\), choice\)/);
|
||||
assert.match(library, /window\.api\.library\.updateMany\(patches\)/);
|
||||
});
|
||||
|
||||
test('卡片定位上下文不随多选状态消失', () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
// 退出多选是同步移除 class,重绘要等 IPC;若定位上下文只在 select-mode 下建立,
|
||||
// 残留的复选框会按视口定位飞到左上角标题上闪一下
|
||||
assert.match(css, /\n\.card\s*\{[^}]*position:\s*relative/);
|
||||
assert.doesNotMatch(css, /#libraryTab\.select-mode \.card\s*\{\s*position:\s*relative/);
|
||||
assert.match(css, /#libraryTab:not\(\.select-mode\) \.card-select\s*\{\s*display:\s*none/);
|
||||
});
|
||||
|
||||
test('多选复选框用原生外观,不套衬底色块', () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
const wrap = css.match(/\.card-select\s*\{([^}]*)\}/);
|
||||
assert.ok(wrap, '缺少复选框容器样式');
|
||||
// padding + 背景板会让 13px 的原生复选框看起来套了一圈很粗的边框,
|
||||
// 浅色封面上的可见性改用投影解决
|
||||
assert.doesNotMatch(wrap[1], /padding:\s*[1-9]/);
|
||||
assert.doesNotMatch(wrap[1], /background:\s*rgba/);
|
||||
const input = css.match(/\.card-select input\s*\{([^}]*)\}/);
|
||||
assert.ok(input, '缺少复选框样式');
|
||||
assert.match(input[1], /drop-shadow/);
|
||||
});
|
||||
|
||||
test('封面完整显示且比例一致,留白由模糊层填充', () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
const coverRule = css.match(/\n\.card-cover\s*\{([^}]*)\}/);
|
||||
assert.ok(coverRule, '缺少封面样式');
|
||||
// cover 会按各自比例裁掉不同的边,同一批封面看起来缩放程度不一致
|
||||
assert.match(coverRule[1], /center\/contain/);
|
||||
assert.doesNotMatch(coverRule[1], /center\/cover/);
|
||||
// ::before 垫模糊底,::after 画清晰的完整封面。
|
||||
// 只用 ::before 的话它会盖住父元素自己的背景,封面整张变成模糊的
|
||||
// 共用规则里 ::after 也单独占一行,所以不能靠 \n 区分;
|
||||
// 按"独占一条规则"来取:选择器后面直接跟 { 且规则里带 z-index
|
||||
const rules = [...css.matchAll(
|
||||
/\.card-cover\[data-cover-state="ready"\]::(before|after)\s*\{([^}]*)\}/g
|
||||
)].filter((m) => /z-index/.test(m[2]));
|
||||
const before = rules.find((m) => m[1] === 'before');
|
||||
const after = rules.find((m) => m[1] === 'after');
|
||||
assert.ok(before && after, '缺少封面双层背景规则');
|
||||
assert.match(before[2], /background-size:\s*cover/);
|
||||
assert.match(before[2], /blur\(/);
|
||||
assert.match(after[2], /background-size:\s*contain/);
|
||||
// 模糊层必须在下、清晰层在上
|
||||
assert.ok(
|
||||
Number(before[2].match(/z-index:\s*(\d+)/)[1]) < Number(after[2].match(/z-index:\s*(\d+)/)[1]),
|
||||
'模糊层盖住了清晰封面'
|
||||
);
|
||||
// 角标与占位文字要浮在两层背景之上
|
||||
assert.match(css, /\.card-cover > \*\s*\{[^}]*z-index:\s*2/);
|
||||
});
|
||||
|
||||
test('可内置阅读的格式在渲染层与 main.js 保持一致', () => {
|
||||
const mainSrc = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
|
||||
const readable = mainSrc.match(/const READABLE_EXT = new Set\(\[([^\]]*)\]\)/);
|
||||
assert.ok(readable, '找不到 READABLE_EXT');
|
||||
const exts = readable[1].match(/\.\w+/g).map((s) => s.slice(1)).sort();
|
||||
// 渲染层漏掉格式不会报错,只是「阅读」按钮和封面点击静默消失,
|
||||
// 用户看到的现象就是"内置阅读器打不开 txt"
|
||||
for (const file of [
|
||||
path.join(__dirname, '..', 'ui', 'views', 'library.js'),
|
||||
path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs')
|
||||
]) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
const re = src.match(/READABLE_RE\s*=\s*\/\\\.\(([^)]*)\)\$\/i/);
|
||||
assert.ok(re, `${path.basename(file)} 缺少 READABLE_RE`);
|
||||
assert.deepStrictEqual(re[1].split('|').sort(), exts, `${path.basename(file)} 的可阅读格式与 main.js 不一致`);
|
||||
}
|
||||
});
|
||||
|
||||
test('状态、笔记与标签标识叠在封面上,不占用封面下方行', () => {
|
||||
const library = fs.readFileSync(libFile, 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
|
||||
// 三种标识都必须在 .card-cover 内部,否则又会各占一行
|
||||
const cover = library.match(/<div class="card-cover\$\{[\s\S]*?\n\s*<\/div>/);
|
||||
assert.ok(cover, '封面结构缺失');
|
||||
assert.match(cover[0], /class="library-card-tags"/);
|
||||
// 下载状态在左下角,批注与笔记在右下角
|
||||
assert.match(cover[0], /class="card-cover-badges start">\$\{badge\}/);
|
||||
assert.match(cover[0], /class="card-cover-badges end">\$\{annotationBadge\}\$\{noteBadge\}/);
|
||||
// 标识不能再出现在封面之后、标题周围
|
||||
const afterCover = library.slice(library.indexOf('class="card-title"'));
|
||||
assert.doesNotMatch(afterCover.slice(0, 400), /card-cover-badges|library-card-tags/);
|
||||
|
||||
const coverRule = css.match(/\.card-cover\s*\{([^}]*)\}/);
|
||||
assert.match(coverRule[1], /position:\s*relative/);
|
||||
assert.match(coverRule[1], /overflow:\s*hidden/);
|
||||
|
||||
const badgeWrap = css.match(/\.card-cover-badges\s*\{([^}]*)\}/);
|
||||
assert.ok(badgeWrap, '缺少封面标识容器样式');
|
||||
assert.match(badgeWrap[1], /position:\s*absolute/);
|
||||
assert.match(badgeWrap[1], /bottom:\s*6px/);
|
||||
// 标识浮在封面上,必须让点击穿透到封面的阅读入口
|
||||
assert.match(badgeWrap[1], /pointer-events:\s*none/);
|
||||
// 左右两组各占一半,避免笔记批注多时与左下角的下载状态叠在一起
|
||||
assert.match(badgeWrap[1], /max-width:\s*calc\(50% - 8px\)/);
|
||||
assert.match(css, /\.card-cover-badges\.start\s*\{\s*left:\s*6px/);
|
||||
assert.match(css, /\.card-cover-badges\.end\s*\{\s*right:\s*6px/);
|
||||
|
||||
const tagsRule = css.match(/\.library-card-tags\s*\{([^}]*)\}/);
|
||||
assert.match(tagsRule[1], /position:\s*absolute/);
|
||||
assert.match(tagsRule[1], /top:\s*6px/);
|
||||
assert.match(tagsRule[1], /pointer-events:\s*none/);
|
||||
// 左上角留给多选复选框,标签宽度必须扣掉这块
|
||||
assert.match(tagsRule[1], /max-width:\s*calc\(100% - 42px\)/);
|
||||
// 允许换行会让三个长标签堆成三行盖住封面
|
||||
assert.doesNotMatch(tagsRule[1], /flex-wrap:\s*wrap/);
|
||||
assert.match(library, /class="library-card-tag" title="\$\{escapeHtml\(tag\)\}"/);
|
||||
|
||||
// 封面图案深浅不可控,衬底必须不透明
|
||||
const badgeRule = css.match(/\n\.card-badge\s*\{([^}]*)\}/);
|
||||
assert.match(badgeRule[1], /background:\s*var\(--bg-card\)/);
|
||||
assert.doesNotMatch(badgeRule[1], /margin-top/);
|
||||
});
|
||||
|
||||
test('书库长标题保持单行省略并提供完整悬浮提示', () => {
|
||||
@@ -564,6 +748,167 @@ test('书库长标题保持单行省略并提供完整悬浮提示', () => {
|
||||
assert.match(library, /class="card-title" title="\$\{escapeHtml\(it\.title\)\}"/);
|
||||
});
|
||||
|
||||
test('AI 面板以线程容器承载多轮对话气泡', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
|
||||
|
||||
// 结构约定要写在 HTML 里,集成测试与 CSS 都按 ai-thread / ai-msg 定位
|
||||
assert.match(html, /id="aiOutput" class="ai-output ai-thread"/);
|
||||
assert.match(shell, /box\.className = `ai-msg ai-msg-\$\{role\}`/);
|
||||
assert.match(shell, /box\.dataset\.messageId = String\(message\.id \|\| ''\)/);
|
||||
assert.match(shell, /body\.className = 'ai-msg-body'/);
|
||||
assert.match(shell, /meta\.className = 'ai-msg-meta'/);
|
||||
assert.match(css, /\.ai-msg-body\s*\{/);
|
||||
assert.match(css, /\.ai-thread\s*\{[^}]*flex-direction:\s*column/);
|
||||
|
||||
// 新回答必须追加而不是覆盖:整块重写等于回到一问一答
|
||||
assert.match(shell, /function appendAiMessage\(message\)/);
|
||||
assert.match(shell, /el\.aiOutput\.appendChild\(node\)/);
|
||||
});
|
||||
|
||||
test('AI 流式增量只重渲染正在生成的那一条气泡', () => {
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
|
||||
// 线程里有几十条消息时,每 80ms 重解析全部 Markdown 会把界面拖死,
|
||||
// 因此增量渲染只允许写 aiStreamNode 里的 .ai-msg-body
|
||||
const render = shell.match(/function renderAiOutput\([\s\S]*?\n\}/);
|
||||
assert.ok(render, '缺少 renderAiOutput');
|
||||
assert.match(render[0], /aiStreamNode\.querySelector\('\.ai-msg-body'\)/);
|
||||
assert.match(render[0], /renderMessageBody\(body,/);
|
||||
assert.doesNotMatch(render[0], /AiMarkdown\.mount\(el\.aiOutput/);
|
||||
assert.doesNotMatch(render[0], /renderAiThread\(\)/);
|
||||
// 整篇重建只发生在换会话时,不能出现在节流的增量路径上
|
||||
const schedule = shell.match(/function scheduleAiOutput\([\s\S]*?\n\}/);
|
||||
assert.ok(schedule, '缺少 scheduleAiOutput');
|
||||
assert.doesNotMatch(schedule[0], /renderAiThread\(\)/);
|
||||
assert.doesNotMatch(schedule[0], /AiMarkdown\.mount\(el\.aiOutput/);
|
||||
|
||||
// 80ms 节流与「贴底才自动滚动」都要保留
|
||||
assert.match(shell, /aiRenderTimer = window\.setTimeout\([\s\S]{0,120}\}, 80\)/);
|
||||
assert.match(shell, /el\.aiOutput\.scrollHeight - el\.aiOutput\.scrollTop - el\.aiOutput\.clientHeight < 48/);
|
||||
});
|
||||
|
||||
test('AI 增量按 messageId 路由到对应气泡', () => {
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
const delta = shell.match(/unsubDelta = api\.ai\.onDelta\([\s\S]*?\n \}\);/);
|
||||
assert.ok(delta, '缺少 onDelta 订阅');
|
||||
// 只按 runId 过滤会让同一次运行里的旧气泡也收到增量,串成一团
|
||||
assert.match(delta[0], /const messageId = d\.messageId \? String\(d\.messageId\) : ''/);
|
||||
assert.match(delta[0], /messageId !== aiRun\.messageId\) return/);
|
||||
assert.match(shell, /function adoptStreamingMessageId\(messageId\)/);
|
||||
assert.match(shell, /aiRun\.messageId = String\(messageId\)/);
|
||||
});
|
||||
|
||||
test('AI 会话管理提供新建、重命名、置顶、清空与删除', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
|
||||
assert.match(html, /id="aiSessionSelect"[^>]+title="切换当前书籍的对话会话"/);
|
||||
assert.match(html, /id="aiSessionNewBtn"[^>]*>新建</);
|
||||
assert.match(html, /id="aiSessionRenameBtn"[^>]*>重命名</);
|
||||
assert.match(html, /id="aiSessionPinBtn"[^>]*>置顶</);
|
||||
assert.match(html, /id="aiSessionClearBtn"[^>]*>清空</);
|
||||
assert.match(html, /id="aiSessionDeleteBtn"[^>]*>删除</);
|
||||
|
||||
// 会话下拉按 pinned 优先、updatedAt 倒序,标题为空时显示「新会话」
|
||||
assert.match(shell, /a\.pinned \? -1 : 1/);
|
||||
assert.match(shell, /Number\(b\.updatedAt\) \|\| 0\) - \(Number\(a\.updatedAt\) \|\| 0\)/);
|
||||
assert.match(shell, /return title \|\| '新会话'/);
|
||||
|
||||
// 一开书就建空会话会很快占满 100 个上限,必须延迟到首次提问
|
||||
assert.match(shell, /async function ensureAiSession\(\)/);
|
||||
assert.match(shell, /if \(aiSessionId\) return aiSessionId/);
|
||||
const activateBlock = shell.match(/async function activate\(id\)[\s\S]*?\n\}/);
|
||||
assert.ok(activateBlock);
|
||||
assert.match(activateBlock[0], /refreshAiSessions\(\)/);
|
||||
assert.doesNotMatch(activateBlock[0], /sessions\.create\(/);
|
||||
|
||||
// 生成中禁止切会话、删会话与发新问题
|
||||
assert.match(shell, /el\.aiSessionSelect\.disabled = busy \|\| !hasEntry/);
|
||||
assert.match(shell, /el\.aiSessionDeleteBtn\.disabled = busy \|\| !hasSession/);
|
||||
assert.match(shell, /function aiBusy\(busy\)[\s\S]{0,400}syncAiSessionControls\(\)/);
|
||||
});
|
||||
|
||||
test('删除与清空 AI 会话都要走应用内二次确认', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
|
||||
assert.match(html, /id="aiSessionDeleteModal"[\s\S]*id="aiSessionDeleteConfirmBtn"[^>]*>删除会话</);
|
||||
assert.match(html, /id="aiSessionClearModal"[\s\S]*id="aiSessionClearConfirmBtn"[^>]*>清空消息</);
|
||||
assert.match(html, /id="aiSessionRenameModal"[\s\S]*id="aiSessionTitleInput"/);
|
||||
|
||||
// 对话记录删掉找不回来,必须先确认再调 remove/clear
|
||||
const remove = shell.match(/async function deleteAiSession\(\)[\s\S]*?\n\}/);
|
||||
assert.ok(remove, '缺少 deleteAiSession');
|
||||
const removeConfirmAt = remove[0].indexOf('await confirmAiSessionDelete(');
|
||||
const removeCallAt = remove[0].indexOf('sessions.remove(');
|
||||
assert.ok(removeConfirmAt >= 0, '删除会话缺少二次确认,会一键抹掉全部对话记录');
|
||||
assert.ok(removeCallAt >= 0, '删除会话未调用 sessions.remove');
|
||||
assert.ok(removeConfirmAt < removeCallAt, '删除会话必须先弹二次确认再调 sessions.remove');
|
||||
const clear = shell.match(/async function clearAiSession\(\)[\s\S]*?\n\}/);
|
||||
assert.ok(clear, '缺少 clearAiSession');
|
||||
const clearConfirmAt = clear[0].indexOf('await confirmAiSessionClear(');
|
||||
const clearCallAt = clear[0].indexOf('sessions.clear(');
|
||||
assert.ok(clearConfirmAt >= 0, '清空会话缺少二次确认,消息删掉找不回来');
|
||||
assert.ok(clearCallAt >= 0, '清空会话未调用 sessions.clear');
|
||||
assert.ok(clearConfirmAt < clearCallAt, '清空会话必须先弹二次确认再调 sessions.clear');
|
||||
assert.match(shell, /function confirmAiSessionDelete\(title\)[\s\S]{0,400}aiSessionDeleteModal\.classList\.remove\('hidden'\)/);
|
||||
});
|
||||
|
||||
test('AI 会话调用 api.ai.sessions 契约并带上 sessionId 发送', () => {
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
assert.match(shell, /api\.ai && api\.ai\.sessions \? api\.ai\.sessions : null/);
|
||||
assert.match(shell, /sessions\.list\(\{ entryId \}\)/);
|
||||
assert.match(shell, /sessions\.create\(\{ entryId, title: '', documentKey: tab\.documentKey \|\| null \}\)/);
|
||||
assert.match(shell, /sessions\.rename\(aiSessionId, title\)/);
|
||||
assert.match(shell, /sessions\.setPinned\(aiSessionId, next\)/);
|
||||
assert.match(shell, /sessions\.remove\(removed\)/);
|
||||
assert.match(shell, /sessions\.clear\(aiSessionId\)/);
|
||||
assert.match(shell, /sessions\.messages\(wanted, \{ limit: AI_THREAD_LIMIT \}\)/);
|
||||
// sessionId 不下发就退化成不落盘的一次性提问,线程无法续接
|
||||
assert.match(shell, /api\.ai\.run\(\{\s*\n\s*runId,\s*\n\s*sessionId,/);
|
||||
assert.match(shell, /res\.data\.assistantMessageId|ids\.assistantMessageId/);
|
||||
});
|
||||
|
||||
test('AI 每条回答各自提供保存为笔记与复制', () => {
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
assert.match(shell, /save\.className = 'tb-btn sm ai-msg-save'/);
|
||||
assert.match(shell, /copy\.className = 'tb-btn ghost sm ai-msg-copy'/);
|
||||
assert.match(shell, /saveAiNote\(aiResultOf\(box\.dataset\.messageId\)\)/);
|
||||
assert.match(shell, /copyAiMessage\(box\.dataset\.messageId\)/);
|
||||
// locator 取该条对应 user 消息的 contextRef,quote 取那条问题文本
|
||||
assert.match(shell, /if \(aiThread\[i\]\.role === 'user'\) \{ ask = aiThread\[i\]; break; \}/);
|
||||
assert.match(shell, /locator: \(ref && ref\.locator\) \|\| null/);
|
||||
assert.match(shell, /quote: ask \? String\(ask\.text \|\| ''\) : ''/);
|
||||
assert.match(shell, /async function saveAiNote\(target\)/);
|
||||
});
|
||||
|
||||
test('AI 气泡标注上下文范围、停止、裁剪与省略轮次', () => {
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
assert.match(shell, /const parts = \[scopeName\(ref\.scope\)\]/);
|
||||
assert.match(shell, /parts\.push\(`\$\{chars\.toLocaleString\(\)\} 字`\)/);
|
||||
assert.match(shell, /`上下文:\$\{parts\.join\(' · '\)\}`/);
|
||||
assert.match(shell, /if \(message\.truncated\) flags\.push\('内容已裁剪'\)/);
|
||||
assert.match(shell, /if \(message\.cancelled\) flags\.push\('已停止生成'\)/);
|
||||
assert.match(shell, /box\.classList\.add\('ai-msg-error'\)/);
|
||||
assert.match(shell, /较早的 \$\{dropped\.toLocaleString\(\)\} 轮对话已省略/);
|
||||
});
|
||||
|
||||
test('AI 线程里的模型输出全部经 AiMarkdown 渲染,不直接写 innerHTML', () => {
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
// 渲染层 CSP 挡不住 DOM 注入,模型文本必须过 AiMarkdown 内的 DOMPurify
|
||||
assert.match(shell, /function renderMessageBody\(body, source\)[\s\S]{0,300}window\.AiMarkdown\.mount\(body, source\)/);
|
||||
assert.match(shell, /else renderMessageBody\(body, message\.text\)/);
|
||||
assert.doesNotMatch(shell, /\.innerHTML\s*=/);
|
||||
// 用户输入按纯文本落地,同样不经 HTML 解析
|
||||
assert.match(shell, /if \(role === 'user'\) body\.textContent = String\(message\.text \|\| ''\)/);
|
||||
// 链接拦截仍挂在整个线程容器上,新增气泡里的链接不会漏掉
|
||||
assert.match(shell, /el\.aiOutput\.addEventListener\('click', activateAiLink\)/);
|
||||
assert.match(shell, /el\.aiOutput\.addEventListener\('auxclick', activateAiLink\)/);
|
||||
assert.match(shell, /if \(!link \|\| !el\.aiOutput\.contains\(link\)\) return/);
|
||||
});
|
||||
|
||||
test('可阅读图书封面支持鼠标与键盘打开内置阅读器', () => {
|
||||
const library = fs.readFileSync(libFile, 'utf8');
|
||||
assert.match(library, /data-act="read" role="button" tabindex="0"/);
|
||||
|
||||
Reference in New Issue
Block a user