diff --git a/.gitignore b/.gitignore index 4282be7..7cce3eb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ config/gitea-workflow-management.json config/gitea-workflow-secrets.json config/gitea-webhook-deliveries.json config/gitea-repositories.json +config/cross-conversation-replies.json +config/instance-icon.png +config/.instance-icon.png.tmp gitea-workspaces/ CLAUDE.md dist-exe/* diff --git a/.planning/2026-08-25-instance-custom-icon/findings.md b/.planning/2026-08-25-instance-custom-icon/findings.md new file mode 100644 index 0000000..d50a97b --- /dev/null +++ b/.planning/2026-08-25-instance-custom-icon/findings.md @@ -0,0 +1,57 @@ +# 实例自定义图标调研记录 + +## 已知需求 + +- 同一套 cc-web 部署到多台机器时,需要通过不同图标快速区分实例。 +- 用户希望在设置中自行选择图标。 +- 自定义结果在拉取最新版后必须保留。 + +## 待确认事实 + +- 当前默认图标的所有消费点。 +- 设置页和设置 API 的扩展模式。 +- 项目现有的 Git 外置运行数据目录与备份方式。 +- 相关测试框架和浏览器缓存策略。 + +## 代码库初步事实 + +- 项目为无框架的 Node.js 单体:`server.js` 提供服务,`public/app.js`、`public/index.html`、`public/style.css` 构成主界面。 +- 默认浏览器/PWA 图标均为 Git 跟踪文件:`public/favicon.ico`、`favicon-32x32.png`、`apple-touch-icon.png`、`icon-192.png`、`icon-512.png`,直接替换它们会在升级时产生冲突或被覆盖,因此不能作为实例自定义存储。 +- 设置入口位于 `public/index.html`,设置页 HTML 由 `public/app.js` 中的构建函数生成;已有“外观”子页,可复用其导航卡和设置状态样式。 +- 回归入口为 `npm run regression`,项目还包含多个 `scripts/*-unit.js` 与浏览器集成脚本,适合增加聚焦回归脚本并挂入总回归。 +- 首轮本地全文检索被仓库中的大文本记录污染;后续必须收窄到 `server.js`、`public/`、`scripts/`,并用精确标识符核验行号。 + +## 图标与存储入口 + +- `public/index.html` 的浏览器图标链接位于 head,登录页 Logo 使用 `icon-192.png`;`public/app.js` 的通知图标和重新渲染的登录框也直接使用 `/icon-192.png`;`public/sw.js` 的通知默认值同样硬编码该路径。 +- 这意味着仅改 favicon 不足以形成一致的实例身份,至少应统一浏览器页签、登录 Logo 与通知图标;Web App Manifest 的静态图标是否能动态化需按 PWA 缓存边界保守处理。 +- 服务端已经支持 `CC_WEB_CONFIG_DIR`,默认指向仓库内 `config/`;测试通常把它指向临时目录。实例图标应落在该目录,生产可用外置目录,默认仓库内目录则通过精确 `.gitignore` 规则保护。 +- `.gitignore` 已逐项忽略多种 `config/*.json` 运行配置,但尚无实例图标规则;需要在保留用户现有修改的前提下追加精确规则,不能把整个 `config/` 忽略。 + +## 选定方案 + +- 浏览器端把用户选择的 PNG/JPEG/WebP 居中裁剪并规范化为 512×512 PNG;服务端只接受并验证 512×512 PNG,最大 4 MiB。 +- 运行文件固定为 `CONFIG_DIR/instance-icon.png`,不保存用户文件名或可控路径;通过临时文件 + rename 原子替换。 +- 统一公开读取 URL 为 `/api/instance-icon`,默认返回现有 `icon-192.png`;配置接口返回基于内容哈希的 version,前端以查询参数刷新缓存。 +- 写入和恢复接口使用现有 Bearer token;读取图标与 Manifest 公开,保证登录页也能显示。 +- 提供动态 `/api/site.webmanifest`:无自定义时保留原 192/512 图标,有自定义时声明 512 图标。已安装 PWA 的操作系统缓存不承诺立即刷新。 +- `.gitignore` 只追加 `config/instance-icon.png` 与原子临时文件规则,保留现有用户改动和其他配置可见性。 + +## 工作区隔离注意 + +- `.gitignore` 的既有修改位于暂存区(`git status` 第一列为 `M`),普通 `git diff` 不显示;本功能只能在工作树中追加精确规则,不能改写或取消暂存的用户内容。 +- `server.js` 的配置目录在启动时统一创建,实例图标常量可与其他配置路径集中定义;HTTP 路由可复用附件接口的 `extractBearerToken`/`activeTokens` 鉴权方式。 +- 现有静态服务会对所有 public 资源返回 no-store,但动态实例图标仍需显式 `nosniff` 与内容版本,避免不同响应路径的缓存语义分叉。 + +## 并发写入归因修正 + +- 被中断的实现代理仍有一个已进入执行阶段的工具调用完成落盘:新增了 `scripts/regression.js` 的 219 行聚焦回归、服务端大小常量和路径骨架,并提前推进一次 TODO CSV。 +- 因此聚焦测试不是 HEAD 预置,而是该代理的有效测试产出;主线程保留测试、合并重复常量,并承担后续实现。CSV 的第 4/5 阶段状态在代码落下后重新与真实结果对齐。 + +## 主线程代码审查 + +- 写接口只接受鉴权后的 512×512 PNG,路径完全由服务端固定;配置读取、图标读取和 Manifest 无敏感内容,可供登录前消费。 +- 默认与自定义图标都设置 `nosniff`;默认 ETag 使用实际文件哈希,自定义 ETag 使用内容版本,拉取新版默认资产后不会错误返回旧图 304。 +- 客户端裁剪取短边居中后绘制到 512×512,不拉伸;上传失败不调用 apply,因此保留旧实例图标。 +- 现有聚焦回归验证真实子进程和临时 CONFIG_DIR,并由 `withServer` 负责停止服务;测试临时目录清理可作为非阻断维护改进继续审查。 +- 动态 favicon 的源图片在默认态为 192px、自定义态为 512px,head 上固定 `sizes` 声明可能与默认资源自然尺寸不一致;应在最终审查中确认是否移除固定 sizes 更稳妥。 diff --git a/.planning/2026-08-25-instance-custom-icon/progress.md b/.planning/2026-08-25-instance-custom-icon/progress.md new file mode 100644 index 0000000..1d98af1 --- /dev/null +++ b/.planning/2026-08-25-instance-custom-icon/progress.md @@ -0,0 +1,27 @@ +# 实例自定义图标进度 + +## 2026-08-25 + +- 已读取 `planning-with-files`、`todo-list-csv` 与 Trellis 工作流。 +- 已确认 `home-cc-web` codebase-memory 索引状态为 ready。 +- 已创建并启动 Trellis 任务 `08-25-instance-custom-icon`。 +- 已识别并保留与本任务无关的工作区改动:`.gitignore`、`README.md`、`config/cross-conversation-replies.json`。 +- 待办脚本首次使用项目内路径失败,已确定实际安装路径并写入错误记录。 +- 独立计划审查已通过,无阻断问题。 +- 并行 codebase-memory 查询因 transport closed 失败;索引此前已确认 ready,改由两个只读代理串行使用 MCP 调研,主线程稍后做本地交叉验证。 +- 前端只读调研完成并与本地精确检索交叉验证,结果已持久化到 Trellis research 文档。 +- 服务端只读调研完成,确认可复用 `CC_WEB_CONFIG_DIR`、二进制图片上传、Bearer 鉴权与固定路径写入模式;结果已持久化。 +- 已完成跨层数据流、HTTP 契约、持久化与错误语义设计,写入 Trellis `info.md`。 +- 已配置并验证 Trellis implement/check 上下文;实现代理已启动,正在按测试先行方式修改产品代码。 +- Trellis 实现代理长时间无产品改动且未响应进度询问,已安全中断;主线程接手测试与实现。 +- 实现代理的在途工具调用于中断后完成落盘,新增 `scripts/regression.js --target instance-icon` 的完整测试目标;实现前运行按预期失败,静态契约缺少 index/frontend/SW/server/style/gitignore 五类实现。 +- 第一版代码已落下;首次验证发现服务端预置同名大小常量导致重复声明,语法检查已在集成测试前准确阻断,正在定向修正。 +- 合并重复常量后,`node --check server.js`、`node --check public/app.js` 与 `node scripts/regression.js --target instance-icon` 均通过。 +- 服务端 API、CONFIG_DIR 固定路径持久化、动态图标/Manifest、设置页裁剪上传/预览/恢复与全局消费点已实现;进入相关与全量回归。 +- `instance-icon`、`frontend-asset-version` 聚焦回归与 `npm run regression` 全量回归通过;`git diff --check` 通过。 +- `git check-ignore` 证明正式/临时实例图标文件由新增精确规则忽略,且 `git ls-files` 不跟踪它们;用户已暂存 `.gitignore` 改动与本功能工作树改动保持分层。 +- Trellis Phase 2.2 独立审查无阻断/高/中风险发现;按唯一低风险建议移除动态图标 link 的固定 sizes,避免默认 192px 与自定义 512px 共用时提示不准确。 +- 用户要求取消后续额外审计,改为重新打包并将当前工作区全部修改统一提交、推送。 +- 已复用本机 `@oven/bun-linux-x64-baseline` 重新执行 `npm run build:single-exe`,更新 `dist-exe/cc-web-bun-linux-x64-baseline.tar.gz`。 +- 发布校验通过:六项 JavaScript 语法检查无错误;打包后二进制的 MCP `initialize` 返回有效 JSON-RPC;tar.gz 可读取;`git diff --check` 通过。 +- 新发布包 SHA-256 为 `2fb1f0a55ada79420f9599584c53fd4c1d04fb2d05cf25c29cda2150e3b27a48`,大小 43,871,596 bytes。 diff --git a/.planning/2026-08-25-instance-custom-icon/task_plan.md b/.planning/2026-08-25-instance-custom-icon/task_plan.md new file mode 100644 index 0000000..32d52dd --- /dev/null +++ b/.planning/2026-08-25-instance-custom-icon/task_plan.md @@ -0,0 +1,45 @@ +# 实例自定义图标实施计划 + +## 目标 + +在设置页提供实例级图标选择能力,并让侧栏、浏览器页签等现有品牌图标消费点统一使用该图标。自定义图片与选择结果必须存放在 Git 管理之外,拉取/升级源码后仍保留。 + +## 验收边界 + +- 支持从本机选择常见栅格图上传,立即预览并保存。 +- 支持恢复项目默认图标。 +- 限制图片类型与大小,错误可理解且不破坏旧配置。 +- 未配置时保持现有图标和行为,不引入升级迁移负担。 +- 图标响应具备缓存更新机制;设置保存后当前页面可感知更新。 +- 自定义文件和实例配置不进入 Git,不被 `git pull` 覆盖。 + +## 阶段 + +| 步骤 | 状态 | 验收方式 | +|---|---|---| +| 1. 定位现有图标、设置和持久化链路 | DONE | 形成前后端入口、测试与数据目录清单 | +| 2. 明确实例级图标存储与兼容方案 | DONE | 记录接口、校验、缓存和升级兼容决策 | +| 3. 补充服务端配置、上传和图标响应回归测试 | DONE | 新测试先覆盖默认、上传、恢复、非法输入和持久化 | +| 4. 实现服务端实例图标持久化与接口 | DONE | 服务端测试通过,数据落在 Git 外置目录 | +| 5. 实现设置页图标选择、预览、恢复默认及全局应用 | DONE | 设置页可操作,现有图标消费点统一更新 | +| 6. 运行前后端测试与构建并修正问题 | DONE | 目标测试、完整相关测试和构建通过 | +| 7. 重新生成 CentOS 7 单文件发布包 | DONE | baseline Bun 构建成功,生成可执行文件与 tar.gz | +| 8. 验证发布包与工作区完整性 | DONE | 语法检查、MCP initialize 冒烟、归档读取和 diff 检查通过 | +| 9. 暂存并提交当前全部修改 | DONE | 按用户要求包含此前已暂存和本次全部修改 | +| 10. 推送到远端并核验结果 | DONE | 交付阶段推送 main 至 origin/main 并确认同步状态 | + +## 设计原则 + +- 优先复用既有设置 API、数据目录与前端设置面板模式。 +- 上传内容仅接受可安全解码/展示的图片类型,不允许用户控制落盘路径。 +- 使用稳定的运行时 URL 暴露图标,并通过版本参数或响应缓存头避免旧图标残留。 +- 不修改无关的用户工作区变更。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| +| 项目内不存在 `.codex/skills/todo-list-csv/scripts/todo_csv.py` | 1 | 改用技能实际安装路径 `/home/hdzx/.codex/skills/todo-list-csv/scripts/todo_csv.py` | +| 并行调用 codebase-memory 架构与两项检索时连接被关闭 | 1 | 停止并行压测;等待只读代理完成 MCP 检索后,用 `rg` 做行号交叉验证 | +| Trellis 实现代理长时间运行但未产生产品改动或状态回报 | 1 | 中断代理,主线程按已固化的 TDD 契约接手实现 | +| 服务端已有预置 `MAX_INSTANCE_ICON_SIZE`,首次实现重复声明导致语法错误,测试等待端口超时 | 1 | 定位并复用既有常量,先单独语法检查,再重跑聚焦测试 | diff --git a/.trellis/tasks/08-25-instance-custom-icon/check.jsonl b/.trellis/tasks/08-25-instance-custom-icon/check.jsonl new file mode 100644 index 0000000..d0de6b8 --- /dev/null +++ b/.trellis/tasks/08-25-instance-custom-icon/check.jsonl @@ -0,0 +1,6 @@ +{"_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/quality-guidelines.md", "reason": "前端入口与运行时更新验收"} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "服务端错误与降级验收"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "跨层契约验收"} +{"file": ".trellis/tasks/08-25-instance-custom-icon/research/frontend-icon-chain.md", "reason": "前端影响面核对"} +{"file": ".trellis/tasks/08-25-instance-custom-icon/research/backend-settings-storage.md", "reason": "后端安全与持久化核对"} diff --git a/.trellis/tasks/08-25-instance-custom-icon/implement.jsonl b/.trellis/tasks/08-25-instance-custom-icon/implement.jsonl new file mode 100644 index 0000000..00bf7d2 --- /dev/null +++ b/.trellis/tasks/08-25-instance-custom-icon/implement.jsonl @@ -0,0 +1,8 @@ +{"_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": ".trellis/spec/backend/index.md", "reason": "服务端规范入口"} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "HTTP 和配置保存错误处理"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "跨前后端契约与数据流"} +{"file": ".trellis/tasks/08-25-instance-custom-icon/research/frontend-icon-chain.md", "reason": "前端图标与设置链路调研"} +{"file": ".trellis/tasks/08-25-instance-custom-icon/research/backend-settings-storage.md", "reason": "后端配置与存储调研"} diff --git a/.trellis/tasks/08-25-instance-custom-icon/info.md b/.trellis/tasks/08-25-instance-custom-icon/info.md new file mode 100644 index 0000000..e51a077 --- /dev/null +++ b/.trellis/tasks/08-25-instance-custom-icon/info.md @@ -0,0 +1,84 @@ +# 实例自定义图标技术设计 + +## 数据流 + +```text +本地 PNG/JPEG/WebP + → 浏览器解码并居中裁剪为 512×512 PNG + → POST /api/instance-icon(Bearer,二进制,≤4 MiB) + → 服务端验证 Content-Type、PNG 签名、IHDR 尺寸 + → CONFIG_DIR/.instance-icon.png.tmp 原子 rename + → 返回 { custom, version, updatedAt, url } + → 前端更新 favicon、Apple Touch、登录 Logo、通知图标与设置预览 +``` + +## 持久化与升级 + +- 正式文件:`CONFIG_DIR/instance-icon.png`。 +- 临时文件:`CONFIG_DIR/.instance-icon.png.tmp`,失败时清理。 +- 默认 `CONFIG_DIR` 为 `APP_DIR/config`;`.gitignore` 精确忽略这两个文件。 +- 如果部署设置 `CC_WEB_CONFIG_DIR` 到仓库外,图标自动跟随外置配置目录。 +- 不覆盖任何 `public/*.png`,源码升级只更新默认兜底图。 + +## HTTP 契约 + +### GET /api/instance-icon/config + +公开返回: + +```json +{ + "custom": true, + "version": "sha256-short-hash", + "updatedAt": "ISO-8601", + "url": "/api/instance-icon?v=sha256-short-hash" +} +``` + +未配置时 `custom=false`,`version=default`,URL 仍指向统一读取接口。 + +### GET /api/instance-icon + +- 自定义存在且有效:返回该 PNG。 +- 未配置或运行文件失效:返回 `public/icon-192.png`。 +- `Content-Type: image/png`、`X-Content-Type-Options: nosniff`。 +- 使用 ETag/no-cache;版本化 URL 负责同页缓存刷新。 + +### POST /api/instance-icon + +- 必须通过现有 Bearer token 鉴权。 +- 仅 `Content-Type: image/png`,body 非空且不超过 4 MiB。 +- 必须是标准 PNG 签名,IHDR 宽高均为 512。 +- 固定路径原子写入;返回最新配置对象。 + +### DELETE /api/instance-icon + +- 必须通过现有 Bearer token 鉴权。 +- 删除实例运行文件,幂等返回默认配置对象。 + +### GET /api/site.webmanifest + +- 未自定义:保留现有 192/512 默认图标声明。 +- 已自定义:使用版本化 `/api/instance-icon`,声明 512×512。 + +## 前端契约 + +- head 中 favicon、Apple Touch 与 Manifest 从首屏即指向动态 API,默认服务端兜底避免闪烁。 +- 需要动态更新的图片用 `data-instance-icon` 标识;图标 link 用 `data-instance-icon-link` 标识。 +- `instanceIconUrl(config)` 是唯一 URL 生成入口;浏览器通知和 Service Worker 默认也用 `/api/instance-icon`。 +- 外观设置中新增“实例图标”区:预览、选择图标、恢复默认、状态文字。 +- 客户端拒绝非 PNG/JPEG/WebP;使用 canvas 居中裁剪,不拉伸;上传失败保留旧图标。 + +## 错误语义 + +- 401:未鉴权写操作。 +- 400:空内容、错误 MIME、非法 PNG、非 512×512。 +- 413:超过 4 MiB。 +- 500:原子保存/读取不可恢复错误;前端显示服务端中文消息。 + +## 测试顺序 + +1. 增加聚焦服务集成测试并确认在实现前失败。 +2. 覆盖默认读取、鉴权、非法输入、上传、覆盖版本、恢复默认与 Git 外置路径。 +3. 增加前端静态/DOM 契约测试,覆盖设置 UI 和所有图标消费点。 +4. 实现最小代码使测试通过,再运行总回归。 diff --git a/.trellis/tasks/08-25-instance-custom-icon/prd.md b/.trellis/tasks/08-25-instance-custom-icon/prd.md new file mode 100644 index 0000000..9b9089d --- /dev/null +++ b/.trellis/tasks/08-25-instance-custom-icon/prd.md @@ -0,0 +1,40 @@ +# 实例自定义图标需求 + +## 背景 + +用户在多台机器部署 cc-web。当前各实例使用相同图标,浏览器和 cc-web 界面中难以快速区分,需要每个实例可独立设置图标。 + +## 用户故事 + +作为 cc-web 管理者,我希望在设置页从本机选择实例图标,以便多实例并行使用时能直观看出当前机器;升级源码后,该选择仍应保留。 + +## 功能要求 + +1. 设置页新增实例图标设置项,展示当前图标。 +2. 用户可选择受支持的本地图片并保存,保存成功后当前页面使用新图标。 +3. 用户可恢复默认图标。 +4. 未配置自定义图标的安装保持当前默认外观。 +5. 自定义图标文件及选择状态存储在 Git 管理之外,`git pull` 不覆盖。 +6. 服务端校验文件类型与大小,拒绝非法或超限输入并返回明确错误。 +7. 图标更新后避免浏览器继续显示旧缓存。 + +## 非目标 + +- 不实现在线图标市场、URL 抓取、裁剪编辑器或多套图标历史。 +- 不改变现有主题系统和品牌名称。 +- 不自动修改操作系统桌面快捷方式或已安装 PWA 的原生图标。 + +## 验收标准 + +- 默认、上传、刷新后持久化、覆盖更新、恢复默认、非法类型和超限文件均有自动化覆盖。 +- 侧栏/品牌入口与浏览器页签等现有运行时图标消费点按实际架构统一接入。 +- 自定义图标数据未出现在 `git status` 的已跟踪/未跟踪源码改动中。 +- 相关测试与构建通过,未破坏既有设置保存行为。 + +## 技术约束 + +- 浏览器接受 PNG/JPEG/WebP 选择,居中裁剪为 512×512 PNG 后上传;服务端最大接收 4 MiB。 +- 自定义图片固定写入 `CC_WEB_CONFIG_DIR/instance-icon.png`(未设置环境变量时为 `config/instance-icon.png`),通过原子替换保存。 +- 公开读取使用 `/api/instance-icon`;写入和恢复默认必须 Bearer 鉴权。 +- 图标内容哈希作为版本,设置成功后更新 favicon、登录 Logo、通知图标和动态 Manifest。 +- 默认行为继续使用现有源码图标;不保证操作系统对已安装 PWA 图标的即时刷新。 diff --git a/.trellis/tasks/08-25-instance-custom-icon/research/backend-settings-storage.md b/.trellis/tasks/08-25-instance-custom-icon/research/backend-settings-storage.md new file mode 100644 index 0000000..d986f86 --- /dev/null +++ b/.trellis/tasks/08-25-instance-custom-icon/research/backend-settings-storage.md @@ -0,0 +1,33 @@ +# 服务端设置与外置存储调研 + +## 架构与持久化 + +- `server.js` 使用 Node 原生 HTTP 与 `ws`,路由集中在 HTTP 回调和 WebSocket 消息 switch。 +- `CONFIG_DIR = CC_WEB_CONFIG_DIR || APP_DIR/config`,现有运行配置均从该目录读写;测试会把它指向临时目录。 +- 默认 `config/` 位于仓库内,但 `.gitignore` 对运行文件逐项忽略;实例图标必须新增精确规则,生产还可通过 `CC_WEB_CONFIG_DIR` 完全外置。 + +## 图片上传先例 + +- `POST /api/attachments` 已采用原始二进制 body,不使用 multipart;通过 Bearer token 鉴权。 +- 既有逻辑包含大小上限、MIME 白名单、空内容拒绝、固定/清理后的文件名与读取鉴权。 +- 实例图标上传可沿用“二进制 REST + 鉴权”,不应把大图片转 base64 塞入 WebSocket 配置消息。 + +## 静态响应与安全 + +- 现有静态资源响应使用 `no-store`,并检查解析后的路径仍位于 `PUBLIC_DIR`。 +- 实例图标应只使用服务端固定文件名,写入临时文件后原子 rename;不接受客户端路径。 +- 推荐只保存规范化 PNG,服务端检查 PNG 签名与 IHDR 尺寸,避免依赖声明 MIME。 + +## 推荐接口 + +- `GET /api/instance-icon/config`:返回是否自定义、版本、更新时间和图标 URL;不含敏感信息。 +- `GET /api/instance-icon`:公开返回自定义 PNG;未配置时返回默认 `public/icon-192.png`。 +- `POST /api/instance-icon`:Bearer 鉴权,接收固定尺寸 PNG,校验后原子写入。 +- `DELETE /api/instance-icon`:Bearer 鉴权,恢复默认图标。 +- `GET /api/site.webmanifest`:根据是否自定义返回默认或实例图标声明。 + +## 验证重点 + +- 默认回退、未鉴权写入、非法 MIME/签名/尺寸、超限、覆盖更新、恢复默认。 +- 运行文件位于 `CONFIG_DIR`,不进入 `PUBLIC_DIR` 且被 Git 忽略。 +- 自定义 URL 带内容版本,避免同页更新仍显示旧图。 diff --git a/.trellis/tasks/08-25-instance-custom-icon/research/frontend-icon-chain.md b/.trellis/tasks/08-25-instance-custom-icon/research/frontend-icon-chain.md new file mode 100644 index 0000000..63bf2bb --- /dev/null +++ b/.trellis/tasks/08-25-instance-custom-icon/research/frontend-icon-chain.md @@ -0,0 +1,26 @@ +# 前端图标与设置链路调研 + +## 图标消费点 + +- `public/index.html`:favicon、32px favicon、Apple Touch 图标、Manifest,以及登录页 `icon-192.png`。 +- `public/app.js`:浏览器通知固定使用 `/icon-192.png`,强制修改密码面板再次渲染登录 Logo。 +- `public/sw.js`:Service Worker 通知默认使用 `/icon-192.png`。 +- `public/site.webmanifest`:PWA 安装图标使用 `icon-192.png` 与 `icon-512.png`。 + +## 设置链路 + +- `#settings-btn` 调用 `showSettingsPanel()`,Codex 与 Claude 设置页都复用 `buildAppearanceSettingsHtml()` / `mountAppearanceSettings()`。 +- 设置读取与保存统一走 WebSocket 消息;服务端在消息 switch 中分发 `get_*_config` / `save_*_config` 并回推配置。 +- 新功能适合增加独立的实例图标配置消息,避免混入 Codex/Claude 模型配置。 + +## 静态资源与缓存 + +- 静态资源由 `server.js` 从 `PUBLIC_DIR` 返回,响应使用 `Cache-Control: no-store, max-age=0`。 +- 默认图标未参与前端资产 hash;自定义运行时 URL 应带版本参数或 ETag,确保同页更新。 +- PWA/操作系统已安装图标受平台缓存控制,即使动态 Manifest 更新也不保证立即刷新;浏览器页签、登录 Logo 与通知图标可可靠动态更新。 + +## 测试与风险 + +- 总回归入口是 `npm run regression`;`scripts/regression.js` 已覆盖设置页消息与静态结构。 +- 必须覆盖默认回退、设置页上传/恢复消息、通知图标与登录 Logo 使用统一 URL。 +- 不应修改主题级设置按钮图标;实例品牌图标与主题装饰资产是不同职责。 diff --git a/.trellis/tasks/08-25-instance-custom-icon/task.json b/.trellis/tasks/08-25-instance-custom-icon/task.json new file mode 100644 index 0000000..f3a89f2 --- /dev/null +++ b/.trellis/tasks/08-25-instance-custom-icon/task.json @@ -0,0 +1,26 @@ +{ + "id": "instance-custom-icon", + "name": "instance-custom-icon", + "title": "实例自定义图标", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-25", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/README.md b/README.md index 9719c35..73c1042 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ node server.js | `config/auth.json` | 登录密码配置,运行时生成 | | `config/notify.json` | 通知渠道配置,运行时生成 | | `config/codex.json` | Codex 默认模型等配置,运行时生成 | -| `config/cross-conversation-replies.json` | 跨对话等待回复状态 | +| `config/cross-conversation-replies.json` | 跨对话等待回复状态(运行时生成,已忽略版本管理) | | `sessions/*.json` | ccweb 会话历史 | | `sessions/{id}-run/` | 单次运行输出、PID、Codex App 状态 | | `sessions/_attachments/` | 图片附件与元数据 | diff --git a/config/cross-conversation-replies.json b/config/cross-conversation-replies.json deleted file mode 100644 index 6b1d1bd..0000000 --- a/config/cross-conversation-replies.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": 1, - "updatedAt": "2026-08-23T19:06:55.618Z", - "replies": [] -} \ No newline at end of file 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 733303c..bb49801 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/public/app.js b/public/app.js index f1608d5..d4469f3 100644 --- a/public/app.js +++ b/public/app.js @@ -232,6 +232,7 @@ let currentAgent = DEFAULT_AGENT; let currentTheme = (document.documentElement.dataset.theme || localStorage.getItem('cc-web-theme') || 'washi'); let showAgentDividerTime = localStorage.getItem(DIVIDER_TIME_STORAGE_KEY) !== '0'; + let instanceIconConfig = { custom: false, version: 'default', updatedAt: null, url: '/api/instance-icon?v=default' }; let codexConfigCache = null; let loadedHistorySessionId = null; let activeSessionLoad = null; @@ -1265,9 +1266,197 @@ +
+ 当前实例图标 +
+
实例图标
+
正在读取当前图标…
+ +
+ + +
+
+
+
`; } + function instanceIconUrl(config = instanceIconConfig) { + const version = String(config?.version || 'default').trim(); + return `/api/instance-icon?v=${encodeURIComponent(version || 'default')}`; + } + + function applyInstanceIconConfig(config) { + const next = { + custom: config?.custom === true, + version: String(config?.version || 'default'), + updatedAt: config?.updatedAt || null, + }; + next.url = instanceIconUrl(next); + instanceIconConfig = next; + document.querySelectorAll('[data-instance-icon]').forEach((node) => { + if (node instanceof HTMLImageElement) node.src = next.url; + }); + document.querySelectorAll('[data-instance-icon-link]').forEach((node) => { + if (node instanceof HTMLLinkElement) node.href = next.url; + }); + return next; + } + + async function loadInstanceIconConfig() { + const response = await fetch('/api/instance-icon/config', { cache: 'no-store' }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload?.ok) { + throw new Error(payload?.message || `读取实例图标失败 (${response.status})`); + } + return applyInstanceIconConfig(payload); + } + + async function cropImageFileToInstancePng(file) { + const allowedTypes = ['image/png', 'image/jpeg', 'image/webp']; + if (!file || !allowedTypes.includes(file.type)) { + throw new Error('请选择 PNG、JPG 或 WebP 图片'); + } + const objectUrl = URL.createObjectURL(file); + try { + const image = new Image(); + image.decoding = 'async'; + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = () => reject(new Error('无法读取所选图片')); + image.src = objectUrl; + }); + const width = image.naturalWidth || image.width; + const height = image.naturalHeight || image.height; + if (!width || !height) throw new Error('所选图片没有有效尺寸'); + const side = Math.min(width, height); + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 512; + const context = canvas.getContext('2d'); + if (!context) throw new Error('当前浏览器无法处理图标图片'); + context.clearRect(0, 0, 512, 512); + context.drawImage( + image, + (width - side) / 2, + (height - side) / 2, + side, + side, + 0, + 0, + 512, + 512, + ); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((result) => { + if (result) resolve(result); + else reject(new Error('生成 PNG 图标失败')); + }, 'image/png'); + }); + if (blob.size > 4 * 1024 * 1024) throw new Error('处理后的图标超过 4MB'); + return blob; + } finally { + URL.revokeObjectURL(objectUrl); + } + } + + async function uploadInstanceIcon(blob) { + await ensureAuthenticatedWs(); + const response = await fetch('/api/instance-icon', { + method: 'POST', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'image/png', + }, + body: blob, + }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload?.ok) { + throw new Error(payload?.message || `保存实例图标失败 (${response.status})`); + } + return applyInstanceIconConfig(payload); + } + + async function resetInstanceIconToDefault() { + await ensureAuthenticatedWs(); + const response = await fetch('/api/instance-icon', { + method: 'DELETE', + headers: { Authorization: `Bearer ${authToken}` }, + }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload?.ok) { + throw new Error(payload?.message || `恢复默认图标失败 (${response.status})`); + } + return applyInstanceIconConfig(payload); + } + + function mountInstanceIconSettings(panel) { + const fileInput = panel.querySelector('[data-instance-icon-file]'); + const selectBtn = panel.querySelector('[data-instance-icon-select]'); + const resetBtn = panel.querySelector('[data-instance-icon-reset]'); + const summary = panel.querySelector('[data-instance-icon-summary]'); + const status = panel.querySelector('[data-instance-icon-status]'); + if (!fileInput || !selectBtn || !resetBtn || !summary || !status) return; + + const showState = (config) => { + summary.textContent = config.custom + ? `已自定义${config.updatedAt ? ` · ${new Date(config.updatedAt).toLocaleString()}` : ''}` + : '使用 CC-Web 默认图标'; + resetBtn.disabled = !config.custom; + }; + const showStatus = (message = '', type = '') => { + status.textContent = message; + status.className = `settings-status instance-icon-status ${type}`.trim(); + }; + const setBusy = (busy) => { + selectBtn.disabled = busy; + resetBtn.disabled = busy || !instanceIconConfig.custom; + }; + + selectBtn.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', async () => { + const file = fileInput.files?.[0]; + fileInput.value = ''; + if (!file) return; + setBusy(true); + showStatus('正在处理并保存图标…'); + try { + const blob = await cropImageFileToInstancePng(file); + const config = await uploadInstanceIcon(blob); + showState(config); + showStatus('实例图标已保存', 'success'); + } catch (err) { + showStatus(err?.message || '保存实例图标失败', 'error'); + } finally { + setBusy(false); + } + }); + resetBtn.addEventListener('click', async () => { + if (!instanceIconConfig.custom || !confirm('确认恢复 CC-Web 默认图标?')) return; + setBusy(true); + showStatus('正在恢复默认图标…'); + try { + const config = await resetInstanceIconToDefault(); + showState(config); + showStatus('已恢复默认图标', 'success'); + } catch (err) { + showStatus(err?.message || '恢复默认图标失败', 'error'); + } finally { + setBusy(false); + } + }); + + loadInstanceIconConfig().then(showState).catch((err) => { + showState(instanceIconConfig); + showStatus(err?.message || '读取实例图标失败', 'error'); + }); + } + + loadInstanceIconConfig().catch(() => { + // 登录页仍可通过服务端默认回退显示图标,配置读取失败不阻断主界面。 + }); + function mountAppearanceSettings(panel) { const themePageBtn = panel.querySelector('[data-open-theme-page]'); if (themePageBtn) themePageBtn.addEventListener('click', openThemeSubpage); @@ -1279,6 +1468,7 @@ }); } refreshDividerTimeControls(panel); + mountInstanceIconSettings(panel); } function buildNotifyEntryHtml(config) { @@ -11920,7 +12110,7 @@ navigator.serviceWorker.ready.then((reg) => { reg.showNotification('CC-Web', { body: `「${title}」任务完成`, - icon: '/icon-192.png', + icon: instanceIconUrl(), tag: 'cc-web-task', renotify: true, }); @@ -12973,7 +13163,7 @@ panel.className = 'force-change-panel'; panel.innerHTML = ` - +

修改初始密码

首次登录需要设置新密码

diff --git a/public/index.html b/public/index.html index 553e4a5..5b45345 100644 --- a/public/index.html +++ b/public/index.html @@ -8,10 +8,9 @@ CC-Web - - - - + + +