merge: integrate Prompt Museum media rendering
This commit is contained in:
@@ -11,6 +11,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
|
||||
- `4013edc` / `3b799af`: integrated per-user Code/Canvas/Learning/Robot entry policy from Works Square, projected by Electron Main as four booleans and enforced before disabled module routes initialize.
|
||||
- `01bee31`: enabled AI Learning course catalog/generation/download/playback, Main-owned cloud/runtime bridges, verified external OpenMAIC player-artifact packaging, account profile reuse, removal of the transient `game-engine` Skill, and project-root `planning-with-files` output from the authoritative remote main. The merge hardens this with strict DTO/error projection, account-isolated local state, bounded same-origin downloads/packages, a nonce-protected account-bound player HTTP session, and an exact-source/origin single-document iframe bridge.
|
||||
- `26b52d7`: Canvas Prompt Museum, editable server-priced generation Quotes, project deletion/task-detail workflow, cloud-default Canvas development entry, and Chinese-only UI consolidation from the authoritative remote main. Its transient bundled `game-engine` Skill is superseded by `01bee31`.
|
||||
- `f8d82e6`: Prompt Museum media rendering now accepts only the server-controlled relative media route, fetches it through a Main-owned bounded Works-authenticated proxy with one refresh retry, and keeps credential-free HTTPS CDN media direct. Renderer-side validation and card-local placeholders cover invalid or failed media; attribution URLs remain optional.
|
||||
- `c1326a2`: Guided Hotspot Binding now scans bounded open `Xiaozhi-*` candidates and connects the user-selected hotspot inside the page through Main-owned Windows WLAN and macOS CoreWLAN/CoreLocation adapters; system Wi-Fi remains fallback, exact `=0` rollback and firmware/cloud contracts are unchanged.
|
||||
- `b78fc07`: Guided Hotspot Binding is enabled by default in Electron Main, with exact environment value `0` as rollback and direct six-digit fallback on capability-read failure; firmware and Host/cloud contracts are unchanged.
|
||||
- `b7a1590` / `14afe4a`: initial firmware-zero-change Guided Hotspot Binding V1 implementation and decision used a Main-owned default-off capability, fixed portal action, in-memory Renderer journey, and existing six-digit Binding contract; `b78fc07` above supersedes only that default.
|
||||
@@ -37,7 +38,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
|
||||
|
||||
AI 绘画的一个 Workspace 可包含多条 Conversation。消息、Brief、Quote 和 `turnRevision` 随 Conversation 隔离;生成任务和资产保持 Workspace 级共享。图片 Brief 支持文生图,以及从当前项目已完成作品或本地上传中选择一张参考图继续生成;视频复用同一选择器绑定首帧。两条路径都通过现有 Workspace Asset 契约提交一个真实 Asset ID。每条 Conversation 使用服务端持久 Agent Gateway Session;连接正常时命令、Run 与设计事件共用双向 WebSocket,只有发送、断连或 ACK 超时等传输故障才以同一 `client_command_id` 回退 REST,结构化业务错误不重复提交且未知上游文本由 Main 脱敏。确认栏允许编辑服务端最终 Prompt 与 generation options,每次修改都由服务端 Quote 重算设计点,确认时提交最新原值;客户端不推算供应商或积分价格。任务详情可预览/下载结果。侧栏删除项目要求完整输入项目名,删除当前项目后切换到最近更新的剩余项目;服务端删除/结算语义仍由 Works Square 契约负责。确认生成会按 Quote 对账 Workspace 任务;任务已经落库但 Run 随后失败时仍恢复任务列表,内部对账失败不覆盖当前 UI 错误,同时 Conversation 写入继续受 Workspace-load 与 Conversation-selection generation 保护。
|
||||
|
||||
Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、分页、详情、作者/来源/许可证和图片地址全部由服务端经 Main-owned Host API 提供,客户端不打包静态数据集;“使用此 Prompt”只把原文带回当前 Canvas 输入框,不自动发送。该模块不是投稿、点赞、评论或排行榜社区。客户端契约已就绪,但不据此宣称 Works Square 内容后台和生产数据已经部署。`pnpm run dev` 现在默认使用云端 Canvas 适配器,本地适配器只能通过显式开发命令启用;产品 UI 只保留中文。
|
||||
Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、分页、详情、作者/来源/许可证和图片地址全部由服务端经 Main-owned Host API 提供,客户端不打包静态数据集;服务端相对媒体只允许固定 `/api/image-prompt-museum/{entry}/media/{thumbnail|number}` 形状,并由 Main 注入 Works Bearer、执行一次 401 刷新、可信 raster MIME 与 10 MiB 上限后转为 Renderer data URL;credential-free HTTPS CDN 图片保持直连。图片失败只显示卡片内占位,不阻断卡片或详情;缺少来源 URL 时显示纯文本。“使用此 Prompt”只把原文带回当前 Canvas 输入框,不自动发送。该模块不是投稿、点赞、评论或排行榜社区。客户端契约已就绪,但不据此宣称 Works Square 内容后台和生产数据已经部署。`pnpm run dev` 现在默认使用云端 Canvas 适配器,本地适配器只能通过显式开发命令启用;产品 UI 只保留中文。
|
||||
|
||||
Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/api/auth/me`,Renderer 只获得 Code、Canvas、Learning、Robot 四个布尔权限。缺失 `module_access` 或任一字段时默认开启;服务端 `design` 显式映射客户端 `painting`。被关闭的模块卡片置灰且不可点击,根路由、深层路由和别名路由均在 `MainLayout` 或模块初始化前阻断。Code provider 等待认证权限加载完成;权限查询返回终止性 `401` 时同时清理 Main 和 Renderer 会话。`/settings` 是全局设置,不受 Code 入口策略阻断。该机制只是客户端入口策略,不代替服务端 API 授权。
|
||||
|
||||
@@ -57,6 +58,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
|
||||
- 2026-08-17: Created merge commit `4013edc` for the reviewed per-user module-entry policy source tip `3b799af`. Main exposes only four booleans from `/api/auth/me`; missing fields remain enabled, `design` maps to `painting`, disabled root/deep/alias routes stop before module initialization, Code provider startup waits for policy hydration, terminal `401` clears both session layers, and global settings remains reachable.
|
||||
- 2026-08-17: Integrated remote `01bee31`: Learning is enabled with course browsing, strict bounded generation materials, verified atomic course installation, multi-module playback, Main-owned Agent/ASR/runtime bridges, and a manifest-verified external OpenMAIC player artifact. Merge review added account-isolated generation/library/player state, fixed-binding token/fetch/401 guards, passive-only course media with hardened responses, pre-existing active-registration checks before side-effect-free identity resolution, nonce-protected single-document player sessions, and a recoverable deep-link profile error gate. At that integration checkpoint, publishing used a coverless first create, existing draft/published were version-only, and races failed closed without cover/PATCH side effects; project-cover source `145a6ce` and matching server merge `0cedfc4` above supersede only the coverless-first-create limitation. The transient `game-engine` Skill was removed and `planning-with-files` writes its files to the project root. Production Works/player-artifact/signed-package acceptance remains pending.
|
||||
- 2026-08-16: Integrated remote `26b52d7`: Canvas now has server-backed Prompt Museum navigation, editable server-repriced generation Quotes, task result details/downloads, guarded project deletion, cloud-default development, and Chinese-only UI. That tip briefly bundled `game-engine`; authoritative successor `01bee31` removed it. Client integration is verified separately from production Prompt Museum data/backend deployment.
|
||||
- 2026-08-18: Integrated Prompt Museum media rendering from `f8d82e6`: relative protected media is fetched through Main with bounded trusted-raster validation and one 401 refresh, HTTPS media remains direct, invalid/failed images are card-local placeholders, and missing attribution URLs render without broken links. Focused unit/Electron E2E, typecheck, scoped lint, and Vite build passed; real Works/CDN production smoke remains pending.
|
||||
- 2026-08-16: Integrated Windows/macOS in-page Robot hotspot discovery, explicit selection, connection, and exact-current-SSID verification behind the existing default-on guided capability. Candidate IDs are bounded and short-lived, native diagnostics stay in Main, system settings remain fallback, and firmware/Portal/Binding contracts are unchanged.
|
||||
- 2026-08-16: Enabled the existing Guided Hotspot Binding journey by default after explicit product confirmation. Exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` remains the operational rollback; fixed portal ownership, direct-code fallback, security warnings, firmware-zero-change, and Binding-without-online semantics are preserved.
|
||||
- 2026-08-16: Initially implemented ADR-002's Robot onboarding V1 without changing firmware, behind a default-off Main capability and fixed portal opener. The later `b78fc07` decision above changes only the default; the same firmware/issuer/native-opener/physical evidence remains outstanding.
|
||||
@@ -94,6 +96,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
|
||||
## Risky Areas
|
||||
|
||||
- 四模块权限只控制 Makelore 客户端入口和初始化,不是 API 授权边界。不得因卡片置灰或路由阻断而放宽 Works/模块服务端的身份与权限校验;旧服务端缺少对象/字段时默认开启是显式兼容策略。
|
||||
- Prompt Museum 相对媒体必须保持固定的服务端路径并由 Main 处理;如果服务端增加媒体变体,需同步维护 entry/path 语法、Works Bearer 所有权、单次刷新、10 MiB 限制、可信 raster MIME 白名单与 Renderer data URL 校验。HTTPS 直连媒体必须继续无凭据,图片失败必须局限在卡片/详情视图。
|
||||
- Learning 的课程目录、生成、Agent、ASR 与 runtime 都依赖真实 Works 权益和服务端契约;本地课程归档与播放器 artifact 必须在信任前完成边界、大小与摘要校验。账号分区/epoch、fixed-binding token+fetch guards、同源重定向、512 MiB 上限、player nonce、exact source/origin 与单文档 bridge 边界不可放宽;不得把模块/场景自报身份当成 aggregate 课程权益,也不得把上游错误、Token、内部 URL 或本地归档路径投影到 Renderer。
|
||||
- Works Project 首次封面已由服务端源 `407c883`(本地 merge `0cedfc4`)提供单请求原子绑定与失败补偿,客户端源 `145a6ce` 因此要求首次发布上传 PNG/JPEG/WebP 封面;部署、安装包和真实账号/对象存储 smoke 仍未完成。服务端仍没有已有 metadata 的 revision/ETag 与 draft-only 条件写,因此已有 draft/published 继续只允许 version-only,客户端不得以无条件 PATCH 替代。
|
||||
- Guided Hotspot Binding 默认开启并提供未经认证的热点扫描/显式连接,但当前 Hotspot/portal 仍是开放 SoftAP + 明文 HTTP,且精确出货镜像、激活码发行契约、签名 macOS、Windows 真机与完整整链尚未验证。界面必须保留环境警告,异常发布可用精确环境值 `0` 回滚;不得把 SSID 前缀宣称为可信设备发现、自动认领或在线证明。
|
||||
@@ -118,4 +121,4 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-08-17
|
||||
2026-08-18
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Task: Publish Prompt Museum media fix on main
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260818-museum-media-main-8c2d
|
||||
- Mode: Integration
|
||||
- Branch: main
|
||||
- Worktree: D:\Datas\OthersProjects\makelore
|
||||
- Base commit: 11b19832a35477d2136c6ea953dd9c408fd84816
|
||||
- Owner: developer
|
||||
- Status: Completed
|
||||
|
||||
## Scope
|
||||
|
||||
- Publish the completed Prompt Museum media-rendering change from integration commit `22bee1d` onto the occupied `main` worktree, preserving the source code/tests and accepted canonical documentation updates.
|
||||
- Keep the source feature task record out of the canonical tree; integration records remain task-scoped and the source worktree remains read-only.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Preserve the Main-owned fixed relative media route, Works Bearer refresh behavior, 10 MiB/trusted-raster limits, Renderer data-URL validation, direct HTTPS images, and local failure placeholders.
|
||||
- The merge must not claim production Works/CDN, real-account, signed-package, or deployment acceptance; it must leave `main` clean and the integration lock releasable.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Merged integration commit `22bee1d` into `main` with an explicit non-fast-forward merge; the final tree contains the Prompt Museum Main proxy, shared DTO, Renderer media conversion/page behavior, focused tests, and canonical state/evidence/commitment updates.
|
||||
- Removed only the intermediate integration task record from the merge result; this final task record is the canonical integration note.
|
||||
|
||||
## Verification
|
||||
|
||||
- `git merge --no-ff --no-commit 22bee1d`: PASS; automatic merge had no conflicts.
|
||||
- Focused Prompt Museum verification inherited from the independently reviewed source: 3 files / 27 unit tests, typecheck, scoped ESLint, Vite build, targeted Electron E2E 1/1, `git diff --check`, and final Sol review PASS.
|
||||
- Main task-aware drift check and final clean-worktree check will run before releasing ownership.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Real Works account, deployed media endpoint/CDN, and signed-package smoke remain release follow-ups.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- `current-state.md`, `50-evidence/evidence-index.md`, and `80-commitments/commitments.md` now record the bounded Main proxy and the remaining production acceptance obligation.
|
||||
@@ -9,6 +9,7 @@ Use this index for searchable, traceable evidence records.
|
||||
| 2026-08-17 | Makelore 用户级四模块入口策略 | 客户端实现与独立复审通过;服务端部署、新包与真实账号 smoke 待验收 | 源任务 `20260817-makelore-module-access-6f2a91c4`、源 tip `3b799af` | 4 files / 69 focused tests、175 files / 2047 full tests、TypeScript、scoped ESLint、Renderer/Main/Preload production build 与最终独立 Sol review PASS。覆盖 Main 四布尔安全投影、缺失默认开启、`design` → `painting`、卡片置灰不可点、根/深层/别名路由初始化前阻断、Code policy hydration、terminal `401` 双层清会话和全局 `/settings`;不据此宣称 API 授权或生产发布已验收 |
|
||||
| 2026-08-17 | 远程 `01bee31` Learning 主线集成与安全收口 | 合并树自动化与独立双轴复审通过;真实 Works、固定生产播放器 artifact 与签名安装包待验收 | 远程提交 `01bee31`、集成任务 `20260813-sync-push-main-9c2f71` | pnpm 10.33.4 frozen install、26 files / 418 pre-review focused、175 files / 1944 pre-review full;四轮审查修复后统一 9 files / 161、最终 175 files / 2028 full、typecheck、lint(0 errors / 7 existing warnings)、Renderer/Main/Preload build 与最终 Electron E2E 4/4 通过(更早跨模块选择 9/9 亦通过);第五轮 Standards/Spec 最终复审 PASS、无 P0-P3。最终树严格投影 generation IPC;分离显式 player registration 与无副作用 identity resolve;manifest 只接受相对 `audio|media|fonts`,权威 root 注入单一 module 前缀,consumer 生成 URL 已穿过 registered ZIP 的真实 HTTP/Woff2/MIME/security-header 测试;保持 fixed-binding 账号/Token/fetch/401 guard、512 MiB/ZIP/同源 5 跳下载、nonce player、exact-source/origin bridge 与可恢复 profile gate。该 checkpoint 因当时缺少 revision/cover cleanup 合同采用 coverless first-create、existing version-only、竞态 fail-closed;当前客户端 `145a6ce` 与服务端 `407c883` / `0cedfc4` 已仅替换首次 coverless 限制,不据此宣称生产服务或签名包已验收 |
|
||||
| 2026-08-16 | 远程 `26b52d7` Canvas/Prompt Museum 主线集成 | 合并树自动化验证通过;真实服务端内容、计费与删除结算待验收 | 远程提交 `26b52d7`、集成任务 `20260813-sync-push-main-9c2f71` | 主工作区及独立临时目录 clean frozen install(955 packages)、16 files / 284 focused、Prompt Museum 3 files / 13、161 files / 1850 full、typecheck、lint、Renderer/Main/Preload build、Electron E2E 6/6 与文档门禁通过。合并额外修复 lock override 实际签名依赖图、Prompt Museum 未知错误脱敏/严格 DTO+HTTPS 投影/401 refresh,以及过期 E2E 断言;不据此宣称 Museum 后台审核数据、Quote 真实计费或 Workspace 删除结算已部署 |
|
||||
| 2026-08-18 | Prompt Museum 相对媒体渲染修复 | 客户端实现与独立复审通过;真实 Works/CDN 媒体端点待验收 | 源任务 `20260818-prompt-museum-client-4f7a`、源提交 `f8d82e6`、集成任务 `20260818-museum-media-integration-6b7e` | Main 仅代理固定相对媒体路径,注入 Works Bearer 并最多刷新一次,限制 10 MiB 与可信 raster MIME;Renderer 验证 JSON/base64 并生成 data URL,HTTPS 图片保持直连,失败显示卡片内占位,缺少 attribution URL 不生成坏链接。3 files / 27 focused tests、typecheck、scoped ESLint、Vite build 与定向 Electron E2E 1/1 通过;未执行真实账号、生产媒体/CDN 或安装包 smoke |
|
||||
| 2026-08-16 | Robot Windows/macOS 配网页内热点连接 | 实现与本地自动化验证通过;双平台实机发布证据待完成 | 源提交 `c1326a2`、ADR-003、集成任务 `20260813-sync-push-main-9c2f71` | 4 files / 132 focused tests、157 files / 1796 full tests、typecheck、lint、Renderer/Main/Preload build 与独立 Standards/Spec review 通过;Electron 40.10.6 加载 Koffi/wlanapi 成功,Windows 权限拒绝安全投影通过。签名 macOS x64/arm64、Windows Robot 真机和真实 Host/native Electron E2E 未完成,不得据此宣称双平台硬件验收 |
|
||||
| 2026-08-11 | AI 设计多会话客户端集成 | 本地功能验证通过;仓库基线仍有既有失败 | `30-worklog/tasks/20260811-merge-all-code-a7c91e.md`、`4980894`、`03dae62` | 8 files / 116 focused tests、typecheck、changed-file ESLint、production build 与新增 Electron E2E 通过;生产 migration 0033/API 尚待验收,全量 lint/unit/E2E 的既有失败已单独记录 |
|
||||
| 2026-08-10 | 客户端静态发布唯一链路 | 本地验证通过 | `30-worklog/tasks/20260810-static-release-only-a91c.md`、`4df0477`、`8dd99c1` | 227 项聚焦回归、typecheck、Vite/Electron build、实际 Electron E2E 与 13 files / 21 tests 收集通过;不等同于真实生产部署验收 |
|
||||
|
||||
@@ -8,7 +8,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks,
|
||||
| 2026-08-17 | 验收用户级四模块入口策略的真实发布链 | 发布包含源 tip `3b799af` 的 Makelore 安装包前 | Works 服务端/客户端/发布集成 | Pending | 部署 `module_access` migration 和 `/api/auth/me` API,重新打包并安装 Makelore;使用真实账号依次关闭 Code、Canvas、Learning、Robot,重启后验证卡片置灰/不可点、根/深层/别名路由阻断、`/settings` 仍可访问和 terminal `401` 退出;另行以直接 API 请求确认服务端授权,不以 UI 置灰代替 |
|
||||
| 2026-08-16 | 验收 AI Learning 的真实 Works、课程包与 production Stage 发布链 | 发布包含 `01bee31` Learning 行为的安装包前 | 客户端/服务端/发布集成 | Pending | 使用真实账号核对课程广场、单课生成(无材料/5 文件边界)、取消/恢复/finalize、同源最多 5 跳下载、512 MiB archive 上限、大小/SHA-256/原子安装、账号切换隔离、离线多模块播放、进度、Agent、ASR、PBL/评分权益;以固定 URL/SHA-256 的 player artifact 构建并完成 Windows 与签名 macOS 安装包 smoke,验证 packaged Chromium 的 loopback cookie/nonce,不以客户端单测替代生产验收 |
|
||||
| 2026-08-17 | 验收首次项目封面并补齐已有资料条件写 | 发布包含源 `145a6ce` 的 Makelore 前,或启用 draft/published metadata 编辑前 | Works 服务端/客户端发布集成 | Partial / Pending | 首次封面已由服务端源 `407c883` 与客户端源 `145a6ce` 通过单请求绑定和失败补偿落地;仍需先部署服务端、重新打包客户端,并以真实对象存储/账号验证 404→create 409、cover 失败和版本阻断。已有资料编辑继续等待 metadata revision/ETag 与 draft-only 条件写;在此之前保持 existing version-only。 |
|
||||
| 2026-08-16 | 验收 Prompt Museum 与 Canvas 删除/重报价的真实服务端链路 | 发布包含 `26b52d7` Canvas 行为的安装包前 | 客户端/服务端集成 | Pending | 使用真实 Works 账号核对 Museum 列表/详情/分页/筛选/署名/CDN/Prompt 回填;核对最终 Prompt/options 重报价和确认设计点;删除 Workspace 后确认软删除可见性、未提交任务取消/预留积分释放、已运行任务结算。保留 Main 错误脱敏和严格 DTO/HTTPS 投影,不以客户端回归替代服务端验收 |
|
||||
| 2026-08-16 | 验收 Prompt Museum 与 Canvas 删除/重报价的真实服务端链路 | 发布包含 `26b52d7` Canvas 行为的安装包前 | 客户端/服务端集成 | Pending | 使用真实 Works 账号核对 Museum 列表/详情/分页/筛选/署名/CDN/Prompt 回填,并确认固定相对媒体路径可经 Main Bearer 代理、401 刷新、10 MiB/可信 raster MIME 边界后在 Renderer 展示;核对最终 Prompt/options 重报价和确认设计点;删除 Workspace 后确认软删除可见性、未提交任务取消/预留积分释放、已运行任务结算。保留 Main 错误脱敏和严格 DTO/HTTPS 投影,不以客户端回归替代服务端验收 |
|
||||
| 2026-08-16 | 验收 default-on Robot Guided Hotspot Binding 的 Windows/macOS 真实设备链路 | 下一份包含页面内热点连接行为的安装包发布前 | 客户端/硬件/服务端集成 | Pending | 核对精确出货固件与固定 Portal、六位码发行/消费语义;执行 Windows Robot 真机扫描/连接、签名 macOS x64/arm64 CoreLocation/CoreWLAN/worker/ASAR/Koffi smoke,以及真实 Host/native Electron 端到端配网+Binding;保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚并记录支持矩阵 |
|
||||
| 2026-08-10 | 完成客户端提交到 App `play_url` 播放的真实生产整链验收 | source+built+contract 服务端协议、OSS immutable Release、CDN/Edge 与 App 消费链成组集成后 | 客户端/服务端集成 | Pending | 使用真实账号执行小游戏和小程序创建、客户端本地构建与同字节预检、双归档提交、服务端逐字节校验/不可变 Release 固化、运营批准、CDN 发布、App 播放与监控核对;如需不可绕过 runtime gate,另行设计可信 verifier |
|
||||
| 2026-08-12 | Windows 发布流水线保留固定 npm 运行时产物门禁 | 每次生成正式 Windows 安装器时 | 客户端发布 | Pending | 运行 `pnpm verify:publish-runtime` 和 `pnpm verify:artifact:win`;当前 223,547,912-byte / SHA-256 `08A0BB7BC66EE4AD8B120E8955B149951CD86AF53CC966C7973AD4D77A5815C1` 安装器仅为本地证据,尚未发布 |
|
||||
|
||||
@@ -14,6 +14,16 @@ const MAX_FACET_ITEMS = 256;
|
||||
const MAX_CATEGORIES = 32;
|
||||
const MAX_IMAGES = 16;
|
||||
const MAX_VARIABLES = 64;
|
||||
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
|
||||
const MEDIA_URL_PATTERN = /^\/api\/image-prompt-museum\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/media\/(?:thumbnail|[0-9]+)$/;
|
||||
const LOCAL_MEDIA_PATH_PATTERN = /^\/api\/works\/image-prompt-museum\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/media\/(thumbnail|[0-9]+)$/;
|
||||
const TRUSTED_MEDIA_MIME_TYPES = new Set([
|
||||
'image/avif',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
type PromptMuseumRouteDependencies = {
|
||||
fetchImpl?: typeof fetch;
|
||||
@@ -22,11 +32,17 @@ type PromptMuseumRouteDependencies = {
|
||||
};
|
||||
|
||||
function isMuseumPath(pathname: string): boolean {
|
||||
return pathname === LOCAL_ROOT || /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname);
|
||||
return pathname === LOCAL_ROOT
|
||||
|| /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname)
|
||||
|| LOCAL_MEDIA_PATH_PATTERN.test(pathname);
|
||||
}
|
||||
|
||||
function upstreamPath(pathname: string): string | null {
|
||||
if (pathname === LOCAL_ROOT) return UPSTREAM_ROOT;
|
||||
const mediaMatch = LOCAL_MEDIA_PATH_PATTERN.exec(pathname);
|
||||
if (mediaMatch) {
|
||||
return `${UPSTREAM_ROOT}/${mediaMatch[1]}/media/${mediaMatch[2]}`;
|
||||
}
|
||||
const entryId = pathname.slice(`${LOCAL_ROOT}/`.length);
|
||||
if (!entryId) return null;
|
||||
return `${UPSTREAM_ROOT}/${encodeURIComponent(decodeURIComponent(entryId))}`;
|
||||
@@ -80,6 +96,12 @@ function httpsUrl(value: unknown): string {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function imageUrl(value: unknown): string {
|
||||
const raw = boundedString(value, 2048);
|
||||
if (MEDIA_URL_PATTERN.test(raw)) return raw;
|
||||
return httpsUrl(raw);
|
||||
}
|
||||
|
||||
function nullableHttpsUrl(value: unknown): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
@@ -116,13 +138,43 @@ function boundedArray(value: unknown, maximum: number): unknown[] {
|
||||
function projectImage(value: unknown): Record<string, unknown> {
|
||||
const image = asRecord(value);
|
||||
return {
|
||||
url: httpsUrl(image.url),
|
||||
url: imageUrl(image.url),
|
||||
width: positiveInteger(image.width, 32_768),
|
||||
height: positiveInteger(image.height, 32_768),
|
||||
alt: boundedString(image.alt, 500),
|
||||
};
|
||||
}
|
||||
|
||||
function mediaMimeType(response: Response): string | null {
|
||||
const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
||||
return mimeType && TRUSTED_MEDIA_MIME_TYPES.has(mimeType) ? mimeType : null;
|
||||
}
|
||||
|
||||
async function readBoundedMedia(response: Response): Promise<Buffer | null> {
|
||||
const declaredLength = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_MEDIA_BYTES) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
if (!response.body) return null;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > MAX_MEDIA_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
if (size === 0) return null;
|
||||
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
||||
}
|
||||
|
||||
function projectCategory(value: unknown): Record<string, unknown> {
|
||||
const category = asRecord(value);
|
||||
const group = boundedString(category.group, 32);
|
||||
@@ -142,6 +194,7 @@ function projectAttribution(value: unknown): Record<string, unknown> {
|
||||
const source = asRecord(attribution.source);
|
||||
const license = asRecord(attribution.license);
|
||||
const authorUrl = nullableHttpsUrl(author.url);
|
||||
const sourceUrl = nullableHttpsUrl(source.url);
|
||||
const licenseUrl = nullableHttpsUrl(license.url);
|
||||
return {
|
||||
author: {
|
||||
@@ -150,7 +203,7 @@ function projectAttribution(value: unknown): Record<string, unknown> {
|
||||
},
|
||||
source: {
|
||||
name: boundedString(source.name, 300),
|
||||
url: httpsUrl(source.url),
|
||||
...(sourceUrl === undefined ? {} : { url: sourceUrl }),
|
||||
},
|
||||
license: {
|
||||
name: boundedString(license.name, 300),
|
||||
@@ -310,6 +363,7 @@ export function createImagePromptMuseumRouteHandler(
|
||||
}
|
||||
|
||||
try {
|
||||
const isMediaRequest = LOCAL_MEDIA_PATH_PATTERN.test(url.pathname);
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) {
|
||||
sendJson(res, 401, {
|
||||
@@ -326,7 +380,7 @@ export function createImagePromptMuseumRouteHandler(
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Accept: isMediaRequest ? 'image/avif,image/webp,image/png,image/jpeg,image/gif' : 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
redirect: 'manual',
|
||||
@@ -344,12 +398,30 @@ export function createImagePromptMuseumRouteHandler(
|
||||
response = await request(refreshed);
|
||||
}
|
||||
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
sendSafeError(res, response.status);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isMediaRequest) {
|
||||
const mimeType = mediaMimeType(response);
|
||||
if (!mimeType) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
sendInvalidResponse(res);
|
||||
return true;
|
||||
}
|
||||
const bytes = await readBoundedMedia(response);
|
||||
if (!bytes) {
|
||||
sendInvalidResponse(res);
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, { dataBase64: bytes.toString('base64'), mimeType });
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = await readPayload(response);
|
||||
|
||||
if (payload === null) {
|
||||
sendInvalidResponse(res);
|
||||
return true;
|
||||
|
||||
@@ -40,7 +40,7 @@ export type PromptMuseumAuthor = {
|
||||
|
||||
export type PromptMuseumSource = {
|
||||
name: string;
|
||||
url: string;
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
export type PromptMuseumLicense = {
|
||||
|
||||
@@ -15,6 +15,21 @@ type PromptMuseumEnvelope<T> = {
|
||||
data?: T;
|
||||
};
|
||||
|
||||
type PromptMuseumMedia = {
|
||||
dataBase64: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
const PROMPT_MUSEUM_MEDIA_URL_PATTERN = /^\/api\/image-prompt-museum\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/media\/(?:thumbnail|[0-9]+)$/;
|
||||
const PROMPT_MUSEUM_MEDIA_MIME_TYPES = new Set([
|
||||
'image/avif',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]);
|
||||
const MAX_MEDIA_BASE64_LENGTH = Math.ceil((10 * 1024 * 1024) / 3) * 4;
|
||||
|
||||
export class PromptMuseumApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
@@ -92,3 +107,23 @@ export async function fetchPromptMuseumPage(query: PromptMuseumListQuery = {}):
|
||||
export async function fetchPromptMuseumEntry(entryId: string): Promise<PromptMuseumEntry> {
|
||||
return await requestData(`${IMAGE_PROMPT_MUSEUM_API_PATH}/${encodeURIComponent(entryId)}`);
|
||||
}
|
||||
|
||||
export async function fetchPromptMuseumMedia(mediaUrl: string): Promise<string> {
|
||||
if (!PROMPT_MUSEUM_MEDIA_URL_PATTERN.test(mediaUrl)) {
|
||||
throw new PromptMuseumApiError(400, 'PROMPT_MUSEUM_INVALID_MEDIA_URL', '提示词图片地址无效');
|
||||
}
|
||||
const localPath = mediaUrl.replace('/api/image-prompt-museum/', `${IMAGE_PROMPT_MUSEUM_API_PATH}/`);
|
||||
const payload = await hostApiFetch<PromptMuseumMedia>(localPath);
|
||||
const mimeType = typeof payload?.mimeType === 'string' ? payload.mimeType.trim().toLowerCase() : '';
|
||||
const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : '';
|
||||
if (
|
||||
!PROMPT_MUSEUM_MEDIA_MIME_TYPES.has(mimeType)
|
||||
|| !dataBase64
|
||||
|| dataBase64.length > MAX_MEDIA_BASE64_LENGTH
|
||||
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(dataBase64)
|
||||
|| dataBase64.length % 4 === 1
|
||||
) {
|
||||
throw new PromptMuseumApiError(502, 'PROMPT_MUSEUM_INVALID_MEDIA_RESPONSE', '提示词图片返回了无效数据');
|
||||
}
|
||||
return `data:${mimeType};base64,${dataBase64}`;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
fetchPromptMuseumEntry,
|
||||
fetchPromptMuseumMedia,
|
||||
fetchPromptMuseumPage,
|
||||
PromptMuseumApiError,
|
||||
} from '@/lib/image-prompt-museum';
|
||||
@@ -84,6 +85,17 @@ function errorMessage(error: unknown): string {
|
||||
return '获取灵感暂时不可用,请稍后再试';
|
||||
}
|
||||
|
||||
function directHttpsUrl(value: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'https:' && !parsed.username && !parsed.password
|
||||
? parsed.toString()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function MuseumImage({
|
||||
image,
|
||||
className,
|
||||
@@ -93,22 +105,52 @@ function MuseumImage({
|
||||
className?: string;
|
||||
sizes?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed || !image.url) {
|
||||
const directSource = directHttpsUrl(image.url);
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
||||
const [mediaState, setMediaState] = useState<{
|
||||
imageUrl: string;
|
||||
source: string | null;
|
||||
failed: boolean;
|
||||
}>({ imageUrl: '', source: null, failed: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (directHttpsUrl(image.url)) return;
|
||||
let cancelled = false;
|
||||
void fetchPromptMuseumMedia(image.url).then(
|
||||
(dataUrl) => {
|
||||
if (!cancelled) setMediaState({ imageUrl: image.url, source: dataUrl, failed: false });
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setMediaState({ imageUrl: image.url, source: null, failed: true });
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [image.url]);
|
||||
|
||||
const relativeState = mediaState.imageUrl === image.url ? mediaState : null;
|
||||
const source = directSource ?? relativeState?.source ?? null;
|
||||
const failed = failedUrl === image.url || relativeState?.failed === true;
|
||||
|
||||
if (failed || !source) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center bg-surface-subtle text-muted-foreground', className)}>
|
||||
<div
|
||||
aria-label={failed ? `${image.alt}加载失败` : `${image.alt}正在加载`}
|
||||
className={cn('flex items-center justify-center bg-surface-subtle text-muted-foreground', className)}
|
||||
>
|
||||
<ImageIcon className="h-8 w-8" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={image.url}
|
||||
src={source}
|
||||
alt={image.alt}
|
||||
sizes={sizes}
|
||||
loading="lazy"
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
onError={() => setFailedUrl(image.url)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -227,15 +269,17 @@ function AttributionBlock({ entry }: { entry: PromptMuseumEntry }) {
|
||||
<div className="flex gap-3">
|
||||
<dt className="w-12 shrink-0 text-muted-foreground">来源</dt>
|
||||
<dd className="min-w-0 truncate font-medium text-foreground">
|
||||
<a
|
||||
href={entry.attribution.source.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex max-w-full items-center gap-1 text-brand hover:underline"
|
||||
>
|
||||
<span className="truncate">{entry.attribution.source.name}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
{entry.attribution.source.url ? (
|
||||
<a
|
||||
href={entry.attribution.source.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex max-w-full items-center gap-1 text-brand hover:underline"
|
||||
>
|
||||
<span className="truncate">{entry.attribution.source.name}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
) : entry.attribution.source.name}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -1,6 +1,94 @@
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
test.describe('AI Design workspace', () => {
|
||||
test('keeps Prompt Museum cards usable when relative media fails', async ({ launchElectronApp }) => {
|
||||
test.setTimeout(90_000);
|
||||
const app = await launchElectronApp({
|
||||
imageWorkspaceMode: 'local',
|
||||
skipSetup: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||
await page.getByTestId('ai-module-option-painting').click();
|
||||
const imageSidebar = page.getByTestId('sidebar-image-workspace');
|
||||
await expect(imageSidebar.getByTestId('sidebar-open-image-prompt-museum')).toBeVisible();
|
||||
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
const image = {
|
||||
url: '/api/image-prompt-museum/e2e-entry/media/thumbnail',
|
||||
width: 1200,
|
||||
height: 900,
|
||||
alt: 'E2E museum thumbnail',
|
||||
};
|
||||
const card = {
|
||||
id: 'e2e-entry',
|
||||
slug: 'e2e-entry',
|
||||
title: '相对媒体 E2E',
|
||||
summary: '验证图片失败不会阻断卡片。',
|
||||
thumbnail: image,
|
||||
categories: [],
|
||||
model: { id: 'e2e-model', name: 'E2E Model' },
|
||||
language: 'zh-CN',
|
||||
attribution: {
|
||||
author: { name: 'E2E 作者' },
|
||||
source: { name: 'E2E 来源' },
|
||||
license: { name: '测试许可', attributionText: 'E2E attribution' },
|
||||
},
|
||||
publishedAt: '2026-08-18T00:00:00.000Z',
|
||||
updatedAt: '2026-08-18T00:00:00.000Z',
|
||||
};
|
||||
const respond = (json: unknown) => ({
|
||||
ok: true,
|
||||
data: { status: 200, ok: true, json },
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => {
|
||||
const path = request.path ?? '';
|
||||
if (path === '/api/works/image-prompt-museum') {
|
||||
return respond({
|
||||
success: true,
|
||||
data: {
|
||||
items: [card],
|
||||
facets: { useCases: [], styles: [], subjects: [] },
|
||||
nextCursor: null,
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === '/api/works/image-prompt-museum/e2e-entry') {
|
||||
return respond({
|
||||
success: true,
|
||||
data: {
|
||||
...card,
|
||||
prompt: 'Create a resilient image card.',
|
||||
variables: [],
|
||||
images: [image],
|
||||
requiresReferenceImages: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === '/api/works/image-prompt-museum/e2e-entry/media/thumbnail') {
|
||||
return respond({ mimeType: 'text/html', dataBase64: 'PGgxPnVuc2FmZTwvaDE+' });
|
||||
}
|
||||
return { ok: false, error: { message: `Unexpected E2E Host API request: ${path}` } };
|
||||
});
|
||||
});
|
||||
|
||||
await imageSidebar.getByTestId('sidebar-open-image-prompt-museum').click();
|
||||
const cardButton = page.getByRole('button', { name: '查看 相对媒体 E2E' });
|
||||
await expect(cardButton).toBeVisible();
|
||||
await expect(page.getByLabel('E2E museum thumbnail加载失败')).toBeVisible();
|
||||
await expect(cardButton).toBeEnabled();
|
||||
await cardButton.click();
|
||||
await expect(page.getByRole('button', { name: '关闭详情' })).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the conversation and generation task panes full height', async ({ launchElectronApp }) => {
|
||||
test.setTimeout(150_000);
|
||||
const app = await launchElectronApp({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
fetchPromptMuseumEntry,
|
||||
fetchPromptMuseumMedia,
|
||||
fetchPromptMuseumPage,
|
||||
PromptMuseumApiError,
|
||||
} from '@/lib/image-prompt-museum';
|
||||
@@ -65,4 +66,38 @@ describe('Prompt Museum renderer API boundary', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('loads a controlled relative media path through Main and creates a data URL', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'image/webp', dataBase64: 'AQID' });
|
||||
|
||||
await expect(fetchPromptMuseumMedia(
|
||||
'/api/image-prompt-museum/entry-1:en/media/thumbnail',
|
||||
)).resolves.toBe('data:image/webp;base64,AQID');
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-prompt-museum/entry-1:en/media/thumbnail',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://cdn.example/image.webp',
|
||||
'/api/image-prompt-museum/../media/thumbnail',
|
||||
'/api/image-prompt-museum/entry/media/original',
|
||||
])('rejects unsafe media URL %s before IPC', async (url) => {
|
||||
await expect(fetchPromptMuseumMedia(url)).rejects.toMatchObject({
|
||||
status: 400,
|
||||
code: 'PROMPT_MUSEUM_INVALID_MEDIA_URL',
|
||||
});
|
||||
expect(hostApiFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an untrusted Main media payload', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'text/html', dataBase64: 'PGgxPg==' });
|
||||
|
||||
await expect(fetchPromptMuseumMedia(
|
||||
'/api/image-prompt-museum/entry/media/0',
|
||||
)).rejects.toMatchObject({
|
||||
status: 502,
|
||||
code: 'PROMPT_MUSEUM_INVALID_MEDIA_RESPONSE',
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PromptMuseumEntry, PromptMuseumPage } from '../../shared/image-pro
|
||||
|
||||
const fetchPromptMuseumPageMock = vi.hoisted(() => vi.fn());
|
||||
const fetchPromptMuseumEntryMock = vi.hoisted(() => vi.fn());
|
||||
const fetchPromptMuseumMediaMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/image-prompt-museum', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/image-prompt-museum')>();
|
||||
@@ -14,6 +15,7 @@ vi.mock('@/lib/image-prompt-museum', async (importOriginal) => {
|
||||
...actual,
|
||||
fetchPromptMuseumPage: (...args: unknown[]) => fetchPromptMuseumPageMock(...args),
|
||||
fetchPromptMuseumEntry: (...args: unknown[]) => fetchPromptMuseumEntryMock(...args),
|
||||
fetchPromptMuseumMedia: (...args: unknown[]) => fetchPromptMuseumMediaMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -66,6 +68,7 @@ describe('Prompt Museum page', () => {
|
||||
useImagePromptMuseumStore.getState().clearPendingPrompt();
|
||||
fetchPromptMuseumPageMock.mockResolvedValue(pageFixture);
|
||||
fetchPromptMuseumEntryMock.mockResolvedValue(entryFixture);
|
||||
fetchPromptMuseumMediaMock.mockResolvedValue('data:image/webp;base64,AQID');
|
||||
});
|
||||
|
||||
it('keeps the inspiration filters compact and below the native title bar', async () => {
|
||||
@@ -126,4 +129,78 @@ describe('Prompt Museum page', () => {
|
||||
expect.objectContaining({ cursor: 'cursor-two' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders source attribution as text when the server omits its optional URL', async () => {
|
||||
fetchPromptMuseumEntryMock.mockResolvedValueOnce({
|
||||
...entryFixture,
|
||||
attribution: {
|
||||
...entryFixture.attribution,
|
||||
source: { name: 'PromptHero' },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-prompts']}>
|
||||
<Routes>
|
||||
<Route path="/image-prompts" element={<ImagePromptMuseum />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看 编辑感产品海报' }));
|
||||
expect(await screen.findByText('PromptHero')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'PromptHero' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads relative media through Main and renders its data URL', async () => {
|
||||
fetchPromptMuseumPageMock.mockResolvedValueOnce({
|
||||
...pageFixture,
|
||||
items: [{
|
||||
...pageFixture.items[0],
|
||||
thumbnail: {
|
||||
...pageFixture.items[0].thumbnail,
|
||||
url: '/api/image-prompt-museum/prompt-one/media/thumbnail',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-prompts']}>
|
||||
<Routes>
|
||||
<Route path="/image-prompts" element={<ImagePromptMuseum />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const image = await screen.findByRole('img', { name: '编辑感产品海报示例' });
|
||||
expect(image).toHaveAttribute('src', 'data:image/webp;base64,AQID');
|
||||
expect(fetchPromptMuseumMediaMock).toHaveBeenCalledWith(
|
||||
'/api/image-prompt-museum/prompt-one/media/thumbnail',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a card usable when its relative image fails', async () => {
|
||||
fetchPromptMuseumMediaMock.mockRejectedValueOnce(new Error('image unavailable'));
|
||||
fetchPromptMuseumPageMock.mockResolvedValueOnce({
|
||||
...pageFixture,
|
||||
items: [{
|
||||
...pageFixture.items[0],
|
||||
thumbnail: {
|
||||
...pageFixture.items[0].thumbnail,
|
||||
url: '/api/image-prompt-museum/prompt-one/media/thumbnail',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-prompts']}>
|
||||
<Routes>
|
||||
<Route path="/image-prompts" element={<ImagePromptMuseum />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('编辑感产品海报示例加载失败')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '查看 编辑感产品海报' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createImagePromptMuseumRouteHandler } from '@electron/api/routes/image-prompt-museum';
|
||||
import type { PromptMuseumCard } from '../../shared/image-prompt-museum';
|
||||
|
||||
function createResponse() {
|
||||
const chunks: string[] = [];
|
||||
@@ -19,6 +20,26 @@ function createResponse() {
|
||||
};
|
||||
}
|
||||
|
||||
function card(imageUrl = '/api/image-prompt-museum/prompt-one/media/thumbnail'): PromptMuseumCard {
|
||||
return {
|
||||
id: 'prompt-one',
|
||||
slug: 'prompt-one',
|
||||
title: '示例',
|
||||
summary: '示例摘要',
|
||||
thumbnail: { url: imageUrl, width: 1200, height: 900, alt: '示例' },
|
||||
categories: [],
|
||||
model: { id: 'image-model', name: 'Image Model' },
|
||||
language: 'zh-CN',
|
||||
attribution: {
|
||||
author: { name: '作者' },
|
||||
source: { name: '来源', url: 'https://example.com/source' },
|
||||
license: { name: 'CC BY 4.0', attributionText: '作者 / 来源 / CC BY 4.0' },
|
||||
},
|
||||
publishedAt: '2026-08-01T00:00:00Z',
|
||||
updatedAt: '2026-08-01T00:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('Prompt Museum Main route boundary', () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>();
|
||||
const getAccessToken = vi.fn();
|
||||
@@ -74,6 +95,165 @@ describe('Prompt Museum Main route boundary', () => {
|
||||
expect(response.json).toMatchObject({ success: true });
|
||||
});
|
||||
|
||||
it('accepts controlled relative media URLs in list and detail DTOs', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
items: [card()],
|
||||
facets: { useCases: [], styles: [], subjects: [] },
|
||||
nextCursor: null,
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
...card(),
|
||||
prompt: 'Create an image',
|
||||
variables: [],
|
||||
images: [{ url: '/api/image-prompt-museum/prompt-one/media/0', width: 1200, height: 900, alt: '详情' }],
|
||||
requiresReferenceImages: false,
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||
const listResponse = createResponse();
|
||||
const detailResponse = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
listResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
||||
{} as never,
|
||||
);
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
detailResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(listResponse.json).toMatchObject({
|
||||
success: true,
|
||||
data: { items: [{ thumbnail: { url: '/api/image-prompt-museum/prompt-one/media/thumbnail' } }] },
|
||||
});
|
||||
expect(detailResponse.json).toMatchObject({
|
||||
success: true,
|
||||
data: { images: [{ url: '/api/image-prompt-museum/prompt-one/media/0' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts list attribution sources with a missing or null optional URL', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
const itemWithoutUrl = card();
|
||||
itemWithoutUrl.attribution.source = { name: 'PromptHero' };
|
||||
const itemWithNullUrl = card();
|
||||
itemWithNullUrl.id = 'prompt-two';
|
||||
itemWithNullUrl.attribution.source = { name: 'Community archive', url: null };
|
||||
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
items: [itemWithoutUrl, itemWithNullUrl],
|
||||
facets: { useCases: [], styles: [], subjects: [] },
|
||||
nextCursor: null,
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(response.json).toMatchObject({
|
||||
success: true,
|
||||
data: { items: [
|
||||
{ attribution: { source: { name: 'PromptHero' } } },
|
||||
{ attribution: { source: { name: 'Community archive', url: null } } },
|
||||
] },
|
||||
});
|
||||
const sources = (response.json.data as { items: Array<{ attribution: { source: unknown } }> })
|
||||
.items.map((item) => item.attribution.source);
|
||||
expect(sources).toEqual([
|
||||
{ name: 'PromptHero' },
|
||||
{ name: 'Community archive', url: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('proxies a fixed media path with Bearer auth and returns only bounded raster data', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(new Response(Uint8Array.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/png; charset=binary', 'Content-Length': '3' },
|
||||
}));
|
||||
const handler = createImagePromptMuseumRouteHandler({
|
||||
fetchImpl,
|
||||
getAccessToken,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum/entry-1:en/media/thumbnail'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://square.example/api/image-prompt-museum/entry-1:en/media/thumbnail',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer works-token' }),
|
||||
redirect: 'manual',
|
||||
}),
|
||||
);
|
||||
expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/png' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['non-image media', { 'Content-Type': 'text/html' }],
|
||||
['oversized media', { 'Content-Type': 'image/webp', 'Content-Length': String(10 * 1024 * 1024 + 1) }],
|
||||
])('rejects %s responses', async (_name, headers) => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(new Response('unsafe', { status: 200, headers }));
|
||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one/media/0'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.res.statusCode).toBe(502);
|
||||
expect(response.json).toMatchObject({ code: 'PROMPT_MUSEUM_INVALID_RESPONSE' });
|
||||
});
|
||||
|
||||
it('does not claim unsafe or unknown media paths', async () => {
|
||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||
const unsafeResponse = createResponse();
|
||||
const unknownResponse = createResponse();
|
||||
|
||||
await expect(handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
unsafeResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum/../media/thumbnail'),
|
||||
{} as never,
|
||||
)).resolves.toBe(false);
|
||||
await expect(handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
unknownResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one/media/original'),
|
||||
{} as never,
|
||||
)).resolves.toBe(false);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a stable auth error without calling the upstream service', async () => {
|
||||
getAccessToken.mockResolvedValue(null);
|
||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||
@@ -215,23 +395,7 @@ describe('Prompt Museum Main route boundary', () => {
|
||||
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
items: [{
|
||||
id: 'prompt-one',
|
||||
slug: 'prompt-one',
|
||||
title: '示例',
|
||||
summary: '示例摘要',
|
||||
thumbnail: { url: 'http://internal.example/secret.png', width: 1200, height: 900, alt: '示例' },
|
||||
categories: [],
|
||||
model: { id: 'image-model', name: 'Image Model' },
|
||||
language: 'zh-CN',
|
||||
attribution: {
|
||||
author: { name: '作者' },
|
||||
source: { name: '来源', url: 'https://example.com/source' },
|
||||
license: { name: 'CC BY 4.0', attributionText: '作者 / 来源 / CC BY 4.0' },
|
||||
},
|
||||
publishedAt: '2026-08-01T00:00:00Z',
|
||||
updatedAt: '2026-08-01T00:00:00Z',
|
||||
}],
|
||||
items: [card('http://internal.example/secret.png')],
|
||||
facets: { useCases: [], styles: [], subjects: [] },
|
||||
nextCursor: null,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user