diff --git a/.planning/display-image-render-failure/browser-after.png b/.planning/display-image-render-failure/browser-after.png new file mode 100644 index 0000000..7addb4c Binary files /dev/null and b/.planning/display-image-render-failure/browser-after.png differ diff --git a/.planning/display-image-render-failure/findings.md b/.planning/display-image-render-failure/findings.md new file mode 100644 index 0000000..6fe7c0d --- /dev/null +++ b/.planning/display-image-render-failure/findings.md @@ -0,0 +1,14 @@ +# 排查发现 + +- 截图会话 ID 前缀为 0bb322b1,项目 kongjun,连续八条“图片渲染失败”。 +- 当前代码索引 home-cc-web 状态 ready。 +- 已有 public/app.js、public/index.html、public/style.css、scripts/regression.js 未提交改动,属于文件浏览器画廊任务。 +- 前端只读子代理并行定位;主代理检查服务端与实际图片来源。 +- 实际历史中恰好八次图片调用均返回 ready;结果外层是 MCP CallToolResult,含 content 文本与 structuredContent。 +- parseCcwebDisplayImageResult 仅解析一层 JSON;createCcwebDisplayImageToolContent 直接读 payload.ok,因而误报失败。 +- 八个附件及元数据均存在,PNG 尺寸全部为 936×1664;大小与历史记录一致。 +- 运行服务 /app.js 返回 200 且 Cache-Control 为 no-store,服务每次从磁盘读取静态资源,不需要重启后端。 +- 后续 codebase-memory 查询出现 Transport closed;此前已取得必要符号与源码,补充验证降级为本地 rg。 +- 已在同一真实会话使用旧代码/新代码做浏览器对照:旧代码 8/8 失败且无 img;新代码 8/8 图片自然尺寸均 936×1664、failures=0、pageErrors=[]。 +- 运行服务提供的 app.js 与磁盘修复代码 cmp 一致;不需要重启服务。 +- 其他任务原始新增行保留检查 328/328。 diff --git a/.planning/display-image-render-failure/progress.md b/.planning/display-image-render-failure/progress.md new file mode 100644 index 0000000..c0f2c85 --- /dev/null +++ b/.planning/display-image-render-failure/progress.md @@ -0,0 +1,16 @@ +# 进度 + +- 已阅读 planning-with-files 与 Trellis 工作流,开发者身份 shiyue 已存在。 +- 已建立三阶段计划与 CSV 跟踪。 +- 已派发前端只读排查子任务。 +- 已确认八个真实结果都是成功返回,前端缺少 MCP CallToolResult 解包。 +- 已创建 Trellis PRD 与开发/检查上下文;准备由实现代理做局部修复与行为验证。 +- 实现完成,独立解析/DOM 行为单测及语法检查通过;八个真实历史结果均进入附件预览分支。 +- Playwright 真实运行会话用修复前代码复现 total=8、failures=8、无图片元素。 +- 浏览器初次被远程 CDN 阻塞;改用既有本地缓存路由对应 CDN 脚本后正常,未改产品代码。 +- 已派发独立审查;开始修复后真实浏览器验收。 +- 修复后浏览器验收通过:原会话八张附件均完成实际鉴权请求并解码,8/8 loaded,页面脚本错误 0。 +- 证据截图保存为 browser-after.png;运行服务响应内容与磁盘修复 app.js 一致。 +- 独立审查通过,独立单测已接入原图片目标回归;规范已补 MCP 结果解包约定。 +- 独立单测、目标回归、三个 JavaScript 文件语法检查及 git diff --check 均通过。 +- 所有阶段完成,清理本任务 CSV,保留计划与浏览器证据。未重启服务,未改写会话数据。 diff --git a/.planning/display-image-render-failure/task_plan.md b/.planning/display-image-render-failure/task_plan.md new file mode 100644 index 0000000..57ddf77 --- /dev/null +++ b/.planning/display-image-render-failure/task_plan.md @@ -0,0 +1,22 @@ +# 图片展示失败排查 + +目标:定位截图中 ccweb_display_image 连续图片渲染失败的原因,修复并验证。 + +## 阶段 +1. 定位图片展示失败的实际链路与原因:completed +2. 修复问题并补充必要回归验证:completed +3. 验证实际图片请求并整理结果:completed + +## 验收结果 +- 真实浏览器旧代码 8/8 失败,新代码 8/8 正常加载,页面异常 0。 +- 独立行为单测、图片目标回归、语法检查与差异检查通过。 +- 静态资源已由运行服务提供;用户刷新页面即可恢复,不需重启或重新生成图片。 + +## 约束 +- 保留已有文件浏览器画廊改动,不回滚、不覆盖。 +- 不对其他运行会话造成中断;重启前检查运行列表。 +- 使用 codebase-memory 索引定位代码,不使用 graphify。 + +## 错误 +- todo-list-csv / trellis 专用技能在本地未发现,采用原始规则和通用子代理。 +- codebase-memory 后续查询连接关闭(Transport closed),已掌握精确入口,降级本地校验,不重复请求。 diff --git a/.planning/file-browser-image-gallery/findings.md b/.planning/file-browser-image-gallery/findings.md new file mode 100644 index 0000000..c43bb64 --- /dev/null +++ b/.planning/file-browser-image-gallery/findings.md @@ -0,0 +1,10 @@ +# 发现 + +- 初始工作区干净;当前 main 提交 fb2c336。 +- codebase-memory-mcp 索引 home-cc-web 状态 ready。 +- 图片预览入口为 public/app.js 的 openFileBrowserFile / showFileBrowser。 +- 已有鉴权图片接口,沿用当前图片格式白名单与资源释放策略。 +- 已找到 openAttachmentPreviewModal 现成弹层,消息附件与 createAssistantImageElement 均调用它;当前没有图库切换或缩放平移。 +- 将扩展已有弹层的文件图库模式,保留消息图片原有行为。首尾禁用对应箭头,支持按钮、方向键与移动端触控按钮。 +- 已完成图库模式:复用 openAttachmentPreviewModal,文件图鉴权来源按需加载,SVG data URL,Blob 在切换/关闭时释放。 +- 已验证首尾禁用、目录仅筛选图片、单图状态、手机 44px 按钮和全视口适配,Escape 捕获避免关闭底层浏览器。 diff --git a/.planning/file-browser-image-gallery/progress.md b/.planning/file-browser-image-gallery/progress.md new file mode 100644 index 0000000..3934ce8 --- /dev/null +++ b/.planning/file-browser-image-gallery/progress.md @@ -0,0 +1,13 @@ +# 进展 + +- 2026-10-17:启动文件图片全屏查看和目录切图任务;完成索引与初始状态检查。 +- 已将 app.js / style.css / regression.js 的实现委派给 gallery_implement;主线程准备独立服务上的真实浏览器验证。 +- 已确认本机 Chromium 149 可直接运行,无需安装浏览器或依赖。Firefox 包装命令超时,改用现有 Chromium。 +- FastCtx 文件输出预算过小且目录遍历耗时;定位优先使用代码索引,具体测试准备按需用终端补充。 +- 实现完成:复用附件弹层的全视口模式,添加同目录图片按钮、方向键、页码、首尾禁用、焦点恢复与请求清理。 +- 实现代理已通过 file-browser-image、file-preview-encoding、frontend-asset-version 三项专项回归及语法检查。 +- 隔离浏览器已成功登录并打开真实图片预览,正在继续图库交互验收。 +- Chromium 真实浏览器验收完成:点击打开、按钮和方向键切换、SVG、首尾与单图状态、手机尺寸、Escape 分层关闭、焦点恢复、快速翻页及 Blob 清理共 20 项断言通过。 +- 浏览器测试屏蔽了无关外部 CDN/字体并为 Markdown 渲染提供空会话所需替身,图片请求、DOM、样式与鉴权均使用真实项目实现。 +- 初次浏览器模拟点击不聚焦、Enter 模拟不产生原生激活,已改为真实鼠标事件后验证焦点恢复通过,未因此修改产品代码。 +- 测试结果 /tmp/ccweb-gallery-e2e.265atk/result.json,桌面与移动端截图已人工查看;本轮没有重启、打包、提交或推送。 diff --git a/.planning/file-browser-image-gallery/task_plan.md b/.planning/file-browser-image-gallery/task_plan.md new file mode 100644 index 0000000..a625e17 --- /dev/null +++ b/.planning/file-browser-image-gallery/task_plan.md @@ -0,0 +1,21 @@ +# 文件预览全屏与目录切图 + +## 目标 +点击文件浏览器里的图片,复用现有图片查看器全屏查看,支持当前文件夹内前后切图。 + +## 阶段 +1. 确认现有查看器与文件列表数据接口:完成。 +2. 接入全屏预览与同目录切换:完成。 +3. 验证桌面、移动端与资源释放:完成。 + +## 验证结果 +- 三项专项回归、两项语法检查和 git diff --check 均通过。 +- Chromium 149:1440×1000 桌面与 390×844 手机视口共 20 项交互断言通过,页面无未捕获异常。 +- 截图与临时浏览器脚本保存在 /tmp/ccweb-gallery-e2e.265atk/;独立服务和浏览器已退出。 +- 复用既有组件与资源加载惯例,没有新增需要单独扩展规范的模式。 + +## 约束 +- 保持现有鉴权图片接口和目录权限边界。 +- 复用现有组件,无新增依赖;按需加载,避免整目录提前下载。 +- 沿用用户要求,不做额外审计。 +- 本轮只实现与验证,不重新打包、提交、推送或重启服务。 diff --git a/.planning/session-switch-live-failure/findings.md b/.planning/session-switch-live-failure/findings.md new file mode 100644 index 0000000..a03eecf --- /dev/null +++ b/.planning/session-switch-live-failure/findings.md @@ -0,0 +1,18 @@ +# 排查发现 + +- 线上已使用 GET /api/sessions/:id,HTTP读取不等待WebSocket。 +- 缓存分为完整新鲜、完整过期和未命中;运行中的会话不能使用完整新鲜缓存直出。 +- 首屏读取12条、后续24条;首屏附带完整运行正文和工具调用快照。 +- 完成快照后,浏览器才订阅实时更新;订阅修订变化会触发后台HTTP对账。 +- 2026-10-18最近业务查询记录200、服务端执行为毫秒级;该计时不覆盖进入处理函数前等待、传输、浏览器解析与显示。 +- 工作区已有图片显示及文件浏览器等未提交修改,必须保留。 +- 真实归档隔离副本首屏实测:常用会话约805KB/122个工具,另一会话1.21MB/143个工具,最大副本6.31MB/311个工具;普通本机Firefox切换约0.4~1.5秒,未复现45秒超时。 +- 实际工具结果占主要体积,最大单条工具输出约1MB;闭合的工具details仍立即构造完整输出DOM,最大副本出现约1秒主线程停顿。 +- 正在受控传输夹具下复现完整工具输出绑在首屏导致的45秒请求超时;不据此断言现场网络有故障。 +- 2026-10-19线上请求02:27:06发出,02:28:25才进入HTTP处理函数,处理本身39ms;8秒CPU采样的大部分样本落在parseJsonTopLevelSuffixFields,调用源为WebSocket的sendSessionList。 +- 原算法对每个嵌套id/title候选复制并JSON.parse整个剩余后缀,近二次方成本与GC阻塞了所有HTTP请求。已改为从根结束符反向单次扫描,只解析完整顶层元数据。 +- 列表改读归档loadMetadata,不读正文块、不因快照过期而同步重建;按归档修订及快照stat缓存列表字段,标题/未读变化立即失效。 +- 128KB密集工具伪键只需14次JSON解析、约12ms;8份大旧快照刷新列表与HTTP读取并发回归通过。290份实际会话只读元数据探针无正文块读取和写盘。 +- 线上重启后真实Firefox点击三个现有会话,分别352/430/184ms显示,当前运行正文恢复且工具详情完整。 +- 追加并发刷新列表的连续9次真实点击,120~462ms全部显示,无浏览器错误;生产历史和未读未被测试修改(测试浏览器阻断实时订阅,另用鉴权WS只发list_sessions)。 +- 290份实际会话元数据冷读1978ms;相同进程后续两次列表47/19ms,修订缓存避免每次重新解析清单。 diff --git a/.planning/session-switch-live-failure/progress.md b/.planning/session-switch-live-failure/progress.md new file mode 100644 index 0000000..6c396da --- /dev/null +++ b/.planning/session-switch-live-failure/progress.md @@ -0,0 +1,15 @@ +# 工作进度 + +- 2026-10-18:用户反馈HTTP改造发布后仍失败;重新打开问题,不再沿用此前完成结论。 +- 已检查线上脚本与源码一致、新GET路由存在;这些只作为版本证据。 +- 开始准备生产历史隔离副本和真实浏览器复现场景。 + +- 已实现 HTTP 历史/运行快照及订阅工具概要,新增鉴权详情读取,浏览器折叠详情懒建。进入隔离回归;尚未上线。 + +- API/真实Firefox回归通过;100个大工具首屏39KB,32KB/s传输下2.3秒显示,工具详情完整且归档后原引用可读。实际最大会话副本由6305982字符降至251096字符,显示416ms。尚未重启/发布。 + +- 2026-10-19线上已激活概要化版本,但真实页面仍未通过:请求session-load-1792376826855在02:27:06发出,服务端02:28:25才进入处理并记39ms。存在服务端处理前长等待,恢复实现阶段;不得报完成。 +- 已修复CPU采样定位的列表解析阻塞,并通过元数据单测、session-preview-metadata并发回归和Firefox完整切换回归(/tmp/cc-web-session-http-xd0sGW)。完整历史大套件60秒到期未跑完,不计全通过。 +- 运行状态已确认仅当前对话,进入重启与线上实际点击验证。 +- 线上已重启,真实点击与连续9次并发列表刷新验证通过,分别见/tmp/ccweb-switch-live-AdoSFT/evidence.json及/tmp/ccweb-switch-live-3DL94a/evidence.json。最大会话、当前运行会话及完整工具详情均正常。 +- 已重新打包Bun baseline单可执行包,MCP初始化冒烟通过。发布包SHA256:ef5801d70fd04737c7c149335967a59b36f2039ab89b1e1bc5612ef997a15d6f。 diff --git a/.planning/session-switch-live-failure/task_plan.md b/.planning/session-switch-live-failure/task_plan.md new file mode 100644 index 0000000..47f8263 --- /dev/null +++ b/.planning/session-switch-live-failure/task_plan.md @@ -0,0 +1,21 @@ +# 继续修复会话切换失败 + +## 目标 +定位用户在 HTTP 改造发布后仍然切换失败的实际原因,验证完整的点击、请求、接收、显示和实时订阅链路。 + +## 阶段 +1. [completed] 复现现场会话切换失败并定位阻塞阶段。 +2. [completed] 修复已复现的根因并覆盖对应回归。 +3. [completed] 验证实际点击切换与运行态恢复并记录上线条件。 + +## 约束 +- 禁止子代理,不修改其他任务尚未提交的改动。 +- 不修改生产会话历史;复现使用隔离副本。 +- 不把 HTTP 200、代码生效或打包通过视作用户问题解决。 +- 重启前确认除当前对话外无其他运行会话。 +- 单次测试最多60秒;沿用用户此前打包、提交全部修改并推送的授权,在修复与验证后交付。 + +## 已定位 +- 线上请求在进入HTTP处理函数前排队约79秒,CPU采样指向WebSocket会话列表的快照尾部重复解析。 +- 修复:归档列表只读元数据,旧快照尾部单次扫描;按磁盘修订缓存列表字段,不重建历史快照。 +- 已通过高密度嵌套字段、截断边界、并发列表与HTTP切换、真实Firefox回归;线上真实点击3次及并发列表刷新连续切换9次均通过。 diff --git a/.trellis/spec/backend/session-query.md b/.trellis/spec/backend/session-query.md index 233a853..9e402ba 100644 --- a/.trellis/spec/backend/session-query.md +++ b/.trellis/spec/backend/session-query.md @@ -12,6 +12,7 @@ ```text GET /api/sessions/:id?requestId= GET /api/sessions/:id/history?before=<非负整数>&requestId= +GET /api/sessions/:id?toolDetail=&requestId= Authorization: Bearer <已有登录令牌> handleSessionQueryApi(req, res, url, encodedId, historyPage = false) @@ -29,11 +30,20 @@ WS ← { type: 'session_subscribed', sessionId, requestId, historyRevision, hist - 两者都提供 `sessionId/requestId/messages/historyTotal/historyBaseIndex/historyCursor/` `historyAvailable/historySource/historyRevision`;`requestId` 最多160字符,浏览器生成 有界且唯一的代次标识,成功响应必须同时匹配会话和请求。 -- 首屏的 `runtime` 是完整 `resume_generating` 快照或 `null`,即使没有 WebSocket - 也能显示已产生输出。停止运行但尚未归档的保留态显示后须收束生成状态。 +- 首屏的 `runtime` 是 `resume_generating` 快照或 `null`,即使没有 WebSocket + 也能显示已产生正文和全部工具概要。停止运行但尚未归档的保留态显示后须收束生成状态。 +- 首屏、分页及订阅恢复中,普通工具只传概要和 `detailRef`,不携带完整输入输出。 + 工具详情独立通过已鉴权 HTTP 读取,响应为 `session_tool_detail` 和完整 `tool`。 + 引用绑定会话、消息身份、工具位置/ID和内容摘要;运行引用另绑定回合对象的唯一作用域。 + 历史内容变更返回409,不能用错误位置冒充原工具;运行结束后可按助手消息身份读取归档。 + 计划、协作状态、提问和图片等直接显示的工具保留完整载荷。存储内容不受概要传输影响。 - HTTP 只读取 cc-web 自身已保存记录;首屏不扫描原生日志、不协调跨会话、不清未读。 归档按块有界读取;旧快照保留来源待核实状态。磁盘和 JSON 处理仍在同一服务进程, 传输拆分不代表所有同步 IO 已异步化。 +- 会话列表优先读取归档元数据,禁止为刷新列表读取正文块或重建快照。 + 列表字段按归档修订及快照文件状态缓存,标题、未读和目标更新后立即失效。 + 大旧快照只扫描有界头尾;尾部从根对象结束符反向单次扫描完整顶层字段, + 禁止对每个嵌套id/title候选重复解析整个后缀,否则会阻塞同进程的HTTP请求。 - 订阅校验元数据、绑定实时输出、清未读并冲刷已有跨会话回复,不发送历史消息。 比较清未读前的归档修订;有差异时由浏览器再通过 HTTP 对账。确认后返回清未读 产生的新修订,避免元数据保存导致重复对账。 @@ -56,6 +66,8 @@ WS ← { type: 'session_subscribed', sessionId, requestId, historyRevision, hist | URI 解码失败或 ID 含字母数字、连字符之外字符 | 400 `history_invalid_id`;不得清洗后别名到另一会话 | | 游标为负数、小数或非安全整数 | 400 `history_invalid_cursor` | | 会话确实缺失 | 404 `session_not_found` | +| 工具详情引用格式错误或跨会话 | 400 `tool_detail_invalid` | +| 引用对应工具已变更或不再存在 | 409 `tool_detail_changed` | | 元数据、清单或历史块读取失败 | 500 `history_read_failed`,禁止吞成404 | | 实时绑定失败 | WS `session_subscribe_failed`,历史继续可读 | | 新选择取消、迟到响应或不匹配订阅 | 不修改新视图及其加载状态 | @@ -79,6 +91,13 @@ WS ← { type: 'session_subscribed', sessionId, requestId, historyRevision, hist - `node scripts/session-http-load-regression.js --browser`:真实 Firefox 经转发层测试 假 OPEN、读取途中断 WS、完全断连后切换、分页、迟到 A 响应、完成间隙及历史500。 断言浏览器不发送 `load_session/load_history_page/resume_session` 查询历史。 +- 同一浏览器回归还覆盖32KB/s传输下100个大工具的首屏、折叠工具不构建详情DOM、 + 展开完整详情,以及运行工具完成归档后继续使用原引用读取详情。接口回归比较原始输入输出, + 检查概要字节数、跨会话/过期引用、鉴权,以及订阅不能再重发大工具结果。 +- `node scripts/session-metadata-unit.js`:高密度嵌套伪字段的解析次数有界、转义与截断边界、 + 缺失/损坏快照仍从归档列举、不读正文不写快照、归档修订更新使缓存失效。 +- `node scripts/regression.js --target session-preview-metadata`:大旧快照的顶层元数据正确, + WebSocket刷新列表与HTTP首屏并发时,HTTP读取不能因列表解析排队数秒。 - 外部 CDN 不稳定时可设置 `CC_WEB_BROWSER_CDN_DIR`,使用与页面相同版本的原始 `marked.min.js/highlight.min.js/mermaid.min.js/atom-one-dark.min.css` 本地夹具;仅转发层 改写这些静态 URL,生产请求函数与渲染器保持原样,避免把登录前静态资源超时当作切换失败。 diff --git a/.trellis/spec/frontend/component-guidelines.md b/.trellis/spec/frontend/component-guidelines.md index 6836c3f..06057b7 100644 --- a/.trellis/spec/frontend/component-guidelines.md +++ b/.trellis/spec/frontend/component-guidelines.md @@ -57,3 +57,13 @@ Questions to answer: (To be filled by the team) + +### MCP 结果组件 + +- 渲染 MCP 工具结果前,先区分直接业务对象和 `CallToolResult` 包装;包装结果优先 + 读取 `structuredContent`,缺失或无效时再解析 `content` 中的文本业务 JSON。 + 不应直接用包装对象的 `ok` 判断业务失败。 +- `isError: true` 表示工具调用失败,不能被内部 `ok: true` 覆盖;保留明确错误消息、 + 未完成时的等待状态和畸形结果的兜底提示。解包限定标准层级,避免循环或递归嵌套。 +- 行为回归必须使用真实 MCP 包装形状,同时覆盖对象与 JSON 字符串、成功附件与 + 直链图片、业务失败与 MCP 错误;历史回放和实时更新应共用相同的解析及渲染入口。 diff --git a/.trellis/spec/frontend/state-management.md b/.trellis/spec/frontend/state-management.md index 93e0f44..4db5a77 100644 --- a/.trellis/spec/frontend/state-management.md +++ b/.trellis/spec/frontend/state-management.md @@ -47,7 +47,8 @@ Questions to answer: - 历史游标表示服务端当前记录中尚未展示的消息数量。正数游标可以分页,与外部原始记录是否可确认恢复分开判断。 - `historyAvailable=false` 只用于旧历史来源的核实说明,不能阻断已保存消息的正文或用户提问目录请求,也不能据此宣称消息已丢失或无法恢复。 - 读取完已保存消息后,游标归零应停止请求;确有旧来源待核实时保留准确说明,正常完整历史不显示该说明。 -- `resume_generating`是完整运行态快照,以正文及全部工具权威替换当前流式状态,并复用同一逻辑助手气泡;不盲目拼接不同用户任务。 +- `resume_generating`以完整正文及全部工具概要权威替换当前流式状态,并复用同一逻辑助手气泡;不盲目拼接不同用户任务。普通工具的 `detailRef` 必须跟随运行状态保存,不能在重建工具时丢失。 +- 折叠工具只构建摘要,首次展开才读取完整详情并构建详情DOM。详情请求单独鉴权、超时、重试,不阻塞会话首屏;切换会话取消详情请求,迟到结果不得修改新视图。工具概要只供显示,禁止回写为完整历史。 - 长气泡折叠只改变显示。完整正文保留在原始消息和DOM中;多次原生回复默认收起较早段,末段可见;单次超长回复可展开完整内容。 - 折叠状态按会话与稳定消息身份记录;完成、切换和重新渲染不得把被折叠的内容当作已删除内容保存。 - 复制按钮与定位、分支同排,仅从完整原始正文中复制最后一个原生回复段。按围栏外的 `agent-message-divider`切分,不以最后自然段或DOM的最后一个p元素判断;代码块、列表保留,时间分隔、工具和按钮不进入剪贴板。没有原生分界时复制整条回复。 diff --git a/.trellis/tasks/10-14-session-switch-timeout/task.json b/.trellis/tasks/10-14-session-switch-timeout/task.json index 2f2ea17..a078e86 100644 --- a/.trellis/tasks/10-14-session-switch-timeout/task.json +++ b/.trellis/tasks/10-14-session-switch-timeout/task.json @@ -11,7 +11,7 @@ "creator": "shiyue", "assignee": "shiyue", "createdAt": "2026-10-14", - "completedAt": "2026-10-15", + "completedAt": "2026-10-19", "branch": null, "base_branch": "main", "worktree_path": null, diff --git a/.trellis/tasks/10-14-session-switch-timeout/validation.json b/.trellis/tasks/10-14-session-switch-timeout/validation.json index 734a2b1..509385b 100644 --- a/.trellis/tasks/10-14-session-switch-timeout/validation.json +++ b/.trellis/tasks/10-14-session-switch-timeout/validation.json @@ -1,46 +1,76 @@ { - "browser": "Firefox 157.0.1", - "browserEvidence": "/tmp/cc-web-session-http-FcFyDq/browser-evidence.json", - "apiEvidence": "/tmp/cc-web-session-http-VTWGN4", - "verified": [ - "WebSocket 假 OPEN 下首屏和完整运行输出", - "HTTP 读取途中断开 WebSocket 仍完成", - "拒绝 WebSocket 重连后仍可切换", - "HTTP 分页58条顺序正确且无重复", - "A 迟到响应不会覆盖 B", - "读取与订阅之间完成的任务通过修订对账补齐", - "损坏历史500立即结束加载并保留当前视图", - "浏览器不发送 WebSocket 历史读取请求", - "同会话代次、分页取消、完成交错、错误响应和渲染异常", - "HTTP 鉴权、非法ID/游标、损坏块/清单、修订变化与运行快照" + "checkedAt": "2026-10-19T02:57:49.386Z", + "status": "verified", + "rootCause": "WebSocket会话列表的尾部重复JSON解析阻塞服务主线程,HTTP进入处理函数前排队约79秒", + "fixes": [ + "归档列表只读元数据、按修订缓存,不读取正文或重建快照", + "旧快照尾部反向单次扫描顶层字段", + "首屏和运行快照只传普通工具概要,展开后HTTP读取完整详情", + "折叠工具延迟构建详情DOM与切换取消" ], - "otherPassed": [ + "activation": { + "status": "online", + "pid": 2886941, + "startedAt": "2026-10-19T02:41:10.051Z", + "servedFrontendMatchesSource": true + }, + "productionBrowser": { + "browser": "158.0", + "evidence": "/tmp/ccweb-switch-live-AdoSFT/evidence.json", + "stages": [ + { + "id": "0b768f2e-acc2-4faa-8cce-841531cf3643", + "durationMs": 352, + "count": 12, + "tools": 122, + "streaming": false, + "errors": [] + }, + { + "id": "ccc82825-4d91-4db7-96fe-315a2c30061f", + "durationMs": 430, + "count": 8, + "tools": 311, + "streaming": false, + "errors": [] + }, + { + "id": "e28cff1a-100e-4225-a33e-d7745017ab79", + "durationMs": 184, + "count": 12, + "tools": 227, + "streaming": true, + "errors": [] + } + ], + "toolDetailComplete": true + }, + "productionConcurrentListStress": { + "evidence": "/tmp/ccweb-switch-live-3DL94a/evidence.json", + "switches": 9, + "minMs": 120, + "maxMs": 462, + "allDisplayedWithoutErrors": true, + "toolDetailComplete": true, + "readOnly": true + }, + "passed": [ + "session-metadata-unit", + "session-preview-metadata: 密集工具伪字段列表与HTTP并发", + "session-http-load: 首屏/分页/错误/运行恢复/工具详情", + "Firefox:32KB/s大工具首屏、折叠懒建、完整详情、断WS切换、迟到隔离、完成交错(/tmp/cc-web-session-http-xd0sGW)", "session-switch-race", - "title-history-outline", - "frontend-asset-version", - "history-frontend-unit", - "history-runtime-unit", - "session-history-unit: 12项", - "history-runtime-completion-unit: 15项", - "JavaScript语法和git diff --check" + "history-runtime-completion-unit:15项", + "ccweb-display-image-unit", + "孤立归档列表不重建快照(完整历史套件第10项)", + "JS语法、git diff --check", + "Bun baseline打包与MCP初始化" ], "incompleteAt60Seconds": [ - "history-recall 大套件", - "session-history-store-unit 全套:前24项已显示通过" + "session-history-store-unit完整套件(60秒已输出24项通过)" ], - "activation": "verified", - "activationEvidence": { - "checkedAt": "2026-10-15T00:17:13.907+08:00", - "status": "online", - "pid": 2353800, - "restarts": 86, - "port": 8002, - "version": "953d5e1a0bbdcf56", - "httpStatus": 401, - "httpErrorCode": "not_authenticated", - "requestIdEchoed": true, - "servedFrontendMatchesSource": true, - "hasHttpQuery": true, - "hasRealtimeSubscription": true + "release": { + "path": "dist-exe/cc-web-bun-linux-x64-baseline.tar.gz", + "sha256": "ef5801d70fd04737c7c149335967a59b36f2039ab89b1e1bc5612ef997a15d6f" } } diff --git a/.trellis/tasks/10-17-display-image-render-failure/check.jsonl b/.trellis/tasks/10-17-display-image-render-failure/check.jsonl new file mode 100644 index 0000000..b378aad --- /dev/null +++ b/.trellis/tasks/10-17-display-image-render-failure/check.jsonl @@ -0,0 +1,2 @@ +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"前端验证要求"} +{"file":".trellis/tasks/10-17-display-image-render-failure/prd.md","reason":"检查真实故障与业务边界"} diff --git a/.trellis/tasks/10-17-display-image-render-failure/implement.jsonl b/.trellis/tasks/10-17-display-image-render-failure/implement.jsonl new file mode 100644 index 0000000..53cd0b9 --- /dev/null +++ b/.trellis/tasks/10-17-display-image-render-failure/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/frontend/index.md","reason":"前端开发规范"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"前端验证要求"} +{"file":".trellis/spec/frontend/component-guidelines.md","reason":"DOM 组件风格"} +{"file":".trellis/tasks/10-17-display-image-render-failure/prd.md","reason":"真实故障形状与修复边界"} diff --git a/.trellis/tasks/10-17-display-image-render-failure/prd.md b/.trellis/tasks/10-17-display-image-render-failure/prd.md new file mode 100644 index 0000000..6d57be3 --- /dev/null +++ b/.trellis/tasks/10-17-display-image-render-failure/prd.md @@ -0,0 +1,20 @@ +# 修复 ccweb_display_image 图片展示失败 + +## 需求 +用户截图中会话 0bb322b1-e9f8-47e7-9af1-970411bfec5a 连续八张图片均显示“图片渲染失败”。定位并修复现有显示链路,使已保存图片在刷新后正常显示,并给后续调用提供相同行为。 + +## 已确认原因 +真实存档 tool.result 是 MCP CallToolResult 的 JSON:`{content:[{type:'text',text:'业务 JSON'}],structuredContent:{ok:true,image,attachment},_meta:null}`。当前前端只解析一层 JSON 后直接检查 payload.ok,因此把成功的 MCP 包装结果识别为失败。服务端业务结果确实 ready。 + +## 实现边界 +- 在前端正确解析直接业务结果与 MCP structuredContent / content 文本形式。 +- 保留明确的业务错误消息、等待状态和异常格式兜底。 +- 使用实际历史结果形状补充行为回归,包括已有图片附件和直链图片。 +- 不修改用户历史数据,不改协议,不重新生成图片。 +- 保留工作区中已经存在的文件浏览器图片画廊改动;改动前核对 git diff。 + +## 验收 +- 截图中八个已有成功结果可以解析得到正确 attachment。 +- 渲染组件进入图片预览分支,不再显示误报失败。 +- 错误响应与不合法结果不被误报成功。 +- 必要测试通过;运行服务能提供新静态文件,若需要重启必须先查其他运行对话。 diff --git a/.trellis/tasks/10-17-display-image-render-failure/task.json b/.trellis/tasks/10-17-display-image-render-failure/task.json new file mode 100644 index 0000000..05c60c8 --- /dev/null +++ b/.trellis/tasks/10-17-display-image-render-failure/task.json @@ -0,0 +1,26 @@ +{ + "id": "display-image-render-failure", + "name": "display-image-render-failure", + "title": "修复图片展示失败", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-10-17", + "completedAt": "2026-10-17", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/.trellis/tasks/10-17-file-browser-image-gallery/check.jsonl b/.trellis/tasks/10-17-file-browser-image-gallery/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/10-17-file-browser-image-gallery/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/10-17-file-browser-image-gallery/implement.jsonl b/.trellis/tasks/10-17-file-browser-image-gallery/implement.jsonl new file mode 100644 index 0000000..c1b464b --- /dev/null +++ b/.trellis/tasks/10-17-file-browser-image-gallery/implement.jsonl @@ -0,0 +1,4 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/frontend/index.md", "reason": "前端入口规范"} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "保留既有前端加载行为"} +{"file": ".planning/file-browser-image-gallery/findings.md", "reason": "现有图片弹层与加载入口"} diff --git a/.trellis/tasks/10-17-file-browser-image-gallery/prd.md b/.trellis/tasks/10-17-file-browser-image-gallery/prd.md new file mode 100644 index 0000000..bad8b7c --- /dev/null +++ b/.trellis/tasks/10-17-file-browser-image-gallery/prd.md @@ -0,0 +1,25 @@ +# 文件预览全屏与目录切图 + +## 用户需求 +当前图片预览被容器缩放,需要点击放大全屏,并能左右切换当前文件夹图片;优先复用 cc-web 现成组件。 + +## 验收条件 +- 点击文件预览中已加载的图片打开已有附件图片弹层的全屏模式,图片适应整个可用视口,保留加载、失败、关闭操作。 +- 查看器提供前后按钮和左右方向键,在当前文件夹中按文件列表顺序切换支持的图片;跳过目录和文本。 +- 显示文件名、当前位置;单图与首尾行为清晰。 +- 移动端按钮可用,关闭全屏返回原文件浏览器,不误触关闭文件浏览器。 +- 图片按需鉴权加载,SVG 延续现有安全路径;切换时处理加载、失败、过期请求和 Blob URL 释放。 +- 文件夹变化或文件浏览器关闭时正确清理查看器状态。 +- 原有消息图片查看体验保持可用。 + +## 已定位的复用入口 +- public/app.js:openAttachmentPreviewModal / closeAttachmentPreviewModal;现有组件目前是居中大图弹层,没有缩放平移功能。 +- public/app.js:openFileBrowserFile / renderFileBrowserDirectory / loadFileBrowserDirectory / showFileBrowser。 +- 文件图片继续通过 fetchAuthImage 读取 /api/fs/image,SVG 用 data URL,其余用 Blob URL。 + +## 约束 +复用现有组件,不新增图库依赖;不做额外代码审计;本轮不打包、不提交、不推送、不重启。 + +## 验证结果 +- file-browser-image、file-preview-encoding、frontend-asset-version 专项回归通过。 +- Chromium 149 桌面和手机视口 20 项交互验证通过,页面无未捕获异常。 diff --git a/.trellis/tasks/10-17-file-browser-image-gallery/task.json b/.trellis/tasks/10-17-file-browser-image-gallery/task.json new file mode 100644 index 0000000..a69b6a8 --- /dev/null +++ b/.trellis/tasks/10-17-file-browser-image-gallery/task.json @@ -0,0 +1,26 @@ +{ + "id": "file-browser-image-gallery", + "name": "file-browser-image-gallery", + "title": "文件预览复用全屏图片查看器并支持目录切图", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-10-17", + "completedAt": "2026-10-17", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "已复用附件图片弹层实现全视口图库;三项专项回归和 Chromium 桌面/手机 20 项交互验证通过;按用户要求跳过额外审计;未打包、提交、推送或重启。", + "meta": {} +} diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index faf44f0..b919793 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/session-query-transport.js b/lib/session-query-transport.js new file mode 100644 index 0000000..e1ff402 --- /dev/null +++ b/lib/session-query-transport.js @@ -0,0 +1,38 @@ +'use strict'; + +const crypto = require('node:crypto'); + +function toolSignature(tool) { + return crypto.createHash('sha256').update(JSON.stringify(tool)).digest('hex'); +} + +function inlineTool(tool) { + const kind = tool?.kind || tool?.meta?.kind; + const name = String(tool?.name || ''); + const target = String(tool?.input?.tool || tool?.input?.name || tool?.meta?.subtitle || ''); + return ['todo_list', 'collab_agent_tool_call', 'subAgentActivity'].includes(kind) + || ['AskUserQuestion', 'subAgentActivity'].includes(name) + || /(?:^|[./])ccweb_display_image$/.test(target); +} + +// 折叠工具只传显示摘要;原始输入、输出仍由详情接口完整读取。 +function summarizeTools(tools, reference) { + return (Array.isArray(tools) ? tools : []).map((tool, index) => { + if (!tool || typeof tool !== 'object' || inlineTool(tool)) return tool; + const input = {}; + for (const key of ['command', 'path', 'file_path', 'tool', 'name', 'kind']) { + if (typeof tool.input?.[key] === 'string') input[key] = tool.input[key].slice(0, 240); + } + const meta = {}; + for (const key of ['title', 'subtitle', 'kind', 'exitCode', 'status']) { + const value = tool.meta?.[key]; + if (typeof value === 'string') meta[key] = value.slice(0, 240); + else if (typeof value === 'number' || typeof value === 'boolean') meta[key] = value; + } + return { id: tool.id, name: tool.name, kind: tool.kind, done: tool.done, input, meta, + detailRef: { ...reference, index, toolId: tool.id || null, + ...(reference.scope ? {} : { signature: toolSignature(tool) }) } }; + }); +} + +module.exports = { summarizeTools, toolSignature }; diff --git a/public/app.js b/public/app.js index 43c7401..3a15d46 100644 --- a/public/app.js +++ b/public/app.js @@ -300,6 +300,7 @@ let codexAppElicitationModal = null; let pendingNewSessionRequest = null; let sessionLoadAbortController = null; + const toolDetailControllers = new Set(); let currentSessionViewRequestId = ''; let currentSessionHistoryRevision = null; let currentSessionSubscriptionRequestId = ''; @@ -4361,6 +4362,58 @@ return !!extension && FILE_BROWSER_IMAGE_EXTENSIONS.has(extension.toLowerCase()); } + async function getFileBrowserImagePreview(sessionId, filePath, signal) { + const { blob, mimeType } = await fetchAuthImage( + `/api/fs/image?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(filePath)}`, + signal + ); + if (signal?.aborted) throw new DOMException('图片读取已取消', 'AbortError'); + const objectUrl = mimeType !== 'image/svg+xml'; + const url = objectUrl + ? URL.createObjectURL(blob) + : await new Promise((resolve, reject) => { + const reader = new FileReader(); + const abort = () => reader.abort(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error || new Error('图片读取失败')); + reader.onabort = () => reject(new DOMException('图片读取已取消', 'AbortError')); + reader.onloadend = () => signal?.removeEventListener('abort', abort); + signal?.addEventListener('abort', abort, { once: true }); + reader.readAsDataURL(blob); + }); + return { url, objectUrl, blob, mimeType }; + } + + function getFileBrowserImageGallery(state) { + return state.directoryEntries + .filter((entry) => entry.kind === 'file' && ( + entry.imagePreviewableHint === true + || (entry.imagePreviewableHint == null && isFileBrowserImagePath(entry.path)) + )) + .map((entry) => ({ + filename: entry.name || getPathLeaf(entry.path), + size: entry.size, + filePath: normalizeBrowserPath(entry.path), + sessionId: state.sessionId, + })); + } + + function openFileBrowserImageGallery() { + const state = fileBrowserState; + if (!state || state.previewImageEl.hidden || !state.selectedFilePath) return; + const gallery = getFileBrowserImageGallery(state); + let index = gallery.findIndex((item) => item.filePath === state.selectedFilePath); + if (index < 0) { + gallery.push({ + filename: getPathLeaf(state.selectedFilePath), + filePath: state.selectedFilePath, + sessionId: state.sessionId, + }); + index = gallery.length - 1; + } + openAttachmentPreviewModal(gallery[index], { gallery, index, browserState: state }); + } + function clearFileBrowserImagePreview(state) { state.previewImageNode.onload = null; state.previewImageNode.onerror = null; @@ -4381,6 +4434,7 @@ function closeFileBrowser() { if (!fileBrowserState) return; const state = fileBrowserState; + if (attachmentPreviewModal?.browserState === state) closeAttachmentPreviewModal(); cancelFileBrowserPreview(state); const { overlay, escapeHandler } = state; if (escapeHandler) document.removeEventListener('keydown', escapeHandler); @@ -4444,6 +4498,7 @@ if (!fileBrowserState) return; const state = fileBrowserState; const safeEntries = Array.isArray(entries) ? entries : []; + state.directoryEntries = safeEntries; if (safeEntries.length === 0) { state.listEl.innerHTML = ''; @@ -4493,6 +4548,8 @@ async function loadFileBrowserDirectory(targetPath, options = {}) { if (!fileBrowserState) return; const state = fileBrowserState; + if (attachmentPreviewModal?.browserState === state) closeAttachmentPreviewModal(); + state.directoryEntries = []; const normalizedPath = normalizeBrowserPath(targetPath); const previousPath = state.currentPath; const requestId = ++state.directoryRequestId; @@ -4542,6 +4599,7 @@ async function openFileBrowserFile(targetPath, options = {}) { if (!fileBrowserState) return; const state = fileBrowserState; + if (attachmentPreviewModal?.browserState === state) closeAttachmentPreviewModal(); const normalizedPath = normalizeBrowserPath(targetPath); const imagePreviewable = options.imagePreviewable === true || (options.imagePreviewable == null && isFileBrowserImagePath(normalizedPath)); @@ -4556,24 +4614,14 @@ try { if (imagePreviewable) { - const { blob, mimeType } = await fetchAuthImage( - `/api/fs/image?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`, - controller.signal + const { url: imageUrl, objectUrl, blob, mimeType } = await getFileBrowserImagePreview( + state.sessionId, normalizedPath, controller.signal ); - if (state !== fileBrowserState || requestId !== state.previewRequestId) return; - const imageUrl = mimeType === 'image/svg+xml' - ? await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(reader.error || new Error('图片读取失败')); - reader.readAsDataURL(blob); - }) - : URL.createObjectURL(blob); if (state !== fileBrowserState || requestId !== state.previewRequestId) { - if (mimeType !== 'image/svg+xml') URL.revokeObjectURL(imageUrl); + if (objectUrl) URL.revokeObjectURL(imageUrl); return; } - if (mimeType !== 'image/svg+xml') state.previewImageUrl = imageUrl; + if (objectUrl) state.previewImageUrl = imageUrl; const name = normalizedPath.split('/').pop() || '图片预览'; state.previewImageNode.alt = name; state.previewImageNode.onload = () => { @@ -4668,9 +4716,9 @@
选择一个文件查看内容
- + @@ -4683,6 +4731,7 @@ sessionId: currentSessionId, rootPath: currentCwd, currentPath: '', + directoryEntries: [], selectedFilePath: '', directoryRequestId: 0, previewRequestId: 0, @@ -4707,7 +4756,7 @@ fileBrowserState = state; state.escapeHandler = (e) => { - if (e.key === 'Escape') closeFileBrowser(); + if (e.key === 'Escape' && !e.defaultPrevented && !attachmentPreviewModal) closeFileBrowser(); }; document.addEventListener('keydown', state.escapeHandler); @@ -4724,6 +4773,7 @@ state.mobileBackBtn.addEventListener('click', () => { setFileBrowserPreviewMode(false); }); + state.previewImageEl.addEventListener('click', openFileBrowserImageGallery); updateFileBrowserPathBar(); renderFileBrowserPreviewEmpty('文件预览', '选择一个文件查看内容'); @@ -4908,15 +4958,89 @@ return promise; } - function closeAttachmentPreviewModal() { - if (!attachmentPreviewModal) return; - const { overlay, escapeHandler } = attachmentPreviewModal; - if (escapeHandler) document.removeEventListener('keydown', escapeHandler); - if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); - attachmentPreviewModal = null; + function clearAttachmentPreviewImage(state) { + state.requestId += 1; + state.abortController?.abort(); + state.abortController = null; + state.imgEl.onload = null; + state.imgEl.onerror = null; + state.imgEl.removeAttribute('src'); + state.imgEl.hidden = true; + if (state.imageObjectUrl) URL.revokeObjectURL(state.imageObjectUrl); + state.imageObjectUrl = ''; + return state.requestId; } - async function openAttachmentPreviewModal(attachment) { + function closeAttachmentPreviewModal() { + if (!attachmentPreviewModal) return; + const state = attachmentPreviewModal; + attachmentPreviewModal = null; + clearAttachmentPreviewImage(state); + document.removeEventListener('keydown', state.escapeHandler, true); + state.overlay.remove(); + if (state.returnFocus?.isConnected) state.returnFocus.focus({ preventScroll: true }); + } + + async function loadAttachmentPreviewImage(state, index) { + const attachment = state.gallery ? state.gallery[index] : state.attachment; + if (!attachment || state !== attachmentPreviewModal) return; + const requestId = clearAttachmentPreviewImage(state); + const controller = new AbortController(); + state.abortController = controller; + state.index = index; + const isCurrent = () => state === attachmentPreviewModal && requestId === state.requestId; + const pageText = state.gallery ? `${index + 1} / ${state.gallery.length}` : ''; + state.nameEl.textContent = attachment.filename || 'image'; + state.nameEl.title = attachment.filename || 'image'; + state.imgEl.alt = attachment.filename || 'image'; + state.descEl.textContent = [pageText, formatFileSize(attachment.size || 0)].filter(Boolean).join(' · '); + state.placeholderEl.textContent = '正在加载图片…'; + state.placeholderEl.hidden = false; + state.stageEl.classList.remove('is-ready', 'is-error'); + state.stageEl.classList.add('is-loading'); + if (state.previousBtn) state.previousBtn.disabled = index <= 0; + if (state.nextBtn) state.nextBtn.disabled = index >= state.gallery.length - 1; + try { + const preview = state.gallery + ? await getFileBrowserImagePreview(attachment.sessionId, attachment.filePath, controller.signal) + : { url: await getAttachmentPreviewUrl(attachment), objectUrl: false }; + if (!isCurrent()) { + if (preview.objectUrl) URL.revokeObjectURL(preview.url); + return; + } + if (preview.objectUrl) state.imageObjectUrl = preview.url; + if (preview.blob) state.descEl.textContent = `${pageText} · ${formatFileSize(preview.blob.size)} · ${preview.mimeType}`; + state.imgEl.onload = () => { + if (!isCurrent()) return; + state.imgEl.hidden = false; + state.placeholderEl.hidden = true; + state.stageEl.classList.remove('is-loading'); + state.stageEl.classList.add('is-ready'); + }; + state.imgEl.onerror = () => { + if (!isCurrent()) return; + state.placeholderEl.textContent = '图片加载失败'; + state.stageEl.classList.remove('is-loading'); + state.stageEl.classList.add('is-error'); + }; + state.imgEl.src = preview.url; + } catch (err) { + if (!isCurrent()) return; + state.placeholderEl.textContent = err.message || '图片预览失败'; + state.stageEl.classList.remove('is-loading'); + state.stageEl.classList.add('is-error'); + } finally { + if (state.abortController === controller) state.abortController = null; + } + } + + function stepAttachmentPreviewImage(state, direction) { + if (!state.gallery || state !== attachmentPreviewModal) return; + const index = state.index + direction; + if (index >= 0 && index < state.gallery.length) loadAttachmentPreviewImage(state, index); + } + + async function openAttachmentPreviewModal(attachment, options = {}) { if (!attachment || attachment.storageState === 'expired') { showToast('图片已过期,无法预览'); return; @@ -4924,10 +5048,11 @@ closeAttachmentPreviewModal(); + const gallery = Array.isArray(options.gallery) && options.gallery.length ? options.gallery : null; const overlay = document.createElement('div'); - overlay.className = 'modal-overlay attachment-preview-overlay'; + overlay.className = `modal-overlay attachment-preview-overlay${gallery ? ' is-fullscreen' : ''}`; overlay.innerHTML = ` -