Compare commits

...

131 Commits

Author SHA1 Message Date
8aa9ebd061 Merge pull request '扩图结果直接归档到七牛存储' (#79) from codex/native-expansion-archive-20261112 into main 2026-11-12 15:09:46 +08:00
d32258e13b 扩图结果直接归档到七牛存储 2026-11-12 15:09:33 +08:00
6caf1b5cce Merge pull request '使用98K原生智能扩图,失败时保留明确错误' (#78) from codex/native-image-expansion-20261112 into main 2026-11-12 14:23:29 +08:00
78b111130b 使用98K原生智能扩图并保留完整输出 2026-11-12 14:23:09 +08:00
8fca3b25c5 Merge pull request '支持98K扩图线路替换 fal 上游' (#77) from codex/replace-fal-outpaint-vip98k-20261112 into main 2026-11-12 11:30:10 +08:00
b8799f4804 支持通过98K替换画布扩图上游 2026-11-12 11:25:57 +08:00
d1f9590d05 Merge pull request '避免页面更新打断图片下载' (#76) from codex/preserve-active-image-downloads-20261111 into main 2026-11-11 15:25:55 +08:00
74053490cc 避免页面更新打断图片下载 2026-11-11 15:25:48 +08:00
61b6eba4b4 Merge pull request '显示下载中的图片格式转换进度' (#75) from codex/download-conversion-progress-20261111 into main 2026-11-11 14:59:51 +08:00
bbecafc826 显示下载中的图片格式转换进度 2026-11-11 14:59:46 +08:00
ed3b1b5e8a Merge pull request '画布图片下载自动兼容 Photoshop' (#74) from codex/automatic-photoshop-download-20261111 into main 2026-11-11 14:29:51 +08:00
8f56b5a567 画布图片下载自动兼容 Photoshop 2026-11-11 14:29:42 +08:00
fd8d5404c6 Merge pull request '避免原图下载查询绕过七牛缓存' (#73) from codex/original-download-metadata-cache-20261111 into main 2026-11-11 13:50:43 +08:00
09db16f74c 避免原图下载查询绕过七牛缓存 2026-11-11 13:50:37 +08:00
70621f4261 Merge pull request '加快画布原图下载和 Agent 图片显示' (#72) from codex/original-delivery-speed-20261111 into main 2026-11-11 13:35:47 +08:00
5a95e7da49 加快画布原图下载和 Agent 图片显示 2026-11-11 13:35:37 +08:00
8cf113b554 Merge pull request '减少七牛 CDN 图片预览的重复回源' (#71) from codex/qiniu-cdn-preview-20261111 into main 2026-11-11 11:34:57 +08:00
5fc3f1bb11 减少七牛 CDN 图片预览的重复回源 2026-11-11 11:34:50 +08:00
1342bf384c Merge pull request '修复局部编辑合成图保存并覆盖直传原图' (#70) from codex/qiniu-local-edit-save-20261111 into main 2026-11-11 10:17:46 +08:00
759ee6af58 修复局部编辑合成图保存并覆盖直传原图 2026-11-11 10:17:27 +08:00
54eb35e21c Merge pull request '去水印直接复用已存七牛的图片' (#69) from codex/watermark-qiniu-direct-20261111 into main 2026-11-11 09:31:27 +08:00
f157161ad9 去水印直接复用已存七牛的图片 2026-11-11 09:31:22 +08:00
eb9ee0ce1e Merge pull request '对齐详情替换模型并支持整套图片' (#68) from codex/detail-replacement-full-set into main 2026-11-10 17:02:29 +08:00
2c30c9d7f9 对齐详情替换模型并支持整套图片 2026-11-10 17:02:20 +08:00
8428886a1d Merge pull request '品牌加载后再按需加载风格模板' (#67) from codex/stylization-defer-templates-20261110 into main 2026-11-10 15:17:31 +08:00
24cf77c9c2 品牌加载后再按需加载风格模板 2026-11-10 15:17:24 +08:00
9cbc32bb5d Merge pull request '改善画布扩图四周衔接' (#66) from codex/canvas-outpaint-20261110 into main 2026-11-10 15:12:36 +08:00
7ddaeb12a0 改善画布扩图四周衔接 2026-11-10 15:12:30 +08:00
5f24743bdb Merge pull request '分开加载风格化品牌与模板' (#65) from codex/stylization-load-20261110 into main 2026-11-10 15:04:19 +08:00
03a5613d80 分开加载风格化品牌与模板 2026-11-10 15:04:09 +08:00
6112892b85 Merge pull request '扩图保留原图主体和文字' (#64) from codex/canvas-fibo-outpaint-20261110 into main 2026-11-10 14:18:32 +08:00
8c50c1ce72 扩图保留原图主体和文字 2026-11-10 14:18:21 +08:00
b3c4f2763a Merge pull request '恢复画布文字识别' (#63) from codex/canvas-ocr-and-outpaint-20261110 into main 2026-11-10 13:57:28 +08:00
97d32fd2d3 恢复画布文字识别 2026-11-10 13:57:20 +08:00
4dd6885f1c Merge pull request '保留 16 位 PNG 原图和色彩信息' (#62) from codex/qiniu-rgb16-original-20261110 into main 2026-11-10 11:50:06 +08:00
7b776f96c1 保留 16 位 PNG 原图和色彩信息 2026-11-10 11:50:00 +08:00
17d5f6fec9 Merge pull request '加速 98K 原图归档和画布下载' (#61) from codex/qiniu-direct-image-archive-20261110 into main 2026-11-10 11:42:36 +08:00
b3549f25d7 加速 98K 原图归档和画布下载 2026-11-10 11:42:30 +08:00
74d9368744 Merge pull request '复用画布识图原图并区分提交前后超时' (#60) from codex/canvas-reference-read-timeout into main 2026-11-09 14:13:34 +08:00
e8e8edcefe 复用画布识图原图并区分提交前后超时 2026-11-09 14:13:17 +08:00
57e94b797c Merge pull request '修复 Agent 单次任务超出设定张数' (#59) from codex/canvas-agent-image-count-limit into main 2026-11-07 15:18:46 +08:00
ca6b27624c 修复 Agent 单次任务超出设定张数 2026-11-07 15:18:23 +08:00
fa3210a367 Merge pull request '修复画布保存状态判定和素材上传刷新保护' (#58) from codex/canvas-leave-pending-upload into main 2026-11-06 16:54:43 +08:00
e335821ca8 修复画布保存状态判定及上传刷新保护 2026-11-06 16:54:35 +08:00
c2e8ff6eac Merge pull request '合并页面关闭时的自动保存触发' (#57) from codex/canvas-leave-save-once into main 2026-11-06 16:11:47 +08:00
d26bec9864 合并画布关闭时的保存触发 2026-11-06 16:11:38 +08:00
24a3c930c2 Merge pull request '修复旧浏览器更新提醒并完善画布自动保存与离开保护' (#56) from codex/canvas-background-save-update-notice into main 2026-11-06 16:09:08 +08:00
ebb0d1514b 修复旧浏览器更新提醒及画布离开保存体验 2026-11-06 16:08:58 +08:00
50ffc8d648 Merge pull request '修复图片处理后画布同步冲突及旧草稿恢复' (#55) from codex/canvas-operation-sync-20261106 into main 2026-11-06 13:52:36 +08:00
ff6381d02e 修复图片处理后的画布同步基准和冲突恢复 2026-11-06 13:52:27 +08:00
feaa18ed3b Merge pull request '修复 Agent 对话上滑时被自动拉回底部' (#54) from codex/agent-chat-scroll-20261106 into main 2026-11-06 13:03:14 +08:00
2939ff8a1f 修复 Agent 对话上滑后的自动滚动 2026-11-06 13:03:07 +08:00
5bffeaf94d Merge pull request '修复大 ZIP 上传超时及慢速回退下载' (#53) from codex/zip-cdn-delivery-20261106 into main 2026-11-06 12:10:50 +08:00
e221027b74 修复大 ZIP 七牛交付与断点重试 2026-11-06 12:10:42 +08:00
445d756a19 Merge pull request '修复画布自动同步并添加新版安全刷新提示' (#52) from codex/canvas-auto-sync-20261106 into main 2026-11-06 11:11:34 +08:00
05c3d90708 fix(canvas): synchronize drafts and refresh safely for new releases 2026-11-06 11:10:33 +08:00
cea4cd01f5 Merge pull request '明确区分请求连接中止与等待超时' (#51) from codex/aborted-request-reason-20261106 into main 2026-11-06 09:44:18 +08:00
ea0351bb2c fix(errors): explain aborted and timed out requests without retrying 2026-11-06 09:44:11 +08:00
37406d7d6e Merge pull request '及时结束 Agent 失败步骤并保留合法修复参考图' (#50) from codex/agent-postrelease-errors-20261106 into main 2026-11-06 09:37:04 +08:00
0ce9a95a9c fix(agent): pause failed paid steps and preserve repair references 2026-11-06 09:35:35 +08:00
8bf8e349ca Merge pull request '恢复画布 Agent 默认处理并修正生图终态与预览闪白' (#49) from codex/agent-feedback-recovery-20261105 into main 2026-11-05 18:49:00 +08:00
8039bea86c fix(canvas): restore Agent planning and settle image failures without duplicate generation 2026-11-05 18:48:00 +08:00
aea18a7fc6 Merge pull request '保留框选工具栏与代理命令的既有计费规则' (#48) from codex/box-cutout-billing-paths-20261105 into main 2026-11-05 15:50:23 +08:00
e9921cae98 保留不同框选入口的计费规则 2026-11-05 15:49:44 +08:00
1f5528331d Merge pull request '发布切换等待共享抠图服务完成' (#47) from codex/rmbg-active-job-drain-20261105 into main 2026-11-05 15:36:16 +08:00
535ea1aee4 发布切换等待所有正在执行的抠图任务 2026-11-05 15:35:38 +08:00
8cbc8b1ab8 Merge pull request '框选抠图接入原任务恢复和画布回填' (#46) from codex/box-cutout-task-recovery-20261105 into main 2026-11-05 15:27:35 +08:00
ae9354f747 让框选抠图恢复原任务并回填画布 2026-11-05 15:27:01 +08:00
c68bf9a824 Merge pull request '按实际抠图输入提交并移除重复任务调用' (#45) from codex/rmbg-input-contract-20261105 into main 2026-11-05 14:25:06 +08:00
21b47751d6 抠图按实际输入提交并避免预处理重复创建任务 2026-11-05 14:24:32 +08:00
c219f5b10b Merge pull request '保留画布后台查询期间的本地编辑' (#44) from codex/canvas-refresh-snapshot-20261105 into main 2026-11-05 14:04:02 +08:00
672c1368e0 在画布版本查询前记录本地编辑状态 2026-11-05 14:03:39 +08:00
c60585aeca Merge pull request '加载画布内容后同步版本并保留本地编辑' (#43) from codex/canvas-read-revision-20261105 into main 2026-11-05 13:59:44 +08:00
2ee8bd4dea 画布读取与加载同步版本并保留加载期间的编辑 2026-11-05 13:59:08 +08:00
85ff680b65 Merge pull request '按 AOS 抠图规则校验原图并返回明确错误' (#42) from codex/rmbg-provider-limits-20261105 into main 2026-11-05 13:23:59 +08:00
751cffae05 抠图输入超限返回明确的请求错误状态 2026-11-05 13:23:23 +08:00
6e4350fe2b 按上游抠图限制校验原图并返回具体失败原因 2026-11-05 13:22:03 +08:00
3f52e24076 Merge pull request '抠图取结果中断时重试原任务读取' (#41) from codex/rmbg-result-recovery-20261105 into main 2026-11-05 12:04:22 +08:00
0b74e71a33 Retry background removal result reads without recreating tasks 2026-11-05 12:04:11 +08:00
4f8c1a5d5d Merge pull request '减少抠图大图上传并保留超时原因' (#40) from codex/rmbg-large-image-timeout-20261105 into main 2026-11-05 11:33:30 +08:00
33662210dd Use archived source URLs for background removal and preserve timeout causes 2026-11-05 11:33:20 +08:00
71413c173e Merge pull request '修复阿里模型结果预览 403 和重复读取大图' (#39) from codex/alibaba-image-preview-20261105 into main 2026-11-05 11:03:39 +08:00
dbe96db34d Serve model result previews through shared image bytes and archived copies 2026-11-05 11:03:32 +08:00
dfaeb9f09d Merge pull request '图片预览即时显示并按屏幕加载高清' (#38) from codex/image-preview-latency-20261105 into main 2026-11-05 10:53:56 +08:00
46abb8259a Show cached images immediately while fullscreen previews load 2026-11-05 10:53:48 +08:00
3b3b5bc78a Merge pull request '历史图片回退保持归档入口和正确媒体类型' (#37) from codex/legacy-image-fallback-20261105 into main 2026-11-05 09:57:48 +08:00
db63f38b9c 历史图片回退保持归档入口并正确识别图片元数据 2026-11-05 09:57:38 +08:00
955ad91ae8 Merge pull request '旧图片预览保留七牛归档入口' (#36) from codex/legacy-preview-source-20261105 into main 2026-11-05 09:50:20 +08:00
36ac3def8f 旧图片预览保持归档入口并停止回退失效源 2026-11-05 09:49:20 +08:00
1d40053c7a Merge pull request '旧阿里存储通过七牛归档副本提供访问' (#35) from codex/legacy-oss-qiniu-recovery-20261105 into main 2026-11-05 09:33:15 +08:00
07451f5558 旧阿里存储链接优先使用七牛副本并停止失效源重试 2026-11-05 09:32:27 +08:00
13684505a6 Merge pull request '失效图片保持容器宽度和下载入口' (#34) from codex/image-preview-error-width-20261104 into main 2026-11-04 20:18:44 +08:00
65b235e747 保持失效图片预览容器宽度 2026-11-04 20:18:38 +08:00
d1ee6d53d5 Merge pull request '失效图片预览保留下载按钮' (#33) from codex/image-preview-download-placeholder-20261104 into main 2026-11-04 20:05:19 +08:00
a484942fb9 失效图片预览保留原图下载入口 2026-11-04 20:04:43 +08:00
084f538e77 Merge pull request '画册同一原图复用稳定下载文件名' (#32) from codex/gallery-download-cache-key-20261104 into main 2026-11-04 20:00:14 +08:00
5250aed7b7 画册原图下载复用稳定文件名 2026-11-04 20:00:07 +08:00
cbec13b285 Merge pull request '普通聊天和画册原图使用 CDN 下载并保留原格式' (#31) from codex/original-download-routing-20261104 into main 2026-11-04 19:43:15 +08:00
5fda42495a 修复普通聊天与画册原图下载中转 2026-11-04 19:42:37 +08:00
73eb276e7f Merge pull request '发布切换等待正在准备的原图下载' (#30) from codex/download-release-drain-20261104 into main 2026-11-04 18:06:34 +08:00
8c7ca174bc 发布切换等待正在准备的原图下载 2026-11-04 18:06:27 +08:00
8fd0dba59f Merge pull request '修复阿里云参考图拉取失败及异步错误原因丢失' (#29) from codex/alibaba-reference-error-20261104 into main 2026-11-04 17:55:27 +08:00
ce94241234 修复阿里云参考图传输及异步任务错误原因 2026-11-04 17:55:19 +08:00
198deca47b Merge pull request '修复图片下载排队、中转速度和错误反馈' (#28) from codex/image-download-reliability-20261104 into main 2026-11-04 17:36:17 +08:00
8908b0d39a 修复图片下载准备与 CDN 交付,细分失败原因和建议 2026-11-04 17:35:34 +08:00
34ba1919c4 Merge pull request '完善图片工具通用失败的核对建议' (#27) from codex/image-tool-failure-advice-20261104 into main 2026-11-04 14:58:11 +08:00
2fdc4299e0 完善通用图片处理失败的原因与核对建议 2026-11-04 14:57:45 +08:00
ac2369b657 Merge pull request '修复平台任务恢复、归档查询限流和失败原因返回' (#26) from codex/platform-failure-recovery-20261104 into main 2026-11-04 14:46:07 +08:00
6642c39198 修复画布任务重复执行并明确失败原因与处理建议 2026-11-04 14:43:28 +08:00
ac7928fe2d 恢复同步生图在网关超时后的原请求结果
同步生图超过边缘等待期限时,生成仍可能在源站继续执行。实际记录中,Cloudflare 在 125.006 秒断开,原请求在约 172 秒完成;应用已经显示 524,成功图片没有送达。原结果 GET 和原图片只读验证均返回 200。

同步图片生成/编辑收到 502、504、524 或提交响应丢失后,现在使用原 Key 和原 Idempotency-Key 查询既有请求。处理中继续查询,成功后 GET 同一请求的保留结果,进入原有图片归档与展示流程。查询复用已有轮询间隔和期限;缺失、失败、未确认、过期或未保留结果不会变成新的生成提交。普通成功响应保持直接返回,原生任务沿用现有协议。ProviderTaskUncertainError 也不触发自动模型切换。

验证:645 项辅助测试通过、1 项原有跳过,包含 524 后处理中到成功取回并进入展示流程、提交响应丢失、查询暂时失败、结果 GET 暂时失败、过期/缺失结果、轮询期限及一次 POST。TypeScript、改动文件 lint、日志脱敏、凭据、测试与制品检查通过。生产构建与上线验收另行记录。

已知问题:全量 lint 有 1004 项历史诊断。Cloudflare 本身的读取期限保持;本次通过取回已受理请求的原结果完成交付,不提高边缘期限。
2026-11-04 11:10:46 +08:00
b06fdbb38b 修复聊天消息加载占位的网关错误展示
画布聊天首次加载 Markdown 组件时,占位组件直接显示原始消息。历史记录包含 Cloudflare 错误页面时,会短暂显示整页 HTML。现在占位组件复用网关错误正文归一化,加载期间和完成后均显示可读提示。普通内容保持原有展示。

验证:TypeScript、改动文件 lint、凭据检查与差异检查通过。网关识别、524 不自动切换模型及供应商只提交一次的测试已包含在 PR #23 的 637 项通过测试中。本次仅修改加载占位组件,生产构建及慢速加载回放验收另行记录。

已知问题:仓库全量 lint 存在 1004 项历史诊断。
2026-11-04 10:43:22 +08:00
61e2bbe0e4 修复粒子动画加载与生图 524 错误展示
风格化粒子动画依赖外部 CDN 的 Three.js,全站同域脚本策略会阻止其加载,导致 THREE 未定义。现在使用同域 ES module,保留原 Three.js 0.160.0、动画参数及 MIT 许可。

扶苏在 2026-11-04 09:44 收到的 gpt-image-2.5 HTTP 524 响应是 Cloudflare HTML 页面,错误正文被直接保存和显示。现在将这类响应显示为结果未确认的超时提示;已有历史消息在渲染时同样归一化,不改动原始记录。524 不触发自动模型切换,避免原任务结果未确认时再次生成。普通 HTML 文档和代码示例保持原样。

验证:原生产 CSP 下的本地浏览器验证粒子有可见像素、连续帧变化、窗口缩放及无外部请求、无脚本错误;637 项辅助测试通过、1 项原有跳过,包含供应商 524 只提交一次、结果未确认、不自动切换及正常 503 行为。TypeScript、改动文件 lint、凭据、日志脱敏、环境、元数据、测试与制品检查通过。

已知问题:全量 lint 仍有 1004 项历史诊断。524 的根因是上游长请求超时,本次修复错误展示与未确认结果的处理,不承诺消除线路侧偶发超时。
2026-11-04 10:26:01 +08:00
70f954382a 减轻风格化入口加载并防止新建项目超时重复创建
风格化入口并发下载完整配置时,新建项目的响应可能超过前端超时限制,重复点击会产生多个空项目。现在未选项目时只加载轻量配置,进入项目后再加载完整风格配置;配置未变化时按更新时间返回 304,复用已加载数据。配置下载超时覆盖响应正文读取,完整配置保留 30 秒读取时间。

新建请求使用稳定的项目编号,数据库按主键去重;丢失响应后按编号核对结果,尚未确认时保留编号供重试和刷新恢复。已有项目的内容与账号归属检查保持有效。

验证:8 项专项测试通过,辅助测试 633 项通过、1 项原有跳过,TypeScript、改动文件 lint、日志脱敏、凭据、环境、元数据、测试和制品检查通过。全量 lint 仍有 1005 项历史诊断;发布构建和线上验收另行核对。
2026-11-04 09:46:45 +08:00
00cef71be7 暂停欠费生图线路并修正 98K 文生图尺寸传递
## Summary

生图线路确认欠费后立即停止新请求,跳过同一上游账户的其他模型,保留已生成结果和已提交任务。欠费状态须经管理员恢复,不再定时自动解封,也不会被旧任务的成功回执清除。旧模型选择仅使用明确配置且尺寸、比例和参考图数量兼容的备用模型;固定模型模式仍要求用户自行选择。

98K 文生图优先使用其公开声明的任务接口,原生传递分辨率和比例,清除文生图请求中的示例参考图片。

## Scope

- [x] API / backend logic
- [x] Settings / admin configuration

## Required Verification

验证普通 Pro、Banana 2、两个 Plus 和 GPT Image 2 共 10 个真实文生图及参考图编辑用例,均获得可下载的 2048×2048 图片。625 项 helper 回归通过,1 项跳过;TypeScript、日志/品牌/CI/环境/凭据/元数据/测试/产物检查通过。全仓 Biome 仍有历史格式和 lint 诊断(1005 项)。构建机执行生产构建及路由烟测;生产切换前核对任务与请求,保留旧构建和模型配置回滚记录。

## Release Readiness

仅修改本次生图运行逻辑与测试,凭据和验证产物保存在仓库外或忽略目录中。内部生成接口的权限与访问方式保持兼容。
2026-11-03 19:20:18 +08:00
525e105876 整理上游搜索与编辑入口
上游搜索位于管理列表顶部,编辑弹窗仅保留配置字段;编辑和删除按钮补充可访问名称与悬停说明。保留已有上游预设、同步及编辑功能。

验证:TypeScript 与页面 lint 通过,发布后验收上游搜索和编辑弹窗。
2026-11-03 18:09:45 +08:00
5371ae2617 关联记录模型名称并处理预览下载超时
生成记录同时包含模型内部 ID 和上游模型 ID,名称显示和筛选统一关联模型目录。图片代理处理源响应体超时与传输中断,返回明确的 504/502;浏览器取消请求同步停止上游下载。小尺寸预览允许 45 秒读取窗口,前端保留 4 路并发和可见区域加载,失败提示展示接口原因。原图下载保持字节不变。

验证:模型 ID 筛选的生产只读查询返回正确记录,浏览器夹具验证内部 ID 名称和峰值并发 4;15 项相关测试覆盖取消、响应体超时、原图字节与 ICC 保留。TypeScript 与改动文件 lint 通过。
2026-11-03 18:04:14 +08:00
af8189908d 优化后台生成记录分页与模型管理
生成记录改为数据库分页,每页 40 条,默认最近 7 天;可见预览限制为 4 路并发并使用 192px 缩略图,翻页取消旧请求,视频在详情中按需播放。保留精选、分类和原图操作,新增画布任务失败记录,统一时间显示。

模型管理拆分模型、上游和运行健康视图,提供搜索、筛选、分页和完整故障详情;列表使用摘要,编辑按需获取完整配置。后台按当前设置页加载,模型 GET 不再自动修改健康状态,98K 同步先显示变更预览。

验证:600 项现有辅助测试和 3 项新增回归测试通过,TypeScript 和本次 API/组件 lint 通过;浏览器验证请求峰值 4、翻页、搜索、失败重试、详情、模型筛选与 390px 窄屏。只读生产查询:当天页约 29ms,历史视频页 122ms,第 7800 页 739ms。两个并发索引已在生产建成,SQL 随代码保留。

已知检查状态:全仓 lint 存量 1004 项诊断使 check:quality 停在 lint 阶段;日志、品牌、CI、环境、凭据、元数据、测试与制品检查单独通过。
2026-11-03 17:41:34 +08:00
6053271352 同步封面和标题更新后的画布版本
自动封面和标题更新会推进画布版本,但页面未同步,导致后续节点保存持续冲突。元数据写入返回前一版本,客户端仅在与本地版本衔接时推进版本,并与画布保存共用队列;处理结果在合并成功后才更新本地版本。视频画布导入也按节点保存、标题更新顺序执行。

验证:17 项画布能力与同步测试通过,包含封面更新后的保存、不覆盖未同步的远端改动、结果合并冲突和切换画布;TypeScript 检查通过。
2026-11-03 14:39:48 +08:00
6b7b24a86e 修复图片工具提交前的画布同步与结果合并
新导入或新生成的图片在自动保存前点击去水印,会因服务端缺少节点而失败。工具栏统一先保存当前画布,保存成功后再调用处理能力;处理与自动保存共用队列,结果合并保留等待期间的本地编辑并更新保存版本。适用于去水印、抠图、增强、扩图和图层拆分。

验证:14 项画布能力与提交同步测试通过,覆盖保存失败不提交、执行期间编辑保留、上游失败和切换画布;TypeScript 检查通过。
2026-11-03 14:23:59 +08:00
c431a574c8 避免同名模型导致精选图片重复和计数偏差
同一 modelId 配置多个上游时,精选接口的模型关联会把一张图片返回多次,导致数量和分页偏大。改为为每张图片选择一个模型名称,保证图片记录和总数一致。

验证:生产只读数据包含同名模型两条,修正查询返回 6 个不同图片 ID、总数 6;TypeScript 检查通过。
2026-11-03 13:51:58 +08:00
1daf08a964 停用旧图片存储并更新主题 Logo
s3.1080p.hk 已永久停用且文件不可恢复。首页查询排除旧源并统一分页统计,历史图册、聊天和画布保留记录并显示失效说明;下载和代理返回 410,停止旧源上传与归档。使用本地黑白 Logo 适配主题。

提供保存原配置后停用旧存储、取消失效精选、替换 Logo 的整理脚本。七牛 10 月 14 日上传核查记录见 docs/retired-storage.md。

验证:7 项旧存储检查、41 项下载和归档回归测试、TypeScript 检查通过。
2026-11-03 13:36:44 +08:00
348cf4cf5d 按已锁定 SDK 的完成回调结束风格化等待
风格化请求 6 张图片时,98K 接入仍提交 n=1 且仅保留第一张结果,页面继续等待剩余图片。现在传递实际张数、保留全部返回结果,请求结束数量不足时结束等待并提示;刷新后也能从历史结果恢复结束状态。阿里通道参考图改为并行准备。

验证:46 项针对性测试通过,包括实际 Provider 流程提交一次并交付 6 张;TypeScript 检查通过。
2026-11-03 13:16:08 +08:00
a43a51adfc 保留连接中断时的生图恢复状态
风格化请求 6 张图片时,98K 接入仍提交 n=1 且仅保留第一张结果,页面继续等待剩余图片。现在传递实际张数、保留全部返回结果,请求结束数量不足时结束等待并提示;刷新后也能从历史结果恢复结束状态。阿里通道参考图改为并行准备。

验证:46 项针对性测试通过,包括实际 Provider 流程提交一次并交付 6 张;TypeScript 检查通过。
2026-11-03 13:11:37 +08:00
6ba5d9606d 修复风格化多张生成和结束状态
风格化请求 6 张图片时,98K 接入仍提交 n=1 且仅保留第一张结果,页面继续等待剩余图片。现在传递实际张数、保留全部返回结果,请求结束数量不足时结束等待并提示;刷新后也能从历史结果恢复结束状态。阿里通道参考图改为并行准备。

验证:46 项针对性测试通过,包括实际 Provider 流程提交一次并交付 6 张;TypeScript 检查通过。
2026-11-03 13:08:33 +08:00
02b9575a47 Merge pull request 'fix(home): deduplicate featured image cards' (#10) from codex/home-image-recovery into main 2026-11-03 09:55:56 +08:00
349735601c fix(home): deduplicate featured image cards 2026-11-03 09:55:32 +08:00
e837788d7a Merge pull request '恢复首页旧备用存储域名的可用缩略图' (#9) from codex/home-image-recovery into main 2026-11-03 09:27:39 +08:00
Codex
dfab95a654 Recover home previews through the documented storage alias 2026-11-03 09:26:31 +08:00
c35f5fc5a8 Merge pull request '修复首页失效封面与 Logo 的破图显示' (#8) from codex/home-image-recovery into main 2026-11-03 09:18:33 +08:00
Codex
e6c6e19e5a Handle unavailable home gallery images and brand logo 2026-11-03 09:17:59 +08:00
bb9d02a053 Merge pull request '修复结果文件链接加载时闪现字符' (#7) from codex/sku-stream-links into main 2026-11-02 19:10:55 +08:00
200 changed files with 10857 additions and 3387 deletions

View File

@ -2377,8 +2377,10 @@ export function LocalAiImagePipelineSections({
items={[
{
label:
extendConfig.provider === "vip98k"
? "VIP98K 服务商"
extendConfig.provider === "fal"
? "fal · FLUX 2 Pro 扩图"
: extendConfig.provider === "vip98k"
? `98K · ${extendConfig.modelId || "image-expansion"}`
: "阿里云服务商",
tone: "enabled",
},
@ -2391,9 +2393,13 @@ export function LocalAiImagePipelineSections({
}
title="图像拓展API配置"
>
{extendConfig.provider === "vip98k" ? (
{extendConfig.provider === "fal" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
使用 FLUX 2 Pro Outpaint 扩展图片。密钥从已启用的 fal 官方服务商读取。
</div>
) : extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 98K 的 {extendConfig.modelId || "image-expansion"} 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。
</div>
) : (

View File

@ -274,7 +274,9 @@ export type EnhanceConfig = {
};
export type ExtendConfig = {
provider: "vip98k" | "dashscope";
provider: "vip98k" | "dashscope" | "fal";
modelId: string;
routeOptionId: string;
xScale: string;
yScale: string;
};

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,8 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
normalizeDetailReplacementImageModelConfig,
findConfiguredDetailReplacementImageModel,
isDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config";
import {
compositeReplacementWithReferenceLocks,
@ -29,7 +30,6 @@ import {
} from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import { bufferToArrayBuffer } from "./persistence";
import { isVideoProviderType } from "./video-models";
import type { AgentPresetMessageImagePart } from "./agent-scene-workflow";
import type { PostRequestBody } from "../schema";
@ -2161,22 +2161,10 @@ export async function resolveDirectReplacementModelRef(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) {
const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const configuredModelSetting = await getSystemSetting({
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
}).catch(() => null);
const configuredModelId = normalizeDetailReplacementImageModelConfig(
configuredModelSetting?.value,
).modelId;
const configuredModel = imageModels.find(
({ model }) =>
model.id === configuredModelId || model.modelId === configuredModelId,
);
const configuredModel = findConfiguredDetailReplacementImageModel(models, configuredModelSetting?.value);
if (configuredModel) {
return configuredModel.model.id;
}
@ -2190,6 +2178,7 @@ export async function resolveDirectReplacementModelRef(params: {
return routedModel;
}
const imageModels = models.filter(isDetailReplacementImageModel);
const preferred =
imageModels.find(({ model }) => model.isDefault) ||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||

View File

@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media";
import { isProviderBalanceError } from "@/lib/request-error";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
const chatApiLogger = createLogger("chat.imageFailover");
@ -326,7 +326,7 @@ export function buildImageFallbackCandidates(
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false;
}
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
if (isImageFallbackModelCoolingDown(item.model.id) || isImageModelBalanceBlocked(item.model)) {
const cooldown = imageFailoverModelCooldown.get(item.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false;
@ -488,7 +488,7 @@ export function getImageCrossProviderCandidates(
}
if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
if (isImageFallbackModelCoolingDown(m.model.id) || isImageModelBalanceBlocked(m.model)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false;

View File

@ -0,0 +1,47 @@
import { saveMessages } from "@/lib/db/queries";
import type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
import { safeWriteStreamChunk, type StreamWriter, writeAssistantTextChunk, writeStructuredDataChunk } from "./stream";
export async function reportImageGenerationFailure(params: {
writer: StreamWriter;
chatId: string;
userMessageId: string;
messageId: string;
error: unknown;
label: string;
}) {
const failure = getRequestFailureDetails(params.error, "图片生成失败");
const status: ImageGenerationStatusStreamPayload = {
phase: "failed",
userMessageId: params.userMessageId,
requestId: params.messageId,
failure,
};
const text = formatRequestFailure(failure, params.messageId);
const parts = [
{ type: "text", text },
{ type: "data-imageGenerationStatus", data: status },
];
writeStructuredDataChunk(params.writer, "data-imageGenerationStatus", status, `${params.label}:status`);
safeWriteStreamChunk(params.writer, {
type: "data-appendMessage",
data: JSON.stringify({ id: params.messageId, role: "assistant", parts }),
}, params.label);
writeAssistantTextChunk(params.writer, "<!-- IMAGE_GENERATION_END -->", `${params.label}:end`);
// History carries the same terminal state when the browser loses the stream.
await saveMessages({ messages: [{
id: params.messageId,
chatId: params.chatId,
role: "assistant",
parts,
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
}] });
return text;
}

View File

@ -208,6 +208,7 @@ export function writeImageGenerationStatusChunk(
writer: StreamWriter,
params: {
phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
userMessageId?: string;
providerType?: string | null;
modelName?: string | null;
imageCount?: number | null;
@ -223,6 +224,7 @@ export function writeImageGenerationStatusChunk(
"data-imageGenerationStatus",
{
phase: params.phase,
userMessageId: params.userMessageId,
providerType: normalizeImageLoadingProviderType(params.providerType),
modelName: String(params.modelName || "").trim() || undefined,
imageCount:

View File

@ -1,10 +1,11 @@
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work";
import { geolocation } from "@vercel/functions";
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStream as createAiUIMessageStream,
generateText,
smoothStream,
stepCountIs,
@ -336,6 +337,7 @@ import {
} from "./_lib/image-media";
import {
buildImageFallbackCandidates,
countChatMessageImageInputs,
generateImageWithFailoverCandidate,
getImageCrossProviderCandidates,
IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
@ -344,6 +346,8 @@ import {
setImageFallbackModelCooldown,
shouldCooldownImageFallbackModel,
} from "./_lib/image-failover";
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { isProviderBalanceError } from "@/lib/request-error";
import {
buildAgentSceneWorkflowPrompt,
buildAgentSlotPrompt,
@ -422,7 +426,11 @@ import {
type VideoModeType,
} from "./_lib/video-models";
import { reportImageGenerationFailure } from "./_lib/image-generation-failure";
const chatApiLogger = createLogger("chat.route");
const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
@ -440,16 +448,6 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
}
function getPublicImageGenerationErrorText(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
return "图片生成失败,请稍后重试";
}
return publicMessage;
}
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
1,
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
@ -5682,6 +5680,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "processing",
modelName: resolvedModelRef,
imageCount: replacementReferences.length,
@ -6024,6 +6023,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase:
queueStatus.status === "running"
? "processing"
@ -6179,6 +6179,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: successCount > 0 ? "completed" : "failed",
modelName: resolvedModelDisplayName,
imageCount: successCount,
@ -6411,6 +6412,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "processing",
modelName:
selectedAgentPreset.defaultModel?.displayName ||
@ -6962,6 +6964,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase:
queueStatus.status === "running"
? "processing"
@ -7287,6 +7290,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase:
queueStatus.status === "running"
? "processing"
@ -7515,6 +7519,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase:
queueStatus.status === "running"
? "processing"
@ -7833,6 +7838,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: successCount > 0 ? "completed" : "failed",
modelName:
selectedAgentPreset.defaultModel?.displayName ||
@ -8042,6 +8048,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "processing",
providerType: requestedModelProviderType,
modelName: finalModelId,
@ -8080,6 +8087,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "processing",
...directImageStatusPayload,
},
@ -8172,6 +8180,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "completed",
providerType: requestedModelProviderType,
modelName: directImageResult.model,
@ -8192,42 +8201,16 @@ export async function POST(request: Request) {
);
} catch (directToolError) {
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
const errorMessage = getDirectAgentToolErrorMessage(
"generate_image",
directToolError,
);
await saveAssistantTextOnlyMessage({
chatId: id,
messageId: assistantMessageId,
text: errorMessage,
const errorMessage = await reportImageGenerationFailure({
writer, chatId: id, userMessageId: message.id,
messageId: assistantMessageId, error: directToolError,
label: "agent:generate_image:error",
});
writeAssistantTextChunk(
writer,
errorMessage,
"agent:generate_image:error",
assistantMessageId,
);
if (contextPayload?.source === "canvas") {
writeImageGenerationStatusChunk(
writer,
{
phase: "failed",
providerType: requestedModelProviderType,
modelName: finalModelId,
},
"agent:generate_image:status-failed",
);
}
writeAgentProgressChunk(
writer,
buildDirectAgentImageGenerateFailedProgress(errorMessage),
"agent:generate_image:progress-failed",
);
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"agent:generate_image:end-error",
);
} finally {
stopDirectImageKeepalive();
if (stopDirectImageStatus) {
@ -8769,6 +8752,19 @@ export async function POST(request: Request) {
}
}
if (/^[0-9a-f-]{36}$/i.test(finalModelId)) {
const requested = await getAiModelById({ id: finalModelId });
if (requested?.supportsImageGen && isImageModelBalanceBlocked(requested)) {
const replacement = resolveConfiguredImageBalanceFallback(requested, await getEnabledAiModelsWithProvider(), {
imageSize: effectiveImageGenerationParams.imageSize,
aspectRatio: effectiveImageGenerationParams.aspectRatio,
referenceImageCount: countChatMessageImageInputs(uiMessagesForModel.filter(item => item.role === "user").slice(-1)),
});
if (!replacement) return new ChatSDKError("bad_request:api", "当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。").toResponse();
finalModelId = replacement.model.id;
}
}
// 检查是否是 UUID 格式(数据库中的模型 ID)
const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
@ -9176,6 +9172,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "processing",
providerType: (languageModel as any).provider,
modelName: currentModelConfig?.displayName || actualModelId,
@ -9263,6 +9260,7 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
});
chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
}
}
@ -9337,6 +9335,7 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError);
});
chatApiLogger.error(`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
}
}
}
@ -9404,6 +9403,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "completed",
providerType: (languageModel as any).provider,
modelName: usedModelDisplayName,
@ -9468,6 +9468,14 @@ export async function POST(request: Request) {
}
} catch (error) {
chatApiLogger.error("[Chat API] Gemini generation error:", error);
let finalError = error;
if (!shouldFailoverAiModelError(error)) {
await reportImageGenerationFailure({
writer, chatId: id, userMessageId: message.id,
messageId: generateUUID(), error, label: "gemini:error",
});
return;
}
chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision...");
// 故障转移:尝试使用火山引擎模型
@ -9545,6 +9553,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "completed",
providerType: "volcano-vision",
modelName: volcanoModel.model.displayName || volcanoModel.model.modelId,
@ -9610,46 +9619,14 @@ export async function POST(request: Request) {
chatApiLogger.info("[Chat API] No Volcano Vision model available for failover");
} catch (failoverError) {
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
finalError = failoverError;
}
// 如果故障转移也失败,返回错误消息
const errorMessageId = generateUUID();
writeAssistantTextChunk(
writer,
"图片生成失败,请稍后重试",
"gemini:error",
errorMessageId,
);
// 🆕 图片生成结束标记(失败)
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"gemini:image-end-failed",
);
// 尝试保存错误消息
try {
await saveMessages({
messages: [
{
id: errorMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
} catch (saveError) {
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
}
await reportImageGenerationFailure({
writer, chatId: id, userMessageId: message.id,
messageId: generateUUID(), error: finalError, label: "gemini:error",
});
} finally {
stopKeepalive?.();
}
@ -9847,6 +9824,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "queued",
providerType: "fal-image",
modelName: modelDisplayName,
@ -9953,6 +9931,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "processing",
providerType: (languageModel as any).provider,
modelName: alibabaModelConfig?.displayName || actualModelId,
@ -10002,7 +9981,10 @@ export async function POST(request: Request) {
let fallbackSuccess = false;
const triedFallbackModelIds = new Set<string>();
const balanceBlockedProviders = new Set<string>();
if (isProviderBalanceError(primaryError) && currentModelEntry?.provider.id) balanceBlockedProviders.add(currentModelEntry.provider.id);
for (const candidate of fallbackCandidates) {
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
try {
triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
@ -10025,6 +10007,7 @@ export async function POST(request: Request) {
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
break;
} catch (fallbackError) {
if (isProviderBalanceError(fallbackError)) balanceBlockedProviders.add(candidate.provider.id);
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
@ -10046,6 +10029,7 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError);
});
chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
}
}
@ -10073,9 +10057,10 @@ export async function POST(request: Request) {
currentModelKey: actualModelId,
currentProviderId: alibabaModelConfig?.providerId,
providerPriority,
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")) && !balanceBlockedProviders.has(candidate.provider.id));
for (const candidate of crossProviderCandidates) {
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
try {
triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
@ -10157,6 +10142,7 @@ export async function POST(request: Request) {
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
break;
} catch (crossError) {
if (isProviderBalanceError(crossError)) balanceBlockedProviders.add(candidate.provider.id);
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
@ -10178,6 +10164,7 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError);
});
chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError);
if (!shouldFailoverAiModelError(crossError)) throw crossError;
}
}
}
@ -10245,6 +10232,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "completed",
providerType: (languageModel as any).provider,
modelName: usedModelDisplayName,
@ -10309,43 +10297,10 @@ export async function POST(request: Request) {
chatApiLogger.error("[Chat API] Alibaba generation error:", error);
// 返回错误消息
const errorMessageId = generateUUID();
const errorText = getPublicImageGenerationErrorText(error);
writeAssistantTextChunk(
writer,
errorText,
"alibaba:error",
errorMessageId,
);
// 🆕 图片生成结束标记(失败)
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"alibaba:image-end-failed",
);
try {
await saveMessages({
messages: [
{
id: errorMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: errorText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
} catch (saveError) {
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
}
await reportImageGenerationFailure({
writer, chatId: id, userMessageId: message.id,
messageId: generateUUID(), error, label: "alibaba:error",
});
} finally {
stopKeepalive?.();
}
@ -10474,6 +10429,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "processing",
providerType: "volcano-vision",
modelName: volcanoModelConfig?.displayName || actualModelId,
@ -10519,6 +10475,7 @@ export async function POST(request: Request) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
}
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
}
}
if (!fallbackSuccess) {
@ -10685,6 +10642,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
writer,
{
userMessageId: message.id,
phase: "completed",
providerType: "volcano-vision",
modelName: usedModelDisplayName,
@ -10749,43 +10707,10 @@ export async function POST(request: Request) {
}
} catch (error) {
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
const errorMessageId = generateUUID();
writeAssistantTextChunk(
writer,
"图片生成失败,请稍后重试",
"volcano:error",
errorMessageId,
);
// 🆕 图片生成结束标记(失败)
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"volcano:image-end-failed",
);
// 尝试保存错误消息
try {
await saveMessages({
messages: [
{
id: errorMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
attachments: [], // 必须提供 attachments 字段
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
} catch (saveError) {
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
}
await reportImageGenerationFailure({
writer, chatId: id, userMessageId: message.id,
messageId: generateUUID(), error, label: "volcano:error",
});
} finally {
stopKeepalive?.();
}
@ -11358,6 +11283,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter,
{
userMessageId: message.id,
phase: "processing",
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
modelName: currentModelConfig?.displayName || actualModelId,
@ -11449,6 +11375,7 @@ export async function POST(request: Request) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
}
chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
}
}
@ -11620,6 +11547,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter,
{
userMessageId: message.id,
phase: "completed",
providerType: resolvedProviderType || providerType,
modelName: usedModelDisplayName,
@ -11679,54 +11607,10 @@ export async function POST(request: Request) {
} catch (error) {
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
const errorMessageId = generateUUID();
const errorText = getPublicImageGenerationErrorText(error);
dataStream.write({
type: "data-appendMessage",
data: JSON.stringify({
id: errorMessageId,
role: "assistant",
parts: [{ type: "text", text: errorText }],
}),
});
dataStream.write({
type: "data-appendMessage",
data: JSON.stringify({
id: generateUUID(),
role: "assistant",
parts: [{ type: "text", text: "<!-- IMAGE_GENERATION_END -->" }],
}),
});
if (contextPayload?.source === "canvas") {
writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter,
{
phase: "failed",
providerType: resolvedProviderType || providerType,
modelName: currentModelConfig?.displayName || actualModelId,
imageCount: effectiveImageParams?.imageCount || 1,
},
"openai-compat:image-status-failed",
);
}
await saveMessages({
messages: [
{
id: errorMessageId,
role: "assistant",
parts: [{ type: "text", text: errorText }],
createdAt: new Date(),
attachments: [],
chatId: id,
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
await reportImageGenerationFailure({
writer: dataStream as unknown as StreamWriter,
chatId: id, userMessageId: message.id,
messageId: generateUUID(), error, label: "openai-compat:error",
});
return;
}

View File

@ -10,6 +10,10 @@ import {
} from "@/lib/db/queries";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
findConfiguredDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
@ -157,10 +161,17 @@ export async function GET(request: Request) {
: await getEnabledAiModelsWithProvider(false, true);
const agentModelWithProvider = await getAgentAiModelWithProvider();
const defaultModel = await getDefaultAiModel();
const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([
const [siteSettings, agentWorkflowUiConfigSetting, detailReplacementModelSetting] = await Promise.all([
getSiteSettings(),
getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null),
getSystemSetting({ key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY }).catch(() => null),
]);
const detailReplacementModel = findConfiguredDetailReplacementImageModel(
includeUnavailable ? modelsWithProvider.filter(({ model, provider }) =>
provider.isEnabled && model.isEnabled && getAiModelFrontendAvailability(model).selectable !== false
) : modelsWithProvider,
detailReplacementModelSetting?.value,
);
type ModelWithProvider = (typeof modelsWithProvider)[number];
const serializeModel = ({ model, provider }: ModelWithProvider) => ({
id: model.id,
@ -309,6 +320,7 @@ export async function GET(request: Request) {
: null,
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
agentWorkflowConfiguredModels,
detailReplacementModel: detailReplacementModel ? serializeModel(detailReplacementModel) : null,
agentModel: agentModelWithProvider
? {
id: agentModelWithProvider.model.id,

View File

@ -8,6 +8,8 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
import { Input } from "@/components/ui/input";
import Image from "next/image";
import { formatBeijingTime } from "@/lib/utils";
import { toast } from "sonner";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
type FeaturedImage = {
id: string;
@ -74,18 +76,11 @@ export default function FeaturedPage() {
const handleDownload = async (url: string, prompt: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = `featured-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
await downloadOriginalImage({ url, name: "featured-image" });
toast.success("原图已开始下载");
} catch (error) {
console.error("Download failed:", error);
toast.error(getCanvasImageDownloadErrorMessage(error));
}
};

View File

@ -1,4 +1,5 @@
"use client";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
@ -12,6 +13,7 @@ import { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatBeijingTime } from "@/lib/utils";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
type GalleryImage = {
id: string;
@ -58,7 +60,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
}
}
return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`;
}
export default function GalleryPage() {
@ -118,25 +120,11 @@ export default function GalleryPage() {
const parsed = new URL(originalUrl, window.location.origin);
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
const response = await fetch(
buildImageProxyUrl(source, { delivery: "original" })
);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
toast.success("图片已开始下载");
} catch (error) {
console.error("Download failed:", error);
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
toast.error(getCanvasImageDownloadErrorMessage(error));
}
};
@ -316,7 +304,12 @@ export default function GalleryPage() {
<>
<div className="flex-1 overflow-y-auto">
<div className="flex flex-wrap justify-center gap-6 px-4">
{images.map((img) => (
{images.map((img) => isRetiredStorageUrl(img.url) ? (
<div key={img.id} className="flex h-[270px] w-[200px] shrink-0 flex-col justify-center rounded-lg border bg-muted p-4 text-center text-sm text-muted-foreground">
<p>{RETIRED_STORAGE_MESSAGE}</p>
<p className="mt-3 line-clamp-3 text-xs">{img.alt}</p>
</div>
) : (
<div
key={img.id}
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
@ -325,7 +318,7 @@ export default function GalleryPage() {
>
<div className="relative w-full h-full">
<Image
src={img.thumbnailUrl || img.url}
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
alt={img.alt}
width={200}
height={270}

View File

@ -1,8 +1,10 @@
import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors";
import { db } from "@/lib/db";
import { aiModelHealthEvent } from "@/lib/db/schema";
import { and, desc, eq, sql } from "drizzle-orm";
import {
listAiModelHealthEvents,
restoreAiModelHealth,
} from "@/lib/ai/runtime/model-health";
@ -15,14 +17,30 @@ export async function GET(request: Request) {
await requireAdmin();
const { searchParams } = new URL(request.url);
const modelId = searchParams.get("modelId") || undefined;
const limit = Number(searchParams.get("limit") || 80);
const events = await listAiModelHealthEvents({
modelId,
limit: Number.isFinite(limit) ? limit : 80,
});
return Response.json({ events });
const modelId = searchParams.get("modelId");
const providerId = searchParams.get("providerId");
const rawLimit = Number(searchParams.get("limit") || 20);
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20;
const rawPage = Number(searchParams.get("page") || 1);
const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1;
const filters = [];
if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId));
if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId));
if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!));
const start = searchParams.get("startDate");
const end = searchParams.get("endDate");
if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`);
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`);
const events = await db.select({
id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName,
modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName,
providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType,
errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs,
consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled,
counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt,
}).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id))
.limit(limit + 1).offset((page - 1) * limit);
return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();

View File

@ -1,9 +1,10 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { aiModel, aiProvider } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { requireAdmin } from "@/lib/middleware/admin";
import {
getAllAiModels,
getAiModelById,
getAiModelsByProviderId,
createAiModel,
updateAiModel,
deleteAiModel,
@ -154,8 +155,14 @@ export async function GET(request: Request) {
const id = searchParams.get("id");
const providerId = searchParams.get("providerId");
if (searchParams.get("view") === "options") {
const models = await db.select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName,
providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
return Response.json({ models: sortModelsForDisplay(models) });
}
if (id) {
const model = await getAiModelById({ id });
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) });
if (!model) {
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
}
@ -163,12 +170,19 @@ export async function GET(request: Request) {
}
if (providerId) {
const models = await getAiModelsByProviderId({ providerId });
const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId));
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
}
const models = await getAllAiModels();
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
const models = await db.select().from(aiModel);
const normalized = models.map(normalizeModel);
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
}) : normalized;
return Response.json({ models: sortModelsForDisplay(output) });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();

View File

@ -1,237 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { chat, generatedImage, message, user } from "@/lib/db/schema";
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
import { generatedImage, user } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
function extractVideoUrlFromParts(parts: unknown): string | null {
const text = Array.isArray(parts)
? parts
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text || "") : ""))
.join("\n")
: typeof parts === "string"
? parts
: "";
if (!text) return null;
const htmlMatch = text.match(/<video[^>]+src="([^"]+)"/i);
if (htmlMatch?.[1]) return htmlMatch[1];
const markdownMatch = text.match(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i);
if (markdownMatch?.[1]) return markdownMatch[1];
const plainMatch = text.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
return plainMatch?.[1] || null;
}
import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors";
import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records";
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const session = await requireAdmin();
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const query = parseMediaQuery(request.nextUrl.searchParams);
if (query.startDate && query.endDate && query.startDate > query.endDate) {
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
}
// 检查是否为管理员
const currentUser = await db.query.user.findFirst({
where: eq(user.id, session.user.id),
});
if (currentUser?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// 获取查询参数
const searchParams = request.nextUrl.searchParams;
const page = Number.parseInt(searchParams.get("page") || "1");
const pageSize = Number.parseInt(searchParams.get("pageSize") || "200");
const offset = (page - 1) * pageSize;
const search = searchParams.get("search") || "";
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
// 构建查询条件
const imageConditions = [];
const videoConditions = [
eq(message.role, "assistant"),
or(
isNotNull(message.temporaryVideoUrl),
isNotNull(message.permanentVideoUrl),
sql`${message.parts}::text ILIKE '%<video%'`,
sql`${message.parts}::text ILIKE '%.mp4%'`
),
];
if (search) {
// 搜索用户名或邮箱
const matchingUsers = await db
.select({ id: user.id })
.from(user)
.where(or(
like(user.name, `%${search}%`),
like(user.email, `%${search}%`)
));
if (matchingUsers.length > 0) {
const matchedUserIds = matchingUsers.map((u) => u.id);
imageConditions.push(
sql`${generatedImage.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
);
videoConditions.push(
sql`${chat.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
);
} else {
// 没有匹配的用户,返回空结果
return NextResponse.json({
images: [],
pagination: { page, pageSize, total: 0, totalPages: 0 }
});
}
}
if (startDate) {
const start = new Date(startDate);
imageConditions.push(gte(generatedImage.createdAt, start));
videoConditions.push(gte(message.createdAt, start));
}
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
imageConditions.push(lte(generatedImage.createdAt, end));
videoConditions.push(lte(message.createdAt, end));
}
const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined;
const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined;
// 查询图片总数
const imageCountResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(generatedImage)
.where(imageWhereClause);
// 查询视频总数
const videoCountResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(message)
.innerJoin(chat, eq(message.chatId, chat.id))
.where(videoWhereClause);
const totalImages = imageCountResult[0]?.count || 0;
const totalVideos = videoCountResult[0]?.count || 0;
const total = totalImages + totalVideos;
const totalPages = Math.ceil(total / pageSize);
const fetchLimit = Math.max(page * pageSize, pageSize);
// 查询图片列表(包含用户信息)
const imageRows = await db
.select({
id: generatedImage.id,
userId: generatedImage.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
thumbnailUrl: generatedImage.thumbnailUrl,
originalUrl: generatedImage.originalUrl,
imageSize: generatedImage.imageSize,
prompt: generatedImage.prompt,
modelId: generatedImage.modelId,
isFeatured: generatedImage.isFeatured,
featuredCategory: generatedImage.featuredCategory,
featuredAt: generatedImage.featuredAt,
createdAt: generatedImage.createdAt,
chatId: generatedImage.chatId,
recordType: sql<"image">`'image'`,
videoUrl: sql<string | null>`null`,
})
.from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id))
.where(imageWhereClause)
.orderBy(desc(generatedImage.createdAt))
.limit(fetchLimit);
// 查询视频列表(来自消息表)
const videoRows = await db
.select({
id: message.id,
userId: chat.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
temporaryVideoUrl: message.temporaryVideoUrl,
permanentVideoUrl: message.permanentVideoUrl,
parts: message.parts,
videoMetadata: message.videoMetadata,
createdAt: message.createdAt,
chatId: message.chatId,
})
.from(message)
.innerJoin(chat, eq(message.chatId, chat.id))
.leftJoin(user, eq(chat.userId, user.id))
.where(videoWhereClause)
.orderBy(desc(message.createdAt))
.limit(fetchLimit);
const normalizedVideos = videoRows
.map((item) => {
const metadata = (item.videoMetadata as Record<string, any> | null) || {};
const videoUrl =
item.permanentVideoUrl ||
item.temporaryVideoUrl ||
extractVideoUrlFromParts(item.parts);
if (!videoUrl) return null;
const resolution =
typeof metadata.resolution === "string" && metadata.resolution.trim()
? metadata.resolution.trim()
: null;
const prompt =
typeof metadata.prompt === "string" && metadata.prompt.trim()
? metadata.prompt.trim()
: null;
const modelId =
typeof metadata.model === "string" && metadata.model.trim()
? metadata.model.trim()
: null;
return {
id: `video-${item.id}`,
sourceMessageId: item.id,
userId: item.userId,
userName: item.userName,
userEmail: item.userEmail,
userDepartment: item.userDepartment,
thumbnailUrl: videoUrl,
originalUrl: videoUrl,
videoUrl,
imageSize: resolution,
prompt,
modelId,
isFeatured: false,
featuredCategory: null,
featuredAt: null,
createdAt: item.createdAt,
chatId: item.chatId,
recordType: "video" as const,
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
const merged = [...imageRows, ...normalizedVideos].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const images = merged.slice(offset, offset + pageSize);
return NextResponse.json({
images,
pagination: { page, pageSize, total, totalPages, totalImages, totalVideos }
});
const [result] = await db.execute(mediaRecordsQuery(query));
const images = await resolveArchivedImageUrls(result.records);
const total = Number(result.total);
return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total,
totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } });
} catch (error) {
console.error("[Admin Images API] Error:", error);
return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
if (error instanceof ChatSDKError) return error.toResponse();
console.error("[Admin Images API] GET failed", error);
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
}
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { sql } from "drizzle-orm";
import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors";
import { parseMediaQuery } from "@/lib/admin/media-records";
export async function GET(request: NextRequest) {
try {
await requireAdmin();
const q = parseMediaQuery(request.nextUrl.searchParams);
const status = request.nextUrl.searchParams.get("status") || "all";
const filters = [sql`true`];
if (q.search)
filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`);
if (["pending", "running", "failed", "succeeded"].includes(status))
filters.push(sql`t.status=${status}`);
if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`);
if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`);
const [result] = await db.execute(sql`with filtered as not materialized (
select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model,
extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs"
from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)}
) select (select count(*)::int from filtered) as total,
coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`);
return NextResponse.json(result);
} catch (error) {
if (error instanceof ChatSDKError) return error.toResponse();
return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 });
}
}

View File

@ -106,6 +106,22 @@ export async function POST(request: Request) {
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
: catalogPresets;
if (body?.dryRun === true) {
const catalogIds = new Set(catalogPresets.map(preset => preset.modelId));
const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const;
const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName }));
const updated = presets.flatMap(preset => {
const existing = existingModels.find(model => model.modelId === preset.modelId);
if (!existing) return [];
const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field]));
if (existing.avatarUrl) changedFields.push("avatarUrl");
if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled");
return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : [];
});
const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : [];
return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length });
}
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const disabledModels: AiModel[] = [];

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { desc, eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
@ -56,14 +57,20 @@ export async function POST(request: NextRequest) {
}
const body = await request.json().catch(() => ({}));
const requestedId = body.id === undefined ? undefined : z.string().uuid().safeParse(body.id);
if (requestedId && !requestedId.success) {
return NextResponse.json({ success: false, error: "项目编号无效" }, { status: 400 });
}
const id = requestedId?.data;
const title = String(body.title || "").trim() || "未命名生图项目";
const description = String(body.description || "").trim() || null;
const thumbnail = String(body.thumbnail || "").trim() || null;
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
const [project] = await db
const [insertedProject] = await db
.insert(canvasProject)
.values({
id,
userId: session.user.id,
title,
description,
@ -78,8 +85,22 @@ export async function POST(request: NextRequest) {
agentProjectState: projectState,
} as any,
})
.onConflictDoNothing({ target: canvasProject.id })
.returning();
const project = insertedProject || (id ? (await db
.select()
.from(canvasProject)
.where(and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
))
.limit(1))[0] : undefined);
if (!project) {
return NextResponse.json({ success: false, error: "项目编号不可用,请重试" }, { status: 409 });
}
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
} catch (error) {
console.error("[AgentProjects API] 创建项目失败:", error);

View File

@ -0,0 +1,20 @@
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { resolveAgentWorkflowComposerSelections } from "@/lib/agents/workflow-composer";
import { AGENT_WORKFLOW_UI_CONFIG_KEY, normalizeAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import { getSystemSetting } from "@/lib/db/queries";
const selectionsSchema = z.record(z.string().max(160), z.string().max(8192))
.refine((selections) => Object.keys(selections).length <= 100);
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return Response.json({ error: "请先登录" }, { status: 401 });
const selections = selectionsSchema.safeParse(await request.json().catch(() => null));
if (!selections.success) return Response.json({ error: "风格模板选择无效" }, { status: 400 });
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY });
const config = normalizeAgentWorkflowUiConfig(setting?.value);
return Response.json({ selections: resolveAgentWorkflowComposerSelections({ config, selections: selections.data }) });
}

View File

@ -4,6 +4,7 @@ import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
import { MAX_DETAIL_REPLACEMENT_REFERENCES } from "@/lib/agents/mode-workspace-state";
import {
createCanvasImageTask,
enqueueCanvasImageTask,
@ -146,6 +147,14 @@ export async function POST(request: Request) {
.map((item) => [String(item.id), String(item.imageUrl)]),
);
const requested = Array.isArray(body.references) ? body.references : [];
if (requested.length === 0) {
return NextResponse.json({ error: "请先选择详情参考图" }, { status: 400 });
}
if (requested.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
return NextResponse.json({
error: `本次选中了 ${requested.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`,
}, { status: 400 });
}
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
return {
@ -160,7 +169,6 @@ export async function POST(request: Request) {
};
});
if (
references.length === 0 || references.length > 40 ||
new Set(references.map((item) => item.id)).size !== references.length ||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
) {
@ -187,6 +195,12 @@ export async function POST(request: Request) {
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
}
if (typeof body.expectedModelId === "string" && body.expectedModelId !== model) {
return NextResponse.json({
code: "DETAIL_REPLACEMENT_MODEL_CHANGED",
error: "详情替换模型已更新,请确认页面显示的模型后重新提交",
}, { status: 409 });
}
const quota = await checkUserImageQuota(session.user.id);
if (!quota.allowed) {
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });

View File

@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export function GET() {
return NextResponse.json({ version: process.env.NEXT_PUBLIC_APP_RELEASE || "development" }, {
headers: { "Cache-Control": "no-store, max-age=0" },
});
}

View File

@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { auth } from "@/app/(auth)/auth";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
@ -12,7 +13,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
"tripo3d.ai",
"tripo3d.com",
"worldlabs.ai",
"s3.1080p.hk",
"img.yydd.ai",
"scqny.jiansun.vip",
"blob.vercel-storage.com",
@ -25,7 +25,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
"bbyy.ai",
"s3.1080p.hk",
"img.yydd.ai",
];
@ -165,6 +164,9 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
const { searchParams } = new URL(request.url);
const rawUrl = String(searchParams.get("url") || "").trim();
if (isRetiredStorageUrl(rawUrl)) {
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
}
if (!rawUrl) {
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
}
@ -180,6 +182,18 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
}
if (normalizeLegacyAliyunStorageUrl(targetUrl.toString())) {
try {
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
targetUrl = new URL(await resolveLegacyAliyunStorageUrl(targetUrl.toString()));
} catch (error) {
if (error instanceof Error && error.message.includes("HTTP 410")) {
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
}
throw error;
}
}
if (!isAllowedModelHost(targetUrl.hostname)) {
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
}

View File

@ -2,12 +2,14 @@ import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { auth } from "@/app/(auth)/auth";
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
export const dynamic = "force-dynamic";
export const maxDuration = 600;
@ -39,7 +41,8 @@ export async function GET(
{ status: 404 }
);
}
return NextResponse.json({ success: true, task });
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
return NextResponse.json({ success: true, task: { ...task, failure, error: failure ? formatRequestFailure(failure, task.id) : task.error } });
}
export async function POST(
@ -51,9 +54,11 @@ export async function POST(
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
let requestId: string | undefined;
try {
const { id } = await params;
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
requestId = command.requestId;
const result = await executeCanvasCapabilityRuntime({
command,
projectId: id,
@ -67,8 +72,9 @@ export async function POST(
error instanceof CanvasCapabilityRuntimeError ||
error instanceof CanvasAgentMutationError
) {
const failure = getRequestFailureDetails(error);
return NextResponse.json(
{ success: false, error: error.message, code: error.code },
{ success: false, error: formatRequestFailure(failure, requestId), code: error.code, failure, requestId },
{ status: error.status }
);
}
@ -82,10 +88,10 @@ export async function POST(
{ status: 400 }
);
}
const message = error instanceof Error ? error.message : "执行画布能力失败";
const failure = getRequestFailureDetails(error, "执行画布能力失败");
return NextResponse.json(
{ success: false, error: message },
{ status: 500 }
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
{ status: error instanceof RmbgServiceError ? error.status : 500 }
);
}
}

View File

@ -207,7 +207,11 @@ export async function PUT(
),
)
.returning();
return NextResponse.json({ success: true, data: saved });
return NextResponse.json({
success: true,
data: saved,
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
});
});
} catch (error) {
console.error("更新/创建画布失败:", error);

View File

@ -329,6 +329,7 @@ export async function POST(request: Request) {
withModel,
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
imagePreferences: snapshot.imagePreferences,
priorCalls: snapshot.calls,
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
operationSelectedNodeIds: selectedNodeIds,
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(

View File

@ -1,100 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
BoxCutoutServiceError,
executeBoxCutoutJob,
} from "@/lib/ai/box-cutout/service";
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
export const runtime = "nodejs";
export const maxDuration = 60;
type BoxCutoutRequest = {
imageUrl?: string;
cropRect?: {
x?: number;
y?: number;
width?: number;
height?: number;
};
paddingRatio?: number;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
skipGallerySave?: boolean;
sourceNodeId?: string;
};
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
}
const body = (await request.json()) as BoxCutoutRequest;
const rawImageUrl = String(body.imageUrl || "").trim();
if (!rawImageUrl) {
return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 });
}
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
reason: "image_box_cutout",
modelId: "box-cutout",
note: "框选抠图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeBoxCutoutJob({
userId: session.user.id,
imageUrl: resolvedImageUrl,
cropRect: body.cropRect || {},
paddingRatio: body.paddingRatio,
prompt: body.prompt || "框选抠图",
chatId: body.chatId || null,
messageId: body.messageId || null,
skipGallerySave: body.skipGallerySave === true,
sourceNodeId: body.sourceNodeId,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume box cutout points:", pointsError);
}
}
return NextResponse.json(result);
} catch (error) {
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
return NextResponse.json(
{
success: false,
error: error.detail || error.message,
},
{ status: error.status },
);
}
console.error("[BoxCutout] 处理失败:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "框选抠图失败",
},
{ status: 500 },
);
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
}
return executeBoxCutoutPOST(request, session.user.id);
}

View File

@ -1,5 +1,9 @@
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
import { randomUUID } from "node:crypto";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger";
import { after, NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
@ -12,6 +16,22 @@ import {
export const runtime = "nodejs";
export const maxDuration = 120;
const logger = createLogger("canvas.download");
function failed(failure: DownloadFailure, requestId: string) {
logger.warn("request_failed", { requestId, code: failure.code, status: failure.status });
return NextResponse.json({ success: false, error: formatDownloadFailure(failure, requestId), failure, requestId }, {
status: failure.status,
headers: { "Cache-Control": "no-store", ...(failure.status === 429 ? { "Retry-After": "3" } : {}) },
});
}
function preparing(requestId: string) {
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
message: "正在准备原图,完成后将自动开始下载" }, {
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
});
}
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
// A proxy candidate for the same original must not repeat the same slow
@ -20,22 +40,25 @@ async function prepareDownload(input: DownloadPreparationInput, signal: AbortSig
const source = extractImageProxySourceUrl(url) || url;
return inferOriginalImageUrl(source) || source;
}))];
for (let attempt = 0; attempt < 3; attempt++) {
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt++) {
signal.throwIfAborted();
for (const url of sources) {
signal.throwIfAborted();
const result = await fetchCanvasDownloadAsset({
file: { url, name: "original" }, index: 0,
requestOrigin: input.requestOrigin, cookie: input.cookie,
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
includeSequencePrefix: false, timeoutMs: 60_000, retryCount: 1, signal,
});
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
lastError = result.error;
}
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
// No AI/model request is involved and a completed source fetch is shared.
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
else break;
}
throw new Error("Original image sources unavailable");
throw new Error(String(lastError || "Original image sources unavailable"));
}
function getRequestOrigin(request: NextRequest): string {
@ -75,27 +98,43 @@ function resolveDownloadUrls(body: Record<string, unknown>, origin: string): str
}
export async function POST(request: NextRequest) {
const requestId = randomUUID();
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
return failed(getDownloadFailure("未登录"), requestId);
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request);
const urls = resolveDownloadUrls(body, requestOrigin);
const candidates = resolveDownloadUrls(body, requestOrigin);
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId);
}
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
const requestedUrls = [...urls];
if (urls.length === 0) {
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
return failed(getDownloadFailure("图片地址无效"), requestId);
}
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
if (body.preferDirect === true) {
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
logger.warn("cdn_lookup_failed", error, { requestId });
return { task: null, direct: null, canPrepareDirect: false };
});
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
if (task && canPrepareDirect && body.preparation === "async") {
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
return failed(asset.failure || getDownloadFailure(null), task.id);
}
scheduleImageDownloadPreparation(task.id);
return preparing(task.id);
}
if (task?.targetUrl) {
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
after(() => prepareImageArchiveDownload(task.id));
scheduleImageDownloadPreparation(task.id);
}
}
const cookie = request.headers.get("cookie") || "";
@ -104,13 +143,10 @@ export async function POST(request: NextRequest) {
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
});
if (result.status === "preparing") {
return NextResponse.json({ delivery: "preparing", retryAfter: 3,
message: "正在准备原图,完成后将自动下载" }, {
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
});
return preparing(result.requestId);
}
if (result.status === "failed") {
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
return failed(result.failure, result.requestId);
}
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
"Content-Type": result.asset.contentType,
@ -131,17 +167,14 @@ export async function POST(request: NextRequest) {
includeSequencePrefix: false,
});
if (result.ok) break;
failures.push(`${url}: ${result.error}`);
failures.push(result.error);
}
if (!result?.ok) {
if (failures.length > 0) {
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
logger.warn("all_sources_failed", { requestId, failures });
}
return NextResponse.json(
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
{ status: 502 }
);
return failed(getDownloadFailure(result?.error), requestId);
}
return new NextResponse(result.bytes, {
@ -154,7 +187,7 @@ export async function POST(request: NextRequest) {
},
});
} catch (error) {
console.error("[CanvasDownloadImage] Failed:", error);
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
logger.error("download_failed", error, { requestId });
return failed(getDownloadFailure(error), requestId);
}
}

View File

@ -10,11 +10,13 @@ import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
import { trackRuntimeWork } from "@/lib/observability/active-work";
export const runtime = "nodejs";
export const maxDuration = 300;
type DownloadFileItem = { url: string; name?: string };
const MAX_DOWNLOAD_FILES = 200;
const TASK_TTL_MS = 60 * 60_000;
const TASK_TTL_MS = 24 * 60 * 60_000;
const PREPARATION_TTL_MS = 6 * 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const active = running.__canvasZipRunning ??= new Set<string>();
function sanitizeFolderName(value: string): string {
@ -98,63 +100,71 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
task = await loadZipTask(id, userId);
if (!task || task.status === "completed" || task.status === "failed") return;
await assertZipDiskSpace();
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
const existingZip = task.zipFile && task.contentDisposition && await stat(task.zipFile.path).catch(() => null);
const reuseZip = existingZip && existingZip.size === task.zipFile!.bytes;
task.status = "running"; task.stage = reuseZip ? "正在准备加速下载" : "正在准备原图"; task.updatedAt = Date.now();
await saveZipTask(task);
// Stage each image on disk, not all PNG buffers in the web process heap.
// A restart resumes from these files using the current owner's session.
let cursor = 0;
let writes = Promise.resolve();
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
while (cursor < task!.files.length) {
const index = cursor++; const file = task!.files[index];
const path = join(zipTaskPath(id), `${index}.image`);
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
delete file.prepared; delete file.filename;
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
if (result.ok) {
await assertZipDiskSpace(result.bytes.length);
await writeFile(path, result.bytes, { mode: 0o600 });
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
} else file.error = result.error;
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
task!.successCount = task!.files.filter(f => f.prepared).length;
task!.failedCount = task!.files.filter(f => f.error).length;
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
task!.updatedAt = Date.now(); await persist();
}
}));
const rejected = results.find(result => result.status === "rejected");
if (rejected?.status === "rejected") throw rejected.reason;
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
for (const file of task.files) if (file.prepared && file.filename)
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName);
if (!reuseZip) {
delete task.zipFile; delete task.contentDisposition; delete task.cdnUrl;
// Stage each image on disk, not all PNG buffers in the web process heap.
// A restart resumes from these files using the current owner's session.
let cursor = 0;
let writes = Promise.resolve();
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
while (cursor < task!.files.length) {
const index = cursor++; const file = task!.files[index];
const path = join(zipTaskPath(id), `${index}.image`);
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
delete file.prepared; delete file.filename;
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
if (result.ok) {
await assertZipDiskSpace(result.bytes.length);
await writeFile(path, result.bytes, { mode: 0o600 });
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
} else file.error = result.error;
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
task!.successCount = task!.files.filter(f => f.prepared).length;
task!.failedCount = task!.files.filter(f => f.error).length;
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
task!.updatedAt = Date.now(); await persist();
}
}));
const rejected = results.find(result => result.status === "rejected");
if (rejected?.status === "rejected") throw rejected.reason;
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
for (const file of task.files) if (file.prepared && file.filename)
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName);
}
task.stage = "正在准备下载链接"; await saveZipTask(task);
try {
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (cdn.url) task.cdnUrl = cdn.url;
}
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile!, disposition: task.contentDisposition!,
onProgress: async bytes => {
task!.stage = `正在传送至七牛 ${Math.round(bytes / task!.zipFile!.bytes * 100)}%`;
task!.updatedAt = Date.now(); await saveZipTask(task!);
} });
if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`);
task.cdnUrl = cdn.url;
}
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes.
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
bytes: task.zipFile!.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
} catch (error) {
if (task) {
task.status = "failed"; task.stage = "打包未完成";
task.status = "failed"; task.stage = task.zipFile ? "ZIP 已打包,加速下载未就绪" : "打包未完成";
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
}
@ -162,7 +172,7 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
} finally { await release?.().catch(() => undefined); active.delete(id); }
}
function schedule(task: ZipTask, request: NextRequest) {
const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "");
const run = () => trackRuntimeWork(() => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || ""));
after(run);
}
export async function POST(request: NextRequest) {
@ -187,14 +197,17 @@ export async function POST(request: NextRequest) {
const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS };
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + PREPARATION_TTL_MS };
await saveZipTask(task);
} else if (task.status === "failed" && body.retry === true) {
} else if ((task.status === "failed" || task.status === "completed" && task.cdnObject && !task.cdnUrl) && body.retry === true) {
task.status = "pending"; task.error = undefined;
await assertZipTaskCapacity(session.user.id);
for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
if (!task.zipFile) {
for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0;
}
task.updatedAt = Date.now(); task.expiresAt = Date.now() + PREPARATION_TTL_MS;
await saveZipTask(task);
}
if (task.status === "pending" || task.status === "running") schedule(task, request);

View File

@ -2,6 +2,7 @@ import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
export const maxDuration = 1200;
@ -23,13 +24,15 @@ export async function GET(request: Request) {
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
}
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
return NextResponse.json({
success: true,
task: {
id: task.id,
status: task.status,
attempts: task.attempts,
error: task.error || null,
error: failure ? formatRequestFailure(failure, task.id) : null,
failure,
result: task.result || null,
createdAt: task.createdAt,
startedAt: task.startedAt,

View File

@ -976,7 +976,8 @@ export async function POST(request: NextRequest) {
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const annotatedUpload = await uploadFile(
annotatedFilename,
toArrayBuffer(annotatedBuffer)
toArrayBuffer(annotatedBuffer),
{ preferQiniuForm: true }
);
const prompt = buildLocalEditPrompt(regions);
const manualRegionReferenceImageUrls = regions.flatMap((region) =>

View File

@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { and, eq, inArray } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import { executeConfiguredVip98kTask, getVip98kRuntimeProviders } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { aiModel } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import sharp from "sharp";
@ -90,6 +94,39 @@ function fallbackItemsFromRaw(raw: string): OcrItem[] {
}));
}
async function detectTextWithVip98k(imageUrl: string): Promise<OcrItem[] | null> {
const providers = await getVip98kRuntimeProviders();
if (!providers.length) return null;
const model = await db.query.aiModel.findFirst({
columns: { providerId: true },
where: and(
inArray(aiModel.providerId, providers.map(({ id }) => id)),
eq(aiModel.modelId, "ocr"),
eq(aiModel.isEnabled, true),
),
});
if (!model) return null;
const result = await executeConfiguredVip98kTask({
modelId: "ocr",
kind: "image-tool",
imageUrls: [imageUrl],
preferredProviderId: model.providerId,
});
const textUrl = result.outputUrls[0];
if (!textUrl) throw new Error("98K 文字识别未返回文本文件");
const response = await fetch(textUrl, { signal: AbortSignal.timeout(15_000) });
const type = response.headers.get("content-type") || "";
if (!response.ok || !/^(?:text\/plain|application\/octet-stream)(?:;|$)/i.test(type)) {
await response.body?.cancel();
throw new Error(`无法读取 98K 文字识别结果(HTTP ${response.status})`);
}
return (await response.text()).split(/\r\n?|\n/).map((line) => line.trim())
.filter(Boolean).slice(0, 50).map((text, index) => ({ id: String(index + 1), text }));
}
function shouldUseDataUrlFallback(error: unknown) {
const message =
error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase();
@ -147,6 +184,11 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
}
const detected = await detectTextWithVip98k(imageUrl);
if (detected !== null) {
return NextResponse.json({ success: true, data: { items: detected, model: "ocr" } });
}
logger.debug("model_selected", { model: "qwen3-vl" });
const prompt = [
"你是图片文字识别助手。",
@ -191,15 +233,9 @@ export async function POST(request: Request) {
}
const parsedJson = parseJsonText(text || "");
let items: OcrItem[] = [];
if (parsedJson && Array.isArray(parsedJson.items)) {
items = sanitizeItems(parsedJson.items);
}
if (items.length === 0 && text) {
items = fallbackItemsFromRaw(text);
}
const items = Array.isArray(parsedJson?.items)
? sanitizeItems(parsedJson.items)
: fallbackItemsFromRaw(text);
return NextResponse.json({
success: true,
@ -213,7 +249,7 @@ export async function POST(request: Request) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "文字识别失败",
error: "图片文字识别暂不可用,请稍后重试",
},
{ status: 500 },
);

View File

@ -1,7 +1,8 @@
import { NextRequest } from "next/server";
import { db } from "@/lib/db";
import { generatedImage, user, aiModel } from "@/lib/db/schema";
import { eq, desc, sql } from "drizzle-orm";
import { and, eq, desc, sql } from "drizzle-orm";
import { isRetiredStorageUrl, RETIRED_STORAGE_URL_PATTERN } from "@/lib/storage/retired-storage";
import { cachedPublicJson } from "@/lib/public-api/performance";
const FEATURED_TIMEOUT_MS = 1800;
@ -57,13 +58,17 @@ export async function GET(request: NextRequest) {
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
modelName: aiModel.displayName,
modelName: sql<string | null>`(select ${aiModel.displayName} from ${aiModel}
where ${aiModel.modelId} = ${generatedImage.modelId}
order by ${aiModel.isEnabled} desc, ${aiModel.createdAt} desc, ${aiModel.id} asc limit 1)`,
totalCount: sql<number>`count(*) over()::int`,
})
.from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id))
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
.where(eq(generatedImage.isFeatured, true))
.where(and(
eq(generatedImage.isFeatured, true),
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
))
.orderBy(desc(generatedImage.featuredAt))
.limit(pageSize)
.offset(offset);
@ -74,7 +79,7 @@ export async function GET(request: NextRequest) {
return {
images: featuredImages.map((img) => ({
id: img.id,
thumbnailUrl: img.thumbnailUrl,
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
originalUrl: img.originalUrl,
imageSize: img.imageSize,
prompt: img.prompt,

View File

@ -1,7 +1,12 @@
import crypto from "node:crypto";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { NextRequest, NextResponse } from "next/server";
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger";
import { buildImageProxyUrl, isAlibabaModelImageUrl } from "@/lib/image-delivery";
export const runtime = "nodejs";
const logger = createLogger("image.download");
const DEFAULT_QUALITY = 82;
const MAX_WIDTH = 4096;
@ -9,6 +14,7 @@ const MIN_WIDTH = 64;
const MIN_QUALITY = 40;
const MAX_QUALITY = 100;
const FETCH_TIMEOUT_MS = 15000;
const SMALL_PREVIEW_FETCH_TIMEOUT_MS = 45000;
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
@ -127,15 +133,13 @@ function buildImageResponse(
etag: string;
vary?: string;
cacheStatus?: "HIT" | "MISS";
cacheControl?: string;
attachment?: boolean;
}
) {
const headers: Record<string, string> = {
"Content-Type": params.contentType,
"Content-Length": String(body.length),
ETag: params.etag,
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
};
if (params.vary) {
headers.Vary = params.vary;
@ -143,11 +147,6 @@ function buildImageResponse(
if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
}
if (params.attachment) {
headers["Content-Disposition"] = "attachment";
headers["X-Content-Type-Options"] = "nosniff";
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
}
return new NextResponse(body, {
status: 200,
headers,
@ -237,6 +236,7 @@ function resolveDirectImageTarget(
requestedQuality: number | null,
requestedFormat: string | null
): URL | null {
if (isAlibabaModelImageUrl(target.toString())) return null;
if (!isBrowserImageRequest(request)) {
return null;
}
@ -265,7 +265,19 @@ function redirectToDirectImage(target: URL): NextResponse {
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const requestId = crypto.randomUUID();
const downloadFailure = (error: unknown) => {
const failure = getDownloadFailure(error);
logger.warn("original_download_failed", error, { requestId, code: failure.code, status: failure.status });
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId }, {
status: failure.status, headers: { "Cache-Control": "no-store" },
});
};
const rawUrl = (searchParams.get("url") || "").trim();
if (isRetiredStorageUrl(rawUrl)) {
if (searchParams.get("original") === "1") return downloadFailure(RETIRED_STORAGE_MESSAGE);
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
}
if (!rawUrl) {
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
}
@ -281,6 +293,24 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
}
if (normalizeLegacyAliyunStorageUrl(target.toString())) {
try {
if (searchParams.get("original") === "1") {
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
buildImageProxyUrl(target.toString(), { delivery: "original" });
}
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
target = new URL(await resolveLegacyAliyunStorageUrl(target.toString()));
} catch (error) {
if (searchParams.get("original") === "1") return downloadFailure(error);
const failure = getDownloadFailure(error);
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId,
code: failure.code === "STORAGE_RETIRED" ? "storage_retired" : failure.code }, {
status: failure.status, headers: { "Cache-Control": "no-store" },
});
}
}
if (isBlockedHost(target.hostname)) {
return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
}
@ -301,6 +331,37 @@ export async function GET(request: NextRequest) {
);
}
if (!original && isAlibabaModelImageUrl(target.toString())) {
try {
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
const mappings = await getArchivedImageUrlMappings([target.toString()]);
const archived = mappings[target.toString()];
if (archived) {
const preview = buildImageProxyUrl(archived, {
width: width || undefined,
quality,
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
});
if (/^https?:\/\//i.test(preview)) return redirectToDirectImage(new URL(preview));
target = new URL(archived);
}
} catch (error) {
logger.warn("preview_archive_lookup_failed", error, { requestId });
}
}
if (original && searchParams.get("download") === "resolve") {
try {
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
buildImageProxyUrl(target.toString(), { delivery: "original" });
const { resolveOriginalCdnDownload } = await import("@/lib/storage/original-image-download");
const direct = await resolveOriginalCdnDownload(target.toString(), searchParams.get("name") || "", request.signal);
return NextResponse.json(direct || { delivery: "proxy" }, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
return downloadFailure(error);
}
}
if (!forceProxy && !original) {
const directImageTarget = resolveDirectImageTarget(
request,
@ -339,39 +400,81 @@ export async function GET(request: NextRequest) {
});
}
let upstreamResponse: Response;
try {
upstreamResponse = await fetch(target.toString(), {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
},
});
} catch (error) {
console.error("[ImageProxy] Upstream fetch failed:", error);
return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 });
}
if (!upstreamResponse.ok) {
const fetchSignal = AbortSignal.any([
request.signal,
AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
]);
const upstreamFailure = (error: unknown) => {
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
if (original) return downloadFailure(error);
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
return NextResponse.json(
{ error: `Upstream responded with ${upstreamResponse.status}` },
{ status: 502 }
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
{ status: timedOut ? 504 : 502 },
);
}
};
let contentType: string;
let originalBuffer: Buffer;
if (!original && isAlibabaModelImageUrl(target.toString())) {
try {
const { fetchImageBytes } = await import("@/lib/storage/image-byte-cache");
const data = await fetchImageBytes(target.toString(), { timeoutMs: SMALL_PREVIEW_FETCH_TIMEOUT_MS });
originalBuffer = data.buffer;
contentType = data.contentType;
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
if (!contentType.toLowerCase().startsWith("image/")) {
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
}
} catch (error) {
return upstreamFailure(error);
}
} else {
let upstreamResponse: Response;
try {
upstreamResponse = await fetch(target.toString(), {
signal: fetchSignal,
headers: {
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
},
});
} catch (error) {
return upstreamFailure(error);
}
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
if (!contentType.toLowerCase().startsWith("image/")) {
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
}
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
if (original) {
return buildImageResponse(originalBuffer, {
contentType,
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
cacheControl: "private, no-store",
attachment: true,
});
if (!upstreamResponse.ok) {
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
return NextResponse.json(
{ error: `Upstream responded with ${upstreamResponse.status}` },
{ status: 502 }
);
}
contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
if (!contentType.toLowerCase().startsWith("image/")) {
if (original) return downloadFailure("Source is not an image");
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
}
if (original) {
if (upstreamResponse.status !== 200 || !upstreamResponse.body) {
await upstreamResponse.body?.cancel();
return downloadFailure("源图片传输中断,请重试");
}
const headers = new Headers({
"Content-Type": contentType,
"Content-Disposition": "attachment",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
});
const length = upstreamResponse.headers.get("content-length");
const encoding = upstreamResponse.headers.get("content-encoding");
if (length && (!encoding || encoding.toLowerCase() === "identity")) headers.set("Content-Length", length);
return new NextResponse(upstreamResponse.body, { headers });
}
try {
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) {
return upstreamFailure(error);
}
}
const isSvg = contentType.toLowerCase().includes("image/svg");
const isGif = contentType.toLowerCase().includes("image/gif");

View File

@ -6,6 +6,7 @@
*/
import { NextResponse } from "next/server";
import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
@ -69,7 +70,6 @@ const SCENE_FUSION_REQUEST_STAGGER_MS = 350;
const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000;
const SCENE_FUSION_AGENT_MAX_WORKERS = 3;
const SCENE_FUSION_PROCESS_MAX_WORKERS = 4;
const SCENE_FUSION_PRE_RMBG_TIMEOUT_MS = 60_000;
const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000;
const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8;
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000;
@ -236,41 +236,6 @@ function isSceneFusionReviewRejectError(message: string): boolean {
);
}
function buildPreRmbgCandidateUrls(request: Request): string[] {
const urls: string[] = [];
const requestUrl = new URL(request.url);
const requestHost = requestUrl.hostname.toLowerCase();
const requestProtocol = requestUrl.protocol.replace(":", "");
if (!(requestProtocol === "https" && (requestHost === "localhost" || requestHost === "127.0.0.1"))) {
urls.push(new URL("/api/rmbg", request.url).toString());
}
const host = request.headers.get("host")?.trim();
const protoHint = request.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim()
.toLowerCase();
const protocol = protoHint === "https" || protoHint === "http" ? protoHint : requestUrl.protocol.replace(":", "");
if (host) {
const normalizedHost = host.split(":")[0]?.toLowerCase() || "";
const isLocalHost = normalizedHost === "localhost" || normalizedHost === "127.0.0.1";
if (!(protocol === "https" && isLocalHost)) {
urls.push(`${protocol}://${host}/api/rmbg`);
}
if (isLocalHost) {
urls.push(`http://${host}/api/rmbg`);
}
}
const localPort = requestUrl.port || process.env.PORT || "3125";
urls.push(`http://127.0.0.1:${localPort}/api/rmbg`);
urls.push(`http://localhost:${localPort}/api/rmbg`);
return Array.from(new Set(urls));
}
function normalizeUrlForCompare(input: string): string {
try {
const parsed = new URL(input);
@ -988,102 +953,6 @@ async function getSceneFusionConfig() {
};
}
async function preprocessProductImageWithRmbg(
request: Request,
requestId: string,
productImageUrl: string,
preRmbgEnabled: boolean
): Promise<{
productImageUrl: string;
usedPreRmbg: boolean;
provider?: string;
reason?: string;
}> {
if (!preRmbgEnabled) {
return {
productImageUrl,
usedPreRmbg: false,
reason: "disabled_by_config",
};
}
const cookie = request.headers.get("cookie");
if (!cookie) {
return {
productImageUrl,
usedPreRmbg: false,
reason: "missing_cookie",
};
}
const candidateUrls = buildPreRmbgCandidateUrls(request);
let lastReason = "no_candidate_url";
logger.info("pre_rmbg_started", {
requestId,
candidateCount: candidateUrls.length,
});
for (const rmbgUrl of candidateUrls) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), SCENE_FUSION_PRE_RMBG_TIMEOUT_MS);
try {
const response = await fetch(rmbgUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie,
},
body: JSON.stringify({
imageUrl: productImageUrl,
prompt: "场景裂变预处理-抠图",
skipGallerySave: true,
}),
signal: controller.signal,
});
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
if (!response.ok) {
lastReason = `request_failed_${response.status}`;
logger.warn("pre_rmbg_candidate_failed", {
requestId,
status: response.status,
detail: payload?.detail || payload?.error || "unknown",
});
continue;
}
const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim();
if (!cleanedUrl || cleanedUrl === productImageUrl) {
lastReason = cleanedUrl ? "same_as_source" : "empty_result";
logger.warn("pre_rmbg_candidate_invalid", {
requestId,
reason: lastReason,
});
continue;
}
return {
productImageUrl: cleanedUrl,
usedPreRmbg: true,
provider: String(payload?.provider || "unknown"),
};
} catch (error) {
lastReason = error instanceof Error ? error.message : "exception";
logger.warn("pre_rmbg_candidate_exception", error, { requestId });
} finally {
clearTimeout(timeoutId);
}
}
return {
productImageUrl,
usedPreRmbg: false,
reason: lastReason,
};
}
/**
* 创建缩略图
*/
@ -2436,12 +2305,13 @@ export async function POST(request: Request) {
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
}
const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
const preprocessedProduct = await preprocessProductImageWithRmbg(
const preprocessedProduct = await preprocessProductImageWithRmbg({
request,
requestId,
userId: session.user.id,
productImageUrl,
shouldForcePreRmbg ? true : config.preRmbgEnabled
);
preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled,
});
const fusionProductImageUrl = preprocessedProduct.productImageUrl;
logger.info("pre_rmbg_resolved", {
requestId,

View File

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveSiteBranding } from "@/lib/site-branding";
import { createHash } from "node:crypto";
import { z } from "zod";
import { db } from "@/lib/db";
@ -9,7 +10,8 @@ import {
systemSettingSnapshot,
systemSettings,
} from "@/lib/db/schema";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { omitAgentWorkflowPrompts } from "@/lib/agents/workflow-loading";
import { auth } from "@/app/(auth)/auth";
import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin";
import {
@ -152,7 +154,7 @@ import {
} from "@/lib/ai-wash/config";
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
import { withTimeout } from "@/lib/public-api/performance";
import type { AgentCategoryTreeConfig } from "@/types/agent";
import type { AgentCategoryTreeConfig, AgentWorkflowUiConfig } from "@/types/agent";
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
@ -552,6 +554,9 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
type PublicSiteSettingsScope =
| "agent-brand-model"
| "agent-canvas"
| "agent-canvas-base"
| "agent-workflow-templates"
| "agent-entry"
| "agent-presets"
| "auth"
| "branding"
@ -563,6 +568,9 @@ type PublicSiteSettingsScope =
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
"agent-brand-model",
"agent-canvas",
"agent-canvas-base",
"agent-workflow-templates",
"agent-entry",
"agent-presets",
"auth",
"branding",
@ -606,22 +614,60 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined),
};
const AGENT_CANVAS_SETTING_KEYS = [
REALTIME_CANVAS_CONFIG_KEY,
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
LAYER_SPLIT_CONFIG_KEY,
ANGLE_EDIT_CONFIG_KEY,
CANVAS_AGENT_MODE_ENABLED_KEY,
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
VIDEO_CANVAS_ENABLED_KEY,
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
ENHANCE_PRO_KREA_SETTING_KEY,
AI_WASH_CONFIG_KEY,
STYLE_PRO_CONFIG_KEY,
AGENT_WORKFLOW_UI_CONFIG_KEY,
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
];
async function getAgentCanvasSettingsEtag(scope: string) {
const [settings, rows, providers] = await Promise.all([
db.select({ id: siteSettings.id, updatedAt: siteSettings.updatedAt })
.from(siteSettings).limit(1),
db.select({ key: systemSettings.key, updatedAt: systemSettings.updatedAt })
.from(systemSettings)
.where(inArray(systemSettings.key, AGENT_CANVAS_SETTING_KEYS)),
db.select({ id: aiProvider.id, updatedAt: aiProvider.updatedAt, enabled: aiProvider.isEnabled })
.from(aiProvider)
.where(inArray(aiProvider.type, ["tripo-3d", "worldlabs-3d"])),
]);
const versions = [
...settings.map((row) => `${row.id}:${row.updatedAt.getTime()}`),
...rows.map((row) => `${row.key}:${row.updatedAt.getTime()}`),
...providers.map((row) => `${row.id}:${row.updatedAt.getTime()}:${row.enabled}`),
].sort();
return `W/"${scope}/${versions.join(",")}"`;
}
function createScopedSiteSettingsResponse(
scope: PublicSiteSettingsScope,
data: Record<string, unknown>,
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
cacheKey: string = scope,
options: { fallback?: boolean; startedAt?: number } = {}
options: { fallback?: boolean; startedAt?: number; etag?: string } = {}
) {
if (cacheStatus === "MISS") {
setScopedSiteSettingsCache(
cacheKey,
data,
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
options.etag
);
}
const response = NextResponse.json(data);
if (options.etag) response.headers.set("ETag", options.etag);
// Lightweight public settings change rarely; keep them browser-local briefly
// so page opens do not repeatedly wait on the large full settings endpoint.
response.headers.set(
@ -641,7 +687,7 @@ function createScopedSiteSettingsResponse(
return response;
}
async function readScopedSiteSettings(keys: string[]) {
async function readScopedSiteSettings(keys: string[], omitWorkflowSteps = false) {
const [settings, settingRows] = await Promise.all([
db
.select()
@ -649,8 +695,14 @@ async function readScopedSiteSettings(keys: string[]) {
.limit(1)
.then((rows) => rows[0]),
keys.length > 0
? db
.select()
? (omitWorkflowSteps
? db.select({
key: systemSettings.key,
value: sql<unknown>`case when ${systemSettings.key} = ${AGENT_WORKFLOW_UI_CONFIG_KEY}
and jsonb_typeof(${systemSettings.value}) = 'object'
then ${systemSettings.value} - 'steps' else ${systemSettings.value} end`,
})
: db.select())
.from(systemSettings)
.where(inArray(systemSettings.key, keys))
: Promise.resolve([]),
@ -663,12 +715,12 @@ async function readScopedSiteSettings(keys: string[]) {
}
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
return {
return resolveSiteBranding({
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
siteLogo: settings?.siteLogo || null,
siteLogoDark: settings?.siteLogoDark || null,
favicon: settings?.favicon || null,
};
siteLogo: String(settings?.siteLogo || ""),
siteLogoDark: String(settings?.siteLogoDark || ""),
favicon: String(settings?.favicon || ""),
});
}
async function getScopedPublicSiteSettings(
@ -676,6 +728,7 @@ async function getScopedPublicSiteSettings(
options: {
brandName?: string;
cacheKey?: string;
etag?: string;
} = {}
) {
if (scope === "auth") {
@ -737,23 +790,17 @@ async function getScopedPublicSiteSettings(
});
}
if (scope === "agent-canvas") {
const { settings, valueByKey } = await readScopedSiteSettings([
REALTIME_CANVAS_CONFIG_KEY,
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
LAYER_SPLIT_CONFIG_KEY,
ANGLE_EDIT_CONFIG_KEY,
CANVAS_AGENT_MODE_ENABLED_KEY,
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
VIDEO_CANVAS_ENABLED_KEY,
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
ENHANCE_PRO_KREA_SETTING_KEY,
AI_WASH_CONFIG_KEY,
STYLE_PRO_CONFIG_KEY,
AGENT_WORKFLOW_UI_CONFIG_KEY,
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
]);
if (scope === "agent-workflow-templates") {
const [setting] = await db.select({ value: systemSettings.value })
.from(systemSettings).where(eq(systemSettings.key, AGENT_WORKFLOW_UI_CONFIG_KEY)).limit(1);
const config = normalizeAgentWorkflowUiConfig(setting?.value);
return createScopedSiteSettingsResponse(scope, {
steps: omitAgentWorkflowPrompts(config.steps),
}, "MISS", options.cacheKey || scope, { etag: options.etag });
}
if (scope === "agent-canvas" || scope === "agent-canvas-base" || scope === "agent-entry") {
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS, scope !== "agent-canvas");
const canvasFeatureAvailability = await readCanvasFeatureAvailability(
valueByKey
);
@ -761,6 +808,8 @@ async function getScopedPublicSiteSettings(
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
);
const rawUiConfig = valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) as
Partial<AgentWorkflowUiConfig> | undefined;
return createScopedSiteSettingsResponse(scope, {
...createBrandingSettings(typedSettings),
agentEnabled: typedSettings?.agentEnabled ?? true,
@ -796,12 +845,20 @@ async function getScopedPublicSiteSettings(
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY)
scope === "agent-entry"
? {
steps: [],
pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
businessModeSettings: rawUiConfig?.businessModeSettings,
}
: scope === "agent-canvas-base"
? { ...rawUiConfig, steps: [] }
: rawUiConfig
),
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
),
});
}, "MISS", options.cacheKey || scope, { etag: options.etag });
}
if (scope === "branding") {
@ -1055,13 +1112,24 @@ export async function GET(request: NextRequest) {
const cacheKey = brandName.trim()
? `${scopedScope}:${brandName.trim().toLowerCase()}`
: scopedScope;
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry" ||
scopedScope === "agent-canvas-base" || scopedScope === "agent-workflow-templates"
? await getAgentCanvasSettingsEtag(scopedScope)
: undefined;
if (etag && request.headers.get("If-None-Match") === etag) {
return new NextResponse(null, {
status: 304,
headers: { ETag: etag, "Cache-Control": "private, no-cache" },
});
}
const cached = getScopedSiteSettingsCache(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
if (cached && cached.expiresAt > Date.now() && (!etag || cached.etag === etag)) {
return createScopedSiteSettingsResponse(
scopedScope,
cached.data,
"HIT",
cacheKey
cacheKey,
{ etag }
);
}
if (cached && scopedScope !== "home") {
@ -1072,6 +1140,7 @@ export async function GET(request: NextRequest) {
const responsePromise = getScopedPublicSiteSettings(scopedScope, {
brandName,
cacheKey,
etag,
});
return scopedScope === "home"
? await withTimeout(responsePromise, 1600, "site-settings:home")
@ -1541,7 +1610,7 @@ export async function GET(request: NextRequest) {
};
const response = NextResponse.json({
...baseSettings,
...resolveSiteBranding(baseSettings),
lovartPromoText:
baseSettings.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",

View File

@ -6,6 +6,7 @@ import { eq } from "drizzle-orm";
import { uploadFile } from "@/lib/storage/service";
import {
createMediaAssetRelation,
getDefaultStorageConfig,
getPointsSystemConfig,
getMediaAssetsByUserAndUrls,
upsertMediaAsset,
@ -21,6 +22,27 @@ export const maxDuration = 600;
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
const WATERMARK_POLL_INTERVAL_MS = 1200;
async function resolveStoredQiniuImageUrl(source: string): Promise<string | null> {
const config = await getDefaultStorageConfig().catch(() => null);
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
try {
const base = new URL(config.publicUrl);
const url = new URL(source);
const prefix = base.pathname.replace(/\/$/, "") + "/";
if (base.protocol !== "https:" || url.origin !== base.origin ||
url.username || url.password || url.search || url.hash ||
!url.pathname.startsWith(prefix)) return null;
const objectKey = url.pathname.slice(prefix.length);
return /^uploads\/(?:direct\/[0-9a-f-]{36}\/)?[\w.-]+$/i.test(objectKey)
? url.toString()
: null;
} catch {
return null;
}
}
// 获取去水印API配置
async function getWatermarkRemoveConfig() {
try {
@ -131,13 +153,16 @@ async function persistWatermarkResult(params: {
processing_time?: number;
}> {
try {
const imageResponse = await fetch(params.processedImageUrl);
if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败");
let resultUrl = await resolveStoredQiniuImageUrl(params.processedImageUrl);
if (!resultUrl) {
const imageResponse = await fetch(params.processedImageUrl);
if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败");
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-removed-${Date.now()}.png`;
resultUrl = (await uploadFile(filename, imageBuffer)).url;
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-removed-${Date.now()}.png`;
const uploadResult = await uploadFile(filename, imageBuffer);
let saved = false;
let saveError: unknown = null;
@ -150,8 +175,8 @@ async function persistWatermarkResult(params: {
try {
([savedImage] = await db.insert(generatedImage).values({
userId: params.userId,
thumbnailUrl: uploadResult.url,
originalUrl: uploadResult.url,
thumbnailUrl: resultUrl,
originalUrl: resultUrl,
imageSize: "2K",
prompt: "去水印处理",
modelId: "watermark-remove",
@ -184,9 +209,9 @@ async function persistWatermarkResult(params: {
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: uploadResult.url,
previewUrl: uploadResult.url,
thumbnailUrl: uploadResult.url,
originalUrl: resultUrl,
previewUrl: resultUrl,
thumbnailUrl: resultUrl,
displayName: "去水印处理",
prompt: "去水印处理",
modelId: "watermark-remove",
@ -217,8 +242,8 @@ async function persistWatermarkResult(params: {
return {
success: true,
original_url: uploadResult.url,
thumbnail_url: uploadResult.url,
original_url: resultUrl,
thumbnail_url: resultUrl,
processing_time:
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
};
@ -362,7 +387,7 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const { image_url } = body;
if (!image_url) {
if (typeof image_url !== "string" || !image_url) {
return NextResponse.json(
{ error: "请提供图片URL" },
{ status: 400 }
@ -413,24 +438,24 @@ export async function POST(request: NextRequest) {
);
}
}
// 如果是 HTTP/HTTPS URL,下载后重新上传确保公网可访问
// 七牛上的公开图片可以直接交给去水印服务读取。
else if (image_url.startsWith("http://") || image_url.startsWith("https://")) {
try {
console.log("[WatermarkRemove] 下载原图并上传到S3...");
const imageResponse = await fetch(image_url);
if (!imageResponse.ok) {
throw new Error("下载原图失败");
const storedImageUrl = await resolveStoredQiniuImageUrl(image_url);
publicImageUrl = storedImageUrl || image_url;
if (!storedImageUrl) {
try {
console.log("[WatermarkRemove] 下载原图并上传到S3...");
const imageResponse = await fetch(image_url);
if (!imageResponse.ok) {
throw new Error("下载原图失败");
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-input-${Date.now()}.jpg`;
publicImageUrl = (await uploadFile(filename, imageBuffer)).url;
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
} catch (downloadError) {
console.error("[WatermarkRemove] 下载/上传原图失败,使用原始URL:", downloadError);
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-input-${Date.now()}.jpg`;
const uploadResult = await uploadFile(filename, imageBuffer);
publicImageUrl = uploadResult.url;
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
} catch (downloadError) {
console.error("[WatermarkRemove] 下载/上传原图失败:", downloadError);
// 如果下载失败,尝试使用原始URL
console.log("[WatermarkRemove] 尝试使用原始URL");
publicImageUrl = image_url;
}
} else {
return NextResponse.json(

File diff suppressed because it is too large Load Diff

View File

@ -48,8 +48,13 @@ export default function CanvasStyleProPage() {
const canvasData = result.data.canvasData;
const latestState = useCanvasStore.getState();
setProject(result.data.id, result.data.title);
if (latestState.nodes.length === 0 && latestState.edges.length === 0) {
if (
latestState.projectId !== id ||
(latestState.nodes.length === 0 && latestState.edges.length === 0)
) {
loadCanvas(canvasData);
const revision = String(result.data.updatedAt || "");
useCanvasStore.getState().setCanvasRevision(revision, revision);
return;
}

View File

@ -3,6 +3,8 @@ import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider";
import { FaviconUpdater } from "@/components/favicon-updater";
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import { AppUpdateNotice } from "@/components/app-update-notice";
import { PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE } from "@/lib/client/page-refresh";
import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css";
@ -141,7 +143,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice();
return;
}
if (lastReloadAt) return;
if (lastReloadAt || document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) return;
var now = Date.now();
// If storage is blocked, a reload cannot remember its attempt and must not run.
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
@ -152,6 +154,10 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice();
return;
}
if (document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) {
if (safeSetSessionStorage(RECOVERY_KEY, '0')) lastReloadAt = 0;
return;
}
window.location.reload();
}, 800);
}
@ -215,6 +221,7 @@ export default function RootLayout({
enableSystem
>
<Toaster position="top-center" />
<AppUpdateNotice />
<SessionProvider>{children}</SessionProvider>
</ThemeProvider>
</body>

View File

@ -0,0 +1,30 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
export function AdminSurface({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let context: { revert: () => void } | undefined;
let disposed = false;
import("gsap").then(({ gsap }) => {
if (disposed) return;
context = gsap.context(() => {
gsap.fromTo(
ref.current,
{ y: 6, opacity: 0.85 },
{ y: 0, opacity: 1, duration: 0.25, ease: "power3.out", clearProps: "transform,opacity" },
);
}, ref);
});
return () => {
disposed = true;
context?.revert();
};
}, []);
return (
<div ref={ref} className="space-y-6">
{children}
</div>
);
}

View File

@ -0,0 +1,603 @@
"use client";
import { useEffect, useState } from "react";
import useSWR from "swr";
import {
RefreshCw,
Search,
ChevronLeft,
ChevronRight,
ExternalLink,
Download,
Star,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner";
import { MediaThumbnail } from "./media-thumbnail";
import { AdminSurface } from "./admin-surface";
import { formatRecordTime } from "@/lib/admin/record-time";
import { buildImageProxyUrl } from "@/lib/image-delivery";
export type MediaRecord = {
id: string;
recordType: "image" | "video";
userName: string | null;
userEmail: string | null;
userDepartment: string | null;
createdAt: string;
modelId: string | null;
chatId: string | null;
thumbnailUrl: string;
originalUrl: string;
videoUrl?: string | null;
prompt: string | null;
imageSize: string | null;
isFeatured: boolean;
featuredCategory: string | null;
featuredAt?: string | null;
};
type MediaResponse = {
images: MediaRecord[];
pagination: {
total: number;
totalPages: number;
totalImages: number;
totalVideos: number;
page: number;
};
};
export async function adminFetcher(url: string) {
const response = await fetch(url);
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.message || "加载失败,请重试");
return data;
}
const selectClass =
"h-10 rounded-md border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
function recentDate() {
const d = new Date();
d.setDate(d.getDate() - 6);
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(d);
}
export function MediaRecordsPanel({ categories }: { categories: string[] }) {
const [view, setView] = useState("assets");
const [search, setSearch] = useState("");
const [filters, setFilters] = useState({
search: "",
type: "all",
startDate: recentDate(),
endDate: "",
model: "",
featured: "false",
status: "all",
});
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<MediaRecord | null>(null);
const [saving, setSaving] = useState(false);
const [originalFailed, setOriginalFailed] = useState(false);
const update = (changes: Partial<typeof filters>) => {
setFilters((f) => ({ ...f, ...changes }));
setPage(1);
};
useEffect(() => {
const timer = setTimeout(() => {
setFilters((f) => (f.search === search ? f : { ...f, search }));
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [search]);
const { data: options } = useSWR<{
models: Array<{ id: string; modelId: string; displayName: string; providerName: string }>;
}>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false });
const modelNames = new Map<string, string>();
for (const model of options?.models || [])
if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName);
const recordModelNames = new Map(modelNames);
for (const model of options?.models || []) recordModelNames.set(model.id, model.displayName);
const openDetails = (item: MediaRecord) => {
setSelected(item);
setOriginalFailed(false);
};
const query = new URLSearchParams({ ...filters, page: String(page), pageSize: "40" });
const { data, error, isLoading, isValidating, mutate } = useSWR<MediaResponse>(
view === "assets" ? `/api/admin/images?${query}` : null,
adminFetcher,
{ keepPreviousData: true, revalidateOnFocus: false },
);
const tasks = useSWR<{
tasks: Array<{
id: string;
userName: string;
status: string;
createdAt: string;
error: string | null;
model: string | null;
elapsedMs: number | null;
}>;
total: number;
}>(view === "tasks" ? `/api/admin/media-tasks?${query}` : null, adminFetcher, {
keepPreviousData: true,
revalidateOnFocus: false,
});
const busy = view === "assets" ? isValidating : tasks.isValidating;
const failure = view === "assets" ? error : tasks.error;
const loading = view === "assets" ? isLoading : tasks.isLoading;
const total = view === "assets" ? data?.pagination.total : tasks.data?.total;
const pages = Math.max(1, Math.ceil((total || 0) / 40));
async function save(changes: Partial<MediaRecord>) {
if (!selected || saving) return;
setSaving(true);
try {
const response = await fetch("/api/admin/images", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageId: selected.id, ...changes }),
});
if (!response.ok) throw new Error("保存失败,请重试");
setSelected({ ...selected, ...changes });
toast.success("已保存");
await mutate();
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
return (
<AdminSurface>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-tight">生成记录</h2>
<p className="mt-2 text-sm text-muted-foreground">
查看图片与视频产物,追踪画布任务。时间统一按北京时间展示。
</p>
</div>
<Button
variant="outline"
disabled={busy}
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
>
<RefreshCw className={`mr-2 size-4 ${busy ? "motion-safe:animate-spin" : ""}`} />
刷新
</Button>
</div>
<div className="flex gap-2" role="tablist" aria-label="记录视图">
{[
["assets", "产物记录"],
["tasks", "画布任务"],
].map(([key, label]) => (
<Button
key={key}
role="tab"
aria-selected={view === key}
variant={view === key ? "default" : "ghost"}
onClick={() => {
setView(key);
setPage(1);
}}
>
{label}
</Button>
))}
</div>
<div className="flex flex-wrap items-end gap-3 rounded-xl border bg-background p-4">
<label className="min-w-48 flex-1 text-xs font-medium">
用户
<div className="relative mt-2">
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
className="h-10 pl-9"
placeholder="搜索姓名或邮箱"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</label>
{view === "assets" ? (
<label className="text-xs font-medium">
类型
<select
aria-label="产物类型"
className={`${selectClass} mt-2 block`}
value={filters.type}
onChange={(e) => update({ type: e.target.value })}
>
<option value="all">图片与视频</option>
<option value="image">只看图片</option>
<option value="video">只看视频</option>
</select>
</label>
) : (
<label className="text-xs font-medium">
任务状态
<select
aria-label="任务状态"
className={`${selectClass} mt-2 block`}
value={filters.status}
onChange={(e) => update({ status: e.target.value })}
>
<option value="all">全部状态</option>
<option value="failed">失败</option>
<option value="running">运行中</option>
<option value="pending">排队中</option>
<option value="succeeded">成功</option>
</select>
</label>
)}
<label className="text-xs font-medium">
开始日期
<Input
aria-label="开始日期"
className="mt-2 h-10 w-40"
type="date"
value={filters.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
</label>
<label className="text-xs font-medium">
结束日期
<Input
aria-label="结束日期"
className="mt-2 h-10 w-40"
type="date"
value={filters.endDate}
onChange={(e) => update({ endDate: e.target.value })}
/>
</label>
<Button
variant="ghost"
onClick={() => {
setSearch("");
update({
search: "",
startDate: "",
endDate: "",
model: "",
featured: "false",
type: "all",
status: "all",
});
}}
>
全部时间 / 重置
</Button>
{view === "assets" && (
<div className="flex w-full flex-wrap items-center gap-3">
<select
aria-label="记录模型"
className={`${selectClass} max-w-full sm:max-w-96`}
value={filters.model}
onChange={(e) => update({ model: e.target.value })}
>
<option value="">全部模型</option>
{Array.from(modelNames, ([id, name]) => (
<option key={id} value={id}>
{name} · {id}
</option>
))}
</select>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={filters.featured === "true"}
onChange={(e) => update({ featured: String(e.target.checked) })}
/>
只看精选
</label>
</div>
)}
</div>
<div
aria-live="polite"
className="flex items-center justify-between text-sm text-muted-foreground"
>
<span>
{total === undefined ? "正在读取记录…" : `共 ${total.toLocaleString()} 条记录`}
{view === "assets" && data
? ` · 图片 ${data.pagination.totalImages.toLocaleString()} · 视频 ${data.pagination.totalVideos.toLocaleString()}`
: ""}
</span>
{busy && !loading && <span>正在更新…</span>}
</div>
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
第 {page} / {pages} 页 · 每页 40 条
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={busy || page === 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={busy || page >= pages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
{failure ? (
<div role="alert" className="rounded-xl border border-destructive/30 p-6 text-sm">
<p>{failure.message}</p>
<Button
className="mt-3"
variant="outline"
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
>
重试加载
</Button>
</div>
) : loading ? (
<div role="status" className="space-y-3 rounded-xl border p-6">
{[0, 1, 2, 3].map((x) => (
<div key={x} className="h-16 rounded-lg bg-muted motion-safe:animate-pulse" />
))}
<span className="sr-only">正在加载记录</span>
</div>
) : total === 0 ? (
<div className="rounded-xl border border-dashed p-12 text-center text-sm text-muted-foreground">
没有符合条件的记录,请调整筛选条件。
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-background" aria-busy={busy}>
{view === "assets" ? (
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
{["产物", "用户", "生成时间", "模型 / 尺寸", "精选", ""].map((v, i) => (
<th key={i} className="px-4 py-3 font-medium">
{v}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{data?.images.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-4">
<div>
<MediaThumbnail
url={item.thumbnailUrl}
video={item.recordType === "video"}
onOpen={() => openDetails(item)}
/>
</div>
</td>
<td className="px-4 py-3">
<div className="font-medium">{item.userName || item.userEmail}</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.userDepartment || "未设置部门"}
</div>
</td>
<td className="whitespace-nowrap px-4 py-3 tabular-nums">
{formatRecordTime(item.createdAt)}
</td>
<td className="max-w-64 px-4 py-3">
<div className="truncate" title={item.modelId || ""}>
{recordModelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.recordType === "video" ? "视频" : "图片"} ·{" "}
{item.imageSize || "未知尺寸"}
</div>
</td>
<td className="px-4 py-3">
{item.isFeatured ? (
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
<Star className="size-3" />
{item.featuredCategory || "精选"}
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-3">
<Button variant="ghost" size="sm" onClick={() => openDetails(item)}>
详情
</Button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-muted/50">
<tr>
{["用户 / 时间", "状态", "模型", "耗时", "失败原因 / 任务编号"].map((x) => (
<th key={x} className="px-4 py-3 font-medium">
{x}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{tasks.data?.tasks.map((task) => (
<tr key={task.id}>
<td className="px-4 py-4">
{task.userName}
<div className="mt-1 text-xs text-muted-foreground">
{formatRecordTime(task.createdAt)}
</div>
</td>
<td className="px-4 py-4">
{{ failed: "失败", running: "运行中", succeeded: "成功", pending: "排队中" }[
task.status
] || task.status}
</td>
<td className="max-w-48 break-words px-4 py-4">{task.model || "自动选择"}</td>
<td className="px-4 py-4 tabular-nums">
{task.elapsedMs === null ? "—" : `${Math.round(task.elapsedMs / 1000)} 秒`}
</td>
<td className="max-w-xl break-words px-4 py-4">
<p className={task.error ? "text-destructive" : "text-muted-foreground"}>
{task.error || "—"}
</p>
<code className="mt-2 block text-xs text-muted-foreground">{task.id}</code>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
每页 40 条 · 第 {page} / {pages} 页
</span>
<div className="flex items-center gap-2">
<Button variant="outline" disabled={busy || page === 1} onClick={() => setPage(1)}>
首页
</Button>
<Button
aria-label="上一页"
variant="outline"
disabled={busy || page === 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
aria-label="下一页"
variant="outline"
disabled={busy || page >= pages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="size-4" />
</Button>
<Button variant="outline" disabled={busy || page >= pages} onClick={() => setPage(pages)}>
末页
</Button>
</div>
</div>
<Dialog
open={!!selected}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto bg-background">
<DialogHeader>
<DialogTitle>{selected?.recordType === "video" ? "视频详情" : "图片详情"}</DialogTitle>
</DialogHeader>
{selected && (
<div className="space-y-6">
<div className="flex min-h-40 justify-center rounded-xl bg-muted">
{selected.recordType === "video" ? (
<video
src={selected.videoUrl || selected.originalUrl}
controls
preload="none"
className="max-h-[45vh] w-full"
/>
) : originalFailed ? (
<div role="alert" className="p-8 text-sm">
原图无法加载,源文件可能已过期或存储不可达。
<Button
className="ml-3"
variant="outline"
onClick={() => setOriginalFailed(false)}
>
重试
</Button>
</div>
) : (
<img
src={selected.originalUrl}
alt="生成原图"
className="max-h-[45vh] object-contain"
onError={() => setOriginalFailed(true)}
/>
)}
</div>
<dl className="grid gap-4 text-sm sm:grid-cols-2">
{[
["用户", selected.userName || selected.userEmail],
["部门", selected.userDepartment],
["生成时间", formatRecordTime(selected.createdAt)],
["尺寸", selected.imageSize],
["模型", recordModelNames.get(selected.modelId || "") || selected.modelId],
["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"],
].map(([k, v]) => (
<div key={k}>
<dt className="text-xs text-muted-foreground">{k}</dt>
<dd className="mt-1 break-words">{v || "未记录"}</dd>
</div>
))}
</dl>
<div>
<h3 className="text-sm font-medium">提示词</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
{selected.prompt || "未记录提示词"}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" asChild>
<a href={selected.originalUrl} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 size-4" />
打开原文件
</a>
</Button>
{selected.recordType === "image" && (
<Button variant="outline" asChild>
<a
href={buildImageProxyUrl(selected.originalUrl, { delivery: "original" })}
download
>
<Download className="mr-2 size-4" />
下载原图
</a>
</Button>
)}
</div>
{selected.recordType === "image" && (
<div className="flex flex-wrap items-center gap-3 rounded-xl border p-4">
<select
aria-label="精选分类"
className={selectClass}
value={selected.featuredCategory || ""}
onChange={(e) =>
setSelected({ ...selected, featuredCategory: e.target.value || null })
}
>
<option value="">未分类</option>
{[...new Set([...categories, selected.featuredCategory].filter(Boolean))].map(
(c) => (
<option key={c} value={c!}>
{c}
</option>
),
)}
</select>
<Button
variant="outline"
disabled={saving}
onClick={() => save({ featuredCategory: selected.featuredCategory })}
>
保存分类
</Button>
<Button
disabled={saving}
onClick={() => save({ isFeatured: !selected.isFeatured })}
>
<Star className="mr-2 size-4" />
{selected.isFeatured ? "取消精选" : "设为精选"}
</Button>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</AdminSurface>
);
}

View File

@ -0,0 +1,150 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ImageOff, Video, RotateCw } from "lucide-react";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
import { queuePreview } from "@/lib/admin/preview-queue";
export function MediaThumbnail({
url,
video = false,
onOpen,
}: {
url: string;
video?: boolean;
onOpen?: () => void;
}) {
const container = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"waiting" | "loading" | "ready" | "error">("waiting");
const [attempt, setAttempt] = useState(0);
const [source, setSource] = useState("");
const [failure, setFailure] = useState("");
const retired = isRetiredStorageUrl(url);
useEffect(() => {
setState("waiting");
setSource("");
setFailure("");
if (video || retired || !url || !container.current) return;
let dispose: (() => void) | undefined;
let abort = () => {};
let objectUrl = "";
let alive = true;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting || dispose) return;
observer.disconnect();
dispose = queuePreview((release) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 48000);
setState("loading");
// Fetch is abortable, so leaving a page also stops in-flight previews.
const parsed = new URL(url, window.location.origin);
const target =
parsed.hostname === "scqnycdn.jiansun.vip" && !parsed.search
? `${url}?imageView2/2/w/192/q/72/format/webp`
: buildImageProxyUrl(url, {
width: 192,
quality: 72,
format: "webp",
delivery: "proxy",
});
fetch(target, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) {
const detail = await response.json().catch(() => null);
throw new Error(`${detail?.error || "预览请求失败"}(HTTP ${response.status})`);
}
const blob = await response.blob();
if (!blob.type.startsWith("image/")) throw new Error("Not an image");
if (alive) {
objectUrl = URL.createObjectURL(blob);
setSource(objectUrl);
setState("ready");
}
})
.catch((error) => {
if (alive) {
setFailure(
error.name === "AbortError" ? "预览加载超时" : error.message || "预览加载失败",
);
setState("error");
}
})
.finally(() => {
clearTimeout(timeout);
release();
});
abort = () => {
controller.abort();
clearTimeout(timeout);
};
});
},
{ rootMargin: "80px" },
);
observer.observe(container.current);
return () => {
alive = false;
observer.disconnect();
abort();
dispose?.();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [url, video, retired, attempt]);
return (
<div
ref={container}
className="relative flex h-16 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
data-preview-state={state}
>
{video ? (
<button
type="button"
aria-label="查看视频详情"
className="flex h-full w-full items-center justify-center"
onClick={onOpen}
>
<Video className="size-5 text-muted-foreground" aria-label="视频,点击查看后播放" />
</button>
) : retired ? (
<span className="text-center text-xs text-muted-foreground">
旧存储
<br />
已停用
</span>
) : source && state === "ready" ? (
<button type="button" aria-label="查看图片详情" className="h-full w-full" onClick={onOpen}>
<img
src={source}
alt="生成结果预览"
className="h-full w-full object-contain"
onError={() => {
setFailure("图片内容无法解码");
setState("error");
}}
/>
</button>
) : state === "error" ? (
<button
type="button"
className="flex h-full w-full flex-col items-center justify-center gap-1 text-xs text-muted-foreground hover:text-foreground"
title={`${failure},点击重试`}
onClick={(e) => {
e.stopPropagation();
setAttempt((x) => x + 1);
}}
>
<ImageOff className="size-4" />
加载失败
</button>
) : (
<RotateCw
className={`size-4 text-muted-foreground ${state === "loading" ? "motion-safe:animate-spin" : ""}`}
aria-label={state === "loading" ? "正在加载预览" : "等待进入可见区域"}
/>
)}
</div>
);
}

View File

@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetcher } from "./media-records-panel";
import { formatRecordTime } from "@/lib/admin/record-time";
type Event = {
id: string;
modelDisplayName: string;
modelRuntimeId: string;
providerName: string;
eventType: string;
createdAt: string;
errorMessage: string;
elapsedMs: number | null;
consecutiveFailures: number;
autoDisabled: boolean;
};
export function ModelHealthPanel({
providers,
models,
initialModelId = "",
}: {
initialModelId?: string;
providers: Array<{ id: string; name: string }>;
models: Array<{ id: string; displayName: string; providerId: string }>;
}) {
const [filters, setFilters] = useState({
providerId: "",
modelId: initialModelId,
eventType: "",
startDate: "",
endDate: "",
});
const [page, setPage] = useState(1);
const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" });
const { data, error, isLoading, isValidating, mutate } = useSWR<{
events: Event[];
hasMore: boolean;
}>(`/api/admin/ai-model-health?${query}`, adminFetcher, {
keepPreviousData: true,
revalidateOnFocus: false,
});
const update = (change: Partial<typeof filters>) => {
setFilters((f) => ({ ...f, ...change }));
setPage(1);
};
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">运行健康</h3>
<p className="mt-2 text-sm text-muted-foreground">
按上游和模型追踪故障,查看完整原因与发生时间。
</p>
</div>
<div className="flex flex-wrap gap-3">
{[
["providerId", "全部上游", providers.map((p) => [p.id, p.name])],
[
"modelId",
"全部模型",
models
.filter((m) => !filters.providerId || m.providerId === filters.providerId)
.map((m) => [m.id, m.displayName]),
],
[
"eventType",
"全部错误",
[
["quota", "额度 / 余额"],
["timeout", "超时"],
["unavailable", "不可用"],
["invalid_model", "模型不可用"],
["download_error", "结果下载失败"],
["error", "其他错误"],
["success", "成功"],
],
],
].map(([key, label, options]) => (
<select
key={key as string}
aria-label={label as string}
className="h-10 max-w-64 rounded-md border bg-background px-3 text-sm"
value={filters[key as keyof typeof filters]}
onChange={(e) =>
update({
[key as string]: e.target.value,
...(key === "providerId" ? { modelId: "" } : {}),
})
}
>
<option value="">{label as string}</option>
{(options as string[][]).map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
))}
<Input
aria-label="故障开始日期"
className="h-10 w-40"
type="date"
value={filters.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
<Input
aria-label="故障结束日期"
className="h-10 w-40"
type="date"
value={filters.endDate}
onChange={(e) => update({ endDate: e.target.value })}
/>
<Button variant="outline" disabled={isValidating} onClick={() => mutate()}>
刷新日志
</Button>
</div>
{error ? (
<p role="alert" className="p-6 text-sm text-destructive">
日志加载失败,请点击刷新重试。
</p>
) : isLoading ? (
<p role="status" className="p-6 text-sm">
正在加载故障日志…
</p>
) : (
<div className="divide-y rounded-xl border">
{data?.events.map((event) => (
<div key={event.id} className="p-4">
<div className="flex flex-wrap items-center gap-3 text-sm">
<strong className="font-semibold">
{event.modelDisplayName || event.modelRuntimeId || "已删除模型"}
</strong>
<span className="text-muted-foreground">{event.providerName || "未知上游"}</span>
<span className="rounded bg-muted px-2 py-1 text-xs">
{{
quota: "额度 / 余额",
timeout: "超时",
unavailable: "不可用",
error: "服务错误",
invalid_model: "模型不可用",
download_error: "结果下载失败",
success: "成功",
}[event.eventType] || event.eventType}
</span>
<time className="ml-auto text-xs tabular-nums text-muted-foreground">
{formatRecordTime(event.createdAt)}
</time>
</div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm">
{event.errorMessage || "未记录详细原因"}
</p>
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
<span>连续失败 {event.consecutiveFailures} 次</span>
{event.elapsedMs !== null && (
<span>耗时 {Math.round(event.elapsedMs / 1000)} 秒</span>
)}
{event.autoDisabled && <span>已自动停用</span>}
</div>
</div>
))}
{!data?.events.length && (
<p className="p-12 text-center text-sm text-muted-foreground">该范围内没有故障记录。</p>
)}
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
第 {page} 页{isValidating && " · 正在更新…"}
</span>
<div className="flex gap-2">
<Button
variant="outline"
disabled={page === 1 || isValidating}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<Button
variant="outline"
disabled={!data?.hasMore || isValidating}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,326 @@
"use client";
import { useMemo, useState } from "react";
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { AiModel } from "@/lib/db/schema";
import {
is3DModelDescriptor,
isVideoModelDescriptor,
isImageGenerationModelDescriptor,
} from "@/lib/ai/model-classification";
type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean };
type Provider = { id: string; name: string; type: string; isEnabled: boolean };
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm";
export function ModelManagementList({
models,
providers,
error,
loading,
onEdit,
onDelete,
onRestore,
onRefresh,
onHealth,
}: {
models: Model[];
providers: Provider[];
error?: Error;
loading: boolean;
onEdit: (model: Model) => Promise<void>;
onDelete: (id: string) => void;
onRestore: (model: Model) => void;
onRefresh: () => void;
onHealth?: (id: string) => void;
}) {
const [search, setSearch] = useState("");
const [provider, setProvider] = useState("");
const [status, setStatus] = useState("enabled");
const [kind, setKind] = useState("");
const [page, setPage] = useState(1);
const [opening, setOpening] = useState<string | null>(null);
const rows = useMemo(
() =>
models
.map((model) => {
const upstream = providers.find((p) => p.id === model.providerId);
const descriptor = { ...model, providerType: upstream?.type };
const type = is3DModelDescriptor(descriptor)
? "3d"
: isVideoModelDescriptor(descriptor)
? "video"
: isImageGenerationModelDescriptor(descriptor)
? "image"
: "chat";
return { model, upstream, type };
})
.filter(({ model, upstream, type }) => {
const text =
`${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase();
return (
text.includes(search.trim().toLowerCase()) &&
(!provider || model.providerId === provider) &&
(!kind || kind === type) &&
(status === "all" ||
(status === "enabled" && model.isEnabled) ||
(status === "disabled" && !model.isEnabled) ||
(status === "unhealthy" && model.healthStatus !== "healthy") ||
(status === "visible" && !model.isHidden))
);
}),
[models, providers, search, provider, status, kind],
);
const pages = Math.max(1, Math.ceil(rows.length / 25));
const currentPage = Math.min(page, pages);
const change = (fn: () => void) => {
fn();
setPage(1);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<div className="relative min-w-56 flex-1">
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
className="h-10 pl-9"
aria-label="搜索模型"
placeholder="搜索模型名称、ID 或上游"
value={search}
onChange={(e) => change(() => setSearch(e.target.value))}
/>
</div>
<select
aria-label="模型上游"
className={selectClass}
value={provider}
onChange={(e) => change(() => setProvider(e.target.value))}
>
<option value="">全部上游</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<select
aria-label="模型类型"
className={selectClass}
value={kind}
onChange={(e) => change(() => setKind(e.target.value))}
>
<option value="">全部类型</option>
<option value="image">生图</option>
<option value="video">视频</option>
<option value="chat">对话</option>
<option value="3d">3D / CAD</option>
</select>
<select
aria-label="模型状态"
className={selectClass}
value={status}
onChange={(e) => change(() => setStatus(e.target.value))}
>
<option value="enabled">已启用</option>
<option value="all">全部模型</option>
<option value="disabled">已停用</option>
<option value="unhealthy">运行异常</option>
<option value="visible">前台可见</option>
</select>
<Button variant="outline" onClick={onRefresh} aria-label="刷新模型">
<RefreshCw className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{loading
? "正在加载模型…"
: `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`}
</p>
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>
第 {currentPage} / {pages} 页
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage === pages}
onClick={() => setPage(currentPage + 1)}
>
下一页
</Button>
</div>
{error ? (
<div role="alert" className="rounded-xl border p-6 text-sm">
模型加载失败。
<Button variant="outline" onClick={onRefresh}>
重试
</Button>
</div>
) : loading ? (
<div role="status" className="h-48 rounded-xl bg-muted motion-safe:animate-pulse" />
) : (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full min-w-[850px] text-left text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
{["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => (
<th key={x} className="px-4 py-3 font-medium">
{x}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{rows
.slice((currentPage - 1) * 25, currentPage * 25)
.map(({ model, upstream, type }) => (
<tr key={model.id} className="hover:bg-muted/30">
<td className="max-w-64 px-4 py-4">
<div className="font-medium">{model.displayName}</div>
<div
className="mt-1 truncate text-xs text-muted-foreground"
title={model.modelId}
>
{model.modelId}
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{model.isDefault && <span>默认模型</span>}
{model.isDefaultTextEditModel && <span>文字编辑默认</span>}
{model.isAgentModel && <span>Agent</span>}
{model.isMultimodal && <span>多模态</span>}
{model.isSubModel && <span>子模型</span>}
</div>
</td>
<td className="px-4 py-4">
{upstream?.name || "未知上游"}
<div className="mt-1 text-xs text-muted-foreground">
{{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]}
{upstream && !upstream.isEnabled ? " · 上游已停用" : ""}
</div>
</td>
<td className="whitespace-nowrap px-4 py-4">
<span
className={`rounded-md px-2 py-1 text-xs ${model.isEnabled ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "bg-muted text-muted-foreground"}`}
>
{model.isEnabled ? "已启用" : "已停用"}
</span>
</td>
<td className="px-4 py-4">
{model.isHidden || model.isSubModel ? "隐藏" : "可见"}
</td>
<td className="max-w-72 px-4 py-4">
<span
className={
model.healthStatus !== "healthy"
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground"
}
>
{model.healthStatus === "auto_disabled"
? "自动停用"
: model.healthStatus === "degraded"
? "观察中"
: "正常"}
</span>
{model.healthConsecutiveFailures > 0 && (
<span className="ml-2 text-xs text-muted-foreground">
连续失败 {model.healthConsecutiveFailures} 次
</span>
)}
{model.healthLastError && (
<details className="mt-2 text-xs">
<summary className="cursor-pointer text-muted-foreground">
查看最近原因
</summary>
<p className="mt-2 whitespace-pre-wrap break-words">
{model.healthLastError}
</p>
</details>
)}
<button
type="button"
className="mt-2 block text-xs underline underline-offset-4"
onClick={() => onHealth?.(model.id)}
>
故障记录
</button>
<div className="mt-2 text-xs text-muted-foreground">
{model.excludeFromHealthBan && "不自动封禁 "}
{model.excludeFromFailover && "不参与迁移"}
</div>
</td>
<td className="px-4 py-4">
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={opening !== null}
onClick={async () => {
setOpening(model.id);
try {
await onEdit(model);
} finally {
setOpening(null);
}
}}
>
{opening === model.id ? "加载中…" : "编辑"}
</Button>
{model.healthStatus === "auto_disabled" && (
<Button variant="outline" size="sm" onClick={() => onRestore(model)}>
恢复
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => onDelete(model.id)}
>
删除
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<p className="p-12 text-center text-sm text-muted-foreground">没有符合条件的模型。</p>
)}
</div>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
每页 25 个 · 第 {currentPage} / {pages} 页
</span>
<div className="flex gap-2">
<Button
aria-label="上一页模型"
variant="outline"
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
aria-label="下一页模型"
variant="outline"
disabled={currentPage === pages}
onClick={() => setPage(currentPage + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
"use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
@ -575,31 +576,11 @@ export function AgentGenerationWorkspace({
toast.error("图片地址无效");
return;
}
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
toast.success("原图已开始下载");
} catch (error) {
toast.error(getCanvasImageDownloadErrorMessage(error));
}
}, []);

View File

@ -1,7 +1,10 @@
"use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
import { DefaultChatTransport } from "ai";
import {
@ -156,6 +159,7 @@ import type {
} from "@/types/agent";
import {
createEmptyAgentModeWorkspaceState,
MAX_DETAIL_REPLACEMENT_REFERENCES,
} from "@/lib/agents/mode-workspace-state";
import {
createEmptyAgentGenerationProjectWorkspaceState,
@ -173,6 +177,7 @@ export interface AgentGenerationWorkspaceDarkV2Props {
workflowFeatureSettings?: AgentWorkflowPageFeatureSettings | null;
workflowPromptOptimizationEnabled?: boolean;
getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null;
getWorkflowPromptStatus?: () => "ready" | "loading" | "error";
onBack?: () => void;
brandStorePanel?: ReactNode;
materialSelectionPanel?: ReactNode;
@ -209,6 +214,7 @@ export interface AgentGenerationWorkspaceDarkV2Props {
interface AgentWorkflowModelOption {
id: string;
displayName: string;
providerName?: string;
providerType: string;
supportsImageGen?: boolean;
isMultimodal?: boolean;
@ -2639,7 +2645,9 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
? record.requestedCardIds
.map((entry) => String(entry || "").trim())
.filter(Boolean)
.slice(0, MAX_AGENT_MODE_REFERENCE_COUNT)
.slice(0, record.modeType === "detail_replacement"
? MAX_DETAIL_REPLACEMENT_REFERENCES
: MAX_AGENT_MODE_REFERENCE_COUNT)
: null,
} satisfies LocalGenerationPendingState;
});
@ -3082,6 +3090,7 @@ export function AgentGenerationWorkspaceDarkV2({
workflowFeatureSettings = null,
workflowPromptOptimizationEnabled = true,
getLatestWorkflowContext,
getWorkflowPromptStatus,
onBack,
brandStorePanel,
materialSelectionPanel,
@ -3379,12 +3388,13 @@ export function AgentGenerationWorkspaceDarkV2({
}
);
const { data: modelsData } = useSWR<{
const { data: modelsData, error: modelsError, mutate: refreshModels } = useSWR<{
models: AgentWorkflowModelOption[];
defaultModelId?: string | null;
agentWorkflowConfiguredDefaultModelId?: string | null;
agentWorkflowDefaultModelId?: string | null;
agentWorkflowConfiguredModels?: AgentWorkflowModelOption[];
detailReplacementModel?: AgentWorkflowModelOption | null;
agentModel?: any;
}>("/api/models", fetcher);
@ -3430,17 +3440,6 @@ export function AgentGenerationWorkspaceDarkV2({
const activeBusinessModeDefaultModelId = !requiresPresetSelection
? String(activeBusinessMode?.defaultModelId || "").trim() || null
: null;
const lockedWorkflowModelId =
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId =
lockedWorkflowModelId ||
modelsData?.defaultModelId ||
null;
const presetDrivenResultModeType: AgentGenerationResultModeType =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"
? detailReplacementMethod === "ai_detail_generate"
@ -3456,6 +3455,19 @@ export function AgentGenerationWorkspaceDarkV2({
const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection
? presetDrivenResultModeType
: businessModeResultModeType;
const detailReplacementModel = currentResultModeType === "detail_replacement"
? modelsData?.detailReplacementModel
: null;
const isDetailReplacementModelLoading = currentResultModeType === "detail_replacement" && !modelsData;
const lockedWorkflowModelId =
detailReplacementModel?.id ||
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId = lockedWorkflowModelId || modelsData?.defaultModelId || null;
const isSceneGenerationPreset = currentResultModeType === "scene_generation";
const isDetailReplacementPreset =
currentResultModeType === "detail_replacement" ||
@ -4107,12 +4119,15 @@ export function AgentGenerationWorkspaceDarkV2({
);
const executableModelOptions = useMemo(() => {
const seen = new Set<string>();
return [...selectableModels, ...configuredWorkflowModels].filter((model) => {
const workflowModels = detailReplacementModel
? [detailReplacementModel, ...configuredWorkflowModels]
: configuredWorkflowModels;
return [...selectableModels, ...workflowModels].filter((model) => {
if (seen.has(model.id)) return false;
seen.add(model.id);
return true;
});
}, [configuredWorkflowModels, selectableModels]);
}, [configuredWorkflowModels, detailReplacementModel, selectableModels]);
const executableModelIdSet = useMemo(
() => new Set(executableModelOptions.map((model) => model.id)),
[executableModelOptions]
@ -4131,8 +4146,9 @@ export function AgentGenerationWorkspaceDarkV2({
selectableModels[0],
[executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId]
);
const lockedWorkflowModelLabel =
lockedWorkflowModel?.displayName ||
const lockedWorkflowModelLabel = detailReplacementModel?.providerName
? `${detailReplacementModel.displayName} 「${detailReplacementModel.providerName}」`
: lockedWorkflowModel?.displayName ||
selectedAgentPreset?.defaultModel?.displayName ||
(lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置");
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings");
@ -4169,6 +4185,7 @@ export function AgentGenerationWorkspaceDarkV2({
? "agent-anchor-materials"
: "agent-anchor-generate";
const [finishedSceneRequestId, setFinishedSceneRequestId] = useState<string | null>(null);
const {
messages,
setMessages,
@ -4189,7 +4206,11 @@ export function AgentGenerationWorkspaceDarkV2({
void refreshHistory();
}
},
onFinish() { void refreshHistory(); },
onFinish({ messages: finishedMessages }) {
const lastUser = finishedMessages.filter((message) => message.role === "user").at(-1);
setFinishedSceneRequestId(lastUser?.id || null);
void refreshHistory();
},
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
@ -4872,6 +4893,14 @@ export function AgentGenerationWorkspaceDarkV2({
return recoverReplacementRun({ messages, ...pendingGeneration });
}, [messages, pendingGeneration]);
const transportIsGenerating = status === "streaming" || status === "submitted";
useEffect(() => {
const beforeRefresh = (event: Event) => {
if (transportIsGenerating || pendingGeneration)
(event as CustomEvent<PageRefreshRequest>).detail.block("当前生图任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [transportIsGenerating, pendingGeneration]);
const pendingGenerationProgressCount = useMemo(() => {
if (!pendingGeneration) {
return 0;
@ -5482,6 +5511,17 @@ export function AgentGenerationWorkspaceDarkV2({
setActiveResultModeType(currentResultModeType);
}, [currentResultModeType]);
useEffect(() => {
if (!pendingGeneration?.requestMessageId || pendingGeneration.modeType !== "scene_generation" || transportIsGenerating) return;
const result = getSceneRunResult(messages, pendingGeneration.requestMessageId);
const persisted = getSceneRunResult(historyMessages, pendingGeneration.requestMessageId);
if (!result || (finishedSceneRequestId !== pendingGeneration.requestMessageId && !persisted?.count)) return;
setPendingGeneration(null);
if (result.count < pendingGeneration.expectedCount) {
toast.warning(`本次生成已结束,收到 ${result.count}/${pendingGeneration.expectedCount} 张图片。缺少的图片未继续生成,可调整张数后重新提交。`);
}
}, [pendingGeneration, transportIsGenerating, finishedSceneRequestId, messages, historyMessages]);
useEffect(() => {
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
// The backend has settled every requested card; an open SSE connection is no longer work.
@ -6287,10 +6327,24 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
return;
}
if (isDetailReplacementModelLoading) {
if (modelsError) {
toast.error("详情替换模型加载失败,正在重新读取");
void refreshModels().catch(() => {});
} else {
toast.info("正在读取详情替换模型,请稍候");
}
return;
}
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
return;
}
const promptStatus = getWorkflowPromptStatus?.();
if (promptStatus === "loading" || promptStatus === "error") {
toast.error(promptStatus === "error" ? "请先在风格模板区重试加载提示词" : "正在加载所选模板的提示词,请稍候");
return;
}
const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext;
if (
isDetailReplacementPreset &&
@ -6349,6 +6403,10 @@ export function AgentGenerationWorkspaceDarkV2({
}
if (replacementModeType === "detail_replacement") {
if (detailBatchSubmittingRef.current) return;
if (replacementExecutionCards.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
toast.error(`本次选中了 ${replacementExecutionCards.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`);
return;
}
if (!isValidChatUuid(projectId)) {
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
return;
@ -6369,13 +6427,14 @@ export function AgentGenerationWorkspaceDarkV2({
const saved = await onPersistProject?.();
if (saved === false) throw new Error("项目尚未保存,请重试");
const requestId = generateUUID();
const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
const response = await fetch("/api/agents/detail-replacement/batches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
requestId,
model: resolvedModelId,
expectedModelId: resolvedModelId === "auto" ? undefined : resolvedModelId,
imageSize: imageParams.imageSize,
replacementStrategy: detailReplacementStrategy,
prompt: detailReplacementStrategy === "product_only"
@ -6399,6 +6458,7 @@ export function AgentGenerationWorkspaceDarkV2({
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.task?.id) {
if (payload?.code === "DETAIL_REPLACEMENT_MODEL_CHANGED") void refreshModels().catch(() => {});
throw new Error(payload?.error || "详情替换任务提交失败");
}
setPendingGeneration({
@ -6586,6 +6646,8 @@ export function AgentGenerationWorkspaceDarkV2({
input,
isDetailAiGenerationMethod,
isDetailReplacementPreset,
isDetailReplacementModelLoading,
modelsError,
isSelectedBusinessModeUnavailable,
isSkuReplacementPreset,
effectiveDefaultModelId,
@ -6602,6 +6664,7 @@ export function AgentGenerationWorkspaceDarkV2({
skuResultCards,
userMessageCount,
getLatestWorkflowContext,
getWorkflowPromptStatus,
isSceneGenerationPreset,
workflowContext,
sceneMessageAttachments,
@ -6612,6 +6675,7 @@ export function AgentGenerationWorkspaceDarkV2({
onPersistProject,
projectId,
refreshDetailBatch,
refreshModels,
]);
const handleStopGeneration = useCallback(() => {
detailGenerationCancelRef.current = true;
@ -6776,31 +6840,11 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error("图片地址无效");
return;
}
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
toast.success("原图已开始下载");
} catch (error) {
toast.error(getCanvasImageDownloadErrorMessage(error));
}
}, []);
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
@ -7795,6 +7839,11 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数");
return;
}
const promptStatus = getWorkflowPromptStatus?.();
if (promptStatus === "loading" || promptStatus === "error") {
toast.error(promptStatus === "error" ? "请先在风格模板区重试加载提示词" : "正在加载所选模板的提示词,请稍候");
return;
}
const productAttachments = detailGenerationProductSlotIndexes.map((index) =>
detailGenerationSlots[index]
@ -9399,9 +9448,11 @@ export function AgentGenerationWorkspaceDarkV2({
</Select>
) : (
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
{isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
{isDetailReplacementModelLoading
? modelsError ? "详情替换模型加载失败,点击批量替换可重试" : "正在读取详情替换模型"
: isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
<span className="ml-2 font-medium text-white">
{isWorkflowModelLocked
{isDetailReplacementModelLoading ? null : isWorkflowModelLocked
? lockedWorkflowModelLabel
: currentModel?.displayName || "自动选择"}
</span>

View File

@ -1,4 +1,5 @@
"use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
@ -725,30 +726,11 @@ export function AgentGenerationWorkspaceNew({
toast.error("图片地址无效");
return;
}
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
toast.success("原图已开始下载");
} catch (error) {
toast.error(getCanvasImageDownloadErrorMessage(error));
}
}, []);

View File

@ -3,6 +3,7 @@
import { useMemo } from "react";
import useSWR from "swr";
import { Check, ImageIcon, Loader2, UserRound } from "lucide-react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import { cn, fetcher } from "@/lib/utils";
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
@ -229,12 +230,11 @@ export function AgentWorkflowBrandModelPicker({
isDark ? "bg-white/[0.04]" : "bg-muted/40"
)}
>
<img
<AgentPreviewImage
src={image.imageUrl}
alt={image.displayName || `${normalizedBrand} 模特图`}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
previewWidth={360}
/>
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">

View File

@ -0,0 +1,77 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
import { isPageDownloadActive, preparePageRefresh, subscribeToPageDownloadActivity } from "@/lib/client/page-refresh";
export function AppUpdateNotice() {
const [availableVersion, setAvailableVersion] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
const downloading = useSyncExternalStore(subscribeToPageDownloadActivity, isPageDownloadActive, () => false);
useEffect(() => {
let disposed = false;
let controller: AbortController | undefined;
let checking = false;
const check = async () => {
if (disposed || checking || document.visibilityState !== "visible") return;
checking = true;
controller = new AbortController();
const current = controller;
const timeout = window.setTimeout(() => current.abort(), 8000);
try {
const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal });
if (!response.ok) return;
const data = await response.json();
if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version)
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
} catch {
// A failed version read leaves the current workspace available.
} finally {
window.clearTimeout(timeout);
checking = false;
}
};
void check();
const interval = window.setInterval(check, 60_000);
window.addEventListener("focus", check);
window.addEventListener("online", check);
document.addEventListener("visibilitychange", check);
return () => {
disposed = true;
controller?.abort();
clearInterval(interval);
window.removeEventListener("focus", check);
window.removeEventListener("online", check);
document.removeEventListener("visibilitychange", check);
};
}, []);
const refresh = async () => {
if (refreshing) return;
setRefreshing(true);
setError("");
try {
await preparePageRefresh();
window.location.reload();
setRefreshing(false);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "当前内容同步失败,请保留此页并重试。");
setRefreshing(false);
}
};
return <AlertDialog open={Boolean(availableVersion) && !downloading}>
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
<AlertDialogHeader>
<AlertDialogTitle>平台已更新</AlertDialogTitle>
<AlertDialogDescription>需要刷新页面后继续使用。点击后会先同步当前画布并保留输入;正在处理的任务完成后再刷新。</AlertDialogDescription>
</AlertDialogHeader>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
<AlertDialogFooter>
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>;
}

View File

@ -2,9 +2,13 @@
import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming";
import { useChat } from "@ai-sdk/react";
import { useStickToBottom } from "use-stick-to-bottom";
import { DefaultChatTransport } from "ai";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress";
import { useCanvasRemoteOperation, useCanvasRemoteSnapshot } from "@/components/canvas/CanvasRemoteOperationContext";
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { canvasHistoryForChat } from "@/lib/client/canvas-history-response";
import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run";
import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
@ -66,13 +70,15 @@ import {
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import { findImageGenerationFailure } from "@/lib/canvas/image-generation-status";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import {
fetchUploadWithRetry,
type UploadProgress,
} from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
downloadOriginalImage,
getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
@ -80,7 +86,8 @@ import { canvasService } from "@/services/canvas";
import {
inferOriginalImageUrl,
} from "@/lib/image-url";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
@ -373,7 +380,9 @@ function MarkdownResponseFallback({
}: Pick<ResponseProps, "children" | "className">) {
return (
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
{children}
{typeof children === "string"
? getGatewayErrorPageMessage(children) || children
: children}
</div>
);
}
@ -1453,7 +1462,7 @@ function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
}
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
function getImageDimensions(url: string): Promise<{
function getImageDimensions(source: string | File): Promise<{
width: number;
height: number;
originalWidth: number;
@ -1461,6 +1470,7 @@ function getImageDimensions(url: string): Promise<{
}> {
return new Promise((resolve) => {
const img = new Image();
const objectUrl = typeof source === "string" ? null : URL.createObjectURL(source);
let settled = false;
const resolveOnce = (dimensions: {
width: number;
@ -1473,9 +1483,11 @@ function getImageDimensions(url: string): Promise<{
window.clearTimeout(timeout);
img.onload = null;
img.onerror = null;
if (objectUrl) URL.revokeObjectURL(objectUrl);
resolve(dimensions);
};
const timeout = window.setTimeout(() => {
img.removeAttribute("src");
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
}, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS);
@ -1508,7 +1520,7 @@ function getImageDimensions(url: string): Promise<{
// 加载失败时使用默认尺寸
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
};
img.src = url;
img.src = objectUrl ?? String(source);
});
}
@ -3238,6 +3250,8 @@ export function CanvasChatPanel({
onRequireLogin,
onBeforeAgentRequest,
}: CanvasChatPanelProps) {
const runCanvasRemoteOperation = useCanvasRemoteOperation();
const applyCanvasRemoteSnapshot = useCanvasRemoteSnapshot();
const isWorkspacePresentation = presentation === "workspace";
const isVideoWorkspace = workspaceVariant === "video";
const propCanvasToolbarConfig = useMemo(
@ -3336,6 +3350,7 @@ export function CanvasChatPanel({
const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId);
const activeMessagesRef = useRef<ChatMessage[]>([]);
const inFlightChatIdRef = useRef<string | null>(null);
const imageGenerationUserMessageIdsRef = useRef<Record<string, string>>({});
const inFlightRawChatIdRef = useRef<string | null>(null);
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
@ -3357,15 +3372,15 @@ export function CanvasChatPanel({
const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]);
const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null);
const suppressSkillTagClickRef = useRef(false);
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollRef: messagesScrollRef, contentRef: messagesContentRef, scrollToBottom: scrollMessagesToBottom, isAtBottom: isMessageListAtBottom } = useStickToBottom({ initial: false, resize: "instant" });
const initialScrollChatRef = useRef<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const clawdFolderInputRef = useRef<HTMLInputElement | null>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const detailModeSwitchRef = useRef(false);
const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null);
@ -3394,7 +3409,6 @@ export function CanvasChatPanel({
const hasInitializedVideoWorkspaceRef = useRef(false);
const sessionMenuRef = useRef<HTMLDivElement>(null);
const clawdHistoryMenuRef = useRef<HTMLDivElement>(null);
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const forceHistoryHydrationRef = useRef<string | null>(null);
const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null);
const composerUserEditedAtRef = useRef(0);
@ -3431,6 +3445,7 @@ export function CanvasChatPanel({
const [fullscreenPreview, setFullscreenPreview] = useState<{
url: string;
alt?: string;
preview: LoadedImagePreview;
} | null>(null);
const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null);
@ -3468,16 +3483,6 @@ export function CanvasChatPanel({
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
const downloadAbortControllerRef = useRef<AbortController | null>(null);
const fullscreenPreviewSrc = useMemo(() => {
if (!fullscreenPreview?.url) return "";
const originalUrl = inferOriginalImageUrl(fullscreenPreview.url);
return buildImageProxyUrl(originalUrl || fullscreenPreview.url, {
delivery: "proxy",
width: 2400,
quality: 90,
format: "webp",
});
}, [fullscreenPreview]);
useEffect(() => {
return () => {
if (composerUploadClearTimeoutRef.current) {
@ -3647,17 +3652,20 @@ export function CanvasChatPanel({
const setStreamImageGenerationStateForChat = useCallback(
(chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => {
const userMessageId = imageGenerationUserMessageIdsRef.current[chatSessionId];
if (next?.userMessageId && userMessageId && next.userMessageId !== userMessageId) return;
setStreamImageGenerationStateByChatId((prev) => ({
...prev,
[chatSessionId]: next,
[chatSessionId]: next ? { ...next, userMessageId: next.userMessageId || userMessageId } : null,
}));
},
[]
);
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string) => {
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string, preserveFailure = false) => {
setStreamImageGenerationStateByChatId((prev) => {
if (!(chatSessionId in prev)) return prev;
if (preserveFailure && prev[chatSessionId]?.phase === "failed") return prev;
const next = { ...prev };
delete next[chatSessionId];
return next;
@ -4459,17 +4467,15 @@ export function CanvasChatPanel({
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
percent: 5,
});
try {
await downloadCanvasImageAsPng({
await downloadOriginalImage({
url: originalUrl,
candidates: [raw, buildImageProxyUrl(originalUrl, { delivery: "proxy" })],
signal: controller.signal,
onProgress: setDownloadProgress,
});
toast.success("原图已开始下载");
toast.success("已开始下载");
} catch (error) {
if (controller.signal.aborted) {
toast.message("已取消下载");
@ -5030,6 +5036,15 @@ export function CanvasChatPanel({
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50));
}, []);
const setComposerEditorRef = useCallback((editor: HTMLDivElement | null) => {
textareaRef.current = editor as unknown as HTMLTextAreaElement;
if (!editor) return;
const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId);
const text = activeChatIdRef.current === activeChatId ? inputRef.current || draft?.text : draft?.text;
if (text) editor.textContent = text;
draft?.attachments.forEach((attachment, index) => insertImageTagInEditor(attachment.url, attachment.name || "参考图片", index));
}, [activeChatId, insertImageTagInEditor]);
const handleCopyUserMessage = useCallback(async (message: ChatMessage) => {
const payload = getUserMessageCopyPayload(message);
if (!payload.text && payload.attachments.length === 0) {
@ -6094,7 +6109,7 @@ export function CanvasChatPanel({
const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video")
? "agent" : rawComposerMode;
const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent";
const [imageAgentEnabled, setImageAgentEnabled] = useState(false);
const [imageAgentEnabled, setImageAgentEnabled] = useState(true);
const imageAgentEnabledRef = useRef(imageAgentEnabled);
imageAgentEnabledRef.current = imageAgentEnabled;
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
@ -7792,7 +7807,6 @@ export function CanvasChatPanel({
existingMessageImageKeys.add(messageImageKey);
}
const dimensions = await getImageDimensions(url);
if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return;
const compareSourceUrlRaw =
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
@ -7825,7 +7839,7 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null;
const size = { width: dimensions.width, height: dimensions.height };
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({
@ -7843,8 +7857,6 @@ export function CanvasChatPanel({
prompt: generationPrompt,
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt,
sourceMessageId: messageId || undefined,
sourceImageKey,
@ -7977,8 +7989,12 @@ export function CanvasChatPanel({
const store = useCanvasStore.getState();
store.markAgentOperationRefresh();
store.loadCanvas(result.data.canvasData);
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
if (applyCanvasRemoteSnapshot) {
if (!applyCanvasRemoteSnapshot({ projectId: targetProjectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt || "") })) return false;
} else {
store.loadCanvas(result.data.canvasData);
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
}
if (createdNodeIds.length > 0) {
const refreshedStore = useCanvasStore.getState();
refreshedStore.selectNodes(createdNodeIds);
@ -8006,7 +8022,7 @@ export function CanvasChatPanel({
return false;
}
},
[applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
[applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
);
const {
@ -8023,6 +8039,8 @@ export function CanvasChatPanel({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
async prepareSendMessagesRequest(request) {
const userMessageId = request.messages.findLast((item) => item.role === "user")?.id;
if (userMessageId) imageGenerationUserMessageIdsRef.current[request.id] = userMessageId;
// 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
const rawId = request.id.startsWith('canvas-')
? request.id.replace('canvas-', '')
@ -8135,20 +8153,27 @@ export function CanvasChatPanel({
const payload: ImageGenerationStatusStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
const userMessageId = imageGenerationUserMessageIdsRef.current[eventChatId];
if (payload.userMessageId && userMessageId && payload.userMessageId !== userMessageId) return;
if (payload.phase === "completed") {
setStreamImageGenerationStateForChat(eventChatId, {
phase: "finalizing",
userMessageId: payload.userMessageId,
providerType: payload.providerType,
modelName: getAgentSafeModelName(payload.modelName),
imageCount: Number(payload.imageCount || 0) || undefined,
});
window.setTimeout(() => {
if (payload.userMessageId && imageGenerationUserMessageIdsRef.current[eventChatId] !== payload.userMessageId) return;
clearStreamImageGenerationStateForChat(eventChatId);
}, 30_000);
return;
}
setStreamImageGenerationStateForChat(eventChatId, {
phase: payload.phase || "processing",
userMessageId: payload.userMessageId,
requestId: payload.requestId,
failure: payload.failure,
providerType: payload.providerType,
modelName: getAgentSafeModelName(payload.modelName),
imageCount: Number(payload.imageCount || 0) || undefined,
@ -8416,6 +8441,11 @@ export function CanvasChatPanel({
)
: false;
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : [];
const failureStatus = findImageGenerationFailure(
[{ role: messageData.role, parts: normalizedParts }],
imageGenerationUserMessageIdsRef.current[eventChatId],
);
if (failureStatus) setStreamImageGenerationStateForChat(eventChatId, failureStatus);
const normalizedAttachments = Array.isArray(messageData.attachments)
? messageData.attachments
: [];
@ -8484,7 +8514,7 @@ export function CanvasChatPanel({
(hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) &&
!shouldDeferClearForActiveMedia
) {
clearStreamImageGenerationStateForChat(eventChatId);
clearStreamImageGenerationStateForChat(eventChatId, true);
}
if (!isEventForActiveChat) {
@ -8810,12 +8840,6 @@ export function CanvasChatPanel({
hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(dbMessages));
if (dbMessages.length > 0) {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
}
};
const reloadMessagesOnce = async () => {
@ -8928,7 +8952,7 @@ export function CanvasChatPanel({
onRunChange: (run) => {
// The live stream owns its message; polling only recovers disconnected replies.
if (status === "submitted" || status === "streaming") return;
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== activeChatIdRef.current) return;
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== toRawCanvasChatId(activeChatIdRef.current)) return;
// Recover the same assistant reply after a stream disconnect or refresh.
setMessages(current => {
const index = current.findIndex(message => message.id === run.assistantMessageId);
@ -8943,6 +8967,33 @@ export function CanvasChatPanel({
});
const activeCanvasAgentRun = canvasAgentTask.run;
const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running";
useEffect(() => {
const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (composerUploadProgress) {
request.block("素材仍在上传,请等待上传完成后再刷新。当前页面和输入已保留。");
return;
}
if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) {
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
return;
}
request.save(async () => {
if ((input.trim() || attachments.length) && !writeCanvasComposerDraft(activeChatId, { text: input, attachments }))
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
});
};
const beforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "true";
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
if (composerUploadProgress) window.addEventListener("beforeunload", beforeUnload);
return () => {
window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
window.removeEventListener("beforeunload", beforeUnload);
};
}, [activeChatId, input, attachments, composerUploadProgress, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
const restorePendingClawdRequestToComposer = useCallback(
(snapshot: PendingClawdRequestRestoreSnapshot) => {
@ -9086,6 +9137,7 @@ export function CanvasChatPanel({
},
} as ChatMessage;
void scrollMessagesToBottom({ animation: "instant" });
sendMessage(launchUserMessage as any);
setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([
@ -9105,7 +9157,7 @@ export function CanvasChatPanel({
if (editableDiv) {
editableDiv.innerHTML = "";
}
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode]);
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode, scrollMessagesToBottom]);
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
const loadedHistorySignatureRef = useRef<string>("");
@ -9125,7 +9177,6 @@ export function CanvasChatPanel({
hiddenPromptRef.current = null;
autoContextRef.current = null;
pendingLaunchRef.current = null;
setMessageViewportKey((prev) => prev + 1);
setMessages([]);
setInput("");
setAttachments([]);
@ -9140,11 +9191,20 @@ export function CanvasChatPanel({
}, [activeChatId, setMessages]);
useEffect(() => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
historyScrollTimerRef.current = null;
}
const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId);
if (!draft || (input && input !== draft.text)) return;
if (!input) setInput(draft.text);
setAttachments(draft.attachments);
const frame = requestAnimationFrame(() => {
const editor = textareaRef.current as HTMLDivElement | null;
if (!editor || getComposerPlainTextFromElement(editor).trim()) return;
editor.textContent = draft.text;
draft.attachments.forEach((attachment, index) => insertImageTagInEditor(attachment.url, attachment.name || "参考图片", index));
});
return () => cancelAnimationFrame(frame);
}, [activeChatId, activeComposerMode, input, insertImageTagInEditor]);
useEffect(() => {
if (!historyData || !rawChatId) return;
const historyMessages = historyData.messages || [];
const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage);
@ -9185,29 +9245,14 @@ export function CanvasChatPanel({
missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages
);
});
setMessageViewportKey((prev) => prev + 1);
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
if (historyMessages.length > 0) {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
historyScrollTimerRef.current = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
historyScrollTimerRef.current = null;
}, 160);
return () => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
historyScrollTimerRef.current = null;
}
};
}
}, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]);
useEffect(() => {
const streamState = streamImageGenerationStateByChatId[activeChatId];
if (!rawChatId || !streamState) return;
if (!rawChatId || !streamState || (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown")) return;
let cancelled = false;
const poll = async () => {
@ -9231,6 +9276,13 @@ export function CanvasChatPanel({
const streamState = streamImageGenerationStateByChatId[activeChatId];
if (!streamState || messages.length === 0) return;
const failure = findImageGenerationFailure(messages, streamState.userMessageId || "");
if (failure && streamState.phase !== "failed") {
setStreamImageGenerationStateForChat(activeChatId, failure);
return;
}
if (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown") return;
const lastUserIndex = [...messages]
.map((message) => message.role)
.lastIndexOf("user");
@ -9247,8 +9299,7 @@ export function CanvasChatPanel({
.join("\n");
return (
extractAssistantImageUrls(textContent).length > 0 ||
extractVideoUrls(textContent).length > 0 ||
/失败|超时|重试|error|failed/i.test(textContent)
extractVideoUrls(textContent).length > 0
);
});
@ -9269,6 +9320,7 @@ export function CanvasChatPanel({
messages,
streamImageGenerationStateByChatId,
clearStreamImageGenerationStateForChat,
setStreamImageGenerationStateForChat,
]);
useEffect(() => {
@ -9529,7 +9581,6 @@ export function CanvasChatPanel({
continue;
}
const dimensions = await getImageDimensions(url);
if (cancelled) return;
const compareSourceUrlRaw =
nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] ||
@ -9562,20 +9613,7 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null;
const size = placeholderNode
? { width: dimensions.width, height: dimensions.height }
: anchorNode
? {
width: Math.max(
1,
Math.round(Number(anchorNode.size?.width || dimensions.width))
),
height: Math.max(
1,
Math.round(Number(anchorNode.size?.height || dimensions.height))
),
}
: { width: dimensions.width, height: dimensions.height };
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({
@ -9594,8 +9632,6 @@ export function CanvasChatPanel({
prompt: generationPrompt || "对话生成",
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id,
sourceImageKey: imageEntry.sourceImageKey,
@ -9775,11 +9811,13 @@ export function CanvasChatPanel({
});
}, [activeChatSessionMode, messages, nodes, updateNode]);
// 滚动到底部
// Open each conversation at its latest message; later updates respect manual scrolling.
useEffect(() => {
if (messages.length === 0 && !isClawdSubmitting) return;
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [isClawdSubmitting, messages]);
if (initialScrollChatRef.current === activeChatId) return;
initialScrollChatRef.current = activeChatId;
void scrollMessagesToBottom({ animation: "instant" });
}, [activeChatId, isClawdSubmitting, messages.length, scrollMessagesToBottom]);
// 标记是否已经为当前画布设置过标题(避免重复调用)
const hasSetCanvasTitle = useRef(false);
@ -10320,7 +10358,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
);
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -10398,7 +10436,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
);
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -12548,6 +12586,7 @@ export function CanvasChatPanel({
const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim();
if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) {
if (supplement && await canvasAgentTask.control("steer", supplement)) {
void scrollMessagesToBottom({ animation: "instant" });
setInput("");
const editor = textareaRef.current as HTMLDivElement | null;
if (editor) editor.innerHTML = "";
@ -12845,6 +12884,7 @@ export function CanvasChatPanel({
requestChatId,
clawdRequestRestoreSnapshot
);
void scrollMessagesToBottom({ animation: "instant" });
startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint);
setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([
@ -13333,6 +13373,26 @@ export function CanvasChatPanel({
});
inFlightChatIdRef.current = activeChatId;
inFlightRawChatIdRef.current = rawChatId;
imageGenerationUserMessageIdsRef.current[activeChatId] = userMessageId;
if (shouldUseCanvasAgentRuntime({
composerMode: effectiveComposerMode,
agentPresetId: effectiveAgentEntryMode === "preset" ? selectedAgentPreset?.id : undefined,
workspaceVariant: isVideoWorkspace ? "video" : "canvas",
contextPayload: contextPayloadRef.current || undefined,
}) && onBeforeAgentRequest) {
try {
if (!(await onBeforeAgentRequest())) throw new Error("画布自动同步失败,本次任务未提交。输入内容已保留,请检查网络后再次发送。");
} catch (error) {
releaseSendGuard(activeChatId);
inFlightChatIdRef.current = null;
inFlightRawChatIdRef.current = null;
contextPayloadRef.current = null;
writeCanvasComposerDraft(activeChatId, { text: composerInput, attachments: effectiveAttachments });
toast.error(error instanceof Error ? error.message : "画布自动同步失败,本次任务未提交,输入内容已保留。");
return;
}
}
clearCanvasComposerDraft(activeChatId);
clearStreamImageGenerationStateForChat(activeChatId);
clearAgentProgressStateForChat(activeChatId);
if (shouldShowOptimisticImageGeneration) {
@ -13357,6 +13417,7 @@ export function CanvasChatPanel({
// 使用与 multimodal-input 相同的格式发送消息
try {
void scrollMessagesToBottom({ animation: "instant" });
sendMessage(optimisticUserMessage as any);
setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([
@ -13395,6 +13456,9 @@ export function CanvasChatPanel({
}
}, [
isCanvasAgentRunning,
scrollMessagesToBottom,
onBeforeAgentRequest,
isVideoWorkspace,
canvasAgentTask.control,
activeChatId,
activeComposerMode,
@ -13532,23 +13596,19 @@ export function CanvasChatPanel({
console.log('[CanvasChat] 更新画布:', rawId);
const updateResponse = await fetch(`/api/canvas/${rawId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
thumbnail: firstImageUrl,
}),
if (!runCanvasRemoteOperation) return;
await runCanvasRemoteOperation(async () => {
if (useCanvasStore.getState().projectId !== rawId) return;
const result = await canvasService.updateProject(rawId, { title, thumbnail: firstImageUrl });
if (!result.success) throw new Error(result.error || "画布名称同步失败");
});
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
} else {
console.error('[CanvasChat] auto-title API 请求失败:', response.status);
}
} catch (error) {
console.error('[CanvasChat] 自动命名失败:', error);
}
}, [chatId]);
}, [chatId, runCanvasRemoteOperation]);
const addUploadedImageAttachmentToComposer = useCallback(
(uploaded: ComposerUploadedImage) => {
@ -13662,7 +13722,7 @@ export function CanvasChatPanel({
};
onUploaded?.(uploadedBase);
clearComposerUploadProgress(250);
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
@ -14851,7 +14911,7 @@ export function CanvasChatPanel({
firstImageUrl = imageUrl;
}
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
// 获取图片原始尺寸后添加到画布
@ -15102,7 +15162,7 @@ export function CanvasChatPanel({
!hasGenerationEnd &&
Boolean(currentModel || activeComposerMode === "agent"));
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0;
const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter;
const shouldRenderImageLoading = shouldShowImageLoading && streamImageGenerationState?.phase !== "failed" && !hasActiveTypewriter;
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
const loadingProviderType =
streamImageGenerationState?.providerType ||
@ -15165,9 +15225,9 @@ export function CanvasChatPanel({
phase === "queued"
? "等待开始生成"
: phase === "finalizing"
? "正在润饰细节"
? "正在保存生成结果"
: phase === "failed"
? "生成失败"
? streamState.failure?.message || "生成失败"
: "正在创建图片";
const status = phase === "failed" ? "failed" : "generating";
const currentNodes = useCanvasStore.getState().nodes;
@ -15223,14 +15283,10 @@ export function CanvasChatPanel({
canvasGenerationPlaceholder: true,
chatId: activeChatId,
detailProgressLabel: label,
generationProgress:
phase === "queued"
? 12
: phase === "finalizing"
? 96
: phase === "failed"
? 100
: undefined,
generationProgress: undefined,
generationFailure: streamState.failure,
generationRequestId: streamState.requestId,
generationUserMessageId: streamState.userMessageId,
modelName: streamState.modelName || currentModel?.displayName || undefined,
providerType: streamState.providerType || currentModel?.providerType || undefined,
placementMode: "anchored-generation",
@ -15324,6 +15380,7 @@ export function CanvasChatPanel({
if (
message.role === "assistant" &&
!rawParts.some((part: any) => part.type === "data-imageGenerationStatus" && part.data?.failure) &&
isInternalCanvasImageGenerationFailureText(cleanedParts)
) {
return acc;
@ -15633,7 +15690,7 @@ export function CanvasChatPanel({
processedMessageIds.current.clear();
hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(fresh.messages));
setMessageViewportKey((previous) => previous + 1);
void scrollMessagesToBottom({ animation: "instant" });
}).catch((error) => {
console.error("[CanvasChat] Failed to reopen current conversation:", error);
toast.error("聊天记录暂时读取失败,请重试");
@ -15647,7 +15704,7 @@ export function CanvasChatPanel({
replaceChatSessionQuery(sessionId);
setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口");
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, setMessages, status]);
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, scrollMessagesToBottom, setMessages, status]);
const switchToClawdHistorySession = useCallback(
(historyItem: ClawdHistoryItem) => {
@ -15809,8 +15866,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
}
}}
/>
@ -16064,11 +16121,17 @@ export function CanvasChatPanel({
{isDetailEmptyState ? (
<div className="flex-1 min-h-0" />
) : (
<div key={activeChatId} className="relative flex min-h-0 flex-1 flex-col">
<div
key={`${activeChatId}-${messageViewportKey}`}
ref={messagesScrollRef}
data-testid="canvas-chat-messages"
role="region"
aria-label="对话消息"
// biome-ignore lint/a11y/noNoninteractiveTabindex: The scroll region needs PageUp/PageDown keyboard input.
tabIndex={0}
className={cn(
"flex-1 min-h-0 overflow-x-hidden",
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto",
"flex-1 min-h-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-auto",
isWorkspacePresentation ? "p-5" : "p-3",
isPlainCanvasEmptyState
? "pt-0"
@ -16077,7 +16140,7 @@ export function CanvasChatPanel({
: "pt-3"
)}
>
<div className={cn(
<div ref={messagesContentRef} className={cn(
"space-y-5 overflow-x-hidden pr-1",
isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24"
)}>
@ -16128,8 +16191,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== attachment.url) {
event.currentTarget.src = attachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
}
}}
/>
@ -16205,8 +16268,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== attachment.url) {
event.currentTarget.src = attachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
}
}}
/>
@ -16391,8 +16454,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== tag.iconUrl) {
event.currentTarget.src = tag.iconUrl || "";
if (event.currentTarget.src !== buildImageFallbackUrl(tag.iconUrl)) {
event.currentTarget.src = buildImageFallbackUrl(tag.iconUrl);
}
}}
/>
@ -16692,8 +16755,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== attachment.url) {
event.currentTarget.src = attachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
}
}}
/>
@ -17029,15 +17092,23 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== url) {
event.currentTarget.src = url;
if (event.currentTarget.src !== buildImageFallbackUrl(url)) {
event.currentTarget.src = buildImageFallbackUrl(url);
}
}}
onClick={() => setFullscreenPreview({ url })}
onClick={(event) => setFullscreenPreview({ url, preview: {
src: event.currentTarget.currentSrc || event.currentTarget.src,
width: event.currentTarget.naturalWidth,
height: event.currentTarget.naturalHeight,
} })}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setFullscreenPreview({ url });
setFullscreenPreview({ url, preview: {
src: e.currentTarget.currentSrc || e.currentTarget.src,
width: e.currentTarget.naturalWidth,
height: e.currentTarget.naturalHeight,
} });
}}
/>
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
@ -17272,9 +17343,22 @@ export function CanvasChatPanel({
/>
) : null}
<div ref={messagesEndRef} />
</div>
</div>
{!isMessageListAtBottom && renderableVisibleMessages.length > 0 ? (
<Button
type="button"
variant="outline"
size="icon"
aria-label="回到最新消息"
title="回到最新消息"
className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full shadow-sm"
onClick={() => void scrollMessagesToBottom({ animation: "instant" })}
>
<ChevronDown className="h-4 w-4" />
</Button>
) : null}
</div>
)}
{fullscreenPreview && createPortal(
@ -17283,7 +17367,7 @@ export function CanvasChatPanel({
onClick={() => setFullscreenPreview(null)}
>
<button
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
onClick={() => setFullscreenPreview(null)}
aria-label="关闭预览"
>
@ -17292,7 +17376,7 @@ export function CanvasChatPanel({
</svg>
</button>
<button
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65"
className="absolute top-4 right-16 z-10 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65"
disabled={isDownloadingOriginalImage}
onClick={(event) => {
event.stopPropagation();
@ -17309,13 +17393,11 @@ export function CanvasChatPanel({
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
</button>
<img
src={fullscreenPreviewSrc}
<ProgressiveImagePreview
src={inferOriginalImageUrl(fullscreenPreview.url) || fullscreenPreview.url}
preview={fullscreenPreview.preview}
alt={fullscreenPreview.alt || "图片预览"}
className="max-w-[95vw] max-h-[95vh] object-contain"
decoding="async"
draggable={false}
onClick={(e) => e.stopPropagation()}
/>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
@ -17882,8 +17964,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = slotAttachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
}
}}
/>
@ -18207,8 +18289,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
}
}}
/>
@ -18467,8 +18549,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== item.url) {
event.currentTarget.src = item.url;
if (event.currentTarget.src !== buildImageFallbackUrl(item.url)) {
event.currentTarget.src = buildImageFallbackUrl(item.url);
}
}}
/>
@ -18523,7 +18605,7 @@ export function CanvasChatPanel({
)}
>
<div
ref={textareaRef as any}
ref={setComposerEditorRef}
contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)}
role="textbox"
tabIndex={isSubmitting ? -1 : 0}
@ -18557,6 +18639,7 @@ export function CanvasChatPanel({
setTagPreview(null);
}}
onInput={(e) => {
clearCanvasComposerDraft(activeChatId);
// 从 contenteditable 中提取纯文本(排除附件标签文本)
const target = e.target as HTMLDivElement;
const clone = target.cloneNode(true) as HTMLDivElement;
@ -18652,8 +18735,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = slotAttachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
}
}}
/>
@ -18720,8 +18803,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = slotAttachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
}
}}
/>
@ -19420,8 +19503,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== tagPreview.url) {
event.currentTarget.src = tagPreview.url;
if (event.currentTarget.src !== buildImageFallbackUrl(tagPreview.url)) {
event.currentTarget.src = buildImageFallbackUrl(tagPreview.url);
}
}}
/>

View File

@ -19,6 +19,7 @@ const PROJECT_LIST_PAGE_SIZE = 24;
interface CanvasFileListPanelProps {
currentId: string;
onClose: () => void;
onNavigate?: (url: string) => void;
workspaceType?: CanvasWorkspaceType;
workspaceLabel?: string;
createUrl?: string;
@ -28,6 +29,7 @@ interface CanvasFileListPanelProps {
export function CanvasFileListPanel({
currentId,
onClose,
onNavigate,
workspaceType = "canvas",
workspaceLabel = "画布",
createUrl = "/canvas?new=1",
@ -88,6 +90,10 @@ export function CanvasFileListPanel({
// 创建新画布
const handleCreateNew = async () => {
if (onNavigate) {
onNavigate(createUrl);
return;
}
router.push(createUrl);
};
@ -100,6 +106,10 @@ export function CanvasFileListPanel({
}
const targetUrl = buildProjectUrl(id);
onClose(); // 关闭文件列表面板
if (onNavigate) {
onNavigate(targetUrl);
return;
}
// 强制整页跳转,避免仅更新 URL 不刷新
setTimeout(() => {
window.location.assign(targetUrl);

View File

@ -28,6 +28,7 @@ export function CanvasImageDownloadProgressDialog({
: null;
const loadedLabel = formatBytes(progress.loadedBytes);
const totalLabel = formatBytes(progress.totalBytes);
const converting = progress.phase === "converting";
return createPortal(
<div
@ -41,12 +42,12 @@ export function CanvasImageDownloadProgressDialog({
{progress.phase === "saving" ? (
<Download className="h-4 w-4" />
) : (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 motion-safe:animate-spin" />
)}
</span>
<div>
<div className="text-[14px] font-semibold">下载原图</div>
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
<div className="mt-0.5 text-[12px] text-white/58" role="status">{progress.message}</div>
</div>
</div>
{onCancel ? (
@ -63,14 +64,19 @@ export function CanvasImageDownloadProgressDialog({
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : converting ? "格式转换中" : "正在处理文件"}</span>
<span>{percent !== null ? `${percent}%` : "..."}</span>
</div>
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
<div
className="relative h-2 overflow-hidden rounded-full bg-white/10"
role="progressbar"
aria-label={converting ? "图片格式转换" : "图片下载"}
aria-valuenow={percent ?? undefined}
>
<div
className={cn(
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
percent === null && "w-1/3 motion-safe:animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
)}
style={percent !== null ? { width: `${percent}%` } : undefined}
/>

View File

@ -0,0 +1,26 @@
"use client";
import { createContext, useContext } from "react";
import type { CanvasState } from "@/types/canvas";
export type CanvasRemoteOperationResult = {
projectId: string;
canvasData: CanvasState;
updatedAt: string;
};
export type CanvasRemoteOperation = (() => Promise<void>) | (() => Promise<CanvasRemoteOperationResult | undefined>);
export const CanvasRemoteOperationContext = createContext<
{
run: (operation: CanvasRemoteOperation) => Promise<void>;
applySnapshot: (result: CanvasRemoteOperationResult) => boolean;
} | null
>(null);
export function useCanvasRemoteOperation() {
return useContext(CanvasRemoteOperationContext)?.run || null;
}
export function useCanvasRemoteSnapshot() {
return useContext(CanvasRemoteOperationContext)?.applySnapshot || null;
}

View File

@ -2,14 +2,10 @@
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Loader2, Save } from "lucide-react";
type CanvasSurfaceHeaderProps = {
isSaving: boolean;
onAddImage: () => void;
onHome: () => void;
onOpenFiles: () => void;
onSave: () => void;
projectTitle: string;
rightInset: number;
};
@ -18,11 +14,9 @@ const iconButtonClass =
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
export function CanvasSurfaceHeader({
isSaving,
onAddImage,
onHome,
onOpenFiles,
onSave,
projectTitle,
rightInset,
}: CanvasSurfaceHeaderProps) {
@ -77,19 +71,6 @@ export function CanvasSurfaceHeader({
<ReferenceIcon name="plus" size={16} />
</button>
</div>
<button
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
disabled={isSaving}
onClick={onSave}
type="button"
>
{isSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
保存
</button>
</div>
);
}

View File

@ -152,16 +152,7 @@ export function shouldRetryCanvasViewOperationAfterRefresh(
);
}
export type ImageGenerationStatusStreamPayload = {
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
providerType?: string;
modelName?: string;
imageCount?: number;
queuePosition?: number;
sameModelRunningCount?: number;
concurrencyLimit?: number;
estimatedWaitMs?: number;
};
export type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
export type AgentTargetLockRefPreview = {
url: string;

View File

@ -448,7 +448,7 @@ function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
}
}
const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
const markdownLinkPattern = /(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
while ((match = markdownLinkPattern.exec(text)) !== null) {
const label = String(match[1] || "");
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
@ -491,7 +491,7 @@ export function stripMediaMarkup(text: string): string {
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
.replace(/<a[\s\S]*?<\/a>/gi, "")
.replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
.replace(/(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
String(label || "")
);

View File

@ -6,6 +6,8 @@ import { ArrowUp, MessageSquare, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
import {
videoAgentSettingsSchema,
type VideoAgentSettings,
@ -94,6 +96,22 @@ export function VideoCanvasAgentPanel(props: {
};
}, [props.projectId, setMessages]);
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming";
useEffect(() => {
const draft = readCanvasComposerDraft(`video-agent-${props.projectId}`);
if (draft) setInput(draft.text);
}, [props.projectId]);
useEffect(() => {
const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (busy) request.block("当前视频任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
request.save(async () => {
if (input.trim() && !writeCanvasComposerDraft(`video-agent-${props.projectId}`, { text: input, attachments: [] }))
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
});
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [busy, input, props.projectId]);
const submit = useCallback(
async (resumeRunId?: string) => {
if (busy || loading) return;
@ -105,6 +123,7 @@ export function VideoCanvasAgentPanel(props: {
try {
await latest.current.props.beforeSend();
if (!mounted.current) return;
clearCanvasComposerDraft(`video-agent-${props.projectId}`);
resumeRef.current = resumeRunId;
setInput("");
await sendMessage({ text });
@ -298,7 +317,7 @@ export function VideoCanvasAgentPanel(props: {
<textarea
aria-label="视频创作要求"
value={input}
onChange={(event) => setInput(event.target.value)}
onChange={(event) => { clearCanvasComposerDraft(`video-agent-${props.projectId}`); setInput(event.target.value); }}
maxLength={2000}
rows={3}
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"

View File

@ -1,4 +1,7 @@
"use client";
import { CanvasPreviewImage } from "./image-node/canvas-preview-image";
import { canvasImagePreviewWidth } from "@/lib/canvas/image-preview-width";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import {
Box,
@ -59,6 +62,7 @@ import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
import {
areImageAdjustmentsEqual,
buildImageAdjustmentFilterStyle,
@ -174,7 +178,7 @@ import {
} from "@/components/ui/tooltip";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
downloadOriginalImage,
getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
@ -185,7 +189,7 @@ import {
normalizeDetailUiSubjectAnalysis,
} from "@/lib/detail-ui-subject-analysis";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId,
@ -231,6 +235,8 @@ import {
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
import { cn, fetcher } from "@/lib/utils";
@ -1708,6 +1714,7 @@ export function ImageNode({
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState<LoadedImagePreview | null>(null);
const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null);
const downloadAbortControllerRef = useRef<AbortController | null>(null);
@ -2077,7 +2084,7 @@ export function ImageNode({
const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const runCanvasRemoteOperation = useCanvasRemoteOperation();
const addPendingChatAttachment = useCanvasStore(
(state) => state.addPendingChatAttachment
);
@ -2475,14 +2482,11 @@ export function ImageNode({
[compareButtonVisualSize]
);
const shouldUseHighFidelityDisplayImage =
isSelected ||
isHovered ||
isCompareMode ||
boxCutoutMode ||
localEditMode ||
cropPanelOpen ||
imageAdjustPanelOpen ||
fullscreenPreviewOpen ||
isThisNodePicking;
const displayImageWidth = useMemo(() => {
const devicePixelRatio =
@ -2497,13 +2501,13 @@ export function ImageNode({
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
return Math.max(
return canvasImagePreviewWidth(Math.max(
minWidth,
Math.min(
maxWidth,
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
)
);
), maxWidth);
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
const displayImageSrc = useMemo(
@ -2515,15 +2519,6 @@ export function ImageNode({
}),
[data.src, displayImageQuality, displayImageWidth]
);
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 4096,
quality: 92,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo(
() =>
buildImageProxyUrl(compareSource, {
@ -4678,106 +4673,44 @@ export function ImageNode({
return;
}
setIsCreatingDetachedResultNode(true);
if (!projectId || !runCanvasRemoteOperation) {
toast.error("画布还在加载,请稍后再试");
return;
}
setIsCreatingDetachedResultNode(false);
setIsProcessing(true);
const actionName = "框选抠图";
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
connections: [node.id],
});
try {
const result = await imageGenerationService.boxCutout({
imageUrl: data.src,
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
sourceNodeId: node.id,
});
if (result.success && result.data?.url) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
cropRectApplied: result.data.cropRectApplied,
paddedCropRectApplied: result.data.paddedCropRectApplied,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
editMode: "box-cutout",
},
},
});
toast.success(`${actionName}完成`);
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(result.error || `${actionName}失败`);
}
} catch (error) {
console.error("框选抠图失败:", error);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
const command = createCanvasCapabilityClientCommand({
capabilityId: "image.box_cutout",
nodeIds: [node.id],
projectId,
source: "toolbar",
args: {
...boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
});
toast.error(`${actionName}失败,请重试`);
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
toast.success(`${actionName}完成`);
} catch (error) {
console.error("框选抠图失败:", error);
toast.error(error instanceof Error ? error.message : `${actionName}失败`);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
resetBoxCutoutState();
}
}, [
addNode,
boxCutoutSelection,
data.src,
displayTitle,
isProcessing,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
projectId,
resetBoxCutoutState,
updateNode,
runCanvasRemoteOperation,
]);
const animateAddToChat = useCallback(async (previewUrl?: string) => {
@ -6707,7 +6640,7 @@ export function ImageNode({
const executeSharedToolbarCapability = async (
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
) => {
if (!projectId) {
if (!projectId || !runCanvasRemoteOperation) {
toast.error("画布还在加载,请稍后再试");
return;
}
@ -6726,21 +6659,7 @@ export function ImageNode({
projectId,
source: "toolbar",
});
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || `${capability.title}失败`);
}
const projectResult = await canvasService.getProject(projectId);
if (!projectResult.success || !projectResult.data?.canvasData) {
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
}
loadCanvas(projectResult.data.canvasData);
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
toast.success(`${capability.title}完成`);
} catch (error) {
const message =
@ -8304,7 +8223,6 @@ export function ImageNode({
}
};
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async () => {
if (!data.src) return;
@ -8318,24 +8236,16 @@ export function ImageNode({
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
percent: 5,
});
try {
await downloadCanvasImageAsPng({
url: data.src,
await downloadOriginalImage({
url: resolveOriginalImageUrl(data.src) || data.src,
name: displayTitle,
candidates: [
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
delivery: "proxy",
}),
],
signal: controller.signal,
onProgress: setDownloadProgress,
});
toast.success("原图已开始下载");
toast.success("已开始下载");
} catch (error) {
if (controller.signal.aborted) {
toast.message("已取消下载");
@ -8439,7 +8349,11 @@ export function ImageNode({
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? (
<div className="relative h-full w-full">
{!shouldRequestDisplayImage ? (
{isRetiredStorageUrl(data.src) ? (
<div role="status" className="flex h-full w-full items-center justify-center bg-muted p-4 text-center text-sm text-muted-foreground">
{RETIRED_STORAGE_MESSAGE}
</div>
) : !shouldRequestDisplayImage ? (
<ImageNodeDeferredLoadState />
) : isCompareMode && hasCompareSource ? (
<div
@ -8466,15 +8380,16 @@ export function ImageNode({
}}
>
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<img
<CanvasPreviewImage
sourceKey={data.src}
alt={displayTitle}
className="absolute inset-0 h-full w-full object-cover"
decoding="async"
draggable={false}
loading="lazy"
loading="eager"
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
event.currentTarget.src = buildImageFallbackUrl(data.src);
}
}}
onLoad={handleDisplayImageLoad}
@ -8495,15 +8410,16 @@ export function ImageNode({
}}
>
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<img
<CanvasPreviewImage
sourceKey={compareSource}
alt={`${displayTitle}-原图`}
className="absolute inset-0 h-full w-full object-cover"
decoding="async"
draggable={false}
loading="lazy"
loading="eager"
onError={(event) => {
if (event.currentTarget.src !== compareSource) {
event.currentTarget.src = compareSource;
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
event.currentTarget.src = buildImageFallbackUrl(compareSource);
}
}}
src={displayCompareSrc}
@ -8545,7 +8461,8 @@ export function ImageNode({
</div>
) : (
<>
<img
<CanvasPreviewImage
sourceKey={data.src}
alt={displayTitle}
className={cn(
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
@ -8557,7 +8474,7 @@ export function ImageNode({
)}
decoding="async"
draggable={false}
loading="lazy"
loading="eager"
onClick={
isThisNodePicking && !boxCutoutMode
? handlePickerClick
@ -8566,12 +8483,17 @@ export function ImageNode({
onDoubleClick={(e) => {
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
e.stopPropagation();
setFullscreenPreviewImage({
src: e.currentTarget.currentSrc || e.currentTarget.src,
width: e.currentTarget.naturalWidth,
height: e.currentTarget.naturalHeight,
});
setFullscreenPreviewOpen(true);
}
}}
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
event.currentTarget.src = buildImageFallbackUrl(data.src);
}
}}
onLoad={handleDisplayImageLoad}
@ -9482,6 +9404,8 @@ export function ImageNode({
) : (
<ImageNodeEmptyState
detailProgressLabel={detailProgressLabel}
failure={data.metadata?.generationFailure}
requestId={data.metadata?.generationRequestId}
fileInputRef={fileInputRef}
generationProgress={generationProgress}
onImageUpload={handleImageUpload}
@ -11971,6 +11895,7 @@ export function ImageNode({
{/* 双击放大预览弹窗 */}
{fullscreenPreviewOpen &&
data.src &&
fullscreenPreviewImage &&
createPortal(
<div
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
@ -11978,7 +11903,7 @@ export function ImageNode({
>
{/* 关闭按钮 */}
<button
className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
className="absolute top-4 right-4 z-10 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
onClick={() => setFullscreenPreviewOpen(false)}
>
<svg
@ -11996,7 +11921,7 @@ export function ImageNode({
</svg>
</button>
<button
className="absolute top-4 right-16 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
className="absolute top-4 right-16 z-10 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
disabled={isDownloadingOriginalImage}
onClick={(event) => {
event.preventDefault();
@ -12015,13 +11940,11 @@ export function ImageNode({
</button>
{/* 图片 */}
<img
<ProgressiveImagePreview
alt={data.prompt || "图片预览"}
className="max-h-[95vh] max-w-[95vw] object-contain"
decoding="async"
draggable={false}
onClick={(e) => e.stopPropagation()}
src={fullscreenPreviewSrc}
src={resolveOriginalImageUrl(data.src) || data.src}
preview={fullscreenPreviewImage}
style={activeImageAdjustmentsStyle}
/>

View File

@ -0,0 +1,32 @@
"use client";
import { forwardRef, type ImgHTMLAttributes, useState } from "react";
type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & { src: string; sourceKey: string };
export const CanvasPreviewImage = forwardRef<HTMLImageElement, Props>(function CanvasPreviewImage(
{ src, sourceKey, onLoad, style, ...props }, ref,
) {
const [displayed, setDisplayed] = useState({ sourceKey, src });
const visibleSrc = displayed.sourceKey === sourceKey ? displayed.src : src;
const sources = visibleSrc === src ? [src] : [visibleSrc, src];
return <>{sources.map(candidate => {
const visible = candidate === visibleSrc;
return <img
{...props}
key={candidate}
ref={visible ? ref : undefined}
src={candidate}
alt={visible ? props.alt : ""}
aria-hidden={visible ? props["aria-hidden"] : true}
style={visible ? style : { ...style, position: "absolute", inset: 0, opacity: 0, pointerEvents: "none" }}
onLoad={event => {
const image = event.currentTarget;
onLoad?.(event);
void image.decode().then(() => {
if (image.isConnected && candidate === src) setDisplayed({ sourceKey, src: candidate });
}).catch(() => undefined);
}}
/>;
})}</>;
});

View File

@ -3,11 +3,14 @@
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
import type React from "react";
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
import type { RequestFailureDetails } from "@/lib/request-error";
interface ImageNodeEmptyStateProps {
detailProgressLabel?: string;
fileInputRef: React.Ref<HTMLInputElement>;
generationProgress?: number | null;
failure?: RequestFailureDetails;
requestId?: string;
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
status?: string;
@ -52,6 +55,8 @@ export function ImageNodeEmptyState({
detailProgressLabel,
fileInputRef,
generationProgress,
failure,
requestId,
onImageUpload,
onSelectFile,
status,
@ -89,7 +94,7 @@ export function ImageNodeEmptyState({
if (status === "failed") {
const failedTitle = detailProgressLabel || "生成失败";
return (
<div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
<div role="alert" className="flex h-full w-full flex-col items-center justify-center overflow-auto bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
<AlertCircle className="h-5 w-5" />
</span>
@ -97,8 +102,9 @@ export function ImageNodeEmptyState({
{failedTitle}
</span>
<span className="mt-1 text-xs text-[#b77480]">
{failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"}
{failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")}
</span>
{requestId ? <span className="mt-2 select-text break-all text-xs text-[#b77480]">请求编号:{requestId}</span> : null}
</div>
);
}

View File

@ -15,9 +15,10 @@ import {
splitOpenClawChartBlocks,
} from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import {
Collapsible,
CollapsibleContent,
@ -441,8 +442,8 @@ function MarkdownTableImagePreview({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== src) {
event.currentTarget.src = src;
if (event.currentTarget.src !== buildImageFallbackUrl(src)) {
event.currentTarget.src = buildImageFallbackUrl(src);
}
}}
/>
@ -472,8 +473,8 @@ function MarkdownTableImagePreview({
decoding="async"
onClick={(event) => event.stopPropagation()}
onError={(event) => {
if (event.currentTarget.src !== originalSrc) {
event.currentTarget.src = originalSrc;
if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) {
event.currentTarget.src = buildImageFallbackUrl(originalSrc);
}
}}
/>
@ -1028,7 +1029,10 @@ export const Response = ({
const rawText =
typeof props.children === "string"
? normalizeMarkdownContent(stableStreamingMarkdown(props.children, isMarkdownStreaming))
? normalizeMarkdownContent(stableStreamingMarkdown(
getGatewayErrorPageMessage(props.children) || props.children,
isMarkdownStreaming
))
: null;
const shouldShowPdfDownload =
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));

View File

@ -1,6 +1,5 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { BloubAvatar } from "@/components/bloub-avatar";
import type { StateId } from "@/lib/bloub-official/states";
import { cn } from "@/lib/utils";
@ -26,193 +25,17 @@ interface GenerationDotPlaceholderProps {
const PHASE_TITLES: Record<GenerationPhase, string> = {
idle: "正在创建图片",
queued: "等待开始生成",
processing: "生成初稿中",
finalizing: "正在润饰细节",
processing: "等待模型返回图片",
finalizing: "正在保存生成结果",
failed: "生成失败",
completed: "生成完成",
};
const GENERATION_STATUS_TITLES = [
"正在理解画面关系",
"正在读取参考图片",
"正在分析主体轮廓",
"正在识别关键元素",
"正在整理构图线索",
"正在确认画面比例",
"正在匹配视觉风格",
"正在提取材质细节",
"正在梳理光影方向",
"正在构建初始画面",
"正在生成第一版构图",
"正在安排主体位置",
"正在校准画面重心",
"正在补全背景层次",
"正在保持主体一致",
"正在优化边缘结构",
"正在调整透视关系",
"正在细化空间层次",
"正在增强画面清晰度",
"正在统一色彩倾向",
"正在检查参考一致性",
"正在处理局部细节",
"正在补充纹理信息",
"正在修正不自然区域",
"正在平衡明暗对比",
"正在优化产品质感",
"正在调整阴影落点",
"正在整理背景干扰",
"正在增强真实感",
"正在重绘细小结构",
"正在合成高清结果",
"正在检查主体完整性",
"正在优化画面边缘",
"正在处理反光细节",
"正在提升材质表现",
"正在微调构图节奏",
"正在清理多余噪点",
"正在强化视觉焦点",
"正在校正颜色偏差",
"正在保留参考特征",
"正在生成高分辨率版本",
"正在压实画面层次",
"正在修饰局部瑕疵",
"正在检查文字与图形",
"正在稳定画面结构",
"正在优化细节密度",
"正在完善背景过渡",
"正在增强主体质感",
"正在统一整体光感",
"正在检查比例关系",
"正在修正边缘锯齿",
"正在提升清晰观感",
"正在处理最后细节",
"正在准备预览结果",
"正在同步生成进度",
"正在确认输出尺寸",
"正在整理结果文件",
"正在校验图片可用性",
"正在生成最终预览",
"正在完成高清渲染",
"正在保存生成结果",
"正在写入画布节点",
"正在同步到对话框",
"正在检查结果完整性",
"正在收尾生成任务",
"正在等待服务商返回",
"正在保持连接状态",
"正在确认生成结果",
"正在拉取图片地址",
"正在准备展示图片",
"正在完成最后处理",
"正在刷新画布内容",
"正在整理图片信息",
"正在完成本次生成",
"正在即将展示结果",
];
const LONG_WAIT_TITLE_THRESHOLD_MS = 5 * 60 * 1000;
const LONG_WAIT_STATUS_TITLES = [
"服务繁忙,排队处理中",
"高峰期生成较慢,请耐心等候",
"仍在保持连接,等待模型返回",
"结果返回后会自动展示",
];
const STATUS_TITLE_DURATIONS_MS = GENERATION_STATUS_TITLES.map((_, index) => {
const rhythm = [3600, 5200, 4100, 6100, 3300, 4700, 5700, 3900, 6800, 4400];
return rhythm[index % rhythm.length];
});
const TYPEWRITER_START_DELAY_MS = 120;
const TYPEWRITER_BASE_STEP_MS = 46;
function clampProgress(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
function estimateProgress(phase: GenerationPhase, elapsedMs: number) {
if (phase === "completed" || phase === "failed") return 100;
if (phase === "finalizing") return 96;
if (phase === "queued") {
return clampProgress(6 + Math.min(12, elapsedMs / 3500));
}
if (phase === "processing") {
const eased = 1 - Math.exp(-elapsedMs / 85_000);
return clampProgress(18 + eased * 78);
}
return clampProgress(Math.min(8, elapsedMs / 1000));
}
function getStatusTitleState(elapsedMs: number, initialTitle?: string) {
const titles = initialTitle
? [
initialTitle,
...GENERATION_STATUS_TITLES.filter((item) => item !== initialTitle),
]
: GENERATION_STATUS_TITLES;
const durations = initialTitle
? [4200, ...STATUS_TITLE_DURATIONS_MS]
: STATUS_TITLE_DURATIONS_MS;
let cursor = Math.max(0, elapsedMs);
for (let index = 0; index < titles.length; index += 1) {
const duration = durations[index] || 4600;
if (cursor < duration) {
return {
elapsedInTitle: cursor,
index,
title:
titles[index] ||
titles[titles.length - 1] ||
PHASE_TITLES.processing,
};
}
cursor -= duration;
}
return {
elapsedInTitle: durations[durations.length - 1] || 4600,
index: titles.length - 1,
title: titles[titles.length - 1] || PHASE_TITLES.processing,
};
}
function getLongWaitTitleState(elapsedMs: number, initialTitle?: string) {
const titles = initialTitle
? [
initialTitle,
...LONG_WAIT_STATUS_TITLES.filter((item) => item !== initialTitle),
]
: LONG_WAIT_STATUS_TITLES;
const elapsedAfterThreshold = Math.max(
0,
elapsedMs - LONG_WAIT_TITLE_THRESHOLD_MS
);
const duration = 7600;
const index = Math.floor(elapsedAfterThreshold / duration) % titles.length;
return {
elapsedInTitle: elapsedAfterThreshold % duration,
index,
title: titles[index] || titles[0] || PHASE_TITLES.processing,
};
}
function getTypewriterText(text: string, elapsedInTitle: number, index: number) {
const chars = Array.from(text);
if (chars.length === 0) return "";
const step = TYPEWRITER_BASE_STEP_MS + (index % 5) * 8;
const visibleCount = Math.max(
1,
Math.min(
chars.length,
Math.floor(
Math.max(0, elapsedInTitle - TYPEWRITER_START_DELAY_MS) / step
) + 1
)
);
return chars.slice(0, visibleCount).join("");
}
function getBloubState(phase: GenerationPhase): StateId {
if (phase === "failed") return "alert";
if (phase === "completed") return "wink";
@ -228,39 +51,10 @@ export function GenerationDotPlaceholder({
showProgress = true,
title,
}: GenerationDotPlaceholderProps) {
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
const startedAt = Date.now();
const timer = window.setInterval(() => {
setElapsedMs(Date.now() - startedAt);
}, 90);
return () => window.clearInterval(timer);
}, [phase]);
const resolvedProgress = clampProgress(
typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs)
);
const isLongWaitTitle =
(phase === "processing" || phase === "finalizing") &&
elapsedMs >= LONG_WAIT_TITLE_THRESHOLD_MS;
const statusTitleState = isLongWaitTitle
? getLongWaitTitleState(elapsedMs, title)
: getStatusTitleState(elapsedMs, title);
const rotatingTitle = getTypewriterText(
statusTitleState.title,
statusTitleState.elapsedInTitle,
statusTitleState.index
);
const resolvedTitle =
phase === "processing" || phase === "finalizing"
? rotatingTitle
: title || PHASE_TITLES[phase];
const resolvedProgress = typeof progress === "number" && Number.isFinite(progress)
? clampProgress(progress) : null;
const resolvedTitle = title || PHASE_TITLES[phase];
const playSequence = phase === "processing" || phase === "finalizing";
const progressStyle = useMemo(
() => ({ width: `${resolvedProgress}%` }),
[resolvedProgress]
);
return (
<div
@ -281,10 +75,6 @@ export function GenerationDotPlaceholder({
)}
>
{resolvedTitle}
<span
aria-hidden="true"
className="ml-1 inline-block h-[1em] w-[0.08em] translate-y-[0.15em] animate-[generationCursorBlink_1s_steps(1,end)_infinite] bg-current"
/>
</div>
<div className="flex min-h-0 flex-1 items-center justify-center py-3">
@ -297,13 +87,13 @@ export function GenerationDotPlaceholder({
/>
</div>
{showProgress ? (
{showProgress && phase !== "completed" ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b] dark:text-zinc-400">
<span>{phase === "failed" ? "已停止" : "生成进度"}</span>
<span>{resolvedProgress}%</span>
<span>{phase === "failed" ? "已停止" : resolvedProgress === null ? "等待结果,返回后自动展示" : "生成进度"}</span>
{resolvedProgress !== null ? <span>{resolvedProgress}%</span> : null}
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-white/80 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] dark:bg-white/10 dark:shadow-none">
{resolvedProgress !== null ? <div role="progressbar" aria-valuenow={resolvedProgress} aria-valuemin={0} aria-valuemax={100} className="h-1.5 overflow-hidden rounded-full bg-white/80 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] dark:bg-white/10 dark:shadow-none">
<div
className={cn(
"h-full rounded-full transition-[width] duration-700 ease-out",
@ -311,25 +101,12 @@ export function GenerationDotPlaceholder({
? "bg-[#c7ccd3] dark:bg-zinc-600"
: "bg-[#0a0a0c] dark:bg-[#3ecf8e]"
)}
style={progressStyle}
style={{ width: `${resolvedProgress}%` }}
/>
</div>
</div> : null}
</div>
) : null}
</div>
<style jsx>{`
@keyframes generationCursorBlink {
0%,
48% {
opacity: 1;
}
49%,
100% {
opacity: 0;
}
}
`}</style>
</div>
);
}

View File

@ -0,0 +1,68 @@
"use client";
import { useState, type ReactNode } from "react";
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
type HomeImageProps = {
sources: Array<string | null | undefined>;
alt: string;
className?: string;
imageClassName?: string;
fallback?: ReactNode;
loading?: "lazy" | "eager";
onUnavailable?: () => void;
};
function imageCandidates(source: string) {
// These two domains are documented aliases for the same gongxiangpan store.
// Do not rewrite other buckets, providers, signed URLs or user-supplied hosts.
try {
const url = new URL(source);
if (url.origin === "https://s3.yydd.ai" &&
url.pathname.startsWith("/gongxiangpan/") && !url.search && !url.hash) {
return [`https://s3.jiansun.vip${url.pathname}`, source];
}
} catch { /* Relative site assets need no host rewrite. */ }
return [source];
}
/** Keep failed image elements out of view, trying each distinct source once. */
export function HomeImage(props: HomeImageProps) {
const sources = [...new Set(props.sources
.filter((url): url is string => Boolean(url?.trim()) && !isRetiredStorageUrl(url))
.flatMap(imageCandidates))];
return <HomeImageAttempt key={JSON.stringify(sources)} {...props} sources={sources} />;
}
function HomeImageAttempt({
sources, alt, className, imageClassName, fallback, loading = "lazy", onUnavailable,
}: Omit<HomeImageProps, "sources"> & { sources: string[] }) {
const [index, setIndex] = useState(0);
const [loaded, setLoaded] = useState(false);
const source = sources[index];
return (
<span className={`relative block overflow-hidden ${className || ""}`}>
{!loaded && (
<span className={fallback ? "block" : "block aspect-[4/5] rounded-[12px] bg-[#e8e8e8] dark:bg-[#1b1c20]"}>
{fallback}
</span>
)}
{source ? (
<img
key={source}
src={source}
alt={alt}
loading={loading}
decoding="async"
className={`${imageClassName || ""} ${loaded ? "" : "absolute inset-0 opacity-0"}`}
onLoad={() => setLoaded(true)}
onError={() => {
setLoaded(false);
setIndex(index + 1);
if (index + 1 >= sources.length) onUnavailable?.();
}}
/>
) : null}
</span>
);
}

View File

@ -1,6 +1,7 @@
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
import type { Ref } from "react";
import { useState, type Ref } from "react";
import { HomeImage } from "./home-image";
import type { FeaturedImage } from "./types";
type LovartFeaturedGalleryProps = {
@ -30,6 +31,10 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
return `${source}?imageView2/2/w/900/q/78`;
}
function imageIdentity(item: FeaturedImage) {
return JSON.stringify([item.id, item.thumbnailUrl, item.originalUrl]);
}
export function LovartFeaturedGallery({
onOpenProjects,
onOpenMaterials,
@ -42,6 +47,13 @@ export function LovartFeaturedGallery({
isLoadingMoreFeatured,
setActiveCategory,
}: LovartFeaturedGalleryProps) {
const [unavailable, setUnavailable] = useState<Set<string>>(() => new Set());
const [retryVersion, setRetryVersion] = useState(0);
const uniqueItems = [...new Map(featuredItems.map((item) => [item.id, item])).values()];
const visibleItems = uniqueItems.filter((item) =>
Boolean(item.thumbnailUrl || item.originalUrl),
);
const unavailableCount = uniqueItems.filter(item => unavailable.has(imageIdentity(item))).length;
return (
<section
ref={featuredSectionRef}
@ -88,44 +100,48 @@ export function LovartFeaturedGallery({
))}
</div>
{featuredItems.length === 0 ? (
{unavailableCount > 0 && (
<div role="status" className="mt-4 flex items-center gap-3 text-xs text-black/50 dark:text-white/50">
<span>{unavailableCount} 张灵感图片加载失败,请重试。</span>
<button
type="button"
className="shrink-0 underline underline-offset-4 hover:text-black dark:hover:text-white"
onClick={() => {
setUnavailable(new Set());
setRetryVersion((version) => version + 1);
}}
>
重试加载
</button>
</div>
)}
{visibleItems.length === 0 ? (
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
{isLoadingFeatured
? "灵感内容加载中..."
: "当前没有可展示的灵感数据。"}
: unavailableCount > 0 ? "图片暂时无法加载,请稍后重试。" : "当前没有可展示的灵感数据。"}
</div>
) : (
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
{featuredItems.map((item, index) => (
{visibleItems.map((item) => (
<button
key={item.id}
key={`${imageIdentity(item)}:${retryVersion}`}
type="button"
disabled={unavailable.has(imageIdentity(item))}
onClick={() =>
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
}
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
>
<img
src={getFeaturedPreviewUrl(item)}
{unavailable.has(imageIdentity(item)) ? (
<div className="flex aspect-[4/5] items-center justify-center rounded-[12px] bg-muted p-4 text-sm text-muted-foreground">图片加载失败</div>
) : <HomeImage
sources={[getFeaturedPreviewUrl(item), item.thumbnailUrl, item.originalUrl]}
alt={item.prompt || "灵感图"}
loading="lazy"
decoding="async"
onError={(event) => {
const image = event.currentTarget;
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
if (
!fallbackUrl ||
image.dataset.fallbackApplied === "true"
) {
return;
}
image.dataset.fallbackApplied = "true";
image.src = fallbackUrl;
}}
className="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
/>
onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
imageClassName="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
/>}
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
<div className="flex min-w-0 items-center gap-[6px] pr-2">
<span className="flex h-[16px] w-[16px] shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#d8d8d8] text-[8px] font-medium text-white">
@ -138,11 +154,11 @@ export function LovartFeaturedGallery({
<div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
<span className="inline-flex items-center gap-[4px]">
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
{(1200 + index * 37).toLocaleString()}
{(1200 + featuredItems.indexOf(item) * 37).toLocaleString()}
</span>
<span className="inline-flex items-center gap-[4px]">
<Heart className="h-[11px] w-[11px]" strokeWidth={2} />
{(20 + (index % 9) * 11).toLocaleString()}
{(20 + (featuredItems.indexOf(item) % 9) * 11).toLocaleString()}
</span>
<Gem className="h-[11px] w-[11px]" strokeWidth={2} />
</div>

View File

@ -13,6 +13,7 @@ import {
} from "@/components/ui/tooltip";
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
import type { NavLink, TutorialVideo } from "./types";
import { HomeImage } from "./home-image";
type SidebarItem = {
iconUrl?: string | null;
@ -183,17 +184,18 @@ export function LovartSidebar({
onClick={onHome}
title={siteName}
>
{siteLogo ? (
<img
src={siteLogo}
<HomeImage
sources={[siteLogo]}
alt={siteName}
loading="eager"
className="h-7 w-7 rounded-full object-cover"
imageClassName="h-7 w-7 rounded-full object-cover"
fallback={
<span aria-hidden="true" className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
{siteName.slice(0, 1).toUpperCase()}
</span>
}
/>
) : (
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-[12px] font-semibold text-white dark:bg-white dark:text-black">
{siteName.slice(0, 1).toUpperCase()}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">{siteName}</TooltipContent>

View File

@ -918,9 +918,9 @@ export function ImageAnnotator({
setIsRemovingBg(true);
try {
// 使用原图URL获取原图数据,而不是canvas的预览图
const base64Data = await imageUrlToBase64(originalImageUrl);
logger.debug("rmbg_source_prepared", { base64Length: base64Data.length });
const input = /^https?:\/\//i.test(originalImageUrl)
? { imageUrl: originalImageUrl }
: { image_base64: await imageUrlToBase64(originalImageUrl) };
// 调用抠图API
const response = await fetch("/api/rmbg", {
@ -929,7 +929,7 @@ export function ImageAnnotator({
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
...input,
prompt: "画图标注-抠图处理", // 可选:添加提示词
}),
});

View File

@ -41,45 +41,11 @@ const PHASE_HINT: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, stri
idle: "任务已创建,正在初始化生成参数。",
queued: "任务已进入队列,请稍候。",
processing: "模型正在生成图片,请稍候。",
finalizing: "图片已生成完成,正在同步到对话。",
failed: "生成失败,请重试。",
finalizing: "图片已返回,正在保存并同步到对话。",
failed: "本次处理已停止,请查看对话中的原因和建议。",
};
const LONG_PROCESSING_HINTS = [
"正在读取参考图与文字需求。",
"正在分析主体、场景和构图关系。",
"正在提取画面中的关键视觉特征。",
"正在确认图片比例和输出尺寸。",
"正在匹配当前模型的最佳生成参数。",
"正在把参考图信息提交给模型。",
"正在等待模型完成第一阶段推理。",
"正在生成初始画面结构。",
"正在保持主体特征和参考一致。",
"正在补全画面中的空间层次。",
"正在调整光影方向和明暗关系。",
"正在优化主体边缘与背景融合。",
"正在细化材质、纹理和局部细节。",
"正在减少画面噪点和不自然区域。",
"正在提升清晰度与整体质感。",
"正在检查生成结果的完整性。",
"正在处理高分辨率输出。",
"正在等待服务商返回最终图片。",
"正在保存图片并同步到对话。",
"正在准备把结果显示到画布。",
];
const LONG_WAIT_SECONDS = 5 * 60;
const LONG_WAIT_HINT =
"服务繁忙,模型可能正在排队或自动尝试备用通道,请耐心等候,结果返回后会自动同步到画布。";
function getLongProcessingHint(phase: NonNullable<ImageGenerationLoadingProps["phase"]>, elapsedSeconds: number) {
if (phase !== "processing" && phase !== "finalizing") return PHASE_HINT[phase];
if (elapsedSeconds >= LONG_WAIT_SECONDS) return LONG_WAIT_HINT;
const index = Math.min(
LONG_PROCESSING_HINTS.length - 1,
Math.floor(Math.max(0, elapsedSeconds) / 15)
);
return LONG_PROCESSING_HINTS[index];
}
function formatImageQueueWaitLabel(estimatedWaitMs?: number) {
const numeric = Number(estimatedWaitMs || 0);
@ -138,16 +104,8 @@ export const ImageGenerationLoading = memo(
const isLongWait =
resolvedElapsedSeconds >= LONG_WAIT_SECONDS &&
(phase === "processing" || phase === "finalizing");
const estimateRangeLabel = isLongWait
? "单次最长约10分钟"
: providerType === "grsai-gpt-image"
? "2~5分钟"
: "60~120秒";
const elapsedLabel =
resolvedElapsedSeconds > 0
? `已等待 ${resolvedElapsedSeconds}s`
: `预计 ${estimateRangeLabel}`;
const phaseHint = getLongProcessingHint(phase, resolvedElapsedSeconds);
const elapsedLabel = resolvedElapsedSeconds > 0 ? `已等待 ${resolvedElapsedSeconds}s` : "";
const phaseHint = PHASE_HINT[phase];
const queueLine =
phase === "queued" && Number.isFinite(queuePosition)
? Math.max(0, Number(queuePosition || 0)) > 0
@ -189,12 +147,12 @@ export const ImageGenerationLoading = memo(
}
compact={count > 1}
phase={phase}
title={isLongWait ? "服务繁忙,排队处理中" : undefined}
title={isLongWait && phase === "processing" ? "模型尚未返回结果,请勿重复提交" : undefined}
/>
<div className="space-y-0.5 text-[#9aa3b2]">
<div className="text-[13px] font-medium">
使用 {resolvedModelName} 生成图片,预计 {estimateRangeLabel}
使用 {resolvedModelName} 生成图片
</div>
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}

View File

@ -1,10 +1,12 @@
"use client";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import dynamic from "next/dynamic";
import React, { useState, useEffect } from "react";
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
import { toast } from "sonner";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
const ImageAnnotator = dynamic(
() => import("./image-annotator").then((module) => module.ImageAnnotator),
@ -22,25 +24,6 @@ const AngleEditDialog = dynamic(
}
);
function triggerBrowserDownload(
url: string,
filename?: string,
options?: { openInNewTab?: boolean }
) {
const link = document.createElement("a");
link.href = url;
if (filename) {
link.download = filename;
}
if (options?.openInNewTab) {
link.target = "_blank";
}
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
export function ImagePreview({
src,
alt,
@ -237,24 +220,12 @@ export function ImagePreview({
toast.error("下载失败,请重试");
return;
}
const filenameBase = `generated-image-${Date.now()}`;
try {
const response = await fetch(
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
);
if (!response.ok) {
throw new Error(`下载失败(${response.status})`);
}
const blob = await response.blob();
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
const url = window.URL.createObjectURL(blob);
triggerBrowserDownload(url, filename);
window.URL.revokeObjectURL(url);
toast.success("下载成功");
await downloadOriginalImage({ url: safeOriginalUrl });
toast.success("原图已开始下载");
} catch (error) {
console.error("下载失败:", error);
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
toast.error(getCanvasImageDownloadErrorMessage(error));
}
};
@ -306,6 +277,10 @@ export function ImagePreview({
}
};
if (isRetiredStorageUrl(src) || isRetiredStorageUrl(original)) {
return <div role="status" className="my-2 rounded-lg border bg-muted p-4 text-sm text-muted-foreground">{RETIRED_STORAGE_MESSAGE}</div>;
}
return (
<>
{/* 缩略图(显示压缩后的缩略图)*/}
@ -332,9 +307,18 @@ export function ImagePreview({
}}
loading="lazy"
decoding="async"
onLoad={(event) => {
event.currentTarget.style.aspectRatio = "";
event.currentTarget.parentElement?.style.removeProperty("width");
event.currentTarget.alt = "Generated Image";
}}
onError={(event) => {
if (event.currentTarget.src !== (thumbnail || original)) {
if (!normalizeLegacyAliyunStorageUrl(thumbnail || original) && event.currentTarget.src !== (thumbnail || original)) {
event.currentTarget.src = thumbnail || original;
} else if (!compactGrid) {
event.currentTarget.style.aspectRatio = "1";
event.currentTarget.parentElement?.style.setProperty("width", "100%");
event.currentTarget.alt = "预览暂不可用,可尝试下载原图";
}
}}
onClick={() => setIsOpen(true)}
@ -626,7 +610,7 @@ export function ImagePreview({
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<img
src={original}
src={normalizeLegacyAliyunStorageUrl(original) ? buildImageProxyUrl(original) : original}
alt="Generated Image - Original"
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
isAnnotationMode ? "cursor-crosshair" : ""

View File

@ -7,6 +7,7 @@ import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { cn, sanitizeText } from "@/lib/utils";
import { extractImageUrlsFromText } from "@/lib/image-url";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import { useDataStream } from "./data-stream-provider";
import { DocumentToolResult } from "./document";
import { DocumentPreview } from "./document-preview";
@ -521,7 +522,7 @@ const PurePreviewMessage = ({
const displayModelName = extractedModelName || inferredFallbackModelName;
const filteredText =
message.role === "assistant"
? appendImageDoneSummary(contentWithoutBadge)
? getGatewayErrorPageMessage(contentWithoutBadge) || appendImageDoneSummary(contentWithoutBadge)
: contentWithoutBadge;
const useAssistantTypewriter =
message.role === "assistant" &&

View File

@ -9,6 +9,7 @@ import { Button } from "./ui/button";
import { X, Download, Target, Paintbrush } from "lucide-react";
import { toast } from "sonner";
import { ImageAnnotator } from "./image-annotator";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
export const PreviewAttachment = ({
attachment,
@ -73,18 +74,11 @@ export const PreviewAttachment = ({
// 下载图片函数
const handleDownload = async () => {
try {
const response = await fetch(url);
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = name ?? `image-${Date.now()}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
await downloadOriginalImage({ url, name: name ?? "image" });
toast.success("原图已开始下载");
} catch (error) {
console.error("下载失败:", error);
toast.error(getCanvasImageDownloadErrorMessage(error));
}
};

View File

@ -0,0 +1,74 @@
"use client";
import { useEffect, useState, type CSSProperties, type MouseEventHandler } from "react";
import { buildImageProxyUrl, getFullscreenPreviewSize } from "@/lib/image-delivery";
export type LoadedImagePreview = { src: string; width: number; height: number };
/** Keep the already displayed image visible while its larger preview loads. */
export function ProgressiveImagePreview({
src,
preview,
alt,
className,
style,
onClick,
}: {
src: string;
preview: LoadedImagePreview;
alt: string;
className?: string;
style?: CSSProperties;
onClick?: MouseEventHandler<HTMLImageElement>;
}) {
const size = getFullscreenPreviewSize({
imageWidth: preview.width,
imageHeight: preview.height,
viewportWidth: typeof window === "undefined" ? 1280 : window.innerWidth,
viewportHeight: typeof window === "undefined" ? 900 : window.innerHeight,
pixelRatio: typeof window === "undefined" ? 1 : window.devicePixelRatio,
});
const fullSrc = buildImageProxyUrl(src, {
delivery: "proxy",
width: size.requestWidth,
quality: 86,
format: "webp",
});
const [loadedSrc, setLoadedSrc] = useState("");
const [failedSrc, setFailedSrc] = useState("");
const ready = fullSrc === preview.src || loadedSrc === fullSrc;
const failed = failedSrc === fullSrc;
useEffect(() => {
if (fullSrc === preview.src) return;
const image = new Image();
image.decoding = "async";
image.onload = () => setLoadedSrc(fullSrc);
image.onerror = () => setFailedSrc(fullSrc);
image.src = fullSrc;
return () => {
image.onload = null;
image.onerror = null;
image.removeAttribute("src");
};
}, [fullSrc, preview.src]);
return (
<div className="relative flex items-center justify-center" onClick={(event) => event.stopPropagation()}>
<img
alt={alt}
className={className}
decoding="async"
draggable={false}
onClick={onClick}
src={ready ? fullSrc : preview.src}
style={{ width: size.displayWidth, ...style }}
/>
{!ready && (
<div role="status" className="pointer-events-none absolute bottom-4 max-w-full rounded-lg bg-black/80 px-3 py-2 text-center text-sm text-white">
{failed ? "高清预览加载失败,已保留当前预览;可关闭后重试或下载原图" : "正在加载高清预览…"}
</div>
)}
</div>
);
}

View File

@ -1,4 +1,5 @@
"use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import {
ArrowLeft,
@ -2073,18 +2074,9 @@ export function SceneFusionView({
const downloadImage = async (url: string, filename: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
} catch {
window.open(url, "_blank");
await downloadOriginalImage({ url, name: filename });
} catch (error) {
toast.error(getCanvasImageDownloadErrorMessage(error));
}
};

View File

@ -29,10 +29,10 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> & { overlayClassName?: string }
>(({ className, overlayClassName, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogOverlay className={overlayClassName} />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(

View File

@ -92,8 +92,16 @@ public 先从当前运行版本创建写入时复制副本,保留未纳入 Git
原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。
实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。
`next.config.ts` 将本流程的发布目录编号写入前后端版本标识;`/api/app-version` 返回该编号且不缓存,发布烟测会核对它与运行目录一致。网页每分钟及切回页面时检查更新,发现版本变化后提示同步并刷新;画布同步失败或当前任务仍在处理时保留页面。首次接入前已经打开的旧网页需先刷新一次,后续发布即可自动收到提示。
维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。
服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。
仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。
每次 PR 须记录本次改动的验证、历史失败和未覆盖项目;当前流程不会自动触发生产发布。
## ZIP 下载交付
默认存储为七牛时,完整 ZIP 通过分片上传至七牛,确认下载地址可用后才提供下载。
大文件使用三个并发分片,上传进度显示在原打包任务中。传送或链接检查失败会返回具体阶段、
下载编号及重试建议,保留已生成的 ZIP;再次点击下载复用原文件,已上传的完整对象不重复上传。
后台 ZIP 任务参与发布期间的工作等待。完成或失败的任务及文件保留 24 小时,待处理任务保留 6 小时。

33
docs/retired-storage.md Normal file
View File

@ -0,0 +1,33 @@
# 旧存储停用与七牛上传核查
## s3.1080p.hk
该存储已永久停用,文件不可恢复。它与 2026 年 10 月 14 日迁入七牛的阿里云 OSS 资源不是同一套存储。
- 首页精选只展示原文件仍在有效存储上的记录,统计和分页按同一条件计算。
- 历史生成记录、聊天、画布节点保留。旧图片显示永久失效说明,不再发起下载、归档或上传到旧桶。
- 单图下载和代理返回 HTTP 410 / `storage_retired`;批量下载将对应文件记为失败,不重试旧源。
- 其他存储的临时加载失败仍展示错误卡片和重试入口。
- 黑色 Logo 为 `public/brand/logo-black.png`;白色版使用 SVG 色彩滤镜保留同一 PNG 的透明度和轮廓。两者随应用发布,不依赖对象存储。
一次性配置整理:在生产运行目录执行 `node scripts/storage/retire-1080p-storage.cjs` 预览,增加 `--apply` 停用旧配置、取消失效图片的精选标记并更新 Logo。执行前将原字段保存至 `/var/lib/ai-chatbot/retired-storage-20261103/`。该操作不删除图片记录或对象文件。
2026-11-03 核查:旧源生成记录 67,267 条;精选 65 条中 59 条来自旧源;旧源图片归档任务为 0。默认上传存储为七牛 `gongxiangpan-sc`。
## 2026-10-14 上传峰值
统计时区 UTC+8,GB 按十进制计算。数据来自七牛只读上传统计、对象清单和生产机迁移记录。
| 范围 | 对象数 | 字节数 | GB |
| --- | ---: | ---: | ---: |
| 七牛当天上传流量 | — | 825,113,404,030 | 825.11 |
| 当天写入且仍存在的 `legacy-oss/demo-sparkai/` | 409,821 | 812,788,883,061 | 812.79 |
| 当天写入且仍存在的 `legacy-s3/gongxiangpan/` | 539 | 865,360,590 | 0.87 |
| 当天写入且仍存在的其他目录 | 3,382 | 9,735,597,543 | 9.74 |
| 上传流量与当天现存对象大小差额 | — | 1,723,562,836 | 1.72 |
813.65 GB 位于本项目历史迁移目录。主要写入发生在 19:00–21:00。源分别为 `demo-sparkai.oss-cn-shanghai.aliyuncs.com` 和 `s3.jiansun.vip/gongxiangpan`,符合仓库迁移脚本的目标路径。
生产机 `/var/lib/ai-chatbot/aliyun-qiniu-20261014/verified.jsonl` 有 349,784 个去重后的目标对象、629,920,510,316 字节(629.92 GB)的逐对象验证记录。当前迁移目录比该回执多 60,576 个对象、183.73 GB;目录归属能够确认,现有回执没有覆盖这些对象。
桶由多个业务共用。其他目录中 `uploads/` 为 7.97 GB,其余包含 `dabi/`、`exports/`、`accio-ghost/` 和 `shared-drive/`,不能全部归为画布新生成资源。上传统计包含传输量,对象清单反映现存对象,不足以单独解释最后 1.72 GB 的差额。本次未删除或改动七牛对象。

View File

@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
import {
CanvasRevisionConflict,
@ -149,6 +150,16 @@ export function useVideoCanvasPersistence(
return !queue.dirty && !queue.failed;
}, [ready]);
useEffect(() => {
const beforeRefresh = (event: Event) => {
(event as CustomEvent<PageRefreshRequest>).detail.save(async () => {
if (!(await saveAndConfirm())) throw new Error("视频画布尚未同步完成,本地草稿已保留,请重试刷新。");
});
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [saveAndConfirm]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {

View File

@ -0,0 +1,83 @@
import { sql, type SQL } from "drizzle-orm";
export function parseMediaQuery(params: URLSearchParams) {
const integer = (key: string, fallback: number, max: number) => {
const value = Number(params.get(key));
return Number.isInteger(value) && value > 0 ? Math.min(value, max) : fallback;
};
const date = (key: string) => {
const value = params.get(key) || "";
return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
};
return {
page: integer("page", 1, 100000),
pageSize: integer("pageSize", 40, 100),
search: (params.get("search") || "").trim().slice(0, 128),
type: ["image", "video"].includes(params.get("type") || "") ? params.get("type")! : "all",
model: params.get("model") || "",
featured: params.get("featured") === "true",
startDate: date("startDate"),
endDate: date("endDate"),
};
}
export function mediaRecordsQuery(params: ReturnType<typeof parseMediaQuery>) {
const conditions: SQL[] = [sql`true`];
if (params.search)
conditions.push(
sql`exists (select 1 from "User" u where u.id=m."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
);
if (params.startDate) conditions.push(sql`m."createdAt" >= ${params.startDate}::date`);
if (params.endDate)
conditions.push(sql`m."createdAt" < ${params.endDate}::date + interval '1 day'`);
if (params.model) conditions.push(sql`(m."modelId" = ${params.model} or m."modelId" in (select id::text from "AiModel" where "modelId"=${params.model}))`);
const where = sql.join(conditions, sql` and `);
const videoFilters: SQL[] = [sql`true`];
if (params.startDate)
videoFilters.push(sql`v."createdAt">=${params.startDate}::date - interval '8 hours'`);
if (params.endDate)
videoFilters.push(sql`v."createdAt"<${params.endDate}::date+interval '16 hours'`);
if (params.model) videoFilters.push(sql`(v."video_metadata"->>'model'=${params.model} or v."video_metadata"->>'model' in (select id::text from "AiModel" where "modelId"=${params.model}))`);
if (params.search)
videoFilters.push(
sql`exists (select 1 from "User" u where u.id=c."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
);
return sql`
with video_candidates as materialized (
select v.id, v."chatId", v."createdAt" + interval '8 hours' as "createdAt", v."video_metadata", c."userId",
coalesce(nullif(v."permanent_video_url", ''), nullif(v."temporary_video_url", ''),
(regexp_match(t.body, '<video[^>]+src="([^"]+)"', 'i'))[1],
(regexp_match(t.body, '(https?://[^[:space:]"<>]+\\.mp4[^[:space:]"<>)]*)', 'i'))[1]) as url
from "Message_v2" v join "Chat" c on c.id=v."chatId"
cross join lateral (select case when jsonb_typeof(v.parts::jsonb)='array'
then (select string_agg(p->>'text', E'\\n') from jsonb_array_elements(v.parts::jsonb) p)
else v.parts::jsonb #>> '{}' end as body) t
where ${params.type !== "image" && !params.featured} and v.role='assistant'
and ${sql.join(videoFilters, sql` and `)}
and (v."permanent_video_url" is not null or v."temporary_video_url" is not null or v.parts::text ilike '%<video%' or v.parts::text ilike '%.mp4%')
), media as not materialized (
select g.id, 'image'::text as kind, g."createdAt", g."userId", g."modelId", null::text as url
from "GeneratedImage" g where ${params.type !== "video"} and (${!params.featured} or g."isFeatured")
union all
select id, 'video', "createdAt", "userId", "video_metadata"->>'model', url
from video_candidates where url is not null
), filtered as not materialized (select * from media m where ${where}),
totals as (select count(*)::int as total, count(*) filter(where kind='image')::int as images, count(*) filter(where kind='video')::int as videos from filtered),
page_rows as (
select * from filtered order by "createdAt" desc, kind, id desc
limit ${params.pageSize} offset ${(params.page - 1) * params.pageSize}
)
select t.total, t.images as "totalImages", t.videos as "totalVideos",
coalesce((select jsonb_agg(jsonb_build_object(
'id', case when p.kind='video' then 'video-'||p.id::text else p.id::text end,
'recordType',p.kind,'userId',p."userId",'userName',u.name,'userEmail',u.email,'userDepartment',u.department,
'createdAt',p."createdAt",'modelId',p."modelId",'chatId',coalesce(g."chatId",v."chatId"),
'thumbnailUrl',coalesce(p.url,g."thumbnailUrl"),'originalUrl',coalesce(p.url,g."originalUrl"),'videoUrl',p.url,
'prompt',coalesce(g.prompt,v."video_metadata"->>'prompt'),'imageSize',coalesce(g."imageSize",v."video_metadata"->>'resolution'),
'isFeatured',coalesce(g."isFeatured",false),'featuredCategory',g."featuredCategory",'featuredAt',g."featuredAt" + interval '8 hours'
) order by p."createdAt" desc,p.kind,p.id desc)
from page_rows p left join "User" u on u.id=p."userId"
left join "GeneratedImage" g on p.kind='image' and g.id=p.id
left join "Message_v2" v on p.kind='video' and v.id=p.id),'[]'::jsonb) as records
from totals t`;
}

View File

@ -0,0 +1,31 @@
// One queue for the records screen: visible previews get four network slots.
const waiting: Array<() => void> = [];
let active = 0;
function pump() {
while (active < 4 && waiting.length) waiting.shift()?.();
}
export function queuePreview(start: (release: () => void) => void) {
let released = false;
let started = false;
const release = () => {
if (released) return;
released = true;
if (started) active--;
else {
const index = waiting.indexOf(run);
if (index >= 0) waiting.splice(index, 1);
}
pump();
};
const run = () => {
started = true;
active++;
start(release);
};
waiting.push(run);
pump();
// Active fetches release their slot in finally, after abort has settled.
return () => {
if (!started) release();
};
}

7
lib/admin/record-time.ts Normal file
View File

@ -0,0 +1,7 @@
export function formatRecordTime(value: string) {
// The admin API normalizes each storage convention to Beijing wall-clock time.
return value
.replace("T", " ")
.replace(/(?:\.\d+)?Z$/, "")
.slice(0, 16);
}

View File

@ -14,6 +14,8 @@ import {
sanitizeAgentDetailBrief,
} from "@/lib/agents/detail-generation-config";
export const MAX_DETAIL_REPLACEMENT_REFERENCES = 64;
export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState {
return {
detailReplacement: {

View File

@ -0,0 +1,15 @@
import { extractImageUrlsFromText } from "@/lib/image-url";
import { messageText } from "./replacement-run-recovery";
type Message = { id: string; role: string; parts?: unknown[] };
export function getSceneRunResult(messages: Message[], requestMessageId: string) {
const index = messages.findIndex((message) => message.id === requestMessageId && message.role === "user");
if (index < 0) return null;
const replies = messages.slice(index + 1);
// A later user turn belongs to another run.
if (replies.some((message) => message.role === "user")) return null;
const urls = new Set(replies.filter((message) => message.role === "assistant")
.flatMap((message) => extractImageUrlsFromText(messageText(message))));
return { count: urls.size };
}

View File

@ -407,14 +407,10 @@ function buildPromptSection(title: string, lines: string[]) {
return `【${title}】\n${normalizedLines.join("\n")}`;
}
export function buildAgentWorkflowComposerContext(params: {
export function resolveAgentWorkflowComposerSelections(params: {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputsValue;
materials?: AgentWorkflowMaterialSelections | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
appearanceRecognitionEnabled?: boolean;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}): AgentWorkflowComposerContext | null {
}): AgentWorkflowComposerContext["selections"] {
const config = params.config || defaultAgentWorkflowUiConfig;
const steps = flattenVisibleAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : []),
@ -422,22 +418,31 @@ export function buildAgentWorkflowComposerContext(params: {
)
.map(({ step }) => step)
.filter((step) => Array.isArray(step.options) && step.options.length > 0);
const selections = steps
.map((step) => {
const selectedOptions = getAgentWorkflowSelectedOptionPath({
step,
selectionValue: params.selections[step.key],
});
if (selectedOptions.length === 0) return [];
return selectedOptions.map((option) => ({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || option.buttonName,
}));
})
.flat() as AgentWorkflowComposerContext["selections"];
return steps.flatMap((step) => {
const selectedOptions = getAgentWorkflowSelectedOptionPath({
step,
selectionValue: params.selections[step.key],
});
return selectedOptions.map((option) => ({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || option.buttonName,
}));
});
}
export function buildAgentWorkflowComposerContext(params: {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputsValue;
materials?: AgentWorkflowMaterialSelections | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
appearanceRecognitionEnabled?: boolean;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
resolvedSelections?: AgentWorkflowComposerContext["selections"];
}): AgentWorkflowComposerContext | null {
const selections = params.resolvedSelections ?? resolveAgentWorkflowComposerSelections(params);
const referenceImages = getSelectedWorkflowReferenceImages({
inputs: params.inputs,
materials: params.materials,

View File

@ -0,0 +1,18 @@
import type { AgentWorkflowOptionUiConfig, AgentWorkflowStepUiConfig } from "@/types/agent";
export function omitAgentWorkflowPrompts(steps: AgentWorkflowStepUiConfig[]): AgentWorkflowStepUiConfig[] {
const omitOptionPrompt = ({ promptFragment: _prompt, children, ...option }: AgentWorkflowOptionUiConfig): AgentWorkflowOptionUiConfig => ({
...option,
...(children?.length ? { children: children.map(omitOptionPrompt) } : {}),
});
return steps.map(({ options, children, ...step }) => ({
...step,
options: options.map(omitOptionPrompt),
...(children?.length ? { children: omitAgentWorkflowPrompts(children) } : {}),
}));
}
export function getWorkflowSelectionsRequest(selections: Record<string, string | undefined>) {
const entries = Object.entries(selections).filter(([, value]) => value?.trim());
return entries.length ? JSON.stringify(Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)))) : "";
}

View File

@ -4,6 +4,9 @@ import {
executeRmbgJob,
fetchImageBufferFromUrl,
} from "@/lib/ai/rmbg/service";
import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("box-cutout-service");
export type NormalizedCropRect = {
x: number;
@ -13,6 +16,7 @@ export type NormalizedCropRect = {
};
export interface ExecuteBoxCutoutJobParams {
requestId?: string;
userId: string;
imageUrl: string;
cropRect: Partial<NormalizedCropRect>;
@ -143,6 +147,7 @@ function applyPadding(
export async function executeBoxCutoutJob(
params: ExecuteBoxCutoutJobParams,
): Promise<ExecuteBoxCutoutJobResult> {
const requestId = params.requestId || crypto.randomUUID();
const imageUrl = String(params.imageUrl || "").trim();
if (!imageUrl) {
throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400);
@ -150,7 +155,7 @@ export async function executeBoxCutoutJob(
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
if (!cropRect) {
throw new BoxCutoutServiceError("缺少有效的 cropRect 参数", 400);
throw new BoxCutoutServiceError("框选范围无效,请重新框选需要抠出的区域后提交。", 400);
}
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
@ -170,7 +175,7 @@ export async function executeBoxCutoutJob(
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
throw new BoxCutoutServiceError("框选区域过小,请重新框选", 400);
throw new BoxCutoutServiceError("框选区域过小,宽、高均须至少 24 像素,请扩大框选范围后重新提交。", 400);
}
const paddedCropRect = applyPadding(cropRect, paddingRatio);
@ -184,7 +189,18 @@ export async function executeBoxCutoutJob(
})
.toBuffer();
logger.info("crop_prepared", {
requestId,
sourceNodeId: params.sourceNodeId,
sourceWidth,
sourceHeight,
cropRectPixels,
paddedCropRectPixels,
inputBytes: croppedBuffer.length,
});
const result = await executeRmbgJob({
requestId,
userId: params.userId,
imageBuffer: croppedBuffer,
imageBase64: croppedBuffer.toString("base64"),

View File

@ -9,6 +9,7 @@ import {
summarizeAgentCapabilities,
} from "./capability-access";
import type { CanvasAgentTool } from "./loop";
import { AgentToolRejectedError } from "./protocol";
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
export function registerImageCanvasAccessTools(params: {
@ -59,10 +60,10 @@ export function registerImageCanvasAccessTools(params: {
(item) => item.workspace === "canvas" && item.id === input.featureId,
);
if (!feature?.authorized || !feature.id.startsWith("canvas.ui."))
throw new Error("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具");
throw new AgentToolRejectedError("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具;本次未打开面板或提交处理。");
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
if (input.nodeIds.some((nodeId) => !nodes.some((node) => node.id === nodeId)))
throw new Error("目标节点不属于当前画布");
throw new AgentToolRejectedError("目标节点不属于当前画布,本次未打开面板或提交处理。");
params.emit("data-canvasAgentUiCommand", {
projectId: params.projectId,
requestId: `${params.runId}:${id}`,

View File

@ -13,13 +13,18 @@ export type AgentImageParameters = {
};
export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters };
export function selectedAgentImageCount(preferences?: AgentImagePreferences) {
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
return count === undefined ? undefined : Math.floor(count);
}
export function applyAgentImagePreferences<T extends Record<string, unknown>>(
input: T,
preferences?: AgentImagePreferences,
): T & AgentImageParameters & { modelId?: string } {
const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {})
.filter(([, value]) => value !== undefined));
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
const count = selectedAgentImageCount(preferences);
return {
...input,
...parameters,

View File

@ -1,5 +1,14 @@
import type { AgentCallRecord, AgentRunSnapshot } from "./protocol";
export function countAgentGeneratedImages(calls: AgentCallRecord[]) {
return calls.reduce((count, call) => {
const output = call.output;
if (call.status !== "succeeded" || !output?.appliedImagePreferences ||
!Array.isArray(output.createdNodeIds)) return count;
return count + output.createdNodeIds.length;
}, 0);
}
// A spontaneous download command is not an unfinished image edit. Keep genuine
// download requests and all editor panels waiting for their actual interaction.
export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) {

View File

@ -5,7 +5,8 @@ import sharp from "sharp";
import { z } from "zod";
import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas";
import type { AgentRunSnapshot } from "./protocol";
import { finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results";
import { countAgentGeneratedImages, finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results";
import { selectedAgentImageCount } from "./image-preferences";
import { isCompletedSimpleCutout } from "./cutout-completion";
export async function inspectImageAlpha(source: Buffer) {
@ -124,14 +125,16 @@ export async function verifyCanvasAgentResults(params: {
(call) =>
call.status !== "succeeded" ||
requiresCanvasInteraction(snapshot, call) ||
(typeof call.output?.status === "string" && call.output.status !== "succeeded"),
(typeof call.output?.status === "string" && call.output.status !== "succeeded" &&
!(call.output.status === "partial" && call.output.appliedImagePreferences)),
);
if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] };
const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output;
if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] };
// Intermediate images consumed by later write tools do not count as final deliverables.
const finalIds = finalResultNodeIds(snapshot);
const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount;
const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount ??
(countAgentGeneratedImages(calls) > 0 ? selectedAgentImageCount(snapshot.imagePreferences) : undefined);
if (expectedCount && finalIds.length !== expectedCount) {
return {
passed: false,

View File

@ -1,5 +1,11 @@
import type { AgentVisionExecutor } from "../runtime/agent-vision-failover";
import { resolveUserImageModel, applyAgentImagePreferences, agentProviderImageParameters, type AgentImagePreferences } from "./image-preferences";
import {
agentProviderImageParameters,
applyAgentImagePreferences,
resolveUserImageModel,
selectedAgentImageCount,
type AgentImagePreferences,
} from "./image-preferences";
import { tool, type LanguageModel, type ToolSet } from "ai";
import { z } from "zod";
import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools";
@ -18,6 +24,8 @@ import {
sortStyleProImageModelEntries,
} from "@/lib/style-pro/model-options";
import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop";
import { AgentToolRejectedError, type AgentCallRecord } from "./protocol";
import { countAgentGeneratedImages } from "./results";
import { readCanvasImages, readOwnedCanvas } from "./canvas";
import { analyzeRuntimeImages } from "./review";
import { isExplicitDestructiveIntent } from "./capability-access";
@ -37,11 +45,13 @@ export async function buildCanvasRuntimeTools(params: {
imageSize?: string;
preferredModelId?: string;
imagePreferences?: AgentImagePreferences;
priorCalls?: AgentCallRecord[];
emit: (type: string, data: unknown) => void;
objective?: string;
operationSelectedNodeIds?: string[];
}) {
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
const selectedImageCount = selectedAgentImageCount(params.imagePreferences);
const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id);
const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id));
const sdk = await createCanvasCapabilityTools({
@ -128,8 +138,8 @@ export async function buildCanvasRuntimeTools(params: {
visualCriteria: z.array(z.string().max(200)).max(10),
}),
async (input) => ({ ...input,
...(input.expectedImageCount !== undefined && params.imagePreferences?.parameters?.imageCount !== undefined
? { expectedImageCount: params.imagePreferences.parameters.imageCount } : {}),
...(input.expectedImageCount !== undefined && selectedImageCount !== undefined
? { expectedImageCount: selectedImageCount } : {}),
...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto"
? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}),
}),
@ -163,6 +173,8 @@ export async function buildCanvasRuntimeTools(params: {
};
},
);
const inspectedImages = new Map<string, Buffer>();
let inspectedImageBytes = 0;
register(
"analyzeCanvasImages",
"检查图片",
@ -171,10 +183,28 @@ export async function buildCanvasRuntimeTools(params: {
nodeIds: z.array(z.string().min(1).max(180)).min(1).max(4),
question: z.string().min(1).max(2000),
}),
async (input) => analyzeRuntimeImages({ ...params, ...input }),
async (input) => analyzeRuntimeImages({
...params,
...input,
reviewPorts: {
readImage: async (url) => {
const cached = inspectedImages.get(url);
if (cached) return cached;
const { fetchImageBufferFromUrl } = await import("@/lib/ai/rmbg/service");
const original = await fetchImageBufferFromUrl(url);
if (!inspectedImages.has(url) && inspectedImageBytes + original.length <= 32 * 1024 * 1024) {
inspectedImages.set(url, original);
inspectedImageBytes += original.length;
}
return original;
},
},
}),
);
let generatedCount = 0;
let submittedCount = 0;
let occupiedImageSlots = countAgentGeneratedImages(params.priorCalls ?? []);
const countLimitMessage = "本次设定的生成张数已占满,请先查看正在生成或已经生成的结果。";
register(
"generateCanvasImages",
"生成图片",
@ -189,9 +219,8 @@ export async function buildCanvasRuntimeTools(params: {
}),
async (toolInput, callId) => {
const input = applyAgentImagePreferences(toolInput, params.imagePreferences);
// Bound per-task spend without introducing any global/model concurrency queue.
if (generatedCount + input.imageCount > 24)
throw new Error("本次任务已达到 24 张生成预算,请检查结果后继续。");
if (selectedImageCount !== undefined && occupiedImageSlots >= selectedImageCount)
throw new AgentToolRejectedError(countLimitMessage);
const sources = await readCanvasImages(
params.userId,
params.projectId,
@ -214,10 +243,17 @@ export async function buildCanvasRuntimeTools(params: {
if (!chosen) throw new Error("系统未配置可用的图片生成模型");
const imageSize = input.imageSize || params.imageSize || "2K";
const quota = await checkUserImageQuota(params.userId);
if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining))
throw new Error("本月图片生成配额不足。");
const points = await getPointsSystemConfig();
const user = await getUserById({ id: params.userId });
// Reserve the remaining slots before dispatch, including calls still in flight.
if (selectedImageCount !== undefined) {
input.imageCount = Math.min(input.imageCount, selectedImageCount - occupiedImageSlots);
if (input.imageCount < 1) throw new AgentToolRejectedError(countLimitMessage);
}
if (submittedCount + input.imageCount > 24)
throw new AgentToolRejectedError("本次任务已达到 24 张生成预算,请检查结果后继续。");
if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining))
throw new Error("本月图片生成配额不足。");
const cost =
getModelGenerationCreditCost(
{ imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null },
@ -231,7 +267,8 @@ export async function buildCanvasRuntimeTools(params: {
(user?.pointsBalance ?? 0) < cost
)
throw new Error("积分不足,请充值或联系管理员。");
generatedCount += input.imageCount;
submittedCount += input.imageCount;
occupiedImageSlots += input.imageCount;
const result = await generateCanvasImage({
prompt: input.prompt,
model: chosen.model.id,
@ -247,6 +284,7 @@ export async function buildCanvasRuntimeTools(params: {
? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined,
ownerKey: params.userId,
referenceImages: sources.map((node) => node.data.src || node.data.url!),
referenceImageBuffers: inspectedImages,
debugLabel: "canvas-agent-runtime",
debugMeta: { runId: params.runId, callId },
});
@ -311,6 +349,7 @@ export async function buildCanvasRuntimeTools(params: {
},
);
}
occupiedImageSlots += result.images.length - input.imageCount;
return {
status: result.images.length < input.imageCount ? "partial" : "succeeded",
appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount },

View File

@ -72,6 +72,7 @@ export async function processVideoCanvasImage(
const result = await executeRmbgJob({
userId: params.userId,
imageBuffer: await fetchImageBufferFromUrl(imageUrl),
inputImageUrl: imageUrl,
sourceImageUrl: imageUrl,
prompt: params.prompt,
});

View File

@ -1,3 +1,5 @@
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
"detail_replacement_image_model_config";
@ -20,3 +22,28 @@ export function normalizeDetailReplacementImageModelConfig(
modelId: modelId || DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID,
};
}
export function isDetailReplacementImageModel({
model,
provider,
}: {
model: { modelId: string; supportsImageGen: boolean; isSubModel: boolean; capabilities?: unknown };
provider?: { type?: string | null } | null;
}) {
return model.supportsImageGen && !model.isSubModel && !isVideoModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
export function findConfiguredDetailReplacementImageModel<
T extends Parameters<typeof isDetailReplacementImageModel>[0] & { model: { id: string } },
>(models: T[], setting: unknown): T | undefined {
const { modelId } = normalizeDetailReplacementImageModelConfig(setting);
return models.find((entry) =>
(entry.model.id === modelId || entry.model.modelId === modelId) &&
isDetailReplacementImageModel(entry)
);
}

View File

@ -1,4 +1,5 @@
import "server-only";
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
import { createOpenAI } from "@ai-sdk/openai";
import {
@ -418,7 +419,7 @@ export async function getDynamicModel(
): Promise<LanguageModel | null> {
try {
const model = await getAiModelById({ id: modelId });
if (!model || !model.isEnabled) {
if (!model || !model.isEnabled || (model.supportsImageGen && isImageModelBalanceBlocked(model))) {
logger.warn("model unavailable", { modelId });
return null;
}

View File

@ -3,6 +3,19 @@ export type ExtendProviderCredential = {
updatedAt?: Date | string | null;
};
export type ExtendProvider = "vip98k" | "dashscope" | "fal";
export function resolveFalExtendApiKey(providers: Array<ExtendProviderCredential & { baseUrl?: string | null }>) {
for (const { apiKey, baseUrl } of providers) {
if (!apiKey || !baseUrl) continue;
const url = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
if (url?.protocol === "https:" && ["queue.fal.run", "fal.run", "fal.ai", "api.fal.ai"].includes(url.hostname)) {
return apiKey.trim().replace(/^(?:Bearer|Key)\s+/i, "");
}
}
return null;
}
function getUpdatedAtTimestamp(value: Date | string | null | undefined): number {
if (!value) return 0;
const timestamp = value instanceof Date ? value.getTime() : Date.parse(value);

View File

@ -0,0 +1,93 @@
import { createFalClient } from "@fal-ai/client";
import sharp from "sharp";
export const FAL_OUTPAINT_MODEL = "fal-ai/flux-2-pro/outpaint";
export function getOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
const ratio = Math.min(1, 2048 / Math.max(targetWidth, targetHeight));
const modelWidth = Math.max(1, Math.floor(targetWidth * ratio));
const modelHeight = Math.max(1, Math.floor(targetHeight * ratio));
const sourceWidth = Math.max(1, Math.round(width * modelWidth / targetWidth));
const sourceHeight = Math.max(1, Math.round(height * modelHeight / targetHeight));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const modelLeft = Math.round(left * modelWidth / targetWidth);
const modelTop = Math.round(top * modelHeight / targetHeight);
// Align the model canvas to its 16-pixel grid, then remove the extra border.
const extraWidth = (16 - modelWidth % 16) % 16;
const extraHeight = (16 - modelHeight % 16) % 16;
const cropLeft = Math.floor(extraWidth / 2);
const cropTop = Math.floor(extraHeight / 2);
return {
targetWidth, targetHeight, left, top, modelWidth, modelHeight, sourceWidth, sourceHeight,
paddedWidth: modelWidth + extraWidth,
paddedHeight: modelHeight + extraHeight,
cropLeft,
cropTop,
expandLeft: modelLeft + cropLeft,
expandRight: modelWidth - sourceWidth - modelLeft + extraWidth - cropLeft,
expandTop: modelTop + cropTop,
expandBottom: modelHeight - sourceHeight - modelTop + extraHeight - cropTop,
};
}
export async function composeOutpaint(source: Buffer, expanded: Buffer, geometry: ReturnType<typeof getOutpaintGeometry>) {
const { targetWidth, targetHeight, left, top, modelWidth, modelHeight, paddedWidth, paddedHeight, cropLeft, cropTop } = geometry;
const modelImage = sharp(expanded);
const info = await modelImage.metadata();
if (info.width !== paddedWidth || info.height !== paddedHeight) {
throw new Error("扩图服务返回的图片尺寸不正确");
}
// Copy the source rectangle, including transparent pixels, without blending or resizing it.
const { data: original, info: sourceInfo } = await sharp(source).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const result = await modelImage
.extract({ left: cropLeft, top: cropTop, width: modelWidth, height: modelHeight })
.resize(targetWidth, targetHeight, { fit: "fill" })
.toColourspace("srgb").ensureAlpha().raw().toBuffer();
const stride = sourceInfo.width * 4;
for (let row = 0; row < sourceInfo.height; row++) {
original.copy(result, ((top + row) * targetWidth + left) * 4, row * stride, (row + 1) * stride);
}
return sharp(result, { raw: { width: targetWidth, height: targetHeight, channels: 4 } }).png().toBuffer();
}
export async function expandWithFalOutpaint({ imageUrl, apiKey, xScale, yScale }: {
imageUrl: string;
apiKey: string;
xScale: number;
yScale: number;
}) {
const response = await fetch(imageUrl, { signal: AbortSignal.timeout(45_000) });
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const geometry = getOutpaintGeometry(info.width, info.height, xScale, yScale);
if (geometry.targetWidth * geometry.targetHeight > 64_000_000) {
throw new Error("扩图后的尺寸过大,请降低扩展倍数");
}
const fal = createFalClient({ credentials: apiKey });
const modelSource = geometry.sourceWidth === info.width && geometry.sourceHeight === info.height
? source
: await sharp(source).resize(geometry.sourceWidth, geometry.sourceHeight, { fit: "fill" }).png().toBuffer();
const image = await fal.storage.upload(new Blob([new Uint8Array(modelSource)], { type: "image/png" }));
const { data } = await fal.subscribe(FAL_OUTPAINT_MODEL, {
input: {
image_url: image,
expand_left: geometry.expandLeft,
expand_right: geometry.expandRight,
expand_top: geometry.expandTop,
expand_bottom: geometry.expandBottom,
mode: "high",
output_format: "png",
},
timeout: 90_000,
});
const outputUrl = (data as { images?: Array<{ url?: string }> }).images?.[0]?.url;
if (!outputUrl) throw new Error("扩图服务未返回图片");
const output = await fetch(outputUrl, { signal: AbortSignal.timeout(25_000) });
if (!output.ok) throw new Error("无法读取扩图结果");
return composeOutpaint(source, Buffer.from(await output.arrayBuffer()), geometry);
}

View File

@ -5,21 +5,26 @@ import {
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { resolveAlibabaExtendApiKey } from "@/lib/ai/extend/config";
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint";
import { executeConfiguredVip98kTask, getVip98kRuntimeProvider } from "@/lib/ai/vip98k-runtime";
import { expandWithVip98kOutpaint, VIP98K_OUTPAINT_MODEL } from "@/lib/ai/extend/vip98k-outpaint";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service";
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
import { randomUUID } from "node:crypto";
type ExtendConfig = {
provider?: "vip98k" | "dashscope";
provider?: ExtendProvider;
apiKey?: string;
xScale?: number;
yScale?: number;
modelId?: string;
routeOptionId?: string;
};
const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
const VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const DASHSCOPE_MIN_IMAGE_EDGE = 512;
const DASHSCOPE_MAX_IMAGE_EDGE = 4096;
const DASHSCOPE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
@ -83,12 +88,12 @@ async function createThumbnail(buffer: Buffer): Promise<Buffer> {
}
}
async function uploadBufferPairToS3(buffer: Buffer): Promise<{ thumbnail: string; original: string }> {
async function uploadBufferPairToS3(buffer: Buffer, originalExtension = "jpg"): Promise<{ thumbnail: string; original: string }> {
try {
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.jpg`;
const originalFilename = `extend-orig-${timestamp}-${randomStr}.jpg`;
const originalFilename = `extend-orig-${timestamp}-${randomStr}.${originalExtension}`;
const thumbnailBuffer = await createThumbnail(buffer);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
@ -142,7 +147,7 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
signal: AbortSignal.timeout(SOURCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new ExtendServiceError(
@ -237,97 +242,6 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
return uploaded.url;
}
function isTemporaryVip98kExpansionError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return /HTTP\s+(?:429|5\d\d)|temporar(?:y|ily)|timeout|timed out|unavailable/i.test(
message,
);
}
async function createVip98kExpansionCanvas(
imageUrl: string,
xScale: number,
yScale: number,
) {
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new ExtendServiceError(
"下载待拓展图片失败",
502,
`${response.status} ${response.statusText}`,
);
}
const sourceBuffer = Buffer.from(await response.arrayBuffer());
const sharp = await import("sharp");
const image = sharp.default(sourceBuffer, { failOn: "none" });
const metadata = await image.metadata();
const width = Number(metadata.width || 0);
const height = Number(metadata.height || 0);
if (!width || !height) {
throw new ExtendServiceError("无法识别待拓展图片尺寸", 422);
}
const targetWidth = Math.max(width, Math.round(width * xScale));
const targetHeight = Math.max(height, Math.round(height * yScale));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const canvas = await image
.ensureAlpha()
.extend({
left,
right: targetWidth - width - left,
top,
bottom: targetHeight - height - top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
const arrayBuffer = canvas.buffer.slice(
canvas.byteOffset,
canvas.byteOffset + canvas.byteLength,
) as ArrayBuffer;
const uploaded = await uploadFile(
`extend-vip98k-canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`,
arrayBuffer,
);
return {
imageUrl: uploaded.url,
width,
height,
targetWidth,
targetHeight,
};
}
async function extendImageWithVip98kEditFallback(
imageUrl: string,
xScale: number,
yScale: number,
) {
const canvas = await createVip98kExpansionCanvas(imageUrl, xScale, yScale);
const result = await executeConfiguredVip98kTask({
modelId: VIP98K_EXPANSION_FALLBACK_MODEL,
kind: "image",
prompt: [
"Outpaint only the transparent regions of this image.",
"Keep every original pixel, subject, scale, position, perspective, lighting and style unchanged.",
`Complete the ${canvas.targetWidth}x${canvas.targetHeight} canvas naturally from the centered ${canvas.width}x${canvas.height} source.`,
"Do not crop, zoom, move, replace or redraw the original content.",
].join(" "),
imageUrls: [canvas.imageUrl],
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 生成式扩图未返回图片", 502);
}
return outputUrl;
}
async function extendImage(
imageUrl: string,
config: ExtendConfig,
@ -336,27 +250,17 @@ async function extendImage(
requestId?: string | null,
): Promise<string> {
if (config.provider === "vip98k") {
try {
const result = await executeConfiguredVip98kTask({
modelId: "image-expansion",
kind: "image-tool",
prompt: `Expand the image canvas naturally to ${xScale}x width and ${yScale}x height. Preserve the original subject, perspective, lighting and style.`,
imageUrls: [imageUrl],
imageParams: { xScale, yScale },
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
}
return outputUrl;
} catch (error) {
if (!isTemporaryVip98kExpansionError(error)) throw error;
console.warn(
"[Extend Service] VIP98K expansion tool is temporarily unavailable; using VIP98K generative outpainting fallback",
error instanceof Error ? error.message : String(error),
);
return extendImageWithVip98kEditFallback(imageUrl, xScale, yScale);
const result = await executeConfiguredVip98kTask({
modelId: "image-expansion",
kind: "image-tool",
imageUrls: [imageUrl],
imageParams: { xScale, yScale },
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
}
return outputUrl;
}
if (!config.apiKey) {
@ -473,6 +377,15 @@ async function getValidatedExtendConfig(): Promise<ExtendConfig> {
if (config.provider === "vip98k") {
return config;
}
if (config.provider === "fal") {
const [imageProviders, videoProviders] = await Promise.all([
getAiProvidersByType({ type: "fal-image", enabledOnly: true }),
getAiProvidersByType({ type: "fal-video", enabledOnly: true }),
]);
const apiKey = resolveFalExtendApiKey([...imageProviders, ...videoProviders]);
if (!apiKey) throw new ExtendServiceError("扩图服务未配置可用的 fal 密钥", 503);
return { ...config, apiKey };
}
const alibabaProviders = await getAiProvidersByType({
type: "alibaba-image",
@ -522,36 +435,48 @@ export async function executeExtendJob(
throw new ExtendServiceError("无法获取输入图片URL", 400);
}
const preparedInputImageUrl =
config.provider === "vip98k"
? inputImageUrl
: await normalizeDashscopeExpansionInput(inputImageUrl);
const startTime = Date.now();
const extendedImageUrl = await extendImage(
preparedInputImageUrl,
config,
xScale,
yScale,
requestId,
);
const imageResponse = await fetch(extendedImageUrl);
if (!imageResponse.ok) {
throw new ExtendServiceError(
"下载拓展图片失败",
500,
`${imageResponse.status} ${imageResponse.statusText}`,
);
let buffer!: Buffer;
let uploadResult: { thumbnail: string; original: string } | undefined;
let archivedBytes: number | undefined;
let downloadAsset: Awaited<ReturnType<typeof archiveTemporaryImage>>["downloadAsset"];
let modelId: string;
if (config.provider === "fal") {
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
modelId = FAL_OUTPAINT_MODEL;
} else if (config.provider === "vip98k" && config.modelId === VIP98K_OUTPAINT_MODEL) {
if (!config.routeOptionId) throw new ExtendServiceError("98K 扩图线路未配置", 503);
const provider = await getVip98kRuntimeProvider();
buffer = await expandWithVip98kOutpaint({
imageUrl: inputImageUrl, baseUrl: provider.baseUrl, apiKey: provider.apiKey,
routeOptionId: config.routeOptionId, xScale, yScale,
});
modelId = VIP98K_OUTPAINT_MODEL;
} else {
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
const url = await extendImage(prepared, config, xScale, yScale, requestId);
if (config.provider === "vip98k" && !includeBase64) {
const archived = await archiveTemporaryImage(url, requestId || randomUUID());
uploadResult = { original: archived.url, thumbnail: archived.thumbnailUrl };
archivedBytes = archived.bytes;
downloadAsset = archived.downloadAsset;
} else {
const imageResponse = await fetch(url);
if (!imageResponse.ok) {
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`);
}
buffer = Buffer.from(await imageResponse.arrayBuffer());
}
modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0";
}
const buffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
const uploadResult = await uploadBufferPairToS3(buffer);
uploadResult ??= await uploadBufferPairToS3(buffer, config.provider === "fal" || config.provider === "vip98k" ? "png" : "jpg");
console.log("[Extend Service] Result uploaded:", {
requestId,
includeBase64,
inputImageUrl: inputImageUrl.slice(0, 120),
extendedImageUrl: extendedImageUrl.slice(0, 120),
outputBytes: buffer.length,
modelId,
outputBytes: archivedBytes ?? buffer.length,
});
let mediaAssetId: string | null = null;
let generatedImageId: string | null = null;
@ -567,7 +492,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail,
originalUrl: uploadResult.original,
prompt,
modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0",
modelId,
imageSize: "2K",
})
.returning();
@ -591,7 +516,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail,
displayName: prompt.slice(0, 80),
prompt,
modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0",
modelId,
sourceMessageId: messageId,
sourceTable: "GeneratedImage",
sourceRowId: generatedImageId,
@ -600,6 +525,7 @@ export async function executeExtendJob(
xScale,
yScale,
imageSize: "2K",
...(downloadAsset ? { downloadAsset } : {}),
},
});
mediaAssetId = media?.id || null;

View File

@ -0,0 +1,85 @@
import sharp from "sharp";
export const VIP98K_OUTPAINT_MODEL = "gpt-image-2.5-flare";
const RATIOS = [[1, 1], [4, 3], [3, 4], [3, 2], [2, 3], [16, 9], [9, 16]];
export function getVip98kOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
if (targetWidth * targetHeight > 64_000_000) throw new Error("扩图后的尺寸过大,请降低扩展倍数");
const ratio = targetWidth / targetHeight;
const [a, b] = RATIOS.reduce((best, next) =>
Math.abs(Math.log(ratio / (next[0] / next[1]))) < Math.abs(Math.log(ratio / (best[0] / best[1]))) ? next : best);
const paddedWidth = Math.max(targetWidth, Math.ceil(targetHeight * a / b));
const paddedHeight = Math.max(targetHeight, Math.ceil(targetWidth * b / a));
const factor = Math.min(1, 2048 / Math.max(paddedWidth, paddedHeight));
const modelWidth = Math.max(1, Math.round(paddedWidth * factor));
const modelHeight = Math.max(1, Math.round(paddedHeight * factor));
const sourceWidth = Math.max(1, Math.round(width * factor));
const sourceHeight = Math.max(1, Math.round(height * factor));
return {
targetWidth, targetHeight, modelWidth, modelHeight, sourceWidth, sourceHeight,
left: Math.floor((modelWidth - sourceWidth) / 2),
top: Math.floor((modelHeight - sourceHeight) / 2),
aspectRatio: `${a}:${b}`,
resolution: Math.max(targetWidth, targetHeight) > 1536 ? "2K" : "1K",
};
}
export async function expandWithVip98kOutpaint(params: {
imageUrl: string;
baseUrl: string;
apiKey: string;
routeOptionId: string;
xScale: number;
yScale: number;
}) {
const response = await fetch(params.imageUrl, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const g = getVip98kOutpaintGeometry(info.width, info.height, params.xScale, params.yScale);
const input = await sharp(source).resize(g.sourceWidth, g.sourceHeight).ensureAlpha().extend({
left: g.left, right: g.modelWidth - g.sourceWidth - g.left,
top: g.top, bottom: g.modelHeight - g.sourceHeight - g.top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
}).png().toBuffer();
const body = new FormData();
const prompt = [
"Extend this product image outward on all four sides, continuing its background naturally.",
`The reference canvas is ${g.modelWidth}x${g.modelHeight}; the original image is centered at (${g.left},${g.top}) with size ${g.sourceWidth}x${g.sourceHeight}.`,
"Fill only the surrounding empty area as a continuation of the same scene.",
"Preserve the existing product identity, structure, colors, and all Chinese text and dimension numbers.",
"Keep approximately the same composition and the product centered.",
"Avoid adding new products or additional text. Return a complete seamless image.",
].join(" ");
for (const [key, value] of Object.entries({
model: VIP98K_OUTPAINT_MODEL, prompt, n: "1", size: g.aspectRatio,
resolution: g.resolution, quality: "high", response_format: "url",
})) body.append(key, value);
body.append("image[]", new Blob([new Uint8Array(input)], { type: "image/png" }), "outpaint.png");
const base = params.baseUrl.replace(/\/$/, "").replace(/\/v1$/, "");
const generated = await fetch(`${base}/v1/images/edits`, {
method: "POST", body,
headers: { Authorization: `Bearer ${params.apiKey}`, "X-Relay-Route-Option": params.routeOptionId },
signal: AbortSignal.timeout(180_000),
});
const result = await generated.json();
if (!generated.ok) throw new Error(`98K 扩图失败:${result?.error?.message || `HTTP ${generated.status}`}`);
const image = result?.data?.[0];
let expanded: Buffer;
if (typeof image?.b64_json === "string") {
expanded = Buffer.from(image.b64_json, "base64");
} else if (typeof image?.url === "string") {
const outputUrl = new URL(image.url);
const headers = new Headers();
if (outputUrl.origin === new URL(base).origin) headers.set("Authorization", `Bearer ${params.apiKey}`);
const output = await fetch(outputUrl, { headers, signal: AbortSignal.timeout(60_000) });
if (!output.ok) throw new Error(`无法读取扩图结果(HTTP ${output.status})`);
expanded = Buffer.from(await output.arrayBuffer());
} else {
throw new Error("98K 扩图未返回图片");
}
return sharp(expanded).resize(g.targetWidth, g.targetHeight, { fit: "cover", position: "centre" }).png().toBuffer();
}

View File

@ -5,15 +5,17 @@
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
* - qwen-image-edit:仅支持 1 张输出
*
* API 文档:https://help.aliyun.com/zh/model-studio/developer-reference/qwen-vl-plus
* API 文档:https://help.aliyun.com/en/model-studio/qwen-image-generation-and-editing-api-reference
*/
import sharp from "sharp";
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification";
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
import { ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
import {
isAlibabaQwenImage3ModelId,
resolveAlibabaImageAsyncEndpoint,
@ -46,6 +48,8 @@ interface AlibabaImageResponse {
output?: {
task_id?: string;
task_status?: string;
code?: string;
message?: string;
choices?: Array<{
finish_reason: string;
message: {
@ -122,11 +126,21 @@ async function fetchImageBufferWithRetry(
timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
attempts = 2
): Promise<{ buffer: Buffer; contentType: string | null }> {
if (imageUrl.startsWith("data:image/")) {
const match = imageUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i);
if (!match) throw new Error("输入图片数据格式无效");
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
}
let sourceUrl = imageUrl;
if (normalizeLegacyAliyunStorageUrl(sourceUrl)) {
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
sourceUrl = await resolveLegacyAliyunStorageUrl(sourceUrl);
}
let lastError: unknown = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const ctx = createAbortSignal(timeoutMs);
try {
const response = await fetch(imageUrl, {
const response = await fetch(sourceUrl, {
cache: "no-store",
redirect: "follow",
signal: ctx.signal,
@ -152,7 +166,7 @@ async function fetchImageBufferWithRetry(
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError || "下载图片失败"));
throw lastError ?? new Error("下载图片失败");
}
function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string {
@ -168,7 +182,7 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st
}
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
if (!/^https?:\/\//i.test(imageUrl)) return null;
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
try {
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
@ -186,7 +200,7 @@ async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number |
async function inferImageDimensionsFromImageUrl(
imageUrl: string,
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
if (!/^https?:\/\//i.test(imageUrl)) return null;
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
try {
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
@ -276,12 +290,20 @@ async function prepareAlibabaInputImageUrl(
imageUrl: string,
modelId: string,
inputRole?: AlibabaImageInputRole,
original?: Buffer,
): Promise<string> {
const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
const maxInputBytes = getAlibabaMaxInputBytes(modelId);
const { buffer, contentType } = await fetchImageBufferWithRetry(normalizedUrl);
const { buffer, contentType } = original
? { buffer: original, contentType: null }
: await fetchImageBufferWithRetry(normalizedUrl).catch((error) => {
if (["AbortError", "TimeoutError"].includes(error?.name)) {
throw new ImageReferenceReadTimeoutError();
}
throw error;
});
let metadata: sharp.Metadata;
try {
metadata = await sharp(buffer, { failOn: "none" }).metadata();
@ -302,6 +324,14 @@ async function prepareAlibabaInputImageUrl(
maxSide: ALIBABA_MAX_INPUT_SIDE,
modelId,
});
if (isAlibabaQwenImage3ModelId(modelId)) {
if (prepared.buffer.length > maxInputBytes) {
throw new Error("输入图片超过模型大小限制,请先压缩图片后重试");
}
const format = prepared.transformed ? prepared.extension : metadata.format;
const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
return `data:${mime};base64,${prepared.buffer.toString("base64")}`;
}
if (!prepared.transformed) {
return normalizedUrl;
}
@ -540,7 +570,7 @@ async function sendAlibabaImageAsyncRequest(params: {
url: params.requestURL,
});
const taskId = String(submitted.output?.task_id || "").trim();
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id");
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认");
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
const startedAt = Date.now();
@ -564,9 +594,11 @@ async function sendAlibabaImageAsyncRequest(params: {
console.log("[Alibaba Image] Async task completed:", taskId);
return task;
}
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
if (["FAILED", "CANCELED"].includes(taskStatus)) {
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
const code = task.output?.code || task.code;
throw new AlibabaImageTerminalTaskError(
task.message || `阿里图片任务${taskStatus || "失败"}(task_id: ${taskId})`,
`${reason}${code ? `(${code})` : ""}(task_id: ${taskId})`,
);
}
lastPollError = null;
@ -578,7 +610,7 @@ async function sendAlibabaImageAsyncRequest(params: {
error.status < 500 &&
![408, 425, 429].includes(error.status)
) {
throw error;
throw new Error(`阿里图片任务已提交,结果尚未确认(task_id: ${taskId})`, { cause: error });
}
lastPollError = error;
console.warn("[Alibaba Image] Async task poll failed; retrying:", {
@ -700,6 +732,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
doGenerate: async (options: {
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>;
imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number };
referenceImageBuffers?: ReadonlyMap<string, Buffer>;
modelDisplayName?: string; // 🆕 模型显示名称,用于错误提示
overrideModelId?: string; // 🆕 备用模型 ID
}) => {
@ -769,16 +802,16 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
}
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
const preparedImageUrls: string[] = [];
for (const imageUrl of selectedImageUrls) {
preparedImageUrls.push(
await prepareAlibabaInputImageUrl(
const preparedImageUrls = await Promise.all(
selectedImageUrls.map((imageUrl) =>
prepareAlibabaInputImageUrl(
imageUrl,
activeModelId,
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
options.referenceImageBuffers?.get(imageUrl),
),
);
}
),
);
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
if (
@ -916,7 +949,9 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
);
// 处理响应
const images = await processResponse(response);
const images = await processResponse(response).catch((error) => {
throw new Error("阿里图片任务已提交,结果尚未确认。", { cause: error });
});
const markdownText = formatImageResponse(images, params.size, imageSize);
console.log(`[Alibaba Image] Generated ${images.length} images successfully`);

View File

@ -48,6 +48,7 @@ import {
VIP98K_REFERENCE_STAGING_KEY_PREFIX,
} from "@/lib/ai/vip98k-reference-staging";
import { createLogger } from "@/lib/observability/logger";
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import {
IMAGE_UPLOAD_MAX_BYTES,
@ -98,6 +99,7 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 8;
const DEFAULT_MAX_POLL_SECONDS = 600;
const REFERENCE_DOWNLOAD_ATTEMPTS = 3;
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const REFERENCE_CDN_TIMEOUT_MS = 10_000;
const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000;
const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES;
const REFERENCE_STAGING_CACHE_MS = 60_000;
@ -681,43 +683,44 @@ async function findStagedReference(
}
}
async function fetchReferenceImage(mediaUrl: string, preferCdn = false) {
const response = await fetch(mediaUrl, {
cache: preferCdn ? "default" : "no-store",
redirect: preferCdn ? "error" : "follow",
headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" },
signal: AbortSignal.timeout(preferCdn ? REFERENCE_CDN_TIMEOUT_MS : REFERENCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok || (preferCdn && response.status !== 200)) {
await response.body?.cancel();
throw new Error(`source returned HTTP ${response.status}`);
}
const mimeType = response.headers.get("content-type") || "image/png";
if (preferCdn && !/^image\//i.test(mimeType) && !/^application\/octet-stream\b/i.test(mimeType)) {
await response.body?.cancel();
throw new Error("CDN response was not an image");
}
const declaredLength = Number(response.headers.get("content-length"));
if (declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
await response.body?.cancel();
throw new Vip98kReferenceTooLargeError();
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
throw new Vip98kReferenceTooLargeError();
}
if ((declaredLength > 0 && buffer.byteLength !== declaredLength) || (preferCdn && !buffer.byteLength)) {
throw new Error("source image download was incomplete");
}
return { buffer, mimeType };
}
async function downloadReferenceImage(mediaUrl: string) {
let lastError: unknown;
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(mediaUrl, {
cache: "no-store",
redirect: "follow",
headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" },
signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`source returned HTTP ${response.status}`);
}
const declaredLength = Number(response.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES
) {
throw new Vip98kReferenceTooLargeError();
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
throw new Vip98kReferenceTooLargeError();
}
if (
Number.isFinite(declaredLength) &&
declaredLength > 0 &&
buffer.byteLength !== declaredLength
) {
throw new Error("source image download was incomplete");
}
return {
buffer,
mimeType: response.headers.get("content-type") || "image/png",
};
return await fetchReferenceImage(mediaUrl);
} catch (error) {
lastError = error;
if (error instanceof Vip98kReferenceTooLargeError) throw error;
@ -737,6 +740,22 @@ async function downloadReferenceImage(mediaUrl: string) {
);
}
async function downloadReferenceImageFromCdn(mediaUrl: string, stagingConfig: ReferenceStagingConfig) {
if (stagingConfig.storageConfig.provider !== "qiniu-kodo") return null;
const cdnUrl = process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "";
if (!URL.canParse(cdnUrl) || !getStorageObjectKeyFromPublicUrl(mediaUrl, stagingConfig.sourcePublicUrl)) return null;
const source = new URL(mediaUrl);
if (source.origin !== new URL(cdnUrl).origin || source.search || source.username || source.password) return null;
try {
return await fetchReferenceImage(mediaUrl, true);
} catch (error) {
if (error instanceof Vip98kReferenceTooLargeError) throw error;
logger.warn("reference CDN download failed; trying source storage", { sourceHost: source.hostname });
return null;
}
}
async function downloadReferenceImageFromStorage(
mediaUrl: string,
stagingConfig: ReferenceStagingConfig,
@ -851,9 +870,10 @@ async function stageReferenceImage(
}
const downloadStartedAt = Date.now();
const cdn = await downloadReferenceImageFromCdn(mediaUrl, stagingConfig);
const storage = cdn ? null : await downloadReferenceImageFromStorage(mediaUrl, stagingConfig);
const downloaded =
(await downloadReferenceImageFromStorage(mediaUrl, stagingConfig)) ||
(await downloadReferenceImage(mediaUrl));
cdn || storage || (await downloadReferenceImage(mediaUrl));
const downloadMs = Date.now() - downloadStartedAt;
const normalizeStartedAt = Date.now();
const normalized = await normalizeReferenceImage(
@ -871,6 +891,7 @@ async function stageReferenceImage(
cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig);
logger.info("reference image staged", {
sourceHost: getUrlHost(mediaUrl),
sourceDelivery: cdn ? "cdn" : storage ? "storage" : "public-url",
targetHost: getUrlHost(uploadedUrl),
sourceBytes: downloaded.buffer.byteLength,
stagedBytes: normalized.buffer.byteLength,
@ -1023,6 +1044,77 @@ function getConfiguredPollSeconds(config: Vip98kModelRuntimeConfig, key: string,
return Math.min(Math.max(value, 1), 3600);
}
function isSynchronousImagePath(path: string) {
return /^\/v1\/images\/(?:generations|edits)\/?$/.test(path);
}
async function recoverImageSubmission(params: {
apiKey: string;
baseURL: string;
modelId: string;
path: string;
idempotencyKey: string;
pollIntervalMs: number;
maxPollMs: number;
}) {
if (!isSynchronousImagePath(params.path)) return null;
const startedAt = Date.now();
const deadline = startedAt + params.maxPollMs;
logger.info("recovering original image request", { modelId: params.modelId });
while (Date.now() < deadline) {
try {
const lookup = await fetch(`${params.baseURL}/api/external/requests/by-idempotency-key`, {
cache: "no-store",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Idempotency-Key": params.idempotencyKey,
},
signal: AbortSignal.timeout(Math.min(60_000, deadline - Date.now())),
});
if ([401, 403, 404, 410].includes(lookup.status)) return null;
if (lookup.ok) {
const state = toRecord(await parseJsonResponse(lookup));
if (!state || !["processing", "succeeded"].includes(String(state.status))) return null;
if (state.status === "succeeded") {
if (state.result_available !== true || !state.request_id) return null;
const result = await fetch(
`${params.baseURL}/api/external/requests/${encodeURIComponent(String(state.request_id))}/result`,
{
cache: "no-store",
headers: { Authorization: `Bearer ${params.apiKey}` },
signal: AbortSignal.timeout(Math.max(1, Math.min(60_000, deadline - Date.now()))),
},
);
if ([401, 403, 404, 410].includes(result.status)) return null;
if (result.ok) {
const payload = await parseJsonResponse(result);
const outputs = collectOutputUrls(payload);
if (!outputs.length) {
throw new ProviderTaskUncertainError("原生图请求已完成,但未取回图片结果,请勿重复生成");
}
logger.info("original image request recovered", {
modelId: params.modelId,
providerRequestId: state.request_id,
outputCount: outputs.length,
durationMs: Date.now() - startedAt,
});
return { payload, taskId: extractTaskId(payload), resultPath: extractResultPath(payload), outputs };
}
}
}
} catch (error) {
if (error instanceof ProviderTaskUncertainError) throw error;
}
await delay(Math.max(0, Math.min(params.pollIntervalMs, deadline - Date.now())));
}
logger.warn("original image request recovery timed out", {
modelId: params.modelId,
durationMs: Date.now() - startedAt,
});
return null;
}
async function submitTask(params: {
apiKey: string;
baseURL?: string | null;
@ -1035,6 +1127,8 @@ async function submitTask(params: {
submissionTimeoutMs: number;
maxSubmissionAttempts: number;
submissionRetryBaseDelayMs: number;
pollIntervalMs: number;
maxPollMs: number;
}) {
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
const path = String(params.path || "/api/external/task/generate").trim();
@ -1098,7 +1192,9 @@ async function submitTask(params: {
signal: AbortSignal.timeout(params.submissionTimeoutMs),
});
} catch (error) {
if (attempt >= maxAttempts) {
const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
if (recovered) return recovered;
if (isSynchronousImagePath(path) || attempt >= maxAttempts) {
logger.error("provider submission transport failed", {
modelId: params.modelId,
path,
@ -1144,6 +1240,14 @@ async function submitTask(params: {
};
}
if ([502, 504, 524].includes(response.status)) {
const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
if (recovered) return recovered;
if (isSynchronousImagePath(path)) {
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: extractErrorMessage(payload), status: response.status })} (HTTP ${response.status})`);
}
}
const errorMessage = extractErrorMessage(payload);
const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({
status: response.status,
@ -1188,9 +1292,9 @@ async function submitTask(params: {
continue;
}
if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage))
|| [502, 504].includes(response.status)
|| [502, 504, 524].includes(response.status)
|| /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage))
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${errorMessage} (HTTP ${response.status})`);
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: errorMessage, status: response.status })} (HTTP ${response.status})`);
throw new Error(`${errorMessage} (HTTP ${response.status})`);
}
@ -1261,6 +1365,7 @@ export async function submitVip98kExternalTask(params: {
const configuredInvocations = getVip98kInvocations(taskParams.modelConfig);
const invocation = selectVip98kTaskInvocation({
modelId: taskParams.modelId,
kind: taskParams.kind,
configuredInvocation: configuredInvocations[0] || null,
configuredInvocations,
imageCount: imageUrls.length,
@ -1309,6 +1414,12 @@ export async function submitVip98kExternalTask(params: {
),
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs,
pollIntervalMs: getConfiguredPollSeconds(
taskParams.modelConfig, "pollIntervalSeconds", DEFAULT_POLL_INTERVAL_SECONDS,
) * 1000,
maxPollMs: getConfiguredPollSeconds(
taskParams.modelConfig, "maxPollSeconds", DEFAULT_MAX_POLL_SECONDS,
) * 1000,
});
const resultPath = submitted.resultPath || invocation?.resultPath || "";
return {
@ -1438,7 +1549,7 @@ function normalizeDuration(value: unknown) {
return Math.round(numeric);
}
function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
const videoParams = params.videoParams || {};
const imageParams = params.imageParams || {};
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
@ -1448,6 +1559,9 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
body.displayName = promptTitle || params.modelId;
}
if ("prompt" in body || !invocation) body.prompt = params.prompt;
if (params.kind === "image" && invocationSupportsBodyField(invocation, "n")) {
body.n = imageParams.imageCount ?? imageParams.n ?? 1;
}
const aspectRatio =
normalizeRatio(videoParams.ratio) ||
@ -1552,6 +1666,16 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
}
if (params.modelId.toLowerCase() === "image-expansion") {
bindVip98kExpansionParams(body, imageParams);
body.output_format = "png";
}
if (params.kind === "image-tool" && params.modelId.toLowerCase() === "ocr") {
// Old synced examples include generic image fields and both language aliases.
const metadata = toRecord(body.metadata);
const language = String(metadata?.language || body.language || "").trim();
body.metadata = { language: language || "English,ChinesePRC,Digits" };
body.output_format = "txt";
delete body.language;
delete body.size;
}
const imageBindingKeys = bindVip98kImages(body, params.imageUrls);
if (params.imageUrls.length > 0 && !invocation && imageBindingKeys.length === 0) {
@ -1697,7 +1821,7 @@ async function runTask(params: Vip98kTaskParams) {
}
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
const archiveStartedAt = Date.now();
const archived = await Promise.all(outputUrls.slice(0, 1).map(archiveOutputImage));
const archived = await Promise.all(outputUrls.map(archiveOutputImage));
logger.info("result archive completed", {
modelId: params.modelId,
outputCount: archived.length,

View File

@ -8,6 +8,7 @@ import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work";
import { uploadFile } from "@/lib/storage/service";
const logger = createLogger("rmbg-service");
@ -27,6 +28,7 @@ type RmbgConfig = {
};
export type ExecuteRmbgJobParams = {
requestId?: string;
userId: string;
imageBuffer: Buffer;
imageBase64?: string;
@ -35,6 +37,8 @@ export type ExecuteRmbgJobParams = {
chatId?: string | null;
messageId?: string | null;
skipGallerySave?: boolean;
// 仅当 imageBuffer 与 URL 对应的输入文件一致时提供。
inputImageUrl?: string | null;
sourceImageUrl?: string | null;
};
@ -53,7 +57,7 @@ export class RmbgServiceError extends Error {
detail?: string;
constructor(message: string, status = 500, detail?: string) {
super(message);
super(detail && detail !== message ? `${message}:${detail}` : message);
this.name = "RmbgServiceError";
this.status = status;
this.detail = detail;
@ -61,7 +65,11 @@ export class RmbgServiceError extends Error {
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// https://picwish.cn/background-removal-api-doc
const AOS_MAX_RESOLUTION = 4096;
const AOS_MAX_INPUT_BYTES = 50 * 1024 * 1024;
// The shared AOS state -5 documents a separate limit for image_url downloads.
const AOS_MAX_URL_INPUT_BYTES = 30 * 1024 * 1024;
const AOS_CREATE_TIMEOUT_MS = 120_000;
const AOS_POLL_TIMEOUT_MS = 60_000;
const AOS_DOWNLOAD_TIMEOUT_MS = 60_000;
@ -147,6 +155,18 @@ const withTimeout = async <T,>(
}
};
async function readAosResult<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await withTimeout(read, timeoutMs);
} catch (error) {
const name = error instanceof Error ? error.name : "";
if (attempt >= 2 || (name !== "TypeError" && name !== "AbortError")) throw error;
await sleep(1000 * (attempt + 1));
}
}
}
function getAosFriendlyErrorMessage(detail: string): string | null {
const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i);
if (resolutionMatch) {
@ -164,50 +184,42 @@ function getAosFriendlyErrorMessage(detail: string): string | null {
async function prepareImageForAos(
imageBuffer: Buffer
): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
if (imageBuffer.length > AOS_MAX_INPUT_BYTES) {
throw new RmbgServiceError(
`抠图参考图文件大小超限(${(imageBuffer.length / 1024 / 1024).toFixed(2)} MB),当前 AOS 上游最大支持 50 MB。`,
400
);
}
let metadata: import("sharp").Metadata;
try {
const sharp = await import("sharp");
const image = sharp.default(imageBuffer);
const metadata = await image.metadata();
const width = metadata.width;
const height = metadata.height;
const format = (metadata.format || "png").toLowerCase();
const ext = format === "jpeg" ? "jpg" : format;
const filenameExt =
ext === "jpg" || ext === "png" || ext === "webp" || ext === "tiff" ? ext : "png";
const mimeType =
ext === "jpg"
? "image/jpeg"
: ext === "webp"
? "image/webp"
: ext === "tiff"
? "image/tiff"
: "image/png";
if (!width || !height || (width <= AOS_MAX_RESOLUTION && height <= AOS_MAX_RESOLUTION)) {
return {
buffer: imageBuffer,
mimeType,
filename: `image.${filenameExt}`,
};
}
const resizedBuffer = await image
.resize(AOS_MAX_RESOLUTION, AOS_MAX_RESOLUTION, {
fit: "inside",
withoutEnlargement: true,
})
.toBuffer();
return {
buffer: resizedBuffer,
mimeType,
filename: `image.${filenameExt}`,
};
metadata = await sharp.default(imageBuffer, { limitInputPixels: false }).metadata();
} catch (error) {
logger.warn("aos_image_preprocess_failed", error);
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" };
// AOS also accepts BMP/RAW/RGB formats that Sharp cannot identify.
logger.warn("aos_image_metadata_unavailable", error);
return { buffer: imageBuffer, mimeType: "application/octet-stream", filename: "image" };
}
const { width, height } = metadata;
if (width && height && (width > AOS_MAX_RESOLUTION || height > AOS_MAX_RESOLUTION)) {
throw new RmbgServiceError(
`抠图参考图分辨率超限(${width}×${height} 像素),当前 AOS 上游要求宽、高均不超过 ${AOS_MAX_RESOLUTION} 像素。`,
400
);
}
const format = metadata.format || "";
if (["gif", "svg", "heif", "avif", "jp2", "jxl"].includes(format)) {
const name = format === "heif" ? "HEIF/AVIF" : format.toUpperCase();
throw new RmbgServiceError(`抠图参考图格式不支持(${name}),当前 AOS 上游不支持此格式。`, 400);
}
const ext = format === "jpeg" ? "jpg" : format;
return {
buffer: imageBuffer,
mimeType: format ? `image/${format}` : "application/octet-stream",
filename: ext ? `image.${ext}` : "image",
};
}
async function processWithRMBG(
@ -369,20 +381,38 @@ async function processWithAosSegmentation(
foregroundType?: "" | "person" | "object" | "stamp";
crop?: 0 | 1;
format?: "png" | "jpg";
inputImageUrl?: string | null;
requestId?: string;
}
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
const startTime = Date.now();
const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, "");
const createUrl = `${baseUrl}/api/tasks/visual/segmentation`;
const headers = { "X-API-KEY": config.apiKey };
let stage = "create";
let providerTaskId: string | undefined;
const sourceImage = await prepareImageForAos(imageBuffer);
try {
const normalizedImage = await prepareImageForAos(imageBuffer);
const createForm = new FormData();
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], {
type: normalizedImage.mimeType,
});
createForm.append("image_file", imageBlob, normalizedImage.filename);
let publicSourceUrl: string | null = null;
if (config.inputImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) {
try {
const source = new URL(config.inputImageUrl);
const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) {
publicSourceUrl = source.toString();
}
} catch { /* Uploaded files and other sources use the file API. */ }
}
if (publicSourceUrl) {
createForm.append("image_url", publicSourceUrl);
} else {
const imageBlob = new Blob([new Uint8Array(sourceImage.buffer)], {
type: sourceImage.mimeType,
});
createForm.append("image_file", imageBlob, sourceImage.filename);
}
createForm.append("sync", "0");
createForm.append("return_type", "1");
createForm.append("output_type", "2");
@ -392,6 +422,9 @@ async function processWithAosSegmentation(
createForm.append("type", config.foregroundType);
}
const createStartedAt = Date.now();
logger.info("aos_task_create_started", { requestId: config.requestId, sourceMode: publicSourceUrl ? "url" : "file", inputBytes: imageBuffer.length, uploadBytes: publicSourceUrl ? 0 : sourceImage.buffer.length });
const createResponse = await withTimeout(
(signal) =>
fetch(createUrl, {
@ -411,28 +444,37 @@ async function processWithAosSegmentation(
});
return {
success: false,
error: `AOS任务创建失败: ${createPayload?.message || createResponse.statusText}`,
error: `AOS任务创建失败(HTTP ${createResponse.status}): ${createPayload?.message || createResponse.statusText}`,
};
}
const taskId = createPayload.data.task_id as string;
providerTaskId = taskId;
logger.info("aos_task_created", { requestId: config.requestId, taskId, createMs: Date.now() - createStartedAt });
const taskUrl = `${createUrl}/${taskId}`;
const maxPollCount = 60;
for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) {
await sleep(1000);
stage = "poll";
const pollResponse = await withTimeout(
(signal) =>
fetch(taskUrl, {
const pollResponse = await readAosResult(
async (signal) => {
const response = await fetch(taskUrl, {
method: "GET",
headers,
signal,
}),
});
const payload = await response.json().catch((error) => {
if (error instanceof Error && error.name !== "SyntaxError") throw error;
return null;
});
return { ok: response.ok, status: response.status, statusText: response.statusText, payload };
},
AOS_POLL_TIMEOUT_MS
);
const pollPayload = await pollResponse.json().catch(() => null);
const pollPayload = pollResponse.payload;
if (!pollResponse.ok || !pollPayload?.data) {
logger.warn("aos_task_poll_failed", {
hasPayload: pollPayload != null,
@ -440,7 +482,7 @@ async function processWithAosSegmentation(
});
return {
success: false,
error: `AOS任务查询失败: ${pollPayload?.message || pollResponse.statusText}`,
error: `AOS任务查询失败(HTTP ${pollResponse.status},任务 ${taskId}): ${pollPayload?.message || pollResponse.statusText}`,
};
}
@ -454,12 +496,15 @@ async function processWithAosSegmentation(
pollCount,
taskId,
});
const imageResponse = await withTimeout(
(signal) =>
fetch(imageUrl, {
stage = "download";
const imageResponse = await readAosResult(
async (signal) => {
const response = await fetch(imageUrl, {
method: "GET",
signal,
}),
});
return { ok: response.ok, status: response.status, statusText: response.statusText, buffer: response.ok ? Buffer.from(await response.arrayBuffer()) : null };
},
AOS_DOWNLOAD_TIMEOUT_MS
);
@ -470,11 +515,11 @@ async function processWithAosSegmentation(
});
return {
success: false,
error: `AOS结果下载失败: ${imageResponse.status} ${imageResponse.statusText}`,
error: `AOS结果下载失败(HTTP ${imageResponse.status},任务 ${taskId}): ${imageResponse.statusText}`,
};
}
const resultBuffer = Buffer.from(await imageResponse.arrayBuffer());
const resultBuffer = imageResponse.buffer!;
return {
success: true,
imageBuffer: resultBuffer,
@ -490,19 +535,30 @@ async function processWithAosSegmentation(
});
return {
success: false,
error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`,
error: `AOS任务失败(状态 ${state},任务 ${taskId}): ${errMessage || stateDetail || pollPayload?.message || "未提供详情"}`,
};
}
}
logger.warn("aos_task_timeout", { maxPollCount });
return { success: false, error: "AOS任务超时(最多60秒)" };
logger.warn("aos_task_timeout", { requestId: config.requestId, taskId, maxPollCount });
return { success: false, error: `AOS查询任务超时,结果尚未确认(任务 ${taskId})` };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return { success: false, error: "AOS请求超时,请稍后重试" };
logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length });
const message = stage === "create"
? "AOS创建任务超时(120秒),结果尚未确认"
: stage === "poll"
? `AOS查询任务超时,结果尚未确认(任务 ${providerTaskId})`
: `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`;
return { success: false, error: message };
}
logger.warn("aos_provider_request_error", error);
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
logger.warn("aos_provider_request_error", error, { requestId: config.requestId, stage, taskId: providerTaskId });
const detail = error instanceof Error ? error.message : "未知错误";
return { success: false, error: stage === "create"
? `AOS创建任务连接失败,结果尚未确认:${detail}`
: stage === "poll"
? `AOS查询任务连接失败,结果尚未确认(任务 ${providerTaskId}):${detail}`
: `AOS结果下载连接失败,任务已完成(任务 ${providerTaskId}):${detail}` };
}
}
@ -599,8 +655,15 @@ export async function fetchImageBufferFromUrl(imageUrl: string): Promise<Buffer>
export async function executeRmbgJob(
params: ExecuteRmbgJobParams
): Promise<ExecuteRmbgJobResult> {
return trackRuntimeWork(() => runRmbgJob(params));
}
async function runRmbgJob(
params: ExecuteRmbgJobParams
): Promise<ExecuteRmbgJobResult> {
const {
requestId = crypto.randomUUID(),
userId,
imageBuffer,
imageBase64 = imageBuffer.toString("base64"),
@ -609,11 +672,13 @@ export async function executeRmbgJob(
chatId = null,
messageId = null,
skipGallerySave = false,
inputImageUrl = null,
sourceImageUrl = null,
} = params;
const { provider, config } = await getValidatedRmbgConfig();
logger.info("job_started", {
requestId,
hasChatId: Boolean(chatId),
hasMessageId: Boolean(messageId),
hasSourceImageUrl: Boolean(sourceImageUrl),
@ -657,6 +722,8 @@ export async function executeRmbgJob(
foregroundType: foregroundType ?? config.aosForegroundType ?? "",
crop: config.aosCrop === 1 ? 1 : 0,
format: config.aosFormat === "jpg" ? "jpg" : "png",
inputImageUrl,
requestId,
});
if (!result.success || !result.imageBuffer) {
@ -793,6 +860,7 @@ export async function executeRmbgJob(
}
logger.info("job_completed", {
requestId,
generatedImageId: savedImage.id,
mediaAssetId,
processingSeconds: Number(processingTime.toFixed(2)),
@ -815,6 +883,7 @@ export async function executeRmbgJob(
}
logger.info("job_completed", {
requestId,
processingSeconds: Number(processingTime.toFixed(2)),
provider,
savedToGallery: false,

View File

@ -3,6 +3,7 @@ import { agentModelFault, type AgentModelFault } from "./agent-model-failover";
export function agentImageFailurePolicy(error: unknown): { retry: boolean; pause: AgentModelFault | null } {
const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null;
const message = String(value?.message || "").toLowerCase();
if (value?.providerOutcomeUnknown || ["AbortError", "TimeoutError"].includes(String((error as { name?: string })?.name || ""))) return { retry: false, pause: null };
// A submitted image job may still succeed. Retain its task and reconcile it;
// never submit another paid job just because a poll/connection timed out.
const unknown = value?.providerOutcomeUnknown || /request_outcome_unknown|may still be running|outcome.unknown|timed out|timeout|超时|fetch failed|econnreset|socket hang up/.test(message);

View File

@ -516,6 +516,7 @@ export async function executeDirectAgentImageEditTool(
const result = await executeRmbgJob({
userId: params.userId,
imageBuffer: sourceBuffer,
inputImageUrl: params.sourceImageUrl,
prompt: params.requestTextContent || "抠图处理",
chatId: params.chatId,
messageId: params.messageId,

Some files were not shown because too many files have changed in this diff Show More