Compare commits

...

95 Commits

Author SHA1 Message Date
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
Codex
d9b06cf0b3 Keep streamed result file links intact 2026-11-02 19:10:16 +08:00
086c58fcc3 Merge pull request '修复生成结果加载时图片链接闪现异常' (#6) from codex/sku-stream-atomic into main 2026-11-02 18:37:28 +08:00
Codex
5a343b6392 Avoid partial image markup during streamed responses 2026-11-02 18:36:50 +08:00
e6f07ed2ce Merge pull request '修复原图下载及 SKU 结果展示' (#5) from codex/sku-original-image-display into main 2026-11-02 17:47:03 +08:00
Codex
db46983725 Fix original image downloads and SKU message rendering 2026-11-02 17:46:31 +08:00
ae4605d479 Merge pull request 'fix(deploy): 切换时重建单个 PM2 应用定义并保留回滚日志' (#4) from codex/production-48-pm2-definition into main 2026-10-30 19:16:50 +08:00
171 changed files with 9311 additions and 2975 deletions

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media"; import { convertMessagesWithImages } from "./image-media";
import { isProviderBalanceError } from "@/lib/request-error"; 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"); const chatApiLogger = createLogger("chat.imageFailover");
@ -326,7 +326,7 @@ export function buildImageFallbackCandidates(
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) { if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false; 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); 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"}`); chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; return false;
@ -488,7 +488,7 @@ export function getImageCrossProviderCandidates(
} }
if (m.model.id === options.currentModelId) return false; if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) 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); 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"}`); chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; 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, writer: StreamWriter,
params: { params: {
phase: "queued" | "processing" | "finalizing" | "failed" | "completed"; phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
userMessageId?: string;
providerType?: string | null; providerType?: string | null;
modelName?: string | null; modelName?: string | null;
imageCount?: number | null; imageCount?: number | null;
@ -223,6 +224,7 @@ export function writeImageGenerationStatusChunk(
"data-imageGenerationStatus", "data-imageGenerationStatus",
{ {
phase: params.phase, phase: params.phase,
userMessageId: params.userMessageId,
providerType: normalizeImageLoadingProviderType(params.providerType), providerType: normalizeImageLoadingProviderType(params.providerType),
modelName: String(params.modelName || "").trim() || undefined, modelName: String(params.modelName || "").trim() || undefined,
imageCount: imageCount:

View File

@ -1,10 +1,11 @@
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access"; import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol"; import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work";
import { geolocation } from "@vercel/functions"; import { geolocation } from "@vercel/functions";
import { import {
convertToModelMessages, convertToModelMessages,
createUIMessageStream, createUIMessageStream as createAiUIMessageStream,
generateText, generateText,
smoothStream, smoothStream,
stepCountIs, stepCountIs,
@ -336,6 +337,7 @@ import {
} from "./_lib/image-media"; } from "./_lib/image-media";
import { import {
buildImageFallbackCandidates, buildImageFallbackCandidates,
countChatMessageImageInputs,
generateImageWithFailoverCandidate, generateImageWithFailoverCandidate,
getImageCrossProviderCandidates, getImageCrossProviderCandidates,
IMAGE_FAILOVER_PROVIDER_ALLOWLIST, IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
@ -344,6 +346,8 @@ import {
setImageFallbackModelCooldown, setImageFallbackModelCooldown,
shouldCooldownImageFallbackModel, shouldCooldownImageFallbackModel,
} from "./_lib/image-failover"; } from "./_lib/image-failover";
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { isProviderBalanceError } from "@/lib/request-error";
import { import {
buildAgentSceneWorkflowPrompt, buildAgentSceneWorkflowPrompt,
buildAgentSlotPrompt, buildAgentSlotPrompt,
@ -422,7 +426,11 @@ import {
type VideoModeType, type VideoModeType,
} from "./_lib/video-models"; } from "./_lib/video-models";
import { reportImageGenerationFailure } from "./_lib/image-generation-failure";
const chatApiLogger = createLogger("chat.route"); const chatApiLogger = createLogger("chat.route");
const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN = const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i; /生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
@ -440,16 +448,6 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`); 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( const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
1, 1,
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6), Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
@ -5682,6 +5680,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
modelName: resolvedModelRef, modelName: resolvedModelRef,
imageCount: replacementReferences.length, imageCount: replacementReferences.length,
@ -6024,6 +6023,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -6179,6 +6179,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: successCount > 0 ? "completed" : "failed", phase: successCount > 0 ? "completed" : "failed",
modelName: resolvedModelDisplayName, modelName: resolvedModelDisplayName,
imageCount: successCount, imageCount: successCount,
@ -6411,6 +6412,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
modelName: modelName:
selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.defaultModel?.displayName ||
@ -6962,6 +6964,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -7287,6 +7290,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -7515,6 +7519,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -7833,6 +7838,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: successCount > 0 ? "completed" : "failed", phase: successCount > 0 ? "completed" : "failed",
modelName: modelName:
selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.defaultModel?.displayName ||
@ -8042,6 +8048,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: requestedModelProviderType, providerType: requestedModelProviderType,
modelName: finalModelId, modelName: finalModelId,
@ -8080,6 +8087,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
...directImageStatusPayload, ...directImageStatusPayload,
}, },
@ -8172,6 +8180,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: requestedModelProviderType, providerType: requestedModelProviderType,
modelName: directImageResult.model, modelName: directImageResult.model,
@ -8192,42 +8201,16 @@ export async function POST(request: Request) {
); );
} catch (directToolError) { } catch (directToolError) {
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError); chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
const errorMessage = getDirectAgentToolErrorMessage( const errorMessage = await reportImageGenerationFailure({
"generate_image", writer, chatId: id, userMessageId: message.id,
directToolError, messageId: assistantMessageId, error: directToolError,
); label: "agent:generate_image:error",
await saveAssistantTextOnlyMessage({
chatId: id,
messageId: assistantMessageId,
text: errorMessage,
}); });
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( writeAgentProgressChunk(
writer, writer,
buildDirectAgentImageGenerateFailedProgress(errorMessage), buildDirectAgentImageGenerateFailedProgress(errorMessage),
"agent:generate_image:progress-failed", "agent:generate_image:progress-failed",
); );
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"agent:generate_image:end-error",
);
} finally { } finally {
stopDirectImageKeepalive(); stopDirectImageKeepalive();
if (stopDirectImageStatus) { 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) // 检查是否是 UUID 格式(数据库中的模型 ID)
const isUUID = const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( /^[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( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: currentModelConfig?.displayName || actualModelId, 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.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
}); });
chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); 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.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); 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( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -9468,6 +9468,14 @@ export async function POST(request: Request) {
} }
} catch (error) { } catch (error) {
chatApiLogger.error("[Chat API] Gemini generation error:", 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..."); chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision...");
// 故障转移:尝试使用火山引擎模型 // 故障转移:尝试使用火山引擎模型
@ -9545,6 +9553,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: "volcano-vision", providerType: "volcano-vision",
modelName: volcanoModel.model.displayName || volcanoModel.model.modelId, 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"); chatApiLogger.info("[Chat API] No Volcano Vision model available for failover");
} catch (failoverError) { } catch (failoverError) {
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError); chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
finalError = failoverError;
} }
// 如果故障转移也失败,返回错误消息 // 如果故障转移也失败,返回错误消息
const errorMessageId = generateUUID(); await reportImageGenerationFailure({
writeAssistantTextChunk( writer, chatId: id, userMessageId: message.id,
writer, messageId: generateUUID(), error: finalError, label: "gemini:error",
"图片生成失败,请稍后重试",
"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);
}
} finally { } finally {
stopKeepalive?.(); stopKeepalive?.();
} }
@ -9847,6 +9824,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "queued", phase: "queued",
providerType: "fal-image", providerType: "fal-image",
modelName: modelDisplayName, modelName: modelDisplayName,
@ -9953,6 +9931,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: alibabaModelConfig?.displayName || actualModelId, modelName: alibabaModelConfig?.displayName || actualModelId,
@ -10002,7 +9981,10 @@ export async function POST(request: Request) {
let fallbackSuccess = false; let fallbackSuccess = false;
const triedFallbackModelIds = new Set<string>(); 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) { for (const candidate of fallbackCandidates) {
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
try { try {
triedFallbackModelIds.add(String(candidate.model.id || "")); triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`); 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!`); chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
break; break;
} catch (fallbackError) { } catch (fallbackError) {
if (isProviderBalanceError(fallbackError)) balanceBlockedProviders.add(candidate.provider.id);
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError); const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) { if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, 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.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); 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, currentModelKey: actualModelId,
currentProviderId: alibabaModelConfig?.providerId, currentProviderId: alibabaModelConfig?.providerId,
providerPriority, 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) { for (const candidate of crossProviderCandidates) {
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
try { try {
triedFallbackModelIds.add(String(candidate.model.id || "")); triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`); 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})`); chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
break; break;
} catch (crossError) { } catch (crossError) {
if (isProviderBalanceError(crossError)) balanceBlockedProviders.add(candidate.provider.id);
const crossErrorMessage = normalizeFailoverErrorMessage(crossError); const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
if (shouldCooldownImageFallbackModel(crossErrorMessage)) { if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, 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.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); 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( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -10309,43 +10297,10 @@ export async function POST(request: Request) {
chatApiLogger.error("[Chat API] Alibaba generation error:", error); chatApiLogger.error("[Chat API] Alibaba generation error:", error);
// 返回错误消息 // 返回错误消息
const errorMessageId = generateUUID(); await reportImageGenerationFailure({
const errorText = getPublicImageGenerationErrorText(error); writer, chatId: id, userMessageId: message.id,
writeAssistantTextChunk( messageId: generateUUID(), error, label: "alibaba:error",
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);
}
} finally { } finally {
stopKeepalive?.(); stopKeepalive?.();
} }
@ -10474,6 +10429,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: "volcano-vision", providerType: "volcano-vision",
modelName: volcanoModelConfig?.displayName || actualModelId, modelName: volcanoModelConfig?.displayName || actualModelId,
@ -10519,6 +10475,7 @@ export async function POST(request: Request) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
} }
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
if (!fallbackSuccess) { if (!fallbackSuccess) {
@ -10685,6 +10642,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: "volcano-vision", providerType: "volcano-vision",
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -10749,43 +10707,10 @@ export async function POST(request: Request) {
} }
} catch (error) { } catch (error) {
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error); chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
const errorMessageId = generateUUID(); await reportImageGenerationFailure({
writeAssistantTextChunk( writer, chatId: id, userMessageId: message.id,
writer, messageId: generateUUID(), error, label: "volcano:error",
"图片生成失败,请稍后重试",
"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);
}
} finally { } finally {
stopKeepalive?.(); stopKeepalive?.();
} }
@ -11358,6 +11283,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter, dataStream as unknown as StreamWriter,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType, providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
modelName: currentModelConfig?.displayName || actualModelId, modelName: currentModelConfig?.displayName || actualModelId,
@ -11449,6 +11375,7 @@ export async function POST(request: Request) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
} }
chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); 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( writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter, dataStream as unknown as StreamWriter,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: resolvedProviderType || providerType, providerType: resolvedProviderType || providerType,
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -11679,54 +11607,10 @@ export async function POST(request: Request) {
} catch (error) { } catch (error) {
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error); chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
const errorMessageId = generateUUID(); await reportImageGenerationFailure({
const errorText = getPublicImageGenerationErrorText(error); writer: dataStream as unknown as StreamWriter,
chatId: id, userMessageId: message.id,
dataStream.write({ messageId: generateUUID(), error, label: "openai-compat:error",
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,
},
],
}); });
return; return;
} }

View File

@ -8,6 +8,8 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import Image from "next/image"; import Image from "next/image";
import { formatBeijingTime } from "@/lib/utils"; import { formatBeijingTime } from "@/lib/utils";
import { toast } from "sonner";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
type FeaturedImage = { type FeaturedImage = {
id: string; id: string;
@ -74,18 +76,11 @@ export default function FeaturedPage() {
const handleDownload = async (url: string, prompt: string) => { const handleDownload = async (url: string, prompt: string) => {
try { try {
const response = await fetch(url); await downloadOriginalImage({ url, name: "featured-image" });
const blob = await response.blob(); toast.success("原图已开始下载");
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);
} catch (error) { } catch (error) {
console.error("Download failed:", error); console.error("Download failed:", error);
toast.error(getCanvasImageDownloadErrorMessage(error));
} }
}; };

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -9,9 +10,10 @@ import { Input } from "@/components/ui/input";
import Image from "next/image"; import Image from "next/image";
import { toast } from "sonner"; import { toast } from "sonner";
import { requestLongTaskResult } from "@/lib/client/long-task"; import { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatBeijingTime } from "@/lib/utils"; import { formatBeijingTime } from "@/lib/utils";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
type GalleryImage = { type GalleryImage = {
id: string; id: string;
@ -32,7 +34,7 @@ type Pagination = {
totalPages: number; totalPages: number;
}; };
function buildGalleryDownloadFilename(url: string, alt: string) { function buildGalleryDownloadFilename(url: string, alt: string, contentType: string) {
const rawUrl = String(url || "").trim(); const rawUrl = String(url || "").trim();
const rawAlt = String(alt || "").trim(); const rawAlt = String(alt || "").trim();
const safeBase = const safeBase =
@ -58,7 +60,7 @@ function buildGalleryDownloadFilename(url: string, alt: string) {
} }
} }
return `${safeBase}-${Date.now()}.${extension}`; return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`;
} }
export default function GalleryPage() { export default function GalleryPage() {
@ -103,7 +105,7 @@ export default function GalleryPage() {
}; };
const handleDownload = async (url: string, alt: string) => { const handleDownload = async (url: string, alt: string) => {
const originalUrl = inferOriginalImageUrl(String(url || "").trim()); const originalUrl = String(url || "").trim();
if (!originalUrl) { if (!originalUrl) {
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
@ -112,32 +114,17 @@ export default function GalleryPage() {
try { try {
toast.info("正在准备下载..."); toast.info("正在准备下载...");
const response = await fetch( // Validate the supplied URL before any legacy thumbnail inference can
buildImageProxyUrl(originalUrl, { delivery: "proxy" }) // remove signed storage transformation parameters.
); buildImageProxyUrl(originalUrl, { delivery: "original" });
if (!response.ok) { const parsed = new URL(originalUrl, window.location.origin);
throw new Error(`download failed: ${response.status}`); const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
}
const blob = await response.blob(); await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = buildGalleryDownloadFilename(originalUrl, alt);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
toast.success("图片已开始下载"); toast.success("图片已开始下载");
} catch (error) { } catch (error) {
console.error("Download failed:", error); console.error("Download failed:", error);
const fallbackLink = document.createElement("a"); toast.error(getCanvasImageDownloadErrorMessage(error));
fallbackLink.href = originalUrl;
fallbackLink.target = "_blank";
fallbackLink.rel = "noopener noreferrer";
document.body.appendChild(fallbackLink);
fallbackLink.click();
document.body.removeChild(fallbackLink);
toast.error("下载失败,已为你打开原图,可直接另存为");
} }
}; };
@ -317,7 +304,12 @@ export default function GalleryPage() {
<> <>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="flex flex-wrap justify-center gap-6 px-4"> <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 <div
key={img.id} key={img.id}
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105" className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
@ -326,7 +318,7 @@ export default function GalleryPage() {
> >
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<Image <Image
src={img.thumbnailUrl || img.url} src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
alt={img.alt} alt={img.alt}
width={200} width={200}
height={270} height={270}

View File

@ -1,8 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin"; import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors"; 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 { import {
listAiModelHealthEvents,
restoreAiModelHealth, restoreAiModelHealth,
} from "@/lib/ai/runtime/model-health"; } from "@/lib/ai/runtime/model-health";
@ -15,14 +17,30 @@ export async function GET(request: Request) {
await requireAdmin(); await requireAdmin();
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const modelId = searchParams.get("modelId") || undefined; const modelId = searchParams.get("modelId");
const limit = Number(searchParams.get("limit") || 80); const providerId = searchParams.get("providerId");
const events = await listAiModelHealthEvents({ const rawLimit = Number(searchParams.get("limit") || 20);
modelId, const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20;
limit: Number.isFinite(limit) ? limit : 80, const rawPage = Number(searchParams.get("page") || 1);
}); const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1;
const filters = [];
return Response.json({ events }); 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) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
return error.toResponse(); return error.toResponse();

View File

@ -1,9 +1,10 @@
import { z } from "zod"; 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 { requireAdmin } from "@/lib/middleware/admin";
import { import {
getAllAiModels,
getAiModelById, getAiModelById,
getAiModelsByProviderId,
createAiModel, createAiModel,
updateAiModel, updateAiModel,
deleteAiModel, deleteAiModel,
@ -154,8 +155,14 @@ export async function GET(request: Request) {
const id = searchParams.get("id"); const id = searchParams.get("id");
const providerId = searchParams.get("providerId"); 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) { if (id) {
const model = await getAiModelById({ id }); const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) });
if (!model) { if (!model) {
return new ChatSDKError("bad_request:api", "Model not found").toResponse(); return new ChatSDKError("bad_request:api", "Model not found").toResponse();
} }
@ -163,12 +170,19 @@ export async function GET(request: Request) {
} }
if (providerId) { 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)) }); return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
} }
const models = await getAllAiModels(); const models = await db.select().from(aiModel);
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) }); 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) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
return error.toResponse(); return error.toResponse();

View File

@ -1,237 +1,31 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { chat, generatedImage, message, user } from "@/lib/db/schema"; import { generatedImage, user } from "@/lib/db/schema";
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { requireAdmin } from "@/lib/middleware/admin";
function extractVideoUrlFromParts(parts: unknown): string | null { import { ChatSDKError } from "@/lib/errors";
const text = Array.isArray(parts) import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records";
? parts import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
.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;
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const session = await auth(); const session = await requireAdmin();
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
if (!session?.user?.id) { if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const query = parseMediaQuery(request.nextUrl.searchParams);
if (query.startDate && query.endDate && query.startDate > query.endDate) {
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
} }
const [result] = await db.execute(mediaRecordsQuery(query));
// 检查是否为管理员 const images = await resolveArchivedImageUrls(result.records);
const currentUser = await db.query.user.findFirst({ const total = Number(result.total);
where: eq(user.id, session.user.id), 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) } });
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 }
});
} catch (error) { } catch (error) {
console.error("[Admin Images API] Error:", error); if (error instanceof ChatSDKError) return error.toResponse();
return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 }); 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.filter((preset) => existingModelIds.has(preset.modelId))
: catalogPresets; : 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 addedModels: AiModel[] = [];
const updatedModels: AiModel[] = []; const updatedModels: AiModel[] = [];
const disabledModels: AiModel[] = []; const disabledModels: AiModel[] = [];

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; 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 { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema"; import { canvasProject } from "@/lib/db/schema";
@ -56,14 +57,20 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json().catch(() => ({})); 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 title = String(body.title || "").trim() || "未命名生图项目";
const description = String(body.description || "").trim() || null; const description = String(body.description || "").trim() || null;
const thumbnail = String(body.thumbnail || "").trim() || null; const thumbnail = String(body.thumbnail || "").trim() || null;
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState); const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
const [project] = await db const [insertedProject] = await db
.insert(canvasProject) .insert(canvasProject)
.values({ .values({
id,
userId: session.user.id, userId: session.user.id,
title, title,
description, description,
@ -78,8 +85,22 @@ export async function POST(request: NextRequest) {
agentProjectState: projectState, agentProjectState: projectState,
} as any, } as any,
}) })
.onConflictDoNothing({ target: canvasProject.id })
.returning(); .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) }); return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
} catch (error) { } catch (error) {
console.error("[AgentProjects API] 创建项目失败:", error); console.error("[AgentProjects API] 创建项目失败:", error);

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 { NextResponse } from "next/server";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
@ -12,7 +13,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
"tripo3d.ai", "tripo3d.ai",
"tripo3d.com", "tripo3d.com",
"worldlabs.ai", "worldlabs.ai",
"s3.1080p.hk",
"img.yydd.ai", "img.yydd.ai",
"scqny.jiansun.vip", "scqny.jiansun.vip",
"blob.vercel-storage.com", "blob.vercel-storage.com",
@ -25,7 +25,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [ const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
"bbyy.ai", "bbyy.ai",
"s3.1080p.hk",
"img.yydd.ai", "img.yydd.ai",
]; ];
@ -165,6 +164,9 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const rawUrl = String(searchParams.get("url") || "").trim(); 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) { if (!rawUrl) {
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 }); 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 }); 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)) { if (!isAllowedModelHost(targetUrl.hostname)) {
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 }); return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
} }

View File

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

View File

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

View File

@ -1,100 +1,15 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
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";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 60; export const maxDuration = 60;
type BoxCutoutRequest = { export async function POST(request: Request) {
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(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 }); return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
} }
return executeBoxCutoutPOST(request, session.user.id);
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 },
);
}
} }

View File

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

View File

@ -10,11 +10,13 @@ import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download"; import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery"; import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
import { trackRuntimeWork } from "@/lib/observability/active-work";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 300; export const maxDuration = 300;
type DownloadFileItem = { url: string; name?: string }; type DownloadFileItem = { url: string; name?: string };
const MAX_DOWNLOAD_FILES = 200; 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 running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const active = running.__canvasZipRunning ??= new Set<string>(); const active = running.__canvasZipRunning ??= new Set<string>();
function sanitizeFolderName(value: string): string { function sanitizeFolderName(value: string): string {
@ -98,8 +100,12 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
task = await loadZipTask(id, userId); task = await loadZipTask(id, userId);
if (!task || task.status === "completed" || task.status === "failed") return; if (!task || task.status === "completed" || task.status === "failed") return;
await assertZipDiskSpace(); 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); await saveZipTask(task);
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. // 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. // A restart resumes from these files using the current owner's session.
let cursor = 0; let cursor = 0;
@ -137,24 +143,28 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task); task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; }); task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName); task.contentDisposition = buildContentDisposition(task.folderName);
}
task.stage = "正在准备下载链接"; await saveZipTask(task); task.stage = "正在准备下载链接"; await saveZipTask(task);
try { const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile!, disposition: task.contentDisposition!,
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) { if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key }; task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (cdn.url) task.cdnUrl = cdn.url; if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`);
task.cdnUrl = cdn.url;
} }
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成"; 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; task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task); await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes. // 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))); 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, 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) { } catch (error) {
if (task) { if (task) {
task.status = "failed"; task.stage = "打包未完成"; task.status = "failed"; task.stage = task.zipFile ? "ZIP 已打包,加速下载未就绪" : "打包未完成";
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试"; task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined); 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); } } finally { await release?.().catch(() => undefined); active.delete(id); }
} }
function schedule(task: ZipTask, request: NextRequest) { 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); after(run);
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -187,14 +197,17 @@ export async function POST(request: NextRequest) {
const now = Date.now(); const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files, task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0, 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); 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; task.status = "pending"; task.error = undefined;
await assertZipTaskCapacity(session.user.id); await assertZipTaskCapacity(session.user.id);
if (!task.zipFile) {
for (const file of task.files) delete file.error; for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length; 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; task.failedCount = 0;
}
task.updatedAt = Date.now(); task.expiresAt = Date.now() + PREPARATION_TTL_MS;
await saveZipTask(task); await saveZipTask(task);
} }
if (task.status === "pending" || task.status === "running") schedule(task, request); 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 { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue"; import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
export const maxDuration = 1200; export const maxDuration = 1200;
@ -23,13 +24,15 @@ export async function GET(request: Request) {
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 }); return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
} }
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
task: { task: {
id: task.id, id: task.id,
status: task.status, status: task.status,
attempts: task.attempts, attempts: task.attempts,
error: task.error || null, error: failure ? formatRequestFailure(failure, task.id) : null,
failure,
result: task.result || null, result: task.result || null,
createdAt: task.createdAt, createdAt: task.createdAt,
startedAt: task.startedAt, startedAt: task.startedAt,

View File

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

View File

@ -1,7 +1,12 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { NextRequest, NextResponse } from "next/server"; 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"; export const runtime = "nodejs";
const logger = createLogger("image.download");
const DEFAULT_QUALITY = 82; const DEFAULT_QUALITY = 82;
const MAX_WIDTH = 4096; const MAX_WIDTH = 4096;
@ -9,6 +14,7 @@ const MIN_WIDTH = 64;
const MIN_QUALITY = 40; const MIN_QUALITY = 40;
const MAX_QUALITY = 100; const MAX_QUALITY = 100;
const FETCH_TIMEOUT_MS = 15000; 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_BYTES = 64 * 1024 * 1024;
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256; const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
@ -127,13 +133,15 @@ function buildImageResponse(
etag: string; etag: string;
vary?: string; vary?: string;
cacheStatus?: "HIT" | "MISS"; cacheStatus?: "HIT" | "MISS";
cacheControl?: string;
attachment?: boolean;
} }
) { ) {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": params.contentType, "Content-Type": params.contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
ETag: params.etag, ETag: params.etag,
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", "Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
}; };
if (params.vary) { if (params.vary) {
headers.Vary = params.vary; headers.Vary = params.vary;
@ -141,6 +149,11 @@ function buildImageResponse(
if (params.cacheStatus) { if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = 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, { return new NextResponse(body, {
status: 200, status: 200,
headers, headers,
@ -230,6 +243,7 @@ function resolveDirectImageTarget(
requestedQuality: number | null, requestedQuality: number | null,
requestedFormat: string | null requestedFormat: string | null
): URL | null { ): URL | null {
if (isAlibabaModelImageUrl(target.toString())) return null;
if (!isBrowserImageRequest(request)) { if (!isBrowserImageRequest(request)) {
return null; return null;
} }
@ -258,7 +272,19 @@ function redirectToDirectImage(target: URL): NextResponse {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl; 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(); 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) { if (!rawUrl) {
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 }); return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
} }
@ -274,6 +300,24 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 }); 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)) { if (isBlockedHost(target.hostname)) {
return NextResponse.json({ error: "Blocked target host" }, { status: 403 }); return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
} }
@ -285,8 +329,47 @@ export async function GET(request: NextRequest) {
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null; const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
const requestedFormat = searchParams.get("f"); const requestedFormat = searchParams.get("f");
const forceProxy = searchParams.get("proxy") === "1"; const forceProxy = searchParams.get("proxy") === "1";
const original = searchParams.get("original") === "1";
if (!forceProxy) { if (original && (requestedWidth || requestedQuality || requestedFormat)) {
return NextResponse.json(
{ error: "Original images cannot be transformed" },
{ status: 400 }
);
}
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( const directImageTarget = resolveDirectImageTarget(
request, request,
target, target,
@ -299,10 +382,12 @@ export async function GET(request: NextRequest) {
} }
} }
const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`; const cacheKey = original
? `${target.toString()}|original-v1`
: `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
const etag = buildEtag(cacheKey); const etag = buildEtag(cacheKey);
const ifNoneMatch = request.headers.get("if-none-match"); const ifNoneMatch = request.headers.get("if-none-match");
if (ifNoneMatch && ifNoneMatch === etag) { if (!original && ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { return new NextResponse(null, {
status: 304, status: 304,
headers: { headers: {
@ -312,7 +397,7 @@ export async function GET(request: NextRequest) {
}); });
} }
const cached = getCachedImageResponse(cacheKey); const cached = original ? null : getCachedImageResponse(cacheKey);
if (cached) { if (cached) {
return buildImageResponse(cached.body, { return buildImageResponse(cached.body, {
contentType: cached.contentType, contentType: cached.contentType,
@ -322,32 +407,73 @@ export async function GET(request: NextRequest) {
}); });
} }
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: 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; let upstreamResponse: Response;
try { try {
upstreamResponse = await fetch(target.toString(), { upstreamResponse = await fetch(target.toString(), {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), signal: fetchSignal,
headers: { headers: {
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}, },
}); });
} catch (error) { } catch (error) {
console.error("[ImageProxy] Upstream fetch failed:", error); return upstreamFailure(error);
return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 });
} }
if (!upstreamResponse.ok) { if (!upstreamResponse.ok) {
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
return NextResponse.json( return NextResponse.json(
{ error: `Upstream responded with ${upstreamResponse.status}` }, { error: `Upstream responded with ${upstreamResponse.status}` },
{ status: 502 } { status: 502 }
); );
} }
contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
if (!contentType.toLowerCase().startsWith("image/")) { 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 }); return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
} }
try {
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) {
return upstreamFailure(error);
}
}
if (original) {
return buildImageResponse(originalBuffer, {
contentType,
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
cacheControl: "private, no-store",
attachment: true,
});
}
const isSvg = contentType.toLowerCase().includes("image/svg"); const isSvg = contentType.toLowerCase().includes("image/svg");
const isGif = contentType.toLowerCase().includes("image/gif"); const isGif = contentType.toLowerCase().includes("image/gif");

View File

@ -6,6 +6,7 @@
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; 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_TIMEOUT_MS = 20000;
const SCENE_FUSION_AGENT_MAX_WORKERS = 3; const SCENE_FUSION_AGENT_MAX_WORKERS = 3;
const SCENE_FUSION_PROCESS_MAX_WORKERS = 4; 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_TIMEOUT_MS = 20_000;
const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8; const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8;
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000; 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 { function normalizeUrlForCompare(input: string): string {
try { try {
const parsed = new URL(input); 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"); productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
} }
const shouldForcePreRmbg = resolvedSceneMode === "random-bg"; const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
const preprocessedProduct = await preprocessProductImageWithRmbg( const preprocessedProduct = await preprocessProductImageWithRmbg({
request, request,
requestId, requestId,
userId: session.user.id,
productImageUrl, productImageUrl,
shouldForcePreRmbg ? true : config.preRmbgEnabled preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled,
); });
const fusionProductImageUrl = preprocessedProduct.productImageUrl; const fusionProductImageUrl = preprocessedProduct.productImageUrl;
logger.info("pre_rmbg_resolved", { logger.info("pre_rmbg_resolved", {
requestId, requestId,

View File

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { resolveSiteBranding } from "@/lib/site-branding";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -152,7 +153,7 @@ import {
} from "@/lib/ai-wash/config"; } from "@/lib/ai-wash/config";
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config"; import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
import { withTimeout } from "@/lib/public-api/performance"; 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. // Historically stored as "home_surprise_categories" but used for the featured gallery categories.
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories"; const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
@ -552,6 +553,7 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
type PublicSiteSettingsScope = type PublicSiteSettingsScope =
| "agent-brand-model" | "agent-brand-model"
| "agent-canvas" | "agent-canvas"
| "agent-entry"
| "agent-presets" | "agent-presets"
| "auth" | "auth"
| "branding" | "branding"
@ -563,6 +565,7 @@ type PublicSiteSettingsScope =
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([ const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
"agent-brand-model", "agent-brand-model",
"agent-canvas", "agent-canvas",
"agent-entry",
"agent-presets", "agent-presets",
"auth", "auth",
"branding", "branding",
@ -606,22 +609,60 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined), 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( function createScopedSiteSettingsResponse(
scope: PublicSiteSettingsScope, scope: PublicSiteSettingsScope,
data: Record<string, unknown>, data: Record<string, unknown>,
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS", cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
cacheKey: string = scope, cacheKey: string = scope,
options: { fallback?: boolean; startedAt?: number } = {} options: { fallback?: boolean; startedAt?: number; etag?: string } = {}
) { ) {
if (cacheStatus === "MISS") { if (cacheStatus === "MISS") {
setScopedSiteSettingsCache( setScopedSiteSettingsCache(
cacheKey, cacheKey,
data, data,
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
options.etag
); );
} }
const response = NextResponse.json(data); const response = NextResponse.json(data);
if (options.etag) response.headers.set("ETag", options.etag);
// Lightweight public settings change rarely; keep them browser-local briefly // Lightweight public settings change rarely; keep them browser-local briefly
// so page opens do not repeatedly wait on the large full settings endpoint. // so page opens do not repeatedly wait on the large full settings endpoint.
response.headers.set( response.headers.set(
@ -663,12 +704,12 @@ async function readScopedSiteSettings(keys: string[]) {
} }
function createBrandingSettings(settings: Record<string, unknown> | undefined) { function createBrandingSettings(settings: Record<string, unknown> | undefined) {
return { return resolveSiteBranding({
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"), siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
siteLogo: settings?.siteLogo || null, siteLogo: String(settings?.siteLogo || ""),
siteLogoDark: settings?.siteLogoDark || null, siteLogoDark: String(settings?.siteLogoDark || ""),
favicon: settings?.favicon || null, favicon: String(settings?.favicon || ""),
}; });
} }
async function getScopedPublicSiteSettings( async function getScopedPublicSiteSettings(
@ -676,6 +717,7 @@ async function getScopedPublicSiteSettings(
options: { options: {
brandName?: string; brandName?: string;
cacheKey?: string; cacheKey?: string;
etag?: string;
} = {} } = {}
) { ) {
if (scope === "auth") { if (scope === "auth") {
@ -737,23 +779,8 @@ async function getScopedPublicSiteSettings(
}); });
} }
if (scope === "agent-canvas") { if (scope === "agent-canvas" || scope === "agent-entry") {
const { settings, valueByKey } = await readScopedSiteSettings([ const { settings, valueByKey } = await readScopedSiteSettings(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,
]);
const canvasFeatureAvailability = await readCanvasFeatureAvailability( const canvasFeatureAvailability = await readCanvasFeatureAvailability(
valueByKey valueByKey
); );
@ -761,6 +788,8 @@ async function getScopedPublicSiteSettings(
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled( const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY) valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
); );
const rawUiConfig = valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) as
Partial<AgentWorkflowUiConfig> | undefined;
return createScopedSiteSettingsResponse(scope, { return createScopedSiteSettingsResponse(scope, {
...createBrandingSettings(typedSettings), ...createBrandingSettings(typedSettings),
agentEnabled: typedSettings?.agentEnabled ?? true, agentEnabled: typedSettings?.agentEnabled ?? true,
@ -796,12 +825,18 @@ async function getScopedPublicSiteSettings(
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled, tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled, worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig( agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) scope === "agent-entry"
? {
steps: [],
pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
businessModeSettings: rawUiConfig?.businessModeSettings,
}
: rawUiConfig
), ),
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig( agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY) valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
), ),
}); }, "MISS", options.cacheKey || scope, { etag: options.etag });
} }
if (scope === "branding") { if (scope === "branding") {
@ -1055,13 +1090,23 @@ export async function GET(request: NextRequest) {
const cacheKey = brandName.trim() const cacheKey = brandName.trim()
? `${scopedScope}:${brandName.trim().toLowerCase()}` ? `${scopedScope}:${brandName.trim().toLowerCase()}`
: scopedScope; : scopedScope;
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry"
? 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); const cached = getScopedSiteSettingsCache(cacheKey);
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now() && (!etag || cached.etag === etag)) {
return createScopedSiteSettingsResponse( return createScopedSiteSettingsResponse(
scopedScope, scopedScope,
cached.data, cached.data,
"HIT", "HIT",
cacheKey cacheKey,
{ etag }
); );
} }
if (cached && scopedScope !== "home") { if (cached && scopedScope !== "home") {
@ -1072,6 +1117,7 @@ export async function GET(request: NextRequest) {
const responsePromise = getScopedPublicSiteSettings(scopedScope, { const responsePromise = getScopedPublicSiteSettings(scopedScope, {
brandName, brandName,
cacheKey, cacheKey,
etag,
}); });
return scopedScope === "home" return scopedScope === "home"
? await withTimeout(responsePromise, 1600, "site-settings:home") ? await withTimeout(responsePromise, 1600, "site-settings:home")
@ -1541,7 +1587,7 @@ export async function GET(request: NextRequest) {
}; };
const response = NextResponse.json({ const response = NextResponse.json({
...baseSettings, ...resolveSiteBranding(baseSettings),
lovartPromoText: lovartPromoText:
baseSettings.lovartPromoText || baseSettings.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",

View File

@ -1,6 +1,11 @@
"use client"; "use client";
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue"; import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft";
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext";
import equal from "fast-deep-equal";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@ -167,6 +172,7 @@ import {
type VideoCanvasSymbolMarkerId, type VideoCanvasSymbolMarkerId,
} from "@/lib/canvas/video-canvas-markers"; } from "@/lib/canvas/video-canvas-markers";
import { import {
isCanvasImageUploadPending,
LOCAL_UPLOAD_PENDING_METADATA_KEY, LOCAL_UPLOAD_PENDING_METADATA_KEY,
LOCAL_UPLOAD_PREVIEW_METADATA_KEY, LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
} from "@/lib/canvas/local-upload-state"; } from "@/lib/canvas/local-upload-state";
@ -227,6 +233,7 @@ import {
type WorkflowSceneRecord, type WorkflowSceneRecord,
} from "@/lib/video-workflow/runtime"; } from "@/lib/video-workflow/runtime";
import { agentProjectService } from "@/services/agent-projects"; import { agentProjectService } from "@/services/agent-projects";
import { fetchCanvasSiteSettings } from "@/lib/client/canvas-site-settings";
import { import {
canvasService, canvasService,
imageGenerationService, imageGenerationService,
@ -264,30 +271,8 @@ import type {
} from "@/types/canvas"; } from "@/types/canvas";
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000; const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5"; const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
const fetchCanvasSiteSettings = async (url: string) => {
const separator = url.includes("?") ? "&" : "?";
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, CANVAS_SITE_SETTINGS_TIMEOUT_MS);
const response = await fetch(`${url}${separator}t=${Date.now()}`, {
cache: "no-store",
signal: controller.signal,
}).finally(() => {
clearTimeout(timeoutId);
});
if (!response.ok) {
throw new Error("Failed to fetch canvas site settings");
}
return response.json();
};
function CanvasLazyPanelLoader({ function CanvasLazyPanelLoader({
label, label,
className, className,
@ -1949,6 +1934,12 @@ export default function CanvasEditorLegacyPage({
() => !initialProjectForCurrentRoute () => !initialProjectForCurrentRoute
); );
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [canvasLoadError, setCanvasLoadError] = useState("");
const [canvasLoadAttempt, setCanvasLoadAttempt] = useState(0);
const canvasReadyRef = useRef(Boolean(initialProjectForCurrentRoute));
const savedCanvasSnapshotRef = useRef<{ id: string; snapshot: string } | null>(null);
const canvasSyncErrorRef = useRef("");
const [canvasSyncConflicts, setCanvasSyncConflicts] = useState<string[]>([]);
const [isEditingTitle, setIsEditingTitle] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false); const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
@ -2184,7 +2175,9 @@ export default function CanvasEditorLegacyPage({
[agentProjectId, id] [agentProjectId, id]
); );
const canvasSiteSettingsKey = requestedAgentMode const canvasSiteSettingsKey = requestedAgentMode
? hasActiveAgentProject
? "/api/site-settings?scope=agent-canvas" ? "/api/site-settings?scope=agent-canvas"
: "/api/site-settings?scope=agent-entry"
: "/api/site-settings?scope=canvas"; : "/api/site-settings?scope=canvas";
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] = const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
useState(false); useState(false);
@ -2220,6 +2213,7 @@ export default function CanvasEditorLegacyPage({
? 0 ? 0
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS, : CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
errorRetryCount: 1, errorRetryCount: 1,
dedupingInterval: 30_000,
revalidateOnFocus: true, revalidateOnFocus: true,
revalidateOnReconnect: true, revalidateOnReconnect: true,
}); });
@ -2336,11 +2330,18 @@ export default function CanvasEditorLegacyPage({
}); });
const sanitizedWorkflowSelections = useMemo( const sanitizedWorkflowSelections = useMemo(
() => () =>
sanitizeAgentWorkflowSelections({ requestedAgentMode && !siteSettingsData?.agentWorkflowUiConfig
? workflowSelections
: sanitizeAgentWorkflowSelections({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
selections: workflowSelections, selections: workflowSelections,
}), }),
[resolvedWorkflowUiConfig, workflowSelections] [
requestedAgentMode,
siteSettingsData?.agentWorkflowUiConfig,
resolvedWorkflowUiConfig,
workflowSelections,
]
); );
const deferredSanitizedWorkflowSelections = useDeferredValue( const deferredSanitizedWorkflowSelections = useDeferredValue(
sanitizedWorkflowSelections sanitizedWorkflowSelections
@ -3375,6 +3376,32 @@ export default function CanvasEditorLegacyPage({
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, []); }, []);
const loadPersistedCanvasState = useCallback((input: CanvasState | null | undefined) => {
const canvasData = input || { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" as const };
const base = JSON.stringify({ canvasData });
savedCanvasSnapshotRef.current = { id, snapshot: base };
externalCanvasConflictRef.current = false;
setCanvasSyncConflicts([]);
const draft = readCanvasDraft(id);
if (draft) {
const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base);
if (merged.conflicts.length) {
externalCanvasConflictRef.current = true;
savedCanvasSnapshotRef.current = { id, snapshot: draft.base };
setCanvasSyncConflicts(merged.conflicts);
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
toast.warning(canvasSyncErrorRef.current);
return;
}
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
if (merged.snapshot !== base) writeCanvasDraft(id, { base, snapshot: merged.snapshot });
else clearCanvasDraft(id);
return;
}
useCanvasStore.getState().loadCanvas(canvasData);
}, [id]);
const hydrateCanvasProject = useCallback( const hydrateCanvasProject = useCallback(
(project: CanvasInitialProject) => { (project: CanvasInitialProject) => {
externalCanvasConflictRef.current = false; externalCanvasConflictRef.current = false;
@ -3384,9 +3411,9 @@ export default function CanvasEditorLegacyPage({
useCanvasStore useCanvasStore
.getState() .getState()
.setCanvasRevision(projectUpdatedAt, projectUpdatedAt); .setCanvasRevision(projectUpdatedAt, projectUpdatedAt);
loadPersistedCanvasState(project.canvasData);
if (project.canvasData) { if (project.canvasData) {
useCanvasStore.getState().loadCanvas(project.canvasData);
const persistedWorkflow = project.canvasData.workflowState; const persistedWorkflow = project.canvasData.workflowState;
if (!requestedAgentMode && persistedWorkflow) { if (!requestedAgentMode && persistedWorkflow) {
const nextAppearanceRecognitionEnabled = const nextAppearanceRecognitionEnabled =
@ -3489,7 +3516,7 @@ export default function CanvasEditorLegacyPage({
setWorkflowHydrationSource("empty"); setWorkflowHydrationSource("empty");
}, },
[id, requestedAgentMode] [id, requestedAgentMode, loadPersistedCanvasState]
); );
useLayoutEffect(() => { useLayoutEffect(() => {
@ -3497,6 +3524,7 @@ export default function CanvasEditorLegacyPage({
if (lastLoadedCanvasIdRef.current === id) return; if (lastLoadedCanvasIdRef.current === id) return;
hydrateCanvasProject(initialProjectForCurrentRoute); hydrateCanvasProject(initialProjectForCurrentRoute);
canvasReadyRef.current = true;
setIsLoading(false); setIsLoading(false);
lastLoadedCanvasIdRef.current = id; lastLoadedCanvasIdRef.current = id;
}, [hydrateCanvasProject, id, initialProjectForCurrentRoute]); }, [hydrateCanvasProject, id, initialProjectForCurrentRoute]);
@ -3504,6 +3532,7 @@ export default function CanvasEditorLegacyPage({
// 加载画布数据 // 加载画布数据
useEffect(() => { useEffect(() => {
console.log("[Canvas] Loading project:", id); console.log("[Canvas] Loading project:", id);
let cancelled = false;
const loadProject = async () => { const loadProject = async () => {
if (lastLoadedCanvasIdRef.current === id) { if (lastLoadedCanvasIdRef.current === id) {
@ -3512,6 +3541,8 @@ export default function CanvasEditorLegacyPage({
} }
setIsLoading(true); setIsLoading(true);
canvasReadyRef.current = false;
setCanvasLoadError("");
if (!requestedAgentMode) { if (!requestedAgentMode) {
setWorkflowHydrationSource("pending"); setWorkflowHydrationSource("pending");
} }
@ -3544,18 +3575,16 @@ export default function CanvasEditorLegacyPage({
setLoadedAgentProjectId(null); setLoadedAgentProjectId(null);
} }
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
useCanvasStore.getState().clearCanvas();
const result = await canvasService.getProject(id); const result = await canvasService.getProject(id);
if (cancelled) return;
if (result.success && result.data) { if (result.success && result.data) {
useCanvasStore.getState().setProject(id, result.data.title); useCanvasStore.getState().setProject(id, result.data.title);
useCanvasStore useCanvasStore
.getState() .getState()
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || "")); .setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
loadPersistedCanvasState(result.data.canvasData);
if (result.data.canvasData) { if (result.data.canvasData) {
useCanvasStore.getState().loadCanvas(result.data.canvasData);
const persistedWorkflow = result.data.canvasData.workflowState; const persistedWorkflow = result.data.canvasData.workflowState;
if (!requestedAgentMode && persistedWorkflow) { if (!requestedAgentMode && persistedWorkflow) {
const nextAppearanceRecognitionEnabled = const nextAppearanceRecognitionEnabled =
@ -3672,8 +3701,15 @@ export default function CanvasEditorLegacyPage({
return; return;
} }
// 画布不存在时,创建一个新画布 if (result.status !== 404) {
setCanvasLoadError(result.error || "画布暂时无法读取,请检查网络后重试加载。现有内容未被覆盖。");
setIsLoading(false);
return;
}
// Only an explicit missing project can create a new empty canvas.
console.log("[Canvas] 画布不存在,创建新画布:", id); console.log("[Canvas] 画布不存在,创建新画布:", id);
useCanvasStore.getState().clearCanvas();
useCanvasStore useCanvasStore
.getState() .getState()
.setProject( .setProject(
@ -3700,15 +3736,20 @@ export default function CanvasEditorLegacyPage({
} }
} }
} }
if (cancelled) return;
canvasReadyRef.current = true;
setIsLoading(false); setIsLoading(false);
lastLoadedCanvasIdRef.current = id; lastLoadedCanvasIdRef.current = id;
}; };
loadProject(); loadProject();
return () => { cancelled = true; };
}, [ }, [
activeCanvasWorkspaceType, activeCanvasWorkspaceType,
defaultCanvasTitle, defaultCanvasTitle,
id, id,
canvasLoadAttempt,
loadPersistedCanvasState,
openLoginDialog, openLoginDialog,
requestedAgentMode, requestedAgentMode,
]); ]);
@ -3946,20 +3987,18 @@ export default function CanvasEditorLegacyPage({
loadCanvas(normalized.canvasData); loadCanvas(normalized.canvasData);
setProjectTitle(nextTitle); setProjectTitle(nextTitle);
const [saveResult, updateResult] = await Promise.all([ const saveResult = await canvasService.saveCanvasState(
canvasService.saveCanvasState(
id, id,
normalized.canvasData, normalized.canvasData,
activeCanvasWorkspaceType activeCanvasWorkspaceType
), );
canvasService.updateProject( const updateResult = await canvasService.updateProject(
id, id,
{ {
title: nextTitle, title: nextTitle,
}, },
activeCanvasWorkspaceType activeCanvasWorkspaceType
), );
]);
if (!saveResult.success) { if (!saveResult.success) {
throw new Error(saveResult.error || "导入后保存失败"); throw new Error(saveResult.error || "导入后保存失败");
@ -4700,7 +4739,7 @@ export default function CanvasEditorLegacyPage({
title: nextTitle, title: nextTitle,
thumbnail: agentProjectThumbnail, thumbnail: agentProjectThumbnail,
projectState: buildPersistedAgentProjectState(), projectState: buildPersistedAgentProjectState(),
}); }, { creationKey: `save-as:${id}:${agentProjectId}:${nextTitle}` });
if (!result.success || !result.data) { if (!result.success || !result.data) {
if (isUnauthorizedApiError(result.error)) { if (isUnauthorizedApiError(result.error)) {
setIsAuthenticated(false); setIsAuthenticated(false);
@ -4725,6 +4764,7 @@ export default function CanvasEditorLegacyPage({
agentProjectThumbnail, agentProjectThumbnail,
agentProjectTitle, agentProjectTitle,
buildPersistedAgentProjectState, buildPersistedAgentProjectState,
id,
openLoginDialog, openLoginDialog,
persistAgentProject, persistAgentProject,
updateCanvasSearchParams, updateCanvasSearchParams,
@ -4789,7 +4829,7 @@ export default function CanvasEditorLegacyPage({
const result = await agentProjectService.createProject({ const result = await agentProjectService.createProject({
title: buildDefaultAgentProjectTitle(), title: buildDefaultAgentProjectTitle(),
projectState: createEmptyAgentGenerationProjectPersistedState(), projectState: createEmptyAgentGenerationProjectPersistedState(),
}); }, { creationKey: `blank:${id}:${agentProjectId}` });
if (!result.success || !result.data) { if (!result.success || !result.data) {
if (isUnauthorizedApiError(result.error)) { if (isUnauthorizedApiError(result.error)) {
setIsAuthenticated(false); setIsAuthenticated(false);
@ -4812,6 +4852,7 @@ export default function CanvasEditorLegacyPage({
}, [ }, [
agentProjectId, agentProjectId,
isAgentProjectSaving, isAgentProjectSaving,
id,
openLoginDialog, openLoginDialog,
persistAgentProject, persistAgentProject,
updateCanvasSearchParams, updateCanvasSearchParams,
@ -5080,15 +5121,70 @@ export default function CanvasEditorLegacyPage({
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue(); if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const canvasPersistenceScope = useRef(id); const canvasPersistenceScope = useRef(id);
const canvasRemoteOperationActive = useRef(false);
const canvasLeaveApprovedRef = useRef(false);
const [canvasLeaveUrl, setCanvasLeaveUrl] = useState("");
const [isSavingBeforeLeave, setIsSavingBeforeLeave] = useState(false);
const [canvasLeaveError, setCanvasLeaveError] = useState("");
canvasPersistenceScope.current = id; canvasPersistenceScope.current = id;
const persistCanvasState = useCallback( const hasUnsavedCanvasChanges = useCallback(() => {
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => { if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false;
if (canvasPersistenceScope.current !== id) return false; if (useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) return true;
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState;
const current = JSON.parse(JSON.stringify(buildPersistedCanvasData())) as CanvasState;
const content = (value: CanvasState) => [value.nodes.map(node => {
const { updatedAt: _updatedAt, ...nodeData } = node;
return { ...nodeData, connections: node.connections || [] };
}), value.edges || [], value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
return !equal(content(saved), content(current));
}, [buildPersistedCanvasData, id]);
const requestCanvasLeave = useCallback((url: string) => {
if (hasUnsavedCanvasChanges()) {
setCanvasLeaveError("");
setCanvasLeaveUrl(url);
return;
}
canvasLeaveApprovedRef.current = true;
window.location.assign(url);
}, [hasUnsavedCanvasChanges]);
const applyCanvasRemoteSnapshot = useCallback((result: CanvasRemoteOperationResult) => {
if (result.projectId !== id || canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return false;
const remoteSnapshot = JSON.stringify({ canvasData: result.canvasData });
const merged = mergeCanvasSnapshots(
savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : remoteSnapshot,
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
remoteSnapshot,
);
if (merged.changed) useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
useCanvasStore.getState().setCanvasRevision(result.updatedAt, result.updatedAt);
if (merged.conflicts.length) {
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
if (!externalCanvasConflictRef.current) toast.warning(canvasSyncErrorRef.current);
externalCanvasConflictRef.current = true;
setCanvasSyncConflicts(merged.conflicts);
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current?.snapshot || remoteSnapshot, snapshot: merged.snapshot });
return false;
}
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
externalCanvasConflictRef.current = false;
setCanvasSyncConflicts([]);
canvasSyncErrorRef.current = "";
return true;
}, [buildPersistedCanvasData, id]);
const persistCanvasStateNow = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => {
if (canvasLeaveApprovedRef.current || canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
if (externalCanvasConflictRef.current) return false;
setIsSaving(true); setIsSaving(true);
try { try {
const canvasData = buildPersistedCanvasData(); let canvasData = buildPersistedCanvasData();
let snapshot = JSON.stringify({ canvasData });
if (savedCanvasSnapshotRef.current?.id === id && equal(JSON.parse(savedCanvasSnapshotRef.current.snapshot), JSON.parse(snapshot))) {
clearCanvasDraft(id);
return true;
}
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
const result = await canvasService.saveCanvasState( let result = await canvasService.saveCanvasState(
id, id,
canvasData, canvasData,
activeCanvasWorkspaceType, activeCanvasWorkspaceType,
@ -5098,23 +5194,53 @@ export default function CanvasEditorLegacyPage({
} }
); );
if (canvasPersistenceScope.current !== id) return false; if (canvasPersistenceScope.current !== id) return false;
if (!result.success) { if (result.status === 409 && savedCanvasSnapshotRef.current?.id === id) {
console.error(`[Canvas] ${reason} save failed:`, result.error); const remote = await canvasService.getProject(id);
if (result.status === 409) { if (canvasPersistenceScope.current !== id) return false;
externalCanvasConflictRef.current = true; if (remote.success && remote.data?.canvasData) {
console.warn("[Canvas] stale save revision handled without user prompt", { const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
reason, const merged = mergeCanvasSnapshots(
canvasUpdatedAt: result.canvasUpdatedAt, savedCanvasSnapshotRef.current.snapshot,
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
remoteSnapshot,
);
if (!merged.conflicts.length) {
canvasData = JSON.parse(merged.snapshot).canvasData;
snapshot = merged.snapshot;
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
useCanvasStore.getState().loadCanvas(canvasData);
const revision = String(remote.data.updatedAt);
useCanvasStore.getState().setCanvasRevision(revision, revision);
result = await canvasService.saveCanvasState(id, canvasData, activeCanvasWorkspaceType, {
baseCanvasRevision: revision, baseCanvasUpdatedAt: revision,
}); });
if (!externalCanvasConflictNotifiedRef.current) { if (canvasPersistenceScope.current !== id) return false;
externalCanvasConflictNotifiedRef.current = true; } else {
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑"); externalCanvasConflictRef.current = true;
setCanvasSyncConflicts(merged.conflicts);
} }
} }
} else if (result.data?.updatedAt) { }
if (!result.success) {
const retained = writeCanvasDraft(id, {
base: savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : JSON.stringify({ canvasData: { nodes: [], edges: [] } }),
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
});
canvasSyncErrorRef.current = result.status === 409
? "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"}。${retained ? "本地草稿已保留,系统会继续自动同步,也可稍后重试。" : "请保留当前页面,检查网络后重试同步,暂勿刷新。"}`;
console.error(`[Canvas] ${reason} save failed:`, result.error);
} else {
canvasSyncErrorRef.current = "";
savedCanvasSnapshotRef.current = { id, snapshot };
clearCanvasDraft(id);
externalCanvasConflictRef.current = false; externalCanvasConflictRef.current = false;
setCanvasSyncConflicts([]);
externalCanvasConflictNotifiedRef.current = false; externalCanvasConflictNotifiedRef.current = false;
useCanvasStore if (result.data?.canvasData && result.data?.updatedAt) {
return applyCanvasRemoteSnapshot({ projectId: id, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) });
}
if (result.data?.updatedAt) useCanvasStore
.getState() .getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); .setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
} }
@ -5122,18 +5248,149 @@ export default function CanvasEditorLegacyPage({
} finally { } finally {
if (canvasPersistenceScope.current === id) setIsSaving(false); if (canvasPersistenceScope.current === id) setIsSaving(false);
} }
}), },
[activeCanvasWorkspaceType, buildPersistedCanvasData, id] [activeCanvasWorkspaceType, applyCanvasRemoteSnapshot, buildPersistedCanvasData, id]
); );
const persistCanvasBeforeAgent = useCallback(() => { const persistCanvasState = useCallback(
(reason: "autosave" | "delete" | "beforeunload") =>
canvasSaveQueue.current!.enqueue(() => persistCanvasStateNow(reason)),
[persistCanvasStateNow]
);
const finishCanvasLeave = useCallback(async (save: boolean) => {
if (!canvasLeaveUrl || isSavingBeforeLeave) return;
setCanvasLeaveError("");
if (save && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
setCanvasLeaveError("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再保存并离开。");
return;
}
if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null;
}
if (save) {
setIsSavingBeforeLeave(true);
try {
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布暂时无法同步,请保留此页并稍后重试。");
if (hasUnsavedCanvasChanges() && !(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current);
if (hasUnsavedCanvasChanges()) throw new Error("还有改动正在同步,请稍后重试离开。");
} catch (error) {
setCanvasLeaveError(error instanceof Error ? error.message : "画布暂时无法同步,请保留此页并稍后重试。");
setIsSavingBeforeLeave(false);
return;
}
}
canvasLeaveApprovedRef.current = true;
clearCanvasDraft(id);
window.location.assign(canvasLeaveUrl);
}, [canvasLeaveUrl, hasUnsavedCanvasChanges, id, isSavingBeforeLeave, persistCanvasState]);
const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => {
return canvasSaveQueue.current!.enqueue(async () => {
setIsSaving(true);
try {
const remote = await canvasService.getProject(id);
if (canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return;
if (!remote.success || !remote.data?.canvasData || savedCanvasSnapshotRef.current?.id !== id) {
throw new Error(remote.error || "画布暂时无法读取,改动已保留,请稍后重试同步。");
}
const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
const merged = mergeCanvasSnapshots(
savedCanvasSnapshotRef.current.snapshot,
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
remoteSnapshot,
choice,
);
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
const revision = String(remote.data.updatedAt);
useCanvasStore.getState().setCanvasRevision(revision, revision);
externalCanvasConflictRef.current = false;
canvasSyncErrorRef.current = "";
setCanvasSyncConflicts([]);
if (!(await persistCanvasStateNow("autosave"))) throw new Error(canvasSyncErrorRef.current);
toast.success("画布已合并同步,可以继续操作");
} catch (error) {
toast.error(error instanceof Error ? error.message : "画布同步失败,改动已保留,请稍后重试。");
} finally {
if (canvasPersistenceScope.current === id) setIsSaving(false);
}
});
}, [buildPersistedCanvasData, id, persistCanvasStateNow]);
const runCanvasRemoteOperation = useCallback(
(operation: CanvasRemoteOperation) => {
if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null;
}
return canvasSaveQueue.current!.enqueueOperation(
async () => {
const saved = await persistCanvasStateNow("autosave");
if (!saved) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次处理未提交。` : "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
return true;
},
async () => {
canvasRemoteOperationActive.current = true;
try {
const result = await operation();
if (result && result.projectId === id && canvasPersistenceScope.current === id) {
savedCanvasSnapshotRef.current = { id, snapshot: JSON.stringify({ canvasData: result.canvasData }) };
canvasSyncErrorRef.current = "";
}
} finally {
canvasRemoteOperationActive.current = false;
}
}
);
},
[persistCanvasStateNow, id]
);
const persistCanvasBeforeAgent = useCallback(async () => {
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
if (savedCanvasSnapshotRef.current?.id === id) {
if (!hasUnsavedCanvasChanges()) return true;
}
// Flush the pending local edit once instead of racing its scheduled autosave. // Flush the pending local edit once instead of racing its scheduled autosave.
if (canvasAutosaveTimer.current) { if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current); clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null; canvasAutosaveTimer.current = null;
} }
return persistCanvasState("autosave"); if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
}, [persistCanvasState]); return true;
}, [persistCanvasState, hasUnsavedCanvasChanges, id]);
const canvasRemoteOperationContext = useMemo(() => ({
run: runCanvasRemoteOperation,
applySnapshot: applyCanvasRemoteSnapshot,
}), [runCanvasRemoteOperation, applyCanvasRemoteSnapshot]);
useEffect(() => {
if (isVideoMode) return;
const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (!isAgentMode && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
request.block("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再刷新。");
return;
}
if (useCanvasStore.getState().isGenerating) {
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
return;
}
request.save(async () => {
if (isAgentMode) {
if (agentProjectId && hasLoadedCurrentAgentProject && !(await persistAgentProject("switch"))) throw new Error("生图项目同步失败,请保留此页并重试刷新。");
} else if (canvasReadyRef.current && !(await persistCanvasState("autosave"))) {
throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,请保留此页并重试刷新。");
}
});
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [isVideoMode, isAgentMode, agentProjectId, hasLoadedCurrentAgentProject, persistAgentProject, persistCanvasState]);
// 自动保存逻辑 // 自动保存逻辑
useEffect(() => { useEffect(() => {
@ -5154,7 +5411,32 @@ export default function CanvasEditorLegacyPage({
clearTimeout(saveTimer); clearTimeout(saveTimer);
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null; if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
}; };
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]); }, [isAgentMode, isAgentOperationRefreshActive, nodes, edges, isLoading, persistCanvasState]);
useEffect(() => {
if (isLoading || isAgentMode) return;
const retrySync = () => {
if (canvasLeaveApprovedRef.current || externalCanvasConflictRef.current || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || !hasUnsavedCanvasChanges()) return;
void persistCanvasState("autosave").catch(error => console.error("[Canvas] background sync failed:", error));
};
const onVisibilityChange = () => {
if (!canvasLeaveApprovedRef.current && document.visibilityState === "hidden" && hasUnsavedCanvasChanges()) {
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }) });
}
};
const retryTimer = window.setInterval(() => {
if (document.visibilityState === "visible" && navigator.onLine && canvasSyncErrorRef.current) retrySync();
}, 20_000);
window.addEventListener("online", retrySync);
window.addEventListener("focus", retrySync);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
window.clearInterval(retryTimer);
window.removeEventListener("online", retrySync);
window.removeEventListener("focus", retrySync);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [buildPersistedCanvasData, hasUnsavedCanvasChanges, id, isAgentMode, isAgentOperationRefreshActive, isLoading, persistCanvasState]);
useEffect(() => { useEffect(() => {
if (isLoading || isAgentOperationRefreshActive()) return; if (isLoading || isAgentOperationRefreshActive()) return;
@ -5168,6 +5450,7 @@ export default function CanvasEditorLegacyPage({
const refreshIfExternallyUpdated = async () => { const refreshIfExternallyUpdated = async () => {
if ( if (
cancelled || cancelled ||
canvasRemoteOperationActive.current ||
document.visibilityState !== "visible" || document.visibilityState !== "visible" ||
isSaving || isSaving ||
externalCanvasConflictRef.current || externalCanvasConflictRef.current ||
@ -5177,6 +5460,7 @@ export default function CanvasEditorLegacyPage({
} }
try { try {
const beforeRead = useCanvasStore.getState();
const revisionResponse = await fetch(`/api/canvas/${id}/revision`, { const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
cache: "no-store", cache: "no-store",
}); });
@ -5197,7 +5481,11 @@ export default function CanvasEditorLegacyPage({
} }
const result = await canvasService.getProject(id); const result = await canvasService.getProject(id);
if (cancelled || !result.success || !result.data) return; const current = useCanvasStore.getState();
if (
cancelled || canvasRemoteOperationActive.current || !result.success || !result.data ||
current.projectId !== id || current.nodes !== beforeRead.nodes || current.edges !== beforeRead.edges
) return;
useCanvasStore.getState().markAgentOperationRefresh(); useCanvasStore.getState().markAgentOperationRefresh();
hydrateCanvasProject(result.data); hydrateCanvasProject(result.data);
lastLocalCanvasMutationAtRef.current = Date.now(); lastLocalCanvasMutationAtRef.current = Date.now();
@ -5251,14 +5539,25 @@ export default function CanvasEditorLegacyPage({
} }
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]); }, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率 // 浏览器离开确认只保护尚未同步的内容;真正离开时保留草稿并发送一次保存。
useEffect(() => { useEffect(() => {
if (isLoading || isAgentMode) return; if (isLoading || isAgentMode) return;
const handleBeforeUnload = () => { const retainDraft = () => {
try { if (canvasLeaveApprovedRef.current || !hasUnsavedCanvasChanges()) return null;
if (isAgentOperationRefreshActive()) return;
const canvasData = buildPersistedCanvasData(); const canvasData = buildPersistedCanvasData();
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData }) });
return canvasData;
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!retainDraft()) return;
event.preventDefault();
event.returnValue = "true";
};
const handlePageHide = () => {
try {
const canvasData = retainDraft();
if (!canvasData || isSaving || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || externalCanvasConflictRef.current) return;
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
void fetch(`/api/canvas/${id}`, { void fetch(`/api/canvas/${id}`, {
method: "PUT", method: "PUT",
@ -5277,18 +5576,20 @@ export default function CanvasEditorLegacyPage({
}; };
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("pagehide", handleBeforeUnload); window.addEventListener("pagehide", handlePageHide);
return () => { return () => {
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("pagehide", handleBeforeUnload); window.removeEventListener("pagehide", handlePageHide);
}; };
}, [ }, [
activeCanvasWorkspaceType, activeCanvasWorkspaceType,
buildPersistedCanvasData, buildPersistedCanvasData,
hasUnsavedCanvasChanges,
id, id,
isAgentOperationRefreshActive, isAgentOperationRefreshActive,
isAgentMode, isAgentMode,
isLoading, isLoading,
isSaving,
]); ]);
// 智能命名与缩略图逻辑 // 智能命名与缩略图逻辑
@ -5320,11 +5621,11 @@ export default function CanvasEditorLegacyPage({
try { try {
if (autoThumbnailSyncedRef.current !== autoTitleKey) { if (autoThumbnailSyncedRef.current !== autoTitleKey) {
await canvasService.updateProject( await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
id, id,
{ thumbnail: imageSrc }, { thumbnail: imageSrc },
activeCanvasWorkspaceType activeCanvasWorkspaceType
); ));
autoThumbnailSyncedRef.current = autoTitleKey; autoThumbnailSyncedRef.current = autoTitleKey;
} }
@ -5340,11 +5641,11 @@ export default function CanvasEditorLegacyPage({
const data = await res.json(); const data = await res.json();
if (data.title) { if (data.title) {
setProjectTitle(data.title); setProjectTitle(data.title);
await canvasService.updateProject( await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
id, id,
{ title: data.title }, { title: data.title },
activeCanvasWorkspaceType activeCanvasWorkspaceType
); ));
toast.success(`${workspaceLabel}已自动命名为:${data.title}`); toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
} }
} catch (error) { } catch (error) {
@ -5372,21 +5673,12 @@ export default function CanvasEditorLegacyPage({
// 保存画布 (手动触发保留作为备用,但UI上移除) // 保存画布 (手动触发保留作为备用,但UI上移除)
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
setIsSaving(true); if (await persistCanvasState("autosave")) {
const canvasData = buildPersistedCanvasData();
const result = await canvasService.saveCanvasState(
id,
canvasData,
activeCanvasWorkspaceType
);
if (result.success) {
toast.success("保存成功"); toast.success("保存成功");
} else { } else {
toast.error(result.error || "保存失败"); toast.error(canvasSyncErrorRef.current || "画布同步失败,请检查网络后重试");
} }
setIsSaving(false); }, [persistCanvasState]);
}, [activeCanvasWorkspaceType, buildPersistedCanvasData, id]);
// 更新标题 // 更新标题
const handleUpdateTitle = async () => { const handleUpdateTitle = async () => {
@ -5395,11 +5687,11 @@ export default function CanvasEditorLegacyPage({
return; return;
} }
const result = await canvasService.updateProject( const result = await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
id, id,
{ title: editTitle.trim() }, { title: editTitle.trim() },
activeCanvasWorkspaceType activeCanvasWorkspaceType
); ));
if (result.success) { if (result.success) {
setProjectTitle(editTitle.trim()); setProjectTitle(editTitle.trim());
} else { } else {
@ -7032,7 +7324,9 @@ export default function CanvasEditorLegacyPage({
const result = existingProject const result = existingProject
? await agentProjectService.updateProject(existingProject.id, payload) ? await agentProjectService.updateProject(existingProject.id, payload)
: await agentProjectService.createProject(payload); : await agentProjectService.createProject(payload, {
creationKey: `workflow:${id}:${nodeId}`,
});
if (!result.success || !result.data) { if (!result.success || !result.data) {
if (isUnauthorizedApiError(result.error)) { if (isUnauthorizedApiError(result.error)) {
@ -10178,6 +10472,14 @@ export default function CanvasEditorLegacyPage({
] ]
); );
if (canvasLoadError && !isAgentMode) {
return <div role="alert" className="flex h-dvh flex-col items-center justify-center gap-4">
<p>{canvasLoadError}</p>
<Button onClick={() => setCanvasLoadAttempt(value => value + 1)}>重新加载画布</Button>
{loginDialogElement}
</div>;
}
if (isAgentMode) { if (isAgentMode) {
if (!hasActiveAgentProject) { if (!hasActiveAgentProject) {
return ( return (
@ -10281,6 +10583,7 @@ export default function CanvasEditorLegacyPage({
} }
return ( return (
<CanvasRemoteOperationContext.Provider value={canvasRemoteOperationContext}>
<TooltipProvider> <TooltipProvider>
<div <div
data-canvas-panel-open={isPanelOpen ? "true" : "false"} data-canvas-panel-open={isPanelOpen ? "true" : "false"}
@ -10296,6 +10599,30 @@ export default function CanvasEditorLegacyPage({
} }
> >
<CanvasAgentUiListener projectId={id} workspace="canvas" /> <CanvasAgentUiListener projectId={id} workspace="canvas" />
<Dialog open={Boolean(canvasLeaveUrl)} onOpenChange={open => { if (!open && !isSavingBeforeLeave) setCanvasLeaveUrl(""); }}>
<DialogContent onEscapeKeyDown={event => { if (isSavingBeforeLeave) event.preventDefault(); }} onPointerDownOutside={event => event.preventDefault()}>
<DialogHeader>
<DialogTitle>离开前保存画布?</DialogTitle>
<DialogDescription>平时会自动保存。当前还有改动尚未同步到云端,可以保存后离开,或放弃这些未同步的改动。</DialogDescription>
</DialogHeader>
{canvasLeaveError ? <p role="alert" className="text-sm text-destructive">{canvasLeaveError}</p> : null}
<DialogFooter className="gap-2">
<Button disabled={isSavingBeforeLeave} variant="ghost" onClick={() => setCanvasLeaveUrl("")}>继续编辑</Button>
<Button disabled={isSavingBeforeLeave} variant="outline" onClick={() => void finishCanvasLeave(false)}>不保存并离开</Button>
<Button disabled={isSavingBeforeLeave} onClick={() => void finishCanvasLeave(true)}>{isSavingBeforeLeave ? "正在保存…" : "保存并离开"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{canvasSyncConflicts.length > 0 ? (
<div role="alert" className="absolute inset-x-3 top-28 z-[60] mx-auto flex max-w-2xl flex-wrap items-center gap-3 rounded-lg border bg-background p-4 text-sm shadow-md">
<div className="min-w-0 flex-1 basis-64">
<p className="font-medium">画布同步发生冲突</p>
<p className="mt-1 text-muted-foreground">本页和云端的同一内容有不同修改。请选择保留哪边的冲突内容,其余图片和修改会合并保留。</p>
</div>
<Button disabled={isSaving} onClick={() => void resolveCanvasSyncConflict("local")}>保留本页修改</Button>
<Button disabled={isSaving} variant="outline" onClick={() => void resolveCanvasSyncConflict("remote")}>保留云端修改</Button>
</div>
) : null}
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */} {/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
<div <div
className="absolute inset-0 z-0" className="absolute inset-0 z-0"
@ -11005,14 +11332,12 @@ export default function CanvasEditorLegacyPage({
{isVideoMode ? brandEntry : ( {isVideoMode ? brandEntry : (
<CanvasSurfaceHeader <CanvasSurfaceHeader
isSaving={isSaving}
onAddImage={handleAddImageNode} onAddImage={handleAddImageNode}
onHome={goHome} onHome={() => requestCanvasLeave("/")}
onOpenFiles={() => { onOpenFiles={() => {
setIsPanelOpen(true); setIsPanelOpen(true);
setPanelMode("fileList"); setPanelMode("fileList");
}} }}
onSave={() => void handleSave()}
projectTitle={projectTitle} projectTitle={projectTitle}
rightInset={panelOcclusionRight} rightInset={panelOcclusionRight}
/> />
@ -11117,6 +11442,7 @@ export default function CanvasEditorLegacyPage({
createUrl={createNewCanvasUrl} createUrl={createNewCanvasUrl}
currentId={id} currentId={id}
onClose={() => setPanelMode("chat")} onClose={() => setPanelMode("chat")}
onNavigate={requestCanvasLeave}
workspaceLabel={workspaceLabel} workspaceLabel={workspaceLabel}
workspaceType={activeCanvasWorkspaceType} workspaceType={activeCanvasWorkspaceType}
/> />
@ -12079,5 +12405,6 @@ export default function CanvasEditorLegacyPage({
{projectLoadingBadge} {projectLoadingBadge}
</div> </div>
</TooltipProvider> </TooltipProvider>
</CanvasRemoteOperationContext.Provider>
); );
} }

View File

@ -48,8 +48,13 @@ export default function CanvasStyleProPage() {
const canvasData = result.data.canvasData; const canvasData = result.data.canvasData;
const latestState = useCanvasStore.getState(); const latestState = useCanvasStore.getState();
setProject(result.data.id, result.data.title); 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); loadCanvas(canvasData);
const revision = String(result.data.updatedAt || "");
useCanvasStore.getState().setCanvasRevision(revision, revision);
return; return;
} }

View File

@ -3,6 +3,7 @@ import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { FaviconUpdater } from "@/components/favicon-updater"; import { FaviconUpdater } from "@/components/favicon-updater";
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer"; import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import { AppUpdateNotice } from "@/components/app-update-notice";
import { getSiteSettings } from "@/lib/db/queries"; import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css"; import "./globals.css";
@ -215,6 +216,7 @@ export default function RootLayout({
enableSystem enableSystem
> >
<Toaster position="top-center" /> <Toaster position="top-center" />
<AppUpdateNotice />
<SessionProvider>{children}</SessionProvider> <SessionProvider>{children}</SessionProvider>
</ThemeProvider> </ThemeProvider>
</body> </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"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
@ -575,31 +576,11 @@ export function AgentGenerationWorkspace({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
const response = await fetch(raw); await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
if (!response.ok) { toast.success("原图已开始下载");
throw new Error(`download failed: ${response.status}`); } catch (error) {
} toast.error(getCanvasImageDownloadErrorMessage(error));
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("已打开原图,可右键另存为");
} }
}, []); }, []);

View File

@ -1,7 +1,10 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; 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 { 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 type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { import {
@ -4169,6 +4172,7 @@ export function AgentGenerationWorkspaceDarkV2({
? "agent-anchor-materials" ? "agent-anchor-materials"
: "agent-anchor-generate"; : "agent-anchor-generate";
const [finishedSceneRequestId, setFinishedSceneRequestId] = useState<string | null>(null);
const { const {
messages, messages,
setMessages, setMessages,
@ -4189,7 +4193,11 @@ export function AgentGenerationWorkspaceDarkV2({
void refreshHistory(); 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, experimental_throttle: 100,
generateId: generateUUID, generateId: generateUUID,
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
@ -4872,6 +4880,14 @@ export function AgentGenerationWorkspaceDarkV2({
return recoverReplacementRun({ messages, ...pendingGeneration }); return recoverReplacementRun({ messages, ...pendingGeneration });
}, [messages, pendingGeneration]); }, [messages, pendingGeneration]);
const transportIsGenerating = status === "streaming" || status === "submitted"; 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(() => { const pendingGenerationProgressCount = useMemo(() => {
if (!pendingGeneration) { if (!pendingGeneration) {
return 0; return 0;
@ -5482,6 +5498,17 @@ export function AgentGenerationWorkspaceDarkV2({
setActiveResultModeType(currentResultModeType); setActiveResultModeType(currentResultModeType);
}, [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(() => { useEffect(() => {
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return; if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
// The backend has settled every requested card; an open SSE connection is no longer work. // The backend has settled every requested card; an open SSE connection is no longer work.
@ -6776,31 +6803,11 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
const response = await fetch(raw); await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
if (!response.ok) { toast.success("原图已开始下载");
throw new Error(`download failed: ${response.status}`); } catch (error) {
} toast.error(getCanvasImageDownloadErrorMessage(error));
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("已打开原图,可右键另存为");
} }
}, []); }, []);
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => { const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
@ -725,30 +726,11 @@ export function AgentGenerationWorkspaceNew({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
const response = await fetch(raw); await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
if (!response.ok) { toast.success("原图已开始下载");
throw new Error(`download failed: ${response.status}`); } catch (error) {
} toast.error(getCanvasImageDownloadErrorMessage(error));
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("已打开原图,可右键另存为");
} }
}, []); }, []);

View File

@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
import { preparePageRefresh } from "@/lib/client/page-refresh";
export function AppUpdateNotice() {
const [availableVersion, setAvailableVersion] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
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)}>
<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

@ -1,9 +1,14 @@
"use client"; "use client";
import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { useStickToBottom } from "use-stick-to-bottom";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing"; import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress"; 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 { canvasHistoryForChat } from "@/lib/client/canvas-history-response";
import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run"; import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run";
import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress"; import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
@ -65,6 +70,8 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils"; 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 { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { import {
fetchUploadWithRetry, fetchUploadWithRetry,
@ -79,7 +86,8 @@ import { canvasService } from "@/services/canvas";
import { import {
inferOriginalImageUrl, inferOriginalImageUrl,
} from "@/lib/image-url"; } 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 { import {
isImageGenerationModelDescriptor, isImageGenerationModelDescriptor,
isVideoModelDescriptor, isVideoModelDescriptor,
@ -372,7 +380,9 @@ function MarkdownResponseFallback({
}: Pick<ResponseProps, "children" | "className">) { }: Pick<ResponseProps, "children" | "className">) {
return ( return (
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}> <div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
{children} {typeof children === "string"
? getGatewayErrorPageMessage(children) || children
: children}
</div> </div>
); );
} }
@ -1917,6 +1927,7 @@ type CanvasTypewriterTextProps = {
className?: string; className?: string;
renderMarkdown?: boolean; renderMarkdown?: boolean;
preserveMarkdownTables?: boolean; preserveMarkdownTables?: boolean;
isMarkdownStreaming?: boolean;
enablePdfDownload?: boolean; enablePdfDownload?: boolean;
pdfFileName?: string; pdfFileName?: string;
pdfSiteLogo?: string | null; pdfSiteLogo?: string | null;
@ -2357,20 +2368,6 @@ function getMarkdownTableBlockRanges(content: string): MarkdownTableBlockRange[]
return ranges; return ranges;
} }
function advanceTypewriterCursor(
contentLength: number,
targetCursor: number,
tableRanges: MarkdownTableBlockRange[]
) {
const clampedCursor = Math.min(contentLength, targetCursor);
for (const range of tableRanges) {
if (clampedCursor >= range.start && clampedCursor < range.end) {
return Math.min(contentLength, range.end);
}
}
return clampedCursor;
}
function CanvasTypewriterCursor() { function CanvasTypewriterCursor() {
return ( return (
<span <span
@ -2387,6 +2384,7 @@ function CanvasTypewriterText({
className, className,
renderMarkdown = false, renderMarkdown = false,
preserveMarkdownTables = false, preserveMarkdownTables = false,
isMarkdownStreaming = false,
enablePdfDownload = false, enablePdfDownload = false,
pdfFileName, pdfFileName,
pdfSiteLogo, pdfSiteLogo,
@ -2413,12 +2411,15 @@ function CanvasTypewriterText({
const tableRanges = preserveMarkdownTables const tableRanges = preserveMarkdownTables
? getMarkdownTableBlockRanges(content) ? getMarkdownTableBlockRanges(content)
: []; : [];
const atomicRanges = renderMarkdown
? [...tableRanges, ...scanMarkdownImageStreaming(content).ranges]
: tableRanges;
activeRef.current = true; activeRef.current = true;
onActiveChange?.(typingKey, true); onActiveChange?.(typingKey, true);
setDisplayText(""); setDisplayText("");
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
cursor = advanceTypewriterCursor(content.length, cursor + 1, tableRanges); cursor = advancePastAtomicRanges(content.length, cursor + 1, atomicRanges);
if (cursor >= content.length) { if (cursor >= content.length) {
setDisplayText(content); setDisplayText(content);
window.clearInterval(timer); window.clearInterval(timer);
@ -2439,7 +2440,7 @@ function CanvasTypewriterText({
onActiveChange?.(typingKey, false); onActiveChange?.(typingKey, false);
} }
}; };
}, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, typingKey]); }, [content, enabled, onActiveChange, onComplete, preserveMarkdownTables, renderMarkdown, typingKey]);
const showCursor = enabled && Boolean(content) && displayText.length < content.length; const showCursor = enabled && Boolean(content) && displayText.length < content.length;
@ -2448,6 +2449,7 @@ function CanvasTypewriterText({
<div className="w-full min-w-0"> <div className="w-full min-w-0">
<Response <Response
className={className} className={className}
isMarkdownStreaming={isMarkdownStreaming}
enablePdfDownload={enablePdfDownload} enablePdfDownload={enablePdfDownload}
pdfFileName={pdfFileName} pdfFileName={pdfFileName}
pdfSiteLogo={pdfSiteLogo} pdfSiteLogo={pdfSiteLogo}
@ -3245,6 +3247,8 @@ export function CanvasChatPanel({
onRequireLogin, onRequireLogin,
onBeforeAgentRequest, onBeforeAgentRequest,
}: CanvasChatPanelProps) { }: CanvasChatPanelProps) {
const runCanvasRemoteOperation = useCanvasRemoteOperation();
const applyCanvasRemoteSnapshot = useCanvasRemoteSnapshot();
const isWorkspacePresentation = presentation === "workspace"; const isWorkspacePresentation = presentation === "workspace";
const isVideoWorkspace = workspaceVariant === "video"; const isVideoWorkspace = workspaceVariant === "video";
const propCanvasToolbarConfig = useMemo( const propCanvasToolbarConfig = useMemo(
@ -3343,6 +3347,7 @@ export function CanvasChatPanel({
const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId); const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId);
const activeMessagesRef = useRef<ChatMessage[]>([]); const activeMessagesRef = useRef<ChatMessage[]>([]);
const inFlightChatIdRef = useRef<string | null>(null); const inFlightChatIdRef = useRef<string | null>(null);
const imageGenerationUserMessageIdsRef = useRef<Record<string, string>>({});
const inFlightRawChatIdRef = useRef<string | null>(null); const inFlightRawChatIdRef = useRef<string | null>(null);
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]); const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false); const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
@ -3364,15 +3369,15 @@ export function CanvasChatPanel({
const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]); const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]);
const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null); const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null);
const suppressSkillTagClickRef = useRef(false); const suppressSkillTagClickRef = useRef(false);
const [messageViewportKey, setMessageViewportKey] = useState(0); const { scrollRef: messagesScrollRef, contentRef: messagesContentRef, scrollToBottom: scrollMessagesToBottom, isAtBottom: isMessageListAtBottom } = useStickToBottom({ initial: false, resize: "instant" });
const messagesEndRef = useRef<HTMLDivElement>(null); const initialScrollChatRef = useRef<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const clawdFolderInputRef = useRef<HTMLInputElement | null>(null); const clawdFolderInputRef = useRef<HTMLInputElement | null>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]); const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const detailModeSwitchRef = useRef(false); const detailModeSwitchRef = useRef(false);
const hiddenPromptRef = useRef<string | null>(null); const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null); const autoContextRef = useRef<string | null>(null);
@ -3401,7 +3406,6 @@ export function CanvasChatPanel({
const hasInitializedVideoWorkspaceRef = useRef(false); const hasInitializedVideoWorkspaceRef = useRef(false);
const sessionMenuRef = useRef<HTMLDivElement>(null); const sessionMenuRef = useRef<HTMLDivElement>(null);
const clawdHistoryMenuRef = useRef<HTMLDivElement>(null); const clawdHistoryMenuRef = useRef<HTMLDivElement>(null);
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const forceHistoryHydrationRef = useRef<string | null>(null); const forceHistoryHydrationRef = useRef<string | null>(null);
const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null); const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null);
const composerUserEditedAtRef = useRef(0); const composerUserEditedAtRef = useRef(0);
@ -3438,6 +3442,7 @@ export function CanvasChatPanel({
const [fullscreenPreview, setFullscreenPreview] = useState<{ const [fullscreenPreview, setFullscreenPreview] = useState<{
url: string; url: string;
alt?: string; alt?: string;
preview: LoadedImagePreview;
} | null>(null); } | null>(null);
const [downloadProgress, setDownloadProgress] = const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null); useState<CanvasImageDownloadProgress | null>(null);
@ -3475,16 +3480,6 @@ export function CanvasChatPanel({
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState(""); const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState(""); const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
const downloadAbortControllerRef = useRef<AbortController | null>(null); 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(() => { useEffect(() => {
return () => { return () => {
if (composerUploadClearTimeoutRef.current) { if (composerUploadClearTimeoutRef.current) {
@ -3654,17 +3649,20 @@ export function CanvasChatPanel({
const setStreamImageGenerationStateForChat = useCallback( const setStreamImageGenerationStateForChat = useCallback(
(chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => { (chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => {
const userMessageId = imageGenerationUserMessageIdsRef.current[chatSessionId];
if (next?.userMessageId && userMessageId && next.userMessageId !== userMessageId) return;
setStreamImageGenerationStateByChatId((prev) => ({ setStreamImageGenerationStateByChatId((prev) => ({
...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) => { setStreamImageGenerationStateByChatId((prev) => {
if (!(chatSessionId in prev)) return prev; if (!(chatSessionId in prev)) return prev;
if (preserveFailure && prev[chatSessionId]?.phase === "failed") return prev;
const next = { ...prev }; const next = { ...prev };
delete next[chatSessionId]; delete next[chatSessionId];
return next; return next;
@ -5037,6 +5035,15 @@ export function CanvasChatPanel({
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50)); 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 handleCopyUserMessage = useCallback(async (message: ChatMessage) => {
const payload = getUserMessageCopyPayload(message); const payload = getUserMessageCopyPayload(message);
if (!payload.text && payload.attachments.length === 0) { if (!payload.text && payload.attachments.length === 0) {
@ -6101,7 +6108,7 @@ export function CanvasChatPanel({
const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video") const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video")
? "agent" : rawComposerMode; ? "agent" : rawComposerMode;
const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent"; const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent";
const [imageAgentEnabled, setImageAgentEnabled] = useState(false); const [imageAgentEnabled, setImageAgentEnabled] = useState(true);
const imageAgentEnabledRef = useRef(imageAgentEnabled); const imageAgentEnabledRef = useRef(imageAgentEnabled);
imageAgentEnabledRef.current = imageAgentEnabled; imageAgentEnabledRef.current = imageAgentEnabled;
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video"; const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
@ -7984,8 +7991,12 @@ export function CanvasChatPanel({
const store = useCanvasStore.getState(); const store = useCanvasStore.getState();
store.markAgentOperationRefresh(); store.markAgentOperationRefresh();
if (applyCanvasRemoteSnapshot) {
if (!applyCanvasRemoteSnapshot({ projectId: targetProjectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt || "") })) return false;
} else {
store.loadCanvas(result.data.canvasData); store.loadCanvas(result.data.canvasData);
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || "")); store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
}
if (createdNodeIds.length > 0) { if (createdNodeIds.length > 0) {
const refreshedStore = useCanvasStore.getState(); const refreshedStore = useCanvasStore.getState();
refreshedStore.selectNodes(createdNodeIds); refreshedStore.selectNodes(createdNodeIds);
@ -8013,7 +8024,7 @@ export function CanvasChatPanel({
return false; return false;
} }
}, },
[applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate] [applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
); );
const { const {
@ -8030,6 +8041,8 @@ export function CanvasChatPanel({
api: "/api/chat", api: "/api/chat",
fetch: fetchWithErrorHandlers, fetch: fetchWithErrorHandlers,
async prepareSendMessagesRequest(request) { 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 // 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
const rawId = request.id.startsWith('canvas-') const rawId = request.id.startsWith('canvas-')
? request.id.replace('canvas-', '') ? request.id.replace('canvas-', '')
@ -8142,20 +8155,27 @@ export function CanvasChatPanel({
const payload: ImageGenerationStatusStreamPayload = const payload: ImageGenerationStatusStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return; if (!payload || typeof payload !== "object") return;
const userMessageId = imageGenerationUserMessageIdsRef.current[eventChatId];
if (payload.userMessageId && userMessageId && payload.userMessageId !== userMessageId) return;
if (payload.phase === "completed") { if (payload.phase === "completed") {
setStreamImageGenerationStateForChat(eventChatId, { setStreamImageGenerationStateForChat(eventChatId, {
phase: "finalizing", phase: "finalizing",
userMessageId: payload.userMessageId,
providerType: payload.providerType, providerType: payload.providerType,
modelName: getAgentSafeModelName(payload.modelName), modelName: getAgentSafeModelName(payload.modelName),
imageCount: Number(payload.imageCount || 0) || undefined, imageCount: Number(payload.imageCount || 0) || undefined,
}); });
window.setTimeout(() => { window.setTimeout(() => {
if (payload.userMessageId && imageGenerationUserMessageIdsRef.current[eventChatId] !== payload.userMessageId) return;
clearStreamImageGenerationStateForChat(eventChatId); clearStreamImageGenerationStateForChat(eventChatId);
}, 30_000); }, 30_000);
return; return;
} }
setStreamImageGenerationStateForChat(eventChatId, { setStreamImageGenerationStateForChat(eventChatId, {
phase: payload.phase || "processing", phase: payload.phase || "processing",
userMessageId: payload.userMessageId,
requestId: payload.requestId,
failure: payload.failure,
providerType: payload.providerType, providerType: payload.providerType,
modelName: getAgentSafeModelName(payload.modelName), modelName: getAgentSafeModelName(payload.modelName),
imageCount: Number(payload.imageCount || 0) || undefined, imageCount: Number(payload.imageCount || 0) || undefined,
@ -8423,6 +8443,11 @@ export function CanvasChatPanel({
) )
: false; : false;
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : []; 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) const normalizedAttachments = Array.isArray(messageData.attachments)
? messageData.attachments ? messageData.attachments
: []; : [];
@ -8491,7 +8516,7 @@ export function CanvasChatPanel({
(hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) && (hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) &&
!shouldDeferClearForActiveMedia !shouldDeferClearForActiveMedia
) { ) {
clearStreamImageGenerationStateForChat(eventChatId); clearStreamImageGenerationStateForChat(eventChatId, true);
} }
if (!isEventForActiveChat) { if (!isEventForActiveChat) {
@ -8817,12 +8842,6 @@ export function CanvasChatPanel({
hasHydratedHistoryRef.current = true; hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(dbMessages)); setMessages(sortCanvasMessagesByCreatedAt(dbMessages));
if (dbMessages.length > 0) {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
}
}; };
const reloadMessagesOnce = async () => { const reloadMessagesOnce = async () => {
@ -8935,7 +8954,7 @@ export function CanvasChatPanel({
onRunChange: (run) => { onRunChange: (run) => {
// The live stream owns its message; polling only recovers disconnected replies. // The live stream owns its message; polling only recovers disconnected replies.
if (status === "submitted" || status === "streaming") return; 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. // Recover the same assistant reply after a stream disconnect or refresh.
setMessages(current => { setMessages(current => {
const index = current.findIndex(message => message.id === run.assistantMessageId); const index = current.findIndex(message => message.id === run.assistantMessageId);
@ -8950,6 +8969,33 @@ export function CanvasChatPanel({
}); });
const activeCanvasAgentRun = canvasAgentTask.run; const activeCanvasAgentRun = canvasAgentTask.run;
const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running"; 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( const restorePendingClawdRequestToComposer = useCallback(
(snapshot: PendingClawdRequestRestoreSnapshot) => { (snapshot: PendingClawdRequestRestoreSnapshot) => {
@ -9093,6 +9139,7 @@ export function CanvasChatPanel({
}, },
} as ChatMessage; } as ChatMessage;
void scrollMessagesToBottom({ animation: "instant" });
sendMessage(launchUserMessage as any); sendMessage(launchUserMessage as any);
setMessages((previousMessages) => setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([ sortCanvasMessagesByCreatedAt([
@ -9112,7 +9159,7 @@ export function CanvasChatPanel({
if (editableDiv) { if (editableDiv) {
editableDiv.innerHTML = ""; 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>(""); const loadedHistorySignatureRef = useRef<string>("");
@ -9132,7 +9179,6 @@ export function CanvasChatPanel({
hiddenPromptRef.current = null; hiddenPromptRef.current = null;
autoContextRef.current = null; autoContextRef.current = null;
pendingLaunchRef.current = null; pendingLaunchRef.current = null;
setMessageViewportKey((prev) => prev + 1);
setMessages([]); setMessages([]);
setInput(""); setInput("");
setAttachments([]); setAttachments([]);
@ -9147,11 +9193,20 @@ export function CanvasChatPanel({
}, [activeChatId, setMessages]); }, [activeChatId, setMessages]);
useEffect(() => { useEffect(() => {
if (historyScrollTimerRef.current) { const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId);
clearTimeout(historyScrollTimerRef.current); if (!draft || (input && input !== draft.text)) return;
historyScrollTimerRef.current = null; 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; if (!historyData || !rawChatId) return;
const historyMessages = historyData.messages || []; const historyMessages = historyData.messages || [];
const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage); const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage);
@ -9192,29 +9247,14 @@ export function CanvasChatPanel({
missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages
); );
}); });
setMessageViewportKey((prev) => prev + 1);
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature); 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]); }, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]);
useEffect(() => { useEffect(() => {
const streamState = streamImageGenerationStateByChatId[activeChatId]; const streamState = streamImageGenerationStateByChatId[activeChatId];
if (!rawChatId || !streamState) return; if (!rawChatId || !streamState || (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown")) return;
let cancelled = false; let cancelled = false;
const poll = async () => { const poll = async () => {
@ -9238,6 +9278,13 @@ export function CanvasChatPanel({
const streamState = streamImageGenerationStateByChatId[activeChatId]; const streamState = streamImageGenerationStateByChatId[activeChatId];
if (!streamState || messages.length === 0) return; 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] const lastUserIndex = [...messages]
.map((message) => message.role) .map((message) => message.role)
.lastIndexOf("user"); .lastIndexOf("user");
@ -9254,8 +9301,7 @@ export function CanvasChatPanel({
.join("\n"); .join("\n");
return ( return (
extractAssistantImageUrls(textContent).length > 0 || extractAssistantImageUrls(textContent).length > 0 ||
extractVideoUrls(textContent).length > 0 || extractVideoUrls(textContent).length > 0
/失败|超时|重试|error|failed/i.test(textContent)
); );
}); });
@ -9276,6 +9322,7 @@ export function CanvasChatPanel({
messages, messages,
streamImageGenerationStateByChatId, streamImageGenerationStateByChatId,
clearStreamImageGenerationStateForChat, clearStreamImageGenerationStateForChat,
setStreamImageGenerationStateForChat,
]); ]);
useEffect(() => { useEffect(() => {
@ -9782,11 +9829,13 @@ export function CanvasChatPanel({
}); });
}, [activeChatSessionMode, messages, nodes, updateNode]); }, [activeChatSessionMode, messages, nodes, updateNode]);
// 滚动到底部 // Open each conversation at its latest message; later updates respect manual scrolling.
useEffect(() => { useEffect(() => {
if (messages.length === 0 && !isClawdSubmitting) return; if (messages.length === 0 && !isClawdSubmitting) return;
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); if (initialScrollChatRef.current === activeChatId) return;
}, [isClawdSubmitting, messages]); initialScrollChatRef.current = activeChatId;
void scrollMessagesToBottom({ animation: "instant" });
}, [activeChatId, isClawdSubmitting, messages.length, scrollMessagesToBottom]);
// 标记是否已经为当前画布设置过标题(避免重复调用) // 标记是否已经为当前画布设置过标题(避免重复调用)
const hasSetCanvasTitle = useRef(false); const hasSetCanvasTitle = useRef(false);
@ -12555,6 +12604,7 @@ export function CanvasChatPanel({
const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim(); const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim();
if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) { if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) {
if (supplement && await canvasAgentTask.control("steer", supplement)) { if (supplement && await canvasAgentTask.control("steer", supplement)) {
void scrollMessagesToBottom({ animation: "instant" });
setInput(""); setInput("");
const editor = textareaRef.current as HTMLDivElement | null; const editor = textareaRef.current as HTMLDivElement | null;
if (editor) editor.innerHTML = ""; if (editor) editor.innerHTML = "";
@ -12852,6 +12902,7 @@ export function CanvasChatPanel({
requestChatId, requestChatId,
clawdRequestRestoreSnapshot clawdRequestRestoreSnapshot
); );
void scrollMessagesToBottom({ animation: "instant" });
startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint); startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint);
setMessages((previousMessages) => setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([ sortCanvasMessagesByCreatedAt([
@ -13340,6 +13391,26 @@ export function CanvasChatPanel({
}); });
inFlightChatIdRef.current = activeChatId; inFlightChatIdRef.current = activeChatId;
inFlightRawChatIdRef.current = rawChatId; 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); clearStreamImageGenerationStateForChat(activeChatId);
clearAgentProgressStateForChat(activeChatId); clearAgentProgressStateForChat(activeChatId);
if (shouldShowOptimisticImageGeneration) { if (shouldShowOptimisticImageGeneration) {
@ -13364,6 +13435,7 @@ export function CanvasChatPanel({
// 使用与 multimodal-input 相同的格式发送消息 // 使用与 multimodal-input 相同的格式发送消息
try { try {
void scrollMessagesToBottom({ animation: "instant" });
sendMessage(optimisticUserMessage as any); sendMessage(optimisticUserMessage as any);
setMessages((previousMessages) => setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([ sortCanvasMessagesByCreatedAt([
@ -13402,6 +13474,9 @@ export function CanvasChatPanel({
} }
}, [ }, [
isCanvasAgentRunning, isCanvasAgentRunning,
scrollMessagesToBottom,
onBeforeAgentRequest,
isVideoWorkspace,
canvasAgentTask.control, canvasAgentTask.control,
activeChatId, activeChatId,
activeComposerMode, activeComposerMode,
@ -13539,23 +13614,19 @@ export function CanvasChatPanel({
console.log('[CanvasChat] 更新画布:', rawId); console.log('[CanvasChat] 更新画布:', rawId);
const updateResponse = await fetch(`/api/canvas/${rawId}`, { if (!runCanvasRemoteOperation) return;
method: 'PUT', await runCanvasRemoteOperation(async () => {
headers: { 'Content-Type': 'application/json' }, if (useCanvasStore.getState().projectId !== rawId) return;
body: JSON.stringify({ const result = await canvasService.updateProject(rawId, { title, thumbnail: firstImageUrl });
title, if (!result.success) throw new Error(result.error || "画布名称同步失败");
thumbnail: firstImageUrl,
}),
}); });
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
} else { } else {
console.error('[CanvasChat] auto-title API 请求失败:', response.status); console.error('[CanvasChat] auto-title API 请求失败:', response.status);
} }
} catch (error) { } catch (error) {
console.error('[CanvasChat] 自动命名失败:', error); console.error('[CanvasChat] 自动命名失败:', error);
} }
}, [chatId]); }, [chatId, runCanvasRemoteOperation]);
const addUploadedImageAttachmentToComposer = useCallback( const addUploadedImageAttachmentToComposer = useCallback(
(uploaded: ComposerUploadedImage) => { (uploaded: ComposerUploadedImage) => {
@ -15109,7 +15180,7 @@ export function CanvasChatPanel({
!hasGenerationEnd && !hasGenerationEnd &&
Boolean(currentModel || activeComposerMode === "agent")); Boolean(currentModel || activeComposerMode === "agent"));
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0; 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 loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
const loadingProviderType = const loadingProviderType =
streamImageGenerationState?.providerType || streamImageGenerationState?.providerType ||
@ -15172,9 +15243,9 @@ export function CanvasChatPanel({
phase === "queued" phase === "queued"
? "等待开始生成" ? "等待开始生成"
: phase === "finalizing" : phase === "finalizing"
? "正在润饰细节" ? "正在保存生成结果"
: phase === "failed" : phase === "failed"
? "生成失败" ? streamState.failure?.message || "生成失败"
: "正在创建图片"; : "正在创建图片";
const status = phase === "failed" ? "failed" : "generating"; const status = phase === "failed" ? "failed" : "generating";
const currentNodes = useCanvasStore.getState().nodes; const currentNodes = useCanvasStore.getState().nodes;
@ -15230,14 +15301,10 @@ export function CanvasChatPanel({
canvasGenerationPlaceholder: true, canvasGenerationPlaceholder: true,
chatId: activeChatId, chatId: activeChatId,
detailProgressLabel: label, detailProgressLabel: label,
generationProgress: generationProgress: undefined,
phase === "queued" generationFailure: streamState.failure,
? 12 generationRequestId: streamState.requestId,
: phase === "finalizing" generationUserMessageId: streamState.userMessageId,
? 96
: phase === "failed"
? 100
: undefined,
modelName: streamState.modelName || currentModel?.displayName || undefined, modelName: streamState.modelName || currentModel?.displayName || undefined,
providerType: streamState.providerType || currentModel?.providerType || undefined, providerType: streamState.providerType || currentModel?.providerType || undefined,
placementMode: "anchored-generation", placementMode: "anchored-generation",
@ -15331,6 +15398,7 @@ export function CanvasChatPanel({
if ( if (
message.role === "assistant" && message.role === "assistant" &&
!rawParts.some((part: any) => part.type === "data-imageGenerationStatus" && part.data?.failure) &&
isInternalCanvasImageGenerationFailureText(cleanedParts) isInternalCanvasImageGenerationFailureText(cleanedParts)
) { ) {
return acc; return acc;
@ -15640,7 +15708,7 @@ export function CanvasChatPanel({
processedMessageIds.current.clear(); processedMessageIds.current.clear();
hasHydratedHistoryRef.current = true; hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(fresh.messages)); setMessages(sortCanvasMessagesByCreatedAt(fresh.messages));
setMessageViewportKey((previous) => previous + 1); void scrollMessagesToBottom({ animation: "instant" });
}).catch((error) => { }).catch((error) => {
console.error("[CanvasChat] Failed to reopen current conversation:", error); console.error("[CanvasChat] Failed to reopen current conversation:", error);
toast.error("聊天记录暂时读取失败,请重试"); toast.error("聊天记录暂时读取失败,请重试");
@ -15654,7 +15722,7 @@ export function CanvasChatPanel({
replaceChatSessionQuery(sessionId); replaceChatSessionQuery(sessionId);
setIsSessionMenuOpen(false); setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口"); toast.success("已切换聊天窗口");
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, setMessages, status]); }, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, scrollMessagesToBottom, setMessages, status]);
const switchToClawdHistorySession = useCallback( const switchToClawdHistorySession = useCallback(
(historyItem: ClawdHistoryItem) => { (historyItem: ClawdHistoryItem) => {
@ -15816,8 +15884,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) { if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
event.currentTarget.src = activeClawdSkillTag.iconUrl || ""; event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
} }
}} }}
/> />
@ -16071,11 +16139,17 @@ export function CanvasChatPanel({
{isDetailEmptyState ? ( {isDetailEmptyState ? (
<div className="flex-1 min-h-0" /> <div className="flex-1 min-h-0" />
) : ( ) : (
<div key={activeChatId} className="relative flex min-h-0 flex-1 flex-col">
<div <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( className={cn(
"flex-1 min-h-0 overflow-x-hidden", "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-y-auto", isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-auto",
isWorkspacePresentation ? "p-5" : "p-3", isWorkspacePresentation ? "p-5" : "p-3",
isPlainCanvasEmptyState isPlainCanvasEmptyState
? "pt-0" ? "pt-0"
@ -16084,7 +16158,7 @@ export function CanvasChatPanel({
: "pt-3" : "pt-3"
)} )}
> >
<div className={cn( <div ref={messagesContentRef} className={cn(
"space-y-5 overflow-x-hidden pr-1", "space-y-5 overflow-x-hidden pr-1",
isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24" isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24"
)}> )}>
@ -16135,8 +16209,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== attachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = attachment.url; event.currentTarget.src = buildImageFallbackUrl(attachment.url);
} }
}} }}
/> />
@ -16212,8 +16286,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== attachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = attachment.url; event.currentTarget.src = buildImageFallbackUrl(attachment.url);
} }
}} }}
/> />
@ -16398,8 +16472,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== tag.iconUrl) { if (event.currentTarget.src !== buildImageFallbackUrl(tag.iconUrl)) {
event.currentTarget.src = tag.iconUrl || ""; event.currentTarget.src = buildImageFallbackUrl(tag.iconUrl);
} }
}} }}
/> />
@ -16577,6 +16651,10 @@ export function CanvasChatPanel({
(isClawdMode || isClawdChatMessage(message)); (isClawdMode || isClawdChatMessage(message));
const isLatestClawAssistantMessage = const isLatestClawAssistantMessage =
message.id === latestClawAssistantMessageId; message.id === latestClawAssistantMessageId;
const isMessageMarkdownStreaming =
message.role === "assistant" &&
index === renderableVisibleMessages.length - 1 &&
(isLoading || (isClawAssistantMessage && isClawdSubmitting));
const openClawArtifacts = isClawAssistantMessage const openClawArtifacts = isClawAssistantMessage
? getOpenClawMessageArtifacts(message) ? getOpenClawMessageArtifacts(message)
: []; : [];
@ -16695,8 +16773,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== attachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = attachment.url; event.currentTarget.src = buildImageFallbackUrl(attachment.url);
} }
}} }}
/> />
@ -16904,6 +16982,7 @@ export function CanvasChatPanel({
<Response <Response
key={key} key={key}
className="break-words [overflow-wrap:anywhere]" className="break-words [overflow-wrap:anywhere]"
isMarkdownStreaming={isMessageMarkdownStreaming}
> >
{text} {text}
</Response> </Response>
@ -16965,6 +17044,7 @@ export function CanvasChatPanel({
className="break-words [overflow-wrap:anywhere]" className="break-words [overflow-wrap:anywhere]"
renderMarkdown renderMarkdown
preserveMarkdownTables={shouldTypeClawAssistantMessage} preserveMarkdownTables={shouldTypeClawAssistantMessage}
isMarkdownStreaming={isMessageMarkdownStreaming}
enablePdfDownload={message.role === "assistant" && !shouldAnimateAnalysis} enablePdfDownload={message.role === "assistant" && !shouldAnimateAnalysis}
pdfFileName={`Claw分析报告-${message.id}`} pdfFileName={`Claw分析报告-${message.id}`}
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null} pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
@ -16987,6 +17067,7 @@ export function CanvasChatPanel({
className="break-words [overflow-wrap:anywhere]" className="break-words [overflow-wrap:anywhere]"
renderMarkdown renderMarkdown
preserveMarkdownTables={shouldTypeClawAssistantMessage} preserveMarkdownTables={shouldTypeClawAssistantMessage}
isMarkdownStreaming={isMessageMarkdownStreaming}
enablePdfDownload={message.role === "assistant" && !shouldAnimatePlainText} enablePdfDownload={message.role === "assistant" && !shouldAnimatePlainText}
pdfFileName={`Claw分析报告-${message.id}`} pdfFileName={`Claw分析报告-${message.id}`}
pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null} pdfSiteLogo={siteSettings?.siteLogo || siteSettings?.siteLogoDark || null}
@ -17029,15 +17110,23 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== url) { if (event.currentTarget.src !== buildImageFallbackUrl(url)) {
event.currentTarget.src = 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) => { onDoubleClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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"> <div className="absolute bottom-2 right-2 flex items-center gap-1.5">
@ -17272,9 +17361,22 @@ export function CanvasChatPanel({
/> />
) : null} ) : null}
<div ref={messagesEndRef} />
</div> </div>
</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( {fullscreenPreview && createPortal(
@ -17283,7 +17385,7 @@ export function CanvasChatPanel({
onClick={() => setFullscreenPreview(null)} onClick={() => setFullscreenPreview(null)}
> >
<button <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)} onClick={() => setFullscreenPreview(null)}
aria-label="关闭预览" aria-label="关闭预览"
> >
@ -17292,7 +17394,7 @@ export function CanvasChatPanel({
</svg> </svg>
</button> </button>
<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} disabled={isDownloadingOriginalImage}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
@ -17309,13 +17411,11 @@ export function CanvasChatPanel({
{isDownloadingOriginalImage ? "下载中" : "下载原图"} {isDownloadingOriginalImage ? "下载中" : "下载原图"}
</button> </button>
<img <ProgressiveImagePreview
src={fullscreenPreviewSrc} src={inferOriginalImageUrl(fullscreenPreview.url) || fullscreenPreview.url}
preview={fullscreenPreview.preview}
alt={fullscreenPreview.alt || "图片预览"} alt={fullscreenPreview.alt || "图片预览"}
className="max-w-[95vw] max-h-[95vh] object-contain" 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"> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
@ -17882,8 +17982,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = slotAttachment.url; event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
} }
}} }}
/> />
@ -18207,8 +18307,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) { if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
event.currentTarget.src = activeClawdSkillTag.iconUrl || ""; event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
} }
}} }}
/> />
@ -18467,8 +18567,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== item.url) { if (event.currentTarget.src !== buildImageFallbackUrl(item.url)) {
event.currentTarget.src = item.url; event.currentTarget.src = buildImageFallbackUrl(item.url);
} }
}} }}
/> />
@ -18523,7 +18623,7 @@ export function CanvasChatPanel({
)} )}
> >
<div <div
ref={textareaRef as any} ref={setComposerEditorRef}
contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)} contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)}
role="textbox" role="textbox"
tabIndex={isSubmitting ? -1 : 0} tabIndex={isSubmitting ? -1 : 0}
@ -18557,6 +18657,7 @@ export function CanvasChatPanel({
setTagPreview(null); setTagPreview(null);
}} }}
onInput={(e) => { onInput={(e) => {
clearCanvasComposerDraft(activeChatId);
// 从 contenteditable 中提取纯文本(排除附件标签文本) // 从 contenteditable 中提取纯文本(排除附件标签文本)
const target = e.target as HTMLDivElement; const target = e.target as HTMLDivElement;
const clone = target.cloneNode(true) as HTMLDivElement; const clone = target.cloneNode(true) as HTMLDivElement;
@ -18652,8 +18753,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = slotAttachment.url; event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
} }
}} }}
/> />
@ -18720,8 +18821,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = slotAttachment.url; event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
} }
}} }}
/> />
@ -19420,8 +19521,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== tagPreview.url) { if (event.currentTarget.src !== buildImageFallbackUrl(tagPreview.url)) {
event.currentTarget.src = tagPreview.url; event.currentTarget.src = buildImageFallbackUrl(tagPreview.url);
} }
}} }}
/> />

View File

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

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 { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Loader2, Save } from "lucide-react";
type CanvasSurfaceHeaderProps = { type CanvasSurfaceHeaderProps = {
isSaving: boolean;
onAddImage: () => void; onAddImage: () => void;
onHome: () => void; onHome: () => void;
onOpenFiles: () => void; onOpenFiles: () => void;
onSave: () => void;
projectTitle: string; projectTitle: string;
rightInset: number; 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"; "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({ export function CanvasSurfaceHeader({
isSaving,
onAddImage, onAddImage,
onHome, onHome,
onOpenFiles, onOpenFiles,
onSave,
projectTitle, projectTitle,
rightInset, rightInset,
}: CanvasSurfaceHeaderProps) { }: CanvasSurfaceHeaderProps) {
@ -77,19 +71,6 @@ export function CanvasSurfaceHeader({
<ReferenceIcon name="plus" size={16} /> <ReferenceIcon name="plus" size={16} />
</button> </button>
</div> </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> </div>
); );
} }

View File

@ -152,16 +152,7 @@ export function shouldRetryCanvasViewOperationAfterRefresh(
); );
} }
export type ImageGenerationStatusStreamPayload = { export type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
providerType?: string;
modelName?: string;
imageCount?: number;
queuePosition?: number;
sameModelRunningCount?: number;
concurrencyLimit?: number;
estimatedWaitMs?: number;
};
export type AgentTargetLockRefPreview = { export type AgentTargetLockRefPreview = {
url: string; 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) { while ((match = markdownLinkPattern.exec(text)) !== null) {
const label = String(match[1] || ""); const label = String(match[1] || "");
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label) 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[\s\S]*?<\/audio>/gi, "")
.replace(/<audio\b[^>]*\/?\s*>/gi, "") .replace(/<audio\b[^>]*\/?\s*>/gi, "")
.replace(/<a[\s\S]*?<\/a>/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( const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
String(label || "") String(label || "")
); );

View File

@ -6,6 +6,8 @@ import { ArrowUp, MessageSquare, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel"; 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 { import {
videoAgentSettingsSchema, videoAgentSettingsSchema,
type VideoAgentSettings, type VideoAgentSettings,
@ -94,6 +96,22 @@ export function VideoCanvasAgentPanel(props: {
}; };
}, [props.projectId, setMessages]); }, [props.projectId, setMessages]);
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming"; 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( const submit = useCallback(
async (resumeRunId?: string) => { async (resumeRunId?: string) => {
if (busy || loading) return; if (busy || loading) return;
@ -105,6 +123,7 @@ export function VideoCanvasAgentPanel(props: {
try { try {
await latest.current.props.beforeSend(); await latest.current.props.beforeSend();
if (!mounted.current) return; if (!mounted.current) return;
clearCanvasComposerDraft(`video-agent-${props.projectId}`);
resumeRef.current = resumeRunId; resumeRef.current = resumeRunId;
setInput(""); setInput("");
await sendMessage({ text }); await sendMessage({ text });
@ -298,7 +317,7 @@ export function VideoCanvasAgentPanel(props: {
<textarea <textarea
aria-label="视频创作要求" aria-label="视频创作要求"
value={input} value={input}
onChange={(event) => setInput(event.target.value)} onChange={(event) => { clearCanvasComposerDraft(`video-agent-${props.projectId}`); setInput(event.target.value); }}
maxLength={2000} maxLength={2000}
rows={3} rows={3}
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频" placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"

View File

@ -1,4 +1,7 @@
"use client"; "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 { import {
Box, Box,
@ -59,6 +62,7 @@ import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar"; import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog"; import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
import { import {
areImageAdjustmentsEqual, areImageAdjustmentsEqual,
buildImageAdjustmentFilterStyle, buildImageAdjustmentFilterStyle,
@ -185,7 +189,7 @@ import {
normalizeDetailUiSubjectAnalysis, normalizeDetailUiSubjectAnalysis,
} from "@/lib/detail-ui-subject-analysis"; } from "@/lib/detail-ui-subject-analysis";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
import { import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId, getDefaultThreeDModelAliasId,
@ -231,6 +235,8 @@ import {
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors"; import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state"; import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; 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 { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types"; import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
@ -1708,6 +1714,7 @@ export function ImageNode({
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false); const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState<LoadedImagePreview | null>(null);
const [downloadProgress, setDownloadProgress] = const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null); useState<CanvasImageDownloadProgress | null>(null);
const downloadAbortControllerRef = useRef<AbortController | null>(null); const downloadAbortControllerRef = useRef<AbortController | null>(null);
@ -2077,7 +2084,7 @@ export function ImageNode({
const selectNode = useCanvasStore((state) => state.selectNode); const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId); const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState); const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const loadCanvas = useCanvasStore((state) => state.loadCanvas); const runCanvasRemoteOperation = useCanvasRemoteOperation();
const addPendingChatAttachment = useCanvasStore( const addPendingChatAttachment = useCanvasStore(
(state) => state.addPendingChatAttachment (state) => state.addPendingChatAttachment
); );
@ -2497,13 +2504,13 @@ export function ImageNode({
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05; const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640; const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600; const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
return Math.max( return canvasImagePreviewWidth(Math.max(
minWidth, minWidth,
Math.min( Math.min(
maxWidth, maxWidth,
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier) Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
) )
); ), maxWidth);
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]); }, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88; const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
const displayImageSrc = useMemo( const displayImageSrc = useMemo(
@ -4678,106 +4685,44 @@ export function ImageNode({
return; return;
} }
setIsCreatingDetachedResultNode(true); if (!projectId || !runCanvasRemoteOperation) {
toast.error("画布还在加载,请稍后再试");
return;
}
setIsCreatingDetachedResultNode(false);
setIsProcessing(true); setIsProcessing(true);
const actionName = "框选抠图"; 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 { try {
const result = await imageGenerationService.boxCutout({ const command = createCanvasCapabilityClientCommand({
imageUrl: data.src, capabilityId: "image.box_cutout",
cropRect: boxCutoutSelection, nodeIds: [node.id],
projectId,
source: "toolbar",
args: {
...boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, 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",
},
}, },
}); });
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
toast.success(`${actionName}完成`); 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) { } catch (error) {
console.error("框选抠图失败:", error); console.error("框选抠图失败:", error);
updateNode(newNodeId, { toast.error(error instanceof Error ? error.message : `${actionName}失败`);
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(`${actionName}失败,请重试`);
} finally { } finally {
setIsCreatingDetachedResultNode(false); setIsCreatingDetachedResultNode(false);
setIsProcessing(false); setIsProcessing(false);
resetBoxCutoutState(); resetBoxCutoutState();
} }
}, [ }, [
addNode,
boxCutoutSelection, boxCutoutSelection,
data.src, data.src,
displayTitle,
isProcessing, isProcessing,
node.id, node.id,
node.position.x, projectId,
node.position.y,
node.size.height,
node.size.width,
resetBoxCutoutState, resetBoxCutoutState,
updateNode, runCanvasRemoteOperation,
]); ]);
const animateAddToChat = useCallback(async (previewUrl?: string) => { const animateAddToChat = useCallback(async (previewUrl?: string) => {
@ -6707,7 +6652,7 @@ export function ImageNode({
const executeSharedToolbarCapability = async ( const executeSharedToolbarCapability = async (
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split" action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
) => { ) => {
if (!projectId) { if (!projectId || !runCanvasRemoteOperation) {
toast.error("画布还在加载,请稍后再试"); toast.error("画布还在加载,请稍后再试");
return; return;
} }
@ -6726,21 +6671,7 @@ export function ImageNode({
projectId, projectId,
source: "toolbar", source: "toolbar",
}); });
const response = await fetch(`/api/canvas/${projectId}/capabilities`, { await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
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);
toast.success(`${capability.title}完成`); toast.success(`${capability.title}完成`);
} catch (error) { } catch (error) {
const message = const message =
@ -8439,7 +8370,11 @@ export function ImageNode({
{/* 图片内容 - 使用 object-contain 保持原始比例 */} {/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? ( {data.src ? (
<div className="relative h-full w-full"> <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 /> <ImageNodeDeferredLoadState />
) : isCompareMode && hasCompareSource ? ( ) : isCompareMode && hasCompareSource ? (
<div <div
@ -8466,15 +8401,16 @@ export function ImageNode({
}} }}
> >
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" /> <div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<img <CanvasPreviewImage
sourceKey={data.src}
alt={displayTitle} alt={displayTitle}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="lazy" loading="eager"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== data.src) { if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
event.currentTarget.src = data.src; event.currentTarget.src = buildImageFallbackUrl(data.src);
} }
}} }}
onLoad={handleDisplayImageLoad} onLoad={handleDisplayImageLoad}
@ -8495,15 +8431,16 @@ export function ImageNode({
}} }}
> >
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" /> <div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<img <CanvasPreviewImage
sourceKey={compareSource}
alt={`${displayTitle}-原图`} alt={`${displayTitle}-原图`}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="lazy" loading="eager"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== compareSource) { if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
event.currentTarget.src = compareSource; event.currentTarget.src = buildImageFallbackUrl(compareSource);
} }
}} }}
src={displayCompareSrc} src={displayCompareSrc}
@ -8545,7 +8482,8 @@ export function ImageNode({
</div> </div>
) : ( ) : (
<> <>
<img <CanvasPreviewImage
sourceKey={data.src}
alt={displayTitle} alt={displayTitle}
className={cn( className={cn(
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900", "h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
@ -8557,7 +8495,7 @@ export function ImageNode({
)} )}
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="lazy" loading="eager"
onClick={ onClick={
isThisNodePicking && !boxCutoutMode isThisNodePicking && !boxCutoutMode
? handlePickerClick ? handlePickerClick
@ -8566,12 +8504,17 @@ export function ImageNode({
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) { if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
e.stopPropagation(); e.stopPropagation();
setFullscreenPreviewImage({
src: e.currentTarget.currentSrc || e.currentTarget.src,
width: e.currentTarget.naturalWidth,
height: e.currentTarget.naturalHeight,
});
setFullscreenPreviewOpen(true); setFullscreenPreviewOpen(true);
} }
}} }}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== data.src) { if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
event.currentTarget.src = data.src; event.currentTarget.src = buildImageFallbackUrl(data.src);
} }
}} }}
onLoad={handleDisplayImageLoad} onLoad={handleDisplayImageLoad}
@ -9482,6 +9425,8 @@ export function ImageNode({
) : ( ) : (
<ImageNodeEmptyState <ImageNodeEmptyState
detailProgressLabel={detailProgressLabel} detailProgressLabel={detailProgressLabel}
failure={data.metadata?.generationFailure}
requestId={data.metadata?.generationRequestId}
fileInputRef={fileInputRef} fileInputRef={fileInputRef}
generationProgress={generationProgress} generationProgress={generationProgress}
onImageUpload={handleImageUpload} onImageUpload={handleImageUpload}
@ -11971,6 +11916,7 @@ export function ImageNode({
{/* 双击放大预览弹窗 */} {/* 双击放大预览弹窗 */}
{fullscreenPreviewOpen && {fullscreenPreviewOpen &&
data.src && data.src &&
fullscreenPreviewImage &&
createPortal( createPortal(
<div <div
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90" className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
@ -11978,7 +11924,7 @@ export function ImageNode({
> >
{/* 关闭按钮 */} {/* 关闭按钮 */}
<button <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)} onClick={() => setFullscreenPreviewOpen(false)}
> >
<svg <svg
@ -11996,7 +11942,7 @@ export function ImageNode({
</svg> </svg>
</button> </button>
<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} disabled={isDownloadingOriginalImage}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
@ -12015,13 +11961,11 @@ export function ImageNode({
</button> </button>
{/* 图片 */} {/* 图片 */}
<img <ProgressiveImagePreview
alt={data.prompt || "图片预览"} alt={data.prompt || "图片预览"}
className="max-h-[95vh] max-w-[95vw] object-contain" className="max-h-[95vh] max-w-[95vw] object-contain"
decoding="async" src={resolveOriginalImageUrl(data.src) || data.src}
draggable={false} preview={fullscreenPreviewImage}
onClick={(e) => e.stopPropagation()}
src={fullscreenPreviewSrc}
style={activeImageAdjustmentsStyle} 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 { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
import type React from "react"; import type React from "react";
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder"; import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
import type { RequestFailureDetails } from "@/lib/request-error";
interface ImageNodeEmptyStateProps { interface ImageNodeEmptyStateProps {
detailProgressLabel?: string; detailProgressLabel?: string;
fileInputRef: React.Ref<HTMLInputElement>; fileInputRef: React.Ref<HTMLInputElement>;
generationProgress?: number | null; generationProgress?: number | null;
failure?: RequestFailureDetails;
requestId?: string;
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void; onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void; onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
status?: string; status?: string;
@ -52,6 +55,8 @@ export function ImageNodeEmptyState({
detailProgressLabel, detailProgressLabel,
fileInputRef, fileInputRef,
generationProgress, generationProgress,
failure,
requestId,
onImageUpload, onImageUpload,
onSelectFile, onSelectFile,
status, status,
@ -89,7 +94,7 @@ export function ImageNodeEmptyState({
if (status === "failed") { if (status === "failed") {
const failedTitle = detailProgressLabel || "生成失败"; const failedTitle = detailProgressLabel || "生成失败";
return ( 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)]"> <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" /> <AlertCircle className="h-5 w-5" />
</span> </span>
@ -97,8 +102,9 @@ export function ImageNodeEmptyState({
{failedTitle} {failedTitle}
</span> </span>
<span className="mt-1 text-xs text-[#b77480]"> <span className="mt-1 text-xs text-[#b77480]">
{failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"} {failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")}
</span> </span>
{requestId ? <span className="mt-2 select-text break-all text-xs text-[#b77480]">请求编号:{requestId}</span> : null}
</div> </div>
); );
} }

View File

@ -15,7 +15,10 @@ import {
splitOpenClawChartBlocks, splitOpenClawChartBlocks,
} from "@/components/elements/openclaw-chart-card"; } from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download"; 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 { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@ -26,6 +29,7 @@ import { cn } from "@/lib/utils";
type ResponseProps = ComponentProps<typeof Streamdown> & { type ResponseProps = ComponentProps<typeof Streamdown> & {
collapsibleImageAnalysis?: boolean; collapsibleImageAnalysis?: boolean;
isMarkdownStreaming?: boolean;
enablePdfDownload?: boolean; enablePdfDownload?: boolean;
pdfFileName?: string; pdfFileName?: string;
pdfSiteLogo?: string | null; pdfSiteLogo?: string | null;
@ -362,43 +366,6 @@ function normalizeMarkdownContent(value: string) {
.trim(); .trim();
} }
function splitMarkdownImages(content: string) {
const segments: Array<
| { type: "text"; value: string }
| { type: "image"; alt?: string; src: string }
> = [];
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = imageRegex.exec(content)) !== null) {
if (match.index > lastIndex) {
segments.push({
type: "text",
value: content.slice(lastIndex, match.index),
});
}
segments.push({
type: "image",
alt: match[1],
src: match[2],
});
lastIndex = imageRegex.lastIndex;
}
if (lastIndex < content.length) {
segments.push({
type: "text",
value: content.slice(lastIndex),
});
}
return segments;
}
function isLikelyImageUrl(value?: string | null) { function isLikelyImageUrl(value?: string | null) {
return IMAGE_URL_REGEX.test(String(value || "").trim()); return IMAGE_URL_REGEX.test(String(value || "").trim());
} }
@ -475,8 +442,8 @@ function MarkdownTableImagePreview({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== src) { if (event.currentTarget.src !== buildImageFallbackUrl(src)) {
event.currentTarget.src = src; event.currentTarget.src = buildImageFallbackUrl(src);
} }
}} }}
/> />
@ -506,8 +473,8 @@ function MarkdownTableImagePreview({
decoding="async" decoding="async"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== originalSrc) { if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) {
event.currentTarget.src = originalSrc; event.currentTarget.src = buildImageFallbackUrl(originalSrc);
} }
}} }}
/> />
@ -797,6 +764,7 @@ function ResponseReportTableDownloadButton({
export const Response = ({ export const Response = ({
className, className,
collapsibleImageAnalysis = false, collapsibleImageAnalysis = false,
isMarkdownStreaming = false,
enablePdfDownload = false, enablePdfDownload = false,
pdfFileName, pdfFileName,
pdfSiteLogo, pdfSiteLogo,
@ -1061,7 +1029,10 @@ export const Response = ({
const rawText = const rawText =
typeof props.children === "string" typeof props.children === "string"
? normalizeMarkdownContent(props.children) ? normalizeMarkdownContent(stableStreamingMarkdown(
getGatewayErrorPageMessage(props.children) || props.children,
isMarkdownStreaming
))
: null; : null;
const shouldShowPdfDownload = const shouldShowPdfDownload =
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText)); Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
@ -1091,7 +1062,7 @@ export const Response = ({
); );
}; };
if (rawText) { if (rawText !== null) {
const analysisSection = collapsibleImageAnalysis const analysisSection = collapsibleImageAnalysis
? extractImageAnalysisSection(rawText) ? extractImageAnalysisSection(rawText)
: null; : null;

View File

@ -1,6 +1,5 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react";
import { BloubAvatar } from "@/components/bloub-avatar"; import { BloubAvatar } from "@/components/bloub-avatar";
import type { StateId } from "@/lib/bloub-official/states"; import type { StateId } from "@/lib/bloub-official/states";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -26,193 +25,17 @@ interface GenerationDotPlaceholderProps {
const PHASE_TITLES: Record<GenerationPhase, string> = { const PHASE_TITLES: Record<GenerationPhase, string> = {
idle: "正在创建图片", idle: "正在创建图片",
queued: "等待开始生成", queued: "等待开始生成",
processing: "生成初稿中", processing: "等待模型返回图片",
finalizing: "正在润饰细节", finalizing: "正在保存生成结果",
failed: "生成失败", failed: "生成失败",
completed: "生成完成", 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) { function clampProgress(value: number) {
if (!Number.isFinite(value)) return 0; if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value))); 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 { function getBloubState(phase: GenerationPhase): StateId {
if (phase === "failed") return "alert"; if (phase === "failed") return "alert";
if (phase === "completed") return "wink"; if (phase === "completed") return "wink";
@ -228,39 +51,10 @@ export function GenerationDotPlaceholder({
showProgress = true, showProgress = true,
title, title,
}: GenerationDotPlaceholderProps) { }: GenerationDotPlaceholderProps) {
const [elapsedMs, setElapsedMs] = useState(0); const resolvedProgress = typeof progress === "number" && Number.isFinite(progress)
? clampProgress(progress) : null;
useEffect(() => { const resolvedTitle = title || PHASE_TITLES[phase];
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 playSequence = phase === "processing" || phase === "finalizing"; const playSequence = phase === "processing" || phase === "finalizing";
const progressStyle = useMemo(
() => ({ width: `${resolvedProgress}%` }),
[resolvedProgress]
);
return ( return (
<div <div
@ -281,10 +75,6 @@ export function GenerationDotPlaceholder({
)} )}
> >
{resolvedTitle} {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>
<div className="flex min-h-0 flex-1 items-center justify-center py-3"> <div className="flex min-h-0 flex-1 items-center justify-center py-3">
@ -297,13 +87,13 @@ export function GenerationDotPlaceholder({
/> />
</div> </div>
{showProgress ? ( {showProgress && phase !== "completed" ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b] dark:text-zinc-400"> <div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b] dark:text-zinc-400">
<span>{phase === "failed" ? "已停止" : "生成进度"}</span> <span>{phase === "failed" ? "已停止" : resolvedProgress === null ? "等待结果,返回后自动展示" : "生成进度"}</span>
<span>{resolvedProgress}%</span> {resolvedProgress !== null ? <span>{resolvedProgress}%</span> : null}
</div> </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 <div
className={cn( className={cn(
"h-full rounded-full transition-[width] duration-700 ease-out", "h-full rounded-full transition-[width] duration-700 ease-out",
@ -311,25 +101,12 @@ export function GenerationDotPlaceholder({
? "bg-[#c7ccd3] dark:bg-zinc-600" ? "bg-[#c7ccd3] dark:bg-zinc-600"
: "bg-[#0a0a0c] dark:bg-[#3ecf8e]" : "bg-[#0a0a0c] dark:bg-[#3ecf8e]"
)} )}
style={progressStyle} style={{ width: `${resolvedProgress}%` }}
/> />
</div> </div> : null}
</div> </div>
) : null} ) : null}
</div> </div>
<style jsx>{`
@keyframes generationCursorBlink {
0%,
48% {
opacity: 1;
}
49%,
100% {
opacity: 0;
}
}
`}</style>
</div> </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 { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react"; 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"; import type { FeaturedImage } from "./types";
type LovartFeaturedGalleryProps = { type LovartFeaturedGalleryProps = {
@ -30,6 +31,10 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
return `${source}?imageView2/2/w/900/q/78`; return `${source}?imageView2/2/w/900/q/78`;
} }
function imageIdentity(item: FeaturedImage) {
return JSON.stringify([item.id, item.thumbnailUrl, item.originalUrl]);
}
export function LovartFeaturedGallery({ export function LovartFeaturedGallery({
onOpenProjects, onOpenProjects,
onOpenMaterials, onOpenMaterials,
@ -42,6 +47,13 @@ export function LovartFeaturedGallery({
isLoadingMoreFeatured, isLoadingMoreFeatured,
setActiveCategory, setActiveCategory,
}: LovartFeaturedGalleryProps) { }: 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 ( return (
<section <section
ref={featuredSectionRef} ref={featuredSectionRef}
@ -88,44 +100,48 @@ export function LovartFeaturedGallery({
))} ))}
</div> </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"> <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 {isLoadingFeatured
? "灵感内容加载中..." ? "灵感内容加载中..."
: "当前没有可展示的灵感数据。"} : unavailableCount > 0 ? "图片暂时无法加载,请稍后重试。" : "当前没有可展示的灵感数据。"}
</div> </div>
) : ( ) : (
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4"> <div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
{featuredItems.map((item, index) => ( {visibleItems.map((item) => (
<button <button
key={item.id} key={`${imageIdentity(item)}:${retryVersion}`}
type="button" type="button"
disabled={unavailable.has(imageIdentity(item))}
onClick={() => onClick={() =>
window.open(item.originalUrl || item.thumbnailUrl, "_blank") window.open(item.originalUrl || item.thumbnailUrl, "_blank")
} }
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left" className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
> >
<img {unavailable.has(imageIdentity(item)) ? (
src={getFeaturedPreviewUrl(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 || "灵感图"} alt={item.prompt || "灵感图"}
loading="lazy" loading="lazy"
decoding="async" onUnavailable={() => setUnavailable((previous) => new Set(previous).add(imageIdentity(item)))}
onError={(event) => { 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]"
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]"
/>
<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="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"> <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"> <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"> <div className="inline-flex shrink-0 items-center gap-[7px] text-[11px] leading-none">
<span className="inline-flex items-center gap-[4px]"> <span className="inline-flex items-center gap-[4px]">
<PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} /> <PlayCircle className="h-[11px] w-[11px]" strokeWidth={2} />
{(1200 + index * 37).toLocaleString()} {(1200 + featuredItems.indexOf(item) * 37).toLocaleString()}
</span> </span>
<span className="inline-flex items-center gap-[4px]"> <span className="inline-flex items-center gap-[4px]">
<Heart className="h-[11px] w-[11px]" strokeWidth={2} /> <Heart className="h-[11px] w-[11px]" strokeWidth={2} />
{(20 + (index % 9) * 11).toLocaleString()} {(20 + (featuredItems.indexOf(item) % 9) * 11).toLocaleString()}
</span> </span>
<Gem className="h-[11px] w-[11px]" strokeWidth={2} /> <Gem className="h-[11px] w-[11px]" strokeWidth={2} />
</div> </div>

View File

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

View File

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

View File

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

View File

@ -1,10 +1,12 @@
"use client"; "use client";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react"; import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
const ImageAnnotator = dynamic( const ImageAnnotator = dynamic(
() => import("./image-annotator").then((module) => module.ImageAnnotator), () => 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({ export function ImagePreview({
src, src,
alt, alt,
@ -237,34 +220,12 @@ export function ImagePreview({
toast.error("下载失败,请重试"); toast.error("下载失败,请重试");
return; return;
} }
const filename = `generated-image-${Date.now()}.jpg`;
try { try {
const response = await fetch( await downloadOriginalImage({ url: safeOriginalUrl });
buildImageProxyUrl(safeOriginalUrl, { delivery: "proxy" }) toast.success("原图已开始下载");
);
if (!response.ok) {
throw new Error(`下载失败(${response.status})`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
triggerBrowserDownload(url, filename);
window.URL.revokeObjectURL(url);
toast.success("下载成功");
} catch (error) { } catch (error) {
console.error("下载失败:", error); console.error("下载失败:", error);
try { toast.error(getCanvasImageDownloadErrorMessage(error));
triggerBrowserDownload(safeOriginalUrl, filename);
toast.message("代理下载失败,已切换为原图直链下载");
} catch (fallbackError) {
console.error("原图直链下载失败:", fallbackError);
try {
triggerBrowserDownload(safeOriginalUrl, undefined, { openInNewTab: true });
toast.message("已打开原图,可直接另存为");
} catch (openError) {
console.error("打开原图失败:", openError);
toast.error("下载失败,请重试");
}
}
} }
}; };
@ -316,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 ( return (
<> <>
{/* 缩略图(显示压缩后的缩略图)*/} {/* 缩略图(显示压缩后的缩略图)*/}
@ -342,9 +307,18 @@ export function ImagePreview({
}} }}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onLoad={(event) => {
event.currentTarget.style.aspectRatio = "";
event.currentTarget.parentElement?.style.removeProperty("width");
event.currentTarget.alt = "Generated Image";
}}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== (thumbnail || original)) { if (!normalizeLegacyAliyunStorageUrl(thumbnail || original) && event.currentTarget.src !== (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)} onClick={() => setIsOpen(true)}
@ -636,7 +610,7 @@ export function ImagePreview({
onClick={(e: React.MouseEvent) => e.stopPropagation()} onClick={(e: React.MouseEvent) => e.stopPropagation()}
> >
<img <img
src={original} src={normalizeLegacyAliyunStorageUrl(original) ? buildImageProxyUrl(original) : original}
alt="Generated Image - Original" alt="Generated Image - Original"
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${ className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
isAnnotationMode ? "cursor-crosshair" : "" isAnnotationMode ? "cursor-crosshair" : ""

View File

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

View File

@ -9,6 +9,7 @@ import { Button } from "./ui/button";
import { X, Download, Target, Paintbrush } from "lucide-react"; import { X, Download, Target, Paintbrush } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ImageAnnotator } from "./image-annotator"; import { ImageAnnotator } from "./image-annotator";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
export const PreviewAttachment = ({ export const PreviewAttachment = ({
attachment, attachment,
@ -73,18 +74,11 @@ export const PreviewAttachment = ({
// 下载图片函数 // 下载图片函数
const handleDownload = async () => { const handleDownload = async () => {
try { try {
const response = await fetch(url); await downloadOriginalImage({ url, name: name ?? "image" });
const blob = await response.blob(); toast.success("原图已开始下载");
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);
} catch (error) { } catch (error) {
console.error("下载失败:", 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"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { import {
ArrowLeft, ArrowLeft,
@ -2073,18 +2074,9 @@ export function SceneFusionView({
const downloadImage = async (url: string, filename: string) => { const downloadImage = async (url: string, filename: string) => {
try { try {
const response = await fetch(url); await downloadOriginalImage({ url, name: filename });
const blob = await response.blob(); } catch (error) {
const blobUrl = URL.createObjectURL(blob); toast.error(getCanvasImageDownloadErrorMessage(error));
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");
} }
}; };

View File

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

View File

@ -92,8 +92,16 @@ public 先从当前运行版本创建写入时复制副本,保留未纳入 Git
原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。 原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。
实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。 实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。
`next.config.ts` 将本流程的发布目录编号写入前后端版本标识;`/api/app-version` 返回该编号且不缓存,发布烟测会核对它与运行目录一致。网页每分钟及切回页面时检查更新,发现版本变化后提示同步并刷新;画布同步失败或当前任务仍在处理时保留页面。首次接入前已经打开的旧网页需先刷新一次,后续发布即可自动收到提示。
维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。 维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。
服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。 服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。
仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。 仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。
每次 PR 须记录本次改动的验证、历史失败和未覆盖项目;当前流程不会自动触发生产发布。 每次 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"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; 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 { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
import { import {
CanvasRevisionConflict, CanvasRevisionConflict,
@ -149,6 +150,16 @@ export function useVideoCanvasPersistence(
return !queue.dirty && !queue.failed; return !queue.dirty && !queue.failed;
}, [ready]); }, [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(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { 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

@ -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

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化 * - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
* - qwen-image-edit:仅支持 1 张输出 * - 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 sharp from "sharp";
@ -14,6 +14,7 @@ import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification"
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order"; import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
import { import {
isAlibabaQwenImage3ModelId, isAlibabaQwenImage3ModelId,
resolveAlibabaImageAsyncEndpoint, resolveAlibabaImageAsyncEndpoint,
@ -46,6 +47,8 @@ interface AlibabaImageResponse {
output?: { output?: {
task_id?: string; task_id?: string;
task_status?: string; task_status?: string;
code?: string;
message?: string;
choices?: Array<{ choices?: Array<{
finish_reason: string; finish_reason: string;
message: { message: {
@ -122,11 +125,21 @@ async function fetchImageBufferWithRetry(
timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS, timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
attempts = 2 attempts = 2
): Promise<{ buffer: Buffer; contentType: string | null }> { ): 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; let lastError: unknown = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) { for (let attempt = 1; attempt <= attempts; attempt += 1) {
const ctx = createAbortSignal(timeoutMs); const ctx = createAbortSignal(timeoutMs);
try { try {
const response = await fetch(imageUrl, { const response = await fetch(sourceUrl, {
cache: "no-store", cache: "no-store",
redirect: "follow", redirect: "follow",
signal: ctx.signal, signal: ctx.signal,
@ -168,7 +181,7 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st
} }
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> { 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 { try {
const { buffer } = await fetchImageBufferWithRetry(imageUrl); const { buffer } = await fetchImageBufferWithRetry(imageUrl);
@ -186,7 +199,7 @@ async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number |
async function inferImageDimensionsFromImageUrl( async function inferImageDimensionsFromImageUrl(
imageUrl: string, imageUrl: string,
): Promise<{ width: number; height: number; aspectRatio: number } | null> { ): 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 { try {
const { buffer } = await fetchImageBufferWithRetry(imageUrl); const { buffer } = await fetchImageBufferWithRetry(imageUrl);
@ -302,6 +315,14 @@ async function prepareAlibabaInputImageUrl(
maxSide: ALIBABA_MAX_INPUT_SIDE, maxSide: ALIBABA_MAX_INPUT_SIDE,
modelId, 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) { if (!prepared.transformed) {
return normalizedUrl; return normalizedUrl;
} }
@ -565,8 +586,10 @@ async function sendAlibabaImageAsyncRequest(params: {
return task; return task;
} }
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) { if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
const code = task.output?.code || task.code;
throw new AlibabaImageTerminalTaskError( throw new AlibabaImageTerminalTaskError(
task.message || `阿里图片任务${taskStatus || "失败"}(task_id: ${taskId})`, `${reason}${code ? `(${code})` : ""}(task_id: ${taskId})`,
); );
} }
lastPollError = null; lastPollError = null;
@ -769,16 +792,15 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
} }
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3); const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
const preparedImageUrls: string[] = []; const preparedImageUrls = await Promise.all(
for (const imageUrl of selectedImageUrls) { selectedImageUrls.map((imageUrl) =>
preparedImageUrls.push( prepareAlibabaInputImageUrl(
await prepareAlibabaInputImageUrl(
imageUrl, imageUrl,
activeModelId, activeModelId,
options.imageParams?.inputImageRoleByUrl?.[imageUrl], options.imageParams?.inputImageRoleByUrl?.[imageUrl],
), ),
),
); );
}
// qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。 // qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。
if ( if (

View File

@ -48,6 +48,7 @@ import {
VIP98K_REFERENCE_STAGING_KEY_PREFIX, VIP98K_REFERENCE_STAGING_KEY_PREFIX,
} from "@/lib/ai/vip98k-reference-staging"; } from "@/lib/ai/vip98k-reference-staging";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { import {
IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_MAX_BYTES,
@ -1023,6 +1024,77 @@ function getConfiguredPollSeconds(config: Vip98kModelRuntimeConfig, key: string,
return Math.min(Math.max(value, 1), 3600); 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: { async function submitTask(params: {
apiKey: string; apiKey: string;
baseURL?: string | null; baseURL?: string | null;
@ -1035,6 +1107,8 @@ async function submitTask(params: {
submissionTimeoutMs: number; submissionTimeoutMs: number;
maxSubmissionAttempts: number; maxSubmissionAttempts: number;
submissionRetryBaseDelayMs: number; submissionRetryBaseDelayMs: number;
pollIntervalMs: number;
maxPollMs: number;
}) { }) {
const baseURL = normalizeVip98kBaseUrl(params.baseURL); const baseURL = normalizeVip98kBaseUrl(params.baseURL);
const path = String(params.path || "/api/external/task/generate").trim(); const path = String(params.path || "/api/external/task/generate").trim();
@ -1098,7 +1172,9 @@ async function submitTask(params: {
signal: AbortSignal.timeout(params.submissionTimeoutMs), signal: AbortSignal.timeout(params.submissionTimeoutMs),
}); });
} catch (error) { } 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", { logger.error("provider submission transport failed", {
modelId: params.modelId, modelId: params.modelId,
path, path,
@ -1144,6 +1220,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 errorMessage = extractErrorMessage(payload);
const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({ const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({
status: response.status, status: response.status,
@ -1188,9 +1272,9 @@ async function submitTask(params: {
continue; continue;
} }
if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage)) 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)) || /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})`); throw new Error(`${errorMessage} (HTTP ${response.status})`);
} }
@ -1261,6 +1345,7 @@ export async function submitVip98kExternalTask(params: {
const configuredInvocations = getVip98kInvocations(taskParams.modelConfig); const configuredInvocations = getVip98kInvocations(taskParams.modelConfig);
const invocation = selectVip98kTaskInvocation({ const invocation = selectVip98kTaskInvocation({
modelId: taskParams.modelId, modelId: taskParams.modelId,
kind: taskParams.kind,
configuredInvocation: configuredInvocations[0] || null, configuredInvocation: configuredInvocations[0] || null,
configuredInvocations, configuredInvocations,
imageCount: imageUrls.length, imageCount: imageUrls.length,
@ -1309,6 +1394,12 @@ export async function submitVip98kExternalTask(params: {
), ),
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts, maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs, 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 || ""; const resultPath = submitted.resultPath || invocation?.resultPath || "";
return { return {
@ -1438,7 +1529,7 @@ function normalizeDuration(value: unknown) {
return Math.round(numeric); return Math.round(numeric);
} }
function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) { export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | null) {
const videoParams = params.videoParams || {}; const videoParams = params.videoParams || {};
const imageParams = params.imageParams || {}; const imageParams = params.imageParams || {};
const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80); const promptTitle = params.prompt.split(/\s+/).slice(0, 8).join(" ").slice(0, 80);
@ -1448,6 +1539,9 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
body.displayName = promptTitle || params.modelId; body.displayName = promptTitle || params.modelId;
} }
if ("prompt" in body || !invocation) body.prompt = params.prompt; 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 = const aspectRatio =
normalizeRatio(videoParams.ratio) || normalizeRatio(videoParams.ratio) ||
@ -1697,7 +1791,7 @@ async function runTask(params: Vip98kTaskParams) {
} }
const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K"; const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K";
const archiveStartedAt = Date.now(); 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", { logger.info("result archive completed", {
modelId: params.modelId, modelId: params.modelId,
outputCount: archived.length, outputCount: archived.length,

View File

@ -8,6 +8,7 @@ import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
const logger = createLogger("rmbg-service"); const logger = createLogger("rmbg-service");
@ -27,6 +28,7 @@ type RmbgConfig = {
}; };
export type ExecuteRmbgJobParams = { export type ExecuteRmbgJobParams = {
requestId?: string;
userId: string; userId: string;
imageBuffer: Buffer; imageBuffer: Buffer;
imageBase64?: string; imageBase64?: string;
@ -35,6 +37,8 @@ export type ExecuteRmbgJobParams = {
chatId?: string | null; chatId?: string | null;
messageId?: string | null; messageId?: string | null;
skipGallerySave?: boolean; skipGallerySave?: boolean;
// 仅当 imageBuffer 与 URL 对应的输入文件一致时提供。
inputImageUrl?: string | null;
sourceImageUrl?: string | null; sourceImageUrl?: string | null;
}; };
@ -53,7 +57,7 @@ export class RmbgServiceError extends Error {
detail?: string; detail?: string;
constructor(message: string, status = 500, detail?: string) { constructor(message: string, status = 500, detail?: string) {
super(message); super(detail && detail !== message ? `${message}:${detail}` : message);
this.name = "RmbgServiceError"; this.name = "RmbgServiceError";
this.status = status; this.status = status;
this.detail = detail; this.detail = detail;
@ -61,7 +65,11 @@ export class RmbgServiceError extends Error {
} }
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 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_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_CREATE_TIMEOUT_MS = 120_000;
const AOS_POLL_TIMEOUT_MS = 60_000; const AOS_POLL_TIMEOUT_MS = 60_000;
const AOS_DOWNLOAD_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 { function getAosFriendlyErrorMessage(detail: string): string | null {
const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i); const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i);
if (resolutionMatch) { if (resolutionMatch) {
@ -164,52 +184,44 @@ function getAosFriendlyErrorMessage(detail: string): string | null {
async function prepareImageForAos( async function prepareImageForAos(
imageBuffer: Buffer imageBuffer: Buffer
): Promise<{ buffer: Buffer; mimeType: string; filename: string }> { ): 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 { try {
const sharp = await import("sharp"); const sharp = await import("sharp");
const image = sharp.default(imageBuffer); metadata = await sharp.default(imageBuffer, { limitInputPixels: false }).metadata();
const metadata = await image.metadata(); } catch (error) {
const width = metadata.width; // AOS also accepts BMP/RAW/RGB formats that Sharp cannot identify.
const height = metadata.height; logger.warn("aos_image_metadata_unavailable", error);
const format = (metadata.format || "png").toLowerCase(); 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; 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 { return {
buffer: imageBuffer, buffer: imageBuffer,
mimeType, mimeType: format ? `image/${format}` : "application/octet-stream",
filename: `image.${filenameExt}`, filename: ext ? `image.${ext}` : "image",
}; };
} }
const resizedBuffer = await image
.resize(AOS_MAX_RESOLUTION, AOS_MAX_RESOLUTION, {
fit: "inside",
withoutEnlargement: true,
})
.toBuffer();
return {
buffer: resizedBuffer,
mimeType,
filename: `image.${filenameExt}`,
};
} catch (error) {
logger.warn("aos_image_preprocess_failed", error);
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" };
}
}
async function processWithRMBG( async function processWithRMBG(
imageBase64: string, imageBase64: string,
config: { apiUrl: string; apiKey?: string } config: { apiUrl: string; apiKey?: string }
@ -369,20 +381,38 @@ async function processWithAosSegmentation(
foregroundType?: "" | "person" | "object" | "stamp"; foregroundType?: "" | "person" | "object" | "stamp";
crop?: 0 | 1; crop?: 0 | 1;
format?: "png" | "jpg"; format?: "png" | "jpg";
inputImageUrl?: string | null;
requestId?: string;
} }
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> { ): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
const startTime = Date.now(); const startTime = Date.now();
const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, ""); const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, "");
const createUrl = `${baseUrl}/api/tasks/visual/segmentation`; const createUrl = `${baseUrl}/api/tasks/visual/segmentation`;
const headers = { "X-API-KEY": config.apiKey }; const headers = { "X-API-KEY": config.apiKey };
let stage = "create";
let providerTaskId: string | undefined;
const sourceImage = await prepareImageForAos(imageBuffer);
try { try {
const normalizedImage = await prepareImageForAos(imageBuffer);
const createForm = new FormData(); const createForm = new FormData();
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], { let publicSourceUrl: string | null = null;
type: normalizedImage.mimeType, 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, normalizedImage.filename); createForm.append("image_file", imageBlob, sourceImage.filename);
}
createForm.append("sync", "0"); createForm.append("sync", "0");
createForm.append("return_type", "1"); createForm.append("return_type", "1");
createForm.append("output_type", "2"); createForm.append("output_type", "2");
@ -392,6 +422,9 @@ async function processWithAosSegmentation(
createForm.append("type", config.foregroundType); 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( const createResponse = await withTimeout(
(signal) => (signal) =>
fetch(createUrl, { fetch(createUrl, {
@ -411,28 +444,37 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, 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; 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 taskUrl = `${createUrl}/${taskId}`;
const maxPollCount = 60; const maxPollCount = 60;
for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) { for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) {
await sleep(1000); await sleep(1000);
stage = "poll";
const pollResponse = await withTimeout( const pollResponse = await readAosResult(
(signal) => async (signal) => {
fetch(taskUrl, { const response = await fetch(taskUrl, {
method: "GET", method: "GET",
headers, headers,
signal, 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 AOS_POLL_TIMEOUT_MS
); );
const pollPayload = await pollResponse.json().catch(() => null); const pollPayload = pollResponse.payload;
if (!pollResponse.ok || !pollPayload?.data) { if (!pollResponse.ok || !pollPayload?.data) {
logger.warn("aos_task_poll_failed", { logger.warn("aos_task_poll_failed", {
hasPayload: pollPayload != null, hasPayload: pollPayload != null,
@ -440,7 +482,7 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, 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, pollCount,
taskId, taskId,
}); });
const imageResponse = await withTimeout( stage = "download";
(signal) => const imageResponse = await readAosResult(
fetch(imageUrl, { async (signal) => {
const response = await fetch(imageUrl, {
method: "GET", method: "GET",
signal, signal,
}), });
return { ok: response.ok, status: response.status, statusText: response.statusText, buffer: response.ok ? Buffer.from(await response.arrayBuffer()) : null };
},
AOS_DOWNLOAD_TIMEOUT_MS AOS_DOWNLOAD_TIMEOUT_MS
); );
@ -470,11 +515,11 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, 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 { return {
success: true, success: true,
imageBuffer: resultBuffer, imageBuffer: resultBuffer,
@ -490,19 +535,30 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`, error: `AOS任务失败(状态 ${state},任务 ${taskId}): ${errMessage || stateDetail || pollPayload?.message || "未提供详情"}`,
}; };
} }
} }
logger.warn("aos_task_timeout", { maxPollCount }); logger.warn("aos_task_timeout", { requestId: config.requestId, taskId, maxPollCount });
return { success: false, error: "AOS任务超时(最多60秒)" }; return { success: false, error: `AOS查询任务超时,结果尚未确认(任务 ${taskId})` };
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") { 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); logger.warn("aos_provider_request_error", error, { requestId: config.requestId, stage, taskId: providerTaskId });
return { success: false, error: error instanceof Error ? error.message : "未知错误" }; 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( export async function executeRmbgJob(
params: ExecuteRmbgJobParams params: ExecuteRmbgJobParams
): Promise<ExecuteRmbgJobResult> {
return trackRuntimeWork(() => runRmbgJob(params));
}
async function runRmbgJob(
params: ExecuteRmbgJobParams
): Promise<ExecuteRmbgJobResult> { ): Promise<ExecuteRmbgJobResult> {
const { const {
requestId = crypto.randomUUID(),
userId, userId,
imageBuffer, imageBuffer,
imageBase64 = imageBuffer.toString("base64"), imageBase64 = imageBuffer.toString("base64"),
@ -609,11 +672,13 @@ export async function executeRmbgJob(
chatId = null, chatId = null,
messageId = null, messageId = null,
skipGallerySave = false, skipGallerySave = false,
inputImageUrl = null,
sourceImageUrl = null, sourceImageUrl = null,
} = params; } = params;
const { provider, config } = await getValidatedRmbgConfig(); const { provider, config } = await getValidatedRmbgConfig();
logger.info("job_started", { logger.info("job_started", {
requestId,
hasChatId: Boolean(chatId), hasChatId: Boolean(chatId),
hasMessageId: Boolean(messageId), hasMessageId: Boolean(messageId),
hasSourceImageUrl: Boolean(sourceImageUrl), hasSourceImageUrl: Boolean(sourceImageUrl),
@ -657,6 +722,8 @@ export async function executeRmbgJob(
foregroundType: foregroundType ?? config.aosForegroundType ?? "", foregroundType: foregroundType ?? config.aosForegroundType ?? "",
crop: config.aosCrop === 1 ? 1 : 0, crop: config.aosCrop === 1 ? 1 : 0,
format: config.aosFormat === "jpg" ? "jpg" : "png", format: config.aosFormat === "jpg" ? "jpg" : "png",
inputImageUrl,
requestId,
}); });
if (!result.success || !result.imageBuffer) { if (!result.success || !result.imageBuffer) {
@ -793,6 +860,7 @@ export async function executeRmbgJob(
} }
logger.info("job_completed", { logger.info("job_completed", {
requestId,
generatedImageId: savedImage.id, generatedImageId: savedImage.id,
mediaAssetId, mediaAssetId,
processingSeconds: Number(processingTime.toFixed(2)), processingSeconds: Number(processingTime.toFixed(2)),
@ -815,6 +883,7 @@ export async function executeRmbgJob(
} }
logger.info("job_completed", { logger.info("job_completed", {
requestId,
processingSeconds: Number(processingTime.toFixed(2)), processingSeconds: Number(processingTime.toFixed(2)),
provider, provider,
savedToGallery: false, 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 } { export function agentImageFailurePolicy(error: unknown): { retry: boolean; pause: AgentModelFault | null } {
const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null; const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null;
const message = String(value?.message || "").toLowerCase(); 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; // 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. // 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); 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({ const result = await executeRmbgJob({
userId: params.userId, userId: params.userId,
imageBuffer: sourceBuffer, imageBuffer: sourceBuffer,
inputImageUrl: params.sourceImageUrl,
prompt: params.requestTextContent || "抠图处理", prompt: params.requestTextContent || "抠图处理",
chatId: params.chatId, chatId: params.chatId,
messageId: params.messageId, messageId: params.messageId,

View File

@ -0,0 +1,74 @@
import { isProviderBalanceError } from "@/lib/request-error";
type BalanceState = {
healthLastEventType?: unknown;
healthLastError?: unknown;
healthAutoDisabledReason?: unknown;
};
export function isImageModelBalanceBlocked(model: BalanceState | null | undefined) {
return Boolean(
model &&
((model.healthLastEventType === "quota" && isProviderBalanceError(model.healthLastError)) ||
isProviderBalanceError(model.healthAutoDisabledReason)),
);
}
export function resolveConfiguredImageBalanceFallback<
T extends {
model: BalanceState & {
id: string;
modelId: string;
isEnabled: boolean;
supportsImageGen: boolean;
imageGenConfig?: unknown;
};
provider: { isEnabled?: boolean };
},
>(
requested: BalanceState & { fallbackModelId1?: string | null; fallbackModelId2?: string | null },
entries: T[],
input: {
imageSize?: unknown;
aspectRatio?: unknown;
referenceImageCount?: number;
},
) {
if (!isImageModelBalanceBlocked(requested)) return undefined;
for (const ref of [requested.fallbackModelId1, requested.fallbackModelId2]) {
if (!ref) continue;
const entry = entries.find(({ model }) => model.id === ref || model.modelId === ref);
if (
!entry ||
!entry.model.isEnabled ||
entry.provider.isEnabled === false ||
!entry.model.supportsImageGen ||
isImageModelBalanceBlocked(entry.model)
)
continue;
const config = entry.model.imageGenConfig as Record<string, unknown> | null;
const size = String(input.imageSize || "").toUpperCase();
const ratio = String(input.aspectRatio || "");
if (
size &&
Array.isArray(config?.supportedImageSizes) &&
!config.supportedImageSizes.includes(size)
)
continue;
if (
ratio &&
ratio !== "auto" &&
Array.isArray(config?.supportedAspectRatios) &&
!config.supportedAspectRatios.includes(ratio)
)
continue;
if (
input.referenceImageCount &&
typeof config?.maxReferenceImages === "number" &&
input.referenceImageCount > config.maxReferenceImages
)
continue;
return entry;
}
return undefined;
}

View File

@ -1,18 +0,0 @@
import { isProviderBalanceError } from "@/lib/request-error";
const IMAGE_FAILOVER_BALANCE_COOLDOWN_MS = 2 * 60 * 60 * 1000;
export function isImageFallbackModelBalanceCoolingDown(
model: {
healthLastEventType?: unknown;
healthLastError?: unknown;
healthLastFailureAt?: unknown;
} | null | undefined,
now = Date.now(),
) {
if (model?.healthLastEventType !== "quota" || !isProviderBalanceError(model.healthLastError)) {
return false;
}
const failedAt = new Date(String(model.healthLastFailureAt || "")).getTime();
return Number.isFinite(failedAt) && now - failedAt < IMAGE_FAILOVER_BALANCE_COOLDOWN_MS && now >= failedAt;
}

View File

@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { ProviderTaskUncertainError } from "@/lib/video-canvas/video-task-lifecycle";
import { isProviderAuthenticationFailure, isCredentialFailureBan } from "./authentication-failover"; import { isProviderAuthenticationFailure, isCredentialFailureBan } from "./authentication-failover";
import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { and, desc, eq, inArray, sql } from "drizzle-orm";
@ -12,7 +13,7 @@ import {
isMediaInputFailure, isMediaInputFailure,
isNonProviderRequestStatus, isNonProviderRequestStatus,
} from "@/lib/ai/runtime/request-failure-classification"; } from "@/lib/ai/runtime/request-failure-classification";
import { isProviderBalanceError, isRateLimitError } from "@/lib/request-error"; import { getRequestFailureDetails, isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
export type AiModelHealthEventType = export type AiModelHealthEventType =
| "success" | "success"
@ -343,6 +344,12 @@ export function classifyAiModelHealthFailure(error: unknown): {
} }
export function shouldFailoverAiModelError(error: unknown) { export function shouldFailoverAiModelError(error: unknown) {
if (
error instanceof ProviderTaskUncertainError ||
isGatewayResponseTimeout(error) ||
getRequestFailureDetails(error).outcome === "unknown" ||
["AbortError", "TimeoutError"].includes(String((error as { name?: string })?.name || ""))
) return false;
const classification = classifyAiModelHealthFailure(error); const classification = classifyAiModelHealthFailure(error);
const normalized = getErrorMessage(error).toLowerCase(); const normalized = getErrorMessage(error).toLowerCase();
const imageProviderTaskFailed = const imageProviderTaskFailed =
@ -499,6 +506,7 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string })
const dueModels = candidates.filter((model) => { const dueModels = candidates.filter((model) => {
if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false; if (isCredentialFailureBan(model.healthAutoDisabledReason)) return false;
if (isProviderBalanceError(model.healthAutoDisabledReason)) return false;
const cooldownStartedAt = const cooldownStartedAt =
model.healthAutoDisabledAt || model.healthAutoDisabledAt ||
model.healthLastFailureAt || model.healthLastFailureAt ||
@ -568,6 +576,7 @@ export async function recordAiModelHealthSuccess(params: {
healthStatus: aiModel.healthStatus, healthStatus: aiModel.healthStatus,
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak, healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
healthObservationStartedAt: aiModel.healthObservationStartedAt, healthObservationStartedAt: aiModel.healthObservationStartedAt,
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
}) })
.from(aiModel) .from(aiModel)
.where(eq(aiModel.id, modelId)) .where(eq(aiModel.id, modelId))
@ -575,6 +584,9 @@ export async function recordAiModelHealthSuccess(params: {
if (!existing) return; if (!existing) return;
// A previously accepted task can finish after the account runs out of credit.
if (isProviderBalanceError(existing.healthAutoDisabledReason)) return;
const now = new Date(); const now = new Date();
// A request that started before the circuit opened can still prove the // A request that started before the circuit opened can still prove the
// provider is reachable. Treat that success as the circuit's half-open // provider is reachable. Treat that success as the circuit's half-open
@ -662,6 +674,7 @@ export async function recordAiModelHealthFailure(params: {
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak, healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
healthObservationStartedAt: aiModel.healthObservationStartedAt, healthObservationStartedAt: aiModel.healthObservationStartedAt,
healthAutoDisabledAt: aiModel.healthAutoDisabledAt, healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
healthAutoDisabledReason: aiModel.healthAutoDisabledReason,
}) })
.from(aiModel) .from(aiModel)
.where(eq(aiModel.id, modelId)) .where(eq(aiModel.id, modelId))
@ -678,13 +691,15 @@ export async function recordAiModelHealthFailure(params: {
: existing.healthConsecutiveFailures || 0; : existing.healthConsecutiveFailures || 0;
const shouldAutoDisable = const shouldAutoDisable =
classification.counted && classification.counted &&
!existing.excludeFromFailover && (classification.eventType === "quota" ||
(!existing.excludeFromFailover &&
!existing.excludeFromHealthBan && !existing.excludeFromHealthBan &&
(classification.reason === "provider_authentication_failed" || (classification.reason === "provider_authentication_failed" ||
(isObservation && existing.isEnabled) || (isObservation && existing.isEnabled) ||
(existing.healthStatus !== "auto_disabled" && (existing.healthStatus !== "auto_disabled" &&
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD)); nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD))));
const nextStatus = shouldAutoDisable const balanceDisabled = isProviderBalanceError(existing.healthAutoDisabledReason);
const nextStatus = shouldAutoDisable || balanceDisabled
? "auto_disabled" ? "auto_disabled"
: classification.counted : classification.counted
? "degraded" ? "degraded"
@ -737,7 +752,7 @@ export async function recordAiModelHealthFailure(params: {
healthUpdatedAt: now, healthUpdatedAt: now,
updatedAt: now, updatedAt: now,
}; };
if (shouldAutoDisable) { if (shouldAutoDisable && !balanceDisabled) {
updateData.healthAutoDisabledAt = now; updateData.healthAutoDisabledAt = now;
updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage; updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage;
} else if (existing.healthStatus !== "auto_disabled" && !isObservation) { } else if (existing.healthStatus !== "auto_disabled" && !isObservation) {

View File

@ -238,7 +238,10 @@ export function bindVip98kImages(
imageUrls: string[], imageUrls: string[],
) { ) {
const bindingKeys: string[] = []; const bindingKeys: string[] = [];
if (imageUrls.length === 0) return bindingKeys; if (imageUrls.length === 0) {
for (const key of [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"]) delete body[key];
return bindingKeys;
}
for (const key of IMAGE_ARRAY_INPUT_KEYS) { for (const key of IMAGE_ARRAY_INPUT_KEYS) {
if (!(key in body)) continue; if (!(key in body)) continue;
@ -305,6 +308,7 @@ function scoreReferenceInvocation(invocation: Vip98kTaskInvocation) {
export function selectVip98kTaskInvocation(params: { export function selectVip98kTaskInvocation(params: {
modelId: string; modelId: string;
kind?: string;
configuredInvocation: Vip98kTaskInvocation | null; configuredInvocation: Vip98kTaskInvocation | null;
configuredInvocations?: Vip98kTaskInvocation[]; configuredInvocations?: Vip98kTaskInvocation[];
imageCount?: number; imageCount?: number;
@ -334,6 +338,17 @@ export function selectVip98kTaskInvocation(params: {
} }
} }
if (params.kind === "image" && !params.imageCount) {
const textImageTask = candidates.find(candidate => {
const properties = candidate.inputSchema?.properties as Record<string, unknown> | undefined;
const required = candidate.inputSchema?.required as string[] | undefined;
return candidate.kind === "task" && candidate.path === "/extensions/v1/tasks" &&
invocationTargetsModel(candidate, params.modelId) && properties?.resolution && properties?.aspect_ratio &&
!required?.some(key => [...IMAGE_ARRAY_INPUT_KEYS, ...IMAGE_SINGLE_INPUT_KEYS, "video_clips"].includes(key));
});
if (textImageTask) return resolveVip98kTaskInvocation(params.modelId, textImageTask);
}
return resolveVip98kTaskInvocation( return resolveVip98kTaskInvocation(
params.modelId, params.modelId,
params.configuredInvocation || candidates[0] || null, params.configuredInvocation || candidates[0] || null,

View File

@ -4,7 +4,8 @@ import { join } from "node:path";
import type { NativeEvent, NativeResult } from "@/services/zcode-agent/runner.mjs"; import type { NativeEvent, NativeResult } from "@/services/zcode-agent/runner.mjs";
import { runNativeZcode } from "./native-runtime"; import { runNativeZcode } from "./native-runtime";
import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop"; import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
import { AgentToolRejectedError, publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol"; import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion"; import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results"; import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
@ -37,6 +38,17 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
snapshot.message = message; snapshot.message = message;
await ports.checkpoint(structuredClone(snapshot), status); return status; await ports.checkpoint(structuredClone(snapshot), status); return status;
} }
function failureMessage(call: AgentCallRecord) {
if (call.confirmedNoResult) return `${call.error}\n\n请求编号:${ports.runId}`;
const details = getRequestFailureDetails(call.error || "任务结果尚未确认");
if (call.uncertain || call.status === "started") {
if (details.outcome !== "unknown") details.message += "结果尚未确认。";
details.outcome = "unknown";
details.recommendation = "请联系管理员按请求编号核对原任务结果;不要换模型、新建对话或重新提交,以免重复生成。";
}
if (!details.recommendation) details.recommendation = "请联系管理员并提供请求编号,核对该步骤的错误。";
return `${call.title || "创作工具"}:${formatRequestFailure(details, ports.runId)}`;
}
let modelError = ""; let modelError = "";
let toolFailure = ""; let toolFailure = "";
let nativeFailed = false; let nativeFailed = false;
@ -44,11 +56,11 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
const seenTextEvents = new Set<string>(); const seenTextEvents = new Set<string>();
let lastTextMessage = ""; let lastTextMessage = "";
async function appendText(delta: string) { async function appendText(delta: string) {
delta = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0))); const text = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0)));
if (!delta) return; if (!text) return;
snapshot.assistantText = (snapshot.assistantText || "") + delta; snapshot.assistantText = (snapshot.assistantText || "") + text;
await ports.checkpoint(structuredClone(snapshot), "running"); await ports.checkpoint(structuredClone(snapshot), "running");
ports.onAssistantText?.(delta); ports.onAssistantText?.(text);
} }
async function finishVerifiedCutout() { async function finishVerifiedCutout() {
snapshot.nativeRuntime!.state = "stopped"; snapshot.nativeRuntime!.state = "stopped";
@ -56,8 +68,8 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
return save(verifiedCompletion, "completed"); return save(verifiedCompletion, "completed");
} }
try { try {
if (snapshot.calls.some(call => call.status === "started" || call.uncertain)) const unconfirmed = snapshot.calls.find(call => call.status === "started" || call.uncertain);
return await save("存在结果未确认的工具调用,请先核对画布,避免重复生成。", "needs_review"); if (unconfirmed) return await save(failureMessage(unconfirmed), "needs_review");
snapshot.nativeRuntime = { ...snapshot.nativeRuntime, engine: "zcode", state: "starting" }; snapshot.nativeRuntime = { ...snapshot.nativeRuntime, engine: "zcode", state: "starting" };
const schemas = new Map(Object.entries(ports.definitions).map(([name, definition]) => [name, asSchema(definition.inputSchema!)])); const schemas = new Map(Object.entries(ports.definitions).map(([name, definition]) => [name, asSchema(definition.inputSchema!)]));
const toolDescriptors = Object.entries(ports.tools).map(([name, entry]) => ({ name, const toolDescriptors = Object.entries(ports.tools).map(([name, entry]) => ({ name,
@ -111,6 +123,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
await save("正在准备处理任务"); await save("正在准备处理任务");
}), }),
onEvent: async (event: NativeEvent | NativeResult) => { onEvent: async (event: NativeEvent | NativeResult) => {
if (attemptController.signal.aborted) return;
if (event.type === "model.streaming" && event.payload?.kind === "text_delta" && typeof event.payload.delta === "string") { if (event.type === "model.streaming" && event.payload?.kind === "text_delta" && typeof event.payload.delta === "string") {
// Child sessions and reasoning/tool-argument deltas are private. // Child sessions and reasoning/tool-argument deltas are private.
if (snapshot.nativeRuntime?.sessionId && event.sessionId !== snapshot.nativeRuntime.sessionId) return; if (snapshot.nativeRuntime?.sessionId && event.sessionId !== snapshot.nativeRuntime.sessionId) return;
@ -155,9 +168,11 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded")) if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded"))
throw new Error("请先制定任务计划再执行画布修改"); throw new Error("请先制定任务计划再执行画布修改");
if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) { if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) {
const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : []), const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : [])]
...(Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [])].filter((id): id is string => typeof id === "string"); .filter((id): id is string => typeof id === "string");
if (!targets.length || targets.some(id => !snapshot.verification!.failedNodeIds!.includes(id))) const references = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [];
const failedIds = snapshot.verification.failedNodeIds;
if (targets.length ? targets.some(id => !failedIds.includes(id)) : !references.some(id => failedIds.includes(String(id))))
throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果"); throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果");
} }
snapshot.calls.push({ id: callId, name, title: entry.title, input, status: "started" }); snapshot.calls.push({ id: callId, name, title: entry.title, input, status: "started" });
@ -205,8 +220,9 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError; call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError;
call.confirmedNoResult = error instanceof AgentImageGenerationError && error.confirmedNoResult; call.confirmedNoResult = error instanceof AgentImageGenerationError && error.confirmedNoResult;
call.uncertain = !entry.readOnly && !call.rejected && !call.confirmedNoResult; call.uncertain = !entry.readOnly && !call.rejected && !call.confirmedNoResult;
if (call.confirmedNoResult) toolFailure = call.error; if (call.uncertain || call.confirmedNoResult) toolFailure = failureMessage(call);
await save(call.error); await save(toolFailure || call.error);
if (call.uncertain || call.confirmedNoResult) attemptController.abort();
}); });
throw error; throw error;
} }
@ -228,15 +244,15 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (toolFailure) return await save(toolFailure, "needs_review"); if (toolFailure) return await save(toolFailure, "needs_review");
// A failed read is not an uncertain paid operation. Independently verify // A failed read is not an uncertain paid operation. Independently verify
// actual outputs below instead of failing a task because an earlier read timed out. // actual outputs below instead of failing a task because an earlier read timed out.
if (snapshot.calls.some(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain)) const failedWrite = snapshot.calls.find(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain);
return await save("部分工具结果待确认,已保留执行记录,请核对后继续。", "needs_review"); if (failedWrite) return await save(failureMessage(failedWrite), "needs_review");
const unresolvedRead = snapshot.calls.some((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly && const unresolvedRead = snapshot.calls.find((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly &&
!snapshot.calls.slice(index + 1).some(later => later.name === call.name && later.status === "succeeded" && !snapshot.calls.slice(index + 1).some(later => later.name === call.name && later.status === "succeeded" &&
JSON.stringify(later.input) === JSON.stringify(call.input))); JSON.stringify(later.input) === JSON.stringify(call.input)));
// Without a real result to independently inspect, a viewport/UI action // Without a real result to independently inspect, a viewport/UI action
// cannot turn a failed analysis into a completed task. // cannot turn a failed analysis into a completed task.
if (unresolvedRead && !snapshot.resultNodeIds.length) if (unresolvedRead && !snapshot.resultNodeIds.length)
return await save("图片或资料暂未读取成功,已保留记录,请稍后继续核对。", "needs_review"); return await save(failureMessage(unresolvedRead), "needs_review");
if (question || snapshot.calls.some(call => requiresCanvasInteraction(snapshot, call))) if (question || snapshot.calls.some(call => requiresCanvasInteraction(snapshot, call)))
return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review"); return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review");
const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id)); const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id));

View File

@ -1,16 +1,16 @@
import { import { executeBoxCutoutJob } from "@/lib/ai/box-cutout/service";
executeBoxCutoutJob,
} from "@/lib/ai/box-cutout/service";
import { executeExtendJob } from "@/lib/ai/extend/service"; import { executeExtendJob } from "@/lib/ai/extend/service";
import { executeLayerSplitJob } from "@/lib/ai/layer-split/service"; import { executeLayerSplitJob } from "@/lib/ai/layer-split/service";
import { executeResizeJob } from "@/lib/ai/resize/service"; import { executeResizeJob } from "@/lib/ai/resize/service";
import { import {
executeRmbgJob, executeRmbgJob,
fetchImageBufferFromUrl, fetchImageBufferFromUrl,
RmbgServiceError,
} from "@/lib/ai/rmbg/service"; } from "@/lib/ai/rmbg/service";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context"; import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context";
import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint"; import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint";
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
import { generateCanvasImage } from "@/lib/canvas/generate-image"; import { generateCanvasImage } from "@/lib/canvas/generate-image";
import { requestLongTaskResult } from "@/lib/client/long-task"; import { requestLongTaskResult } from "@/lib/client/long-task";
import { import {
@ -128,8 +128,10 @@ async function executeForTarget(
if (command.capabilityId === "image.remove_background") { if (command.capabilityId === "image.remove_background") {
const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl); const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl);
const result = await executeRmbgJob({ const result = await executeRmbgJob({
requestId: command.requestId,
userId: params.userId, userId: params.userId,
imageBuffer, imageBuffer,
inputImageUrl: target.sourceImageUrl,
sourceImageUrl: target.sourceImageUrl, sourceImageUrl: target.sourceImageUrl,
prompt: prompt || "抠图处理", prompt: prompt || "抠图处理",
skipGallerySave: false, skipGallerySave: false,
@ -185,8 +187,7 @@ async function executeForTarget(
} }
if (command.capabilityId === "image.box_cutout") { if (command.capabilityId === "image.box_cutout") {
const result = await executeBoxCutoutJob({ const job = {
userId: params.userId,
imageUrl: target.sourceImageUrl, imageUrl: target.sourceImageUrl,
sourceNodeId: target.nodeId, sourceNodeId: target.nodeId,
cropRect: { cropRect: {
@ -197,13 +198,30 @@ async function executeForTarget(
}, },
paddingRatio: asFiniteNumber(command.args.paddingRatio), paddingRatio: asFiniteNumber(command.args.paddingRatio),
prompt: prompt || "框选抠图", prompt: prompt || "框选抠图",
}); };
let result;
if (command.source === "toolbar") {
const response = await executeBoxCutoutPOST(new Request(params.requestUrl, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Request-ID": command.requestId },
body: JSON.stringify(job),
}), params.userId);
result = await response.json();
if (!response.ok || !result.success) {
throw new RmbgServiceError(result.detail || result.message || result.error || "框选抠图未能完成", response.status);
}
} else {
result = await executeBoxCutoutJob({ ...job, requestId: command.requestId, userId: params.userId });
}
return [{ return [{
...common, ...common,
assetId: result.mediaAssetId || undefined, assetId: result.mediaAssetId || undefined,
metadata: { metadata: {
editMode: "box-cutout",
cropRectApplied: result.cropRectApplied, cropRectApplied: result.cropRectApplied,
paddedCropRectApplied: result.paddedCropRectApplied, paddedCropRectApplied: result.paddedCropRectApplied,
cropRectPixels: result.cropRectPixels,
paddedCropRectPixels: result.paddedCropRectPixels,
}, },
title: `${target.title}-框选抠图`, title: `${target.title}-框选抠图`,
url: result.original_url, url: result.original_url,

View File

@ -14,13 +14,16 @@ import { getCanvasCapability } from "@/lib/canvas/capabilities/registry";
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue"; import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
import { import {
createOrRecoverCanvasCapabilityTask, createOrRecoverCanvasCapabilityTask,
MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS, getCanvasCapabilityTask,
heartbeatCanvasCapabilityTask,
markCanvasCapabilityTaskFailed, markCanvasCapabilityTaskFailed,
markCanvasCapabilityTaskRunning, markCanvasCapabilityTaskRunning,
markCanvasCapabilityTaskSucceeded, markCanvasCapabilityTaskSucceeded,
} from "@/lib/canvas/capabilities/task-store"; } from "@/lib/canvas/capabilities/task-store";
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations"; import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol"; import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
import { trackRuntimeWork } from "@/lib/observability/active-work";
export type CanvasCapabilityRuntimeResult = { export type CanvasCapabilityRuntimeResult = {
clientCommand?: CanvasCapabilityClientCommand; clientCommand?: CanvasCapabilityClientCommand;
@ -58,9 +61,11 @@ function serializeTask(task: {
status: string; status: string;
version: number; version: number;
}) { }) {
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
return { return {
attempts: task.attempts, attempts: task.attempts,
error: task.error, error: failure ? formatRequestFailure(failure, task.id) : task.error,
failure,
id: task.id, id: task.id,
requestId: task.requestId, requestId: task.requestId,
result: task.result, result: task.result,
@ -69,6 +74,13 @@ function serializeTask(task: {
}; };
} }
function storedTaskResult(task: Parameters<typeof serializeTask>[0], mode: "headless" | "interactive"): CanvasCapabilityRuntimeResult {
const result = task.status === "succeeded" && task.result && typeof task.result === "object"
? task.result as Omit<CanvasCapabilityRuntimeResult, "task">
: { mode };
return { ...result, task: serializeTask(task) };
}
function buildMediaOperationPlan(params: { function buildMediaOperationPlan(params: {
command: CanvasCapabilityClientCommand; command: CanvasCapabilityClientCommand;
mediaResults: CanvasCapabilityMediaResult[]; mediaResults: CanvasCapabilityMediaResult[];
@ -211,11 +223,7 @@ export async function executeCanvasCapabilityRuntime(params: {
}); });
if (task.status === "succeeded") { if (task.status === "succeeded") {
const storedResult = return storedTaskResult(task, "headless");
task.result && typeof task.result === "object"
? (task.result as Omit<CanvasCapabilityRuntimeResult, "task">)
: ({ mode: "headless" } as const);
return { ...storedResult, task: serializeTask(task) };
} }
if (task.status === "running") { if (task.status === "running") {
return { return {
@ -225,15 +233,8 @@ export async function executeCanvasCapabilityRuntime(params: {
task: serializeTask(task), task: serializeTask(task),
}; };
} }
if ( if (task.status === "failed") {
task.status === "failed" && return { mode: "headless", task: serializeTask(task) };
task.attempts >= MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
) {
throw new CanvasCapabilityRuntimeError(
task.error || "画布能力任务超过最大重试次数",
409,
"CANVAS_CAPABILITY_RETRY_EXHAUSTED"
);
} }
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({ const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
@ -241,7 +242,9 @@ export async function executeCanvasCapabilityRuntime(params: {
command, command,
}); });
if (!shouldRunHeadlessly) { if (!shouldRunHeadlessly) {
task = await markCanvasCapabilityTaskRunning(task.id); const claimed = await markCanvasCapabilityTaskRunning(task.id);
if (!claimed) return storedTaskResult((await getCanvasCapabilityTask({ requestId: command.requestId, userId: params.userId, projectId: params.projectId })) || task, "interactive");
task = claimed;
const result = { const result = {
mode: "interactive" as const, mode: "interactive" as const,
clientCommand: command, clientCommand: command,
@ -250,10 +253,18 @@ export async function executeCanvasCapabilityRuntime(params: {
return { ...result, task: serializeTask(task) }; return { ...result, task: serializeTask(task) };
} }
return enqueueCanvasCapabilityExecution({ const heartbeat = setInterval(() => {
void heartbeatCanvasCapabilityTask(task.id).catch((error) => {
console.warn("[Canvas Capability] Task heartbeat failed:", error);
});
}, 30_000);
try {
return await trackRuntimeWork(() => enqueueCanvasCapabilityExecution({
projectId: params.projectId, projectId: params.projectId,
execute: async () => { execute: async () => {
task = await markCanvasCapabilityTaskRunning(task.id); const claimed = await markCanvasCapabilityTaskRunning(task.id);
if (!claimed) return storedTaskResult((await getCanvasCapabilityTask({ requestId: command.requestId, userId: params.userId, projectId: params.projectId })) || task, "headless");
task = claimed;
try { try {
const projectRevision = context.project.updatedAt.toISOString(); const projectRevision = context.project.updatedAt.toISOString();
const mutationPlan = buildMutationOperationPlan({ const mutationPlan = buildMutationOperationPlan({
@ -299,5 +310,8 @@ export async function executeCanvasCapabilityRuntime(params: {
throw error; throw error;
} }
}, },
}); }));
} finally {
clearInterval(heartbeat);
}
} }

View File

@ -1,11 +1,11 @@
import { and, eq, lt, sql } from "drizzle-orm"; import { and, eq, inArray, lt, sql } from "drizzle-orm";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasCapabilityTask } from "@/lib/db/schema"; import { canvasCapabilityTask } from "@/lib/db/schema";
const STALE_RUNNING_TASK_MS = 15 * 60 * 1000; const STALE_RUNNING_TASK_MS = 15 * 60 * 1000;
export const MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS = 3; const INTERRUPTED_TASK_MESSAGE = "任务执行中断,结果尚未确认。请先查看画布和历史记录,或联系管理员核对原任务,避免重复处理。";
export type CanvasCapabilityTaskResult = Record<string, unknown>; export type CanvasCapabilityTaskResult = Record<string, unknown>;
@ -25,7 +25,25 @@ export async function getCanvasCapabilityTask(params: {
) )
) )
.limit(1); .limit(1);
return task || null; if (!task) return null;
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
if (["pending", "running"].includes(task.status) && task.updatedAt < staleBefore) {
const [interrupted] = await db.update(canvasCapabilityTask).set({
status: "failed",
error: INTERRUPTED_TASK_MESSAGE,
completedAt: new Date(),
updatedAt: new Date(),
}).where(and(
eq(canvasCapabilityTask.id, task.id),
inArray(canvasCapabilityTask.status, ["pending", "running"]),
lt(canvasCapabilityTask.updatedAt, staleBefore),
)).returning();
if (interrupted) return interrupted;
const [latest] = await db.select().from(canvasCapabilityTask)
.where(eq(canvasCapabilityTask.id, task.id)).limit(1);
return latest || task;
}
return task;
} }
export async function createOrRecoverCanvasCapabilityTask(params: { export async function createOrRecoverCanvasCapabilityTask(params: {
@ -42,6 +60,7 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
capabilityId: params.command.capabilityId, capabilityId: params.command.capabilityId,
source: params.command.source, source: params.command.source,
request: params.command, request: params.command,
updatedAt: new Date(),
}) })
.onConflictDoNothing({ target: canvasCapabilityTask.requestId }); .onConflictDoNothing({ target: canvasCapabilityTask.requestId });
@ -50,57 +69,17 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
userId: params.userId, userId: params.userId,
projectId: params.projectId, projectId: params.projectId,
}; };
let task = await getCanvasCapabilityTask(taskLookup); const task = await getCanvasCapabilityTask(taskLookup);
if (!task) { if (!task) {
throw new Error("无法创建画布能力任务"); throw new Error("无法创建画布能力任务");
} }
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS); return task;
if (
task.status === "failed" &&
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
) {
await db
.update(canvasCapabilityTask)
.set({
status: "pending",
error: null,
completedAt: null,
updatedAt: new Date(),
})
.where(eq(canvasCapabilityTask.id, task.id));
} else if (
task.status === "running" &&
task.updatedAt < staleBefore &&
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
) {
await db
.update(canvasCapabilityTask)
.set({
status: "pending",
error: "恢复超时的执行中任务",
updatedAt: new Date(),
})
.where(
and(
eq(canvasCapabilityTask.id, task.id),
lt(canvasCapabilityTask.updatedAt, staleBefore)
)
);
} else if (task.status === "running" && task.updatedAt < staleBefore) {
await db
.update(canvasCapabilityTask)
.set({
status: "failed",
error: "画布能力任务超过最大重试次数",
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(canvasCapabilityTask.id, task.id));
} }
task = (await getCanvasCapabilityTask(taskLookup)) || task; export async function heartbeatCanvasCapabilityTask(taskId: string) {
return task; await db.update(canvasCapabilityTask).set({ updatedAt: new Date() })
.where(and(eq(canvasCapabilityTask.id, taskId), inArray(canvasCapabilityTask.status, ["pending", "running"])));
} }
export async function markCanvasCapabilityTaskRunning(taskId: string) { export async function markCanvasCapabilityTaskRunning(taskId: string) {
@ -115,11 +94,10 @@ export async function markCanvasCapabilityTaskRunning(taskId: string) {
error: null, error: null,
updatedAt: now, updatedAt: now,
}) })
.where(eq(canvasCapabilityTask.id, taskId)) .where(and(eq(canvasCapabilityTask.id, taskId), eq(canvasCapabilityTask.status, "pending")))
.returning(); .returning();
if (!task) throw new Error("画布能力任务不存在"); return task || null;
return task;
} }
export async function markCanvasCapabilityTaskSucceeded( export async function markCanvasCapabilityTaskSucceeded(

View File

@ -0,0 +1,31 @@
export type DownloadFailure = {
code: string;
message: string;
recommendation: string;
status: number;
retryable: boolean;
};
export function getDownloadFailure(error: unknown): DownloadFailure {
const text = error && typeof error === "object" && "message" in error
? `${"name" in error ? error.name : "Error"}: ${error.message}` : String(error || "");
const failure = (code: string, message: string, recommendation: string, status: number, retryable = false) =>
({ code, message, recommendation, status, retryable });
if (/旧存储已停用|storage_retired/.test(text)) return failure("STORAGE_RETIRED", "原图所在的旧存储已停用。", "请重新上传原图;重试下载无法恢复该文件。", 410);
if (/HTTP (?:404|410)|SOURCE_NOT_FOUND/i.test(text)) return failure("SOURCE_NOT_FOUND", "原图链接已失效或源文件已被删除。", "请重新上传原图,或联系管理员核对归档副本;无需重新生成图片。", 404);
if (/HTTP (?:401|403)|SOURCE_FORBIDDEN/i.test(text)) return failure("SOURCE_FORBIDDEN", "图片源站拒绝访问原图。", "请联系管理员检查原图访问权限或链接有效期,并提供下载编号。", 403);
if (/queue.*busy|正在准备的原图较多|DOWNLOAD_BUSY/i.test(text)) return failure("DOWNLOAD_BUSY", "正在准备的下载较多。", "请等待当前下载完成后再试,避免同时重复点击。", 429, true);
if (/TimeoutError|AbortError|timeout|timed out|deadline|超时|HTTP (?:408|504|524)/i.test(text)) return failure("SOURCE_TIMEOUT", "读取原图或准备下载文件超时。", "请稍后再次下载;持续失败时联系管理员并提供下载编号,已归档的原图无需重新生成。", 504, true);
if (/fetch failed|failed to fetch|ECONN|ENOTFOUND|EAI_AGAIN|network|HTTP (?:429|5\d\d)/i.test(text)) return failure("SOURCE_UNAVAILABLE", "暂时无法连接图片源站。", "请稍后重试下载;持续失败时联系管理员检查取图线路,并提供下载编号。", 503, true);
if (/not.*image|error document|不是图片|无法识别图片|empty response|image is empty|Invalid prepared original/i.test(text)) return failure("INVALID_IMAGE", "源地址没有返回可用的图片文件。", "请重新上传可打开的原图;若原图正常,请联系管理员并提供下载编号。", 422);
if (/sharp|pngload|jpegload|unsupported image|无法解析图片|convert|转换/i.test(text)) return failure("IMAGE_CONVERSION_FAILED", "原图无法转换为下载所需的 PNG 文件。", "请保留原图并联系管理员提供下载编号,或重新上传 PNG/JPEG 图片。", 422);
if (/upload|存储桶|保存下载|STORAGE_WRITE_FAILED/i.test(text)) return failure("DOWNLOAD_STORAGE_FAILED", "下载文件保存失败,已归档的原图仍然保留。", "请稍后重试;持续失败时联系管理员检查存储,并提供下载编号。", 503, true);
if (/未登录/.test(text)) return failure("LOGIN_REQUIRED", "登录状态已失效。", "请重新登录后下载,当前图片不会因此删除。", 401);
if (/无法确认原图/.test(text)) return failure("ORIGINAL_IMAGE_UNCONFIRMED", "图片链接包含处理参数,无法确认原图。", "请选择未裁剪、未缩放的原图链接,或重新上传原图。", 400);
if (/图片地址无效/.test(text)) return failure("INVALID_IMAGE_URL", "图片地址无效。", "请刷新画布后重新选择图片;仍然失败时请重新上传原图。", 400);
return failure("DOWNLOAD_FAILED", "下载准备未能完成。", "请联系管理员并提供下载编号,以便核对原图、转换和存储的具体错误;无需重新生成。", 500, true);
}
export function formatDownloadFailure(failure: DownloadFailure, requestId?: string) {
return `${failure.message}${failure.recommendation}${requestId ? `\n下载编号:${requestId}` : ""}`;
}

View File

@ -1,6 +1,10 @@
import "server-only"; import "server-only";
import { createHash } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache"; import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "./download-error";
import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("canvas.download");
export type DownloadPreparationInput = { export type DownloadPreparationInput = {
userId: string; userId: string;
@ -9,17 +13,18 @@ export type DownloadPreparationInput = {
cookie: string; cookie: string;
}; };
type Result = type Result =
| { status: "preparing" } | { status: "preparing"; requestId: string }
| { status: "ready"; asset: ImageBytes } | { status: "ready"; asset: ImageBytes }
| { status: "failed"; error: string }; | { status: "failed"; error: string; failure: DownloadFailure; requestId: string };
type Job = { type Job = {
requestId: string;
userId: string; userId: string;
input: DownloadPreparationInput; input: DownloadPreparationInput;
status: "queued" | "running" | "ready" | "failed"; status: "queued" | "running" | "ready" | "failed";
updatedAt: number; updatedAt: number;
asset?: ImageBytes; asset?: ImageBytes;
failure?: DownloadFailure;
}; };
const FAILURE_MESSAGE = "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
const MAX_MEMORY_BYTES = 128 * 1024 * 1024; const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
// A download is independent of node autosave and of the archive worker's batch. // A download is independent of node autosave and of the archive worker's batch.
@ -69,7 +74,7 @@ export function createDownloadPreparationQueue(deps: {
timer = setTimeout(() => { timer = setTimeout(() => {
controller.abort(); controller.abort();
reject(new Error("Download preparation deadline")); reject(new Error("Download preparation deadline"));
}, deps.timeoutMs || 90_000); }, deps.timeoutMs || 150_000);
}), }),
]); ]);
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) { if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
@ -81,8 +86,10 @@ export function createDownloadPreparationQueue(deps: {
const stored = await deps.read(key).catch(() => null); const stored = await deps.read(key).catch(() => null);
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined; if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
job.status = "ready"; job.status = "ready";
} catch { } catch (error) {
job.status = "failed"; job.status = "failed";
job.failure = getDownloadFailure(error);
logger.warn("preparation_failed", error, { requestId: job.requestId, code: job.failure.code });
} finally { } finally {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
// Do not retain authentication data after a preparation finishes. // Do not retain authentication data after a preparation finishes.
@ -105,8 +112,11 @@ export function createDownloadPreparationQueue(deps: {
prune(); prune();
const current = jobs.get(key); const current = jobs.get(key);
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset }; if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
if (current?.status === "failed") return { status: "failed", error: FAILURE_MESSAGE }; if (current?.status === "failed") {
if (current && current.status !== "ready") return { status: "preparing" }; const failure = current.failure || getDownloadFailure(null);
return { status: "failed", error: formatDownloadFailure(failure, current.requestId), failure, requestId: current.requestId };
}
if (current && current.status !== "ready") return { status: "preparing", requestId: current.requestId };
if (jobs.size >= 64) { if (jobs.size >= 64) {
for (const [oldKey, job] of jobs) { for (const [oldKey, job] of jobs) {
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey); if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
@ -115,11 +125,14 @@ export function createDownloadPreparationQueue(deps: {
} }
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId && if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
(job.status === "queued" || job.status === "running")).length >= 4) { (job.status === "queued" || job.status === "running")).length >= 4) {
return { status: "failed", error: "正在准备的原图较多,请等待当前下载完成后再试" }; const requestId = randomUUID();
const failure = getDownloadFailure("DOWNLOAD_BUSY");
return { status: "failed", error: formatDownloadFailure(failure, requestId), failure, requestId };
} }
jobs.set(key, { userId: input.userId, input, status: "queued", updatedAt: now() }); const requestId = randomUUID();
jobs.set(key, { requestId, userId: input.userId, input, status: "queued", updatedAt: now() });
deps.schedule(drain); deps.schedule(drain);
return { status: "preparing" }; return { status: "preparing", requestId };
}, },
}; };
} }

View File

@ -10,7 +10,7 @@ import { generateCanvasImage, type CanvasGenerateImageRequest } from "@/lib/canv
import { getPointsSystemConfig } from "@/lib/db/queries"; import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config"; import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config";
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error"; import { getUserFacingImageGenerationErrorMessage, getRequestFailureDetails } from "@/lib/request-error";
interface GenerateImageRequest extends CanvasGenerateImageRequest { interface GenerateImageRequest extends CanvasGenerateImageRequest {
async?: boolean; async?: boolean;
@ -121,7 +121,7 @@ export async function executeCanvasImageResponse(body: GenerateImageRequest, use
} catch (error) { } catch (error) {
console.error("[Canvas Generate Image] Error:", error); console.error("[Canvas Generate Image] Error:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, error: getUserFacingImageGenerationErrorMessage(error), { success: false, error: getUserFacingImageGenerationErrorMessage(error), failure: getRequestFailureDetails(error),
...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) }, ...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) },
{ status: error instanceof ProviderTaskUncertainError ? 503 : 500 }, { status: error instanceof ProviderTaskUncertainError ? 503 : 500 },
); );

View File

@ -6,7 +6,7 @@ import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-sto
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing"; import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover"; import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image"; import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image"; import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
@ -43,7 +43,9 @@ import {
shouldFailoverAiModelError, shouldFailoverAiModelError,
} from "@/lib/ai/runtime/model-health"; } from "@/lib/ai/runtime/model-health";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown"; import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { getRequestFailureDetails, isProviderBalanceError } from "@/lib/request-error";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
export interface CanvasLocalEditCompositeRegion { export interface CanvasLocalEditCompositeRegion {
x: number; x: number;
@ -198,6 +200,7 @@ export interface CanvasGenerateImageRequest {
agentImageMaxCreditCost?: number; agentImageMaxCreditCost?: number;
internalFailoverTriedModelRefs?: string[]; internalFailoverTriedModelRefs?: string[];
internalFailoverTriedProviderTypes?: string[]; internalFailoverTriedProviderTypes?: string[];
internalBalanceBlockedProviderIds?: string[];
onQueueStatusChange?: ( onQueueStatusChange?: (
status: CanvasImageModelQueueStatus & { status: CanvasImageModelQueueStatus & {
modelName?: string | null; modelName?: string | null;
@ -4512,7 +4515,7 @@ function findCanvasFailoverModel(params: {
!item.model.isEnabled || !item.model.isEnabled ||
item.model.healthStatus === "auto_disabled" || item.model.healthStatus === "auto_disabled" ||
item.model.isSubModel || item.model.isSubModel ||
isImageFallbackModelBalanceCoolingDown(item.model) || isImageModelBalanceBlocked(item.model) ||
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback) (item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
) { ) {
return false; return false;
@ -4630,10 +4633,10 @@ function findCanvasFailoverModel(params: {
} }
export async function generateCanvasImage( export async function generateCanvasImage(
request: CanvasGenerateImageRequest input: CanvasGenerateImageRequest
): Promise<CanvasGenerateImageResult> { ): Promise<CanvasGenerateImageResult> {
const startedAt = Date.now(); const startedAt = Date.now();
if (request.agentImageMode === "pinned") request = { ...request, allowFailover: false }; const request = input.agentImageMode === "pinned" ? { ...input, allowFailover: false } : input;
const prompt = String(request.prompt || "").trim(); const prompt = String(request.prompt || "").trim();
if (!prompt) { if (!prompt) {
throw new Error("请输入提示词"); throw new Error("请输入提示词");
@ -4871,11 +4874,16 @@ export async function generateCanvasImage(
typeof request.ownerKey === "string" && request.ownerKey.trim() typeof request.ownerKey === "string" && request.ownerKey.trim()
? request.ownerKey.trim() ? request.ownerKey.trim()
: undefined; : undefined;
const referenceImage = const resolveReference = async (url: string) => {
typeof request.referenceImage === "string" ? request.referenceImage : undefined; if (!normalizeLegacyAliyunStorageUrl(url)) return url;
const referenceImages = Array.isArray(request.referenceImages) const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
return resolveLegacyAliyunStorageUrl(url);
};
const referenceImage = typeof request.referenceImage === "string"
? await resolveReference(request.referenceImage) : undefined;
const referenceImages = await Promise.all((Array.isArray(request.referenceImages)
? request.referenceImages ? request.referenceImages
: []; : []).map(resolveReference));
const debugLabel = String(request.debugLabel || "").trim() || "canvas-image"; const debugLabel = String(request.debugLabel || "").trim() || "canvas-image";
const debugMeta = const debugMeta =
request.debugMeta && typeof request.debugMeta === "object" request.debugMeta && typeof request.debugMeta === "object"
@ -4904,7 +4912,20 @@ export async function generateCanvasImage(
); );
const allModels = await getEnabledAiModelsWithProvider(); const allModels = await getEnabledAiModelsWithProvider();
let imageModels = allModels.filter((m) => m.model.supportsImageGen); const balanceBlockedProviderIds = new Set(request.internalBalanceBlockedProviderIds || []);
let imageModels = allModels.filter((m) => m.model.supportsImageGen && !isImageModelBalanceBlocked(m.model) && !balanceBlockedProviderIds.has(m.provider.id));
const requestedModel = allModels.find(entry => entry.model.id === model || entry.model.modelId === model)?.model ||
(/^[0-9a-f-]{36}$/i.test(String(model || "")) ? await getAiModelById({ id: model! }) : undefined);
let effectiveModel = model;
if (requestedModel?.supportsImageGen && isImageModelBalanceBlocked(requestedModel)) {
const replacement = request.allowFailover !== false && request.agentImageMode !== "pinned"
? resolveConfiguredImageBalanceFallback(requestedModel, imageModels, {
imageSize: request.imageSize, aspectRatio: request.aspectRatio,
referenceImageCount: new Set([...(referenceImages || []), referenceImage].filter(Boolean)).size,
}) : undefined;
if (!replacement) throw new Error("当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。");
effectiveModel = replacement.model.id;
}
if (request.agentImageMode === "auto") { if (request.agentImageMode === "auto") {
const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) }))); const states = await Promise.all(imageModels.map(async entry => ({ entry, paused: await agentImageCircuitStore.isPaused(entry) })));
imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" && imageModels = states.filter(item => !item.paused && item.entry.model.healthStatus !== "auto_disabled" &&
@ -4916,8 +4937,8 @@ export async function generateCanvasImage(
throw new Error("暂无可用的图片生成模型"); throw new Error("暂无可用的图片生成模型");
} }
let selectedModel = model let selectedModel = effectiveModel
? imageModels.find((m) => m.model.modelId === model || m.model.id === model) ? imageModels.find((m) => m.model.modelId === effectiveModel || m.model.id === effectiveModel)
: imageModels.find((m) => m.model.isDefault) || imageModels[0]; : imageModels.find((m) => m.model.isDefault) || imageModels[0];
if (!selectedModel) { if (!selectedModel) {
@ -5642,7 +5663,11 @@ export async function generateCanvasImage(
} }
// An accepted or uncertain request must not start another paid model/variant. // An accepted or uncertain request must not start another paid model/variant.
if (caughtError instanceof ProviderTaskUncertainError) throw caughtError; if (caughtError && (
caughtError instanceof ProviderTaskUncertainError ||
getRequestFailureDetails(caughtError).outcome === "unknown" ||
["AbortError", "TimeoutError"].includes(String((caughtError as { name?: string })?.name || ""))
)) throw caughtError;
const retryVariants = Array.isArray(request.retryVariants) const retryVariants = Array.isArray(request.retryVariants)
? request.retryVariants ? request.retryVariants
@ -5654,7 +5679,7 @@ export async function generateCanvasImage(
? request.internalRetryVariantIndex + 1 ? request.internalRetryVariantIndex + 1
: 0; : 0;
const retryVariant = retryVariants[nextRetryVariantIndex]; const retryVariant = retryVariants[nextRetryVariantIndex];
if (retryVariant && !request.agentImageMode) { if (retryVariant && !request.agentImageMode && !isProviderBalanceError(caughtError)) {
const { const {
debugMeta: retryVariantDebugMeta, debugMeta: retryVariantDebugMeta,
label: retryVariantLabel, label: retryVariantLabel,
@ -5710,7 +5735,8 @@ export async function generateCanvasImage(
} else if (request.agentImageMode) { } else if (request.agentImageMode) {
const failure = agentImageFailurePolicy(caughtError); const failure = agentImageFailurePolicy(caughtError);
if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause); if (failure.pause) await agentImageCircuitStore.fail(selectedModel, failure.pause);
} else { }
if (!scaleAdjustRejected && (!request.agentImageMode || isProviderBalanceError(caughtError))) {
await recordAiModelHealthFailure({ await recordAiModelHealthFailure({
model: selectedModel.model, model: selectedModel.model,
provider, provider,
@ -5756,17 +5782,18 @@ export async function generateCanvasImage(
...(Array.isArray(request.internalFailoverTriedProviderTypes) ...(Array.isArray(request.internalFailoverTriedProviderTypes)
? request.internalFailoverTriedProviderTypes ? request.internalFailoverTriedProviderTypes
: []), : []),
...(request.agentImageMode !== "auto" && shouldFailover && ...(request.agentImageMode !== "auto" && !isProviderBalanceError(caughtError) && shouldFailover &&
failureClassification.reason !== "non_provider_failure" && failureClassification.reason !== "non_provider_failure" &&
failureClassification.reason !== "user_cancelled" failureClassification.reason !== "user_cancelled"
? [provider.type] ? [provider.type]
: []), : []),
].filter(Boolean)) ].filter(Boolean))
); );
if (isProviderBalanceError(caughtError)) balanceBlockedProviderIds.add(provider.id);
const fallbackModel = allowFailover && shouldFailover const fallbackModel = allowFailover && shouldFailover
? findCanvasFailoverModel({ ? findCanvasFailoverModel({
selectedModel, selectedModel,
imageModels, imageModels: imageModels.filter(entry => !balanceBlockedProviderIds.has(entry.provider.id)),
failedModelRefs, failedModelRefs,
failedProviderTypes, failedProviderTypes,
preferLowCostSameNameProviders: Boolean( preferLowCostSameNameProviders: Boolean(
@ -5797,6 +5824,7 @@ export async function generateCanvasImage(
model: fallbackModel.model.id, model: fallbackModel.model.id,
internalFailoverTriedModelRefs: failedModelRefs, internalFailoverTriedModelRefs: failedModelRefs,
internalFailoverTriedProviderTypes: failedProviderTypes, internalFailoverTriedProviderTypes: failedProviderTypes,
internalBalanceBlockedProviderIds: [...balanceBlockedProviderIds],
debugMeta: { debugMeta: {
...debugMeta, ...debugMeta,
failoverFromModelId: selectedModel.model.modelId, failoverFromModelId: selectedModel.model.modelId,

View File

@ -0,0 +1,34 @@
import type { RequestFailureDetails } from "@/lib/request-error";
export type ImageGenerationStatusStreamPayload = {
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
userMessageId?: string;
requestId?: string;
failure?: RequestFailureDetails;
providerType?: string;
modelName?: string;
imageCount?: number;
queuePosition?: number;
sameModelRunningCount?: number;
concurrencyLimit?: number;
estimatedWaitMs?: number;
};
export function findImageGenerationFailure(
messages: { role: string; parts?: unknown[] }[],
userMessageId: string,
): ImageGenerationStatusStreamPayload | null {
if (!userMessageId) return null;
for (const message of [...messages].reverse()) {
if (message.role !== "assistant") continue;
for (const part of message.parts || []) {
const candidate = part as { type?: string; data?: ImageGenerationStatusStreamPayload };
if (candidate.type !== "data-imageGenerationStatus") continue;
const status = candidate.data;
if (status?.phase === "failed" && status.failure && status.userMessageId === userMessageId) {
return status;
}
}
}
return null;
}

View File

@ -0,0 +1,5 @@
const PREVIEW_WIDTHS = [640, 960, 1280, 1600, 2048, 2600, 3200, 4096];
export function canvasImagePreviewWidth(width: number, maxWidth: number) {
return Math.min(maxWidth, PREVIEW_WIDTHS.find(value => value >= width) || 4096);
}

View File

@ -38,6 +38,7 @@ import {
} from "@/lib/agents/detail-replacement-batch"; } from "@/lib/agents/detail-replacement-batch";
import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner"; import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner";
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error"; import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
import { trackRuntimeWork } from "@/lib/observability/active-work";
type CanvasImageTaskStatus = type CanvasImageTaskStatus =
| "waiting" | "waiting"
@ -328,7 +329,7 @@ async function markStaleDatabaseRunningTasksFailed() {
.update(canvasImageTask) .update(canvasImageTask)
.set({ .set({
status: "failed", status: "failed",
error: "图片生成任务已超时,请重新发起生成", error: "图片任务执行中断或等待超时,结果尚未确认。请先查看原任务和历史记录,联系管理员核对,避免重复生成。",
completedAt, completedAt,
updatedAt: completedAt, updatedAt: completedAt,
}) })
@ -383,7 +384,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
} }
} }
const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request); const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request);
const execution: Promise<CanvasImageTaskResult> = isStyleProTaskRequest(request) const execution: Promise<CanvasImageTaskResult> = trackRuntimeWork<CanvasImageTaskResult>(() => isStyleProTaskRequest(request)
? executeStyleProTask({ ? executeStyleProTask({
request, request,
userId: String(task.userId || entry.userId), userId: String(task.userId || entry.userId),
@ -412,7 +413,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
ownerKey, ownerKey,
request: imageRequest, request: imageRequest,
}); });
})(); })());
const heartbeat = isDetailReplacementBatchRequest(request) const heartbeat = isDetailReplacementBatchRequest(request)
? setInterval(() => { ? setInterval(() => {
void db.update(canvasImageTask).set({ updatedAt: new Date() }) void db.update(canvasImageTask).set({ updatedAt: new Date() })
@ -426,7 +427,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
try { try {
result = await withCanvasImageTaskExecutionTimeout( result = await withCanvasImageTaskExecutionTimeout(
execution, execution,
runningTimeoutMs, Math.max(runningTimeoutMs, LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS),
entry.taskId entry.taskId
); );
} finally { } finally {
@ -751,7 +752,7 @@ export async function getCanvasImageTaskForUser(params: {
if (shouldMarkStale) { if (shouldMarkStale) {
const staleBefore = new Date(now - runningTimeoutMs); const staleBefore = new Date(now - runningTimeoutMs);
const timeoutMessage = "图片生成任务已超时,请重新发起生成"; const timeoutMessage = "图片任务执行中断或等待超时,结果尚未确认。请先查看原任务和历史记录,联系管理员核对,避免重复生成。";
const completedAt = new Date(); const completedAt = new Date();
const [timedOutTask] = await db const [timedOutTask] = await db
.update(canvasImageTask) .update(canvasImageTask)

View File

@ -0,0 +1,113 @@
export type MarkdownImageSegment =
| { type: "text"; value: string }
| { type: "image"; alt?: string; src: string };
// Markdown destinations may include balanced parentheses and backslash escapes.
function readDestination(content: string, open: number) {
if (content[open] !== "(") return null;
let depth = 1;
for (let index = open + 1; index < content.length; index += 1) {
const character = content[index];
if (character === "\\") {
index += 1;
continue;
}
if (character === "(") depth += 1;
if (character === ")" && --depth === 0) {
const raw = content.slice(open + 1, index);
if (!/^https?:\/\//i.test(raw) || /\s/.test(raw)) return null;
return { url: raw.replace(/\\([()\\])/g, "$1"), end: index + 1 };
}
}
return null;
}
function readImage(content: string, start: number) {
if (!content.startsWith("![", start)) return null;
let depth = 1;
for (let index = start + 2; index < content.length; index += 1) {
const character = content[index];
if (character === "\\") {
index += 1;
continue;
}
if (character === "[") depth += 1;
if (character !== "]" || --depth !== 0) continue;
const destination = readDestination(content, index + 1);
if (!destination) return null;
return {
alt: content.slice(start + 2, index).replace(/\\([\[\]\\])/g, "$1"),
src: destination.url,
end: destination.end,
};
}
return null;
}
export function splitMarkdownImages(content: string): MarkdownImageSegment[] {
const segments: MarkdownImageSegment[] = [];
let textStart = 0;
let cursor = 0;
while (cursor < content.length) {
// Leave Markdown examples in code spans and fences untouched.
if (content[cursor] === "`") {
let endOfRun = cursor + 1;
while (content[endOfRun] === "`") endOfRun += 1;
const marker = content.slice(cursor, endOfRun);
const close = content.indexOf(marker, endOfRun);
cursor = close < 0 ? content.length : close + marker.length;
continue;
}
const lineStart = cursor === 0 || content[cursor - 1] === "\n";
if (lineStart) {
const fence = content.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
if (fence) {
const marker = fence[1];
const closing = new RegExp(`^ {0,3}~{${marker.length},}\\s*$`);
let line = cursor + fence[0].length;
while (line < content.length) {
const next = content.indexOf("\n", line);
const end = next < 0 ? content.length : next;
if (closing.test(content.slice(line, end))) {
line = next < 0 ? end : end + 1;
break;
}
line = next < 0 ? end : end + 1;
}
cursor = line;
continue;
}
}
if (content[cursor] === "\\") {
cursor += 2;
continue;
}
const wrapped = content.startsWith("[![", cursor);
const image = readImage(content, cursor + (wrapped ? 1 : 0));
if (!image) {
cursor += 1;
continue;
}
let end = image.end;
if (wrapped) {
const outer = content[end] === "]" ? readDestination(content, end + 1) : null;
// Preserve incomplete, unsafe and different-target links as Markdown so
// Streamdown applies its normal link sanitization and click behavior.
if (!outer || outer.url !== image.src) {
cursor = outer ? outer.end : end;
continue;
}
end = outer.end;
}
if (cursor > textStart) {
segments.push({ type: "text", value: content.slice(textStart, cursor) });
}
segments.push({ type: "image", alt: image.alt, src: image.src });
cursor = end;
textStart = end;
}
if (textStart < content.length) {
segments.push({ type: "text", value: content.slice(textStart) });
}
return segments;
}

View File

@ -0,0 +1,146 @@
/** A half-open span containing a complete Markdown link or image. */
export type MarkdownImageRange = { start: number; end: number };
type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" };
// This scanner recognizes syntax only; rendering and URL safety remain the
// responsibility of the existing Markdown renderer and image splitter.
function readDestination(text: string, start: number, allowWhitespace = false): Parsed {
if (start >= text.length) return { kind: "pending" };
if (text[start] !== "(") return { kind: "invalid" };
let depth = 1;
for (let i = start + 1; i < text.length; i += 1) {
const char = text[i];
if (char === "\\") {
if (i + 1 >= text.length) return { kind: "pending" };
i += 1;
continue;
}
if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" };
if (char === "(") depth += 1;
if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 };
}
return { kind: "pending" };
}
function readImage(text: string, start: number, wrapped: boolean): Parsed {
const imageStart = start + (wrapped ? 1 : 0);
if (imageStart + 1 >= text.length) return { kind: "pending" };
if (text[imageStart] !== "!" || text[imageStart + 1] !== "[") return { kind: "invalid" };
let depth = 1;
for (let i = imageStart + 2; i < text.length; i += 1) {
const char = text[i];
if (char === "\n" || char === "\r") return { kind: "invalid" };
if (char === "\\") {
if (i + 1 >= text.length) return { kind: "pending" };
i += 1;
continue;
}
if (char === "[") depth += 1;
if (char !== "]" || --depth !== 0) continue;
const inner = readDestination(text, i + 1);
if (inner.kind !== "complete" || !wrapped) return inner;
if (inner.end >= text.length) return { kind: "pending" };
if (text[inner.end] !== "]") return { kind: "invalid" };
return readDestination(text, inner.end + 1);
}
return { kind: "pending" };
}
function readLink(text: string, start: number): Parsed {
let depth = 1;
for (let i = start + 1; i < text.length; i += 1) {
const char = text[i];
// An unfinished bracket on an earlier line must not hide later output.
if (char === "\n" || char === "\r") return { kind: "invalid" };
if (char === "\\") {
if (i + 1 >= text.length) return { kind: "pending" };
i += 1;
continue;
}
if (char === "[") depth += 1;
if (char !== "]" || --depth !== 0) continue;
if (text[i + 1] === "[") {
for (let j = i + 2; j < text.length; j += 1) {
if (text[j] === "\n" || text[j] === "\r") return { kind: "invalid" };
if (text[j] === "\\") {
if (j + 1 >= text.length) return { kind: "pending" };
j += 1;
continue;
}
if (text[j] === "]") return { kind: "complete", end: j + 1 };
}
return { kind: "pending" };
}
return readDestination(text, i + 1, true);
}
return { kind: "pending" };
}
/** Locate complete Markdown links/images and an incomplete trailing construct.
* Code spans/fences and escaped markers remain normal text. */
export function scanMarkdownImageStreaming(text: string): {
ranges: MarkdownImageRange[];
pendingStart: number | null;
} {
const ranges: MarkdownImageRange[] = [];
let cursor = 0;
while (cursor < text.length) {
if (text[cursor] === "\\") { cursor += 2; continue; }
if (text[cursor] === "`") {
let end = cursor + 1;
while (text[end] === "`") end += 1;
const marker = text.slice(cursor, end);
const close = text.indexOf(marker, end);
cursor = close < 0 ? text.length : close + marker.length;
continue;
}
if (cursor === 0 || text[cursor - 1] === "\n") {
const fence = text.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
if (fence) {
const closing = new RegExp(`^ {0,3}~{${fence[1].length},}\\s*$`);
let line = cursor + fence[0].length;
while (line < text.length) {
const next = text.indexOf("\n", line);
const end = next < 0 ? text.length : next;
if (closing.test(text.slice(line, end))) { line = next < 0 ? end : end + 1; break; }
line = next < 0 ? end : end + 1;
}
cursor = line;
continue;
}
}
const char = text[cursor];
if (char !== "[" && char !== "!") { cursor += 1; continue; }
const wrapped = char === "[";
const parsed = wrapped && text[cursor + 1] !== "!"
? readLink(text, cursor)
: readImage(text, cursor, wrapped);
if (parsed.kind === "pending") return { ranges, pendingStart: cursor };
if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; }
else cursor += 1;
}
return { ranges, pendingStart: null };
}
/** During active delivery, withhold only unfinished link/image syntax at the tail. */
export function stableStreamingMarkdown(text: string, streaming: boolean): string {
if (!streaming) return text;
const { pendingStart } = scanMarkdownImageStreaming(text);
return pendingStart === null ? text : text.slice(0, pendingStart);
}
export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) {
let position = Math.min(length, target);
// Overlapping table/link ranges can require more than one jump.
for (let changed = true; changed;) {
changed = false;
for (const range of ranges) {
if (position > range.start && position < range.end) {
position = Math.min(length, range.end);
changed = true;
}
}
}
return position;
}

View File

@ -0,0 +1,96 @@
import "server-only";
import { NextResponse } from "next/server";
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 { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
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 executeBoxCutoutPOST(request: Request, userId: string) {
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
try {
if (!userId) {
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
}
const body = (await request.json()) as BoxCutoutRequest;
const rawImageUrl = String(body.imageUrl || "").trim();
if (!rawImageUrl) {
throw new BoxCutoutServiceError("缺少原图链接,请重新选择图片后提交。", 400);
}
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId,
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({
requestId,
userId,
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, requestId });
} catch (error) {
const failure = getRequestFailureDetails(error, "框选抠图未能完成");
const payload = {
success: false,
error: formatRequestFailure(failure, requestId),
detail: error instanceof Error ? error.message : "框选抠图未能完成",
code: failure.code,
failure,
requestId,
};
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
return NextResponse.json(payload, { status: error.status });
}
console.error("[BoxCutout] 处理失败:", error);
return NextResponse.json(payload, { status: 500 });
}
}

View File

@ -9,9 +9,11 @@ import {
import { getPointsSystemConfig } from "@/lib/db/queries"; import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config"; import { getImageToolCreditCost } from "@/lib/points/config";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) { export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) {
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
try { try {
const session = { user: { id: trustedUserId } }; const session = { user: { id: trustedUserId } };
if (!session?.user?.id) { if (!session?.user?.id) {
@ -27,6 +29,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
let messageId: string | null = null; let messageId: string | null = null;
let skipGallerySave = false; let skipGallerySave = false;
let sourceImageUrl: string | null = null; let sourceImageUrl: string | null = null;
let inputImageUrl: string | null = null;
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
const body = await request.json(); const body = await request.json();
@ -40,6 +43,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
if (imageUrl) { if (imageUrl) {
sourceImageUrl = String(imageUrl); sourceImageUrl = String(imageUrl);
inputImageUrl = sourceImageUrl;
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl); imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
} else if (body.image_base64) { } else if (body.image_base64) {
imageBase64 = String(body.image_base64); imageBase64 = String(body.image_base64);
@ -94,6 +98,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
} }
const result = await executeRmbgJob({ const result = await executeRmbgJob({
requestId,
userId: session.user.id, userId: session.user.id,
imageBuffer, imageBuffer,
imageBase64, imageBase64,
@ -101,6 +106,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
chatId, chatId,
messageId, messageId,
skipGallerySave, skipGallerySave,
inputImageUrl,
sourceImageUrl, sourceImageUrl,
}); });
@ -114,22 +120,29 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
requestId,
...result, ...result,
}); });
} catch (error) { } catch (error) {
if (error instanceof RmbgServiceError) { if (error instanceof RmbgServiceError) {
const failure = getRequestFailureDetails(error);
return NextResponse.json( return NextResponse.json(
{ {
error: error.message, success: false,
error: formatRequestFailure(failure, requestId),
detail: error.detail || error.message, detail: error.detail || error.message,
code: failure.code,
failure,
requestId,
}, },
{ status: error.status } { status: error.status }
); );
} }
console.error("[RMBG API] 处理错误:", error); console.error("[RMBG API] 处理错误:", error);
const failure = getRequestFailureDetails(error, "抠图请求未能完成");
return NextResponse.json( return NextResponse.json(
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" }, { success: false, error: formatRequestFailure(failure, requestId), detail: error instanceof Error ? error.message : "未知错误", code: failure.code, failure, requestId },
{ status: 500 } { status: 500 }
); );
} }

View File

@ -1,6 +1,8 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache"; import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url"; import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
import { getDownloadFailure } from "./download-error";
type DownloadFileItem = { type DownloadFileItem = {
url: string; url: string;
@ -30,6 +32,7 @@ function sleep(ms: number) {
function normalizeDownloadError(error: unknown): string { function normalizeDownloadError(error: unknown): string {
if (error instanceof Error) return error.message || "download failed"; if (error instanceof Error) return error.message || "download failed";
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
if (typeof error === "string") return error; if (typeof error === "string") return error;
return "download failed"; return "download failed";
} }
@ -296,6 +299,9 @@ export async function fetchCanvasDownloadAsset(params: {
retryCount?: number; retryCount?: number;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<CanvasDownloadAssetResult> { }): Promise<CanvasDownloadAssetResult> {
if (isRetiredStorageUrl(params.file.url)) {
return { ok: false, url: params.file.url, name: params.file.name, error: RETIRED_STORAGE_MESSAGE };
}
let lastError = ""; let lastError = "";
const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS; const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT; const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT;
@ -326,6 +332,7 @@ export async function fetchCanvasDownloadAsset(params: {
try { try {
const data = await fetchImageBytes(candidateUrl, { const data = await fetchImageBytes(candidateUrl, {
timeoutMs, timeoutMs,
signal: params.signal,
headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie), headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie),
// Authenticated local assets must never share a cache across users. // Authenticated local assets must never share a cache across users.
scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie
@ -340,11 +347,13 @@ export async function fetchCanvasDownloadAsset(params: {
}); });
} catch (error) { } catch (error) {
params.signal?.throwIfAborted(); params.signal?.throwIfAborted();
lastError = normalizeDownloadError(error); const message = normalizeDownloadError(error);
if (!lastError || getDownloadFailure(message).retryable || !getDownloadFailure(lastError).retryable) lastError = message;
} }
} }
// Archiving may have completed during the failed fetch. Resolve aliases // Archiving may have completed during the failed fetch. Resolve aliases
// again before retrying, rather than spending every attempt on the old URL. // again before retrying, rather than spending every attempt on the old URL.
if (!getDownloadFailure(lastError).retryable) break;
if (attempt < retryCount) await sleep(350 * attempt); if (attempt < retryCount) await sleep(350 * attempt);
} }

View File

@ -40,22 +40,6 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
async function executeScaleAdjustRmbgJob(
params: Parameters<typeof executeRmbgJob>[0]
) {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await executeRmbgJob(params);
} catch (error) {
lastError = error;
if (attempt === 3) break;
await new Promise((resolve) => setTimeout(resolve, attempt * 800));
}
}
throw lastError;
}
function rectToPixelBox( function rectToPixelBox(
rect: CanvasScaleAdjustRect, rect: CanvasScaleAdjustRect,
sourceWidth: number, sourceWidth: number,
@ -220,7 +204,7 @@ async function buildPersonExclusionAlpha(params: {
userId: string; userId: string;
}) { }) {
try { try {
const person = await executeScaleAdjustRmbgJob({ const person = await executeRmbgJob({
foregroundType: "person", foregroundType: "person",
imageBuffer: params.sourceBuffer, imageBuffer: params.sourceBuffer,
prompt: "比例调整人物保护蒙版", prompt: "比例调整人物保护蒙版",
@ -330,7 +314,7 @@ export async function buildScaleAdjustProductLockAssets(params: {
.png({ compressionLevel: 6 }) .png({ compressionLevel: 6 })
.toBuffer(); .toBuffer();
semanticSourceCropBuffers.push(cropBuffer); semanticSourceCropBuffers.push(cropBuffer);
const subject = await executeScaleAdjustRmbgJob({ const subject = await executeRmbgJob({
imageBuffer: cropBuffer, imageBuffer: cropBuffer,
prompt: "比例调整完整产品蒙版", prompt: "比例调整完整产品蒙版",
skipGallerySave: true, skipGallerySave: true,

View File

@ -0,0 +1,55 @@
export type CanvasDraft = { base: string; snapshot: string };
const key = (projectId: string) => `canvas-unsynced:${projectId}`;
export function readCanvasDraft(projectId: string): CanvasDraft | null {
try {
const value = JSON.parse(localStorage.getItem(key(projectId)) || "null");
return typeof value?.base === "string" && typeof value?.snapshot === "string" ? value : null;
} catch {
return null;
}
}
export function writeCanvasDraft(projectId: string, draft: CanvasDraft): boolean {
try {
localStorage.setItem(key(projectId), JSON.stringify(draft));
return true;
} catch {
return false;
}
}
export function clearCanvasDraft(projectId: string) {
try {
localStorage.removeItem(key(projectId));
} catch {
// Keep the current in-memory canvas when browser storage is unavailable.
}
}
const composerKey = (chatId: string) => `canvas-unsent-message:${chatId}`;
export function writeCanvasComposerDraft(chatId: string, draft: { text: string; attachments: unknown[] }) {
try {
localStorage.setItem(composerKey(chatId), JSON.stringify(draft));
return true;
} catch {
return false;
}
}
export function readCanvasComposerDraft<T>(chatId: string): { text: string; attachments: T[] } | null {
try {
const value = JSON.parse(localStorage.getItem(composerKey(chatId)) || "null");
return typeof value?.text === "string" && Array.isArray(value?.attachments) ? value : null;
} catch {
return null;
}
}
export function clearCanvasComposerDraft(chatId: string) {
try {
localStorage.removeItem(composerKey(chatId));
} catch {
// Keep the current editor contents when browser storage is unavailable.
}
}

View File

@ -1,5 +1,6 @@
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving"; export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
@ -26,6 +27,8 @@ export class CanvasImageDownloadError extends Error {
code: CanvasImageDownloadErrorCode; code: CanvasImageDownloadErrorCode;
retryAfterSeconds?: number; retryAfterSeconds?: number;
status?: number; status?: number;
failure?: DownloadFailure;
requestId?: string;
constructor( constructor(
message: string, message: string,
@ -33,6 +36,8 @@ export class CanvasImageDownloadError extends Error {
code: CanvasImageDownloadErrorCode; code: CanvasImageDownloadErrorCode;
retryAfterSeconds?: number; retryAfterSeconds?: number;
status?: number; status?: number;
failure?: DownloadFailure;
requestId?: string;
} }
) { ) {
super(message); super(message);
@ -40,11 +45,14 @@ export class CanvasImageDownloadError extends Error {
this.code = options.code; this.code = options.code;
this.retryAfterSeconds = options.retryAfterSeconds; this.retryAfterSeconds = options.retryAfterSeconds;
this.status = options.status; this.status = options.status;
this.failure = options.failure;
this.requestId = options.requestId;
} }
} }
export function getCanvasImageDownloadErrorMessage(error: unknown) { export function getCanvasImageDownloadErrorMessage(error: unknown) {
if (error instanceof CanvasImageDownloadError) { if (error instanceof CanvasImageDownloadError) {
if (error.failure) return formatDownloadFailure(error.failure, error.requestId);
if (error.code === "rate_limited") { if (error.code === "rate_limited") {
return error.retryAfterSeconds return error.retryAfterSeconds
? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试` ? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试`
@ -54,10 +62,10 @@ export function getCanvasImageDownloadErrorMessage(error: unknown) {
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试"; return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
} }
if (error.code === "preparation_timeout") { if (error.code === "preparation_timeout") {
return "原图仍在后台准备中,请稍后再次点击下载"; return `原图仍在后台准备中,请稍后再次点击下载${error.requestId ? `\n下载编号:${error.requestId}` : ""}`;
} }
} }
return error instanceof Error ? error.message : "图片下载失败,请稍后重试"; return formatDownloadFailure(getDownloadFailure(error));
} }
type DownloadCanvasImageParams = { type DownloadCanvasImageParams = {
@ -71,6 +79,8 @@ type DownloadCanvasImageParams = {
type DownloadErrorPayload = { type DownloadErrorPayload = {
error?: string; error?: string;
retryAfter?: number; retryAfter?: number;
failure?: DownloadFailure;
requestId?: string;
}; };
const MAX_RATE_LIMIT_RETRIES = 1; const MAX_RATE_LIMIT_RETRIES = 1;
@ -166,6 +176,63 @@ function triggerBlobDownload(blob: Blob, filename: string) {
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} }
function triggerCdnDownload(result: { downloadUrl: string; filename: string; bytes?: number }, signal?: AbortSignal) {
if (typeof result.downloadUrl !== "string" || new URL(result.downloadUrl).protocol !== "https:") {
throw new Error("下载链接无效");
}
signal?.throwIfAborted();
const frame = document.createElement("iframe");
frame.hidden = true;
frame.src = result.downloadUrl;
document.body.appendChild(frame);
window.setTimeout(() => frame.remove(), 10 * 60_000);
return { filename: result.filename, bytes: result.bytes || 0 };
}
/** Keep the source format and bytes for chat, gallery and reference downloads. */
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
const source = String(url || "").trim();
if (!source) throw new Error("图片地址无效");
const proxy = buildImageProxyUrl(source, { delivery: "original" });
const filenameBase = sanitizeDownloadName(name || inferDownloadNameFromUrl(source), "image");
const target = new URL(proxy, window.location.origin);
onProgress?.({ phase: "preparing", message: "正在准备原图..." });
const checkedFetch = async (input: string) => {
let response: Response;
try { response = await fetch(input, { signal }); }
catch (error) {
if (signal?.aborted) throw error;
const failure = getDownloadFailure(error);
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
}
if (!response.ok) {
const payload = await response.json().catch(() => null) as DownloadErrorPayload | null;
throw new CanvasImageDownloadError(payload?.error || "原图下载失败", { code: "request_failed",
status: response.status, failure: payload?.failure || getDownloadFailure(`HTTP ${response.status}`), requestId: payload?.requestId });
}
return response;
};
if (target.origin === window.location.origin && target.pathname === "/api/image-proxy") {
target.searchParams.set("download", "resolve");
target.searchParams.set("name", filenameBase);
const metadata = await checkedFetch(target.pathname + target.search);
const delivery = await metadata.json();
if (delivery.delivery === "cdn") {
const result = triggerCdnDownload(delivery, signal);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return result;
}
if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
}
const response = await checkedFetch(proxy);
const blob = await readResponseBlobWithProgress(response, onProgress);
if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
triggerBlobDownload(blob, filename);
return { filename, bytes: blob.size };
}
function toDownloadCandidates(url: string, candidates: string[] = []) { function toDownloadCandidates(url: string, candidates: string[] = []) {
const raw = String(url || "").trim(); const raw = String(url || "").trim();
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim(); const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
@ -257,6 +324,7 @@ export async function downloadCanvasImageAsPng({
let preparationPolls = 0; let preparationPolls = 0;
for (;;) { for (;;) {
signal?.throwIfAborted(); signal?.throwIfAborted();
try {
response = await fetch("/api/canvas/download-image", { response = await fetch("/api/canvas/download-image", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -269,6 +337,11 @@ export async function downloadCanvasImageAsPng({
}), }),
signal, signal,
}); });
} catch (error) {
if (signal?.aborted) throw error;
const failure = getDownloadFailure(error);
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
}
if (response.status === 202) { if (response.status === 202) {
rateLimitRetries = 0; rateLimitRetries = 0;
@ -278,7 +351,7 @@ export async function downloadCanvasImageAsPng({
throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 }); throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 });
} }
if (Date.now() >= preparationDeadline || preparationPolls >= 60) { if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202 }); throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
} }
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" }); onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3)); const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
@ -299,6 +372,8 @@ export async function downloadCanvasImageAsPng({
code: "rate_limited", code: "rate_limited",
retryAfterSeconds, retryAfterSeconds,
status: response.status, status: response.status,
failure: errorPayload?.failure,
requestId: errorPayload?.requestId,
} }
); );
} }
@ -321,22 +396,18 @@ export async function downloadCanvasImageAsPng({
{ {
code: status === 502 ? "source_unavailable" : "request_failed", code: status === 502 ? "source_unavailable" : "request_failed",
status, status,
failure: errorPayload?.failure,
requestId: errorPayload?.requestId,
} }
); );
} }
if (response.headers.get("content-type")?.includes("application/json")) { if (response.headers.get("content-type")?.includes("application/json")) {
const result = await response.json(); const result = await response.json();
if (result.delivery !== "cdn" || typeof result.downloadUrl !== "string" if (result.delivery !== "cdn") throw new Error("下载链接无效");
|| new URL(result.downloadUrl).protocol !== "https:") throw new Error("下载链接无效"); const downloaded = triggerCdnDownload(result, signal);
if (signal?.aborted) throw signal.reason || new DOMException("已取消下载", "AbortError");
const frame = document.createElement("iframe");
frame.hidden = true;
frame.src = result.downloadUrl;
document.body.appendChild(frame);
window.setTimeout(() => frame.remove(), 10 * 60_000);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" }); onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return { filename: result.filename, bytes: result.bytes || 0 }; return downloaded;
} }
const filename = const filename =

View File

@ -1,11 +1,20 @@
/** Serialize local saves so each request reads the revision returned by the last. */ /** Serialize local saves so each request reads the revision returned by the last. */
export function createCanvasSaveQueue() { export function createCanvasSaveQueue() {
let pending: Promise<unknown> = Promise.resolve(); let pending: Promise<unknown> = Promise.resolve();
return { const queue = {
enqueue<T>(save: () => Promise<T>): Promise<T> { enqueue<T>(save: () => Promise<T>): Promise<T> {
const next = pending.then(save, save); const next = pending.then(save, save);
pending = next.catch(() => undefined); pending = next.catch(() => undefined);
return next; return next;
}, },
}; enqueueOperation(save: () => Promise<boolean>, operation: () => Promise<void>) {
return queue.enqueue(async () => {
if (!(await save())) {
throw new Error("画布尚未保存成功,请确认网络或画布同步状态后重试");
}
await operation();
});
},
};
return queue;
} }

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