Compare commits

..

198 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
Codex
36efdfae53 fix(deploy): replace only the target PM2 process definition 2026-10-30 19:15:56 +08:00
10efb4c1e7 Merge pull request 'build: 独立封装发布依赖并用写入时复制控制空间' (#3) from codex/production-48-runtime-snapshot into main 2026-10-30 18:43:39 +08:00
Codex
9da598967a build: snapshot complete release runtime with copy-on-write files 2026-10-30 18:42:59 +08:00
91edc4019e Merge pull request 'build: 建立 main 到 48 / PM2 的可回滚发布流程' (#2) from codex/production-48-release-pipeline into main 2026-10-30 18:23:21 +08:00
Codex
7439792d40 build: add verified PM2 release workflow for production 48 2026-10-30 18:22:11 +08:00
be642948ec chore(repo): restore main from the verified production source baseline (#1)
Preserve production history and recovered source changes, with portable repository validation.

Production snapshot and previous main are retained by tags. This merge updates source management only; no production deployment or data migration was performed.
2026-10-30 16:39:57 +08:00
Codex
9155682238 chore(repo): make the recovered production baseline portable
Record source and release ownership, keep source line endings consistent,
and exclude local build/config backups from version control.

Restore default Next type paths, normalize CI checks across line endings,
and use portable Playwright and postbuild entry points on Windows/Linux.
Align the stale timeout fixture with the existing production timeout policy.

Application runtime files remain identical to production-snapshot-48-20261030.
Production build and focused regressions passed; pre-existing full-repository
lint findings remain documented instead of relaxing lint rules.
2026-10-30 16:19:08 +08:00
Codex
03b8a0deb0 chore(repo): preserve production-48 source snapshot
Recover the existing 19 modified tracked files and 6 untracked source files
from the production working tree without changing their contents.

Production source HEAD: 439fd56fe120fdb9f855b50fa4df9ce2e71bb72a
Production build ID: QhoygcZ4-x42PwCgCEj1d
Captured: 2026-10-30; all 25 source file SHA256 values verified.
This is a source recovery checkpoint; no deployment was performed.
2026-10-30 15:37:23 +08:00
root
439fd56fe1 style(canvas): use brain icon for image agent toggle 2026-10-22 20:50:46 +08:00
root
99db635e0a feat(canvas): default image composer to direct generation 2026-10-22 19:02:59 +08:00
134
14a7668274 fix(agent): verify delivered images when the planner reply disconnects 2026-10-21 16:15:24 +08:00
134
01485340b5 fix(agent): ask before replacing refused pinned image models and settle completed actions 2026-10-21 16:10:09 +08:00
134
8784bb783e fix(agent): honor requests not to download during review 2026-10-21 14:06:35 +08:00
134
82c71b8cd8 fix(agent): finish verified edits after ancillary download actions 2026-10-21 13:59:32 +08:00
134
90c17a61fb fix(agent): enforce structured JSON for vision inspection 2026-10-21 13:54:15 +08:00
134
404d783fa5 test(agent): give vision fixtures a provider identity 2026-10-21 13:42:03 +08:00
134
1705f0ba43 fix(agent): keep official DeepSeek available across task rounds 2026-10-21 13:39:54 +08:00
134
b689f8f770 fix(agent): support DeepSeek official multimodal tool conversations 2026-10-21 12:52:03 +08:00
134
7541262f28 fix(agent): require a real deliverable before bypassing failed read checks 2026-10-21 12:10:56 +08:00
134
75e6a28309 chore(deploy): include UTC-safe checks for active canvas tasks 2026-10-21 12:06:00 +08:00
134
7925545980 fix(agent): recover read timeouts and align model waits with safe UTC deployment checks 2026-10-21 12:06:00 +08:00
134
59994062c9 fix(agent): retire multi-angle tools and use reference-image generation for viewpoint edits 2026-10-21 11:54:13 +08:00
134
fb10747828 fix(agent): recover unavailable automatic image choices and correct regression fixtures 2026-10-21 10:40:52 +08:00
134
d2d0a1725c fix(agent): keep pinned image features from substituting missing models 2026-10-21 10:40:52 +08:00
134
f38488b546 fix(agent): recover image parameter compatibility errors without false model bans 2026-10-21 10:35:23 +08:00
134
36761e37a4 fix(agent): retain image fault records and show actionable recovery messages 2026-10-21 10:30:30 +08:00
134
32e8c9cbd6 fix(agent): handle legacy image service failure messages 2026-10-21 10:30:30 +08:00
134
2a2a32c3bd fix(agent): recognize image provider HTTP and quota failures 2026-10-21 10:24:41 +08:00
134
d4a6b54078 fix(agent): preserve legacy automatic image model selection 2026-10-21 10:22:54 +08:00
134
d8aa51185e feat(agent): recover automatic image generation without switching user-pinned models 2026-10-21 10:22:54 +08:00
134
73f9844022 fix(agent): consider the full fallback pool within the request deadline 2026-10-21 10:00:50 +08:00
134
1803727d4b fix(agent): reject malformed model envelopes before tool execution 2026-10-21 09:54:37 +08:00
134
8848d00107 fix(agent): distinguish empty upstream answers from policy refusals 2026-10-21 09:49:41 +08:00
134
623859f846 fix(agent): encode circuit timestamps for PostgreSQL comparisons 2026-10-21 09:45:28 +08:00
134
f55d21968f fix(agent): cancel vision failover when the task stops 2026-10-21 09:45:07 +08:00
134
ad25b4e17f feat(agent): add persistent 30-minute multimodal failover circuits 2026-10-21 09:43:12 +08:00
134
5dbdc3df46 fix(agent): restrict cutout completion to explicit single-action requests 2026-10-21 09:21:15 +08:00
134
cfb071d0df fix(agent): finish verified cutouts without repeated visual review 2026-10-21 09:17:23 +08:00
134
fce14356d9 fix(canvas): collapse image parameter captions before they truncate 2026-10-21 09:00:01 +08:00
134
ac2e355a7a fix(canvas): adapt composer controls to panel width and remove reference placeholder 2026-10-21 08:55:47 +08:00
134
a8c6a5a026 fix(canvas): restore inline Agent history from existing cache payloads 2026-10-21 00:27:14 +08:00
134
1965ee9cc0 fix(canvas): serialize pre-Agent saves with scheduled autosave 2026-10-21 00:12:25 +08:00
134
0d44ac12c8 fix(canvas): integrate Agent activity into chat messages without internal branding 2026-10-21 00:03:16 +08:00
134
c7f320074a fix(canvas): capture current image model and canvas save callback at Agent admission 2026-10-20 23:40:27 +08:00
134
62311d187f feat(canvas): unify image composer with native Agent and honor generation controls 2026-10-20 23:30:49 +08:00
134
a4bf68734c fix(canvas): defer expired archive cleanup after ZIP admission 2026-10-20 23:03:15 +08:00
134
145f699823 fix(canvas): release ZIP staging bytes and revalidate recovery files 2026-10-20 18:51:58 +08:00
134
8ee12a5b13 fix(canvas): prepare durable selection ZIPs before resumable download 2026-10-20 18:44:42 +08:00
134
0a3eef1f4d feat(agent): complete non-video business grants with verified contracts 2026-10-20 18:19:10 +08:00
134
0fd14f18bb fix(canvas-agent): block video-only controls in shared image menus 2026-10-20 17:53:50 +08:00
134
ac9103906e fix(canvas-agent): apply core dump limit to sandbox process tree 2026-10-20 17:46:02 +08:00
134
320d52d26f fix(canvas-agent): preserve server disk reserve for sandbox tasks 2026-10-20 17:45:01 +08:00
134
2bd2910fae fix(canvas-agent): load sandbox host runtime outside web asset tracing 2026-10-20 17:45:01 +08:00
134
54ce53f263 fix(canvas-agent): cover legacy tools and recover confirmed request rejections 2026-10-20 17:33:12 +08:00
134
f0b6c2765f feat(canvas-agent): grant audited project tools and pause video authorization 2026-10-20 17:29:03 +08:00
root
d3b8162faf fix(canvas-agent): preserve production autosave confirmation 2026-10-20 17:26:51 +08:00
134
ac54ade6b7 fix(canvas-agent): avoid production migration numbering collision 2026-10-20 15:03:19 +08:00
134
1aa61570db feat(canvas-agent): run native ZCode core in isolated task sandboxes 2026-10-20 15:02:01 +08:00
134
3504141be5 fix(canvas): quarantine unstable detailed inspection behind explicit opt-in 2026-10-20 02:35:23 +08:00
134
79d10cf1ac fix(canvas): retry uncertain text inspection without replaying image generation 2026-10-20 02:01:12 +08:00
134
3a9e392a7f fix(canvas): preserve text legibility and isolate product detail verification 2026-10-20 01:54:23 +08:00
134
07101946ff fix(canvas): independently verify product support structure before accepting replacements 2026-10-20 01:10:53 +08:00
134
8111ce7708 fix(canvas): bind visual verification roles and preserve measured product proportions 2026-10-20 00:35:37 +08:00
134
a03ac48f3a fix(canvas): accept structured OCR differences without overlooking numeric changes 2026-10-19 21:52:09 +08:00
134
1dd4487cf0 fix(canvas): verify template copy independently and diversify vision fallback 2026-10-19 20:58:09 +08:00
134
6319a3ceef fix(canvas): restore template text pixels before replacement quality checks 2026-10-19 20:10:04 +08:00
134
4734d5cb76 fix(canvas): keep source pixels authoritative over inferred product facts 2026-10-19 19:46:12 +08:00
134
d33db04ecc fix(canvas): lock observed product structure during template edits 2026-10-19 19:41:19 +08:00
134
ab7240b3d4 fix(canvas): focus replacement product and prefer healthy editors 2026-10-19 19:30:50 +08:00
134
55ad5b21f0 fix(canvas): validate template replacement direction and product identity 2026-10-19 19:23:44 +08:00
134
562b9a3f1d fix(canvas): prepare original downloads independently of archiving 2026-10-19 18:45:23 +08:00
134
30cfd62669 fix(agent): fail over rejected credentials and allow local image downloads 2026-10-19 17:57:12 +08:00
134
255e9082da fix(flow-board): prevent runtime recovery from reloading edits 2026-10-19 17:30:10 +08:00
134
53e565045a fix(canvas): deduplicate archived image result replays 2026-10-19 17:05:16 +08:00
134
a2b65b426c fix(canvas): reuse save transaction for image archive lookups 2026-10-19 14:42:00 +08:00
134
d1789f5f93 fix(ai): honor provider chat contracts and prevent unsafe retries 2026-10-15 16:05:26 +08:00
134
726d66bd19 fix(canvas): prevent stale autosaves from overwriting generated images 2026-10-15 14:52:51 +08:00
134
74aa67ce44 fix(psd): preserve dimension annotation positions in image exports
(cherry picked from commit e6dbb612ce9902f7ef8ff193279645390a795b49)
2026-10-15 11:33:41 +08:00
134
f83779e65e fix(canvas): scope dark divider color to the chat panel surface 2026-10-12 18:44:02 +08:00
134
10ef40b734 fix(canvas): adapt chat panel divider to dark mode 2026-10-12 18:26:17 +08:00
134
850f664fb1 fix(models): keep Nano Banana sync compatible with project types 2026-10-12 16:58:02 +08:00
134
3750e89ed1 feat(models): add targeted VIP98K nano-banana-2.1 synchronization 2026-10-12 16:53:57 +08:00
134
c0cbc34911 test(canvas): verify batch downloads reuse stored PNG variants 2026-10-12 15:35:20 +08:00
134
dfc1c6d496 perf(canvas): reuse durable prepared PNGs in batch and fallback downloads 2026-10-12 15:34:45 +08:00
134
425d3a4ddf test(canvas): exercise native CDN downloads with a browser adapter 2026-10-12 15:28:46 +08:00
134
1a806b4275 perf(canvas): share image bytes and deliver prepared downloads through Qiniu CDN 2026-10-12 15:28:05 +08:00
134
47e1009ee0 fix(images): allow capability preview reads through the access proxy 2026-10-12 08:10:21 +08:00
134
50569ca88a feat(canvas): deliver image previews before background archiving and remove generation caps 2026-10-12 08:03:58 +08:00
134
c3f5903a86 feat(flow-board): add rich notes, Gantt tasks and PNG export
Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
2026-10-06 18:06:29 +08:00
134
9611fafe0b feat(flow-board): add native XMind markers and bulk editing 2026-10-05 11:49:36 +08:00
134
9f98a68049 fix(flow-board): support image paste into mind map topics
Handle clipboard images while editing or selecting a topic and route Insert image through the same topic attachment behavior. Read image data locally in preview mode and retain the production upload path.

Add four browser regression cases for real clipboard paste, undo/redo, export resources, canvas images, and text input isolation. Record validation in the client comparison notes.
2026-10-05 10:56:37 +08:00
134
822c4cbfb9 fix(flow-board): align Xmind editor UI and restore marquee feedback
Add native-style menus, search and presentation controls, typography resources, and Xmind import/export styling support.

Bundle canvas styles for preview and production, show marquee borders and selection fills, and align editor density with the project UI. Include interaction regression coverage and client comparison notes.
2026-10-04 18:57:02 +08:00
134
a492d082ed feat: recreate XMind editing and file workflows 2026-10-04 18:56:53 +08:00
134
4ada7eaf38 fix: polish canvas icons, context menu and panel layout 2026-10-04 12:13:47 +08:00
134
63e2f48192 fix: restore canvas skills and local uploads, simplify home and canvas headers 2026-10-04 10:31:53 +08:00
134
d376f5959f feat: refresh home and canvas UI with customizable image toolbar 2026-10-04 03:41:03 +08:00
134
2c8f306e74 docs(tvstar): record live production evidence and remaining gaps 2026-10-03 17:58:58 +08:00
134
c4a2753a6e fix(tvstar): enforce structured video analysis and reject truncation 2026-10-03 17:48:41 +08:00
134
0d752d6e38 fix(character-studio): separate identity from view composition 2026-10-03 17:34:01 +08:00
134
96d67955cb fix(canvas): preserve uncertain image submissions without paid failover 2026-10-03 17:29:31 +08:00
134
c45a6a77a0 fix(tvstar): isolate shot inputs and reject observed visual defects 2026-10-03 16:56:57 +08:00
134
88cf96b998 fix(tvstar): validate live video contracts and avoid duplicate planning 2026-10-03 16:18:56 +08:00
134
570375b5f1 fix(video-canvas): enforce production delivery requirements and analyze real footage 2026-10-03 15:33:10 +08:00
774 changed files with 55400 additions and 6965 deletions

View File

@ -153,8 +153,9 @@ PRODUCT_PLANNING_DB_SSL=false
# Home Performance / Queues # Home Performance / Queues
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
CANVAS_IMAGE_TASK_CONCURRENCY=3 # Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000 CANVAS_IMAGE_TASK_CONCURRENCY=0
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3 EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20 EXTERNAL_IMAGE_API_MAX_QUEUE=20
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000 EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000

20
.gitattributes vendored Normal file
View File

@ -0,0 +1,20 @@
# Keep source checkouts consistent across Windows and Linux.
.gitattributes text eol=lf
.gitignore text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.json text eol=lf
*.jsonc text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.sh text eol=lf
*.py text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf

10
.gitignore vendored
View File

@ -13,6 +13,7 @@ coverage
.next/ .next/
.next-build/ .next-build/
.next-previous/ .next-previous/
/.next-*/
out/ out/
build build
@ -67,6 +68,11 @@ migration-backup-complete/
migration-*.log migration-*.log
migration-*.txt migration-*.txt
# Local release backups, model credentials and migration scratch data.
/.model-config-backups/
/backups/
/tmp/
# 本地抓包/竞品参考产物,不能提交 # 本地抓包/竞品参考产物,不能提交
artifacts/liblib-*/ artifacts/liblib-*/
analysis/qiaodou-extract/ analysis/qiaodou-extract/
@ -128,3 +134,7 @@ services/video-upscale/vendor/__pycache__/
/services/audio-processing/dist/ /services/audio-processing/dist/
/services/audio-processing/__pycache__/ /services/audio-processing/__pycache__/
# Python release-tool and test bytecode.
__pycache__/
*.pyc

View File

@ -1078,139 +1078,15 @@ export function LocalAiRuntimeSections({
</Button> </Button>
</LocalAiSectionShell> </LocalAiSectionShell>
{/* 全站生图单模型并发 */}
<LocalAiSectionShell <LocalAiSectionShell
description={ description="生图任务直接发起,图片先显示,后台转存到存储桶。"
<>
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
</>
}
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")} expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
id="local-ai-section-canvas-image-task-queue" id="local-ai-section-canvas-image-task-queue"
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")} onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
summary={ summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />}
<LocalAiSectionSummary title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>}
items={[
{
label: `${canvasImageTaskQueueConfig.concurrency} 并发/模型`,
tone: "neutral",
},
{
label: `${(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒补位`,
tone: "neutral",
},
{ label: "全站生图生效", tone: "enabled" },
]}
/>
}
title={
<span className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
全站生图单模型并发与批次间隔
</span>
}
> >
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]"> <p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 text-sm">
<div className="font-medium">生效范围</div>
<div className="mt-2 space-y-1 text-amber-800 text-xs leading-5">
<p>
•
影响画布异步生图、智能体直调生图、视频分镜高清预览等全站生图入口。
</p>
<p>
•
多用户共享同一套并发限制,但会按模型分别计数,不同模型之间不再互相阻塞。
</p>
<p>
• 当前配置的是单模型并发,不是全站总并发,也不是单用户并发。
</p>
<p>
•
批次间隔只控制排队补位的“下一批”发起节奏,不影响首批任务即时启动。
</p>
<p>
• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key
池继续细分。
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="canvasImageTaskQueueConcurrency">
单模型最大同时运行任务数
</Label>
<Input
id="canvasImageTaskQueueConcurrency"
max={MAX_CANVAS_IMAGE_TASK_CONCURRENCY}
min={MIN_CANVAS_IMAGE_TASK_CONCURRENCY}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
concurrency: e.target.value,
})
)
}
type="number"
value={canvasImageTaskQueueConfig.concurrency}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_TASK_CONCURRENCY}-
{MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "}
{canvasImageTaskQueueConfig.concurrency} 并发,默认值为{" "}
{DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasImageTaskBatchDispatchIntervalSeconds">
下一批排队任务发起间隔(秒)
</Label>
<Input
id="canvasImageTaskBatchDispatchIntervalSeconds"
max={MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
min={MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
batchDispatchIntervalMs: Math.round(
Number.parseFloat(e.target.value || "0") * 1000
),
})
)
}
step="0.5"
type="number"
value={String(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
)}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}-
{MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000} 秒,当前为{" "}
{(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)}{" "}
秒,默认值为{" "}
{(
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000
).toFixed(1)}{" "}
秒。
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveCanvasImageTaskQueueConfig}>
<Save className="mr-2 h-4 w-4" /> 保存并发与间隔
</Button>
</div>
</LocalAiSectionShell> </LocalAiSectionShell>
{/* 批量处理配置 */} {/* 批量处理配置 */}

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,8 +1,11 @@
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
import { createLogger } from "@/lib/observability/logger"; import { 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,
@ -194,6 +197,7 @@ import {
} from "@/lib/ai/runtime/agent-media-markdown"; } from "@/lib/ai/runtime/agent-media-markdown";
import { extractImageUrlsFromText } from "@/lib/image-url"; import { extractImageUrlsFromText } from "@/lib/image-url";
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence"; import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
import { withAuthenticationFailover } from "@/lib/ai/runtime/authentication-failover";
import { import {
recordAiModelHealthFailure, recordAiModelHealthFailure,
recordAiModelHealthSuccess, recordAiModelHealthSuccess,
@ -333,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,
@ -341,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,
@ -419,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;
@ -437,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),
@ -904,6 +905,15 @@ export async function POST(request: Request) {
const json = await request.json(); const json = await request.json();
chatApiLogger.info("[Chat API] Received request body:", JSON.stringify(json, null, 2)); chatApiLogger.info("[Chat API] Received request body:", JSON.stringify(json, null, 2));
requestBody = postRequestBodySchema.parse(json); requestBody = postRequestBodySchema.parse(json);
// Agent/Auto Agent has one execution owner: the isolated native ZCode entry.
// This also upgrades existing browser tabs without routing Claw here.
if (requestBody.composerMode === "agent" && requestBody.contextPayload?.source === "canvas" && requestBody.contextPayload.projectId) {
const { POST: nativeAgentPost } = await import("@/app/api/canvas/agent-chat/route");
return nativeAgentPost(new Request(new URL("/api/canvas/agent-chat", request.url), {
method: "POST", headers: request.headers, body: JSON.stringify(json),
}));
}
} catch (error) { } catch (error) {
chatApiLogger.error("[Chat API] Schema validation failed:", error); chatApiLogger.error("[Chat API] Schema validation failed:", error);
return new ChatSDKError("bad_request:api").toResponse(); return new ChatSDKError("bad_request:api").toResponse();
@ -1198,6 +1208,14 @@ export async function POST(request: Request) {
} }
} }
if (shouldRunAgentConversation && preferredAgentModelId && !shouldKeepSelectedModel) {
const preferred = await getAiModelById({ id: preferredAgentModelId }).catch(() => null);
if (!preferred?.isEnabled || preferred.healthStatus === "auto_disabled") {
preferredAgentModelId = undefined;
configuredAgentConversationModelId = undefined;
}
}
if (shouldRunAgentConversation && !preferredAgentModelId) { if (shouldRunAgentConversation && !preferredAgentModelId) {
const configuredAgentModel = await getAgentAiModelWithProvider().catch((agentModelError) => { const configuredAgentModel = await getAgentAiModelWithProvider().catch((agentModelError) => {
chatApiLogger.warn("[Chat API] Failed to load configured agent model:", agentModelError); chatApiLogger.warn("[Chat API] Failed to load configured agent model:", agentModelError);
@ -1318,6 +1336,7 @@ export async function POST(request: Request) {
} }
const allowAgentVideoGeneration = const allowAgentVideoGeneration =
(contextPayload?.source === "canvas" && inferVideoTaskRequirements(requestTextContent).expectsVideo) ||
Boolean(requestBody.videoParams) || Boolean(requestBody.videoParams) ||
isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined); isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined);
let smartAutoWorkflowDecision: let smartAutoWorkflowDecision:
@ -5661,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,
@ -6003,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"
@ -6158,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,
@ -6390,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 ||
@ -6941,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"
@ -7266,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"
@ -7494,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"
@ -7812,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 ||
@ -8021,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,
@ -8059,6 +8087,7 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
...directImageStatusPayload, ...directImageStatusPayload,
}, },
@ -8151,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,
@ -8171,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) {
@ -8748,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(
@ -8865,7 +8882,7 @@ export async function POST(request: Request) {
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
const activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor( let activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
resolvedRuntimeModelLookupId, resolvedRuntimeModelLookupId,
).catch(() => null); ).catch(() => null);
if (shouldRunAgentConversation && activeRuntimeModelDescriptor) { if (shouldRunAgentConversation && activeRuntimeModelDescriptor) {
@ -9155,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,
@ -9242,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;
} }
} }
@ -9316,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;
} }
} }
} }
@ -9383,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,
@ -9447,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...");
// 故障转移:尝试使用火山引擎模型 // 故障转移:尝试使用火山引擎模型
@ -9524,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,
@ -9589,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?.();
} }
@ -9826,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,
@ -9932,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,
@ -9981,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})`);
@ -10004,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);
@ -10025,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;
} }
} }
@ -10052,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})`);
@ -10136,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);
@ -10157,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;
} }
} }
} }
@ -10224,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,
@ -10288,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?.();
} }
@ -10453,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,
@ -10498,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) {
@ -10664,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,
@ -10728,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?.();
} }
@ -11222,7 +11168,7 @@ export async function POST(request: Request) {
requestHeaders: request.headers, requestHeaders: request.headers,
requestUrl: request.url, requestUrl: request.url,
defaultModelId: finalModelId, defaultModelId: finalModelId,
destructiveConfirmed: isCanvasOperationConfirmText(requestTextContent), authorizeDestructive: (nodeIds) => isExplicitDestructiveIntent(requestTextContent) && nodeIds.length > 0 && nodeIds.every((nodeId) => selectedCanvasImageNodeIds.includes(nodeId)),
callbacks: { callbacks: {
onClientCommand: (command, capability) => { onClientCommand: (command, capability) => {
writeCanvasCapabilityCommandChunk( writeCanvasCapabilityCommandChunk(
@ -11337,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,
@ -11428,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;
} }
} }
@ -11599,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,
@ -11658,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;
} }
@ -12251,8 +12156,70 @@ export async function POST(request: Request) {
const shouldDisableSmoothStream = const shouldDisableSmoothStream =
resolvedModelSupportsImageGen || isVolcanoVideoModel; resolvedModelSupportsImageGen || isVolcanoVideoModel;
let activeConversationModel = currentModelConfig;
let activeConversationProvider = currentProviderConfig;
const recordedAuthFailures = new Set<string>();
const recordConversationAuthFailure = async (error: unknown) => {
const key = activeConversationModel?.id;
if (!key || recordedAuthFailures.has(key)) return;
recordedAuthFailures.add(key);
recordProviderHealthFailure({
providerKey: activeConversationProvider?.id,
providerType: activeConversationProvider?.type,
error,
});
await recordAiModelHealthFailure({
model: activeConversationModel,
provider: activeConversationProvider,
error,
context: "canvas_agent_conversation_auth",
}).catch((healthError) => chatApiLogger.warn("[Chat API] Could not persist model authentication failure:", healthError));
};
const needsVisionFallback = convertedMessages.some((item: any) =>
Array.isArray(item.content) && item.content.some((part: any) =>
part.type === "image" || (part.type === "file" && String(part.mediaType || "").startsWith("image/"))
)
);
const streamModel = shouldRunAgentConversation && !shouldKeepSelectedModel &&
currentModelConfig?.excludeFromFailover !== true &&
!resolvedModelSupportsImageGen && !isVolcanoVideoModel
? withAuthenticationFailover({
primary: languageModel as any,
onAuthenticationFailure: recordConversationAuthFailure,
resolveFallback: async () => {
const enabled = await getEnabledAiModelsWithProvider(false);
const preferredIds = [currentModelConfig?.fallbackModelId1, currentModelConfig?.fallbackModelId2];
const candidates = enabled.filter(({ model, provider }) =>
model.id !== activeConversationModel?.id && model.isEnabled && provider.isEnabled &&
!model.excludeFromFailover && !model.isSubModel && model.healthStatus !== "auto_disabled" &&
!isImageGenerationModelType(provider.type, model) && !isVideoProviderType(provider.type, model) &&
(!needsVisionFallback || model.supportsVision) &&
(isAiModelAgentAuto(model) || preferredIds.includes(model.id)) &&
!shouldAvoidProvider(getProviderHealthSnapshot(provider.id))
).sort((a, b) => {
const rank = (id: string, isDefault: boolean) => {
const index = preferredIds.indexOf(id);
return index >= 0 ? index : isDefault ? 2 : 3;
};
return rank(a.model.id, a.model.isDefault) - rank(b.model.id, b.model.isDefault);
});
for (const candidate of candidates) {
const fallback = await getLanguageModelById(candidate.model.id);
if (!fallback || typeof fallback === "string") continue;
activeConversationModel = candidate.model;
activeConversationProvider = candidate.provider;
finalModelId = candidate.model.id;
actualModelId = candidate.model.modelId;
activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(candidate.model.id).catch(() => null);
return fallback as any;
}
return null;
},
})
: languageModel;
const result = streamText({ const result = streamText({
model: languageModel, model: streamModel,
maxRetries: shouldRunAgentConversation ? 0 : undefined,
system: effectiveConversationSystemPrompt, system: effectiveConversationSystemPrompt,
messages: convertedMessages, messages: convertedMessages,
stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5), stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),

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

@ -3,10 +3,5 @@ export default function FlowBoardLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return <>{children}</>;
<>
<link href="/vendor/drawnix.css" rel="stylesheet" />
{children}
</>
);
} }

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

@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { and, eq, inArray, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject, imageArchiveTask, generatedImage, canvasImageTask } from "@/lib/db/schema";
import { imageArchiveSourceKey } from "@/lib/storage/image-archive-db";
import { isGeneratedImageSource } from "@/lib/storage/image-archive-urls";
import { scheduleImageArchiveWorker } from "@/lib/storage/image-archive-worker";
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const { id } = await params;
const [project] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)))
.limit(1);
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const body = await request.json();
if (
!Array.isArray(body.urls) ||
body.urls.length > 100 ||
body.urls.some((url: unknown) => typeof url !== "string" || !isGeneratedImageSource(url))
) {
return NextResponse.json({ error: "图片地址格式错误" }, { status: 400 });
}
const urls: string[] = [...new Set<string>(body.urls)];
if (!urls.length) return NextResponse.json({ replacements: {}, pending: [] });
const source = imageArchiveTask.sourceUrl;
const rows = await db
.select({
sourceUrl: imageArchiveTask.sourceUrl,
targetUrl: imageArchiveTask.targetUrl,
status: imageArchiveTask.status,
})
.from(imageArchiveTask)
.where(
and(
inArray(imageArchiveTask.sourceUrl, urls.map(imageArchiveSourceKey)),
sql`(
exists (select 1 from ${generatedImage} where ${generatedImage.userId} = ${session.user.id}
and (${generatedImage.originalUrl} = ${source} or ${generatedImage.thumbnailUrl} = ${source}
or ${generatedImage.originalUrl} = ${imageArchiveTask.targetUrl} or ${generatedImage.thumbnailUrl} = ${imageArchiveTask.targetUrl}))
or exists (select 1 from ${canvasImageTask} where ${canvasImageTask.userId} = ${session.user.id}
and (position(${source} in ${canvasImageTask.result}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasImageTask.result}::text) > 0))
or exists (select 1 from ${canvasProject} where ${canvasProject.id} = ${id} and ${canvasProject.userId} = ${session.user.id}
and (position(${source} in ${canvasProject.canvasData}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasProject.canvasData}::text) > 0))
)`,
),
);
const byKey = new Map(rows.map((row) => [row.sourceUrl, row]));
const replacements: Record<string, string> = {};
const pending: string[] = [];
for (const url of urls) {
const row = byKey.get(imageArchiveSourceKey(url));
if (row?.targetUrl) replacements[url] = row.targetUrl;
else if (row && row.status !== "abandoned") pending.push(url);
}
if (rows.some((row) => row.status === "pending")) scheduleImageArchiveWorker(true);
return NextResponse.json(
{ replacements, pending },
{ headers: { "Cache-Control": "private, no-store" } },
);
}

View File

@ -3,6 +3,7 @@ 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';
import { syncCanvasNodesForProject } from '@/lib/db/queries'; import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { import {
buildStaleCanvasSaveConflictPayload, buildStaleCanvasSaveConflictPayload,
@ -122,7 +123,8 @@ export async function PUT(
); );
} }
const { title, description, thumbnail, canvasData } = body; const { title, description, canvasData } = body;
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
const baseCanvasRevision = const baseCanvasRevision =
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision; body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType = const workspaceType =
@ -205,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

@ -0,0 +1,40 @@
import { PROJECT_AGENT_OPERATIONS, projectOperationAuthorized } from "@/lib/ai/canvas-agent-runtime/project-operation-catalog";
import { NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { getSystemSetting } from "@/lib/db/queries";
import {
normalizeVideoCanvasFunctionConfig,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
} from "@/lib/video-canvas/config";
import { getCanvasAgentAccessManifest } from "@/lib/ai/canvas-agent-runtime/capability-access";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const parsed = z.string().uuid().safeParse(new URL(request.url).searchParams.get("projectId"));
if (!parsed.success) return NextResponse.json({ error: "画布编号无效" }, { status: 400 });
const [project] = await db
.select({ id: canvasProject.id, workspace: canvasProject.workspaceType })
.from(canvasProject)
.where(and(eq(canvasProject.id, parsed.data), eq(canvasProject.userId, session.user.id)))
.limit(1);
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const config = normalizeVideoCanvasFunctionConfig(
(await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }))?.value,
);
return NextResponse.json(
{
workspace: project.workspace,
projectServices: PROJECT_AGENT_OPERATIONS.map(item => ({ ...item, authorized: projectOperationAuthorized(item) })),
utilityTools: ["getWeather", "createDocument", "updateDocument", "requestSuggestions", "loadProjectSkill"],
capabilities: getCanvasAgentAccessManifest(config).filter(
(item) => item.workspace === (project.workspace === "video" ? "video" : "canvas"),
),
},
{ headers: { "Cache-Control": "private, no-store" } },
);
}

View File

@ -0,0 +1,491 @@
import { agentModelCandidates } from "@/lib/ai/runtime/agent-model-failover";
import { agentModelCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
import { createAgentVisionExecutor } from "@/lib/ai/runtime/agent-vision-failover";
import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options";
import { registerProjectAgentTools } from "@/lib/ai/canvas-agent-runtime/project-tools";
import { isZcodeWorkspaceAuthorized } from "@/lib/ai/canvas-agent-runtime/authorization";
import {
createUIMessageStream,
createUIMessageStreamResponse,
generateText,
} from "ai";
import { after, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { siteSettings, canvasProject } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { postRequestBodySchema } from "@/app/(chat)/api/chat/schema";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import {
getAgentAiModelWithProvider,
getAccessibleAgentPresetById,
getEnabledAiModelsWithProvider,
getChatById,
getMessagesByChatId,
getMessageCountByUserId,
getSiteSettings,
saveChat,
saveMessages,
} from "@/lib/db/queries";
import { runZcodeCanvasTask } from "@/lib/ai/zcode-agent/task";
import { canvasAgentReplyText } from "@/lib/ai/zcode-agent/assistant-output";
import { createZcodeModelGateway } from "@/lib/ai/zcode-agent/model-gateway";
import { newAgentRunSnapshot, publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
import {
acquireAgentRun,
agentRunView,
AgentRunError,
checkpointAgentRun,
consumeAgentRunInputs,
heartbeatAgentRun,
} from "@/lib/ai/canvas-agent-runtime/store";
import { readOwnedCanvas } from "@/lib/ai/canvas-agent-runtime/canvas";
import { buildCanvasRuntimeTools } from "@/lib/ai/canvas-agent-runtime/tools";
import { canvasRuntimeSystemPrompt } from "@/lib/ai/canvas-agent-runtime/prompt";
import { verifyCanvasAgentResults } from "@/lib/ai/canvas-agent-runtime/review";
import { inferCanvasTaskRequirements } from "@/lib/ai/canvas-agent-runtime/requirements";
import {
resolveRuntimeConversationSources,
persistRuntimeConversationResults,
} from "@/lib/ai/canvas-agent-runtime/conversation";
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
import { canApplyCanvasAgentMutations } from "@/lib/canvas/agent-feature-flags";
import {
videoAgentSettingsSchema,
inferVideoTaskRequirements,
videoNodeUrl,
videoRuntimeSystemPrompt,
} from "@/lib/ai/canvas-agent-runtime/video-protocol";
import { readOwnedVideoCanvas } from "@/lib/ai/canvas-agent-runtime/video-store";
import {
buildVideoRuntimeTools,
createVideoRuntimePorts,
} from "@/lib/ai/canvas-agent-runtime/video-tools";
import { probeVideoMetadata } from "@/lib/ai/canvas-agent-runtime/video-probe";
import { verifyVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-review";
export const runtime = "nodejs";
export const maxDuration = 1200;
const requestSchema = postRequestBodySchema.extend({
resumeRunId: z.string().uuid().optional(),
selectedImageModel: z.string().min(1).max(180).optional(),
workspaceVariant: z.enum(["canvas", "video"]).default("canvas"),
videoAgentSettings: videoAgentSettingsSchema.optional(),
});
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = requestSchema.parse(await request.json());
if (!shouldUseCanvasAgentRuntime(body))
throw new AgentRunError("此请求不属于画布 Agent 任务", 400);
const projectId = body.contextPayload!.projectId!;
const [ownedProject] = await db.select({ userId: canvasProject.userId, workspaceType: canvasProject.workspaceType })
.from(canvasProject).where(eq(canvasProject.id, projectId)).limit(1);
if (!ownedProject || ownedProject.userId !== session.user.id) throw new AgentRunError("画布不存在", 404);
if (!isZcodeWorkspaceAuthorized(ownedProject.workspaceType)) throw new AgentRunError("视频画布暂未授权给 ZCode Agent,请使用手工功能。", 403);
const isVideo = ownedProject.workspaceType === "video";
const preset = body.agentPresetId ? await getAccessibleAgentPresetById({ id: body.agentPresetId, userId: session.user.id }) : null;
if (preset && String(preset.type) === "video") throw new AgentRunError("视频技能暂未授权给 ZCode Agent", 403);
if (body.agentPresetId && (!preset || preset.status !== "active")) throw new AgentRunError("此 Agent 预设不可用", 403);
const objective = body.message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
if (!objective) throw new AgentRunError("请描述需要完成的创作任务", 400);
const settings = await getSiteSettings();
if (
!settings.agentEnabled ||
!canApplyCanvasAgentMutations(await getCanvasAgentFeatureFlagsFromSystemSettings())
) {
throw new AgentRunError("管理员尚未开启画布 Agent 操作", 403);
}
const [usageSettings] = await db.select().from(siteSettings).limit(1);
if (usageSettings?.usageTimeEnabled) {
const time = new Intl.DateTimeFormat("en-GB", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(new Date());
if (
time < (usageSettings.usageTimeStart || "08:00") ||
time > (usageSettings.usageTimeEnd || "20:00")
)
throw new AgentRunError(usageSettings.usageTimeMessage || "当前不在系统使用时间段内", 403);
}
const count = await getMessageCountByUserId({ id: session.user.id, differenceInHours: 24 });
if (count > entitlementsByUserType[session.user.type].maxMessagesPerDay)
throw new AgentRunError("今日对话次数已达到限额", 429);
const imageCanvas = isVideo ? null : await readOwnedCanvas(session.user.id, projectId);
if (imageCanvas?.project.workspaceType === "video")
throw new AgentRunError("请使用视频画布 Agent 入口", 400);
const videoCanvas = isVideo ? await readOwnedVideoCanvas(session.user.id, projectId) : null;
const nodes = imageCanvas?.nodes || [];
const availableIds = isVideo
? videoCanvas!.nodes.map((node) => node.id)
: nodes.map((node) => node.id);
const selectedNodeIds = body.contextPayload?.selectedNodeIds || [];
if (selectedNodeIds.some((id) => !availableIds.includes(id))) {
throw new AgentRunError("选中图片尚未保存到画布,请等待保存完成后重试。");
}
const existingChat = await getChatById({ id: body.id });
if (existingChat && existingChat.userId !== session.user.id)
throw new AgentRunError("对话不存在", 404);
if (!existingChat)
await saveChat({
id: body.id,
userId: session.user.id,
title: objective.slice(0, 80),
visibility: "private",
});
const conversation = isVideo
? {
sourceNodeIds: selectedNodeIds,
targetReason: "video-selected-nodes",
previousContext: null,
}
: await resolveRuntimeConversationSources({
userId: session.user.id,
chatId: body.id,
projectId,
text: objective,
parts: body.message.parts,
nodes,
selectedNodeIds,
});
const candidates = agentModelCandidates(await getAgentAiModelWithProvider(), await getEnabledAiModelsWithProvider());
const configured = candidates[0];
if (!configured) throw new AgentRunError("请先在后台配置可用的多模态 Agent 对话模型", 503);
let model = await getLanguageModelById(configured.model.id);
for (const candidate of candidates.slice(1)) {
if (model) break;
model = await getLanguageModelById(candidate.model.id);
}
if (!model) throw new AgentRunError("Agent 模型暂不可用", 503);
const visionModel = model;
const executeVisionModel = createAgentVisionExecutor(candidates, agentModelCircuitStore, getLanguageModelById);
// Validate the user's image choice independently of the Agent planning model.
if (body.selectedImageModel) {
const images = await getStyleProImageModelEntries();
if (!images.some(({ model }) => model.id === body.selectedImageModel && model.healthStatus !== "auto_disabled"))
throw new AgentRunError("你选择的生图模型当前不可用,请重新选择;不会自动换模型生成。", 400);
}
const assistantMessageId = crypto.randomUUID();
const previousVideoMessages =
existingChat
? (await getMessagesByChatId({ id: body.id })).slice(-6).map((message) => ({
role: message.role as "user" | "assistant",
content: (message.parts as Array<{ type: string; text?: string }>)
.filter((part) => part.type === "text")
.map((part) => part.text || "")
.join("\n")
.slice(0, 4000),
}))
: [];
const initialSnapshot = newAgentRunSnapshot(objective, [
...previousVideoMessages,
{ role: "user", content: objective },
]);
initialSnapshot.workspace = isVideo ? "video" : "canvas";
if (isVideo) {
initialSnapshot.videoRequirements = inferVideoTaskRequirements(objective);
initialSnapshot.videoSettings = videoAgentSettingsSchema.parse({
...body.videoAgentSettings,
...(initialSnapshot.videoRequirements.duration
? { duration: initialSnapshot.videoRequirements.duration }
: {}),
...(initialSnapshot.videoRequirements.ratio
? { ratio: initialSnapshot.videoRequirements.ratio }
: {}),
});
}
initialSnapshot.sourceNodeIds = conversation.sourceNodeIds;
initialSnapshot.requirements = {
...inferCanvasTaskRequirements(objective, initialSnapshot.sourceNodeIds.length),
sourceCount: initialSnapshot.sourceNodeIds.length,
};
const run = await acquireAgentRun({
id: body.message.id,
userId: session.user.id,
projectId,
chatId: body.id,
assistantMessageId,
resumeRunId: body.resumeRunId,
snapshot: initialSnapshot,
});
const snapshot = run.snapshot;
snapshot.imagePreferences = {
...snapshot.imagePreferences,
// Each submitted turn captures the current controls, including switching back to Auto.
modelId: body.selectedImageModel,
parameters: body.imageParams || snapshot.imagePreferences?.parameters,
};
const taskSourceNodeIds = snapshot.sourceNodeIds ?? selectedNodeIds;
const context = {
recentConversation: previousVideoMessages,
preset: preset ? { name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId, defaultParams: preset.defaultParams, slots: preset.slots.map(slot => ({ title: slot.title, promptTemplate: slot.promptTemplate, defaultParams: slot.defaultParams })) } : null,
videoSettings: snapshot.videoSettings,
selectedNodeIds: taskSourceNodeIds,
nodes: nodes
.filter((node) => taskSourceNodeIds.includes(node.id))
.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
size: node.size,
title: node.data.title,
})),
skillInstructions: body.hiddenPrompt || undefined,
imagePreferences: snapshot.imagePreferences,
targetReason: conversation.targetReason,
previousConversationContext: conversation.previousContext,
};
await saveMessages({
messages: [
{
id: body.message.id,
chatId: body.id,
role: "user",
parts: body.message.parts,
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
let archiveVideoResults: (() => Promise<void>) | undefined;
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const emit = (type: string, data: unknown) => writer.write({ type, data, ...(type === "data-canvasAgentRun" ? { transient: true } : {}) } as never);
let cancelled = false;
let leaseLost = false;
const activeModelController = new AbortController();
const withModel: typeof executeVisionModel = (execute, signal) => executeVisionModel(execute,
signal ? AbortSignal.any([signal, activeModelController.signal]) : activeModelController.signal);
const heartbeat = setInterval(() => {
void heartbeatAgentRun(run)
.then((current) => {
if (!current) leaseLost = true;
if (current?.cancelRequested) cancelled = true;
if (cancelled || leaseLost) activeModelController?.abort();
})
.catch(() => {
leaseLost = true;
activeModelController?.abort();
});
emit("data-keepalive", { label: "canvas-agent-runtime", ts: Date.now() });
}, 10_000);
const textId = crypto.randomUUID();
writer.write({ type: "start", messageId: assistantMessageId });
writer.write({ type: "text-start", id: textId });
let streamedText = "";
const emitAssistantText = (delta: string) => {
streamedText += delta;
writer.write({ type: "text-delta", id: textId, delta });
};
try {
const videoPorts = isVideo
? await createVideoRuntimePorts({
userId: session.user.id,
chatId: body.id,
projectId,
runId: run.id,
assistantMessageId,
request,
snapshot,
emit,
})
: null;
archiveVideoResults = videoPorts?.archiveResults;
const tools = isVideo
? buildVideoRuntimeTools({
runId: run.id,
projectId,
selectedNodeIds,
snapshot,
ports: videoPorts!,
})
: await buildCanvasRuntimeTools({
userId: session.user.id,
chatId: body.id,
projectId,
runId: run.id,
assistantMessageId,
selectedNodeIds: taskSourceNodeIds,
request,
model: visionModel || model,
supportsVision: Boolean(visionModel),
withModel,
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
imagePreferences: snapshot.imagePreferences,
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
operationSelectedNodeIds: selectedNodeIds,
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
"\n",
),
emit,
});
await registerProjectAgentTools({ session, projectId, request, objective: snapshot.objective,
definitions: tools.definitions, executable: tools.executable, emit,
imagePreferences: snapshot.imagePreferences });
const gateway = await createZcodeModelGateway(configured, undefined, candidates);
const status = await runZcodeCanvasTask({
runId: run.id, snapshot, definitions: tools.definitions,
instructions: (isVideo ? videoRuntimeSystemPrompt : canvasRuntimeSystemPrompt)(snapshot.objective, snapshot.summary, snapshot.inputs.map(input => input.text), context),
context, gateway, signal: activeModelController.signal,
onAssistantText: emitAssistantText,
tools: tools.executable,
checkpoint: async (state, stateStatus) => {
if (leaseLost) throw new AgentRunError("任务执行已中断");
const saved = await checkpointAgentRun(run, state, stateStatus);
cancelled ||= saved.cancelRequested;
emit("data-canvasAgentRun", agentRunView(saved));
},
consumeInputs: async (state) => {
if (leaseLost) throw new AgentRunError("任务执行已中断");
const control = await consumeAgentRunInputs(run, state);
cancelled ||= control.cancelled;
return { cancelled };
},
isCancelled: async () => {
const current = await heartbeatAgentRun(run);
if (!current) throw new AgentRunError("任务执行已中断");
cancelled ||= current.cancelRequested;
return cancelled;
},
verify: async (state) =>
isVideo
? verifyVideoAgentResults({
snapshot: state,
nodes: (await readOwnedVideoCanvas(session.user.id, projectId)).nodes,
inspect: async (node, requirements) => {
const metadata = await probeVideoMetadata(videoNodeUrl(node));
const report = await videoPorts!.analyze({
mode: "video-analysis",
prompt: `请基于真实抽帧画面检查主体、场景、镜头连贯性,说明不确定内容。用户要求:${requirements}`,
imageUrls: [],
videoUrls: [videoNodeUrl(node)],
});
const verdict = await generateText({
model,
system:
'你是视频验收员。依据实际抽帧分析判断是否满足用户要求;不能从文件名或提示词判断。若报告无法读取真实帧,必须不通过。抽帧只能检查画面,不能证明音频、精确时长或完整运动,涉及这些要求无法确认时说明需要人工检查。仅返回 JSON {"passed":boolean,"issues":string[]}。',
prompt: JSON.stringify({ requirements, report }),
maxOutputTokens: 1200,
maxRetries: 0,
abortSignal: AbortSignal.timeout(60_000),
});
state.inputTokens += verdict.usage.inputTokens || 0;
state.outputTokens += verdict.usage.outputTokens || 0;
const match = verdict.text.match(/\{[\s\S]*\}/);
if (!match) throw new Error("视频检查未返回有效结果");
return {
...z
.object({ passed: z.boolean(), issues: z.array(z.string()).max(12) })
.parse(JSON.parse(match[0])),
...metadata,
};
},
})
: verifyCanvasAgentResults({
snapshot: state,
userId: session.user.id,
projectId,
model: visionModel || model,
supportsVision: Boolean(visionModel),
withModel,
signal: activeModelController.signal,
}),
});
const finalText = canvasAgentReplyText(snapshot);
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot, status }));
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
if (!isVideo)
await persistRuntimeConversationResults({
userId: session.user.id,
chatId: body.id,
projectId,
assistantMessageId,
snapshot,
}).catch((error) => {
console.warn("[Canvas Agent] Conversation memory update failed", {
runId: run.id,
error: publicAgentError(error),
});
});
await saveMessages({
messages: [
{
id: assistantMessageId,
chatId: body.id,
role: "assistant",
parts: [{ type: "text", text: finalText }, { type: "data-canvasAgentProgress", data: progress }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
emitAssistantText(finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : `\n\n${snapshot.message}`);
} catch (error) {
const statusText = publicAgentError(error);
const text = canvasAgentReplyText({ ...snapshot, message: statusText });
await checkpointAgentRun(run, { ...snapshot, message: statusText }, "needs_review").catch(
() => undefined,
);
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot: { ...snapshot, message: statusText }, status: "needs_review" }));
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
await saveMessages({ messages: [{ id: assistantMessageId, chatId: body.id, role: "assistant",
parts: [{ type: "text", text }, { type: "data-canvasAgentProgress", data: progress }],
attachments: [], createdAt: new Date(), videoStorageStatus: null, temporaryVideoUrl: null,
permanentVideoUrl: null, archivedAt: null, videoMetadata: null }] }).catch(() => undefined);
emitAssistantText(text.startsWith(streamedText) ? text.slice(streamedText.length) : `\n\n${statusText}`);
} finally {
clearInterval(heartbeat);
writer.write({ type: "text-end", id: textId });
writer.write({ type: "finish" });
}
},
});
// Browser disconnection does not cancel paid image work. Keep consuming on the server.
const [client, background] = stream.tee();
after(async () => {
const reader = background.getReader();
try {
while (!(await reader.read()).done) {
/* Drain the server branch. */
}
} finally {
reader.releaseLock();
await archiveVideoResults?.();
}
});
return createUIMessageStreamResponse({
stream: client,
headers: { "Cache-Control": "no-store", "X-Accel-Buffering": "no" },
});
} catch (error) {
return NextResponse.json(
{ error: publicAgentError(error), message: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}
}

View File

@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
AgentRunError,
agentRunView,
assertAgentRunOwnership,
controlAgentRun,
getAgentRun,
} from "@/lib/ai/canvas-agent-runtime/store";
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
export const dynamic = "force-dynamic";
const scope = z.object({
chatId: z.string().uuid(),
projectId: z.string().uuid(),
runId: z.string().uuid().optional(),
});
const control = scope
.extend({
runId: z.string().uuid(),
action: z.enum(["steer", "cancel"]),
inputId: z.string().uuid().optional(),
text: z.string().trim().min(1).max(2000).optional(),
})
.refine(
(input) => input.action !== "steer" || Boolean(input.inputId && input.text),
"补充要求缺少内容或请求号",
);
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const input = scope.parse(Object.fromEntries(new URL(request.url).searchParams));
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
const run = await getAgentRun(session.user.id, input.chatId, input.projectId, input.runId);
return NextResponse.json(
{ run: run ? agentRunView(run) : null },
{ headers: { "Cache-Control": "private, no-store" } },
);
} catch (error) {
return failure(error);
}
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const input = control.parse(await request.json());
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
return NextResponse.json({ run: await controlAgentRun({ ...input, userId: session.user.id }) });
} catch (error) {
return failure(error);
}
}
function failure(error: unknown) {
return NextResponse.json(
{ error: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}

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,13 @@
import { NextRequest, NextResponse } from "next/server"; import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
import { randomUUID } from "node:crypto";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger";
import { after, NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
import { import {
buildAttachmentContentDisposition, buildAttachmentContentDisposition,
fetchCanvasDownloadAsset, fetchCanvasDownloadAsset,
@ -8,6 +16,50 @@ 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) {
// A proxy candidate for the same original must not repeat the same slow
// upstream request through our own server (and create another HTTP timeout).
const sources = [...new Set(input.urls.map(url => {
const source = extractImageProxySourceUrl(url) || url;
return inferOriginalImageUrl(source) || source;
}))];
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt++) {
signal.throwIfAborted();
for (const url of sources) {
signal.throwIfAborted();
const result = await fetchCanvasDownloadAsset({
file: { url, name: "original" }, index: 0,
requestOrigin: input.requestOrigin, cookie: input.cookie,
includeSequencePrefix: false, timeoutMs: 60_000, retryCount: 1, signal,
});
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
lastError = result.error;
}
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
// No AI/model request is involved and a completed source fetch is shared.
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
else break;
}
throw new Error(String(lastError || "Original image sources unavailable"));
}
function getRequestOrigin(request: NextRequest): string { function getRequestOrigin(request: NextRequest): string {
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim(); const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
@ -46,21 +98,63 @@ 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];
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) {
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
logger.warn("cdn_lookup_failed", error, { requestId });
return { task: null, direct: null, canPrepareDirect: false };
});
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
if (task && canPrepareDirect && body.preparation === "async") {
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
return failed(asset.failure || getDownloadFailure(null), task.id);
}
scheduleImageDownloadPreparation(task.id);
return preparing(task.id);
}
if (task?.targetUrl) {
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
scheduleImageDownloadPreparation(task.id);
}
}
const cookie = request.headers.get("cookie") || ""; const cookie = request.headers.get("cookie") || "";
if (body.preparation === "async") {
const result = await getDownloadPreparationQueue(prepareDownload, after).request({
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
});
if (result.status === "preparing") {
return preparing(result.requestId);
}
if (result.status === "failed") {
return failed(result.failure, result.requestId);
}
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
"Content-Type": result.asset.contentType,
"Content-Length": String(result.asset.buffer.length),
"Content-Disposition": buildAttachmentContentDisposition(`${name}.png`),
"Cache-Control": "no-store",
} });
}
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null; let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
const failures: string[] = []; const failures: string[] = [];
@ -73,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, {
@ -96,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

@ -1,54 +1,24 @@
import { writeDownloadZip, readDownloadZip } from "@/lib/canvas/download-zip-stream";
import { claimZipTask, loadZipTask, saveZipTask, zipTaskPath, assertZipDiskSpace, cleanupZipTasks, assertZipTaskCapacity, type ZipTask } from "@/lib/canvas/download-zip-task-store";
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
import { createReadStream } from "node:fs";
import { mkdir, stat, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server"; import { after, NextRequest, NextResponse } from "next/server";
import JSZip from "jszip"; 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 { 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;
};
type ZipTaskStatus = "pending" | "running" | "completed" | "failed";
type ZipTask = {
id: string;
userId: string;
folderName: string;
files: DownloadFileItem[];
totalCount: number;
processedCount: number;
successCount: number;
failedCount: number;
compressPercent: number;
status: ZipTaskStatus;
stage: string;
error?: string;
zipBuffer?: Buffer;
contentDisposition?: string;
createdAt: number;
updatedAt: number;
expiresAt: number;
};
const MAX_CONCURRENCY = 8;
const MAX_DOWNLOAD_FILES = 200; const MAX_DOWNLOAD_FILES = 200;
const TASK_TTL_MS = 30 * 60 * 1000; const TASK_TTL_MS = 24 * 60 * 60_000;
const PREPARATION_TTL_MS = 6 * 60 * 60_000;
const globalTaskStore = globalThis as typeof globalThis & { const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
__canvasDownloadZipTasks?: Map<string, ZipTask>; const active = running.__canvasZipRunning ??= new Set<string>();
};
function getTaskStore() {
if (!globalTaskStore.__canvasDownloadZipTasks) {
globalTaskStore.__canvasDownloadZipTasks = new Map<string, ZipTask>();
}
return globalTaskStore.__canvasDownloadZipTasks;
}
function sanitizeFolderName(value: string): string { function sanitizeFolderName(value: string): string {
const cleaned = String(value || "") const cleaned = String(value || "")
.replace(/[\\/:*?"<>|]+/g, "-") .replace(/[\\/:*?"<>|]+/g, "-")
@ -56,7 +26,7 @@ function sanitizeFolderName(value: string): string {
.replace(/-+/g, "-") .replace(/-+/g, "-")
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
.trim(); .trim();
return cleaned || `canvas-images-${Date.now()}`; return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `canvas-images-${Date.now()}`;
} }
function buildContentDisposition(folderName: string): string { function buildContentDisposition(folderName: string): string {
@ -110,258 +80,172 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
.slice(0, MAX_DOWNLOAD_FILES); .slice(0, MAX_DOWNLOAD_FILES);
} }
function cleanupExpiredTasks() {
const now = Date.now();
const store = getTaskStore();
for (const [taskId, task] of store.entries()) {
if (task.expiresAt <= now) {
store.delete(taskId);
}
}
}
function getTaskProgress(task: ZipTask) {
if (task.status === "completed") {
return 100;
}
if (task.status === "failed") {
return Math.max(
0,
Math.min(100, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
);
}
if (task.processedCount >= task.totalCount && task.totalCount > 0) {
return Math.min(99, 85 + Math.round((task.compressPercent / 100) * 14));
}
return Math.max(
4,
Math.min(85, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
);
}
function serializeTask(task: ZipTask) { function serializeTask(task: ZipTask) {
const downloadUrl = return { id: task.id, folderName: task.folderName, status: task.status, stage: task.stage,
task.status === "completed" totalCount: task.totalCount, processedCount: task.processedCount, successCount: task.successCount,
? `/api/canvas/download-zip-task?taskId=${encodeURIComponent(task.id)}&download=1` failedCount: task.failedCount, progress: task.status === "completed" ? 100 : Math.min(99,
: ""; Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)),
return { error: task.error || "", expiresAt: task.expiresAt,
id: task.id, downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" };
folderName: task.folderName,
status: task.status,
stage: task.stage,
totalCount: task.totalCount,
processedCount: task.processedCount,
successCount: task.successCount,
failedCount: task.failedCount,
progress: getTaskProgress(task),
error: task.error || "",
downloadUrl,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
expiresAt: task.expiresAt,
};
} }
async function processTask(id: string, userId: string, requestOrigin: string, cookie: string) {
async function processZipTask(params: { if (active.has(id) || active.size >= 2) return;
task: ZipTask; active.add(id);
requestOrigin: string; let release: (() => Promise<void>) | null = null;
cookie: string; let task: ZipTask | null = null;
}) {
const { task, requestOrigin, cookie } = params;
const store = getTaskStore();
try { try {
task.status = "running"; release = await claimZipTask(id);
task.stage = "正在读取源图"; if (!release) return;
task.updatedAt = Date.now(); task = await loadZipTask(id, userId);
if (!task || task.status === "completed" || task.status === "failed") return;
const zip = new JSZip(); await assertZipDiskSpace();
const folder = zip.folder(task.folderName) || zip; const existingZip = task.zipFile && task.contentDisposition && await stat(task.zipFile.path).catch(() => null);
const results: Array< const reuseZip = existingZip && existingZip.size === task.zipFile!.bytes;
| Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> task.status = "running"; task.stage = reuseZip ? "正在准备加速下载" : "正在准备原图"; task.updatedAt = Date.now();
| null await saveZipTask(task);
> = new Array(task.files.length).fill(null); if (!reuseZip) {
delete task.zipFile; delete task.contentDisposition; delete task.cdnUrl;
// Stage each image on disk, not all PNG buffers in the web process heap.
// A restart resumes from these files using the current owner's session.
let cursor = 0; let cursor = 0;
const workerCount = Math.min(MAX_CONCURRENCY, task.files.length); let writes = Promise.resolve();
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
await Promise.all( const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
Array.from({ length: workerCount }, async () => { while (cursor < task!.files.length) {
while (true) { const index = cursor++; const file = task!.files[index];
const index = cursor; const path = join(zipTaskPath(id), `${index}.image`);
cursor += 1; if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
if (index >= task.files.length) break; delete file.prepared; delete file.filename;
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
const file = task.files[index]!; signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
results[index] = await fetchCanvasDownloadAsset({ if (result.ok) {
file, await assertZipDiskSpace(result.bytes.length);
index, await writeFile(path, result.bytes, { mode: 0o600 });
requestOrigin, file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
cookie, } else file.error = result.error;
}); task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
task!.successCount = task!.files.filter(f => f.prepared).length;
const result = results[index]; task!.failedCount = task!.files.filter(f => f.error).length;
task.processedCount += 1; task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
if (result?.ok) { task!.updatedAt = Date.now(); await persist();
task.successCount += 1;
} else {
task.failedCount += 1;
} }
task.stage = `正在处理 ${task.processedCount}/${task.totalCount} 张`; }));
task.updatedAt = Date.now(); const rejected = results.find(result => result.status === "rejected");
} if (rejected?.status === "rejected") throw rejected.reason;
}) if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
); const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
for (const file of task.files) if (file.prepared && file.filename)
const failures: Array<{ index: number; url: string; name?: string; error: string }> = []; folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
for (const [index, item] of results.entries()) { .once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
if (!item || !item.ok) { if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
failures.push({ .map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
index: index + 1, task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
url: item?.ok === false ? item.url : task.files[index]?.url || "", task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
name: item?.ok === false ? item.name : task.files[index]?.name,
error: item?.ok === false ? item.error : "未知错误",
});
continue;
}
folder.file(item.filename, item.bytes);
}
if (failures.length > 0) {
folder.file(
"_download-failures.txt",
[
"以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。",
"",
...failures.map((item) => {
const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`;
return `${label}\nURL: ${item.url}\n原因: ${item.error}`;
}),
].join("\n\n")
);
console.warn("[CanvasDownloadZipTask] Partial download failures:", failures);
}
task.stage = "正在生成 ZIP 文件";
task.updatedAt = Date.now();
task.zipBuffer = await zip.generateAsync(
{
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
},
(metadata) => {
task.compressPercent = Math.max(0, Math.min(100, metadata.percent || 0));
task.updatedAt = Date.now();
}
);
task.contentDisposition = buildContentDisposition(task.folderName); task.contentDisposition = buildContentDisposition(task.folderName);
task.status = "completed"; }
task.stage = "打包完成"; task.stage = "正在准备下载链接"; await saveZipTask(task);
task.compressPercent = 100; const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile!, disposition: task.contentDisposition!,
task.updatedAt = Date.now(); onProgress: async bytes => {
task.expiresAt = Date.now() + TASK_TTL_MS; task!.stage = `正在传送至七牛 ${Math.round(bytes / task!.zipFile!.bytes * 100)}%`;
task!.updatedAt = Date.now(); await saveZipTask(task!);
} });
if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`);
task.cdnUrl = cdn.url;
}
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes.
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
bytes: task.zipFile!.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
} catch (error) { } catch (error) {
console.error("[CanvasDownloadZipTask] Failed:", error); if (task) {
task.status = "failed"; task.status = "failed"; task.stage = task.zipFile ? "ZIP 已打包,加速下载未就绪" : "打包未完成";
task.stage = "打包失败"; 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;
} finally {
store.set(task.id, task);
} }
console.error("[CanvasDownloadZipTask] Preparation failed", { taskId: id, message: task?.error });
} finally { await release?.().catch(() => undefined); active.delete(id); }
}
function schedule(task: ZipTask, request: NextRequest) {
const run = () => trackRuntimeWork(() => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || ""));
after(run);
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try {
cleanupExpiredTasks();
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); try {
} const body = await request.json();
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; let task = await loadZipTask(id, session.user.id);
const requestOrigin = getRequestOrigin(request); if (!task) {
const files = toDownloadItems(body.files, requestOrigin); await assertZipTaskCapacity(session.user.id);
const folderName = sanitizeFolderName(String(body.folderName || "")); if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID();
if (files.length === 0) { const files = toDownloadItems(body.files, getRequestOrigin(request));
return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 }); if (!files.length || !Array.isArray(body.files) || body.files.length > MAX_DOWNLOAD_FILES || files.length !== body.files.length)
} return NextResponse.json({ error: "请选择 1 到 200 张可下载图片" }, { status: 400 });
// Exclusive creation prevents an arbitrary taskId from overwriting another owner's manifest.
const task: ZipTask = { await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => {
id: randomUUID(), if (error.code !== "ENOENT") throw error;
userId: session.user.id, await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 });
folderName, await mkdir(zipTaskPath(id), { mode: 0o700 });
files,
totalCount: files.length,
processedCount: 0,
successCount: 0,
failedCount: 0,
compressPercent: 0,
status: "pending",
stage: "等待服务端开始打包",
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + TASK_TTL_MS,
};
getTaskStore().set(task.id, task);
void processZipTask({
task,
requestOrigin,
cookie: request.headers.get("cookie") || "",
});
return NextResponse.json({
success: true,
task: serializeTask(task),
}); });
const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + PREPARATION_TTL_MS };
await saveZipTask(task);
} else if ((task.status === "failed" || task.status === "completed" && task.cdnObject && !task.cdnUrl) && body.retry === true) {
task.status = "pending"; task.error = undefined;
await assertZipTaskCapacity(session.user.id);
if (!task.zipFile) {
for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0;
}
task.updatedAt = Date.now(); task.expiresAt = Date.now() + PREPARATION_TTL_MS;
await saveZipTask(task);
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); }));
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
} catch (error) { } catch (error) {
console.error("[CanvasDownloadZipTask] Create failed:", error); console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message });
return NextResponse.json({ success: false, error: "创建打包任务失败" }, { status: 500 }); return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 });
} }
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try {
cleanupExpiredTasks();
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); try {
} const id = request.nextUrl.searchParams.get("taskId") || "";
const task = await loadZipTask(id, session.user.id);
const taskId = String(request.nextUrl.searchParams.get("taskId") || "").trim(); if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 });
if (!taskId) {
return NextResponse.json({ success: false, error: "缺少任务 ID" }, { status: 400 });
}
const task = getTaskStore().get(taskId);
if (!task || task.userId !== session.user.id) {
return NextResponse.json({ success: false, error: "打包任务不存在或已过期" }, { status: 404 });
}
if (request.nextUrl.searchParams.get("download") === "1") { if (request.nextUrl.searchParams.get("download") === "1") {
if (task.status !== "completed" || !task.zipBuffer) { if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 });
return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 }); if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1")
return NextResponse.redirect(task.cdnUrl, 307);
const info = await stat(task.zipFile.path).catch(() => null);
if (!info || info.size !== task.zipFile.bytes) return NextResponse.json({ error: "下载文件已过期,请重新打包" }, { status: 410 });
const etag = `"${task.id}-${info.size}"`;
const headers: Record<string, string> = { "Content-Type": "application/zip", "Content-Disposition": task.contentDisposition!,
"Accept-Ranges": "bytes", "ETag": etag, "Cache-Control": "private, no-store",
"X-Download-Success-Count": String(task.successCount), "X-Download-Failed-Count": String(task.failedCount) };
const ifRange = request.headers.get("if-range");
let range;
try { range = parseDownloadByteRange(!ifRange || ifRange === etag ? request.headers.get("range") : null, info.size); }
catch { return new NextResponse(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${info.size}` } }); }
headers["Content-Length"] = String(range ? range.end - range.start + 1 : info.size);
if (range) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${info.size}`;
return new NextResponse(readDownloadZip(task.zipFile.path, range || undefined), { status: range ? 206 : 200, headers });
} }
return new NextResponse(task.zipBuffer, { if (task.status === "pending" || task.status === "running") schedule(task, request);
status: 200, return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
headers: { } catch {
"Content-Type": "application/zip", return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 });
"Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName),
"Content-Length": String(task.zipBuffer.length),
"Cache-Control": "no-store",
"X-Download-Success-Count": String(task.successCount),
"X-Download-Failed-Count": String(task.failedCount),
},
});
}
return NextResponse.json({
success: true,
task: serializeTask(task),
});
} catch (error) {
console.error("[CanvasDownloadZipTask] Query failed:", error);
return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 });
} }
} }

View File

@ -1,3 +1,4 @@
import { streamDownloadZip } from "@/lib/canvas/download-zip-stream";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import JSZip from "jszip"; import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
@ -159,13 +160,7 @@ export async function POST(request: NextRequest) {
console.warn("[CanvasDownloadZip] Partial download failures:", failures); console.warn("[CanvasDownloadZip] Partial download failures:", failures);
} }
const zipBuffer = await zip.generateAsync({ return new NextResponse(streamDownloadZip(zip), {
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
return new NextResponse(zipBuffer, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/zip", "Content-Type": "application/zip",

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

@ -14,6 +14,7 @@ import {
ownsTaskProject, ownsTaskProject,
} from "@/lib/video-canvas/task-store"; } from "@/lib/video-canvas/task-store";
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker"; import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 900; export const maxDuration = 900;
@ -28,7 +29,7 @@ async function owner(projectId: unknown) {
} }
const fail = (error: unknown) => const fail = (error: unknown) =>
NextResponse.json( NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "任务操作失败" }, { success: false, error: error instanceof Error ? error.message : "任务操作失败", code: (error as any)?.code },
{ status: Number((error as any)?.status) || (error instanceof SyntaxError ? 400 : 500) }, { status: Number((error as any)?.status) || (error instanceof SyntaxError ? 400 : 500) },
); );
const kick = (userId: string, projectId: string) => const kick = (userId: string, projectId: string) =>
@ -65,7 +66,8 @@ export async function POST(request: Request) {
throw Object.assign(Error("剪辑参数无效"), { status: 400 }); throw Object.assign(Error("剪辑参数无效"), { status: 400 });
if (parsed.data.kind === "video-upscale" && !videoUpscaleSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频高清参数无效"), {status:400}); if (parsed.data.kind === "video-upscale" && !videoUpscaleSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频高清参数无效"), {status:400});
const userId = await owner(parsed.data.projectId); const userId = await owner(parsed.data.projectId);
const task = await createCanvasGenerationTask(userId, parsed.data); const task = await createCanvasGenerationTask(userId, parsed.data,
() => preflightCanvasProductionRequests([parsed.data]));
kick(userId, parsed.data.projectId); kick(userId, parsed.data.projectId);
return NextResponse.json({ success: true, task }, { status: 202 }); return NextResponse.json({ success: true, task }, { status: 202 });
} catch (error) { } catch (error) {

View File

@ -6,6 +6,7 @@ import {
ownsTaskProject, ownsTaskProject,
} from "@/lib/video-canvas/task-store"; } from "@/lib/video-canvas/task-store";
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker"; import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
export const runtime = "nodejs", export const runtime = "nodejs",
maxDuration = 900; maxDuration = 900;
export async function POST(request: Request) { export async function POST(request: Request) {
@ -27,12 +28,13 @@ export async function POST(request: Request) {
userId = session.user.id; userId = session.user.id;
if (!(await ownsTaskProject(userId, projectId))) if (!(await ownsTaskProject(userId, projectId)))
return NextResponse.json({ error: "画布不存在" }, { status: 404 }); return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const tasks = await createCanvasGroupTasks(userId, parsed.data); const tasks = await createCanvasGroupTasks(userId, parsed.data,
() => preflightCanvasProductionRequests(parsed.data));
after(() => runCanvasGenerationQueue(userId, projectId)); after(() => runCanvasGenerationQueue(userId, projectId));
return NextResponse.json({ success: true, tasks }, { status: 202 }); return NextResponse.json({ success: true, tasks }, { status: 202 });
} catch (e) { } catch (e) {
return NextResponse.json( return NextResponse.json(
{ error: e instanceof Error ? e.message : "编组执行失败" }, { error: e instanceof Error ? e.message : "编组执行失败", code: (e as any)?.code },
{ status: Number((e as any)?.status) || 400 } { status: Number((e as any)?.status) || 400 }
); );
} }

View File

@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
import {
readOwnedVideoCanvas,
saveVideoAgentCanvas,
} from "@/lib/ai/canvas-agent-runtime/video-store";
import { AgentRunError } from "@/lib/ai/canvas-agent-runtime/store";
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const projectId = z.string().uuid().parse(new URL(request.url).searchParams.get("projectId"));
await readOwnedVideoCanvas(session.user.id, projectId);
// One stable conversation per video workspace survives browser reloads.
const chat = await getChatById({ id: projectId });
if (chat && chat.userId !== session.user.id) throw new AgentRunError("对话不存在", 404);
const messages = chat ? (await getMessagesByChatId({ id: projectId })).slice(-40) : [];
return NextResponse.json(
{ messages: messages.map(({ id, role, parts }) => ({ id, role, parts })) },
{ headers: { "Cache-Control": "private, no-store" } },
);
} catch (error) {
return NextResponse.json(
{ error: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}
}
const saveSchema = z.object({
projectId: z.string().uuid(),
baseRevision: z.string().datetime(),
canvasData: z.object({
nodes: z.array(z.record(z.unknown())).max(2000),
edges: z
.array(
z.object({
id: z.string(),
source: z.string(),
target: z.string(),
type: z.string().optional(),
}),
)
.max(4000),
selectedNodeIds: z.array(z.string()).max(20),
viewport: z.object({
x: z.number().finite(),
y: z.number().finite(),
zoom: z.number().positive(),
}),
tool: z.literal("select"),
}),
});
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = saveSchema.parse(await request.json());
await saveVideoAgentCanvas(session.user.id, body.projectId, body.baseRevision, body.canvasData);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}
}

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,4 +1,8 @@
import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema"; import type {
FlowBoardDraft,
FlowBoardSheetSnapshotData,
FlowBoardSnapshotData,
} from "@/lib/db/schema";
export function isRecord(value: unknown): value is Record<string, unknown> { export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
@ -8,6 +12,73 @@ function normalizeRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null; return isRecord(value) ? value : null;
} }
const DEFAULT_FLOW_BOARD_CANVAS = {
backgroundColor: "#f7f8fb",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
};
function normalizeCanvasColor(value: unknown, fallback: string) {
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value)
? value.toLowerCase()
: fallback;
}
function normalizeFlowBoardCanvas(value: unknown): Record<string, unknown> {
const canvas = isRecord(value) ? value : {};
const requestedGridSize =
typeof canvas.gridSize === "number" && Number.isFinite(canvas.gridSize)
? Math.round(canvas.gridSize)
: DEFAULT_FLOW_BOARD_CANVAS.gridSize;
return {
backgroundColor: normalizeCanvasColor(
canvas.backgroundColor,
DEFAULT_FLOW_BOARD_CANVAS.backgroundColor
),
gridColor: normalizeCanvasColor(
canvas.gridColor,
DEFAULT_FLOW_BOARD_CANVAS.gridColor
),
gridEnabled:
typeof canvas.gridEnabled === "boolean"
? canvas.gridEnabled
: DEFAULT_FLOW_BOARD_CANVAS.gridEnabled,
gridSize: Math.min(64, Math.max(8, requestedGridSize)),
};
}
function normalizeSheetId(value: unknown, index: number) {
const id = typeof value === "string" ? value.trim().slice(0, 128) : "";
return id || `sheet-${index + 1}`;
}
function normalizeSheetTitle(value: unknown, index: number) {
const title = typeof value === "string" ? value.trim().slice(0, 128) : "";
return title || `画布 ${index + 1}`;
}
function normalizeFlowBoardSheet(
value: unknown,
index: number
): FlowBoardSheetSnapshotData | null {
if (!isRecord(value)) {
return null;
}
return {
id: normalizeSheetId(value.id, index),
title: normalizeSheetTitle(value.title, index),
children: Array.isArray(value.children)
? value.children.filter(isRecord)
: [],
viewport: normalizeRecord(value.viewport),
theme: normalizeRecord(value.theme),
canvas: normalizeFlowBoardCanvas(value.canvas),
};
}
export function normalizeFlowBoardTitle(value: unknown) { export function normalizeFlowBoardTitle(value: unknown) {
const title = String(value || "").trim(); const title = String(value || "").trim();
return title.slice(0, 256) || "未命名流程白板"; return title.slice(0, 256) || "未命名流程白板";
@ -17,15 +88,67 @@ export function normalizeFlowBoardSnapshot(
value: unknown value: unknown
): FlowBoardSnapshotData { ): FlowBoardSnapshotData {
const raw = isRecord(value) ? value : {}; const raw = isRecord(value) ? value : {};
const legacyChildren = Array.isArray(raw.children)
? raw.children.filter(isRecord)
: [];
const normalizedSheets = Array.isArray(raw.sheets)
? raw.sheets
.map(normalizeFlowBoardSheet)
.filter(
(sheet): sheet is FlowBoardSheetSnapshotData => Boolean(sheet)
)
: [];
const sheets =
normalizedSheets.length > 0
? normalizedSheets
: [
{
id: "sheet-1",
title: "画布 1",
children: legacyChildren,
viewport: normalizeRecord(raw.viewport),
theme: normalizeRecord(raw.theme),
canvas: normalizeFlowBoardCanvas(raw.canvas),
},
];
const requestedActiveSheetId =
typeof raw.activeSheetId === "string" ? raw.activeSheetId.trim() : "";
const activeSheet =
sheets.find((sheet) => sheet.id === requestedActiveSheetId) || sheets[0];
const activeChildren = Array.isArray(raw.children)
? legacyChildren
: activeSheet.children;
const activeViewport = isRecord(raw.viewport)
? raw.viewport
: activeSheet.viewport;
const activeTheme = isRecord(raw.theme) ? raw.theme : activeSheet.theme;
const activeCanvas = isRecord(raw.canvas)
? normalizeFlowBoardCanvas(raw.canvas)
: normalizeFlowBoardCanvas(activeSheet.canvas);
const nextSheets = sheets.map((sheet) =>
sheet.id === activeSheet.id
? {
...sheet,
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
}
: sheet
);
const updatedAt = const updatedAt =
typeof raw.updatedAt === "string" && raw.updatedAt.trim() typeof raw.updatedAt === "string" && raw.updatedAt.trim()
? raw.updatedAt ? raw.updatedAt
: new Date().toISOString(); : new Date().toISOString();
return { return {
children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [], version: 2,
viewport: normalizeRecord(raw.viewport), activeSheetId: activeSheet.id,
theme: normalizeRecord(raw.theme), sheets: nextSheets,
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
updatedAt, updatedAt,
}; };
} }

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { imageArchiveTask } from "@/lib/db/schema";
// The SHA-256 URL is a capability, like the provider's signed image URL. It must be
// readable by image nodes and reference-image providers without a session cookie.
export async function GET(request: NextRequest, { params }: { params: Promise<{ key: string }> }) {
const { key } = await params;
if (!/^[a-f0-9]{64}$/.test(key)) return new NextResponse(null, { status: 404 });
const configured =
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
const origin = configured
? new URL(configured).origin
: `http://localhost:${process.env.PORT || "3000"}`;
const sourceUrl = `${origin}/api/generated-image-preview/${key}`;
const [task] = await db
.select({ targetUrl: imageArchiveTask.targetUrl, metadata: imageArchiveTask.metadata })
.from(imageArchiveTask)
.where(eq(imageArchiveTask.sourceUrl, sourceUrl))
.limit(1);
if (task?.targetUrl) return NextResponse.redirect(task.targetUrl, 307);
const inline = (task?.metadata as { inlineSource?: string } | null)?.inlineSource;
const match = inline?.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
if (!match) return new NextResponse(null, { status: 404 });
return new NextResponse(Buffer.from(match[2], "base64"), {
headers: {
"Content-Type": match[1],
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
},
});
}

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 折优惠",

File diff suppressed because it is too large Load Diff

View File

@ -48,8 +48,13 @@ export default function CanvasStyleProPage() {
const canvasData = result.data.canvasData; const 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

@ -0,0 +1,17 @@
import { notFound } from "next/navigation";
import { FlowBoardClient } from "@/components/flow-board/flow-board-client";
export default function FlowBoardPreviewPage() {
if (process.env.NODE_ENV === "production") {
notFound();
}
return (
<FlowBoardClient
previewMode
siteLogo={null}
siteLogoDark={null}
siteName="SnapGen"
/>
);
}

View File

@ -519,3 +519,301 @@
padding: 0 12px 12px; padding: 0 12px 12px;
} }
} }
/* Lovart surface typography and controls, scoped to the home and image canvas. */
.lovart-home-surface,
.lovart-canvas-surface {
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
}
.lovart-suggestion {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 8px;
height: 32px;
padding: 0 14px;
border: 1px solid #e5e5e5;
border-radius: 999px;
background: white;
color: #333;
font-size: 13px;
white-space: nowrap;
transition: background-color 150ms, border-color 150ms;
}
.lovart-suggestion:hover { background: #f7f7f7; border-color: #d5d5d5; }
.lovart-suggestion:focus-visible,
.lovart-start-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
.lovart-start-action {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 9px;
min-width: 172px;
height: 40px;
padding: 0 16px;
border: 1px solid #e5e5e5;
border-radius: 999px;
background: white;
color: #333;
font-size: 14px;
font-weight: 400;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
transition: background-color 150ms, border-color 150ms;
}
.lovart-start-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-bottom: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer { padding: 0 8px 8px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] {
min-height: 120px;
border-color: #e5e5e5;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
}
/* The promo backplate continues underneath the composer's rounded corners. */
.lovart-chat-promo-connected { padding: 0 8px; }
.lovart-chat-promo-surface { padding-bottom: 24px; border-radius: 24px 24px 0 0; background: #ebebeb; }
.lovart-chat-promo-row { display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 10px 0 16px; }
.lovart-chat-promo-message { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; padding: 0; border: 0; background: transparent; color: #202020; font-size: 14px; font-weight: 400; line-height: 20px; text-align: left; cursor: pointer; }
.lovart-chat-promo-message:disabled { cursor: default; opacity: 1; }
.lovart-chat-promo-message > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lovart-chat-promo-badge { flex-shrink: 0; }
.lovart-chat-promo-close { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; border: 0; border-radius: 50%; background: transparent; color: #262626; cursor: pointer; }
.lovart-chat-promo-close:hover { background: rgb(0 0 0 / 5%); }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-promo-connected + .lovart-chat-composer { position: relative; z-index: 1; margin-top: -24px; background: transparent; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor][data-canvas-composer-mode="image"] { min-height: 184px; border-radius: 24px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-upload { margin: 8px 8px 0; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { flex-direction: column; gap: 8px; width: 56px; height: 68px; border: 0; border-radius: 18px; background: #f5f5f5; color: #aaa; font-size: 12px; font-weight: 400; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #ececec; color: #777; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile > svg { width: 14px; height: 14px; stroke-width: 1.5; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-text-region { padding: 12px 16px 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"] { min-height: 40px; font-size: 14px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"]:empty::before { color: #b0b0b0; }
.dark .lovart-chat-promo-surface { background: #27272a; }
.dark .lovart-chat-promo-message, .dark .lovart-chat-promo-close { color: #eee; }
.dark .lovart-chat-promo-close:hover { background: rgb(255 255 255 / 10%); }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { background: #27272a; color: #a1a1aa; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #3f3f46; color: #eee; }
.dark .lovart-suggestion,
.dark .lovart-start-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
.dark .lovart-suggestion:hover,
.dark .lovart-start-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-color: rgb(255 255 255 / 10%); }
.dark [data-canvas-right-panel="true"] > .border-l { border-left-color: #27272a; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] { border-color: rgb(255 255 255 / 12%); }
@media (max-width: 640px) {
.lovart-home-surface main { padding-top: calc(72px + var(--home-banner-offset, 0px)); }
.lovart-hero { margin-top: 16px; }
.lovart-home-composer { border-radius: 20px; }
.lovart-suggestion { padding: 0 11px; font-size: 12px; }
.lovart-start-action { min-width: 160px; }
.lovart-chat-welcome h1 { font-size: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.lovart-home-surface *, .lovart-canvas-surface * { scroll-behavior: auto !important; }
}
.lovart-home-surface :is(button, input, textarea, select),
.lovart-canvas-surface :is(button, input, textarea, select) { font-family: inherit; }
@media (max-width: 640px) {
.lovart-canvas-surface[data-canvas-panel-open="true"] :is([data-canvas-topbar], [data-canvas-floating-toolbar], [data-canvas-compact-controls]) { display: none; }
}
/* Reference icon sizing is independent of the application's root rem scale. */
.lovart-suggestion { gap: 8px; padding: 0 12px; }
.lovart-start-action { min-width: 172.71875px; gap: 8px; padding: 0 12px; }
.lovart-canvas-surface [data-canvas-floating-toolbar] button { min-width: 32px; height: 32px; border-radius: 8px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector { width: 90px; height: 32px; padding: 0 8px; background: transparent; font-size: 14px; font-weight: 400; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector > span { font-size: 13px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector:hover,
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params:hover,
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector:hover { background: rgb(0 0 0 / 4%); }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params { width: 105.3828125px; min-width: 105.3828125px; height: 32px; padding: 0 4px 0 12px; background: transparent; font-size: 13px; gap: 4px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector { flex: 0 0 121px; width: 121px; height: 32px; padding: 0 4px 0 8px; gap: 0; border: 0; background: transparent; box-shadow: none; font-size: 13px; font-weight: 400; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > [data-reference-icon]:first-child { margin-right: 8px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > span:not([data-reference-icon]) { max-width: 65px; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params) { color: #eee; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params, .lovart-composer-mode-selector):hover { background: rgb(255 255 255 / 8%); }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer { padding: 0 8px 8px; gap: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:first-child { flex: 0 0 90px; padding-right: 0; overflow: visible; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:last-child { flex: 1 1 0; min-width: 0; gap: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-send { min-width: 32px; width: auto; height: 32px; padding: 0 8px; gap: 2px; font-size: 13px; font-weight: 400; }
/* Adapt to the resizable chat panel, independently of the browser width. */
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] { container: canvas-composer / inline-size; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer { gap: 4px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer > div:first-child { flex: 0 0 auto; overflow: visible; padding-right: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer > div:last-child { flex: 0 1 auto; min-width: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-mode-selector { width: auto; max-width: none; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params { width: auto; min-width: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params-label { overflow: visible; text-overflow: clip; white-space: nowrap; }
.lovart-image-params-icon { display: none; }
@container canvas-composer (max-width: 480px) {
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-mode-selector { width: 32px; min-width: 32px; padding: 0; }
.lovart-composer-mode-selector .lovart-composer-mode-label,
.lovart-composer-mode-selector [data-reference-icon="chevron-down"] { display: none; }
.lovart-composer-mode-selector > span > :first-child { margin-right: 0; }
}
@container canvas-composer (max-width: 360px) {
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-model-selector { flex: 0 0 32px; width: 32px; padding: 0; justify-content: center; }
.lovart-model-selector > span:not([data-reference-icon]),
.lovart-model-selector > [data-reference-icon="chevron-down"] { display: none; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > [data-reference-icon]:first-child { margin-right: 0; }
}
@container canvas-composer (max-width: 480px) {
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params { width: 32px; min-width: 32px; padding: 0; justify-content: center; }
.lovart-image-params .lovart-image-params-icon { display: block; }
.lovart-image-params .lovart-image-params-label,
.lovart-image-params > [data-reference-icon="chevron-down"] { display: none; }
}
/* The existing image toolbar is scaled to 80%; these dimensions render at
the reference's 44px bar, 32px buttons, and 20px icons. */
.lovart-image-action-bar {
height: 55px;
gap: 5px;
padding: 0 7.5px;
border: 1.25px solid rgba(0, 0, 0, 0.07);
border-radius: 17.5px;
background: #fff !important;
color: #000;
box-shadow: 0 1.25px 2.5px rgba(0, 0, 0, 0.04);
backdrop-filter: none;
}
.lovart-image-action-bar::before { display: none; }
.lovart-image-action-bar > button {
width: 40px;
min-width: 40px;
height: 40px;
margin: 0;
gap: 0;
padding: 0;
border-radius: 10px;
background: transparent;
color: #000;
font-size: 0;
font-weight: 400;
box-shadow: none;
}
.lovart-image-action-bar > button:hover,
.lovart-image-action-bar > button[data-state="open"],
.lovart-image-action-bar > button[data-reference-active="true"] {
background: rgba(0, 0, 0, 0.04);
color: #000;
}
.lovart-image-action-bar > button:disabled {
background: transparent;
color: rgba(0, 0, 0, 0.3);
}
.lovart-image-action-bar > button > svg,
.lovart-image-action-bar > button > [data-reference-icon],
.lovart-image-action-bar > button > span[aria-hidden="true"],
.lovart-image-action-bar > button > img {
width: 25px;
height: 25px;
margin: 0;
flex-shrink: 0;
}
.lovart-image-action-bar > button > img { filter: brightness(0); }
.lovart-image-action-bar > div.h-px,
.lovart-image-action-bar > div.w-px {
width: 1.25px;
height: 55px;
margin: 0 2.5px;
background: rgba(0, 0, 0, 0.07);
}
.lovart-image-action-menu [role="menuitem"] > svg,
.lovart-image-action-menu [role="menuitem"] > [data-reference-icon] {
width: 18px;
height: 18px;
}
.dark .lovart-image-action-bar {
border-color: rgba(255, 255, 255, 0.1);
background: #18191c !important;
color: #fff;
}
.dark .lovart-image-action-bar > button { color: #fff; }
.dark .lovart-image-action-bar > button:hover,
.dark .lovart-image-action-bar > button[data-state="open"],
.dark .lovart-image-action-bar > button[data-reference-active="true"] {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.dark .lovart-image-action-bar > button:disabled { color: rgba(255, 255, 255, 0.3); }
.dark .lovart-image-action-bar > button > img { filter: brightness(0) invert(1); }
.dark .lovart-image-action-bar > div.w-px { background: rgba(255, 255, 255, 0.1); }
.lovart-image-action-bar[data-show-tool-names="true"] > button[data-tool-key] { width: auto; gap: 7.5px; padding: 0 12.5px; font-size: 16.25px; }
.lovart-image-tool-label { white-space: nowrap; }
.lovart-image-tool-tooltip { z-index: 10030; border: 0; border-radius: 6px; background: #1b1b1b; padding: 6px 9px; color: #fff; font-size: 13px; line-height: 18px; box-shadow: 0 4px 10px #00000012; --tw-ring-shadow: 0 0 transparent; }
.lovart-image-custom-more { min-width: 172px; padding: 6px 12px; border: 1px solid #e8e8e8; border-radius: 12px; background: #fff; }
.lovart-image-custom-more [role="menuitem"] { min-height: 32px; gap: 10px; padding: 4px 0; font-size: 14px; font-weight: 400; }
.lovart-image-custom-more .lovart-customize-entry { justify-content: center; gap: 5px; }
.lovart-toolbar-customizer-overlay { z-index: 10010; background: #0006; }
.lovart-toolbar-customizer { z-index: 10020; display: flex; flex-direction: column; width: min(720px, calc(100vw - 32px)); max-width: 720px; max-height: calc(100dvh - 48px); gap: 0; padding: 20px 24px 24px; border: 1px solid #e5e5e5; border-radius: 16px; background: #fff; color: #242424; box-shadow: 0 8px 24px #00000012; }
.lovart-customizer-header { display: flex; flex-shrink: 0; justify-content: space-between; align-items: flex-start; gap: 16px; }
.lovart-customizer-header h2 { margin: 0; font-size: 18px; line-height: 24px; font-weight: 600; }
.lovart-customizer-header p { margin-top: 2px; color: inherit; font-size: 14px; line-height: 22px; }
.lovart-customizer-header-actions { display: flex; gap: 4px; }
.lovart-customizer-header-actions button { display: flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 7px; }
.lovart-customizer-header-actions button:last-child { background: #f1f1f1; }
.lovart-customizer-header-actions button:hover { background: #e9e9e9; }
.lovart-customizer-body { min-height: 0; overflow-y: auto; margin: 24px 0 20px; }
.lovart-customizer-preview { display: flex; height: 180px; align-items: center; overflow: hidden; padding: 16px; border-radius: 10px; background: #e5edef url("/icons/lovart/toolbar-preview.png") center / cover; }
.lovart-customizer-preview-bar { display: flex; align-items: center; flex: 1; min-width: 0; gap: 4px; padding: 5px 8px; border: 1px solid #e5e5e5; border-radius: 14px; background: #fff; overflow-x: auto; scrollbar-width: thin; }
.lovart-preview-fixed,.lovart-preview-chip { display: flex; align-items: center; flex-shrink: 0; height: 32px; gap: 8px; padding: 0 10px; font-size: 13px; white-space: nowrap; }
.lovart-preview-chip { position: relative; margin-top: 5px; margin-bottom: 5px; border-radius: 8px; background: #f4f4f4; cursor: grab; }
.lovart-preview-chip:active { cursor: grabbing; }
.lovart-preview-chip > button:last-child { position: absolute; top: -5px; right: -3px; display: flex; width: 16px; height: 16px; align-items: center; justify-content: center; border: 1px solid #e6e6e6; border-radius: 50%; background: #fff; color: #888; }
.lovart-customizer-tools { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; margin-top: 24px; }
.lovart-customizer-tools > button { display: flex; align-items: center; gap: 10px; height: 36px; min-width: 0; padding: 0 12px; border: 1px solid #e2e2e2; border-radius: 8px; background: #fff; font-size: 14px; text-align: left; }
.lovart-customizer-tools > button > span:not([aria-hidden]) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lovart-customizer-tools > button > svg:last-child { flex-shrink: 0; }
.lovart-customizer-tools > button:hover { background: #f7f7f7; }
.lovart-customizer-footer { display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; gap: 16px; }
.lovart-customizer-footer > label,.lovart-customizer-footer > div { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.lovart-customizer-switch { width: 32px; height: 18px; padding: 2px; border-radius: 20px; background: #d5d5d5; }
.lovart-customizer-switch > span { display: block; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .15s; }
.lovart-customizer-switch[aria-checked="true"] { background: #2c2c2c; }
.lovart-customizer-switch[aria-checked="true"] > span { transform: translateX(14px); }
.lovart-customizer-footer > div > button { width: 80px; height: 36px; border: 1px solid #e3e3e3; border-radius: 8px; font-size: 14px; }
.lovart-customizer-footer .lovart-customizer-save { border-color: #2c2c2c; background: #2c2c2c; color: #fff; }
.lovart-customizer-footer > div > button:hover { opacity: .8; }
.lovart-toolbar-customizer button:focus-visible,.lovart-preview-chip:focus-visible { outline: 2px solid #397dff; outline-offset: 2px; }
.dark .lovart-toolbar-customizer,.dark .lovart-image-custom-more { background: #202124; border-color: #3d3e40; color: #f5f5f5; }
.dark .lovart-customizer-preview-bar,.dark .lovart-customizer-tools > button { background: #26272a; border-color: #46474a; }
.dark .lovart-preview-chip,.dark .lovart-customizer-header-actions button:last-child { background: #35363a; }
.dark .lovart-preview-chip > button:last-child { background: #26272a; border-color: #46474a; color: #ccc; }
.dark .lovart-customizer-tools > button:hover { background: #35363a; }
@media(max-width:600px) { .lovart-toolbar-customizer { padding: 18px; } .lovart-customizer-tools { grid-template-columns: repeat(2,minmax(0,1fr)); } .lovart-customizer-preview { height: 140px; } }
.lovart-preview-handle { display: flex; align-items: center; gap: 8px; white-space: nowrap; cursor: grab; }
.lovart-image-tool-tooltip { display: flex; align-items: center; gap: 8px; }
.lovart-image-tool-cost { margin-left: auto; padding: 0; background: transparent; color: #999; font-size: 12px; font-weight: 400; box-shadow: none; }
.lovart-image-tool-cost > svg { width: 12px; height: 12px; }
/* Preserve the canvas's own skills as the primary welcome content. */
.canvas-welcome-action {
display: inline-flex;
min-height: 36px;
min-width: 0;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid #e5e5e5;
border-radius: 999px;
background: white;
color: #333;
font-size: 12px;
line-height: 16px;
transition: background-color 150ms, border-color 150ms;
}
.canvas-welcome-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
.canvas-welcome-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
.dark .canvas-welcome-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
.dark .canvas-welcome-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }

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";
@ -94,17 +95,32 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
} }
} }
function safeRemoveSessionStorage(key) { // Keep one attempt per route for the browser session. A cooldown alone can
try { // turn a recurring editor exception into an endless reload cycle.
sessionStorage.removeItem(key); var RECOVERY_KEY = 'chunk-reload-attempted-v3:' + window.location.pathname;
return true; var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0;
} catch (e) { var recoveryNoticeShown = false;
return false;
} function isFlowBoardEditing() {
var path = window.location.pathname;
return path === '/flow-board' || path.indexOf('/flow-board/') === 0;
} }
var RECOVERY_KEY = 'chunk-reload-attempted-v2'; function showManualRecoveryNotice() {
var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0; if (recoveryNoticeShown) return;
recoveryNoticeShown = true;
function show() {
if (!document.body || !isFlowBoardEditing()) return;
var notice = document.createElement('div');
notice.setAttribute('role', 'alert');
notice.setAttribute('data-runtime-recovery-notice', 'true');
notice.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:calc(100vw - 32px);padding:12px 16px;border:1px solid var(--border,#e4e4e7);border-radius:12px;background:var(--background,#fff);color:var(--foreground,#27272a);box-shadow:0 4px 20px #0002;font:14px/1.5 system-ui;';
notice.textContent = '部分页面资源加载失败,请先保存白板后手动刷新页面。';
document.body.appendChild(notice);
}
if (document.body) show();
else window.addEventListener('DOMContentLoaded', show, { once: true });
}
function shouldAutoRecover(message) { function shouldAutoRecover(message) {
if (!message) return false; if (!message) return false;
@ -117,20 +133,26 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
normalized.indexOf('error loading dynamically imported module') !== -1 || normalized.indexOf('error loading dynamically imported module') !== -1 ||
normalized.indexOf('importing a module script failed') !== -1 || normalized.indexOf('importing a module script failed') !== -1 ||
normalized.indexOf('module script load failed') !== -1 || normalized.indexOf('module script load failed') !== -1 ||
normalized.indexOf('/_next/static/chunks/') !== -1 || normalized.indexOf('failed to find server action') !== -1
normalized.indexOf('failed to find server action') !== -1 ||
normalized.indexOf('server components render') !== -1 ||
normalized.indexOf('application error: a client-side exception') !== -1
); );
} }
function triggerAutoRecover(reason) { function triggerAutoRecover(reason) {
if (isFlowBoardEditing()) {
showManualRecoveryNotice();
return;
}
if (lastReloadAt) return;
var now = Date.now(); var now = Date.now();
if (lastReloadAt && now - lastReloadAt < 30000) return; // If storage is blocked, a reload cannot remember its attempt and must not run.
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
lastReloadAt = now; lastReloadAt = now;
console.warn('[RuntimeRecover] 检测到前端运行时异常,自动刷新页面...', reason || ''); console.warn('[RuntimeRecover] 检测到页面资源加载失败,尝试恢复一次...', reason || '');
safeSetSessionStorage(RECOVERY_KEY, String(now));
setTimeout(function() { setTimeout(function() {
if (isFlowBoardEditing()) {
showManualRecoveryNotice();
return;
}
window.location.reload(); window.location.reload();
}, 800); }, 800);
} }
@ -138,7 +160,6 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
window.addEventListener('error', function(event) { window.addEventListener('error', function(event) {
var message = ''; var message = '';
if (event && event.message) message += event.message; if (event && event.message) message += event.message;
if (event && event.filename) message += ' ' + event.filename;
if (event && event.error && event.error.message) { if (event && event.error && event.error.message) {
message += ' ' + event.error.message; message += ' ' + event.error.message;
} }
@ -157,12 +178,6 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
} }
}); });
// 页面稳定一段时间后清除标记,避免旧资源异常无限刷新。
window.addEventListener('load', function() {
setTimeout(function() {
safeRemoveSessionStorage(RECOVERY_KEY);
}, 120000);
});
})();`; })();`;
export default function RootLayout({ export default function RootLayout({
@ -201,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

@ -1,303 +1,6 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail
# 自动部署脚本 - 包含密码 printf '%s\n' 'Legacy deployment entry point disabled: it targets an obsolete host/runtime.' >&2
# 使用方法: ./auto-deploy.sh printf '%s\n' 'Use: python scripts/production48.py status' >&2
printf '%s\n' 'Then follow docs/production-48.md for build, stage, activate and rollback.' >&2
set -e exit 2
# 服务器配置
SERVER="156.225.27.52"
PORT="5204"
USER="root"
PASSWORD="${DEPLOY_SSH_PASSWORD:?set DEPLOY_SSH_PASSWORD}"
REMOTE_DIR="/var/www/ai-chatbot"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${GREEN}开始自动部署到服务器...${NC}"
# 检查是否安装了 sshpass
if ! command -v sshpass &> /dev/null; then
echo -e "${YELLOW}安装 sshpass...${NC}"
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
if ! command -v brew &> /dev/null; then
echo -e "${RED}请先安装 Homebrew: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"${NC}"
exit 1
fi
brew install hudochenkov/sshpass/sshpass
else
# Linux
sudo apt-get update && sudo apt-get install -y sshpass
fi
fi
# SSH 连接函数
ssh_cmd() {
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -p $PORT $USER@$SERVER "$1"
}
scp_cmd() {
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -P $PORT "$1" $USER@$SERVER:"$2"
}
scp_dir() {
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -P $PORT -r "$1" $USER@$SERVER:"$2"
}
echo -e "${GREEN}步骤 1: 检查服务器连接...${NC}"
ssh_cmd "echo '连接成功' && uname -a"
echo -e "${GREEN}步骤 2: 检查并安装必要软件...${NC}"
ssh_cmd "
# 检查 Node.js
if ! command -v node &> /dev/null; then
echo '安装 Node.js...'
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
export NVM_DIR=\"\$HOME/.nvm\"
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
nvm install 20
nvm use 20
nvm alias default 20
fi
# 检查 pnpm
if ! command -v pnpm &> /dev/null; then
echo '安装 pnpm...'
npm install -g pnpm
fi
# 检查 PM2
if ! command -v pm2 &> /dev/null; then
echo '安装 PM2...'
npm install -g pm2
fi
# 检查 PostgreSQL
if ! command -v psql &> /dev/null; then
echo '安装 PostgreSQL...'
apt-get update -y
apt-get install -y postgresql postgresql-contrib
systemctl start postgresql
systemctl enable postgresql
fi
# 检查 Nginx
if ! command -v nginx &> /dev/null; then
echo '安装 Nginx...'
apt-get install -y nginx
systemctl start nginx
systemctl enable nginx
fi
# 创建项目目录
mkdir -p $REMOTE_DIR
mkdir -p /var/log/pm2
mkdir -p /root/backups/ai-chatbot
"
echo -e "${GREEN}步骤 3: 配置数据库...${NC}"
ssh_cmd "
# 检查并创建/更新数据库用户
echo '配置数据库用户...'
sudo -u postgres psql << 'PSQL'
-- 删除已存在的用户(如果存在)
DROP USER IF EXISTS chatbot_user;
-- 创建新用户
CREATE USER chatbot_user WITH PASSWORD 'ChatBot@2024!Secure';
-- 检查数据库是否存在
SELECT 1 FROM pg_database WHERE datname='ai_chatbot'\\gexec
-- 如果不存在则创建
DO \\$\\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'ai_chatbot') THEN
CREATE DATABASE ai_chatbot;
END IF;
END
\\$\\$;
-- 授予权限
GRANT ALL PRIVILEGES ON DATABASE ai_chatbot TO chatbot_user;
-- 连接到数据库并授予schema权限
\\c ai_chatbot
GRANT ALL ON SCHEMA public TO chatbot_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO chatbot_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO chatbot_user;
\\q
PSQL
echo '数据库配置完成'
"
echo -e "${GREEN}步骤 4: 上传项目文件...${NC}"
# 创建临时部署包
echo "准备部署文件..."
tar --exclude='node_modules' \
--exclude='.next' \
--exclude='.git' \
--exclude='.env.local' \
--exclude='*.log' \
--exclude='deploy.tar.gz' \
-czf /tmp/deploy.tar.gz .
# 上传部署包
scp_cmd "/tmp/deploy.tar.gz" "/tmp/"
# 在服务器上解压
ssh_cmd "
cd $REMOTE_DIR
tar -xzf /tmp/deploy.tar.gz
rm /tmp/deploy.tar.gz
if [ -f \"$REMOTE_DIR/scripts/security/install-security-stack.sh\" ]; then
echo '安装 security-stack...'
bash \"$REMOTE_DIR/scripts/security/install-security-stack.sh\" || echo 'security-stack 安装失败,继续部署'
elif [ -f \"$REMOTE_DIR/scripts/security/install-perf-guard.sh\" ]; then
echo '安装 perf-guard...'
bash \"$REMOTE_DIR/scripts/security/install-perf-guard.sh\" || echo 'perf-guard 安装失败,继续部署'
fi
"
echo -e "${GREEN}步骤 5: 配置环境变量...${NC}"
ssh_cmd "
cd $REMOTE_DIR
# 生成 AUTH_SECRET
AUTH_SECRET=\$(openssl rand -base64 32)
# 创建 .env.local 文件
cat > .env.local << EOF
POSTGRES_URL=postgresql://chatbot_user:ChatBot@2024!Secure@localhost:5432/ai_chatbot
AUTH_SECRET=\$AUTH_SECRET
AUTH_URL=http://$SERVER
NODE_ENV=production
EOF
echo '环境变量文件已创建'
"
echo -e "${GREEN}步骤 6: 安装依赖和构建...${NC}"
ssh_cmd "
cd $REMOTE_DIR
export NVM_DIR=\"\$HOME/.nvm\"
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
echo '安装依赖...'
pnpm install --production=false
echo '运行数据库迁移...'
pnpm db:migrate || echo '迁移可能已存在,继续...'
echo '构建项目...'
pnpm build
"
echo -e "${GREEN}步骤 7: 配置 PM2...${NC}"
# 上传 PM2 配置文件
scp_cmd "ecosystem.config.js" "$REMOTE_DIR/"
ssh_cmd "
cd $REMOTE_DIR
export NVM_DIR=\"\$HOME/.nvm\"
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
# 停止旧进程(如果存在)
pm2 delete ai-chatbot 2>/dev/null || true
# 启动应用
pm2 start ecosystem.config.js
pm2 save
# 设置开机自启
pm2 startup systemd -u root --hp /root 2>/dev/null || echo 'PM2 startup 已配置'
"
echo -e "${GREEN}步骤 8: 配置 Nginx...${NC}"
ssh_cmd "
# 创建 Nginx 配置
cat > /etc/nginx/sites-available/ai-chatbot << 'NGINX'
server {
listen 80;
server_name $SERVER;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
}
NGINX
# 启用站点
ln -sf /etc/nginx/sites-available/ai-chatbot /etc/nginx/sites-enabled/
# 删除默认配置(如果存在)
rm -f /etc/nginx/sites-enabled/default
# 测试配置
nginx -t
# 重启 Nginx
systemctl restart nginx
"
echo -e "${GREEN}步骤 9: 配置防火墙...${NC}"
ssh_cmd "
ufw --force enable
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 5204/tcp
ufw allow 3000/tcp
"
echo -e "${GREEN}步骤 10: 检查部署状态...${NC}"
ssh_cmd "
echo '=== PM2 状态 ==='
pm2 status
echo ''
echo '=== 应用日志(最后20行)==='
pm2 logs ai-chatbot --lines 20 --nostream
echo ''
echo '=== Nginx 状态 ==='
systemctl status nginx --no-pager -l
echo ''
echo '=== 端口监听 ==='
netstat -tulpn | grep -E ':(80|3000|5432)' || ss -tulpn | grep -E ':(80|3000|5432)'
"
# 清理本地临时文件
rm -f /tmp/deploy.tar.gz
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}部署完成!${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "${YELLOW}访问地址: http://$SERVER${NC}"
echo -e "${YELLOW}管理员设置: http://$SERVER/admin/settings${NC}"
echo -e "${YELLOW}查看日志: ssh -p $PORT $USER@$SERVER 'pm2 logs ai-chatbot'${NC}"
echo -e "${YELLOW}查看状态: ssh -p $PORT $USER@$SERVER 'pm2 status'${NC}"
echo ""
echo -e "${YELLOW}数据库信息:${NC}"
echo -e " 数据库名: ai_chatbot"
echo -e " 用户名: chatbot_user"
echo -e " 密码: ChatBot@2024!Secure"
echo ""
echo -e "${YELLOW}注意: 请尽快修改数据库密码和服务器密码!${NC}"

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

@ -112,6 +112,8 @@ const PREVIEW_DIMENSION_HANDLE_RADIUS = 3.2;
const PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH = 1.2; const PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH = 1.2;
const PREVIEW_DIMENSION_TEXT_HALO_WIDTH = 2.2; const PREVIEW_DIMENSION_TEXT_HALO_WIDTH = 2.2;
const PREVIEW_DIMENSION_TEXT_OUTLINE_WIDTH = 0.85; const PREVIEW_DIMENSION_TEXT_OUTLINE_WIDTH = 0.85;
const DIMENSION_FONT_FAMILY =
'"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif';
const SIZE_INFO_LINE_REGEX = /尺寸信息[::]\s*([^\n]+)/i; const SIZE_INFO_LINE_REGEX = /尺寸信息[::]\s*([^\n]+)/i;
const NUMBER_REGEX = /\d+(?:\.\d+)?/g; const NUMBER_REGEX = /\d+(?:\.\d+)?/g;
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/; const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
@ -568,87 +570,54 @@ function drawAnnotationToCanvas(params: {
context: CanvasRenderingContext2D; context: CanvasRenderingContext2D;
annotation: DimensionAnnotation; annotation: DimensionAnnotation;
imageSize: ImageSize; imageSize: ImageSize;
previewRect: PreviewRect;
annotations: DimensionAnnotation[]; annotations: DimensionAnnotation[];
autoDimensionValues?: AutoDimensionValues | null; autoDimensionValues?: AutoDimensionValues | null;
}) { }) {
const { context, annotation, imageSize, annotations, autoDimensionValues } = const { context, annotation } = params;
params;
const annotationColor = getAnnotationColor(annotation); const annotationColor = getAnnotationColor(annotation);
const label = getDimensionLabel({ const geometry = getPreviewAnnotationGeometry(params);
annotation,
annotations,
autoDimensionValues,
});
const vector = getAnnotationVector(
annotation.x1,
annotation.y1,
annotation.x2,
annotation.y2
);
const rawAngleRadians = Math.atan2(vector.dy, vector.dx);
const angleRadians =
rawAngleRadians > Math.PI / 2
? rawAngleRadians - Math.PI
: rawAngleRadians < -Math.PI / 2
? rawAngleRadians + Math.PI
: rawAngleRadians;
const strokeWidth = clamp(
Math.min(imageSize.width, imageSize.height) * 0.001_35,
1,
2.4
);
const capLength = strokeWidth * 5.3;
const fontSize = clamp(
Math.round(Math.min(imageSize.width, imageSize.height) * 0.0176),
11,
27
);
const labelOffset = fontSize * 0.95;
const centerX = (annotation.x1 + annotation.x2) / 2 + vector.nx * labelOffset;
const centerY = (annotation.y1 + annotation.y2) / 2 + vector.ny * labelOffset;
const textOutlineColor = getAnnotationTextOutlineColor(annotation); const textOutlineColor = getAnnotationTextOutlineColor(annotation);
const textOutlineWidth = annotation.glow
? Math.max(1.5, fontSize * 0.18)
: Math.max(0.85, fontSize * 0.08);
context.save(); context.save();
context.strokeStyle = annotationColor; context.strokeStyle = annotationColor;
context.lineWidth = strokeWidth; context.lineWidth = PREVIEW_DIMENSION_STROKE_WIDTH;
context.lineCap = "round"; context.lineCap = "round";
context.lineJoin = "round"; context.lineJoin = "round";
context.shadowColor = annotation.glow context.shadowColor = annotation.glow
? colorWithAlpha(annotationColor, 0.52) ? colorWithAlpha(annotationColor, 0.42)
: "transparent"; : "transparent";
context.shadowBlur = annotation.glow ? Math.max(8, fontSize * 0.7) : 0; context.shadowBlur = annotation.glow ? 4 : 0;
context.beginPath(); context.beginPath();
context.moveTo(annotation.x1, annotation.y1); context.moveTo(geometry.start.x, geometry.start.y);
context.lineTo(annotation.x2, annotation.y2); context.lineTo(geometry.end.x, geometry.end.y);
context.stroke(); context.stroke();
context.lineWidth = PREVIEW_DIMENSION_CAP_STROKE_WIDTH;
drawDimensionCap({ drawDimensionCap({
context, context,
point: { x: annotation.x1, y: annotation.y1 }, point: geometry.start,
size: capLength, size: PREVIEW_DIMENSION_TICK_LENGTH,
vector, vector: geometry.vector,
}); });
drawDimensionCap({ drawDimensionCap({
context, context,
point: { x: annotation.x2, y: annotation.y2 }, point: geometry.end,
size: capLength, size: PREVIEW_DIMENSION_TICK_LENGTH,
vector, vector: geometry.vector,
}); });
context.translate(centerX, centerY); context.translate(geometry.labelCenterX, geometry.labelCenterY);
context.rotate(angleRadians); context.rotate((geometry.angleDegrees * Math.PI) / 180);
context.font = `500 ${fontSize}px "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif`; context.font = `500 ${PREVIEW_DIMENSION_FONT_SIZE}px ${DIMENSION_FONT_FAMILY}`;
context.textAlign = "center"; context.textAlign = "center";
context.textBaseline = "middle"; context.textBaseline = "middle";
context.lineWidth = textOutlineWidth; context.lineWidth = getAnnotationTextOutlineWidth(annotation);
context.strokeStyle = textOutlineColor; context.strokeStyle = textOutlineColor;
context.strokeText(label, 0, 0); context.strokeText(geometry.label, 0, 0.5);
context.fillStyle = annotationColor; context.fillStyle = annotationColor;
context.fillText(label, 0, 0); context.fillText(geometry.label, 0, 0.5);
context.restore(); context.restore();
} }
@ -1067,6 +1036,10 @@ export function AgentPsdTemplateExportPreview({
return; return;
} }
if (!imageSize || !previewRect) {
throw new Error("请等待预览图片加载完成后再下载");
}
const { image, dispose } = await loadImageFromCandidates([ const { image, dispose } = await loadImageFromCandidates([
buildImageProxyUrl(imageUrl, { delivery: "proxy" }), buildImageProxyUrl(imageUrl, { delivery: "proxy" }),
imageUrl, imageUrl,
@ -1082,18 +1055,25 @@ export function AgentPsdTemplateExportPreview({
} }
context.drawImage(image, 0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height);
// Draw in the same coordinates as the SVG, then map its image area to
// the original bitmap. The preview can use a smaller image variant.
context.save();
context.scale(
canvas.width / previewRect.width,
canvas.height / previewRect.height
);
context.translate(-previewRect.left, -previewRect.top);
for (const annotation of annotations) { for (const annotation of annotations) {
drawAnnotationToCanvas({ drawAnnotationToCanvas({
annotation, annotation,
annotations, annotations,
autoDimensionValues, autoDimensionValues,
context, context,
imageSize: { imageSize,
width: canvas.width, previewRect,
height: canvas.height,
},
}); });
} }
context.restore();
const blob = await new Promise<Blob>((resolve, reject) => { const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
@ -1125,6 +1105,8 @@ export function AgentPsdTemplateExportPreview({
downloadName, downloadName,
enableDimensionAnnotations, enableDimensionAnnotations,
imageUrl, imageUrl,
imageSize,
previewRect,
]); ]);
return ( return (
@ -1305,6 +1287,7 @@ export function AgentPsdTemplateExportPreview({
<text <text
dominantBaseline="middle" dominantBaseline="middle"
fill={annotationColor} fill={annotationColor}
fontFamily={DIMENSION_FONT_FAMILY}
fontSize={PREVIEW_DIMENSION_FONT_SIZE} fontSize={PREVIEW_DIMENSION_FONT_SIZE}
fontWeight={500} fontWeight={500}
paintOrder="stroke" paintOrder="stroke"

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,7 +1,7 @@
"use client"; "use client";
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react"; import { Check, ImageIcon, ImagePlus, Loader2, Search, Upload, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -21,6 +21,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_TOO_LARGE_MESSAGE } from "@/lib/image-upload-limits";
import { imageGenerationService } from "@/services/canvas";
import type { import type {
AgentWorkflowMaterialImage, AgentWorkflowMaterialImage,
AgentWorkflowMaterialQueryType, AgentWorkflowMaterialQueryType,
@ -163,6 +165,8 @@ export function CanvasAddImageDialog({
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]); const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const localUploadInputRef = useRef<HTMLInputElement>(null);
const [errorText, setErrorText] = useState<string | null>(null); const [errorText, setErrorText] = useState<string | null>(null);
const images = useMemo(() => normalizeImages(result), [result]); const images = useMemo(() => normalizeImages(result), [result]);
@ -182,12 +186,13 @@ export function CanvasAddImageDialog({
const handleDialogOpenChange = useCallback( const handleDialogOpenChange = useCallback(
(nextOpen: boolean) => { (nextOpen: boolean) => {
if (!nextOpen && (isUploading || isAdding)) return;
if (!nextOpen) { if (!nextOpen) {
resetDialogState(); resetDialogState();
} }
onOpenChange(nextOpen); onOpenChange(nextOpen);
}, },
[onOpenChange, resetDialogState] [isAdding, isUploading, onOpenChange, resetDialogState]
); );
const closeDialog = useCallback(() => { const closeDialog = useCallback(() => {
@ -263,6 +268,51 @@ export function CanvasAddImageDialog({
} }
}; };
const handleLocalUpload = async (files: File[]) => {
if (!files.length || isUploading || isAdding) return;
if (files.some((file) => !file.type.startsWith("image/"))) {
setErrorText("请选择图片文件");
return;
}
if (files.some((file) => file.size > IMAGE_UPLOAD_MAX_BYTES)) {
setErrorText(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
return;
}
setIsUploading(true);
setErrorText(null);
try {
const uploadedImages: CanvasAddImageDialogImage[] = [];
const failures: string[] = [];
for (const [index, file] of files.entries()) {
const upload = await imageGenerationService.uploadImage(file);
if (!upload.success || !upload.data?.url) {
failures.push(`${file.name}:${upload.error || "图片上传失败"}`);
continue;
}
uploadedImages.push({
id: upload.data.assetId || crypto.randomUUID(),
key: upload.data.pathname || file.name,
label: file.name.replace(/\.[^.]+$/, "") || file.name,
fileName: file.name,
imageUrl: upload.data.url,
assetId: upload.data.assetId,
mimeType: file.type,
sourceType: "upload",
group: "other",
sortOrder: index,
});
}
if (uploadedImages.length) await onAddImages(uploadedImages);
if (failures.length) setErrorText(failures.join(";"));
else closeDialog();
} catch (error) {
setErrorText(error instanceof Error ? error.message : "图片上传失败,请重试");
} finally {
setIsUploading(false);
}
};
return ( return (
<Dialog onOpenChange={handleDialogOpenChange} open={open}> <Dialog onOpenChange={handleDialogOpenChange} open={open}>
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0"> <DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
@ -272,15 +322,38 @@ export function CanvasAddImageDialog({
添加图片 添加图片
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。 从本地上传图片、添加空白图片节点,或检索 SPU / SKU 白底图。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[62vh] overflow-y-auto px-5 py-4"> <div className="max-h-[62vh] overflow-y-auto px-5 py-4">
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3"> <div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
<div className="flex flex-col gap-2.5 sm:flex-row"> <div className="flex flex-col gap-2.5 sm:flex-row">
<input
accept="image/*"
className="hidden"
multiple
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
void handleLocalUpload(files);
}}
ref={localUploadInputRef}
type="file"
/>
<Button <Button
className="h-9 justify-start gap-2" className="h-9 shrink-0 justify-start gap-2"
disabled={isUploading || isAdding}
onClick={() => localUploadInputRef.current?.click()}
type="button"
variant="secondary"
>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{isUploading ? "上传中…" : "本地上传"}
</Button>
<Button
className="h-9 shrink-0 justify-start gap-2"
disabled={isUploading || isAdding}
onClick={() => { onClick={() => {
onCreateBlankImage(); onCreateBlankImage();
closeDialog(); closeDialog();
@ -355,13 +428,13 @@ export function CanvasAddImageDialog({
</div> </div>
{!materialSearchEnabled ? ( {!materialSearchEnabled ? (
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground"> <div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。 风格化功能或素材搜索步骤未开启,可从本地上传或添加空白图片。
</div> </div>
) : null} ) : null}
</div> </div>
{errorText ? ( {errorText ? (
<div className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive"> <div role="alert" className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
{errorText} {errorText}
</div> </div>
) : null} ) : null}
@ -440,6 +513,7 @@ export function CanvasAddImageDialog({
<DialogFooter className="border-t px-5 py-3"> <DialogFooter className="border-t px-5 py-3">
<Button <Button
disabled={isUploading || isAdding}
onClick={closeDialog} onClick={closeDialog}
type="button" type="button"
variant="outline" variant="outline"
@ -448,7 +522,7 @@ export function CanvasAddImageDialog({
</Button> </Button>
<Button <Button
className="gap-2" className="gap-2"
disabled={!selectedImages.length || isAdding} disabled={!selectedImages.length || isAdding || isUploading}
onClick={() => void handleAddSelected()} onClick={() => void handleAddSelected()}
type="button" type="button"
> >

View File

@ -0,0 +1,115 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { Position } from "@/types/canvas";
type Props = {
position: Position | null;
isMac: boolean;
videoUploading: boolean;
onClose: () => void;
onUploadImage: () => void;
onUploadVideo: () => void;
onPaste: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onFitAll: () => void;
onResetZoom: () => void;
};
export function CanvasBlankContextMenu({
position,
isMac,
videoUploading,
onClose,
onUploadImage,
onUploadVideo,
onPaste,
onZoomIn,
onZoomOut,
onFitAll,
onResetZoom,
}: Props) {
const modifier = isMac ? "⌘" : "Ctrl+";
const itemClass =
"h-8 cursor-pointer justify-between whitespace-nowrap rounded-[5px] px-0 text-[14px] font-normal focus:bg-[#f5f5f5] dark:focus:bg-white/10";
const shortcut = (label: string) => (
<span className="ml-2 whitespace-nowrap text-[13px] text-[#aaa]">
{label}
</span>
);
return (
<DropdownMenu
modal={false}
open={position !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DropdownMenuTrigger asChild>
<button
aria-hidden="true"
tabIndex={-1}
data-canvas-no-drag="true"
className="pointer-events-none fixed h-px w-px opacity-0"
style={{ left: position?.x ?? 0, top: position?.y ?? 0 }}
type="button"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
aria-label="画布右键菜单"
align="start"
sideOffset={0}
collisionPadding={8}
data-canvas-blank-menu="true"
data-canvas-no-drag="true"
className="z-[10005] w-[200px] rounded-[12px] border-[#ededed] bg-white px-3 py-[5px] text-[#242424] shadow-[0_8px_20px_rgba(0,0,0,0.06)] ring-0 dark:border-white/10 dark:bg-zinc-900 dark:text-white"
style={{
fontFamily:
'Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
}}
onCloseAutoFocus={(event) => event.preventDefault()}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DropdownMenuItem className={itemClass} onSelect={onUploadImage}>
上传图片
</DropdownMenuItem>
<DropdownMenuItem
className={itemClass}
disabled={videoUploading}
onSelect={onUploadVideo}
>
上传视频
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
<DropdownMenuItem className={itemClass} onSelect={onPaste}>
粘贴{shortcut(`${modifier}V`)}
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
<DropdownMenuItem className={itemClass} onSelect={onZoomIn}>
放大{shortcut(`${modifier}+`)}
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={onZoomOut}>
缩小{shortcut(`${modifier}-`)}
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={onFitAll}>
显示画布所有元素{shortcut(isMac ? "⇧1" : "Shift+1")}
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={onResetZoom}>
缩放至100%{shortcut(`${modifier}0`)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,54 @@
"use client";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
type CanvasComposerPromoProps = {
text: string;
onDismiss: () => void;
onActivate?: () => void;
};
export function CanvasComposerPromo({
text,
onDismiss,
onActivate,
}: CanvasComposerPromoProps) {
return (
<div className="lovart-chat-promo lovart-chat-promo-connected">
<div className="lovart-chat-promo-surface">
<div className="lovart-chat-promo-row">
<button
type="button"
className="lovart-chat-promo-message"
onClick={onActivate}
disabled={!onActivate}
title={text}
>
<svg
aria-hidden="true"
width="16"
height="18"
viewBox="0 0 24 28"
className="lovart-chat-promo-badge"
>
<path d="M10 1.15a4 4 0 0 1 4 0l8 4.6a4 4 0 0 1 2 3.46v9.58a4 4 0 0 1-2 3.46l-8 4.6a4 4 0 0 1-4 0l-8-4.6A4 4 0 0 1 0 18.79V9.21a4 4 0 0 1 2-3.46Z" fill="#ddef65" />
<path
d="M12 5.5c1.2 5.3 2.2 6.3 7.5 7.5-5.3 1.2-6.3 2.2-7.5 7.5C10.8 15.2 9.8 14.2 4.5 13c5.3-1.2 6.3-2.2 7.5-7.5Z"
fill="#111"
/>
</svg>
<span>{text}</span>
</button>
<button
type="button"
className="lovart-chat-promo-close"
aria-label="关闭优惠广告"
onClick={onDismiss}
>
<ReferenceIcon name="close" size={18} />
</button>
</div>
</div>
</div>
);
}

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

@ -0,0 +1,76 @@
"use client";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
type CanvasSurfaceHeaderProps = {
onAddImage: () => void;
onHome: () => void;
onOpenFiles: () => void;
projectTitle: string;
rightInset: number;
};
const iconButtonClass =
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
export function CanvasSurfaceHeader({
onAddImage,
onHome,
onOpenFiles,
projectTitle,
rightInset,
}: CanvasSurfaceHeaderProps) {
return (
<div
className="absolute left-0 top-0 z-20 flex h-[40px] items-center justify-between border-b border-[#ededed] bg-white px-3 dark:border-white/10 dark:bg-zinc-950"
data-canvas-topbar="true"
style={{ right: rightInset }}
>
<div className="flex min-w-0 items-center gap-2">
<button
aria-label="返回首页"
className={iconButtonClass}
onClick={onHome}
title="返回首页"
type="button"
>
<ReferenceIcon name="branding" size={16} />
</button>
<button
aria-label="添加参考素材"
className={iconButtonClass}
onClick={onAddImage}
title="添加参考素材"
type="button"
>
<ReferenceIcon name="reference" size={16} />
</button>
<button
aria-label="查看项目文件"
className={iconButtonClass}
onClick={onOpenFiles}
title="查看项目文件"
type="button"
>
<ReferenceIcon name="brand" size={16} />
</button>
<div className="mx-1 h-4 w-px bg-[#ededed] dark:bg-white/10" />
<span className="inline-flex h-7 min-w-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[13px] text-[#333333] dark:bg-white/10 dark:text-white">
<ReferenceIcon name="page" size={16} />
<span className="max-w-[160px] truncate">
{projectTitle || "页面 1"}
</span>
</span>
<button
aria-label="添加到画布"
className={iconButtonClass}
onClick={onAddImage}
title="添加到画布"
type="button"
>
<ReferenceIcon name="plus" size={16} />
</button>
</div>
</div>
);
}

View File

@ -1,6 +1,10 @@
"use client"; "use client";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import {
ReferenceIcon,
type ReferenceIconName,
} from "@/components/lovart/reference-icon";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config"; import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -12,13 +16,12 @@ type CanvasToolbarIconProps = {
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
active?: boolean; active?: boolean;
referenceName?: ReferenceIconName;
referenceSize?: number;
}; };
function isSvgIconUrl(url: string) { function isSvgIconUrl(url: string) {
return ( return /^data:image\/svg\+xml/i.test(url) || /\.svg(?:[?#].*)?$/i.test(url);
/^data:image\/svg\+xml/i.test(url) ||
/\.svg(?:[?#].*)?$/i.test(url)
);
} }
function stripImageFilterClasses(className?: string) { function stripImageFilterClasses(className?: string) {
@ -42,6 +45,8 @@ export function CanvasToolbarIcon({
className, className,
style, style,
active = false, active = false,
referenceName,
referenceSize = 18,
}: CanvasToolbarIconProps) { }: CanvasToolbarIconProps) {
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim(); const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
@ -75,7 +80,9 @@ export function CanvasToolbarIcon({
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
"object-contain", "object-contain",
active ? "brightness-0 invert opacity-95" : "brightness-0 opacity-80 dark:invert", active
? "brightness-0 invert opacity-95"
: "brightness-0 opacity-80 dark:invert",
className className
)} )}
src={resolvedIconUrl} src={resolvedIconUrl}
@ -84,5 +91,15 @@ export function CanvasToolbarIcon({
); );
} }
if (referenceName) {
return (
<ReferenceIcon
name={referenceName}
size={referenceSize}
className={className}
style={style}
/>
);
}
return <FallbackIcon className={className} style={style} />; return <FallbackIcon className={className} style={style} />;
} }

View File

@ -12,8 +12,13 @@ import { UiScreenNode } from "./nodes/UiScreenNode";
import { VideoNode } from "./nodes/VideoNode"; import { VideoNode } from "./nodes/VideoNode";
import { WorkflowNode } from "./nodes/WorkflowNode"; import { WorkflowNode } from "./nodes/WorkflowNode";
import { CanvasToolbarIcon } from "./CanvasToolbarIcon"; import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
import { CanvasBlankContextMenu } from "./CanvasBlankContextMenu";
import { getCanvasFitViewport } from "@/lib/canvas/viewport-fit";
import { downloadCanvasSelectionZip } from "@/lib/client/canvas-zip-download";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner"; import { toast } from "sonner";
import { imageGenerationService } from "@/services/canvas"; import { imageGenerationService } from "@/services/canvas";
import { import {
@ -28,6 +33,7 @@ import {
LayoutTemplate, LayoutTemplate,
LayoutGrid, LayoutGrid,
Lightbulb, Lightbulb,
Loader2,
Minus, Minus,
Pin, Pin,
Plus, Plus,
@ -390,6 +396,11 @@ export function CanvasWorkspace({
} | null; } | null;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const contentLayerRef = useRef<HTMLDivElement>(null); const contentLayerRef = useRef<HTMLDivElement>(null);
const contextImageInputRef = useRef<HTMLInputElement>(null);
const contextVideoInputRef = useRef<HTMLInputElement>(null);
const contextCanvasPointRef = useRef<Position>({ x: 0, y: 0 });
const [blankContextPosition, setBlankContextPosition] = useState<Position | null>(null);
const [isContextVideoUploading, setIsContextVideoUploading] = useState(false);
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 }); const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false); const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
@ -727,10 +738,6 @@ export function CanvasWorkspace({
canvasToolbarConfig, canvasToolbarConfig,
"imageLayoutReplace" "imageLayoutReplace"
); );
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
const selectedImageLocalEditEnabled = true;
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
const selectedImageLayoutReplaceEnabled = true;
// Store actions // Store actions
const selectNode = useCanvasStore((state) => state.selectNode); const selectNode = useCanvasStore((state) => state.selectNode);
@ -1106,42 +1113,61 @@ export function CanvasWorkspace({
); );
const fitAllImageNodesIntoViewport = useCallback(() => { const fitAllImageNodesIntoViewport = useCallback(() => {
const imageNodes = nodes.filter( const fit = getCanvasFitViewport(
(node) => node.type === "image" && Boolean((node.data as any)?.src) nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)),
containerSize, occlusionRight
); );
if (imageNodes.length === 0) return; if (fit) setViewport(fit);
if (containerSize.width <= 0 || containerSize.height <= 0) return; }, [containerSize, nodes, occlusionRight, setViewport]);
const padding = 80; const fitAllCanvasNodesIntoViewport = useCallback(() => {
const minX = Math.min(...imageNodes.map((node) => node.position.x)); const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight);
const minY = Math.min(...imageNodes.map((node) => node.position.y)); if (fit) setViewport(fit);
const maxX = Math.max( }, [containerSize, nodes, occlusionRight, setViewport]);
...imageNodes.map((node) => node.position.x + node.size.width)
);
const maxY = Math.max(
...imageNodes.map((node) => node.position.y + node.size.height)
);
const contentWidth = Math.max(1, maxX - minX + padding * 2); const handleBlankCanvasContextMenu = useCallback((event: React.MouseEvent) => {
const contentHeight = Math.max(1, maxY - minY + padding * 2); if (isWorkflowCanvas || hasBlockingDialogOpen() || canvasImageSelectionSession) return;
if (!(event.target instanceof Element) || event.target.closest(
'[data-canvas-node-id], [data-canvas-overlay], [data-canvas-no-drag="true"], button, input, textarea, select, a, [contenteditable="true"]'
)) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.stopPropagation();
contextCanvasPointRef.current = {
x: (event.clientX - rect.left - viewportRef.current.x) / viewportRef.current.zoom,
y: (event.clientY - rect.top - viewportRef.current.y) / viewportRef.current.zoom,
};
setBlankContextPosition({ x: event.clientX, y: event.clientY });
setAgentSlotContextMenu(null);
}, [canvasImageSelectionSession, hasBlockingDialogOpen, isWorkflowCanvas]);
const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight)); const zoomCanvasFromContextMenu = useCallback((factor: number) => {
const fitZoomX = visibleWidth / contentWidth; const current = useCanvasStore.getState().viewport;
const fitZoomY = containerSize.height / contentHeight; const zoom = Math.max(0.1, Math.min(4, current.zoom * factor));
const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY))); const centerX = Math.max(1, containerSize.width - occlusionRight) / 2;
const centerY = containerSize.height / 2;
const ratio = zoom / current.zoom;
setViewport({ x: centerX - (centerX - current.x) * ratio, y: centerY - (centerY - current.y) * ratio, zoom });
}, [containerSize, occlusionRight, setViewport]);
const centerX = (minX + maxX) / 2; React.useEffect(() => {
const centerY = (minY + maxY) / 2; if (isWorkflowCanvas) return;
const visibleCenterX = visibleWidth / 2; const handleFitShortcut = (event: KeyboardEvent) => {
const nextViewportX = visibleCenterX - centerX * targetZoom; if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.code !== "Digit1") return;
const nextViewportY = containerSize.height / 2 - centerY * targetZoom; if (hasBlockingDialogOpen() || (event.target instanceof Element && event.target.closest(
'input, textarea, [contenteditable="true"], [role="textbox"]'
setViewport({ ))) return;
x: nextViewportX, const active = document.activeElement;
y: nextViewportY, if (active && active !== document.body && !containerRef.current?.contains(active)
zoom: targetZoom, && !active.closest('[data-canvas-floating-toolbar], [data-canvas-blank-menu="true"]')) return;
}); event.preventDefault();
}, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]); fitAllCanvasNodesIntoViewport();
setBlankContextPosition(null);
};
window.addEventListener("keydown", handleFitShortcut);
return () => window.removeEventListener("keydown", handleFitShortcut);
}, [fitAllCanvasNodesIntoViewport, hasBlockingDialogOpen, isWorkflowCanvas]);
const getNodeInputAnchor = useCallback( const getNodeInputAnchor = useCallback(
(node: CanvasNode): Position => ({ (node: CanvasNode): Position => ({
@ -1258,6 +1284,7 @@ export function CanvasWorkspace({
if (canvasImageSelectionSession) return; if (canvasImageSelectionSession) return;
if (hasBlockingDialogOpen()) return; if (hasBlockingDialogOpen()) return;
setAgentSlotContextMenu(null); setAgentSlotContextMenu(null);
setBlankContextPosition(null);
if (!isEventFromCanvasContainer(e.target)) return; if (!isEventFromCanvasContainer(e.target)) return;
if (suppressCanvasClickRef.current) { if (suppressCanvasClickRef.current) {
suppressCanvasClickRef.current = false; suppressCanvasClickRef.current = false;
@ -1294,7 +1321,7 @@ export function CanvasWorkspace({
!isPointOnNodeElement(e.target); !isPointOnNodeElement(e.target);
if ( if (
tool === 'pan' || (tool === 'pan' && e.button === 0) ||
e.button === 1 || e.button === 1 ||
(e.button === 0 && e.altKey) || (e.button === 0 && e.altKey) ||
shouldPanBlankWorkflowCanvas shouldPanBlankWorkflowCanvas
@ -1657,6 +1684,105 @@ export function CanvasWorkspace({
} }
}, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]); }, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
const getContextUploadClientPoint = useCallback(() => {
const rect = containerRef.current?.getBoundingClientRect();
const current = viewportRef.current;
return {
x: (rect?.left ?? 0) + current.x + contextCanvasPointRef.current.x * current.zoom,
y: (rect?.top ?? 0) + current.y + contextCanvasPointRef.current.y * current.zoom,
};
}, []);
const pasteCanvasText = useCallback((text: string, point: Position) => {
if (!text.trim()) return;
addNode({
type: "text", position: point, size: { width: 320, height: 160 },
data: { content: text, color: "#111827" }, connections: [],
});
}, [addNode]);
const uploadContextVideos = useCallback(async (files: File[]) => {
if (!files.length || isContextVideoUploading) return;
const position = { ...contextCanvasPointRef.current };
setIsContextVideoUploading(true);
try {
for (const [index, file] of files.entries()) {
if (!["video/mp4", "video/quicktime", "video/webm"].includes(file.type)) {
toast.error("请选择 MP4、MOV 或 WebM 视频");
continue;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("视频文件不能超过 50MB");
continue;
}
const loadingToast = toast.loading(`正在上传 ${file.name}`);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-video-process");
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result.url) throw new Error(result.error || "视频上传失败");
addNode({
type: "video", position: { x: position.x + index * 24, y: position.y + index * 24 },
size: { width: 320, height: 180 },
data: { src: result.url, assetId: result.assetId, status: "completed", prompt: file.name,
metadata: { source: "local-upload", fileName: file.name, contentType: file.type } },
connections: [],
});
toast.success("视频已添加到画布");
} catch (error) {
toast.error(error instanceof Error ? error.message : "视频上传失败,请重试");
} finally {
toast.dismiss(loadingToast);
}
}
} finally {
setIsContextVideoUploading(false);
}
}, [addNode, isContextVideoUploading]);
const pasteFromContextMenu = useCallback(async () => {
const pastePoint = { ...contextCanvasPointRef.current };
const clientPoint = getContextUploadClientPoint();
try {
const files: File[] = [];
let text = "";
if (typeof navigator.clipboard?.read === "function") {
const items = await navigator.clipboard.read();
for (const [index, item] of items.entries()) {
const imageType = item.types.find((type) => type.startsWith("image/"));
if (imageType) {
const blob = await item.getType(imageType);
files.push(new File([blob], `粘贴图片-${Date.now()}-${index}.${imageType.split("/")[1]}`, { type: imageType }));
} else if (item.types.includes("text/plain")) {
text += await (await item.getType("text/plain")).text();
}
}
} else if (typeof navigator.clipboard?.readText === "function") {
text = await navigator.clipboard.readText();
} else {
throw new Error("Clipboard unavailable");
}
if (files.length) {
await processDroppedFiles(files, clientPoint.x, clientPoint.y);
} else if (text.trim()) {
pasteCanvasText(text, pastePoint);
} else {
toast.info("剪贴板中没有可粘贴的图片或文字");
}
} catch {
toast.info(`无法读取剪贴板,请使用 ${isMacPlatform ? "⌘V" : "Ctrl+V"} 粘贴`);
}
}, [getContextUploadClientPoint, isMacPlatform, pasteCanvasText, processDroppedFiles]);
React.useEffect(() => {
if (!blankContextPosition) return;
const close = () => setBlankContextPosition(null);
window.addEventListener("resize", close);
return () => window.removeEventListener("resize", close);
}, [blankContextPosition]);
React.useEffect(() => { React.useEffect(() => {
const handleWindowDrop = async (e: DragEvent) => { const handleWindowDrop = async (e: DragEvent) => {
if (!isFileDrag(e.dataTransfer)) return; if (!isFileDrag(e.dataTransfer)) return;
@ -1719,24 +1845,44 @@ export function CanvasWorkspace({
React.useEffect(() => { React.useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => { const handlePaste = async (e: ClipboardEvent) => {
if (hasBlockingDialogOpen() || isEditablePasteTarget(e.target)) return; const activeElement = document.activeElement;
const fromContextMenu = Boolean(activeElement?.closest('[data-canvas-blank-menu="true"]'));
if (hasBlockingDialogOpen() || (!fromContextMenu && isEditablePasteTarget(e.target))) return;
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
const activeElement = document.activeElement;
if ( if (
activeElement instanceof HTMLElement && activeElement instanceof HTMLElement &&
activeElement !== document.body && activeElement !== document.body &&
!container.contains(activeElement) !container.contains(activeElement) && !fromContextMenu
) { ) {
return; return;
} }
const files = extractImageFilesFromDataTransfer(e.clipboardData); const files = extractImageFilesFromDataTransfer(e.clipboardData);
if (files.length === 0) return; const text = e.clipboardData?.getData("text/plain") || "";
if (files.length === 0 && (isWorkflowCanvas || !text.trim())) return;
e.preventDefault(); e.preventDefault();
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
if (fromContextMenu) {
setBlankContextPosition(null);
if (files.length) {
const point = getContextUploadClientPoint();
await processDroppedFiles(files, point.x, point.y);
} else {
pasteCanvasText(text, { ...contextCanvasPointRef.current });
}
return;
}
if (files.length === 0) {
const current = viewportRef.current;
pasteCanvasText(text, {
x: (rect.width / 2 - current.x) / current.zoom,
y: (rect.height / 2 - current.y) / current.zoom,
});
return;
}
await processDroppedFiles( await processDroppedFiles(
files, files,
rect.left + rect.width / 2, rect.left + rect.width / 2,
@ -1746,7 +1892,7 @@ export function CanvasWorkspace({
window.addEventListener("paste", handlePaste); window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste); return () => window.removeEventListener("paste", handlePaste);
}, [hasBlockingDialogOpen, processDroppedFiles]); }, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, processDroppedFiles]);
const handleDownloadSelectedImages = useCallback(async () => { const handleDownloadSelectedImages = useCallback(async () => {
if (selectedImageNodes.length === 0) { if (selectedImageNodes.length === 0) {
@ -1769,10 +1915,10 @@ export function CanvasWorkspace({
const rawImageUrl = String(nodeData?.src || "").trim(); const rawImageUrl = String(nodeData?.src || "").trim();
if (!rawImageUrl) return null; if (!rawImageUrl) return null;
let imageUrl = inferOriginalImageUrl(rawImageUrl) || rawImageUrl; let imageUrl = inferOriginalImageUrl(rawImageUrl) || rawImageUrl;
if (browserOrigin && rawImageUrl.startsWith("/")) { if (browserOrigin && rawImageUrl.startsWith("//")) {
imageUrl = `${browserOrigin}${rawImageUrl}`;
} else if (browserOrigin && rawImageUrl.startsWith("//")) {
imageUrl = `${window.location.protocol}${rawImageUrl}`; imageUrl = `${window.location.protocol}${rawImageUrl}`;
} else if (browserOrigin && rawImageUrl.startsWith("/")) {
imageUrl = `${browserOrigin}${rawImageUrl}`;
} }
const name = const name =
String(nodeData?.title || "").trim() || String(nodeData?.title || "").trim() ||
@ -1788,32 +1934,18 @@ export function CanvasWorkspace({
} }
setIsDownloadSubmitting(true); setIsDownloadSubmitting(true);
const toastId = toast.loading("正在准备所选图片...");
try { try {
const form = document.createElement("form"); const result = await downloadCanvasSelectionZip({ folderName, files,
form.method = "POST"; selectionKey: `${useCanvasStore.getState().projectId}:${selectedImageNodes.map(node => node.id).sort().join(",")}`,
form.action = "/api/canvas/download-zip"; onProgress: task => toast.loading(`${task.stage}(${task.progress}%)`, { id: toastId }),
form.style.display = "none";
const payloadInput = document.createElement("input");
payloadInput.type = "hidden";
payloadInput.name = "payload";
payloadInput.value = JSON.stringify({
folderName,
files,
}); });
form.appendChild(payloadInput); if (result.failedCount) toast.warning(`已开始下载 ${result.successCount} 张图片,${result.failedCount} 张未能读取;ZIP 内附失败清单`, { id: toastId, duration: 10000 });
else toast.success(`ZIP 已准备完成,正在下载 ${result.successCount} 张图片`, { id: toastId });
document.body.appendChild(form); } catch (error) {
form.submit(); toast.error(error instanceof Error ? error.message : "打包下载失败,请稍后重试", { id: toastId });
form.remove();
toast.success("已开始打包下载,请留意浏览器下载栏");
} catch (_error) {
toast.error("下载失败,请稍后重试");
} finally { } finally {
window.setTimeout(() => {
setIsDownloadSubmitting(false); setIsDownloadSubmitting(false);
}, 800);
} }
}, [isDownloadSubmitting, selectedImageNodes]); }, [isDownloadSubmitting, selectedImageNodes]);
@ -2269,12 +2401,47 @@ export function CanvasWorkspace({
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp} onMouseLeave={handleMouseUp}
onDoubleClick={handleBlankCanvasDoubleClick} onDoubleClick={handleBlankCanvasDoubleClick}
onContextMenu={handleBlankCanvasContextMenu}
onWheel={handleWheel} onWheel={handleWheel}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
> >
{!isWorkflowCanvas && (
<>
<input
ref={contextImageInputRef} type="file" accept="image/*" multiple className="hidden"
aria-label="从右键菜单上传图片"
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
const point = getContextUploadClientPoint();
void processDroppedFiles(files, point.x, point.y);
}}
/>
<input
ref={contextVideoInputRef} type="file" accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm" multiple className="hidden"
aria-label="从右键菜单上传视频"
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
void uploadContextVideos(files);
}}
/>
<CanvasBlankContextMenu
position={blankContextPosition} isMac={isMacPlatform} videoUploading={isContextVideoUploading}
onClose={() => setBlankContextPosition(null)}
onUploadImage={() => contextImageInputRef.current?.click()}
onUploadVideo={() => contextVideoInputRef.current?.click()}
onPaste={() => { void pasteFromContextMenu(); }}
onZoomIn={() => zoomCanvasFromContextMenu(1.2)}
onZoomOut={() => zoomCanvasFromContextMenu(1 / 1.2)}
onFitAll={fitAllCanvasNodesIntoViewport}
onResetZoom={() => setViewport({ x: 0, y: 0, zoom: 1 })}
/>
</>
)}
{isWorkflowCanvas ? ( {isWorkflowCanvas ? (
<> <>
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" /> <div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
@ -2566,10 +2733,14 @@ export function CanvasWorkspace({
)} )}
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && ( {!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
<div <div
className="absolute bottom-16 left-4 z-30 flex items-center gap-2" className={cn(
"absolute left-4 z-30 flex items-center gap-2",
isWorkflowCanvas ? "bottom-16" : "bottom-[20px]"
)}
data-canvas-overlay data-canvas-overlay
data-canvas-selection-actions="true"
> >
{selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? ( {isWorkflowCanvas && selectedImageNodes.length === 1 ? (
<Button <Button
variant={isWorkflowCanvas ? "outline" : "secondary"} variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm" size="sm"
@ -2591,7 +2762,7 @@ export function CanvasWorkspace({
{selectedImageLocalEditItem.label} {selectedImageLocalEditItem.label}
</Button> </Button>
) : null} ) : null}
{selectedImageLayoutReplaceEnabled && {isWorkflowCanvas &&
selectedImageNodes.length >= 1 ? ( selectedImageNodes.length >= 1 ? (
<Button <Button
variant={isWorkflowCanvas ? "outline" : "secondary"} variant={isWorkflowCanvas ? "outline" : "secondary"}
@ -2620,27 +2791,48 @@ export function CanvasWorkspace({
{selectedImageLayoutReplaceItem.label} {selectedImageLayoutReplaceItem.label}
</Button> </Button>
) : null} ) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant={isWorkflowCanvas ? "outline" : "secondary"} variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm" size={isWorkflowCanvas ? "sm" : "icon"}
className={cn( className={cn(
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60", isWorkflowCanvas
isWorkflowCanvas && ? "h-8 gap-1.5 rounded-full border border-[#dbe2ee] bg-white px-3 text-xs text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]" : "h-[32px] w-[32px] rounded-[8px] border border-[#e9e9e9] bg-white p-0 text-[#333333] shadow-[0_1px_3px_rgba(0,0,0,0.035)] hover:bg-[#f5f5f5] dark:border-white/10 dark:bg-zinc-950 dark:text-white dark:hover:bg-white/10"
)} )}
onClick={handleDownloadSelectedImages} onClick={handleDownloadSelectedImages}
disabled={isDownloadSubmitting} disabled={isDownloadSubmitting}
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label} aria-label={
isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
}
> >
{!isWorkflowCanvas && isDownloadSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<CanvasToolbarIcon <CanvasToolbarIcon
className="h-3.5 w-3.5" className={isWorkflowCanvas ? "h-3.5 w-3.5" : "h-5 w-5"}
fallback={Download} fallback={Download}
referenceName={isWorkflowCanvas ? undefined : "image-download"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")} item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
/> />
)}
{isWorkflowCanvas
? isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
: null}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{isDownloadSubmitting {isDownloadSubmitting
? "打包中..." ? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`} : `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
</Button> </TooltipContent>
</Tooltip>
</div> </div>
)} )}

View File

@ -15,7 +15,9 @@ import { AudioGenerationComposer } from "./AudioGenerationComposer";
import { TVstarPanel, type TVstarGenerationModel, type TVstarGenerationSelection, type TVstarModel, type TVstarNodeContext, type TVstarReference, type TVstarShot } from "./tvstar/TVstarPanel"; import { TVstarPanel, type TVstarGenerationModel, type TVstarGenerationSelection, type TVstarModel, type TVstarNodeContext, type TVstarReference, type TVstarShot } from "./tvstar/TVstarPanel";
import { resolveTVstarGenerationReferences } from "./tvstar/generation-references"; import { resolveTVstarGenerationReferences } from "./tvstar/generation-references";
import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing"; import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing";
import { tvstarOutputRequirements } from "@/lib/video-canvas/tvstar-output";
import { CharacterStudioPanel, type StudioCharacter, type StudioView } from "./tvstar/CharacterStudioPanel"; import { CharacterStudioPanel, type StudioCharacter, type StudioView } from "./tvstar/CharacterStudioPanel";
import { tvstarShotComposition } from "@/lib/video-canvas/tvstar-shot-input";
import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice"; import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice";
import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story"; import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -102,6 +104,11 @@ import {
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ZCODE_VIDEO_CANVAS_AUTHORIZED } from "@/lib/ai/canvas-agent-runtime/authorization";
import { CanvasAgentUiListener } from "./chat-panel/canvas-agent-ui-listener";
import type { CanvasAgentUiCommand } from "@/lib/ai/canvas-agent-runtime/ui-command";
import { VideoCanvasAgentPanel } from "./chat-panel/video-canvas-agent-panel";
import { mergeVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-protocol";
import useSWR from "swr"; import useSWR from "swr";
import { import {
getGenerationCreditCostFromImageGenConfig, getGenerationCreditCostFromImageGenConfig,
@ -814,28 +821,28 @@ const STARTER_CARDS = [
title: "故事脚本生成", title: "故事脚本生成",
subtitle: "脚本到分镜", subtitle: "脚本到分镜",
coverUrl: coverUrl:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"], steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"],
}, },
{ {
title: "角色三视图", title: "角色三视图",
subtitle: "角色资产沉淀", subtitle: "角色资产沉淀",
coverUrl: coverUrl:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"], steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"],
}, },
{ {
title: "全能参考生视频", title: "全能参考生视频",
subtitle: "参考图到成片", subtitle: "参考图到成片",
coverUrl: coverUrl:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
steps: ["上传图片", "首帧图", "可灵 3.0", "预览"], steps: ["上传图片", "首帧图", "可灵 3.0", "预览"],
}, },
{ {
title: "音频生视频", title: "音频生视频",
subtitle: "音频驱动画面", subtitle: "音频驱动画面",
coverUrl: coverUrl:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
icon: "music", icon: "music",
steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"], steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"],
}, },
@ -858,7 +865,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "预设", badge: "预设",
category: "周星驰经典名场面", category: "周星驰经典名场面",
cover: cover:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
description: "左弧滑行,镜头从人物左侧弧线推进,带轻微喜剧节奏和夸张表情停顿。", description: "左弧滑行,镜头从人物左侧弧线推进,带轻微喜剧节奏和夸张表情停顿。",
duration: "5s", duration: "5s",
id: "left-arc-slide", id: "left-arc-slide",
@ -873,7 +880,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "商用", badge: "商用",
category: "电商展示", category: "电商展示",
cover: cover:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
description: "电商手机弹出效果,产品从画面中心弹出并旋转定格,适合主图视频开场。", description: "电商手机弹出效果,产品从画面中心弹出并旋转定格,适合主图视频开场。",
duration: "5s", duration: "5s",
id: "phone-pop-product", id: "phone-pop-product",
@ -888,7 +895,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "商用", badge: "商用",
category: "电商展示", category: "电商展示",
cover: cover:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
description: "咖啡杯出场,杯身从桌面滑入,蒸汽和柔光形成温暖的品牌氛围。", description: "咖啡杯出场,杯身从桌面滑入,蒸汽和柔光形成温暖的品牌氛围。",
duration: "5s", duration: "5s",
id: "coffee-cup-entry", id: "coffee-cup-entry",
@ -903,7 +910,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "热门", badge: "热门",
category: "镜头运动", category: "镜头运动",
cover: cover:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
description: "360旋转展示,围绕主体做一圈稳定环绕,适合产品、人物和空间展示。", description: "360旋转展示,围绕主体做一圈稳定环绕,适合产品、人物和空间展示。",
duration: "6s", duration: "6s",
id: "orbit-360-showcase", id: "orbit-360-showcase",
@ -917,7 +924,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
author: "工业镜头", author: "工业镜头",
category: "镜头运动", category: "镜头运动",
cover: cover:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-image.png",
description: "机械臂视角,以第一视角穿过机械臂与产品线,增强科技感和空间纵深。", description: "机械臂视角,以第一视角穿过机械臂与产品线,增强科技感和空间纵深。",
duration: "5s", duration: "5s",
id: "robot-arm-pov", id: "robot-arm-pov",
@ -932,7 +939,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "NEW", badge: "NEW",
category: "人物动画", category: "人物动画",
cover: cover:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-three-view.png",
description: "Live 2D,让单张角色图产生眨眼、微笑和轻微身体摆动,适合口播开场。", description: "Live 2D,让单张角色图产生眨眼、微笑和轻微身体摆动,适合口播开场。",
duration: "4s", duration: "4s",
id: "live-2d-character", id: "live-2d-character",
@ -946,7 +953,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
author: "电影运镜", author: "电影运镜",
category: "镜头运动", category: "镜头运动",
cover: cover:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png",
description: "瞳孔拉近,镜头快速推向眼部并在反光里展示下一场景,适合悬疑转场。", description: "瞳孔拉近,镜头快速推向眼部并在反光里展示下一场景,适合悬疑转场。",
duration: "5s", duration: "5s",
id: "pupil-zoom", id: "pupil-zoom",
@ -961,7 +968,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "热门", badge: "热门",
category: "人物动画", category: "人物动画",
cover: cover:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png",
description: "飞鸟解体,主体由细碎羽片散开再重组,适合视觉冲击强的短视频片头。", description: "飞鸟解体,主体由细碎羽片散开再重组,适合视觉冲击强的短视频片头。",
duration: "6s", duration: "6s",
id: "bird-dissolve", id: "bird-dissolve",
@ -976,7 +983,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "商用", badge: "商用",
category: "电商展示", category: "电商展示",
cover: cover:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png",
description: "商品震撼登场,产品带冲击波与局部高光进入画面,适合活动主推款。", description: "商品震撼登场,产品带冲击波与局部高光进入画面,适合活动主推款。",
duration: "5s", duration: "5s",
id: "product-hero-entry", id: "product-hero-entry",
@ -990,7 +997,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
author: "旅拍模板", author: "旅拍模板",
category: "镜头运动", category: "镜头运动",
cover: cover:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png", "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png",
description: "旅拍转场,前景遮挡切换到新地点,带自然手持感和轻微速度变化。", description: "旅拍转场,前景遮挡切换到新地点,带自然手持感和轻微速度变化。",
duration: "5s", duration: "5s",
id: "travel-whip-transition", id: "travel-whip-transition",
@ -1153,9 +1160,9 @@ const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [
]; ];
const TARGET_AVATAR = const TARGET_AVATAR =
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/target-avatar.jpg"; "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/target-avatar.jpg";
const PUBLISH_MODAL_BG_URL = const PUBLISH_MODAL_BG_URL =
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/publish-modal-bg.png"; "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/publish-modal-bg.png";
const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本"; const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本";
const AUDIO_TO_VIDEO_PROMPT = const AUDIO_TO_VIDEO_PROMPT =
"根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。"; "根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。";
@ -4566,6 +4573,8 @@ export function LiblibVideoCanvasReplica({
); );
const [nodes, setNodes] = useState<CloneNode[]>([]); const [nodes, setNodes] = useState<CloneNode[]>([]);
const activeGenerationSubmissions = useRef(new Set<string>()); const activeGenerationSubmissions = useRef(new Set<string>());
const [isVideoAgentOpen, setIsVideoAgentOpen] = useState(false);
const receivedAgentNodeIds = useRef(new Set<string>());
const [edges, setEdges] = useState<CloneEdge[]>([]); const [edges, setEdges] = useState<CloneEdge[]>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@ -4758,6 +4767,7 @@ export function LiblibVideoCanvasReplica({
setNodes(migrateLegacyGroups(restored)); setNodes(migrateLegacyGroups(restored));
setMultiSelection([]); setMultiSelection([]);
setGroupTemplates((project.canvasData as any)?.groupTemplates||[]); setGroupTemplates((project.canvasData as any)?.groupTemplates||[]);
receivedAgentNodeIds.current = new Set(restored.map((node) => node.id));
setEdges(restoreEdges(project.canvasData)); setEdges(restoreEdges(project.canvasData));
const storedViewport = data?.viewport; const storedViewport = data?.viewport;
setViewport(storedViewport && [storedViewport.x, storedViewport.y, storedViewport.zoom].every(Number.isFinite) setViewport(storedViewport && [storedViewport.x, storedViewport.y, storedViewport.zoom].every(Number.isFinite)
@ -4863,6 +4873,66 @@ export function LiblibVideoCanvasReplica({
applyMergedSnapshot, applyMergedSnapshot,
); );
const refreshVideoAgentResults = useCallback(async (projectId: string, resultNodeIds: string[]) => {
if (projectId !== canvasId) return;
const result = await canvasService.getProject(canvasId);
if (!result.success || !result.data) return;
const incoming = restoreNodes(result.data.canvasData).filter((node) => resultNodeIds.includes(node.id));
const unseen = incoming.filter((node) => !receivedAgentNodeIds.current.has(node.id));
for (const node of incoming) receivedAgentNodeIds.current.add(node.id);
setNodes((current) => mergeVideoAgentResults(current.map((node) => {
const remote = incoming.find((item) => item.id === node.id);
return remote && remote.draftValues?.temporaryVideoUrl === node.draftValues?.mediaUrl && remote.draftValues?.mediaUrl !== node.draftValues?.mediaUrl ? { ...node, draftValues: { ...node.draftValues, mediaUrl: remote.draftValues?.mediaUrl, url: remote.draftValues?.url } } : node;
}), unseen));
setEdges((current) => mergeVideoAgentResults(current, restoreEdges(result.data!.canvasData).filter((edge) => unseen.some((node) => node.id === edge.target))));
}, [canvasId]);
useEffect(() => {
const handler = async (event: Event) => {
const change = (event as CustomEvent<{ projectId: string; createdNodeIds?: string[]; updatedNodeIds?: string[]; deletedNodeIds?: string[]; action?: string; nodeIds?: string[]; viewportChanged?: boolean }>).detail;
if (change?.projectId !== canvasId) return;
const response = await canvasService.getProject(canvasId);
if (!response.success || !response.data) return;
const remote = restoreNodes(response.data.canvasData);
const changed = new Set([...(change.createdNodeIds || []), ...(change.updatedNodeIds || [])]);
const deleted = new Set(change.deletedNodeIds || []);
setNodes((current) => mergeVideoAgentResults(current.filter((node) => !deleted.has(node.id)).map((node) => changed.has(node.id) ? remote.find((item) => item.id === node.id) || node : node), remote.filter((node) => changed.has(node.id))));
setEdges((current) => {
const kept = current.filter((edge) => !deleted.has(edge.source) && !deleted.has(edge.target) && !(change.action === "disconnect" && edge.source === change.nodeIds?.[0] && edge.target === change.nodeIds?.[1]));
return change.action === "connect" ? mergeVideoAgentResults(kept, restoreEdges(response.data!.canvasData).filter((edge) => edge.source === change.nodeIds?.[0] && edge.target === change.nodeIds?.[1])) : kept;
});
if (change.viewportChanged && response.data.canvasData?.viewport) setViewport(response.data.canvasData.viewport);
};
window.addEventListener("video-canvas-agent-mutation", handler);
return () => window.removeEventListener("video-canvas-agent-mutation", handler);
}, [canvasId]);
const saveBeforeVideoAgent = useCallback(async () => {
const statusResponse = await fetch(`/api/canvas/agent-runs?${new URLSearchParams({ chatId: canvasId, projectId: canvasId })}`, { cache: "no-store" });
if (statusResponse.ok && (await statusResponse.json()).run?.status === "running") throw new Error("当前视频任务还在执行,请使用补充要求。");
const remote = await canvasService.getProject(canvasId);
if (!remote.success || !remote.data) throw new Error("无法读取视频画布,请稍后重试");
const remoteNodes = restoreNodes(remote.data.canvasData);
const unseen = remoteNodes.filter((node) => node.draftValues?.canvasAgentRunId && !receivedAgentNodeIds.current.has(node.id));
const syncedNodes = nodes.map((node) => {
const archived = remoteNodes.find((item) => item.id === node.id && item.draftValues?.temporaryVideoUrl === node.draftValues?.mediaUrl);
return archived ? { ...node, draftValues: { ...node.draftValues, mediaUrl: archived.draftValues?.mediaUrl, url: archived.draftValues?.url, temporaryVideoUrl: archived.draftValues?.temporaryVideoUrl } } : node;
});
const mergedNodes = mergeVideoAgentResults(syncedNodes, unseen);
const mergedEdges = mergeVideoAgentResults(edges, restoreEdges(remote.data.canvasData).filter((edge) => unseen.some((node) => node.id === edge.target)));
const response = await fetch("/api/canvas/video-agent", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: canvasId, baseRevision: remote.data.updatedAt, canvasData: {
nodes: serializeNodes(mergedNodes), edges: serializeEdges(mergedEdges),
selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], viewport, tool: "select",
} }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "画布保存失败,请稍后重试");
if (result.success) { setNodes(mergedNodes); setEdges(mergedEdges); for (const node of unseen) receivedAgentNodeIds.current.add(node.id); }
if (!result.success) throw new Error(result.error || "画布保存失败,请稍后重试");
}, [canvasId, nodes, edges, selectedNodeId, viewport]);
const commitTitle = useCallback(() => { const commitTitle = useCallback(() => {
const nextTitle = draftTitle.trim() || "未命名"; const nextTitle = draftTitle.trim() || "未命名";
setProjectTitle(nextTitle); setProjectTitle(nextTitle);
@ -5550,7 +5620,7 @@ export function LiblibVideoCanvasReplica({
const draft = (d.smartEditDraft || {}) as any, plan = draft.plans?.[draft.selectedPlanIndex || 0] || d.editPlan; const draft = (d.smartEditDraft || {}) as any, plan = draft.plans?.[draft.selectedPlanIndex || 0] || d.editPlan;
const changes = sourceEdges.some(e => e.target === node.id && groupMembers(sourceNodes, groupId).some(m => m.id === e.source && (/GENERATE|UPSCALE/.test(String(m.draftValues?.action)) || m.draftValues?.generationTaskId))); const changes = sourceEdges.some(e => e.target === node.id && groupMembers(sourceNodes, groupId).some(m => m.id === e.source && (/GENERATE|UPSCALE/.test(String(m.draftValues?.action)) || m.draftValues?.generationTaskId)));
if (plan && !changes) return { kind: "smart-edit", params: { operation: "render", plan } }; if (plan && !changes) return { kind: "smart-edit", params: { operation: "render", plan } };
return { kind: "smart-edit", params: { operation: "plan", autoRender: true, mode: draft.mode || "sequence", targetDuration: draft.targetDuration || 300, shotDurations: draft.shotDurations, shotDescriptions: draft.shotDescriptions, ratio: draft.ratio || "16:9", resolution: draft.resolution || "720p", semantic: draft.semantic ?? false, preferredModelRef: draft.preferredModelRef, prompt: draft.prompt || "", removeSilence: draft.removeSilence || false, transcribe: draft.transcribe || false, music: draft.music, beatSync: draft.beatSync || false, variants: 1 } }; return { kind: "smart-edit", params: { operation: "plan", autoRender: true, mode: draft.mode || "sequence", targetDuration: draft.targetDuration || 300, shotDurations: draft.shotDurations, shotDescriptions: draft.shotDescriptions, ratio: draft.ratio || "16:9", resolution: draft.resolution || "720p", semantic: draft.semantic ?? false, muteAudio: draft.muteAudio === true, preferredModelRef: draft.preferredModelRef, prompt: draft.prompt || "", removeSilence: draft.removeSilence || false, transcribe: draft.transcribe || false, music: draft.music, beatSync: draft.beatSync || false, variants: 1 } };
} }
const key = String(p.functionKey || d.functionKey || (node.inputType === "video" ? "text-to-video" : node.inputType === "image" ? "" : "story-script")); const key = String(p.functionKey || d.functionKey || (node.inputType === "video" ? "text-to-video" : node.inputType === "image" ? "" : "story-script"));
const definition = key ? getConfiguredFunction(videoCanvasFunctionConfig, key) : { key: "image-generate", kind: "image-edit" as const, route: "canvas-generate-image" as const, enabled: true, label: "图片生成", prompt: "", description: "" }; const definition = key ? getConfiguredFunction(videoCanvasFunctionConfig, key) : { key: "image-generate", kind: "image-edit" as const, route: "canvas-generate-image" as const, enabled: true, label: "图片生成", prompt: "", description: "" };
@ -5568,6 +5638,10 @@ export function LiblibVideoCanvasReplica({
const applyTVstarShots = useCallback(async (shots: TVstarShot[], goal: string, references: TVstarReference[] = [], selection: TVstarGenerationSelection = {}, runMode: "submit" | "prepare" = "submit") => { const applyTVstarShots = useCallback(async (shots: TVstarShot[], goal: string, references: TVstarReference[] = [], selection: TVstarGenerationSelection = {}, runMode: "submit" | "prepare" = "submit") => {
if (!shots.length) return false; if (!shots.length) return false;
let output: ReturnType<typeof tvstarOutputRequirements>;
let timing: ReturnType<typeof tvstarTiming>;
try { output = tvstarOutputRequirements(goal); timing = tvstarTiming(goal, shots); }
catch (error) { toast.error(error instanceof Error ? error.message : "成片要求无效"); return false; }
const videoDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "first-frame-video"); const videoDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "first-frame-video");
if (runMode === "submit" && (!videoCanvasFunctionConfig.enabled || !videoDefinition.enabled)) { if (runMode === "submit" && (!videoCanvasFunctionConfig.enabled || !videoDefinition.enabled)) {
toast.error("首帧生视频功能尚未启用,无法创建可执行分镜"); toast.error("首帧生视频功能尚未启用,无法创建可执行分镜");
@ -5583,8 +5657,9 @@ export function LiblibVideoCanvasReplica({
if (selection.videoModelRef && !filterCanvasModels(availableModels, "video", "first-frame") if (selection.videoModelRef && !filterCanvasModels(availableModels, "video", "first-frame")
.some(model => model.id === selection.videoModelRef || model.modelId === selection.videoModelRef)) .some(model => model.id === selection.videoModelRef || model.modelId === selection.videoModelRef))
throw new Error("所选视频模型不支持首帧生视频,请换一个模型"); throw new Error("所选视频模型不支持首帧生视频,请换一个模型");
videoSettings = shots.map(shot => chooseStoryVideoSettings( videoSettings = shots.map((_shot, index) => chooseStoryVideoSettings(
availableModels, true, Number.parseFloat(shot.duration) || 5, preferredModel, availableModels, true, timing.shotDurations[index], preferredModel,
{ ...output, minimumDuration: true, minimumResolution: true, strictModel: Boolean(selection.videoModelRef) },
)); ));
} catch (error) { } catch (error) {
if (runMode === "submit") { if (runMode === "submit") {
@ -5594,7 +5669,7 @@ export function LiblibVideoCanvasReplica({
videoSettings = shots.map(shot => ({ videoSettings = shots.map(shot => ({
mode: "first-frame" as const, model: selection.videoModelRef || "", mode: "first-frame" as const, model: selection.videoModelRef || "",
duration: Math.max(1, Math.min(10, Math.ceil(Number.parseFloat(shot.duration) || 5))), duration: Math.max(1, Math.min(10, Math.ceil(Number.parseFloat(shot.duration) || 5))),
ratio: "16:9", resolution: "720p", generateAudio: false, ratio: output.ratio, resolution: output.resolution, generateAudio: output.generateAudio ?? false,
})); }));
} }
let referenceImages: string[]; let referenceImages: string[];
@ -5624,31 +5699,35 @@ export function LiblibVideoCanvasReplica({
x, y, x, y,
}); });
script.draftValues = { ...script.draftValues, tvstarShots: shots, tvstarReferences: references, source: "tvstar" }; script.draftValues = { ...script.draftValues, tvstarShots: shots, tvstarReferences: references, source: "tvstar" };
const timing = tvstarTiming(goal, shots);
const nextNodes: CloneNode[] = [script]; const nextNodes: CloneNode[] = [script];
const nextEdges: CloneEdge[] = []; const nextEdges: CloneEdge[] = [];
const videoNodeIds: string[] = []; const videoNodeIds: string[] = [];
shots.forEach((shot, index) => { shots.forEach((shot, index) => {
const settings = videoSettings[index]; const settings = videoSettings[index];
const rowY = y + index * 450; const rowY = y + index * 450;
const imagePrompt = shot.imagePrompt || shot.sceneDescription; const imagePrompt = [`仅生成镜头 ${shot.shot} 的一张电影关键帧。`, shot.imagePrompt || shot.sceneDescription, tvstarShotComposition(goal)].join("\n");
const image = createTargetModuleNode("image", x + 560, rowY, nodes.length + index * 2 + 1); const image = createTargetModuleNode("image", x + 560, rowY, nodes.length + index * 2 + 1);
image.title = `镜头 ${shot.shot} · 关键帧`; image.title = `镜头 ${shot.shot} · 关键帧`;
image.draftValues = { image.draftValues = {
...image.draftValues, ...image.draftValues,
prompt: imagePrompt, prompt: imagePrompt,
params: { prompt: imagePrompt, aspectRatio: "16:9", referenceImages, ...(imageModelRef ? { model: imageModelRef } : {}) }, params: { prompt: imagePrompt, aspectRatio: output.ratio, referenceImages, ...(imageModelRef ? { model: imageModelRef } : {}) },
tvstarShot: shot.shot, tvstarShot: shot.shot,
source: "tvstar", source: "tvstar",
}; };
const video = createTargetModuleNode("video", x + 1020, rowY, nodes.length + index * 2 + 2); const video = createTargetModuleNode("video", x + 1020, rowY, nodes.length + index * 2 + 2);
video.title = `镜头 ${shot.shot} · 视频`; video.title = `镜头 ${shot.shot} · 视频`;
const videoPrompt = [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement,
...(settings.generateAudio ? [shot.dialogue, shot.backgroundMusic, shot.voiceAndSfx] : []),
`整片背景要求(当前只拍镜头 ${shot.shot},不执行其他镜头):${goal}`,
tvstarShotComposition(goal), "保持当前镜头连续运动,不生成片中片,不切换到其他分镜。",
].filter(Boolean).join("。");
video.draftValues = { video.draftValues = {
...video.draftValues, ...video.draftValues,
prompt: [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。"), prompt: videoPrompt,
params: { params: {
mode: "first-frame", mode: "first-frame",
prompt: [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。"), prompt: videoPrompt,
model: settings.model, model: settings.model,
duration: settings.duration, duration: settings.duration,
ratio: settings.ratio, ratio: settings.ratio,
@ -5672,8 +5751,9 @@ export function LiblibVideoCanvasReplica({
source: "tvstar", source: "tvstar",
smartEditDraft: { smartEditDraft: {
mode: "sequence", mode: "sequence",
ratio: "16:9", ratio: output.ratio,
resolution: "720p", resolution: output.resolution,
muteAudio: output.generateAudio === false,
targetDuration: timing.targetDuration, targetDuration: timing.targetDuration,
shotDurations: timing.shotDurations, shotDurations: timing.shotDurations,
shotDescriptions: shots.map(shot => [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。").slice(0, 600)), shotDescriptions: shots.map(shot => [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。").slice(0, 600)),
@ -5727,6 +5807,9 @@ export function LiblibVideoCanvasReplica({
const applyTVstarEdit = useCallback(async (references: TVstarReference[], goal: string) => { const applyTVstarEdit = useCallback(async (references: TVstarReference[], goal: string) => {
const videos = references.filter(item => item.type === "video").slice(0, 12); const videos = references.filter(item => item.type === "video").slice(0, 12);
if (!videos.length) { toast.error("请先引用至少一个视频片段"); return false; } if (!videos.length) { toast.error("请先引用至少一个视频片段"); return false; }
let output: ReturnType<typeof tvstarOutputRequirements>;
try { output = tvstarOutputRequirements(goal); }
catch (error) { toast.error(error instanceof Error ? error.message : "成片要求无效"); return false; }
const timing = tvstarTiming(goal, videos.map(item => ({ duration: `${Math.min(item.duration || 5, 5)}s` }))); const timing = tvstarTiming(goal, videos.map(item => ({ duration: `${Math.min(item.duration || 5, 5)}s` })));
const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
const x = Math.round(center.x - 380); const x = Math.round(center.x - 380);
@ -5747,7 +5830,7 @@ export function LiblibVideoCanvasReplica({
const result = createTargetModuleNode("smart-edit", x + 1140, y + 90, nodes.length + videos.length + 1); const result = createTargetModuleNode("smart-edit", x + 1140, y + 90, nodes.length + videos.length + 1);
result.title = `TVstar · ${timing.targetDuration} 秒智能成片`; result.title = `TVstar · ${timing.targetDuration} 秒智能成片`;
result.draftValues = { ...result.draftValues, source: "tvstar", smartEditDraft: { result.draftValues = { ...result.draftValues, source: "tvstar", smartEditDraft: {
mode: "sequence", ratio: "16:9", resolution: "720p", mode: "sequence", ratio: output.ratio, resolution: output.resolution, muteAudio: output.generateAudio === false,
targetDuration: timing.targetDuration, shotDurations: timing.shotDurations, targetDuration: timing.targetDuration, shotDurations: timing.shotDurations,
shotDescriptions: videos.map(item => `从《${item.title}》选取符合用户要求的真实高光画面:${goal}`.slice(0, 600)), shotDescriptions: videos.map(item => `从《${item.title}》选取符合用户要求的真实高光画面:${goal}`.slice(0, 600)),
semantic: true, preferredModelRef: videoCanvasFunctionConfig.defaultMultimodalModel || undefined, semantic: true, preferredModelRef: videoCanvasFunctionConfig.defaultMultimodalModel || undefined,
@ -5779,9 +5862,18 @@ export function LiblibVideoCanvasReplica({
const applyCharacterStudio = useCallback(async (character: StudioCharacter, style: string, views: StudioView[], referenceUrl?: string): Promise<boolean> => { const applyCharacterStudio = useCallback(async (character: StudioCharacter, style: string, views: StudioView[], referenceUrl?: string): Promise<boolean> => {
if (!views.length) return false; if (!views.length) return false;
const imageCandidates = filterCanvasModels(canvasModelOptions, referenceUrl ? "image-edit" : "image"); const requiresIdentityReference = Boolean(referenceUrl) || views.length > 1;
if (referenceUrl && !imageCandidates.length) { // Recover a missed initial catalog load before deciding that generation is unavailable.
toast.error("没有支持参考图的图片模型,无法按角色参考图出图"); let availableModels = canvasModelOptions;
if (!availableModels.length) {
const response = await fetch("/api/models", { cache: "no-store" });
if (!response.ok) throw Error("角色出图模型暂时无法读取,请稍后重试");
const catalog = await response.json();
availableModels = Array.isArray(catalog.models) ? catalog.models : [];
}
const imageCandidates = filterCanvasModels(availableModels, requiresIdentityReference ? "image-edit" : "image");
if (!imageCandidates.length) {
toast.error(requiresIdentityReference ? "没有支持参考图的图片模型,无法保持多视图角色一致性" : "没有可执行的图片模型");
return false; return false;
} }
const imageModelRef = imageCandidates.find(model => model.id === videoCanvasFunctionConfig.defaultImageModel const imageModelRef = imageCandidates.find(model => model.id === videoCanvasFunctionConfig.defaultImageModel
@ -5789,31 +5881,44 @@ export function LiblibVideoCanvasReplica({
const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
const x = Math.round(center.x - 360); const x = Math.round(center.x - 360);
const y = Math.round(center.y - 160); const y = Math.round(center.y - 160);
const identity = [character.name, character.description, character.look, character.wardrobe, character.prompt, `风格:${style}`].filter(Boolean).join("。 "); // Appearance is shared; generated prompt framing must not override each view's composition.
const identity = [character.name, character.description, character.look, character.wardrobe,
...(character.look || character.wardrobe ? [] : [character.prompt]), `风格:${style}`].filter(Boolean).join("。 ");
const source = createTextResourceNode({ content: identity, title: `${character.name} · 角色设定`, x, y }); const source = createTextResourceNode({ content: identity, title: `${character.name} · 角色设定`, x, y });
source.draftValues = { ...source.draftValues, source: "character-studio", characterIdentity: identity }; source.draftValues = { ...source.draftValues, source: "character-studio", characterIdentity: identity };
const viewPrompts: Record<StudioView, string> = { const viewPrompts: Record<StudioView, string> = {
"角色立绘": "全身角色立绘,站姿自然,人物完整入画,纯净背景,清晰展示稳定面部特征、服装和道具。", "角色立绘": "全身角色立绘,站姿自然,人物完整入画,纯净背景,清晰展示稳定面部特征、服装和道具。",
"脸部近景": "同一角色脸部近景,保持五官、发型与标志性特征完全一致,柔和电影布光。", "脸部近景": "只拍同一角色脸部近景,脸部占据画幅主体,最多露出肩部,不出现全身或半身立绘;保持五官、发型与标志性特征完全一致,柔和电影布光。",
"表情参考": "同一角色表情设定图,四宫格分别展示平静、喜悦、惊讶、愤怒,五官与服装细节一致。", "表情参考": "同一角色表情设定图,四宫格分别展示平静、喜悦、惊讶、愤怒,五官与服装细节一致。",
"角色三视图": "同一角色正面、侧面、背面三视图并排,站姿和服装统一,清晰标注轮廓,纯净背景。", "角色三视图": "同一角色正面、侧面、背面三视图并排,站姿和服装统一,清晰标注轮廓,纯净背景。",
}; };
const nextNodes: CloneNode[] = [source]; const nextNodes: CloneNode[] = [source];
const nextEdges: CloneEdge[] = []; const nextEdges: CloneEdge[] = [];
views.forEach((view, index) => { // A single generated identity anchor feeds every other view. Text alone
const prompt = `${identity}\n${viewPrompts[view]} 不改变角色身份,不增加多余人物。`; // does not constrain an image model to reproduce the same face.
const orderedViews: StudioView[] = views.includes("角色立绘")
? ["角色立绘", ...views.filter(view => view !== "角色立绘")]
: views;
let identityAnchorId: string | undefined;
orderedViews.forEach((view, index) => {
const singleView = view === "角色立绘" || view === "脸部近景"
? "只生成一张单一完整构图,禁止拼贴、画中画、分屏、宫格和附加小窗;参考图只用于人物身份,不复用其构图。" : "";
const prompt = `${identity}\n${viewPrompts[view]} 不改变角色身份,不增加多余人物。${singleView}`;
const node = createTargetModuleNode("image", x + 500 + index * 440, y, nodes.length + index + 1); const node = createTargetModuleNode("image", x + 500 + index * 440, y, nodes.length + index + 1);
node.title = `${character.name} · ${view}`; node.title = `${character.name} · ${view}`;
node.draftValues = { node.draftValues = {
...node.draftValues, ...node.draftValues,
prompt, prompt,
params: { prompt, aspectRatio: view === "角色三视图" || view === "表情参考" ? "16:9" : "3:4", ...(referenceUrl ? { referenceImages: [referenceUrl] } : {}), ...(imageModelRef ? { model: imageModelRef } : {}) }, params: { prompt, aspectRatio: view === "角色三视图" || view === "表情参考" ? "16:9" : "3:4", ...(referenceUrl && index === 0 ? { referenceImages: [referenceUrl] } : {}), ...(imageModelRef ? { model: imageModelRef } : {}) },
characterIdentity: identity, characterIdentity: identity,
characterView: view, characterView: view,
characterAnchorId: identityAnchorId,
source: "character-studio", source: "character-studio",
}; };
nextNodes.push(node); nextNodes.push(node);
nextEdges.push({ id: makeId("edge"), source: source.id, target: node.id }); nextEdges.push({ id: makeId("edge"), source: source.id, target: node.id });
if (identityAnchorId) nextEdges.push({ id: makeId("edge"), source: identityAnchorId, target: node.id });
else identityAnchorId = node.id;
}); });
const groupId = makeId("character-group"); const groupId = makeId("character-group");
const groupNodes = makeCanvasGroup(nextNodes, nextNodes.map(node => node.id), groupId, `${character.name} · 角色造型`); const groupNodes = makeCanvasGroup(nextNodes, nextNodes.map(node => node.id), groupId, `${character.name} · 角色造型`);
@ -10861,6 +10966,50 @@ export function LiblibVideoCanvasReplica({
}; };
}, [lineConnectState, nodes]); }, [lineConnectState, nodes]);
const handleAgentVideoUiCommand = async (command: CanvasAgentUiCommand) => {
const target = nodes.find((node) => node.id === (command.nodeIds[0] || selectedNodeId));
if (command.nodeIds.some((id) => !nodes.some((node) => node.id === id))) throw new Error("目标节点不属于当前视频画布");
if (target) { setSelectedNodeId(target.id); setSelectedEdgeId(null); }
if (command.featureId.startsWith("video.function.")) {
const key = command.featureId.slice("video.function.".length);
const tool = configuredTools.find((item) => item.toolKey === key);
if (!tool) throw new Error("此视频功能已关闭或尚未配置");
if ((key === "grid-split" || key === "image-crop") && target) { await runSelectedImageToolAction(target.id, "crop"); return; }
if (key === "angle-edit" && target) { setAngleEditorNodeId(target.id); return; }
const created = createToolNode(tool, target ? target.x + target.width + 72 : 360, target?.y || 300);
addNodes([created], command.nodeIds.map((id) => ({ id: makeId("edge"), source: id, target: created.id })));
setInspectorNodeId(created.id);
return;
}
const action = command.featureId.replace("video.ui.", "");
const panels: Record<string, Exclude<ActivePanel, null>> = { assets: "assets", roles: "roleLibrary", styles: "styleLibrary", effects: "effectLibrary", history: "history", workflows: "workflow", share: "share", publish: "publish", skills: "skills", help: "help" };
if (panels[action]) { setActivePanel(panels[action]); return; }
if (action.startsWith("upload-")) { addTargetModule(action === "upload-video" ? "video" : action === "upload-audio" ? "audio" : "image"); toast.message("已添加上传模块,请选择本机文件"); return; }
if (action === "save") { if (!(await persistence.saveAndConfirm())) throw new Error("画布保存尚未确认,请稍后重试"); return; }
if (action === "export") {
const url = URL.createObjectURL(new Blob([JSON.stringify({ title: projectTitle, workspaceType: "video", canvasData: { nodes: serializeNodes(nodes), edges: serializeEdges(edges), viewport } }, null, 2)], { type: "application/json" }));
const link = document.createElement("a"); link.href = url; link.download = `${projectTitle}.json`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); return;
}
if (action === "import") {
const picker = document.createElement("input"); picker.type = "file"; picker.accept = ".json,application/json";
picker.onchange = async () => { try { const file = picker.files?.[0]; if (!file) return; const data = JSON.parse(await file.text()); const imported = restoreNodes(data.canvasData || data); if (!imported.length) throw new Error("文件中没有可用的视频画布节点"); if (!window.confirm("确认将文件里的节点添加到当前画布?")) return; const ids = new Map(imported.map((node) => [node.id, makeId("import")])); addNodes(imported.map((node) => ({ ...node, id: ids.get(node.id)! })), restoreEdges(data.canvasData || data).filter((edge) => ids.has(edge.source) && ids.has(edge.target)).map((edge) => ({ id: makeId("edge"), source: ids.get(edge.source)!, target: ids.get(edge.target)! }))); } catch (error) { toast.error(error instanceof Error ? error.message : "导入失败"); } }; picker.click(); return;
}
if (!target) throw new Error("请先选中具体素材节点");
if (action === "download") { downloadMediaNode(target); return; }
if (action === "preview") { setPreviewNode(target); return; }
if (action === "local-edit") { await runSelectedImageToolAction(target.id, "localEdit"); return; }
if (action === "annotate") { await runSelectedImageToolAction(target.id, "annotate"); return; }
if (action === "director") { setDirectorConsoleNodeId(target.id); return; }
if (action === "storyboard") { openStoryScriptWorkflow(target.id); return; }
if (action === "batch-storyboard" || action === "batch-video") { openStoryScriptWorkflow(target.id); toast.message("请检查分镜及费用后在工作台提交批量任务"); return; }
if (action === "video-hd") { setInspectorNodeId(target.id); toast.message("当前视频 AI 高清尚缺真实后端,不能将原视频冒充高清结果"); return; }
if (action === "video-clip") { setActiveVideoToolbarMode("clip"); return; }
if (action === "video-crop") { setActiveVideoToolbarMode("crop"); toast.message("当前裁剪导出后端尚未接入,不能当作已完成的视频裁剪"); return; }
if (action === "subtitle-erase") { setActiveVideoToolbarMode("subtitleErase"); toast.message("当前去字幕尚未接入真实处理后端"); return; }
if (["audio-split", "voice-split", "background-split", "frame-first", "frame-last", "frame-current"].includes(action)) { setPreviewNode(target); toast.message("已授权入口;当前页面此功能仍为模拟流程,Agent 不会生成占位素材或伪报完成"); return; }
throw new Error("此入口需要在相应的视频工作区打开");
};
return ( return (
<div <div
className="video-canvas-workspace fixed inset-0 h-dvh w-full overflow-clip bg-[#151515] text-white" className="video-canvas-workspace fixed inset-0 h-dvh w-full overflow-clip bg-[#151515] text-white"
@ -10903,6 +11052,7 @@ export function LiblibVideoCanvasReplica({
void addUploadedFilesAtPoint(files, toCanvasPoint(event.clientX, event.clientY)); void addUploadedFilesAtPoint(files, toCanvasPoint(event.clientX, event.clientY));
}} }}
> >
<CanvasAgentUiListener projectId={canvasId} workspace="video" onVideoAction={handleAgentVideoUiCommand} />
<div className="pointer-events-none absolute inset-0" style={{backgroundImage:"radial-gradient(circle,rgba(255,255,255,0.14) 0.7px,transparent 0.7px)",backgroundPosition:"0.5px 0.5px",backgroundSize:"16px 16px"}} /> <div className="pointer-events-none absolute inset-0" style={{backgroundImage:"radial-gradient(circle,rgba(255,255,255,0.14) 0.7px,transparent 0.7px)",backgroundPosition:"0.5px 0.5px",backgroundSize:"16px 16px"}} />
{fileDragActive && ( {fileDragActive && (
<div className="pointer-events-none absolute inset-3 z-[1800] flex items-center justify-center rounded-2xl border-2 border-dashed border-cyan-300 bg-cyan-300/5" role="status" aria-live="polite"> <div className="pointer-events-none absolute inset-3 z-[1800] flex items-center justify-center rounded-2xl border-2 border-dashed border-cyan-300 bg-cyan-300/5" role="status" aria-live="polite">
@ -11791,6 +11941,29 @@ export function LiblibVideoCanvasReplica({
onResolve={persistence.resolveConflict} onResolve={persistence.resolveConflict}
onDownload={persistence.downloadDraft} onDownload={persistence.downloadDraft}
/> />
{ZCODE_VIDEO_CANVAS_AUTHORIZED && (isVideoAgentOpen ? (
<VideoCanvasAgentPanel
key={canvasId}
projectId={canvasId}
selectedNodeIds={selectedNodeId ? [selectedNodeId] : []}
videoModels={canvasModelOptions.filter((model) => isVideoModelDescriptor(model))}
imageModels={canvasModelOptions.filter((model) => isImageGenerationModelDescriptor(model))}
beforeSend={saveBeforeVideoAgent}
onResults={(projectId, ids) => void refreshVideoAgentResults(projectId, ids)}
onClose={() => setIsVideoAgentOpen(false)}
/>
) : (
<button
type="button"
data-canvas-overlay="true"
onClick={() => { setInspectorNodeId(null); setIsVideoAgentOpen(true); }}
className="absolute right-4 top-[76px] z-40 flex items-center gap-2 rounded-xl border border-white/10 bg-[#252525] px-4 py-3 text-sm text-white shadow-lg hover:bg-[#303030]"
aria-label="打开视频创作 Agent"
>
<FileText className="h-4 w-4" />视频 Agent
</button>
))}
<TopBar <TopBar
canvasId={canvasId} canvasId={canvasId}
canvasViewMode={canvasViewMode} canvasViewMode={canvasViewMode}
@ -21661,8 +21834,8 @@ const STYLE_MARKET_CATEGORIES = [
"全部", "全部",
]; ];
const STYLE_MARKET_THUMBNAIL_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png"; const STYLE_MARKET_THUMBNAIL_URL = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png";
const EFFECT_MARKET_THUMBNAIL_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png"; const EFFECT_MARKET_THUMBNAIL_URL = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png";
const STYLE_MARKET_CARDS: AssetMarketCard[] = [ const STYLE_MARKET_CARDS: AssetMarketCard[] = [
["复古马卡龙", "算力潮汐", 177], ["复古马卡龙", "算力潮汐", 177],

View File

@ -0,0 +1,452 @@
"use client";
import { ChevronRight, X } from "lucide-react";
import {
Children,
cloneElement,
forwardRef,
isValidElement,
useState,
type HTMLAttributes,
type MouseEvent,
type ReactElement,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
saveImageToolbarPreferences,
useImageToolbarPreferences,
type ImageToolbarPreferences,
} from "@/lib/canvas-image-toolbar-preferences";
type ToolProps = HTMLAttributes<HTMLButtonElement> & {
"data-tool-key"?: string;
disabled?: boolean;
children?: ReactNode;
};
type Tool = ReactElement<ToolProps>;
type Props = HTMLAttributes<HTMLDivElement> & {
referencePresentation: boolean;
defaultPinnedKeys: string[];
};
function toolLabel(tool: Tool) {
return tool.props.title || "";
}
function toolIcon(tool: Tool, size: number) {
const icon = Children.toArray(tool.props.children).find(isValidElement);
if (
!isValidElement<{
className?: string;
style?: object;
referenceSize?: number;
}>(icon)
)
return null;
return cloneElement(icon, {
className: "shrink-0",
style: { width: size, height: size },
...(icon.type === CanvasToolbarIcon ? { referenceSize: size } : {}),
});
}
function toolCredit(tool: Tool) {
return Children.toArray(tool.props.children).find(
(child) =>
isValidElement<{ compact?: boolean; label?: string }>(child) &&
child.props.compact &&
child.props.label
) as
| ReactElement<{ compact?: boolean; label?: string; className?: string }>
| undefined;
}
function tooltipButton(tool: Tool, showNames: boolean) {
const name = toolLabel(tool);
// Clone the existing action so its handler, busy state and administrator icon override remain intact.
const button = cloneElement(tool, {
title: undefined,
"aria-label": name,
children: (
<>
{toolIcon(tool, 25)}
{showNames && <span className="lovart-image-tool-label">{name}</span>}
</>
),
});
return (
<Tooltip key={tool.props["data-tool-key"] || name}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
className="lovart-image-tool-tooltip"
side="top"
sideOffset={8}
>
{name}
{toolCredit(tool) &&
cloneElement(toolCredit(tool)!, {
compact: false,
className: "lovart-image-tool-cost",
})}
</TooltipContent>
</Tooltip>
);
}
export const ReferenceImageToolbar = forwardRef<HTMLDivElement, Props>(
function ReferenceImageToolbar(
{ referencePresentation, defaultPinnedKeys, children, ...rootProps },
ref
) {
const saved = useImageToolbarPreferences();
const [draft, setDraft] = useState<ImageToolbarPreferences | null>(null);
const [draggedKey, setDraggedKey] = useState<string | null>(null);
const elements = Children.toArray(children).filter(
isValidElement
) as ReactElement<ToolProps>[];
const tools = elements.filter(
(element) =>
element.props["data-tool-key"] &&
!["addToChat", "download"].includes(element.props["data-tool-key"]!)
);
const toolMap = new Map(
tools.map((tool) => [tool.props["data-tool-key"]!, tool])
);
const preferences = saved || {
pinnedKeys: defaultPinnedKeys,
showNames: false,
};
const pinned = preferences.pinnedKeys.filter((key) => toolMap.has(key));
const moreTools = tools.filter(
(tool) => !pinned.includes(tool.props["data-tool-key"]!)
);
const addToChat = elements.find(
(element) => element.props["data-tool-key"] === "addToChat"
);
const download = elements.find(
(element) => element.props["data-tool-key"] === "download"
);
const originalMenu = elements.find(
(element) => element.type === DropdownMenu
);
const menuChildren = originalMenu
? (Children.toArray(originalMenu.props.children).filter(
isValidElement
) as ReactElement<{ children?: ReactNode }>[])
: [];
const menuTrigger = menuChildren.find(
(element) => element.type === DropdownMenuTrigger
);
const moreButton =
menuTrigger &&
(Children.toArray(menuTrigger.props.children).find(isValidElement) as
| Tool
| undefined);
const extras = menuChildren.find(
(element) => element.type === DropdownMenuContent
)?.props.children;
const openCustomizer = () =>
setDraft({
pinnedKeys: [...preferences.pinnedKeys],
showNames: preferences.showNames,
});
const toggle = (key: string) =>
setDraft((current) =>
current
? {
...current,
pinnedKeys: current.pinnedKeys.includes(key)
? current.pinnedKeys.filter((item) => item !== key)
: [...current.pinnedKeys, key],
}
: current
);
const move = (key: string, target: string) =>
setDraft((current) => {
if (!current || key === target) return current;
const next = [...current.pinnedKeys];
const from = next.indexOf(key),
to = next.indexOf(target);
if (from < 0 || to < 0) return current;
next.splice(from, 1);
next.splice(to, 0, key);
return { ...current, pinnedKeys: next };
});
if (!referencePresentation)
return (
<div {...rootProps} ref={ref}>
{children}
</div>
);
return (
<>
<div
{...rootProps}
ref={ref}
style={{ ...rootProps.style, overflowX: "auto" }}
data-show-tool-names={preferences.showNames ? "true" : "false"}
>
{addToChat && tooltipButton(addToChat, preferences.showNames)}
{addToChat && <div className="w-px" />}
{pinned.map((key) =>
tooltipButton(toolMap.get(key)!, preferences.showNames)
)}
<DropdownMenu>
{moreButton && (
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
{cloneElement(moreButton, {
title: undefined,
"aria-label": toolLabel(moreButton),
children: toolIcon(moreButton, 25),
})}
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent
className="lovart-image-tool-tooltip"
sideOffset={8}
>
{toolLabel(moreButton)}
</TooltipContent>
</Tooltip>
)}
<DropdownMenuContent
className="lovart-image-action-menu lovart-image-custom-more"
side="bottom"
align="end"
>
{moreTools.map((tool) => (
<DropdownMenuItem
key={tool.props["data-tool-key"]}
disabled={tool.props.disabled}
onClick={(event) =>
tool.props.onClick?.(
event as unknown as MouseEvent<HTMLButtonElement>
)
}
>
{toolIcon(tool, 18)}
<span>{toolLabel(tool)}</span>
{toolCredit(tool) &&
cloneElement(toolCredit(tool)!, {
compact: false,
className: "lovart-image-tool-cost",
})}
</DropdownMenuItem>
))}
{extras}
<DropdownMenuSeparator />
<DropdownMenuItem
className="lovart-customize-entry"
onSelect={openCustomizer}
>
自定义工具栏
<ChevronRight size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{download && (
<>
<div className="w-px" />
{tooltipButton(download, preferences.showNames)}
</>
)}
</div>
<Dialog
open={Boolean(draft)}
onOpenChange={(open) => {
if (!open) setDraft(null);
}}
>
<DialogContent
hideCloseButton
className="lovart-toolbar-customizer"
overlayClassName="lovart-toolbar-customizer-overlay"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<header className="lovart-customizer-header">
<div>
<DialogTitle>自定义工具栏</DialogTitle>
<DialogDescription>
选择你想在编辑栏中使用的工具
</DialogDescription>
</div>
<div className="lovart-customizer-header-actions">
<button
type="button"
aria-label="恢复默认工具栏"
onClick={() =>
setDraft({
pinnedKeys: [...defaultPinnedKeys],
showNames: false,
})
}
>
<ReferenceIcon name="toolbar-reset" size={16} />
</button>
<button
type="button"
aria-label="关闭自定义工具栏"
onClick={() => setDraft(null)}
>
<ReferenceIcon name="toolbar-close" size={16} />
</button>
</div>
</header>
<div className="lovart-customizer-body">
<div
className="lovart-customizer-preview"
aria-label="工具栏预览"
>
<div className="lovart-customizer-preview-bar">
{addToChat && (
<span className="lovart-preview-fixed">
{toolIcon(addToChat, 18)}
{draft?.showNames && toolLabel(addToChat)}
</span>
)}
{draft?.pinnedKeys
.filter((key) => toolMap.has(key))
.map((key, index, keys) => {
const tool = toolMap.get(key)!;
return (
<div
className="lovart-preview-chip"
key={key}
draggable
onDragStart={(event) => {
setDraggedKey(key);
event.dataTransfer.setData("text/plain", key);
event.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => setDraggedKey(null)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
if (draggedKey) move(draggedKey, key);
setDraggedKey(null);
}}
>
<button
type="button"
className="lovart-preview-handle"
aria-label={`${toolLabel(tool)},按左右方向键调整顺序`}
onKeyDown={(event) => {
const offset =
event.key === "ArrowLeft"
? -1
: event.key === "ArrowRight"
? 1
: 0;
if (offset && keys[index + offset]) {
event.preventDefault();
move(key, keys[index + offset]);
}
}}
>
{toolIcon(tool, 18)}
{draft.showNames && <span>{toolLabel(tool)}</span>}
</button>
<button
type="button"
aria-label={`移除${toolLabel(tool)}`}
onClick={() => toggle(key)}
>
<X size={12} />
</button>
</div>
);
})}
</div>
</div>
<div className="lovart-customizer-tools">
{tools.map((tool) => {
const key = tool.props["data-tool-key"]!;
const isPinned = Boolean(draft?.pinnedKeys.includes(key));
return (
<button
key={key}
type="button"
aria-label={`${isPinned ? "取消固定" : "固定"}${toolLabel(tool)}`}
aria-pressed={isPinned}
onClick={() => toggle(key)}
>
{toolIcon(tool, 18)}
<span>{toolLabel(tool)}</span>
<ReferenceIcon
name={
isPinned
? "toolbar-pin-filled"
: "toolbar-pin-outline"
}
size={13}
/>
</button>
);
})}
</div>
</div>
<footer className="lovart-customizer-footer">
<label>
<button
type="button"
role="switch"
aria-label="显示工具名称"
aria-checked={Boolean(draft?.showNames)}
className="lovart-customizer-switch"
onClick={() =>
setDraft((current) =>
current
? { ...current, showNames: !current.showNames }
: current
)
}
>
<span />
</button>
显示工具名称
</label>
<div>
<button type="button" onClick={() => setDraft(null)}>
取消
</button>
<button
type="button"
className="lovart-customizer-save"
onClick={() => {
if (draft && saveImageToolbarPreferences(draft))
setDraft(null);
else
toast.error("无法保存工具栏设置,请检查浏览器存储权限");
}}
>
保存
</button>
</div>
</footer>
</DialogContent>
</Dialog>
</>
);
}
);

View File

@ -0,0 +1,47 @@
"use client";
import { CircleAlert, Check, ChevronDown, Loader2, RotateCcw, Square } from "lucide-react";
import type { AgentRunView } from "@/lib/ai/canvas-agent-runtime/protocol";
import { canvasAgentProgressLabel, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
export function CanvasAgentProgressMessage(props: {
progress: CanvasAgentProgress; run?: AgentRunView | null; busy?: boolean;
stopping?: boolean; sending?: boolean; onCancel?: () => void; onResume?: () => void;
}) {
const { progress, run } = props;
const running = progress.status === "running";
const label = progress.status === "completed" ? "处理完成"
: props.stopping && running ? "正在停止后续步骤,已完成的结果会保留"
: canvasAgentProgressLabel(progress.message || "正在处理你的任务");
return (
<div className="my-2 w-full min-w-0 text-xs text-zinc-500 dark:text-zinc-400" data-testid="canvas-agent-progress" data-agent-message-id={run?.assistantMessageId}>
<div className="flex items-start gap-2">
<details className="min-w-0 flex-1" open={running}>
<summary className="flex cursor-pointer list-none items-center gap-2 py-1 [&::-webkit-details-marker]:hidden">
{running ? <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" /> : progress.status === "completed" ? <Check className="h-3.5 w-3.5 shrink-0" /> : <CircleAlert className="h-3.5 w-3.5 shrink-0" />}
<span className="min-w-0 flex-1" role="status" aria-live="polite">{label}</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</summary>
{progress.activities?.length ? (
<ol className="ml-1.5 mt-1 space-y-1.5 border-l border-zinc-200 pl-4 dark:border-zinc-800" aria-label="任务执行步骤">
{progress.activities.map(activity => (
<li key={activity.id} className="flex items-center gap-2">
{activity.status === "started" ? <Loader2 className="h-3 w-3 shrink-0 animate-spin" /> : <span className="h-1 w-1 shrink-0 rounded-full bg-current" />}
<span>{canvasAgentProgressLabel(activity.title)}</span>
<span className="text-[11px] opacity-75">{activity.status === "succeeded" ? "已完成" : activity.status === "failed" ? "未完成" : activity.status === "queued" ? "待执行" : "进行中"}</span>
</li>
))}
</ol>
) : null}
{run?.inputs.slice(-2).map(input => (
<p key={input.id} className="mt-2 whitespace-pre-wrap break-words">{input.applied ? "已应用补充要求" : "已收到补充要求"}:{input.text}</p>
))}
</details>
{running && props.onCancel ? (
<button type="button" onClick={props.onCancel} disabled={props.sending || props.stopping} className="rounded-md p-1.5 hover:bg-zinc-100 disabled:opacity-40 dark:hover:bg-white/10" title="停止后续步骤" aria-label="停止后续步骤"><Square className="h-3.5 w-3.5" /></button>
) : run?.resumable && props.onResume ? (
<button type="button" onClick={props.onResume} disabled={props.busy} className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 hover:bg-zinc-100 disabled:opacity-40 dark:hover:bg-white/10"><RotateCcw className="h-3.5 w-3.5" />继续任务</button>
) : null}
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
"use client";
// Compatibility wrapper for the paused video entry. Image chat observes the run
// once and renders its activity in the corresponding assistant message.
import { CanvasAgentProgressMessage } from "./canvas-agent-progress";
import { useCanvasAgentRun, type CanvasAgentRunOptions } from "./use-canvas-agent-run";
import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
export function CanvasAgentRunPanel(props: CanvasAgentRunOptions & { onResume: (runId: string) => void }) {
const task = useCanvasAgentRun(props);
if (!task.run) return null;
return <CanvasAgentProgressMessage progress={canvasAgentProgress(task.run)} run={task.run} busy={props.busy}
stopping={task.stopPending} sending={task.sending} onCancel={() => void task.control("cancel")}
onResume={() => props.onResume(task.run!.id)} />;
}

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

@ -0,0 +1,78 @@
"use client";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { getCanvasAgentAccessManifest } from "@/lib/ai/canvas-agent-runtime/capability-access";
import {
canvasAgentUiCommandSchema,
CANVAS_AGENT_UI_COMMAND_EVENT,
type CanvasAgentUiCommand,
} from "@/lib/ai/canvas-agent-runtime/ui-command";
import { useCanvasStore } from "@/store/canvas";
export function CanvasAgentUiListener(props: {
projectId: string;
workspace: "canvas" | "video";
onVideoAction?: (command: CanvasAgentUiCommand) => void | Promise<void>;
}) {
const latest = useRef(props);
latest.current = props;
const seen = useRef(new Set<string>());
useEffect(() => {
const handler = async (event: Event) => {
const parsed = canvasAgentUiCommandSchema.safeParse((event as CustomEvent).detail);
if (!parsed.success) return;
const command = parsed.data,
current = latest.current;
if (
command.projectId !== current.projectId ||
command.workspace !== current.workspace ||
seen.current.has(command.requestId)
)
return;
seen.current.add(command.requestId);
try {
if (current.workspace === "video") {
await current.onVideoAction?.(command);
return;
}
const feature = getCanvasAgentAccessManifest().find(
(item) => item.id === command.featureId && item.workspace === "canvas",
);
if (!feature || !feature.id.startsWith("canvas.ui.")) throw new Error("这个界面入口不存在");
const key = feature.id.slice("canvas.ui.".length);
if (/chatSend|chatStartGeneration/.test(key)) {
toast.message("已定位对话提交入口,请由你确认并发送;Agent 不会伪造下一条用户消息");
return;
}
const state = useCanvasStore.getState();
if (state.projectId !== current.projectId) return;
if (command.nodeIds.length)
state.selectNodes(
command.nodeIds.filter((id) => state.nodes.some((node) => node.id === id)),
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const element = Array.from(
document.querySelectorAll<HTMLElement>("[data-canvas-feature-key]"),
).find((item) => item.dataset.canvasFeatureKey === key && item.getClientRects().length);
const button = element?.closest<HTMLElement>("button,[role=button],a");
if (!button || button.hasAttribute("disabled"))
throw new Error(
"此入口当前未显示或不可用;请切换相应工作区。工具授权不会恢复被隐藏的按钮。",
);
if (
/Delete|Generate|Confirm|Cancel|SaveText|realtimeSend/.test(key) &&
!window.confirm(`Agent 请求操作「${feature.title}」。确认在当前画布执行?`)
)
return;
button.click();
} catch (error) {
toast.error(error instanceof Error ? error.message : "无法打开画布入口");
}
};
window.addEventListener(CANVAS_AGENT_UI_COMMAND_EVENT, handler);
return () => window.removeEventListener(CANVAS_AGENT_UI_COMMAND_EVENT, handler);
}, []);
return null;
}

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

@ -0,0 +1,70 @@
import { normalizeImageIdentityUrl } from "./canvas-chat-media";
import type { ArchivedImageUrlMap } from "@/lib/storage/image-archive-urls";
type ImageResultNode = {
type: string;
data?: unknown;
};
function imageUrls(node: ImageResultNode): string[] {
if (node.type !== "image") return [];
const data = node.data as { src?: string; metadata?: { sourceImageKey?: string } } | undefined;
return [data?.src, data?.metadata?.sourceImageKey].filter(
(url): url is string => typeof url === "string" && Boolean(url.trim()),
);
}
export function getCanvasImageResultUrlKeys(
nodes: readonly ImageResultNode[],
aliases: ArchivedImageUrlMap = {},
) {
const keys = new Set<string>();
for (const node of nodes) {
for (const url of imageUrls(node)) {
keys.add(normalizeImageIdentityUrl(url));
if (aliases[url]) keys.add(normalizeImageIdentityUrl(aliases[url]));
}
}
keys.delete("");
return keys;
}
// A history refresh may already contain bucket URLs while this tab still shows
// provider previews. Resolve aliases only for an existing result of this message;
// a new generation never waits for storage before appearing on the canvas.
export async function resolveCanvasImageResultAliases(params: {
nodes: readonly ImageResultNode[];
messageId?: string;
imageUrls: string[];
resolveAliases: (urls: string[]) => Promise<ArchivedImageUrlMap>;
}): Promise<ArchivedImageUrlMap> {
const keys = getCanvasImageResultUrlKeys(params.nodes);
if (!params.messageId || params.imageUrls.every((url) => keys.has(normalizeImageIdentityUrl(url)))) {
return {};
}
const urls = [...new Set(params.nodes.flatMap((node) => {
const data = node.data as { metadata?: { sourceMessageId?: string } } | undefined;
return data?.metadata?.sourceMessageId === params.messageId ? imageUrls(node) : [];
}))].filter((url) => /^https?:\/\//i.test(url));
if (!urls.length) return {};
const aliases: ArchivedImageUrlMap = {};
for (let index = 0; index < urls.length; index += 100) {
Object.assign(aliases, await params.resolveAliases(urls.slice(index, index + 100)));
}
return aliases;
}
// Call synchronously immediately before changing the store, after all image
// loading/network awaits. Both stream and history writers share this check.
export function commitCanvasImageResultIfMissing<T>(params: {
getNodes: () => readonly ImageResultNode[];
url: string;
aliases: ArchivedImageUrlMap;
commit: () => T;
}): T | undefined {
const key = normalizeImageIdentityUrl(params.url);
if (!key || getCanvasImageResultUrlKeys(params.getNodes(), params.aliases).has(key)) {
return undefined;
}
return params.commit();
}

View File

@ -0,0 +1,64 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import type { AgentRunView } from "@/lib/ai/canvas-agent-runtime/protocol";
export type CanvasAgentRunOptions = {
enabled: boolean; chatId: string; projectId: string; busy: boolean;
onResults: (projectId: string, nodeIds: string[]) => void;
onRunChange?: (run: AgentRunView | null) => void;
};
export function useCanvasAgentRun(props: CanvasAgentRunOptions) {
const [sending, setSending] = useState(false);
const [stopping, setStopping] = useState(false);
const sendingRef = useRef(false);
const scope = `${props.chatId}:${props.projectId}`;
const latest = useRef({ props, scope }); latest.current = { props, scope };
const resultSignature = useRef("");
const key = props.enabled ? `/api/canvas/agent-runs?${new URLSearchParams({ chatId: props.chatId, projectId: props.projectId })}` : null;
const { data, mutate } = useSWR<{ run: AgentRunView | null }>(key, async (url: string) => {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) return { run: null };
return response.json();
}, { refreshInterval: latest => latest?.run?.status === "running" || props.busy ? 2000 : 0,
revalidateOnFocus: true, shouldRetryOnError: false });
const run = props.enabled && data?.run?.chatId === props.chatId && data.run.projectId === props.projectId ? data.run : null;
useEffect(() => { latest.current.props.onRunChange?.(run); }, [run]);
useEffect(() => { setSending(false); sendingRef.current = false; setStopping(false); resultSignature.current = ""; }, [scope]);
useEffect(() => {
if (!props.enabled) return;
const handler = (event: Event) => {
const update = (event as CustomEvent<AgentRunView>).detail;
if (update?.chatId === props.chatId && update?.projectId === props.projectId) void mutate({ run: update }, false);
};
window.addEventListener("canvas-agent-run:update", handler);
return () => window.removeEventListener("canvas-agent-run:update", handler);
}, [props.enabled, props.chatId, props.projectId, mutate]);
useEffect(() => {
if (!run?.resultNodeIds.length) return;
const signature = `${run.id}:${run.resultNodeIds.join(",")}:${run.status}`;
if (signature === resultSignature.current) return;
resultSignature.current = signature;
latest.current.props.onResults(run.projectId, run.resultNodeIds);
}, [run]);
const control = useCallback(async (action: "steer" | "cancel", text?: string) => {
if (!run || sendingRef.current) return false;
sendingRef.current = true;
const startedScope = scope;
setSending(true);
try {
const response = await fetch("/api/canvas/agent-runs", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, chatId: props.chatId, projectId: props.projectId, runId: run.id,
...(action === "steer" ? { inputId: crypto.randomUUID(), text: text?.trim() } : {}) }) });
const result = await response.json();
if (!response.ok) throw new Error(result.error || "操作失败,请重试");
if (startedScope !== latest.current.scope) return false;
await mutate(result, false);
if (action === "cancel") setStopping(true);
toast.message(action === "steer" ? "补充要求已收到,将在下一步应用" : "正在停止后续步骤,已完成的结果会保留");
return true;
} catch (error) { toast.error(error instanceof Error ? error.message : "操作失败"); return false; }
finally { if (startedScope === latest.current.scope) { sendingRef.current = false; setSending(false); } }
}, [run, sending, scope, props.chatId, props.projectId, mutate]);
return { run, sending, stopPending: stopping || Boolean(run?.cancelRequested), control };
}

View File

@ -0,0 +1,343 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { ArrowUp, MessageSquare, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
import {
videoAgentSettingsSchema,
type VideoAgentSettings,
} from "@/lib/ai/canvas-agent-runtime/video-protocol";
type ModelOption = { id: string; displayName: string };
export function VideoCanvasAgentPanel(props: {
projectId: string;
selectedNodeIds: string[];
videoModels: ModelOption[];
imageModels: ModelOption[];
beforeSend: () => Promise<void>;
onResults: (projectId: string, nodeIds: string[]) => void;
onClose: () => void;
}) {
const [input, setInput] = useState("");
const [settings, setSettings] = useState<VideoAgentSettings>(() =>
videoAgentSettingsSchema.parse({}),
);
const [preparing, setPreparing] = useState(false);
const [loading, setLoading] = useState(true);
const [backgroundRunning, setBackgroundRunning] = useState(false);
const latest = useRef({ props, settings });
latest.current = { props, settings };
const resumeRef = useRef<string | undefined>(undefined);
const mounted = useRef(true);
const resultIds = useRef<string[]>([]);
const restoredRunId = useRef("");
const { messages, setMessages, sendMessage, status } = useChat({
id: `video-agent-${props.projectId}`,
generateId: () => crypto.randomUUID(),
transport: new DefaultChatTransport({
api: "/api/canvas/agent-chat",
prepareSendMessagesRequest: ({ messages }) => {
const current = latest.current;
const resumeRunId = resumeRef.current;
resumeRef.current = undefined;
return {
body: {
id: current.props.projectId,
message: messages[messages.length - 1],
selectedChatModel: "agent",
selectedVisibilityType: "private",
composerMode: "agent",
workspaceVariant: "video",
videoAgentSettings: current.settings,
resumeRunId,
contextPayload: {
source: "canvas",
projectId: current.props.projectId,
selectedNodeIds: current.props.selectedNodeIds,
},
},
};
},
}),
onData: (part) => {
if (part.type === "data-videoCanvasMutation") window.dispatchEvent(new CustomEvent("video-canvas-agent-mutation", { detail: part.data }));
if (part.type === "data-canvasAgentUiCommand") window.dispatchEvent(new CustomEvent("canvas-agent-ui-command", { detail: part.data }));
if (part.type === "data-canvasAgentRun")
window.dispatchEvent(new CustomEvent("canvas-agent-run:update", { detail: part.data }));
},
onError: (error) => toast.error(error.message || "视频任务启动失败,请重试"),
});
useEffect(() => {
mounted.current = true;
const controller = new AbortController();
void fetch(`/api/canvas/video-agent?projectId=${props.projectId}`, {
cache: "no-store",
signal: controller.signal,
})
.then(async (response) => {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "读取视频对话失败");
if (!controller.signal.aborted) setMessages(data.messages as UIMessage[]);
})
.catch((error) => {
if (!controller.signal.aborted) toast.error(error.message);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => {
mounted.current = false;
controller.abort();
};
}, [props.projectId, setMessages]);
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming";
useEffect(() => {
const draft = readCanvasComposerDraft(`video-agent-${props.projectId}`);
if (draft) setInput(draft.text);
}, [props.projectId]);
useEffect(() => {
const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (busy) request.block("当前视频任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
request.save(async () => {
if (input.trim() && !writeCanvasComposerDraft(`video-agent-${props.projectId}`, { text: input, attachments: [] }))
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
});
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [busy, input, props.projectId]);
const submit = useCallback(
async (resumeRunId?: string) => {
if (busy || loading) return;
const text =
input.trim() ||
(resumeRunId ? "请继续尚未完成的视频创作步骤,保留已完成的脚本、关键帧和视频。" : "");
if (!text) return;
setPreparing(true);
try {
await latest.current.props.beforeSend();
if (!mounted.current) return;
clearCanvasComposerDraft(`video-agent-${props.projectId}`);
resumeRef.current = resumeRunId;
setInput("");
await sendMessage({ text });
} catch (error) {
toast.error(error instanceof Error ? error.message : "视频任务启动失败");
} finally {
resumeRef.current = undefined;
if (mounted.current) setPreparing(false);
}
},
[busy, loading, input, sendMessage],
);
const onRunChange = useCallback(
(run: import("@/lib/ai/canvas-agent-runtime/protocol").AgentRunView | null) => {
resultIds.current = run?.resultNodeIds || [];
if (run?.videoSettings && restoredRunId.current !== run.id) {
restoredRunId.current = run.id;
setSettings(run.videoSettings);
}
setBackgroundRunning(run?.status === "running");
},
[],
);
const onResults = useCallback(
(projectId: string, ids: string[]) => latest.current.props.onResults(projectId, ids),
[],
);
useEffect(() => {
const timer = setInterval(() => {
if (resultIds.current.length)
latest.current.props.onResults(latest.current.props.projectId, resultIds.current);
}, 10_000);
return () => clearInterval(timer);
}, []);
return (
<aside
className="dark absolute bottom-3 right-3 top-[72px] z-40 flex w-[390px] max-w-[calc(100vw-24px)] flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#202020] text-zinc-100 shadow-2xl"
data-canvas-overlay="true"
data-testid="video-agent-panel"
aria-label="视频创作 Agent"
>
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3">
<MessageSquare className="h-4 w-4" />
<span className="flex-1 font-medium">视频创作 Agent</span>
<button
type="button"
onClick={props.onClose}
aria-label="收起视频 Agent"
className="rounded-lg p-1 hover:bg-white/10"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4" aria-live="polite">
{loading ? (
<p className="text-sm text-zinc-400">正在读取对话…</p>
) : messages.length === 0 ? (
<p className="text-sm leading-7 text-zinc-400">
选择画布素材,告诉我你想制作的视频。我可以分析参考、编写分镜、生成关键帧并继续生成视频。
</p>
) : null}
{messages.map((message) => (
<div
key={message.id}
className={
message.role === "user"
? "ml-5 whitespace-pre-wrap rounded-2xl bg-white/10 px-3 py-2 text-sm leading-6"
: "whitespace-pre-wrap text-sm leading-6 text-zinc-300"
}
>
{message.parts
.filter((part) => part.type === "text")
.map((part, i) => (
<span key={`${message.id}-${i}`}>{part.type === "text" ? part.text : ""}</span>
))}
</div>
))}
</div>
<div className="border-t border-white/10 p-3">
<CanvasAgentRunPanel
enabled={!loading}
chatId={props.projectId}
projectId={props.projectId}
busy={busy}
onResume={(id) => void submit(id)}
onResults={onResults}
onRunChange={onRunChange}
/>
<div className="mb-2 grid grid-cols-2 gap-2 text-xs">
<select
aria-label="视频模型"
disabled={busy}
value={settings.modelRef || ""}
onChange={(e) => setSettings({ ...settings, modelRef: e.target.value || undefined })}
className="min-w-0 rounded-lg border border-white/10 bg-zinc-800 p-2"
>
<option value="">默认视频模型</option>
{props.videoModels.map((m) => (
<option key={m.id} value={m.id}>
{m.displayName}
</option>
))}
</select>
<select
aria-label="关键帧模型"
disabled={busy}
value={settings.imageModelRef || ""}
onChange={(e) =>
setSettings({ ...settings, imageModelRef: e.target.value || undefined })
}
className="min-w-0 rounded-lg border border-white/10 bg-zinc-800 p-2"
>
<option value="">默认关键帧模型</option>
{props.imageModels.map((m) => (
<option key={m.id} value={m.id}>
{m.displayName}
</option>
))}
</select>
<select
aria-label="视频模式"
disabled={busy}
value={settings.mode}
onChange={(e) =>
setSettings({ ...settings, mode: e.target.value as VideoAgentSettings["mode"] })
}
className="rounded-lg border border-white/10 bg-zinc-800 p-2"
>
{[
["auto", "按参考图匹配模式"],
["text-to-video", "文生视频"],
["first-frame", "首帧视频"],
["first-last-frame", "首尾帧视频"],
["reference-images", "参考图视频"],
].map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<div className="flex items-center gap-1 rounded-lg border border-white/10 bg-zinc-800 px-2">
<input
aria-label="视频时长(秒)"
type="number"
min={1}
max={120}
disabled={busy}
value={settings.duration}
onChange={(e) => setSettings({ ...settings, duration: Number(e.target.value) })}
className="w-12 min-w-0 bg-transparent py-2"
/>
<span>秒</span>
<select
aria-label="视频比例"
disabled={busy}
value={settings.ratio}
onChange={(e) =>
setSettings({ ...settings, ratio: e.target.value as VideoAgentSettings["ratio"] })
}
className="min-w-0 flex-1 bg-zinc-800"
>
{["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"].map((v) => (
<option key={v}>{v}</option>
))}
</select>
</div>
<select
aria-label="视频分辨率"
disabled={busy}
value={settings.resolution}
onChange={(e) =>
setSettings({
...settings,
resolution: e.target.value as VideoAgentSettings["resolution"],
})
}
className="rounded-lg border border-white/10 bg-zinc-800 p-2"
>
{["480p", "720p", "1080p"].map((v) => (
<option key={v}>{v}</option>
))}
</select>
</div>
<form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
className="rounded-2xl border border-white/10 bg-[#151515] p-3"
>
<textarea
aria-label="视频创作要求"
value={input}
onChange={(event) => { clearCanvasComposerDraft(`video-agent-${props.projectId}`); setInput(event.target.value); }}
maxLength={2000}
rows={3}
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"
className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-zinc-500"
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-zinc-500">
已选择 {props.selectedNodeIds.length} 个素材
</span>
<button
type="submit"
aria-label="开始视频任务"
disabled={busy || loading || !input.trim()}
className="rounded-full bg-zinc-100 p-2 text-zinc-900 disabled:opacity-30"
>
<ArrowUp className="h-4 w-4" />
</button>
</div>
</form>
</div>
</aside>
);
}

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,
@ -42,7 +45,7 @@ import {
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, usePathname, useRouter } from "next/navigation";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@ -56,8 +59,10 @@ import { CanvasLayoutReplaceBatchDialog } from "@/components/canvas/CanvasLayout
import { TextEditColorField } from "@/components/canvas/TextEditColorField"; import { TextEditColorField } from "@/components/canvas/TextEditColorField";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer"; import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer"; import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
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,
@ -184,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,
@ -230,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";
@ -1307,6 +1314,8 @@ export function ImageNode({
}: ImageNodeProps) { }: ImageNodeProps) {
const router = useRouter(); const router = useRouter();
const routeParams = useParams(); const routeParams = useParams();
const pathname = usePathname();
const useReferenceImageToolbar = /^\/canvas\/[^/]+\/?$/.test(pathname);
const canvasId = Array.isArray(routeParams?.id) const canvasId = Array.isArray(routeParams?.id)
? routeParams.id[0] || "" ? routeParams.id[0] || ""
: typeof routeParams?.id === "string" : typeof routeParams?.id === "string"
@ -1532,9 +1541,11 @@ export function ImageNode({
.map((action) => action.key) .map((action) => action.key)
); );
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) => const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
selectedImageInlineActionKeys.has(key); useReferenceImageToolbar
? enabledSelectedImageToolbarActions.some((action) => action.key === key)
: selectedImageInlineActionKeys.has(key);
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) => const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
selectedImageMoreActionKeys.has(key); !useReferenceImageToolbar && selectedImageMoreActionKeys.has(key);
const hasHoverImageMorePrimaryActions = const hasHoverImageMorePrimaryActions =
imageRemoveBackgroundEnabled || imageRemoveBackgroundEnabled ||
imageRemoveWatermarkEnabled || imageRemoveWatermarkEnabled ||
@ -1560,6 +1571,7 @@ export function ImageNode({
const hasSelectedImageMorePrimaryActions = const hasSelectedImageMorePrimaryActions =
selectedImageMoreActionKeys.size > 0; selectedImageMoreActionKeys.size > 0;
const hasSelectedImageMoreMenu = const hasSelectedImageMoreMenu =
useReferenceImageToolbar ||
hasSelectedImageMorePrimaryActions || hasSelectedImageMorePrimaryActions ||
(imageNodeMoreEnabled && hasCanvasAgentAnnotations) || (imageNodeMoreEnabled && hasCanvasAgentAnnotations) ||
(imageNodeMoreEnabled && imageDeleteEnabled); (imageNodeMoreEnabled && imageDeleteEnabled);
@ -1702,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);
@ -2071,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
); );
@ -2491,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(
@ -4672,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) => {
@ -6701,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;
} }
@ -6720,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 =
@ -8329,7 +8266,7 @@ export function ImageNode({
signal: controller.signal, signal: controller.signal,
onProgress: setDownloadProgress, onProgress: setDownloadProgress,
}); });
toast.success("原图下载成功"); toast.success("原图已开始下载");
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
toast.message("已取消下载"); toast.message("已取消下载");
@ -8433,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
@ -8460,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}
@ -8489,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}
@ -8539,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",
@ -8551,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
@ -8560,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}
@ -9476,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}
@ -9640,6 +9591,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={LayoutTemplate} fallback={LayoutTemplate}
item={imageLayoutReplaceItem} item={imageLayoutReplaceItem}
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
referenceSize={18}
/> />
<span>{imageLayoutReplaceItem.label}</span> <span>{imageLayoutReplaceItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} /> <ImageActionCreditBadge label={localEditCostShortLabel} />
@ -10192,8 +10145,12 @@ export function ImageNode({
typeof document !== "undefined" && typeof document !== "undefined" &&
createPortal( createPortal(
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<div <ReferenceImageToolbar
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62" referencePresentation={useReferenceImageToolbar}
defaultPinnedKeys={enabledSelectedImageToolbarActions.slice(0, selectedImageInlineActionCount).map((action) => action.key)}
className={cn("fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62", useReferenceImageToolbar && "lovart-image-action-bar")}
data-selected-image-toolbar="true"
data-reference-presentation={useReferenceImageToolbar ? "true" : undefined}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
@ -10217,6 +10174,7 @@ export function ImageNode({
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200" className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
disabled={isProcessing || isAddingToChat} disabled={isProcessing || isAddingToChat}
onClick={handleAddToChat} onClick={handleAddToChat}
data-tool-key="addToChat"
title={imageAddToChatItem.label} title={imageAddToChatItem.label}
variant="ghost" variant="ghost"
> >
@ -10228,6 +10186,8 @@ export function ImageNode({
className="h-4 w-4 dark:invert-0" className="h-4 w-4 dark:invert-0"
fallback={MessageSquarePlus} fallback={MessageSquarePlus}
item={imageAddToChatItem} item={imageAddToChatItem}
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
referenceSize={25}
/> />
)} )}
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span> <span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
@ -10242,6 +10202,7 @@ export function ImageNode({
{isSelectedImageActionInline("pickObject") ? ( {isSelectedImageActionInline("pickObject") ? (
<Button <Button
data-tool-key="pickObject"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isPickerLoading || isThisNodePicking} disabled={isProcessing || isPickerLoading || isThisNodePicking}
onClick={handleStartPicking} onClick={handleStartPicking}
@ -10252,6 +10213,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Crosshair} fallback={Crosshair}
item={imagePickObjectItem} item={imagePickObjectItem}
referenceName={useReferenceImageToolbar ? "image-move-object" : undefined}
referenceSize={25}
/> />
{imagePickObjectItem.label} {imagePickObjectItem.label}
</Button> </Button>
@ -10259,6 +10222,7 @@ export function ImageNode({
{isSelectedImageActionInline("enhance") ? ( {isSelectedImageActionInline("enhance") ? (
<Button <Button
data-tool-key="enhance"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("enhance")} onClick={() => handleAction("enhance")}
@ -10269,6 +10233,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Sparkles} fallback={Sparkles}
item={imageEnhanceItem} item={imageEnhanceItem}
referenceName={useReferenceImageToolbar ? "image-upscale" : undefined}
referenceSize={25}
/> />
{imageEnhanceItem.label} {imageEnhanceItem.label}
<ImageActionCreditBadge compact label={enhanceCostShortLabel} /> <ImageActionCreditBadge compact label={enhanceCostShortLabel} />
@ -10277,6 +10243,7 @@ export function ImageNode({
{isSelectedImageActionInline("removeBackground") ? ( {isSelectedImageActionInline("removeBackground") ? (
<Button <Button
data-tool-key="removeBackground"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("rmbg")} onClick={() => handleAction("rmbg")}
@ -10287,6 +10254,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Scissors} fallback={Scissors}
item={imageRemoveBackgroundItem} item={imageRemoveBackgroundItem}
referenceName={useReferenceImageToolbar ? "image-remove-background" : undefined}
referenceSize={25}
/> />
{imageRemoveBackgroundItem.label} {imageRemoveBackgroundItem.label}
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10298,6 +10267,7 @@ export function ImageNode({
{isSelectedImageActionInline("aiWashQuick") ? ( {isSelectedImageActionInline("aiWashQuick") ? (
<Button <Button
data-tool-key="aiWashQuick"
className={cn( className={cn(
"group relative h-10 rounded-xl px-3 font-medium text-[13px] transition", "group relative h-10 rounded-xl px-3 font-medium text-[13px] transition",
aiWashQuickPanelOpen aiWashQuickPanelOpen
@ -10307,6 +10277,7 @@ export function ImageNode({
disabled={isProcessing || isSubmittingAiWashQuick} disabled={isProcessing || isSubmittingAiWashQuick}
onClick={() => handleAction("ai-wash")} onClick={() => handleAction("ai-wash")}
title={aiWashQuickModeLabel} title={aiWashQuickModeLabel}
data-reference-active={aiWashQuickPanelOpen ? "true" : undefined}
variant="ghost" variant="ghost"
> >
{isSubmittingAiWashQuick ? ( {isSubmittingAiWashQuick ? (
@ -10317,6 +10288,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Sparkles} fallback={Sparkles}
iconUrl={aiWashQuickConfig.modeIconUrl} iconUrl={aiWashQuickConfig.modeIconUrl}
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
referenceSize={25}
/> />
)} )}
{aiWashQuickModeLabel} {aiWashQuickModeLabel}
@ -10329,6 +10302,7 @@ export function ImageNode({
{isSelectedImageActionInline("textEdit") ? ( {isSelectedImageActionInline("textEdit") ? (
<Button <Button
data-tool-key="textEdit"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isDetectingText || isApplyingTextEdit} disabled={isProcessing || isDetectingText || isApplyingTextEdit}
onClick={() => handleAction("text-edit")} onClick={() => handleAction("text-edit")}
@ -10339,6 +10313,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Type} fallback={Type}
item={imageTextEditItem} item={imageTextEditItem}
referenceName={useReferenceImageToolbar ? "image-edit-text" : undefined}
referenceSize={25}
/> />
{imageTextEditItem.label} {imageTextEditItem.label}
</Button> </Button>
@ -10346,6 +10322,7 @@ export function ImageNode({
{isSelectedImageActionInline("boxCutout") ? ( {isSelectedImageActionInline("boxCutout") ? (
<Button <Button
data-tool-key="boxCutout"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("box-cutout")} onClick={() => handleAction("box-cutout")}
@ -10356,6 +10333,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Scan} fallback={Scan}
item={imageBoxCutoutItem} item={imageBoxCutoutItem}
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
referenceSize={25}
/> />
{imageBoxCutoutItem.label} {imageBoxCutoutItem.label}
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} /> <ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
@ -10364,6 +10343,7 @@ export function ImageNode({
{isSelectedImageActionInline("localEdit") ? ( {isSelectedImageActionInline("localEdit") ? (
<Button <Button
data-tool-key="localEdit"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isSubmittingLocalEdit} disabled={isProcessing || isSubmittingLocalEdit}
onClick={() => handleAction("local-edit")} onClick={() => handleAction("local-edit")}
@ -10374,6 +10354,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Wand2} fallback={Wand2}
item={imageLocalEditItem} item={imageLocalEditItem}
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
referenceSize={25}
/> />
{imageLocalEditItem.label} {imageLocalEditItem.label}
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10385,6 +10367,7 @@ export function ImageNode({
{isSelectedImageActionInline("layoutReplace") ? ( {isSelectedImageActionInline("layoutReplace") ? (
<Button <Button
data-tool-key="layoutReplace"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("layout-replace")} onClick={() => handleAction("layout-replace")}
@ -10395,6 +10378,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={LayoutTemplate} fallback={LayoutTemplate}
item={imageLayoutReplaceItem} item={imageLayoutReplaceItem}
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
referenceSize={18}
/> />
{imageLayoutReplaceItem.label} {imageLayoutReplaceItem.label}
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10406,6 +10391,7 @@ export function ImageNode({
{isSelectedImageActionInline("removeWatermark") ? ( {isSelectedImageActionInline("removeWatermark") ? (
<Button <Button
data-tool-key="removeWatermark"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("watermark")} onClick={() => handleAction("watermark")}
@ -10416,6 +10402,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Eraser} fallback={Eraser}
item={imageRemoveWatermarkItem} item={imageRemoveWatermarkItem}
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
referenceSize={25}
/> />
{imageRemoveWatermarkItem.label} {imageRemoveWatermarkItem.label}
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10427,6 +10415,7 @@ export function ImageNode({
{isSelectedImageActionInline("resize") ? ( {isSelectedImageActionInline("resize") ? (
<Button <Button
data-tool-key="resize"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("resize")} onClick={() => handleAction("resize")}
@ -10444,6 +10433,7 @@ export function ImageNode({
{isSelectedImageActionInline("multiAngle") ? ( {isSelectedImageActionInline("multiAngle") ? (
<Button <Button
data-tool-key="multiAngle"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("angle")} onClick={() => handleAction("angle")}
@ -10454,6 +10444,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Box} fallback={Box}
item={imageMultiAngleItem} item={imageMultiAngleItem}
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
referenceSize={25}
/> />
{imageMultiAngleItem.label} {imageMultiAngleItem.label}
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10465,6 +10457,7 @@ export function ImageNode({
{isSelectedImageActionInline("generate3dModel") ? ( {isSelectedImageActionInline("generate3dModel") ? (
<Button <Button
data-tool-key="generate3dModel"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("generate-3d-model")} onClick={() => handleAction("generate-3d-model")}
@ -10486,6 +10479,7 @@ export function ImageNode({
{isSelectedImageActionInline("generate3dWorld") ? ( {isSelectedImageActionInline("generate3dWorld") ? (
<Button <Button
data-tool-key="generate3dWorld"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("generate-3d-world")} onClick={() => handleAction("generate-3d-world")}
@ -10507,6 +10501,7 @@ export function ImageNode({
{isSelectedImageActionInline("layerSplit") ? ( {isSelectedImageActionInline("layerSplit") ? (
<Button <Button
data-tool-key="layerSplit"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("layer-split")} onClick={() => handleAction("layer-split")}
@ -10517,6 +10512,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Layers} fallback={Layers}
item={imageLayerSplitItem} item={imageLayerSplitItem}
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
referenceSize={25}
/> />
{imageLayerSplitItem.label} {imageLayerSplitItem.label}
</Button> </Button>
@ -10524,6 +10521,7 @@ export function ImageNode({
{isSelectedImageActionInline("extend") ? ( {isSelectedImageActionInline("extend") ? (
<Button <Button
data-tool-key="extend"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("extend")} onClick={() => handleAction("extend")}
@ -10534,6 +10532,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Expand} fallback={Expand}
item={imageExtendItem} item={imageExtendItem}
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
referenceSize={25}
/> />
{imageExtendItem.label} {imageExtendItem.label}
<ImageActionCreditBadge compact label={extendCostShortLabel} /> <ImageActionCreditBadge compact label={extendCostShortLabel} />
@ -10542,6 +10542,7 @@ export function ImageNode({
{isSelectedImageActionInline("crop") ? ( {isSelectedImageActionInline("crop") ? (
<Button <Button
data-tool-key="crop"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("crop")} onClick={() => handleAction("crop")}
@ -10552,6 +10553,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Crop} fallback={Crop}
item={imageCropItem} item={imageCropItem}
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
referenceSize={25}
/> />
{imageCropItem.label} {imageCropItem.label}
</Button> </Button>
@ -10559,6 +10562,7 @@ export function ImageNode({
{isSelectedImageActionInline("adjust") ? ( {isSelectedImageActionInline("adjust") ? (
<Button <Button
data-tool-key="adjust"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("adjust")} onClick={() => handleAction("adjust")}
@ -10569,6 +10573,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={SlidersHorizontal} fallback={SlidersHorizontal}
item={imageAdjustItem} item={imageAdjustItem}
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
referenceSize={25}
/> />
{imageAdjustItem.label} {imageAdjustItem.label}
</Button> </Button>
@ -10576,6 +10582,7 @@ export function ImageNode({
{isSelectedImageActionInline("flipRotate") ? ( {isSelectedImageActionInline("flipRotate") ? (
<Button <Button
data-tool-key="flipRotate"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("flip-rotate")} onClick={() => handleAction("flip-rotate")}
@ -10586,6 +10593,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={FlipHorizontal2} fallback={FlipHorizontal2}
item={imageFlipRotateItem} item={imageFlipRotateItem}
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
referenceSize={25}
/> />
{imageFlipRotateItem.label} {imageFlipRotateItem.label}
</Button> </Button>
@ -10593,6 +10602,7 @@ export function ImageNode({
{isSelectedImageActionInline("promptRegenerate") ? ( {isSelectedImageActionInline("promptRegenerate") ? (
<Button <Button
data-tool-key="promptRegenerate"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("prompt-regenerate")} onClick={() => handleAction("prompt-regenerate")}
@ -10614,6 +10624,7 @@ export function ImageNode({
{isSelectedImageActionInline("note") ? ( {isSelectedImageActionInline("note") ? (
<Button <Button
data-tool-key="note"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("note")} onClick={() => handleAction("note")}
@ -10624,6 +10635,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={StickyNote} fallback={StickyNote}
item={imageNoteItem} item={imageNoteItem}
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
referenceSize={25}
/> />
{imageNoteItem.label} {imageNoteItem.label}
</Button> </Button>
@ -10648,12 +10661,14 @@ export function ImageNode({
className="h-4 w-4" className="h-4 w-4"
fallback={MoreHorizontal} fallback={MoreHorizontal}
item={imageNodeMoreItem} item={imageNodeMoreItem}
referenceName={useReferenceImageToolbar ? "image-more" : undefined}
referenceSize={25}
/> />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="center" align="center"
className="bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100" className={cn("bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100", useReferenceImageToolbar && "lovart-image-action-menu")}
side="bottom" side="bottom"
> >
{isSelectedImageActionInMore("boxCutout") ? ( {isSelectedImageActionInMore("boxCutout") ? (
@ -10662,6 +10677,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Scan} fallback={Scan}
item={imageBoxCutoutItem} item={imageBoxCutoutItem}
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
referenceSize={18}
/> />
<span>{imageBoxCutoutItem.label}</span> <span>{imageBoxCutoutItem.label}</span>
<ImageActionCreditBadge label={boxCutoutCostShortLabel} /> <ImageActionCreditBadge label={boxCutoutCostShortLabel} />
@ -10673,6 +10690,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Wand2} fallback={Wand2}
item={imageLocalEditItem} item={imageLocalEditItem}
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
referenceSize={18}
/> />
<span>{imageLocalEditItem.label}</span> <span>{imageLocalEditItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} /> <ImageActionCreditBadge label={localEditCostShortLabel} />
@ -10686,6 +10705,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={LayoutTemplate} fallback={LayoutTemplate}
item={imageLayoutReplaceItem} item={imageLayoutReplaceItem}
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
referenceSize={18}
/> />
<span>{imageLayoutReplaceItem.label}</span> <span>{imageLayoutReplaceItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} /> <ImageActionCreditBadge label={localEditCostShortLabel} />
@ -10697,6 +10718,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Eraser} fallback={Eraser}
item={imageRemoveWatermarkItem} item={imageRemoveWatermarkItem}
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
referenceSize={18}
/> />
<span>{imageRemoveWatermarkItem.label}</span> <span>{imageRemoveWatermarkItem.label}</span>
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10720,6 +10743,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Box} fallback={Box}
item={imageMultiAngleItem} item={imageMultiAngleItem}
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
referenceSize={18}
/> />
<span>{imageMultiAngleItem.label}</span> <span>{imageMultiAngleItem.label}</span>
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10761,6 +10786,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Layers} fallback={Layers}
item={imageLayerSplitItem} item={imageLayerSplitItem}
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
referenceSize={18}
/> />
{imageLayerSplitItem.label} {imageLayerSplitItem.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -10771,6 +10798,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Expand} fallback={Expand}
item={imageExtendItem} item={imageExtendItem}
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
referenceSize={18}
/> />
<span>{imageExtendItem.label}</span> <span>{imageExtendItem.label}</span>
<ImageActionCreditBadge label={extendCostShortLabel} /> <ImageActionCreditBadge label={extendCostShortLabel} />
@ -10782,6 +10811,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Crop} fallback={Crop}
item={imageCropItem} item={imageCropItem}
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
referenceSize={18}
/> />
{imageCropItem.label} {imageCropItem.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -10792,6 +10823,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={SlidersHorizontal} fallback={SlidersHorizontal}
item={imageAdjustItem} item={imageAdjustItem}
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
referenceSize={18}
/> />
{imageAdjustItem.label} {imageAdjustItem.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -10802,6 +10835,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={FlipHorizontal2} fallback={FlipHorizontal2}
item={imageFlipRotateItem} item={imageFlipRotateItem}
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
referenceSize={18}
/> />
{imageFlipRotateItem.label} {imageFlipRotateItem.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -10827,6 +10862,8 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={StickyNote} fallback={StickyNote}
item={imageNoteItem} item={imageNoteItem}
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
referenceSize={18}
/> />
{imageNoteItem.label} {imageNoteItem.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -10866,6 +10903,7 @@ export function ImageNode({
void handleDownload(); void handleDownload();
}} }}
size="icon" size="icon"
data-tool-key="download"
title={imageDownloadItem.label} title={imageDownloadItem.label}
variant="ghost" variant="ghost"
> >
@ -10876,11 +10914,13 @@ export function ImageNode({
className="h-4 w-4" className="h-4 w-4"
fallback={Download} fallback={Download}
item={imageDownloadItem} item={imageDownloadItem}
referenceName={useReferenceImageToolbar ? "image-download" : undefined}
referenceSize={25}
/> />
)} )}
</Button> </Button>
) : null} ) : null}
</div> </ReferenceImageToolbar>
</TooltipProvider>, </TooltipProvider>,
document.body document.body
)} )}
@ -11876,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"
@ -11883,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
@ -11901,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();
@ -11920,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

@ -89,6 +89,12 @@ function isShot(value: unknown): value is TVstarShot {
return Boolean(value && typeof value === "object" && typeof (value as TVstarShot).sceneDescription === "string"); return Boolean(value && typeof value === "object" && typeof (value as TVstarShot).sceneDescription === "string");
} }
function productionShots(value: unknown) {
const shots = Array.isArray(value) ? value.filter(isShot) : [];
if (shots.length > 12) throw Error("自动视频制作最多支持12个镜头,请拆分制作;不会截掉多余镜头");
return shots;
}
export function TVstarPanel({ export function TVstarPanel({
canvasId, canvasId,
models, models,
@ -175,6 +181,7 @@ export function TVstarPanel({
const active = sessions.find(item => item.id === activeId); const active = sessions.find(item => item.id === activeId);
const activeTasks = active?.groupId ? tasks.filter(task => task.request.workflow?.groupId === active.groupId) : []; const activeTasks = active?.groupId ? tasks.filter(task => task.request.workflow?.groupId === active.groupId) : [];
const finalTask = activeTasks.find(task => task.request.kind === "smart-edit" && task.request.params.operation === "render"); const finalTask = activeTasks.find(task => task.request.kind === "smart-edit" && task.request.params.operation === "render");
const failedTask = activeTasks.find(task => (task.status === "failed" || task.status === "interrupted") && task.error && !task.error.startsWith("上游任务失败")) || finalTask;
const finalUrl = typeof finalTask?.result?.url === "string" ? finalTask.result.url : ""; const finalUrl = typeof finalTask?.result?.url === "string" ? finalTask.result.url : "";
const productionStatus = active?.preparedOnly ? "待运行工作流已建好,检查后可开始制作" const productionStatus = active?.preparedOnly ? "待运行工作流已建好,检查后可开始制作"
: activeTasks.some(task => task.status === "failed" || task.status === "interrupted") : activeTasks.some(task => task.status === "failed" || task.status === "interrupted")
@ -365,7 +372,7 @@ export function TVstarPanel({
} }
setError(""); setError("");
if (active) { await reviseSession(active, input); return; } if (active) { await reviseSession(active, input); return; }
const directShots = mode === "script" || mode === "storyboard"; const directShots = mode === "script" || mode === "storyboard" || mode === "full";
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(input); const allowAutoRun = autoGenerate && tvstarAllowsGeneration(input);
setBusy(mode === "edit" ? "submit" : directShots ? "shots" : "plan"); setBusy(mode === "edit" ? "submit" : directShots ? "shots" : "plan");
let item: Session = { let item: Session = {
@ -396,13 +403,14 @@ export function TVstarPanel({
return; return;
} }
const data = await runAnalysis(directShots ? "story-script" : "agent-plan", input, item.references || [], item.nodeContexts || []); const data = await runAnalysis(directShots ? "story-script" : "agent-plan", input, item.references || [], item.nodeContexts || []);
const shots = directShots && Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : undefined; const shots = directShots ? productionShots(data.analysisRows) : undefined;
if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试"); if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试");
item = { item = {
...item, ...item,
plan: directShots ? undefined : { plan: directShots && mode !== "full" ? undefined : {
summary: String(data.summary || ""), summary: String(data.summary || ""),
agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String) : [], agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String)
: Array.isArray(data.recommendedNextSteps) ? data.recommendedNextSteps.map(String) : [],
deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [], deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [],
recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [], recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [],
model: typeof data.model === "string" ? data.model : undefined, model: typeof data.model === "string" ? data.model : undefined,
@ -424,9 +432,9 @@ export function TVstarPanel({
} }
if (mode === "full") { if (mode === "full") {
setBusy("shots"); setBusy("shots");
const storyboard = await runAnalysis("story-script", [input, item.plan?.summary, const storyboard = item.shots?.length ? { analysisRows: item.shots } : await runAnalysis("story-script", [input, item.plan?.summary,
item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []); item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []);
const generated = Array.isArray(storyboard.analysisRows) ? storyboard.analysisRows.filter(isShot).slice(0, 12) : []; const generated = productionShots(storyboard.analysisRows);
if (!generated.length) throw new Error("模型没有返回有效分镜,请重试"); if (!generated.length) throw new Error("模型没有返回有效分镜,请重试");
item = { ...item, shots: generated, pendingStage: allowAutoRun ? "submit" : undefined, messages: [ item = { ...item, shots: generated, pendingStage: allowAutoRun ? "submit" : undefined, messages: [
...(item.messages || []), message("assistant", allowAutoRun ...(item.messages || []), message("assistant", allowAutoRun
@ -510,7 +518,7 @@ export function TVstarPanel({
return; return;
} }
const data = await runAnalysis("story-script", prompt, mergedRefs, mergedContexts); const data = await runAnalysis("story-script", prompt, mergedRefs, mergedContexts);
const shots = Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : []; const shots = productionShots(data.analysisRows);
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试修改要求"); if (!shots.length) throw new Error("模型没有返回有效分镜,请重试修改要求");
let updated: Session = { ...pending, shots, runSubmitted: false, pendingStage: session.mode === "full" && allowAutoRun ? "submit" : undefined, let updated: Session = { ...pending, shots, runSubmitted: false, pendingStage: session.mode === "full" && allowAutoRun ? "submit" : undefined,
messages: [...(pending.messages || []), message("assistant", `已按新要求更新 ${shots.length} 个分镜。`)] }; messages: [...(pending.messages || []), message("assistant", `已按新要求更新 ${shots.length} 个分镜。`)] };
@ -545,7 +553,7 @@ export function TVstarPanel({
try { try {
const data = await runAnalysis("story-script", [active.goal, active.plan?.summary, const data = await runAnalysis("story-script", [active.goal, active.plan?.summary,
active.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), active.references || [], active.nodeContexts || []); active.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), active.references || [], active.nodeContexts || []);
const shots = Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : []; const shots = productionShots(data.analysisRows);
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试"); if (!shots.length) throw new Error("模型没有返回有效分镜,请重试");
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal; const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(latest); const allowAutoRun = autoGenerate && tvstarAllowsGeneration(latest);
@ -787,7 +795,7 @@ export function TVstarPanel({
].map(([label, action]) => <button className="flex h-10 w-full items-center justify-between rounded-xl px-3 text-left text-[13px] text-white/60 hover:bg-white/5 hover:text-white" key={label as string} onClick={action as () => void}><span><Sparkles className="mr-2 inline size-3.5" />{label as string}</span><span>→</span></button>)}</div> ].map(([label, action]) => <button className="flex h-10 w-full items-center justify-between rounded-xl px-3 text-left text-[13px] text-white/60 hover:bg-white/5 hover:text-white" key={label as string} onClick={action as () => void}><span><Sparkles className="mr-2 inline size-3.5" />{label as string}</span><span>→</span></button>)}</div>
</div> : <div className="space-y-4 pt-4"> </div> : <div className="space-y-4 pt-4">
{(active.messages || []).length ? <div aria-label="TVstar 对话记录" className="space-y-2">{active.messages?.map(item => <p className={`max-w-[92%] whitespace-pre-wrap rounded-xl px-3 py-2 text-xs leading-5 ${item.role === "user" ? "ml-auto bg-[#343b48] text-white" : "bg-[#292929] text-white/70"}`} key={item.id}>{item.content}</p>)}</div> : null} {(active.messages || []).length ? <div aria-label="TVstar 对话记录" className="space-y-2">{active.messages?.map(item => <p className={`max-w-[92%] whitespace-pre-wrap rounded-xl px-3 py-2 text-xs leading-5 ${item.role === "user" ? "ml-auto bg-[#343b48] text-white" : "bg-[#292929] text-white/70"}`} key={item.id}>{item.content}</p>)}</div> : null}
{active.groupId ? <div aria-label="TVstar 制作进度" className={`${panel} p-3`}><p className="text-xs text-cyan-200" role="status">{productionStatus}</p>{!active.preparedOnly ? <p className="mt-1 text-[11px] text-white/45">{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成</p> : <p className="mt-1 text-[11px] text-white/45">画布节点已保存,尚未启动模型或扣费</p>}{finalUrl ? <video className="mt-3 w-full rounded-lg bg-black" controls playsInline preload="metadata" src={finalUrl} /> : null}{finalTask?.error ? <p className="mt-2 text-xs text-red-200">{finalTask.error}</p> : null}</div> : null} {active.groupId ? <div aria-label="TVstar 制作进度" className={`${panel} p-3`}><p className="text-xs text-cyan-200" role="status">{productionStatus}</p>{!active.preparedOnly ? <p className="mt-1 text-[11px] text-white/45">{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成</p> : <p className="mt-1 text-[11px] text-white/45">画布节点已保存,尚未启动模型或扣费</p>}{finalUrl ? <video className="mt-3 w-full rounded-lg bg-black" controls playsInline preload="metadata" src={finalUrl} /> : null}{failedTask?.error ? <p className="mt-2 text-xs text-red-200" role="alert">{failedTask.request.node.title}:{failedTask.error}</p> : null}</div> : null}
<div className={`${panel} p-3`}><p className="mb-2 text-[11px] text-white/40">创作目标</p><p className="whitespace-pre-wrap text-[13px] leading-6">{active.goal}</p></div> <div className={`${panel} p-3`}><p className="mb-2 text-[11px] text-white/40">创作目标</p><p className="whitespace-pre-wrap text-[13px] leading-6">{active.goal}</p></div>
{active.plan ? <div className={`${panel} p-4`}><div className="mb-3 flex items-center gap-2 text-[13px] font-medium"><Sparkles className="size-4 text-cyan-300" />创作规划</div><p className="mb-3 text-[13px] leading-6 text-white/75">{active.plan.summary}</p><ol className="space-y-2">{active.plan.agentPlan.map((step, index) => <li className="flex gap-2 text-xs leading-5 text-white/70" key={`${index}-${step}`}><span className="text-cyan-300">{String(index + 1).padStart(2, "0")}</span><span>{step}</span></li>)}</ol>{active.plan.deliverables.length ? <p className="mt-4 border-t border-white/10 pt-3 text-[11px] text-white/45">交付物 · {active.plan.deliverables.join(" / ")}</p> : null}</div> : null} {active.plan ? <div className={`${panel} p-4`}><div className="mb-3 flex items-center gap-2 text-[13px] font-medium"><Sparkles className="size-4 text-cyan-300" />创作规划</div><p className="mb-3 text-[13px] leading-6 text-white/75">{active.plan.summary}</p><ol className="space-y-2">{active.plan.agentPlan.map((step, index) => <li className="flex gap-2 text-xs leading-5 text-white/70" key={`${index}-${step}`}><span className="text-cyan-300">{String(index + 1).padStart(2, "0")}</span><span>{step}</span></li>)}</ol>{active.plan.deliverables.length ? <p className="mt-4 border-t border-white/10 pt-3 text-[11px] text-white/45">交付物 · {active.plan.deliverables.join(" / ")}</p> : null}</div> : null}
{active.shots?.length ? <div className="space-y-2"><h3 className="text-[13px] font-medium">分镜脚本 · {active.shots.length} 镜头</h3>{active.shots.map((shot, index) => <article className={`${panel} p-3`} key={`${index}-${shot.shot}`}><div className="mb-1 flex items-center justify-between text-xs"><span className="text-white/90">镜头 {shot.shot} · {shot.scene || "场景"}</span><span className="text-white/40">{shot.duration}</span></div><p className="text-xs leading-5 text-white/60">{shot.sceneDescription}</p><p className="mt-2 text-[11px] text-cyan-200/70">{shot.shotSize} · {shot.cameraMovement}</p></article>)}</div> : null} {active.shots?.length ? <div className="space-y-2"><h3 className="text-[13px] font-medium">分镜脚本 · {active.shots.length} 镜头</h3>{active.shots.map((shot, index) => <article className={`${panel} p-3`} key={`${index}-${shot.shot}`}><div className="mb-1 flex items-center justify-between text-xs"><span className="text-white/90">镜头 {shot.shot} · {shot.scene || "场景"}</span><span className="text-white/40">{shot.duration}</span></div><p className="text-xs leading-5 text-white/60">{shot.sceneDescription}</p><p className="mt-2 text-[11px] text-cyan-200/70">{shot.shotSize} · {shot.cameraMovement}</p></article>)}</div> : null}

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;

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
"use client"; "use client";
import "@/public/vendor/drawnix.css";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@ -7,6 +9,7 @@ type FlowBoardClientProps = {
siteName: string; siteName: string;
siteLogo: string | null; siteLogo: string | null;
siteLogoDark: string | null; siteLogoDark: string | null;
previewMode?: boolean;
}; };
const DrawnixWorkspace = dynamic( const DrawnixWorkspace = dynamic(

View File

@ -0,0 +1,206 @@
import {
addSelectedElement,
BoardTransforms,
clearSelectedElement,
PlaitBoard,
type PlaitElement,
PlaitHistoryBoard,
PlaitPointerType,
type Point,
Transforms,
} from "@plait/core";
import {
ArrowLineMarkerType,
ArrowLineShape,
BasicShapes,
createArrowLineElement,
createGeometryElementWithoutText,
} from "@plait/draw";
import { getRectangleByNode, MindElement } from "@plait/mind";
import {
type AnnotatedElement,
getBoundaryPoints,
getRelationshipPoints,
isMindAnnotation,
type MindAnnotation,
type TopicRectangle,
} from "@/lib/flow-board/mind-annotations";
function getTopics(board: PlaitBoard) {
const topics = new Map<
string,
{ element: MindElement; visible: MindElement }
>();
const visit = (elements: PlaitElement[], collapsedAncestor?: MindElement) => {
for (const element of elements) {
if (!MindElement.isMindElement(board, element)) continue;
topics.set(element.id, {
element,
visible: collapsedAncestor || element,
});
visit(
element.children || [],
collapsedAncestor || (element.isCollapsed ? element : undefined)
);
}
};
visit(board.children);
return topics;
}
function rectangle(element: MindElement): TopicRectangle | null {
const node = MindElement.getNode(element);
return node ? getRectangleByNode(node) : null;
}
function annotationPoints(
board: PlaitBoard,
annotation: MindAnnotation
): Point[] | null {
const topics = getTopics(board);
if (annotation.kind === "relationship") {
const source = topics.get(annotation.sourceId);
const target = topics.get(annotation.targetId);
if (!source || !target) return null;
const a = rectangle(source.visible);
const b = rectangle(target.visible);
return a && b ? getRelationshipPoints(a, b) : null;
}
const rectangles: TopicRectangle[] = [];
const seen = new Set<string>();
const collect = (element: MindElement) => {
if (seen.has(element.id)) return;
seen.add(element.id);
const rect = rectangle(element);
if (rect) rectangles.push(rect);
if (!element.isCollapsed) element.children?.forEach(collect);
};
for (const id of annotation.topicIds) {
const topic = topics.get(id);
if (topic) collect(topic.visible);
}
return getBoundaryPoints(rectangles, annotation.padding);
}
export function insertMindAnnotation(
board: PlaitBoard,
annotation: MindAnnotation
) {
const points = annotationPoints(board, annotation);
if (!points) return null;
const endpoints: [Point, Point] = [points[0], points[points.length - 1]];
const label = { type: "paragraph", children: [{ text: "联系" }] };
const shape =
annotation.kind === "relationship"
? createArrowLineElement(
ArrowLineShape.curve,
endpoints,
{ marker: ArrowLineMarkerType.none },
{ marker: ArrowLineMarkerType.arrow },
[{ position: 0.5, text: label }],
{ strokeColor: "#64748b", strokeWidth: 2 }
)
: createGeometryElementWithoutText(
BasicShapes.roundRectangle,
endpoints,
{
fill: "transparent",
strokeColor: "#8b9bb8",
strokeWidth: 2,
}
);
const element: AnnotatedElement = {
...shape,
points,
mindAnnotation: annotation,
};
PlaitHistoryBoard.withNewBatch(board, () => {
// Boundaries sit behind topics, so clicking within them still selects topics.
Transforms.insertNode(board, element, [
annotation.kind === "boundary" ? 0 : board.children.length,
]);
});
BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
clearSelectedElement(board);
addSelectedElement(board, element);
return element;
}
const installed = new WeakSet<PlaitBoard>();
/** Keep native drawable shapes linked to topic IDs, using the existing undo stack. */
export function withMindAnnotations(board: PlaitBoard) {
if (installed.has(board)) return;
installed.add(board);
const { afterChange, undo, redo, apply } = board;
board.apply = (operation) => {
// Plait keeps a hover reference after deleting or collapsing a topic. Clear it
// while the node's DOM still exists, otherwise the next pointerLeave throws.
if (
operation.type === "remove_node" ||
(operation.type === "set_node" &&
("isCollapsed" in operation.newProperties ||
"children" in operation.newProperties))
) {
board.pointerLeave(new PointerEvent("pointerleave"));
}
apply(operation);
};
let pending = false;
let replaying = true;
const synchronize = () => {
pending = false;
const replay = replaying;
replaying = false;
const topics = getTopics(board);
const changes = board.children
.filter(isMindAnnotation)
.flatMap((element) => {
const annotation = element.mindAnnotation;
const missing =
annotation.kind === "relationship"
? !topics.has(annotation.sourceId) ||
!topics.has(annotation.targetId)
: !annotation.topicIds.some((id) => topics.has(id));
const points = missing ? null : annotationPoints(board, annotation);
if (
!missing &&
(!points || JSON.stringify(points) === JSON.stringify(element.points))
)
return [];
return [{ element, points, missing }];
});
if (!changes.length) return;
const update = () => {
for (const { element, points, missing } of changes) {
const index = board.children.findIndex(
(child) => child.id === element.id
);
if (index < 0) continue;
if (missing) Transforms.removeNode(board, [index]);
else if (points) Transforms.setNode(board, { points }, [index]);
}
};
if (replay) PlaitHistoryBoard.withoutSaving(board, update);
else PlaitHistoryBoard.withMerging(board, update);
};
board.undo = () => {
replaying = board.history.undos.length > 0;
undo();
};
board.redo = () => {
replaying = board.history.redos.length > 0;
redo();
};
board.afterChange = () => {
afterChange();
if (
!pending &&
board.operations.some((op) => op.type !== "set_selection")
) {
pending = true;
queueMicrotask(synchronize);
}
};
queueMicrotask(synchronize);
}

View File

@ -0,0 +1,30 @@
import type { PlaitBoard } from "@plait/core";
import type { withEmoji } from "@plait/mind";
import { getXmindMarker } from "@/lib/flow-board/xmind-markers";
/** Use the native marker artwork in Plait's existing emoji geometry and hit targets. */
export function withXmindMarkers(board: PlaitBoard) {
const markerBoard = board as ReturnType<typeof withEmoji>;
const renderEmoji = markerBoard.renderEmoji;
markerBoard.renderEmoji = (container, props) => {
const marker = getXmindMarker(props.emojiItem.name);
if (!marker) return renderEmoji(container, props);
const image = document.createElement("img");
image.className = "mind-node-emoji xmind-topic-marker";
image.draggable = false;
let currentProps = props;
const update = (next: Partial<typeof props>) => {
currentProps = { ...currentProps, ...next };
const current = getXmindMarker(currentProps.emojiItem.name);
image.src = current?.asset || marker.asset;
image.alt = current?.name || marker.name;
image.dataset.markerId = current?.id || marker.id;
image.style.width = `${currentProps.fontSize}px`;
image.style.height = `${currentProps.fontSize}px`;
};
update(props);
container.append(image);
return { update, destroy: () => image.remove() };
};
return board;
}

View File

@ -0,0 +1,506 @@
import type { PlaitBoard } from "@plait/core";
import {
MindElement,
MindNodeComponent,
PlaitMind,
PlaitMindComponent,
getBranchColorByMindElement,
getBranchWidthByMindElement,
type MindNode,
} from "@plait/mind";
import {
arrangeSpecialMind,
isSpecialMindLayout,
isSummaryNode,
specialNodeRect,
specialSubtreeBounds,
} from "@/lib/flow-board/mind-special-layout";
import { measureXmindTopics } from "@/lib/flow-board/xmind-typography";
const ns = "http://www.w3.org/2000/svg";
type TableCell = {
x: number;
y: number;
width: number;
height: number;
depth: number;
branch: number;
};
type TableGrid = {
kind: "spreadsheet";
x: number;
y: number;
width: number;
height: number;
columns: string[];
headerHeight: number;
columnHeaderHeight: number;
rowHeaderWidth: number;
columnWidth: number;
rows: { y: number; height: number; branch: number }[];
};
const tableLayouts = new Set(["treeTable", "treeTableSide", "spreadsheet"]);
const tableTopicState = new WeakMap<
SVGGElement,
{
foreignObject: SVGForeignObjectElement;
x: string | null;
y: string | null;
width: string | null;
height: string | null;
textStyle: string | null;
contentStyle: string | null;
shape: SVGElement | null;
shapeVisibility: string;
}
>();
function ensureTreeTableStyles() {
const id = "xmind-tree-table-topic-styles";
if (document.getElementById(id)) return;
const style = document.createElement("style");
style.id = id;
style.textContent = `
[data-special-table-topic] .plait-text-container {
align-items: center !important;
display: flex !important;
height: 100% !important;
justify-content: center !important;
overflow-wrap: normal !important;
text-align: center !important;
white-space: nowrap !important;
width: 100% !important;
}
[data-special-table-topic] .plait-text-container > [data-slate-node="element"] {
text-align: center !important;
white-space: nowrap !important;
width: 100% !important;
}
[data-special-table-topic="0"] .plait-text-container {
color: #fff !important;
}
`;
document.head.append(style);
}
function frameTreeTableTopic(
component: MindNodeComponent,
node: MindNode,
enabled: boolean
) {
const host = component.getElementG();
const saved = tableTopicState.get(host);
if (!enabled) {
delete host.dataset.specialTableTopic;
if (!saved) return;
for (const [name, value] of [
["x", saved.x],
["y", saved.y],
["width", saved.width],
["height", saved.height],
] as const) {
if (value === null) saved.foreignObject.removeAttribute(name);
else saved.foreignObject.setAttribute(name, value);
}
const text = saved.foreignObject.firstElementChild as HTMLElement | null;
if (text) {
if (saved.textStyle === null) text.removeAttribute("style");
else text.setAttribute("style", saved.textStyle);
const content = text.firstElementChild as HTMLElement | null;
if (content) {
if (saved.contentStyle === null) content.removeAttribute("style");
else content.setAttribute("style", saved.contentStyle);
}
}
if (saved.shape) saved.shape.style.visibility = saved.shapeVisibility;
tableTopicState.delete(host);
return;
}
const cell = (node as MindNode & { tableCell?: TableCell }).tableCell;
const foreignObject = host.querySelector<SVGForeignObjectElement>(
":scope > .text foreignObject"
);
if (!cell || !foreignObject) return;
ensureTreeTableStyles();
host.dataset.specialTableTopic = String(cell.depth);
const currentShape = (
component.nodeShapeGenerator as unknown as { g?: SVGGElement }
).g;
if (!saved) {
tableTopicState.set(host, {
foreignObject,
x: foreignObject.getAttribute("x"),
y: foreignObject.getAttribute("y"),
width: foreignObject.getAttribute("width"),
height: foreignObject.getAttribute("height"),
textStyle: foreignObject.firstElementChild?.getAttribute("style") ?? null,
contentStyle:
foreignObject.firstElementChild?.firstElementChild?.getAttribute(
"style"
) ?? null,
shape: currentShape || null,
shapeVisibility: currentShape?.style.visibility || "",
});
} else if (currentShape && currentShape !== saved.shape) {
saved.shape = currentShape;
saved.shapeVisibility = currentShape.style.visibility || "";
}
if (currentShape) currentShape.style.visibility = "hidden";
foreignObject.setAttribute("x", String(cell.x + 14));
foreignObject.setAttribute("y", String(cell.y + 8));
foreignObject.setAttribute("width", String(Math.max(1, cell.width - 28)));
foreignObject.setAttribute("height", String(Math.max(1, cell.height - 16)));
const text = foreignObject.firstElementChild as HTMLElement | null;
if (text) {
text.style.alignItems = "center";
text.style.color = cell.depth === 0 ? "#ffffff" : "#17252a";
text.style.display = "flex";
text.style.height = "100%";
text.style.justifyContent = "center";
text.style.overflowWrap = "normal";
text.style.textAlign = "center";
text.style.whiteSpace = "nowrap";
text.style.width = "100%";
const content = text.firstElementChild as HTMLElement | null;
if (content) {
content.style.alignItems = "center";
content.style.color = cell.depth === 0 ? "#ffffff" : "#17252a";
content.style.display = "flex";
content.style.height = "100%";
content.style.justifyContent = "center";
content.style.textAlign = "center";
content.style.whiteSpace = "nowrap";
content.style.width = "100%";
}
}
}
function specialLink(component: MindNodeComponent) {
const node = component.node;
let root: MindNode = node;
while (root.parent) root = root.parent;
const kind = root.origin.specialLayout;
const tableLayout = tableLayouts.has(String(kind));
frameTreeTableTopic(component, node, tableLayout);
if (tableLayout)
requestAnimationFrame(() => {
if (component.getElementG().isConnected)
frameTreeTableTopic(component, node, true);
});
if (!isSpecialMindLayout(kind) || isSummaryNode(node)) return false;
if (
kind !== "brace" &&
kind !== "verticalTimeline" &&
!tableLayout &&
node !== root &&
node.parent !== root
)
return false;
component.linkLineG?.remove();
const group = document.createElementNS(ns, "g");
group.dataset.specialStructure = kind;
const color =
getBranchColorByMindElement(component.board, component.element) ||
"#64748b";
const width =
getBranchWidthByMindElement(component.board, component.element) || 2;
const path = (d: string) => {
const shape = document.createElementNS(ns, "path");
shape.setAttribute("d", d);
shape.setAttribute("fill", "none");
shape.setAttribute("stroke", color);
shape.setAttribute("stroke-width", String(width));
group.append(shape);
};
const blendWithWhite = (value: string, amount: number) => {
const match = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (!match) return value;
const channels = match.slice(1).map((channel) => Number.parseInt(channel, 16));
return `#${channels
.map((channel) =>
Math.round(channel + (255 - channel) * amount)
.toString(16)
.padStart(2, "0")
)
.join("")}`;
};
const solidColor = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback;
const short = value.match(/^#([\da-f])([\da-f])([\da-f])$/i);
if (short) return `#${short.slice(1).map((channel) => channel + channel).join("")}`;
if (/^#[\da-f]{6}$/i.test(value)) return value;
const rgba = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (!rgba) return fallback;
const alpha = Number.parseInt(rgba[4], 16) / 255;
return `#${rgba
.slice(1, 4)
.map((channel) =>
Math.round(Number.parseInt(channel, 16) * alpha + 255 * (1 - alpha))
.toString(16)
.padStart(2, "0")
)
.join("")}`;
};
const rootRect = specialNodeRect(root);
const axisY = rootRect.y + rootRect.height / 2;
const branches = root.children.filter((child) => !isSummaryNode(child));
if (kind === "treeTable" || kind === "treeTableSide") {
if (node === root) {
group.setAttribute("pointer-events", "none");
const palette = ["#ffbc9f", "#d8ac8f", "#83c5be", "#f3c78e", "#8ab7d9"];
const tableNodes: MindNode[] = [];
const collect = (item: MindNode) => {
if (!isSummaryNode(item)) tableNodes.push(item);
item.children.forEach(collect);
};
collect(root);
const rootFill = solidColor(root.origin.fill, "#006d77");
tableNodes.forEach((item) => {
const cell = (item as typeof item & { tableCell?: TableCell }).tableCell;
if (!cell) return;
const rectangle = document.createElementNS(ns, "rect");
rectangle.dataset.specialTableCell = item.origin.id || "topic";
rectangle.dataset.depth = String(cell.depth);
rectangle.setAttribute("x", String(cell.x));
rectangle.setAttribute("y", String(cell.y));
rectangle.setAttribute("width", String(cell.width));
rectangle.setAttribute("height", String(cell.height));
const branchColor = palette[cell.branch % palette.length] || palette[0];
const explicit = solidColor(item.origin.fill, "");
rectangle.setAttribute(
"fill",
cell.depth === 0
? rootFill
: explicit ||
blendWithWhite(branchColor, cell.depth === 1 ? 0.08 : 0.72)
);
rectangle.setAttribute("stroke", rootFill);
rectangle.setAttribute("stroke-width", "2");
rectangle.setAttribute("vector-effect", "non-scaling-stroke");
group.append(rectangle);
});
group.dataset.specialTable = kind;
} else {
group.dataset.specialConnector = kind;
}
} else if (kind === "spreadsheet") {
if (node === root) {
group.setAttribute("pointer-events", "none");
const grid = (root as MindNode & { tableGrid?: TableGrid }).tableGrid;
if (grid) {
const palette = ["#ff9d8e", "#f6c75d", "#70c8af", "#68a7dd", "#9c8bd8"];
const rootFill = solidColor(root.origin.fill, "#1f2766");
const rectangle = (
x: number,
y: number,
width: number,
height: number,
fill: string,
dataset?: Record<string, string>
) => {
const rect = document.createElementNS(ns, "rect");
rect.setAttribute("x", String(x));
rect.setAttribute("y", String(y));
rect.setAttribute("width", String(width));
rect.setAttribute("height", String(height));
rect.setAttribute("fill", fill);
rect.setAttribute("stroke", rootFill);
rect.setAttribute("stroke-width", "2");
rect.setAttribute("vector-effect", "non-scaling-stroke");
Object.assign(rect.dataset, dataset);
group.append(rect);
};
rectangle(grid.x, grid.y, grid.width, grid.headerHeight, rootFill, {
specialTableCell: root.origin.id || "topic",
depth: "0",
});
rectangle(
grid.x,
grid.y + grid.headerHeight,
grid.rowHeaderWidth,
grid.columnHeaderHeight,
rootFill
);
grid.columns.forEach((column, index) => {
const x = grid.x + grid.rowHeaderWidth + index * grid.columnWidth;
const y = grid.y + grid.headerHeight;
rectangle(x, y, grid.columnWidth, grid.columnHeaderHeight, rootFill, {
specialMatrixColumn: column,
});
const label = document.createElementNS(ns, "text");
label.dataset.specialMatrixColumnLabel = column;
label.setAttribute("x", String(x + grid.columnWidth / 2));
label.setAttribute("y", String(y + grid.columnHeaderHeight / 2 + 5));
label.setAttribute("fill", "#ffffff");
label.setAttribute("font-size", "14");
label.setAttribute("font-weight", "600");
label.setAttribute("text-anchor", "middle");
label.textContent = column;
group.append(label);
});
grid.rows.forEach((row, rowIndex) => {
const rowNode = branches[rowIndex];
const rowColor = solidColor(
rowNode?.origin.fill,
palette[rowIndex % palette.length]
);
rectangle(grid.x, row.y, grid.rowHeaderWidth, row.height, rowColor, {
specialTableCell: rowNode?.origin.id || `row-${rowIndex}`,
depth: "1",
});
grid.columns.forEach((column, columnIndex) =>
rectangle(
grid.x + grid.rowHeaderWidth + columnIndex * grid.columnWidth,
row.y,
grid.columnWidth,
row.height,
"#ffffff",
{ specialMatrixCell: `${rowIndex}:${columnIndex}` }
)
);
rowNode?.children
.filter((child) => !isSummaryNode(child))
.forEach((entry) => {
const cell = (entry as typeof entry & { tableCell?: TableCell }).tableCell;
if (!cell) return;
rectangle(cell.x, cell.y, cell.width, cell.height, blendWithWhite(rowColor, 0.2), {
specialMatrixEntry: entry.origin.id || "topic",
});
});
});
group.dataset.specialTable = kind;
}
} else group.dataset.specialConnector = kind;
} else if (kind === "verticalTimeline") {
const axisX = rootRect.x + rootRect.width / 2;
if (node === root) {
if (branches.length) {
const end = Math.max(
...branches.map((child) => {
const rect = specialNodeRect(child);
return rect.y + rect.height / 2;
})
);
path(`M${axisX} ${rootRect.y + rootRect.height}V${end}`);
group.dataset.specialAxis = kind;
}
} else {
const rect = specialNodeRect(node);
const right = rect.x > axisX;
const endX = right ? rect.x : rect.x + rect.width;
const endY = rect.y + rect.height / 2;
if (node.parent === root) {
path(`M${axisX} ${endY}H${endX}`);
} else if (node.parent) {
const parent = specialNodeRect(node.parent);
const startX = right ? parent.x + parent.width : parent.x;
const startY = parent.y + parent.height / 2;
const midX = (startX + endX) / 2;
path(
`M${startX} ${startY}C${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`
);
}
group.dataset.specialConnector = kind;
}
} else if (kind === "brace") {
const parent = node.parent;
const siblings = parent?.children.filter((child) => !isSummaryNode(child));
if (parent && siblings?.[0] === node) {
const owner = specialNodeRect(parent);
const rects = siblings.map(specialNodeRect);
const top = Math.min(...rects.map((rect) => rect.y + rect.height / 2));
const bottom = Math.max(...rects.map((rect) => rect.y + rect.height / 2));
const middle = (top + bottom) / 2;
const right = Math.min(...rects.map((rect) => rect.x)) - 10;
const x = right - 18;
const left = Math.max(owner.x + owner.width + 6, x - 18);
const radius = Math.min(18, (bottom - top) / 4);
path(
top === bottom
? `M${left} ${top}H${right}`
: `M${right} ${top}Q${x} ${top} ${x} ${top + radius}V${middle - radius}Q${x} ${middle} ${left} ${middle}Q${x} ${middle} ${x} ${middle + radius}V${bottom - radius}Q${x} ${bottom} ${right} ${bottom}`
);
}
} else if (node === root) {
if (branches.length) {
const end =
Math.max(
...branches.map((child) => {
const rect = specialSubtreeBounds(child);
return rect.x + rect.width;
})
) + 32;
path(`M${rootRect.x + rootRect.width} ${axisY}H${end}`);
group.dataset.specialAxis = kind;
}
} else {
const rect = specialNodeRect(node);
const above = rect.y + rect.height / 2 < axisY;
const endX = rect.x + rect.width / 2;
const startX = endX - (kind === "fishbone" ? 48 : 0);
path(
`M${startX} ${axisY}L${endX} ${above ? rect.y + rect.height : rect.y}`
);
group.dataset.specialConnector = kind;
if (kind === "timeline") {
const circle = document.createElementNS(ns, "circle");
circle.setAttribute("cx", String(startX));
circle.setAttribute("cy", String(axisY));
circle.setAttribute("r", "4");
circle.setAttribute("fill", color);
group.append(circle);
}
}
component.linkLineG = group;
const host = component.getElementG();
if (tableLayout && node === root && host.parentElement)
host.parentElement.insertBefore(group, host.parentElement.firstChild);
else host.append(group);
return true;
}
class SpecialMindRoot extends PlaitMindComponent {
override updateMindLayout(element = this.element) {
measureXmindTopics(this.board, element);
super.updateMindLayout(element);
if (isSpecialMindLayout(element.specialLayout))
arrangeSpecialMind(this.root, element.specialLayout);
}
override drawLink() {
if (!specialLink(this)) {
this.linkLineG?.remove();
this.linkLineG = undefined;
super.drawLink();
}
}
}
class SpecialMindNode extends MindNodeComponent {
private lastSpecialLayout: unknown;
private rootLayout() {
let root = this.node;
while (root.parent) root = root.parent;
return root.origin.specialLayout;
}
override onContextChanged(
...args: Parameters<MindNodeComponent["onContextChanged"]>
) {
super.onContextChanged(...args);
// A structure switch can leave descendant positions and parent identity unchanged.
if (this.rootLayout() !== this.lastSpecialLayout) this.drawLink();
}
override drawLink() {
if (!specialLink(this)) super.drawLink();
this.lastSpecialLayout = this.rootLayout();
}
}
const installed = new WeakSet<PlaitBoard>();
export function withSpecialMindLayouts(board: PlaitBoard) {
if (installed.has(board)) return board;
installed.add(board);
const drawElement = board.drawElement;
board.drawElement = (context) => {
if (PlaitMind.isMind(context.element)) return SpecialMindRoot;
if (MindElement.isMindElement(board, context.element))
return SpecialMindNode;
return drawElement(context);
};
return board;
}

View File

@ -0,0 +1,129 @@
"use client";
import { Trash2, X } from "lucide-react";
import type { AnnotatedElement } from "@/lib/flow-board/mind-annotations";
export function XmindAnnotationPanel({
element,
onChange,
onDelete,
onClose,
}: {
element: AnnotatedElement;
onChange: (patch: Record<string, unknown>) => void;
onDelete: () => void;
onClose: () => void;
}) {
const relationship = element.mindAnnotation.kind === "relationship";
const label = (element.texts?.[0]?.text?.children || [])
.map((leaf: { text?: string }) => leaf.text || "")
.join("");
return (
<aside
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
data-flow-board-shell-control="true"
>
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
<h2 className="text-sm font-semibold">
{relationship ? "联系格式" : "外框格式"}
</h2>
<button aria-label="关闭格式面板" onClick={onClose} type="button">
<X size={16} />
</button>
</div>
<div className="space-y-5 p-4 text-xs">
{relationship ? (
<label className="block space-y-2">
<span>联系文字</span>
<input
className="h-9 w-full rounded border border-[#dfe2e7] px-2 text-sm"
value={label}
onChange={(event) =>
onChange({
texts: event.target.value
? [
{
position: 0.5,
text: {
type: "paragraph",
children: [{ text: event.target.value }],
},
},
]
: [],
})
}
/>
</label>
) : (
<label className="flex items-center justify-between">
<span>外框留白</span>
<select
aria-label="外框留白"
className="h-8 rounded border border-[#dfe2e7] px-2"
value={
element.mindAnnotation.kind === "boundary"
? element.mindAnnotation.padding
: 18
}
onChange={(event) =>
onChange({
mindAnnotation: {
...element.mindAnnotation,
padding: Number(event.target.value),
},
})
}
>
{[12, 18, 24, 32].map((value) => (
<option key={value} value={value}>
{value} px
</option>
))}
</select>
</label>
)}
<label className="flex items-center justify-between">
<span>线条颜色</span>
<input
aria-label="联系或外框颜色"
className="h-7 w-12 cursor-pointer"
type="color"
value={element.strokeColor || "#64748b"}
onChange={(event) => onChange({ strokeColor: event.target.value })}
/>
</label>
<label className="flex items-center justify-between">
<span>线条粗细</span>
<select
aria-label="联系或外框粗细"
className="h-8 rounded border border-[#dfe2e7] px-2"
value={element.strokeWidth || 2}
onChange={(event) =>
onChange({ strokeWidth: Number(event.target.value) })
}
>
{[1, 2, 3, 4].map((value) => (
<option key={value} value={value}>
{value} pt
</option>
))}
</select>
</label>
<p className="leading-5 text-[#8b919c]">
{relationship
? "联系线会跟随主题移动。双击线上的文字也可以编辑。"
: "外框包含所选主题及其展开的子主题,随分支布局自动调整。"}
</p>
<button
className="flex h-9 w-full items-center justify-center gap-2 rounded border border-[#e5e7eb] text-[#b44343] hover:bg-red-50"
onClick={onDelete}
type="button"
>
<Trash2 size={14} />
删除{relationship ? "联系" : "外框"}
</button>
</div>
</aside>
);
}

View File

@ -0,0 +1,70 @@
"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { ChevronDown, type LucideIcon } from "lucide-react";
export type XmindCommand = {
label: string;
icon?: LucideIcon;
shortcut?: string;
disabled?: boolean;
checked?: boolean;
separator?: boolean;
run: () => void;
};
export function XmindCommandMenu({ label, children, commands, className = "", align = "left" }: {
label: string;
children: ReactNode;
commands: XmindCommand[];
className?: string;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const host = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const dismiss = (event: PointerEvent) => {
if (!host.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [open]);
return (
<div className={`xmind-menu-host ${className}`} ref={host} data-flow-board-shell-control="true">
<button type="button" ref={trigger} aria-label={label} aria-haspopup="menu" aria-expanded={open}
className="xmind-menu-trigger" onClick={() => setOpen(value => !value)}
onKeyDown={event => {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
requestAnimationFrame(() => host.current?.querySelector<HTMLButtonElement>("[role=menuitem]:not(:disabled)")?.focus());
}
}}>
{children}<ChevronDown size={12} />
</button>
{open ? <div role="menu" aria-label={label} className={`xmind-command-menu ${align === "right" ? "xmind-menu-right" : ""}`}
onKeyDown={event => {
if (event.key === "Escape") {
event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus();
}
const items = Array.from(host.current?.querySelectorAll<HTMLButtonElement>("[role=menuitem]:not(:disabled)") || []);
const index = items.indexOf(document.activeElement as HTMLButtonElement);
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items[next]?.focus();
}
}}>
{commands.map(({ label: title, icon: Icon, shortcut, disabled, checked, separator, run }) => <div key={title}>
{separator ? <hr className="xmind-menu-separator" /> : null}
<button type="button" role="menuitem" disabled={disabled} className="xmind-command" onClick={() => { setOpen(false); run(); }}>
{Icon ? <Icon size={16} /> : <span className="xmind-command-check">{checked ? "✓" : ""}</span>}
<span>{title}</span>{shortcut ? <kbd>{shortcut}</kbd> : null}
</button>
</div>)}
</div> : null}
</div>
);
}

View File

@ -0,0 +1,470 @@
"use client";
import {
CalendarDays,
ChartGantt,
ArrowLeft,
ArrowUpRight,
Bold,
ChevronDown,
Copy,
FileText,
FolderOpen,
GitBranchPlus,
GitMerge,
Hand,
Image as ImageIcon,
Italic,
ListPlus,
Link2,
StickyNote,
Maximize,
Minus,
MousePointer2,
Network,
PanelRight,
Pencil,
Plus,
Redo2,
Save,
Shapes,
Tag,
Trash2,
Type,
Underline,
Undo2,
Upload,
Download,
X,
type LucideIcon,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { Home, Smile, Scan, Search, MoreHorizontal, AlignLeft, AlignCenter, AlignRight, Presentation, SlidersHorizontal } from "lucide-react";
import { XmindCommandMenu } from "./xmind-command-menu";
export type FlowBoardToolId =
| "selection"
| "hand"
| "mind"
| "text"
| "arrow"
| "shape"
| "image";
export type FlowBoardSheetTab = {
id: string;
title: string;
};
export type FlowBoardLayoutOption = {
id: string;
label: string;
preview:
| "both"
| "right"
| "left"
| "down"
| "up"
| "indent"
| "fishbone"
| "timeline"
| "brace"
| "verticalTimeline"
| "treeTable"
| "treeTableSide"
| "spreadsheet";
};
export type FlowBoardThemeOption = {
id: string;
label: string;
colors: [string, string, string];
};
function LayoutPreview({
direction,
}: {
direction: FlowBoardLayoutOption["preview"];
}) {
const horizontal = direction === "right" || direction === "left";
const vertical = direction === "down" || direction === "up";
const leaf = (x: number, y: number) => (
<rect key={`${x}-${y}`} x={x - 3} y={y - 3} width="6" height="6" fill="white" />
);
const root = (x: number, y: number) => (
<rect x={x - 4.5} y={y - 4.5} width="9" height="9" fill="currentColor" stroke="none" />
);
return (
<svg aria-hidden="true" className="block h-9 w-12 shrink-0" data-layout-preview={direction}
viewBox="0 0 48 36" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
{direction === "both" ? (
<>
<path d="M19.5 18H14V8H8M14 18V28H8M28.5 18H34V8H40M34 18V28H40" />
{root(24, 18)}
{[leaf(5, 8), leaf(5, 28), leaf(43, 8), leaf(43, 28)]}
</>
) : null}
{horizontal ? (
<g transform={direction === "left" ? "translate(48 0) scale(-1 1)" : undefined}>
<path d="M13.5 18H24V8H35M24 18V28H35" />
{root(9, 18)}
{[leaf(38, 8), leaf(38, 28)]}
</g>
) : null}
{vertical ? (
<g transform={direction === "up" ? "translate(0 36) scale(1 -1)" : undefined}>
<path d="M24 10.5V19M7 25V19H41V25M24 19V25" />
{root(24, 6)}
{[leaf(7, 28), leaf(24, 28), leaf(41, 28)]}
</g>
) : null}
{direction === "indent" ? (
<>
<path d="M9 10.5V30H27M9 18H27" />
{root(9, 6)}
{[leaf(30, 18), leaf(30, 30)]}
<path d="M34 18H44M34 30H44" />
</>
) : null}
{direction === "fishbone" || direction === "timeline" ? <>
{root(6, 18)}<path d="M10.5 18H46" />
<path d={direction === "fishbone" ? "M17 18L23 7M29 18L35 29M34 18L40 7" : "M20 18V7M29 18V29M40 18V7"} />
{[leaf(direction === "fishbone" ? 23 : 20, 5), leaf(direction === "fishbone" ? 35 : 29, 31), leaf(40, 5)]}
</> : null}
{direction === "verticalTimeline" ? <>
{root(24, 5)}<path d="M24 9.5V32M24 15H35M24 23H13M24 31H35" />
{[leaf(38, 15), leaf(10, 23), leaf(38, 31)]}
</> : null}
{direction === "treeTable" ? <>
<rect x="7" y="4" width="34" height="8" fill="currentColor" stroke="none" />
<path d="M7 12H41V32H7ZM7 22H41M25 12V32" />
</> : null}
{direction === "treeTableSide" ? <>
<rect x="5" y="5" width="10" height="26" fill="currentColor" stroke="none" />
<path d="M15 5H43V31H15ZM15 14H43M15 23H43M29 5V31" />
</> : null}
{direction === "spreadsheet" ? <>
<rect x="5" y="4" width="38" height="8" fill="currentColor" stroke="none" />
<path d="M5 12H43V32H5ZM5 19H43M5 25.5H43M16 12V32M25 12V32M34 12V32" />
<rect x="6" y="20" width="9" height="5" fill="currentColor" stroke="none" opacity=".55" />
</> : null}
{direction === "brace" ? <>{root(8,18)}<path d="M30 5Q23 5 23 11V13Q23 18 17 18Q23 18 23 23V25Q23 31 30 31" />{[leaf(37,5),leaf(37,18),leaf(37,31)]}</> : null}
</svg>
);
}
export function XmindEditorTopBar({
activeTool, canEditMind, currentTitle, formatPanelOpen, markerPanelOpen, isSaving, onBack,
onImport, onExport, onExportImage, isExporting, onInsertChild, onInsertSibling,
onRelationship, onBoundary, relationshipPending, onOpenFiles, onRedo,
onSave, onSummary, onTopicDetails, onTitleChange, onTitleCommit,
onToggleFormat, onToggleMarkers, onToolChange, onUndo, siteName,
onSearch, onToggleZen, onToggleOutline, onPresentation, onTask, onGantt,
}: {
activeTool: FlowBoardToolId;
canEditMind: boolean;
currentTitle: string;
formatPanelOpen: boolean;
markerPanelOpen: boolean;
isSaving: boolean;
onBack: () => void;
onImport: () => void;
onExport: () => void;
onExportImage: () => void;
isExporting: boolean;
onInsertChild: () => void;
onInsertSibling: () => void;
onRelationship: () => void;
onBoundary: () => void;
relationshipPending: boolean;
onOpenFiles: () => void;
onRedo: () => void;
onSave: () => void;
onSummary: () => void;
onTopicDetails: (section: "notes" | "links" | "labels") => void;
onTitleChange: (value: string) => void;
onTitleCommit: () => void;
onToggleFormat: () => void;
onToggleMarkers: () => void;
onToolChange: (tool: FlowBoardToolId) => void;
onUndo: () => void;
siteLogo: string | null;
siteName: string;
onTask: () => void;
onGantt: () => void;
onSearch: () => void;
onToggleZen: () => void;
onToggleOutline: () => void;
onPresentation: () => void;
}) {
const nativeIcons: Record<string,string> = {"同级主题":"topic","子主题":"subtopic","联系":"relationship","概要":"summary","外框":"boundary","ZEN 模式":"zen","演示":"pitch","格式":"sidebar","标记":"marker","导出 XMind":"export"};
const action = (label: string, Icon: LucideIcon, run: () => void, disabled = false, active = false) => (
<button aria-label={label} title={label} type="button" disabled={disabled} aria-pressed={active}
className={`xmind-icon-button ${active ? "is-active" : ""}`} onClick={run}>{nativeIcons[label] ? <span aria-hidden="true" className="xmind-native-icon" style={{maskImage:`url(/icons/xmind/${nativeIcons[label]}.svg)`}} /> : <Icon size={20} strokeWidth={1.8} />}</button>
);
return <header className="xmind-topbar" data-flow-board-shell-control="true">
<div className="xmind-document-heading">
<button type="button" aria-label="返回首页" title={`返回 ${siteName}`} className="xmind-home-button" onClick={onBack}><Home size={21} strokeWidth={1.8} /></button>
<div className="xmind-document-name">
<input aria-label="白板标题" value={currentTitle} maxLength={256} onChange={e => onTitleChange(e.target.value)}
onBlur={onTitleCommit} onKeyDown={e => { if (e.key === "Enter") e.currentTarget.blur(); }} />
<span role="status">{isSaving ? "正在保存…" : "已编辑"}</span>
</div>
<XmindCommandMenu label="文件与编辑" className="xmind-document-menu" commands={[
{label:"保存",icon:Save,shortcut:"⌘S",run:onSave},
{label:"云文件",icon:FolderOpen,run:onOpenFiles},
{label:"导入",icon:Upload,run:onImport},
{label:"导出 XMind",icon:Download,disabled:isExporting,run:onExport},
{label:"导出图片",icon:ImageIcon,disabled:isExporting,run:onExportImage},
{label:"撤销",icon:Undo2,shortcut:"⌘Z",separator:true,run:onUndo},
{label:"重做",icon:Redo2,shortcut:"⇧⌘Z",run:onRedo},
{label:"甘特图",icon:ChartGantt,run:onGantt},
{label:"查找与替换",icon:Search,shortcut:"⌘F",separator:true,run:onSearch},
{label:"选择",icon:MousePointer2,checked:activeTool === "selection",separator:true,run:() => onToolChange("selection")},
{label:"画布",icon:Hand,checked:activeTool === "hand",run:() => onToolChange("hand")},
]}><MoreHorizontal size={17} /></XmindCommandMenu>
</div>
<nav className="xmind-pointer-toolbar" aria-label="画布工具">
<button type="button" aria-label="选择工具" title="选择 · V(空白处拖动框选)"
aria-pressed={activeTool === "selection"} className={`xmind-icon-button ${activeTool === "selection" ? "is-active" : ""}`} onClick={() => onToolChange("selection")}><MousePointer2 size={17} strokeWidth={1.8} /></button>
<button type="button" aria-label="拖动画布" title="拖动画布 · H(也可按住空格拖动)"
aria-pressed={activeTool === "hand"} className={`xmind-icon-button ${activeTool === "hand" ? "is-active" : ""}`} onClick={() => onToolChange("hand")}><Hand size={17} strokeWidth={1.8} /></button>
</nav>
<nav className="xmind-primary-tools" aria-label="主题工具">
<div className="xmind-toolbar-pill">
{action("同级主题", ListPlus, onInsertSibling, !canEditMind)}
{action("子主题", GitBranchPlus, onInsertChild, !canEditMind)}
</div>
<div className="xmind-toolbar-pill">
{action("联系", ArrowUpRight, onRelationship, !canEditMind && !relationshipPending, relationshipPending)}
{action("概要", GitMerge, onSummary, !canEditMind)}
{action("外框", Shapes, onBoundary, !canEditMind)}
<XmindCommandMenu label="插入" commands={[
{label:"主题",icon:Network,run:() => onToolChange("mind")},
{label:"笔记",icon:StickyNote,shortcut:"⇧⌘N",disabled:!canEditMind,separator:true,run:() => onTopicDetails("notes")},
{label:"任务",icon:CalendarDays,disabled:!canEditMind,run:onTask},
{label:"链接",icon:Link2,disabled:!canEditMind,run:() => onTopicDetails("links")},
{label:"标签",icon:Tag,disabled:!canEditMind,run:() => onTopicDetails("labels")},
{label:"图片",icon:ImageIcon,separator:true,run:() => onToolChange("image")},
{label:"文本",icon:Type,run:() => onToolChange("text")},
{label:"形状",icon:Shapes,run:() => onToolChange("shape")},
{label:"箭头",icon:ArrowUpRight,run:() => onToolChange("arrow")},
]}><Plus size={23} strokeWidth={1.7} /></XmindCommandMenu>
</div>
</nav>
<div className="xmind-utility-tools">
<div className="xmind-toolbar-pill">{action("导出 XMind", Download, onExport, isExporting)}<XmindCommandMenu label="更多导出选项" align="right" commands={[
{label:"导出图片",icon:ImageIcon,disabled:isExporting,run:onExportImage},
{label:"导出 XMind",icon:Download,disabled:isExporting,run:onExport},
]}><span className="sr-only">导出</span></XmindCommandMenu></div>
<div className="xmind-toolbar-pill">
{action("ZEN 模式", Scan, onToggleZen)}
{action("大纲视图", FileText, onToggleOutline)}
{action("演示", Presentation, onPresentation)}
</div>
<div className="xmind-toolbar-pill">
{action("标记", Smile, onToggleMarkers, false, markerPanelOpen)}
{action("格式", PanelRight, onToggleFormat, false, formatPanelOpen)}
</div>
</div>
</header>;
}
export function XmindFormatPanel({
activeLayoutId,
activeThemeId,
branchColor,
branchShape,
branchWidth,
canvasBackgroundColor,
canvasGridColor,
canvasGridEnabled,
canvasGridSize,
fontSize,
isBold,
isItalic,
isUnderlined,
layouts,
nodeFill,
nodeShape,
onBranchColorChange,
onBranchShapeChange,
onBranchWidthChange,
onCanvasBackgroundColorChange,
onCanvasGridColorChange,
onCanvasGridEnabledChange,
onCanvasGridSizeChange,
onClose,
onFontSizeChange,
onLayoutChange,
onNodeFillChange,
onNodeShapeChange,
onTextColorChange,
onThemeChange,
onToggleTextMark,
selectedCount,
textColor,
themes,
}: {
activeLayoutId: string;
activeThemeId: string;
branchColor: string;
branchShape: "bight" | "polyline";
branchWidth: number;
canvasBackgroundColor: string;
canvasGridColor: string;
canvasGridEnabled: boolean;
canvasGridSize: number;
fontSize: string;
isBold: boolean;
isItalic: boolean;
isUnderlined: boolean;
layouts: FlowBoardLayoutOption[];
nodeFill: string;
nodeShape: "round-rectangle" | "underline";
onBranchColorChange: (value: string) => void;
onBranchShapeChange: (value: "bight" | "polyline") => void;
onBranchWidthChange: (value: number) => void;
onCanvasBackgroundColorChange: (value: string) => void;
onCanvasGridColorChange: (value: string) => void;
onCanvasGridEnabledChange: (value: boolean) => void;
onCanvasGridSizeChange: (value: number) => void;
onClose: () => void;
onFontSizeChange: (value: string) => void;
onLayoutChange: (value: string) => void;
onNodeFillChange: (value: string) => void;
onNodeShapeChange: (value: "round-rectangle" | "underline") => void;
onTextColorChange: (value: string) => void;
onThemeChange: (value: string) => void;
onToggleTextMark: (mark: "bold" | "italic" | "underlined") => void;
selectedCount: number;
textColor: string;
themes: FlowBoardThemeOption[];
}) {
const [activePanel, setActivePanel] = useState<"format" | "canvas">("format");
const [structuresOpen, setStructuresOpen] = useState(false);
const activeLayout = layouts.find(layout => layout.id === activeLayoutId) || layouts[0];
const colorControl = (label: string, value: string, change: (value: string) => void, disabled = false) => (
<input type="color" aria-label={label} value={/^#[0-9a-f]{6}$/i.test(value) ? value : "#ffffff"}
disabled={disabled} onChange={e => change(e.target.value)} className="xmind-color-control" />
);
return <aside className="xmind-format-panel" aria-label="格式面板" data-flow-board-shell-control="true">
<div className="xmind-panel-heading"><span>格式</span><button type="button" aria-label="关闭格式面板" onClick={onClose}><X size={16} /></button></div>
<div className="xmind-panel-tabs" role="tablist" aria-label="格式分类">
<button type="button" role="tab" aria-selected={activePanel === "format"} onClick={() => setActivePanel("format")}>样式</button>
<button type="button" role="tab" aria-selected={activePanel === "canvas"} onClick={() => setActivePanel("canvas")}>画布</button>
</div>
<div className="xmind-panel-content">
{activePanel === "format" ? <>
<div className="xmind-topic-preview"><span style={{ backgroundColor: nodeFill, color: textColor,
fontWeight: isBold ? 700 : 500, fontStyle: isItalic ? "italic" : "normal", borderRadius: nodeShape === "underline" ? 0 : 8 }}>{selectedCount ? "分支主题" : "中心主题"}</span><ChevronDown size={14} /></div>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />形状</h3>
<div className="xmind-segmented">
<button type="button" aria-label="圆角矩形节点" aria-pressed={nodeShape === "round-rectangle"} disabled={!selectedCount} onClick={() => onNodeShapeChange("round-rectangle")}><span className="xmind-shape-rectangle" /></button>
<button type="button" aria-label="下划线节点" aria-pressed={nodeShape === "underline"} disabled={!selectedCount} onClick={() => onNodeShapeChange("underline")}><span className="xmind-shape-line" /></button>
</div>
<label className="xmind-setting-row"><span>填充</span>{colorControl("节点填充颜色", nodeFill, onNodeFillChange, !selectedCount)}</label>
</section>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />文本</h3>
<label className="xmind-setting-row"><span>字号</span><select aria-label="节点字号" value={fontSize} disabled={!selectedCount} onChange={e => onFontSizeChange(e.target.value)}>
{Array.from(new Set([12,13,14,15,16,18,20,24,28,30,32,40,48,Number(fontSize)].filter(size => size > 0))).sort((a,b) => a-b).map(size => <option value={size} key={size}>{size}</option>)}
</select></label>
<label className="xmind-setting-row"><span>颜色</span>{colorControl("文字颜色", textColor, onTextColorChange, !selectedCount)}</label>
<div className="xmind-segmented xmind-text-marks">
{[{icon:Bold,id:"bold" as const,label:"粗体",active:isBold},{icon:Italic,id:"italic" as const,label:"斜体",active:isItalic},{icon:Underline,id:"underlined" as const,label:"下划线",active:isUnderlined}].map(({icon:Icon,id,label,active}) =>
<button type="button" key={id} aria-label={label} aria-pressed={active} disabled={!selectedCount} onClick={() => onToggleTextMark(id)}><Icon size={18} /></button>)}
</div>
</section>
<section className="xmind-format-section">
<div className="xmind-setting-row"><h3><ChevronDown size={13} />结构</h3><button type="button" aria-label="选择结构" aria-expanded={structuresOpen} className="xmind-structure-trigger" onClick={() => setStructuresOpen(value => !value)}><LayoutPreview direction={activeLayout.preview} /><ChevronDown size={12} /></button></div>
<div className="xmind-current-structure">{activeLayout.label}</div>
{structuresOpen ? <div className="xmind-structure-grid">
{layouts.map(layout => <button type="button" key={layout.id} aria-label={layout.label} aria-pressed={activeLayoutId === layout.id}
onClick={() => { onLayoutChange(layout.id); setStructuresOpen(false); }}><LayoutPreview direction={layout.preview} /><span>{layout.label}</span></button>)}
</div> : null}
</section>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />分支</h3>
<label className="xmind-setting-row"><span>线条</span><select aria-label="分支形状" value={branchShape} disabled={!selectedCount} onChange={e => onBranchShapeChange(e.target.value as "bight" | "polyline")}><option value="bight">曲线</option><option value="polyline">折线</option></select></label>
<label className="xmind-setting-row"><span>粗细</span><select aria-label="分支粗细" value={branchWidth} disabled={!selectedCount} onChange={e => onBranchWidthChange(Number(e.target.value))}>{[1,2,3,4,5,6,8].map(width => <option key={width} value={width}>{width} px</option>)}</select></label>
<label className="xmind-setting-row"><span>颜色</span>{colorControl("分支颜色", branchColor, onBranchColorChange, !selectedCount)}</label>
</section>
</> : <>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />背景</h3>
<div className="xmind-background-presets">{["#ffffff","#f7f8fb","#f1f5f9","#fff8e7","#eef8f1","#f5f0ff"].map(color => <button type="button" key={color} aria-label={`画布背景 ${color}`} aria-pressed={color === canvasBackgroundColor} style={{backgroundColor:color}} onClick={() => onCanvasBackgroundColorChange(color)} />)}</div>
<label className="xmind-setting-row"><span>自定义颜色</span>{colorControl("画布背景颜色",canvasBackgroundColor,onCanvasBackgroundColorChange)}</label>
</section>
<section className="xmind-format-section"><h3><ChevronDown size={13} />彩虹色</h3><div className="xmind-theme-grid">{themes.map(theme => <button type="button" key={theme.id} aria-label={theme.label} aria-pressed={theme.id === activeThemeId} onClick={() => onThemeChange(theme.id)}><span>{theme.colors.map(color => <i key={color} style={{backgroundColor:color}} />)}</span>{theme.label}</button>)}</div></section>
<section className="xmind-format-section">
<div className="xmind-setting-row"><h3><ChevronDown size={13} />网格</h3><button type="button" role="switch" aria-label="显示画布网格" aria-checked={canvasGridEnabled} className="xmind-switch" onClick={() => onCanvasGridEnabledChange(!canvasGridEnabled)}><i /></button></div>
<label className="xmind-setting-row"><span>网格颜色</span>{colorControl("网格颜色",canvasGridColor,onCanvasGridColorChange,!canvasGridEnabled)}</label>
<label className="xmind-setting-row"><span>网格间距</span><select aria-label="网格间距" value={canvasGridSize} disabled={!canvasGridEnabled} onChange={e => onCanvasGridSizeChange(Number(e.target.value))}>{[8,16,24,32,40,48,64].map(size => <option key={size} value={size}>{size} px</option>)}</select></label>
<div aria-label="画布样式预览" className="xmind-canvas-preview" style={{backgroundColor:canvasBackgroundColor,backgroundImage:canvasGridEnabled ? `radial-gradient(${canvasGridColor} 1px, transparent 1px)` : "none",backgroundSize:`${canvasGridSize}px ${canvasGridSize}px`}} />
</section>
</>}
</div>
</aside>;
}
export function XmindSheetBar({
outlineOpen, onToggleOutline, activeSheetId, formatPanelOpen, onAddSheet,
onDeleteSheet, onDuplicateSheet, onFit, onRenameSheet, onSwitchSheet,
onZoomIn, onZoomOut, onZoomTo, sheets, zoomPercent, topicCount, selectedCount,
}: {
outlineOpen: boolean;
onToggleOutline: () => void;
activeSheetId: string;
formatPanelOpen: boolean;
onAddSheet: () => void;
onDeleteSheet: (id: string) => void;
onDuplicateSheet: (id: string) => void;
onFit: () => void;
onRenameSheet: (id: string, title: string) => void;
onSwitchSheet: (id: string) => void;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomTo: (value: number) => void;
sheets: FlowBoardSheetTab[];
zoomPercent: number;
topicCount: number;
selectedCount: number;
}) {
const [renamingSheetId, setRenamingSheetId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [contextId, setContextId] = useState<string | null>(null);
const activeSheet = sheets.find(sheet => sheet.id === activeSheetId);
const rename = (id: string) => { const sheet = sheets.find(item => item.id === id); if (sheet) { setRenamingSheetId(id); setRenameDraft(sheet.title); } };
const commitRename = () => { if (renamingSheetId && renameDraft.trim()) onRenameSheet(renamingSheetId,renameDraft.trim()); setRenamingSheetId(null); };
return <footer className="xmind-sheetbar" data-flow-board-shell-control="true">
<div className="xmind-sheet-tabs" role="tablist" aria-label="工作表">
{sheets.map(sheet => <div className="xmind-sheet-tab-host" key={sheet.id}>
{renamingSheetId === sheet.id ? <input aria-label="重命名画布" autoFocus value={renameDraft} maxLength={80} onChange={e => setRenameDraft(e.target.value)} onBlur={commitRename} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); commitRename(); } if (e.key === "Escape") { e.preventDefault(); setRenamingSheetId(null); } }} /> :
<button type="button" aria-label={sheet.title} aria-pressed={sheet.id === activeSheetId} onClick={() => onSwitchSheet(sheet.id)} onDoubleClick={() => rename(sheet.id)} onContextMenu={e => { e.preventDefault(); setContextId(contextId === sheet.id ? null : sheet.id); }}>{sheet.title}</button>}
{contextId === sheet.id ? <div className="xmind-sheet-context" data-flow-board-shell-control="true">
<button type="button" onClick={() => { rename(sheet.id); setContextId(null); }}>重命名</button>
<button type="button" onClick={() => { onDuplicateSheet(sheet.id); setContextId(null); }}>复制</button>
<button type="button" disabled={sheets.length <= 1} onClick={() => { onDeleteSheet(sheet.id); setContextId(null); }}>删除</button>
<button type="button" onClick={() => setContextId(null)}>关闭菜单</button>
</div> : null}
</div>)}
</div>
<span className="xmind-footer-divider" />
<button type="button" aria-label="新增画布" title="新增画布" className="xmind-footer-icon" onClick={onAddSheet}><Plus size={21} strokeWidth={1.5} /></button>
<XmindCommandMenu label="画布操作" className="xmind-sheet-actions" commands={[
{label:"重命名当前画布",icon:Pencil,disabled:!activeSheet,run:() => activeSheet && rename(activeSheet.id)},
{label:"复制当前画布",icon:Copy,disabled:!activeSheet,run:() => activeSheet && onDuplicateSheet(activeSheet.id)},
{label:"删除当前画布",icon:Trash2,disabled:sheets.length <= 1,run:() => activeSheet && onDeleteSheet(activeSheet.id)},
]}><MoreHorizontal size={17} /></XmindCommandMenu>
<div className="xmind-sheet-status"><span className="xmind-topic-count">主题: {selectedCount} / {topicCount}</span><span className="xmind-footer-divider" />
<XmindCommandMenu label="缩放" align="right" className="xmind-zoom-menu" commands={[
{label:"放大",icon:Plus,run:onZoomIn},{label:"缩小",icon:Minus,run:onZoomOut},
...[50,75,100,125,150,200].map(value => ({label:`${value}%`,checked:value === zoomPercent,separator:value === 50,run:() => onZoomTo(value)})),
{label:"适应画布",icon:Maximize,separator:true,run:onFit},
]}><span>{zoomPercent}%</span></XmindCommandMenu>
<span className="xmind-footer-divider" /><button type="button" aria-label={outlineOpen ? "切换到思维导图" : "切换到大纲"} aria-pressed={outlineOpen} className="xmind-outline-toggle" onClick={onToggleOutline}>{outlineOpen ? "思维导图" : "大纲"}</button>
</div>
</footer>;
}

View File

@ -0,0 +1,778 @@
"use client";
import {
CalendarDays,
ChevronDown,
ChevronRight,
Download,
Link2,
Minus,
Plus,
Printer,
Search,
UserRound,
X,
} from "lucide-react";
import type { PlaitElement } from "@plait/core";
import { useEffect, useMemo, useRef, useState } from "react";
import {
addDays,
dateIndex,
getTaskRows,
indexDate,
todayDate,
type TopicTask,
} from "@/lib/flow-board/topic-tasks";
import { getTopicEntries } from "@/lib/flow-board/topic-details";
import {
activeWorkingCalendar,
getProjectCalendar,
isWorkingDate,
preserveNonWorkingDue,
taskDueDate,
workingDaysBetween,
} from "@/lib/flow-board/project-calendar";
const ROW = 38,
HEADER = 64;
export function XmindGantt({
elements,
title,
onClose,
onEdit,
onCalendar,
onChange,
onUndo,
onRedo,
}: {
elements: PlaitElement[];
title: string;
onClose: () => void;
onEdit: (id: string) => void;
onCalendar: () => void;
onChange: (id: string, task: TopicTask) => void;
onUndo: () => void;
onRedo: () => void;
}) {
const panelRef = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
panelRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, []);
const [scale, setScale] = useState(34),
[query, setQuery] = useState(""),
[assignee, setAssignee] = useState(""),
[status, setStatus] = useState("all");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<string[]>([]);
const [drag, setDrag] = useState<{
id: string;
x: number;
kind: "move" | "start" | "due";
task: TopicTask;
days: number;
} | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const rowScrollRef = useRef<HTMLDivElement>(null);
const projectCalendar = getProjectCalendar(elements);
const calendar = activeWorkingCalendar(projectCalendar);
const rows = getTaskRows(elements);
const visible = rows.filter(
(row) =>
row.title.toLocaleLowerCase().includes(query.toLocaleLowerCase()) &&
(!assignee || row.task.assignees.includes(assignee)) &&
(status === "all" ||
(status === "done" && row.task.progress === 100) ||
(status === "open" && row.task.progress < 100)) &&
!rows.some(
(parent) =>
collapsed.has(parent.element.id) &&
parent.path.length < row.path.length &&
parent.path.every((n, i) => row.path[i] === n)
)
);
const candidates = getTopicEntries(elements).filter(
(e) => !rows.some((r) => r.element.id === e.element.id)
);
const [candidate, setCandidate] = useState("");
const [notice, setNotice] = useState("");
const today = dateIndex(todayDate());
const dated = rows.filter((r) => r.task.start && r.task.due);
const from =
Math.min(today, ...dated.map((r) => dateIndex(r.task.start))) - 3;
const last =
Math.max(today + 20, ...dated.map((r) => dateIndex(r.task.due))) + 4;
// Very long plans use a virtual day grid; no thousands of day DOM nodes.
const width = Math.max(800, (last - from + 1) * scale);
const [scrollX, setScrollX] = useState(0),
[viewportWidth, setViewportWidth] = useState(1000);
const tickStep = scale >= 24 ? 1 : scale >= 10 ? 7 : 30;
const monthBands = useMemo(() => {
const start = from + Math.max(0, Math.floor(scrollX / scale) - 31),
end = Math.min(
last,
from + Math.ceil((scrollX + viewportWidth) / scale) + 31
);
const bands: { date: string; offset: number; days: number }[] = [];
for (let day = start; day <= end; ) {
const date = indexDate(day),
next = dateIndex(`${date.slice(0, 7)}-01`);
const d = new Date(next * 86_400_000);
d.setUTCMonth(d.getUTCMonth() + 1);
const monthEnd = d.getTime() / 86_400_000;
const bandStart = Math.max(from, next),
bandEnd = Math.min(last + 1, monthEnd);
bands.push({
date: date.slice(0, 7),
offset: bandStart - from,
days: bandEnd - bandStart,
});
day = monthEnd;
}
return bands;
}, [from, last, scrollX, scale, viewportWidth]);
const ticks = useMemo(() => {
const result: number[] = [];
const begin = Math.max(
0,
Math.floor(scrollX / scale / tickStep) * tickStep - tickStep
);
const end = Math.min(
last - from,
Math.ceil((scrollX + viewportWidth) / scale) + tickStep
);
for (let i = begin; i <= end; i += tickStep) result.push(i);
return result;
}, [scrollX, scale, tickStep, viewportWidth, last, from]);
const moveTask = (task: TopicTask, days: number) => {
const start = addDays(task.start, days);
return {
...task,
start,
due: preserveNonWorkingDue(
addDays(task.due, days),
taskDueDate(start, task.duration, calendar),
calendar
),
};
};
const effective = (row: (typeof rows)[number]) => {
if (drag?.id !== row.element.id || !drag.days) return row.task;
const task = row.task;
if (drag.kind === "move") return moveTask(task, drag.days);
const start =
drag.kind === "start" ? addDays(task.start, drag.days) : task.start;
const due = drag.kind === "due" ? addDays(task.due, drag.days) : task.due;
return {
...task,
start,
due,
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
};
};
const goToday = () =>
scrollRef.current?.scrollTo({
left: Math.max(0, (today - from) * scale - 200),
behavior: "smooth",
});
const downloadCsv = () => {
const quote = (v: unknown) => `"${String(v ?? "").replaceAll('"', '""')}"`;
const content =
"\ufeff" +
[
[
"任务名称",
"开始日期",
"截止日期",
"时长(天)",
"进度(%)",
"优先级",
"负责人",
"前置任务",
],
...visible.map((r) => [
r.title,
r.task.start,
r.task.due,
r.task.duration,
r.task.progress,
r.task.priority == null ? "" : `P${r.task.priority}`,
r.task.assignees.join(", "),
r.task.dependencies
.map(
(d) =>
`${rows.find((t) => t.element.id === d.id)?.title || d.id} ${d.type}`
)
.join(", "),
]),
]
.map((row) => row.map(quote).join(","))
.join("\r\n");
const url = URL.createObjectURL(
new Blob([content], { type: "text/csv;charset=utf-8" })
);
const a = document.createElement("a");
a.href = url;
a.download = `${title}-甘特图.csv`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const linkTasks = () => {
if (selected.length !== 2) return;
const target = rows.find((r) => r.element.id === selected[1]);
if (!target) return;
onChange(target.element.id, {
...target.task,
dependencies: [
...target.task.dependencies.filter((d) => d.id !== selected[0]),
{ id: selected[0], type: "FS", lag: 0 },
],
});
};
return (
<section
role="dialog"
aria-modal="true"
aria-label="甘特图"
className="xmind-gantt"
ref={panelRef}
data-flow-board-shell-control="true"
onKeyUp={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
if (e.key === "Tab") {
const controls = Array.from(
panelRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled),input,select,[tabindex="0"]'
) || []
);
const first = controls[0],
last = controls.at(-1);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
}
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
if (
(e.metaKey || e.ctrlKey) &&
e.key.toLowerCase() === "z" &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLSelectElement)
) {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) onRedo();
else onUndo();
}
}}
>
<header className="xmind-gantt-toolbar">
<div className="xmind-gantt-title">
<button type="button" aria-label="关闭甘特图" onClick={onClose}>
<X size={18} />
</button>
<h2>甘特图</h2>
<span>{title}</span>
</div>
<div className="xmind-gantt-actions">
<button
type="button"
aria-label="连接选中任务"
title="按选择顺序建立完成 → 开始依赖"
disabled={selected.length !== 2}
onClick={linkTasks}
>
<Link2 size={18} />
</button>
<button
type="button"
aria-label="项目工作日"
title="项目工作日"
onClick={onCalendar}
>
<CalendarDays size={18} />
</button>
<button
type="button"
aria-label="导出甘特图 CSV"
onClick={downloadCsv}
>
<Download size={18} />
</button>
<button
type="button"
aria-label="打印甘特图"
onClick={() => window.print()}
>
<Printer size={18} />
</button>
<button type="button" aria-label="定位今天" onClick={goToday}>
<CalendarDays size={18} />
</button>
<div className="xmind-gantt-zoom">
<button
type="button"
aria-label="缩小甘特图"
onClick={() => setScale((s) => Math.max(2, s - 4))}
>
<Minus size={16} />
</button>
<input
type="range"
aria-label="甘特图缩放"
min={2}
max={64}
value={scale}
onChange={(e) => setScale(Number(e.target.value))}
/>
<button
type="button"
aria-label="放大甘特图"
onClick={() => setScale((s) => Math.min(64, s + 4))}
>
<Plus size={16} />
</button>
</div>
</div>
</header>
<div className="xmind-gantt-filters">
<label>
<Search size={14} />
<input
aria-label="搜索甘特图任务"
placeholder="搜索任务"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<select
aria-label="按负责人筛选"
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
>
<option value="">全部负责人</option>
{[...new Set(rows.flatMap((r) => r.task.assignees))].map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<select
aria-label="按任务进度筛选"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="all">全部任务</option>
<option value="open">未完成</option>
<option value="done">已完成</option>
</select>
<select
aria-label="选择新增任务主题"
value={candidate}
onChange={(e) => setCandidate(e.target.value)}
>
<option value="">选择主题添加任务</option>
{candidates.map((e) => (
<option key={e.element.id} value={e.element.id}>
{e.title}
</option>
))}
</select>
<button
type="button"
disabled={!candidate}
onClick={() => {
onEdit(candidate);
setCandidate("");
}}
>
添加任务
</button>
<span>
{calendar ? `${calendar.name} · 跳过非工作日 · ` : ""}
{visible.length} 个任务
</span>
</div>
<div className="xmind-gantt-body">
<div className="xmind-gantt-table">
<div className="xmind-gantt-table-head">
<span>任务名称</span>
<span>开始日期</span>
<span>时长</span>
</div>
<div
className="xmind-gantt-row-scroll"
ref={rowScrollRef}
onScroll={(e) => {
const node = scrollRef.current;
if (node && node.scrollTop !== e.currentTarget.scrollTop)
node.scrollTop = e.currentTarget.scrollTop;
}}
data-gantt-row-scroll="true"
>
{visible.map((row) => (
<div
className={`xmind-gantt-row ${selected.includes(row.element.id) ? "is-selected" : ""}`}
key={row.element.id}
style={{ height: ROW }}
>
<div
className="xmind-gantt-row-name"
style={{ paddingLeft: 8 + Math.min(row.depth, 5) * 12 }}
>
<input
type="checkbox"
aria-label={`选择任务 ${row.title}`}
checked={selected.includes(row.element.id)}
onChange={(e) =>
setSelected((s) =>
e.target.checked
? [...s, row.element.id]
: s.filter((id) => id !== row.element.id)
)
}
/>
{row.summary ? (
<button
type="button"
aria-label={`折叠任务 ${row.title}`}
aria-expanded={!collapsed.has(row.element.id)}
onClick={() =>
setCollapsed((old) => {
const next = new Set(old);
if (next.has(row.element.id))
next.delete(row.element.id);
else next.add(row.element.id);
return next;
})
}
>
{collapsed.has(row.element.id) ? (
<ChevronRight size={12} />
) : (
<ChevronDown size={12} />
)}
</button>
) : null}
<button
type="button"
className="xmind-gantt-task-title"
title={row.title}
onClick={() => onEdit(row.element.id)}
>
{row.title}
</button>
</div>
<button
type="button"
className="xmind-gantt-date"
onClick={() => onEdit(row.element.id)}
>
{effective(row).start || "未排期"}
</button>
<span>{effective(row).duration} 天</span>
</div>
))}
{!visible.length ? (
<div className="xmind-gantt-empty">
<CalendarDays size={52} />
<strong>{rows.length ? "没有匹配的任务" : "没有任务"}</strong>
<p>
{rows.length
? "调整搜索或筛选条件。"
: "请选择一个主题以添加任务。"}
</p>
</div>
) : null}
</div>
</div>
<div
className="xmind-gantt-timeline"
ref={scrollRef}
onScroll={(e) => {
setScrollX(e.currentTarget.scrollLeft);
setViewportWidth(e.currentTarget.clientWidth);
const rowScroll = rowScrollRef.current;
if (rowScroll && rowScroll.scrollTop !== e.currentTarget.scrollTop)
rowScroll.scrollTop = e.currentTarget.scrollTop;
}}
>
<div
className="xmind-gantt-grid"
style={{
width,
height: Math.max(600, HEADER + visible.length * ROW),
backgroundSize: `${scale}px ${ROW}px`,
}}
>
{calendar
? Array.from(
{ length: Math.ceil(viewportWidth / scale) + 3 },
(_, index) =>
Math.max(0, Math.floor(scrollX / scale) - 1) + index
)
.filter(
(offset) =>
offset <= last - from &&
!isWorkingDate(indexDate(from + offset), calendar)
)
.map((offset) => (
<div
key={`off-${offset}`}
className="xmind-gantt-day-off"
data-gantt-day-off={indexDate(from + offset)}
style={{
left: offset * scale,
width: scale,
top: HEADER,
bottom: 0,
}}
/>
))
: null}
<div
className="xmind-gantt-calendar"
style={{ width, height: HEADER }}
>
{ticks.map((offset) => {
const date = indexDate(from + offset);
return (
<div
className={`xmind-gantt-day ${calendar && !isWorkingDate(date, calendar) ? "is-day-off" : ""}`}
key={offset}
style={{ left: offset * scale, width: tickStep * scale }}
>
<b>
{tickStep >= 30
? date.slice(5, 7) + "月"
: tickStep >= 7
? date.slice(5)
: String(Number(date.slice(8)))}
</b>
</div>
);
})}
{monthBands.map((band) => (
<div
key={band.date}
className="xmind-gantt-month"
style={{
left: band.offset * scale,
width: band.days * scale,
}}
>
{band.date.replace("-", "年")}月
</div>
))}
</div>
<div
className="xmind-gantt-today"
aria-label="今天"
style={{ left: (today - from) * scale, top: HEADER }}
/>
<svg
className="xmind-gantt-connections"
aria-label="任务依赖关系"
role="img"
width={width}
height={Math.max(600, HEADER + visible.length * ROW)}
>
<defs>
<marker
id="gantt-arrow"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8d94a1" />
</marker>
</defs>
{visible.flatMap((row, i) =>
row.task.dependencies.map((d) => {
const fromIndex = visible.findIndex(
(r) => r.element.id === d.id
);
if (fromIndex < 0) return null;
const before = effective(visible[fromIndex]),
after = effective(row);
if (!before.start || !after.start) return null;
const x1 =
(dateIndex(
d.type[0] === "F" ? before.due : before.start
) -
from +
(d.type[0] === "F" ? 1 : 0)) *
scale,
x2 =
(dateIndex(d.type[1] === "F" ? after.due : after.start) -
from +
(d.type[1] === "F" ? 1 : 0)) *
scale,
y1 = HEADER + fromIndex * ROW + ROW / 2,
y2 = HEADER + i * ROW + ROW / 2;
return (
<path
key={`${row.element.id}-${d.id}`}
data-gantt-dependency={`${d.id}:${row.element.id}`}
d={`M${x1},${y1} H${x1 + 9} V${y2} H${x2}`}
fill="none"
stroke="#8d94a1"
strokeWidth={1.3}
markerEnd="url(#gantt-arrow)"
/>
);
})
)}
</svg>
{visible.map((row, i) =>
selected.includes(row.element.id) ? (
<div
key={`selection-${row.element.id}`}
className="xmind-gantt-selection"
style={{ top: HEADER + i * ROW, height: ROW, width }}
/>
) : null
)}
{visible.map((row, i) => {
const t = effective(row);
if (!t.start || !t.due) return null;
const left = (dateIndex(t.start) - from) * scale,
barWidth = Math.max(
6,
(dateIndex(t.due) - dateIndex(t.start) + 1) * scale
);
return (
<div
key={row.element.id}
className={`xmind-gantt-bar ${row.summary ? "is-summary" : ""} ${t.progress === 100 ? "is-complete" : ""}`}
data-gantt-task-id={row.element.id}
style={{ left, top: HEADER + i * ROW + 6, width: barWidth }}
role="button"
tabIndex={0}
aria-label={`任务时间条 ${row.title}`}
aria-pressed={selected.includes(row.element.id)}
title={`${row.title}\n${t.start} — ${t.due}\n进度 ${t.progress}%`}
onDoubleClick={() => onEdit(row.element.id)}
onKeyDown={(e) => {
if (e.key === "Enter") onEdit(row.element.id);
if (
!row.summary &&
["ArrowLeft", "ArrowRight"].includes(e.key)
) {
e.preventDefault();
const days = e.key === "ArrowLeft" ? -1 : 1;
onChange(row.element.id, moveTask(t, days));
}
}}
onPointerDown={(e) => {
if (row.summary || e.button !== 0) return;
e.preventDefault();
setSelected((ids) =>
e.metaKey || e.ctrlKey
? ids.includes(row.element.id)
? ids.filter((id) => id !== row.element.id)
: [...ids, row.element.id]
: [row.element.id]
);
e.currentTarget.setPointerCapture(e.pointerId);
const kind =
(e.target as HTMLElement).dataset.resize === "start"
? "start"
: (e.target as HTMLElement).dataset.resize === "due"
? "due"
: "move";
setDrag({
id: row.element.id,
x: e.clientX,
kind,
task: row.task,
days: 0,
});
}}
onPointerMove={(e) => {
if (drag?.id !== row.element.id) return;
let days = Math.round((e.clientX - drag.x) / scale);
if (drag.kind === "start")
days = Math.min(
dateIndex(drag.task.due) - dateIndex(drag.task.start),
days
);
if (drag.kind === "due")
days = Math.max(
dateIndex(drag.task.start) - dateIndex(drag.task.due),
days
);
setDrag({ ...drag, days });
}}
onPointerUp={() => {
if (drag?.id !== row.element.id) return;
if (drag.days) onChange(row.element.id, t);
setDrag(null);
}}
onPointerCancel={() => setDrag(null)}
>
<div
className="xmind-gantt-progress"
style={{ width: `${t.progress}%` }}
/>
{!row.summary ? (
<>
<span
data-resize="start"
className="xmind-gantt-resize is-start"
/>
<span
data-resize="due"
className="xmind-gantt-resize is-due"
/>
</>
) : null}
<span className="xmind-gantt-bar-label">
<span
className="xmind-gantt-avatar"
title={t.assignees.join("、") || "未分配负责人"}
>
<UserRound size={12} />
</span>
<span>{row.title}</span>
</span>
</div>
);
})}
</div>
</div>
</div>
<footer className="xmind-gantt-footer">
<span>
{drag
? `调整 ${drag.days > 0 ? "+" : ""}${drag.days} 天`
: "拖动调整日期 · 拉伸修改工期 · 双击编辑任务"}
</span>
<span>{notice}</span>
<button
type="button"
onClick={() => {
setQuery("");
setAssignee("");
setStatus("all");
setNotice("");
}}
>
清除筛选
</button>
</footer>
</section>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import { Download, Loader2, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ImageExportOptions } from "@/lib/flow-board/image-export";
export function XmindImageExport({
busy,
onExport,
onClose,
}: {
busy: boolean;
onExport: (
options: Pick<ImageExportOptions, "scale" | "transparent">
) => void;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [scale, setScale] = useState<1 | 2 | 3>(2);
const [transparent, setTransparent] = useState(false);
useEffect(() => {
const node = dialog.current;
node?.showModal();
return () => node?.close();
}, []);
return (
<dialog
ref={dialog}
className="xmind-image-export"
aria-labelledby="image-export-title"
data-flow-board-shell-control="true"
onKeyDown={(event) => event.stopPropagation()}
onCancel={(event) => {
event.preventDefault();
if (!busy) onClose();
}}
>
<header>
<h2 id="image-export-title">导出图片</h2>
<button
type="button"
aria-label="关闭图片导出"
disabled={busy}
onClick={onClose}
>
<X size={18} />
</button>
</header>
<p>导出当前工作表的完整内容为 PNG 图片。</p>
<label className="xmind-image-export-field">
清晰度
<select
aria-label="图片清晰度"
value={scale}
disabled={busy}
onChange={(event) =>
setScale(Number(event.target.value) as 1 | 2 | 3)
}
>
<option value={1}>标准 · 1 倍</option>
<option value={2}>高清 · 2 倍</option>
<option value={3}>超清 · 3 倍</option>
</select>
</label>
<label className="xmind-image-export-check">
<input
type="checkbox"
checked={transparent}
disabled={busy}
onChange={(event) => setTransparent(event.target.checked)}
/>
透明背景
</label>
<footer>
<button type="button" disabled={busy} onClick={onClose}>
取消
</button>
<button
type="button"
className="xmind-primary-button"
disabled={busy}
onClick={() => onExport({ scale, transparent })}
>
{busy ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
{busy ? "正在导出…" : "导出 PNG"}
</button>
</footer>
</dialog>
);
}

View File

@ -0,0 +1,46 @@
"use client";
import { useState } from "react";
import { ChevronDown, Search, X } from "lucide-react";
import { XMIND_MARKER_GROUPS, type XmindMarker } from "@/lib/flow-board/xmind-markers";
export function XmindMarkerPanel({ selectedCount, activeIds, mixedIds, onApply, onClear, onClose }: {
selectedCount: number;
activeIds: string[];
mixedIds: string[];
onApply: (marker: XmindMarker) => void;
onClear: () => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [showAll, setShowAll] = useState(false);
const [collapsed, setCollapsed] = useState<string[]>([]);
const needle = query.trim().toLowerCase();
const groups = XMIND_MARKER_GROUPS.filter(group => showAll || needle || !group.hidden).map(group => ({
...group, markers: group.markers.filter(marker => (showAll || needle || !marker.hidden) && (!needle || `${group.label} ${marker.name} ${marker.id}`.toLowerCase().includes(needle))),
})).filter(group => group.markers.length);
return <aside className="xmind-format-panel xmind-marker-panel" aria-label="标记面板" data-flow-board-shell-control="true"
onPointerDownCapture={event => event.stopPropagation()} onMouseDownCapture={event => event.stopPropagation()}>
<div className="xmind-panel-heading"><span>标记</span><button type="button" aria-label="关闭标记面板" onClick={onClose}><X size={16} /></button></div>
<div className="xmind-marker-search"><Search size={14} /><input aria-label="搜索标记" placeholder="搜索标记" value={query} onChange={event => setQuery(event.target.value)} /></div>
<p className="xmind-marker-selection" role="status">{selectedCount ? `已选中 ${selectedCount} 个主题` : "先选中主题,再添加标记"}</p>
<div className="xmind-panel-content">
{groups.map(group => <section className="xmind-marker-group" key={group.id}>
<button type="button" className="xmind-marker-group-heading" aria-expanded={!collapsed.includes(group.id) || Boolean(needle)} onClick={() => setCollapsed(ids => ids.includes(group.id) ? ids.filter(id => id !== group.id) : [...ids, group.id])}>
<ChevronDown size={13} /><span>{group.label}</span><span>{group.markers.length}</span>
</button>
{!collapsed.includes(group.id) || needle ? <div className="xmind-marker-grid">
{group.markers.map(marker => <button type="button" key={marker.id} data-marker-id={marker.id}
aria-label={marker.id.startsWith("priority-") ? marker.name : `${group.label} ${marker.name}`}
title={`${group.label} · ${marker.name}`} aria-pressed={activeIds.includes(marker.id) ? true : mixedIds.includes(marker.id) ? "mixed" : false}
disabled={!selectedCount} onPointerDown={event => event.preventDefault()} onMouseDown={event => event.preventDefault()} onClick={() => onApply(marker)}>
<img src={marker.asset} alt="" draggable={false} loading="lazy" />
</button>)}
</div> : null}
</section>)}
{!groups.length ? <p className="xmind-marker-empty">没有匹配的标记</p> : null}
{!needle ? <button type="button" className="xmind-marker-more" aria-expanded={showAll} onClick={() => setShowAll(value => !value)}>{showAll ? "收起更多标记" : "更多标记"}</button> : null}
</div>
<div className="xmind-marker-footer"><button type="button" disabled={!selectedCount} onPointerDown={event => event.preventDefault()} onMouseDown={event => event.preventDefault()} onClick={onClear}>清空所选节点标记</button></div>
</aside>;
}

View File

@ -0,0 +1,280 @@
/* Xmind editing controls use the project's home/canvas typography and density. */
.xmind-workspace {
--xmind-panel-width: 240px;
--xmind-ink: #24282b;
--xmind-muted: #888b8e;
--xmind-panel: #f3f4f5;
--xmind-border: #e3e5e8;
color: var(--xmind-ink);
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
background: white;
font-size: 13px;
}
.xmind-workspace button { cursor: pointer; }
.xmind-workspace :is(button,input,textarea,select) { font-family: inherit; }
.xmind-workspace button:disabled { cursor: default; opacity: .3; }
.xmind-workspace button:focus-visible { outline: 2px solid #35b5ff; outline-offset: 2px; }
.xmind-topic-marker { display:inline-block; object-fit:contain; flex-shrink:0; vertical-align:middle; }
.xmind-marker-search { display:flex; align-items:center; gap:6px; margin:4px 12px; padding:6px 8px; border:1px solid var(--xmind-border); border-radius:7px; background:white; }
.xmind-marker-search input { width:100%; min-width:0; outline:none; background:transparent; font-size:12px; }
.xmind-marker-selection { margin:6px 12px 10px; font-size:11px; color:var(--xmind-muted); }
.xmind-marker-panel .xmind-panel-content { flex:1; overflow-x:hidden; }
.xmind-marker-group { margin-bottom:12px; }
.xmind-marker-group-heading { display:flex; align-items:center; gap:5px; width:100%; height:26px; font-size:12px; font-weight:600; }
.xmind-marker-group-heading > span:last-child { margin-left:auto; font-size:10px; font-weight:400; color:var(--xmind-muted); }
.xmind-marker-group-heading[aria-expanded=false] svg { transform:rotate(-90deg); }
.xmind-marker-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }
.xmind-marker-grid button { display:grid; place-items:center; width:100%; height:28px; border:1px solid transparent; border-radius:5px; background:transparent; }
.xmind-marker-grid button:hover:not(:disabled) { background:#e3e6ea; }
.xmind-marker-grid button[aria-pressed=true] { border-color:#387cff; background:#e9f0ff; }
.xmind-marker-grid button[aria-pressed=mixed] { border:1px dashed #387cff; background:#e9f0ff80; }
.xmind-marker-grid img { width:20px; height:20px; object-fit:contain; }
.xmind-marker-more { width:100%; padding:7px; border-radius:6px; color:#3569e8; background:#e9edf4; font-size:12px; }
.xmind-marker-empty { padding:24px 0; text-align:center; color:var(--xmind-muted); }
.xmind-marker-footer { padding:10px 12px; border-top:1px solid var(--xmind-border); }
.xmind-marker-footer button { width:100%; padding:7px; border:1px solid var(--xmind-border); border-radius:6px; background:white; font-size:12px; }
.xmind-topbar { position:absolute; top:0; left:0; right:var(--xmind-panel-offset,0px); height:56px; z-index:50; display:flex; align-items:flex-start; padding:12px 12px 0; gap:8px; pointer-events:none; }
.xmind-topbar > * { pointer-events:auto; }
.xmind-document-heading { display:flex; align-items:center; gap:8px; min-width:200px; }
.xmind-home-button { display:grid; place-items:center; width:32px; height:32px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; flex-shrink:0; }
.xmind-document-name { height:32px; display:flex; flex-direction:column; justify-content:center; gap:0; min-width:100px; }
.xmind-document-name input { background:transparent; border:1px solid transparent; border-radius:5px; font-size:13px; font-weight:600; width:140px; padding:0 3px; outline:none; }
.xmind-document-name input:focus { border-color:#35b5ff; }
.xmind-document-name span { font-size:11px; padding-left:4px; }
.xmind-document-menu { margin-left:-8px; opacity:0; transition:opacity .15s; }
.xmind-document-heading:hover .xmind-document-menu,.xmind-document-heading:focus-within .xmind-document-menu { opacity:1; }
.xmind-pointer-toolbar { position:absolute; top:64px; left:12px; display:flex; flex-direction:column; gap:2px; padding:3px; border:1px solid var(--xmind-border); border-radius:10px; background:#fffffff5; box-shadow:0 2px 8px #00000008; }
.xmind-pointer-toolbar .xmind-icon-button { width:28px; height:28px; }
.xmind-primary-tools { display:flex; align-items:center; gap:8px; margin:auto; margin-top:0; }
.xmind-toolbar-pill { display:flex; align-items:center; padding:2px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; backdrop-filter:blur(24px); }
.xmind-icon-button { width:30px; height:28px; display:grid; place-items:center; border-radius:6px; color:var(--xmind-ink); background:transparent; flex-shrink:0; }
.xmind-icon-button > svg { width:18px; height:18px; }
.xmind-icon-button:hover:not(:disabled),.xmind-home-button:hover,.xmind-menu-trigger:hover { background:#00000009; }
.xmind-icon-button.is-active { background:#363b3e; color:white; }
.xmind-utility-tools { display:flex; align-items:center; gap:8px; }
.xmind-menu-host { position:relative; }
.xmind-menu-trigger { display:flex; align-items:center; justify-content:center; gap:4px; min-height:28px; padding:2px 5px; border-radius:24px; }
.xmind-document-menu .xmind-menu-trigger > svg:last-child,.xmind-sheet-actions .xmind-menu-trigger > svg:last-child { display:none; }
.xmind-command-menu { position:absolute; top:calc(100% + 10px); left:0; min-width:220px; max-height:min(70vh,540px); overflow-y:auto; padding:5px; border:1px solid #dedfe2; border-radius:10px; background:#fcfcfcf5; backdrop-filter:blur(24px); box-shadow:0 12px 32px #00000020,0 2px 6px #0000000a; z-index:70; }
.xmind-menu-right { left:auto; right:0; }
.xmind-command { width:100%; display:flex; align-items:center; gap:10px; border-radius:5px; padding:7px 9px; text-align:left; min-height:32px; color:#34393c; font-size:13px; }
.xmind-command:hover:not(:disabled),.xmind-command:focus-visible { background:#35a6fc; color:white; outline:none; }
.xmind-command kbd { font:inherit; opacity:.5; margin-left:auto; font-size:11px; }
.xmind-command-check { display:block; width:16px; }
.xmind-menu-separator { height:1px; background:#e1e2e4; margin:4px 6px; }
.xmind-format-panel { position:absolute; top:8px; right:8px; bottom:8px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid #e5e7e9; box-shadow:-5px 0 20px #00000005; display:flex; flex-direction:column; z-index:35; overflow:hidden; }
.xmind-panel-heading { display:flex; justify-content:space-between; align-items:center; min-height:36px; padding:0 12px; font-weight:600; }
.xmind-panel-heading button { border-radius:6px; padding:4px; color:#7a7e83; }
.xmind-panel-heading button:hover { background:#e2e4e6; }
.xmind-panel-tabs { display:flex; margin:4px 10px 10px; padding:3px; height:28px; border-radius:8px; background:#e7e8eb; flex-shrink:0; }
.xmind-panel-tabs button { flex:1; border-radius:6px; font-size:12px; color:#6d7276; }
.xmind-panel-tabs button[aria-selected=true] { background:#ffffffd9; color:#23282b; font-weight:600; }
.xmind-panel-content { overflow-y:auto; min-height:0; padding:0 12px 12px; scrollbar-width:thin; scrollbar-color:#b5b5b5 transparent; }
.xmind-topic-preview { min-height:48px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; display:flex; align-items:center; gap:8px; padding:8px; margin-bottom:12px; }
.xmind-topic-preview > span { flex:1; text-align:center; font-size:14px; padding:5px; }
.xmind-topic-preview svg { color:#5c6267; flex-shrink:0; }
.xmind-format-section { padding:12px 0; border-top:1px solid var(--xmind-border); }
.xmind-format-section h3 { display:flex; align-items:center; gap:5px; margin:0 0 10px; font-size:12px; font-weight:600; }
.xmind-setting-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; font-size:12px; }
.xmind-setting-row h3 { margin:0; }
.xmind-setting-row > span { flex-shrink:0; }
.xmind-setting-row select { height:25px; background:#fff; border:1px solid var(--xmind-border); border-radius:8px; min-width:74px; max-width:170px; padding:0 8px; font-size:12px; }
.xmind-color-control { height:25px; width:74px; padding:0; border:1px solid var(--xmind-border); border-radius:8px; overflow:hidden; background:white; cursor:pointer; }
.xmind-color-control::-webkit-color-swatch-wrapper { padding:0; }
.xmind-color-control::-webkit-color-swatch { border:0; border-radius:7px; }
.xmind-segmented { display:flex; border:1px solid var(--xmind-border); background:white; border-radius:8px; overflow:hidden; height:27px; }
.xmind-segmented button { flex:1; display:grid; place-items:center; margin:2px; border-radius:6px; }
.xmind-segmented button[aria-pressed=true] { background:#e4e6e8; }
.xmind-segmented button:hover:not(:disabled) { background:#f1f2f3; }
.xmind-shape-rectangle { width:18px; height:12px; border:1.5px solid currentColor; border-radius:3px; }
.xmind-shape-line { width:24px; border-bottom:1.5px solid currentColor; }
.xmind-text-marks { height:28px; margin-top:12px; }
.xmind-structure-trigger { display:flex; height:26px; align-items:center; padding:0 8px; background:white; border:1px solid var(--xmind-border); border-radius:8px; }
.xmind-structure-trigger svg[data-layout-preview] { width:35px; height:24px; }
.xmind-current-structure { padding:4px 10px; margin-top:14px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; font-size:12px; color:#7e8388; }
.xmind-structure-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; margin-top:12px; }
.xmind-structure-grid button { display:flex; flex-direction:column; justify-content:center; align-items:center; gap:4px; min-height:64px; border:1px solid #e0e3e7; border-radius:7px; font-size:9px; padding:4px 2px; background:white; }
.xmind-structure-grid button[aria-pressed=true] { border-color:#36b4ff; background:#edf8ff; color:#179cdc; }
.xmind-background-presets { display:grid; grid-template-columns:repeat(6,1fr); gap:6px; }
.xmind-background-presets button { height:28px; border:1px solid #d6d8dc; border-radius:6px; }
.xmind-background-presets button[aria-pressed=true] { outline:2px solid #35b5ff; }
.xmind-theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
.xmind-theme-grid button { background:white; border:1px solid #e1e3e6; border-radius:8px; font-size:11px; padding:8px; }
.xmind-theme-grid button[aria-pressed=true] { border-color:#35b5ff; }
.xmind-theme-grid button > span { display:flex; margin-bottom:8px; height:12px; overflow:hidden; border-radius:3px; }
.xmind-theme-grid i { flex:1; }
.xmind-switch { width:30px; height:18px; border-radius:12px; background:#bfc3c8; position:relative; }
.xmind-switch i { position:absolute; top:2px; left:2px; height:14px; width:14px; background:white; border-radius:50%; transition:transform .15s; }
.xmind-switch[aria-checked=true] { background:#35b5ff; }
.xmind-switch[aria-checked=true] i { transform:translateX(12px); }
.xmind-canvas-preview { height:96px; border:1px solid var(--xmind-border); border-radius:8px; margin-top:12px; }
.xmind-sheetbar { position:absolute; bottom:10px; left:16px; right:calc(var(--xmind-panel-offset,0px) + 16px); height:30px; z-index:40; display:flex; align-items:center; gap:8px; pointer-events:none; }
.xmind-sheetbar > * { pointer-events:auto; }
.xmind-sheet-tabs { display:flex; gap:4px; max-width:60%; overflow-x:auto; scrollbar-width:none; }
.xmind-sheet-tab-host { flex-shrink:0; }
.xmind-sheet-tab-host > button { border-radius:16px; padding:4px 18px; font-size:12px; font-weight:500; background:#ffffffd9; max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.xmind-sheet-tab-host > button[aria-pressed=true] { background:#e4e5e6; font-weight:600; }
.xmind-sheet-tab-host input { width:130px; height:25px; border-radius:6px; background:white; border:1px solid #35b5ff; padding:0 8px; }
.xmind-sheet-context { position:absolute; bottom:36px; min-width:140px; padding:5px; border:1px solid #e2e4e7; border-radius:10px; background:white; box-shadow:0 10px 25px #00000015; }
.xmind-sheet-context button { display:block; width:100%; text-align:left; padding:7px 10px; border-radius:6px; }
.xmind-sheet-context button:hover { background:#f0f3f5; }
.xmind-footer-divider { width:1px; height:13px; background:#dfe2e5; flex-shrink:0; }
.xmind-footer-icon { display:grid; place-items:center; width:26px; height:26px; border-radius:6px; }
.xmind-footer-icon:hover { background:#f2f3f5; }
.xmind-sheet-actions { opacity:0; }
.xmind-sheetbar:hover .xmind-sheet-actions,.xmind-sheet-actions:focus-within { opacity:1; }
.xmind-sheet-status { margin-left:auto; display:flex; gap:12px; align-items:center; background:#ffffffd9; }
.xmind-topic-count { font-size:11px; white-space:nowrap; }
.xmind-zoom-menu .xmind-command-menu,.xmind-sheet-actions .xmind-command-menu { top:auto; bottom:calc(100% + 8px); }
.xmind-zoom-menu .xmind-menu-trigger { font-size:12px; padding:0; min-width:50px; }
.xmind-outline-toggle { font-weight:600; font-size:13px; white-space:nowrap; }
.xmind-workspace .flow-board-canvas-shell { top:0; bottom:0; right:var(--xmind-panel-offset,0px); }
.xmind-workspace .board-host-svg { background:transparent !important; }
.xmind-workspace .board-active-svg { z-index:2; pointer-events:none; }
.xmind-workspace .active-host-g path[stroke="#6698FF"] { stroke:#387cff; stroke-width:2px; vector-effect:non-scaling-stroke; }
.xmind-workspace .active-host-g path[fill="#6698FF25"] { fill:rgba(56,124,255,.12); }
.xmind-outline-shell { position:absolute; inset:56px var(--xmind-panel-offset,0px) 48px 0; z-index:20; }
.xmind-workspace .xmind-aux-panel { top:8px; bottom:8px; right:8px; width:var(--xmind-panel-width); max-width:var(--xmind-panel-width); border-radius:12px; border:1px solid var(--xmind-border); background:var(--xmind-panel); }
.xmind-zen-exit { position:absolute; top:16px; right:16px; z-index:45; display:flex; gap:8px; align-items:center; padding:9px 14px; border-radius:24px; background:#fffffff0; box-shadow:0 4px 20px #0000000c; font-size:12px; }
.xmind-empty-canvas { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:20; border:1px solid #333; background:white; border-radius:8px; padding:8px 16px; font-size:16px; font-weight:600; }
.xmind-search-panel { position:absolute; top:64px; left:8px; bottom:48px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid var(--xmind-border); z-index:45; display:flex; flex-direction:column; box-shadow:5px 0 20px #00000008; }
.xmind-workspace:has(.xmind-search-panel) .xmind-pointer-toolbar { visibility:hidden; }
.xmind-search-panel .xmind-panel-content { padding:0 14px 14px; }
.xmind-search-box { display:flex; gap:8px; border-radius:8px; border:1px solid var(--xmind-border); background:white; padding:8px; align-items:center; margin:8px 0; }
.xmind-search-box input { outline:0; width:100%; background:transparent; font-size:13px; }
.xmind-search-options { display:flex; align-items:center; gap:12px; margin:10px 0; font-size:11px; }
.xmind-search-panel select { width:100%; height:30px; border-radius:8px; background:white; border:1px solid var(--xmind-border); padding:0 8px; font-size:12px; }
.xmind-search-navigation { display:flex; align-items:center; gap:8px; margin:12px 0; font-size:11px; color:#81868b; }
.xmind-search-navigation span { flex:1; }
.xmind-search-navigation button { padding:4px; border-radius:5px; }
.xmind-search-navigation button:hover { background:#e5e7e9; }
.xmind-search-result { display:block; width:100%; text-align:left; padding:10px; border-radius:8px; margin:4px 0; background:#ffffffa6; }
.xmind-search-result.is-active { outline:1px solid #35b5ff; background:#ecf8ff; }
.xmind-search-result > span { display:block; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.xmind-search-result small { font-size:10px; color:#8a8e91; }
.xmind-search-replace-buttons { display:flex; gap:8px; margin:10px 0 16px; }
.xmind-search-replace-buttons button { flex:1; border:1px solid var(--xmind-border); border-radius:6px; background:white; padding:5px; font-size:11px; }
.xmind-presentation { position:absolute; inset:0; z-index:60; background:#fff; display:flex; flex-direction:column; }
.xmind-presentation > header { display:flex; align-items:center; justify-content:space-between; padding:20px 28px; color:#8b9095; font-size:12px; }
.xmind-presentation > header button { display:flex; align-items:center; gap:6px; }
.xmind-presentation-slide { display:flex; flex:1; flex-direction:column; align-items:center; justify-content:center; padding:60px; overflow:auto; }
.xmind-presentation-slide h1 { font-size:clamp(28px,4vw,56px); font-weight:650; text-align:center; }
.xmind-presentation-slide ul { display:flex; flex-direction:column; gap:20px; margin-top:40px; font-size:clamp(18px,2vw,28px); }
.xmind-presentation-slide li { display:flex; align-items:center; gap:15px; }
.xmind-presentation-slide li::before { content:""; width:8px; height:8px; border-radius:50%; background:#ff7160; }
.xmind-presentation > footer { display:flex; justify-content:center; align-items:center; gap:20px; padding:20px; font-size:12px; color:#72787e; }
.xmind-presentation > footer button { padding:8px; border:1px solid #e6e7e9; border-radius:50%; }
@media (max-width:1100px) { .xmind-window-dots { display:none; } .xmind-document-heading { min-width:160px; gap:8px; } .xmind-document-name input { width:100px; } .xmind-utility-tools { gap:8px; } .xmind-primary-tools { gap:8px; } .xmind-topbar { gap:8px; padding-left:12px; } }
@media (max-width:850px) { .xmind-document-name { display:none; } .xmind-document-heading { min-width:auto; } .xmind-document-menu { opacity:1; } .xmind-utility-tools > .xmind-toolbar-pill:nth-child(2) { display:none; } .xmind-topbar { justify-content:space-between; } }
@media (max-width:640px) { .xmind-workspace { --xmind-panel-offset:0px !important; } .xmind-format-panel,.xmind-workspace .xmind-aux-panel { width:min(var(--xmind-panel-width),calc(100vw - 16px)); } .xmind-topbar { gap:4px; padding:12px 8px; } .xmind-home-button { width:32px; } .xmind-primary-tools { gap:4px; margin:0 auto; } .xmind-icon-button { width:28px; } .xmind-document-menu { display:none; } .xmind-topic-count { display:none; } .xmind-sheetbar { left:10px; right:10px; gap:5px; } .xmind-sheet-status { gap:6px; } .xmind-sheet-tabs { max-width:50%; } }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Regular.ttf') format('truetype'); font-weight:400; font-style:normal; font-display:swap; }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Medium.ttf') format('truetype'); font-weight:500; font-style:normal; font-display:swap; }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Bold.ttf') format('truetype'); font-weight:700; font-style:normal; font-display:swap; }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Extrabold.ttf') format('truetype'); font-weight:800; font-style:normal; font-display:swap; }
@layer base {
.xmind-workspace .drawnix,.xmind-workspace .drawnix * { --ui-font:Arial,"PingFang SC",sans-serif; font-family:Arial,"PingFang SC",sans-serif !important; }
.xmind-workspace .drawnix .plait-text-container,.xmind-workspace .drawnix .plait-text-container * { font-family:NeverMind,Arial,"PingFang SC",sans-serif !important; }
}
.xmind-native-icon { display:block; width:18px; height:18px; background:currentColor; mask-size:contain; mask-position:center; mask-repeat:no-repeat; }
/* Topic notes follow the native compact formatting toolbar. */
.xmind-notes-editor { border:1px solid #e2e5eb; border-radius:12px; background:#fafbfc; overflow:hidden; }
.xmind-notes-toolbar { display:flex; justify-content:center; align-items:center; gap:2px; padding:7px; border-bottom:1px solid #eceef1; }
.xmind-notes-toolbar button { display:grid; place-items:center; width:28px; height:28px; border-radius:6px; }
.xmind-notes-toolbar button:hover,.xmind-notes-toolbar button[aria-pressed=true] { background:#e9edf5; color:#3569e8; }
.xmind-note-content { min-height:180px; max-height:380px; overflow:auto; padding:12px; outline:none; font-size:13px; line-height:1.7; color:#30343b; counter-reset:note-number; }
.xmind-note-content p { margin:0 0 4px; position:relative; }
.xmind-note-content p[data-note-list] { padding-left:20px; }
.xmind-note-content p[data-note-list=bullet]::before { content:"•"; position:absolute; left:3px; }
.xmind-note-content p[data-note-list=number] { counter-increment:note-number; }
.xmind-note-content p[data-note-list=number]::before { content:counter(note-number) "."; position:absolute; left:0; }
.xmind-note-content a { color:#3569e8; text-decoration:underline; }
.xmind-note-link-form { padding:8px; display:flex; flex-wrap:wrap; gap:6px; }
.xmind-note-link-form input { min-width:0; flex:1; border:1px solid #dfe2e7; padding:4px; border-radius:5px; }
.xmind-note-link-form p { width:100%; color:#c0392b; font-size:11px; }
.xmind-task-backdrop { position:absolute; inset:0; background:rgb(20 27 40 / .16); z-index:85; display:grid; place-items:center; padding:20px; }
.xmind-task-editor { width:min(460px,100%); max-height:calc(100vh - 40px); overflow:auto; border:1px solid #e0e3e8; border-radius:16px; background:#fafbfc; color:#343b48; padding:18px; box-shadow:0 16px 64px rgb(20 27 40 / .18); font-size:12px; }
.xmind-task-editor header,.xmind-task-editor header h2 { display:flex; align-items:center; gap:8px; }
.xmind-task-editor header { justify-content:space-between; }.xmind-task-editor h2 { font-size:14px; font-weight:600; }
.xmind-task-name { margin:14px 0; font-size:15px; font-weight:600; overflow-wrap:anywhere; }
.xmind-task-fields { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.xmind-task-fields label { display:flex; flex-direction:column; gap:5px; }
.xmind-task-editor input,.xmind-task-editor select { width:100%; min-width:0; height:32px; padding:0 8px; border:1px solid #dfe2e7; background:white; border-radius:6px; outline:none; }
.xmind-task-editor input:focus,.xmind-task-editor select:focus { border-color:#799ce8; }
.xmind-task-editor h3 { font-weight:600; margin:16px 0 8px; }
.xmind-task-dependencies>div { display:flex; justify-content:space-between; padding:6px 0; }.xmind-task-add-dependency { display:grid; grid-template-columns:1fr 1fr 50px 34px; gap:5px; }
.xmind-task-editor footer { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-top:20px; }.xmind-task-editor footer button { border-radius:6px; padding:7px 11px; }.xmind-task-editor footer button:first-child { margin-right:auto; }
.xmind-primary-button { background:#3569e8; color:white; }.xmind-danger,.xmind-task-error { color:#c23c36; }.xmind-task-error { margin-top:10px; }
.xmind-gantt { position:absolute; inset:0; z-index:75; display:flex; flex-direction:column; background:#edf0f3; color:#30343b; font-size:12px; }
.xmind-gantt-toolbar { display:flex; align-items:center; justify-content:space-between; min-height:58px; padding:8px 16px; gap:10px; }.xmind-gantt-title,.xmind-gantt-actions { display:flex; align-items:center; gap:12px; }.xmind-gantt-title h2 { font-size:15px; font-weight:600; white-space:nowrap; }.xmind-gantt-title>span { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#7d8592; }
.xmind-gantt-actions>button,.xmind-gantt-title>button { display:grid; place-items:center; width:32px; height:32px; background:#fafbfc; border-radius:12px; }.xmind-gantt button:disabled { opacity:.35; cursor:default; }.xmind-gantt-zoom { display:flex; align-items:center; gap:8px; padding:6px 10px; background:#fafbfc; border-radius:16px; }.xmind-gantt-zoom input { width:90px; }
.xmind-gantt-filters { display:flex; align-items:center; gap:8px; padding:0 16px 10px; flex-wrap:wrap; }.xmind-gantt-filters label { display:flex; align-items:center; gap:6px; background:#fafbfc; padding:0 8px; border:1px solid #e0e3e8; border-radius:6px; }.xmind-gantt-filters input { width:130px; height:28px; outline:none; background:transparent; }.xmind-gantt-filters select { max-width:210px; height:30px; border:1px solid #e0e3e8; background:#fafbfc; padding:0 8px; border-radius:6px; }.xmind-gantt-filters>span { margin-left:auto; color:#7d8592; }
.xmind-gantt-body { min-height:0; flex:1; display:grid; grid-template-columns:minmax(290px,34%) minmax(0,1fr); gap:12px; padding:0 12px; }.xmind-gantt-table,.xmind-gantt-timeline { border:1px solid #dfe3e9; border-radius:12px; background:#fafbfc; overflow:hidden; min-width:0; position:relative; }.xmind-gantt-table { display:flex; flex-direction:column; }.xmind-gantt-table-head,.xmind-gantt-row { display:grid; grid-template-columns:minmax(0,1fr) 104px 54px; align-items:center; }.xmind-gantt-table-head { height:64px; min-height:64px; font-weight:600; border-bottom:1px solid #e2e5eb; }.xmind-gantt-table-head>span { display:flex; align-items:center; height:100%; padding:0 12px; border-right:1px solid #e2e5eb; }.xmind-gantt-row-scroll { overflow:auto; scrollbar-width:none; flex:1; position:relative; }.xmind-gantt-row { border-bottom:1px solid #e2e5eb; }.xmind-gantt-row.is-selected { background:#edf3ff; }.xmind-gantt-row>span { padding:0 7px; }.xmind-gantt-row-name { display:flex; align-items:center; gap:5px; overflow:hidden; }.xmind-gantt-row-name input { flex-shrink:0; }.xmind-gantt-task-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; }.xmind-gantt-date { font-size:11px; text-align:left; padding:0 8px; }
.xmind-gantt-timeline { overflow:auto; }.xmind-gantt-grid { position:relative; background-image:linear-gradient(to right,#e7e9ee 1px,transparent 1px),linear-gradient(to bottom,#e7e9ee 1px,transparent 1px); background-position:0 64px; }.xmind-gantt-calendar { position:sticky; top:0; z-index:5; background:#fafbfc; border-bottom:1px solid #e2e5eb; }.xmind-gantt-day { position:absolute; top:0; height:64px; display:flex; flex-direction:column; justify-content:space-around; align-items:center; border-right:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }.xmind-gantt-day span { font-size:10px; color:#8b929d; }.xmind-gantt-day b { font-weight:500; }.xmind-gantt-today { position:absolute; bottom:0; width:2px; background:#fa603b; pointer-events:none; z-index:2; }.xmind-gantt-connections { position:absolute; inset:0; pointer-events:none; z-index:2; }.xmind-gantt-bar { position:absolute; height:26px; border-radius:16px; background:#fac28a; cursor:grab; touch-action:none; z-index:3; overflow:hidden; min-width:6px; border:1px solid #f5b978; }.xmind-gantt-bar:focus { outline:2px solid #3569e8; outline-offset:2px; }.xmind-gantt-progress { position:absolute; inset:0 auto 0 0; background:#f3a660; }.xmind-gantt-bar.is-complete { background:#b8dcca; border-color:#68a986; }.xmind-gantt-bar.is-complete .xmind-gantt-progress { background:#86b99b; }.xmind-gantt-bar.is-summary { height:10px; margin-top:4px; background:#667387; border-color:#667387; cursor:default; }.xmind-gantt-bar-label { position:relative; display:flex; align-items:center; gap:8px; padding:0 6px; height:24px; color:#72491f; font-size:11px; pointer-events:none; white-space:nowrap; }
.xmind-gantt-bar-label>span:last-child { flex:1; overflow:hidden; text-overflow:ellipsis; text-align:center; }
.xmind-gantt-avatar { width:18px; height:18px; border-radius:50%; background:#eceff1; color:#939ba2; display:grid; place-items:center; flex-shrink:0; }
.xmind-gantt-selection { position:absolute; left:0; background:rgb(125 136 148 / .15); pointer-events:none; z-index:1; }
.xmind-gantt-bar[aria-pressed=true] { outline:2px solid #539efa; outline-offset:1px; }.xmind-gantt-bar.is-summary .xmind-gantt-bar-label { display:none; }.xmind-gantt-resize { position:absolute; top:0; bottom:0; width:6px; cursor:ew-resize; z-index:4; }.xmind-gantt-resize.is-start { left:0; }.xmind-gantt-resize.is-due { right:0; }
.xmind-gantt-empty { min-height:300px; height:70%; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#a0a6af; gap:10px; }.xmind-gantt-empty strong { font-size:15px; color:#424852; }.xmind-gantt-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:32px; padding:0 16px; color:#7d8592; font-size:11px; }
@media(max-width:800px) { .xmind-gantt-title>span { display:none; }.xmind-gantt-body { grid-template-columns:260px minmax(0,1fr); gap:6px; padding:0 6px; }.xmind-gantt-table-head,.xmind-gantt-row { grid-template-columns:minmax(0,1fr) 84px 42px; }.xmind-gantt-actions { gap:5px; }.xmind-gantt-toolbar { padding:8px; }.xmind-gantt-zoom input { width:50px; } }
@media print { .xmind-workspace:has(.xmind-gantt)>:not(.xmind-gantt) { display:none!important; }.xmind-gantt { position:fixed; background:white; }.xmind-gantt-toolbar button,.xmind-gantt-actions,.xmind-gantt-filters,.xmind-gantt-footer { display:none; }.xmind-gantt-timeline,.xmind-gantt-row-scroll { overflow:visible; }.xmind-gantt-body { overflow:visible; }.xmind-gantt-table { overflow:visible; }.xmind-gantt-row-name input { display:none; } }
.xmind-gantt-grid { min-height:100%; }
.xmind-gantt-day { top:28px; height:36px; }
.xmind-gantt-month { position:absolute; top:0; height:28px; padding:6px 12px; text-align:center; color:#7d8592; border-right:1px solid #e2e5eb; border-bottom:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }
.xmind-topic-badges { position:absolute; inset:0; pointer-events:none; z-index:12; overflow:hidden; }
.xmind-topic-badge { position:absolute; display:flex; align-items:center; gap:2px; pointer-events:auto; }
.xmind-topic-badge button { display:grid; place-items:center; width:19px; height:19px; background:rgb(255 255 255 / .9); border-radius:4px; color:#8b929d; }
.xmind-topic-badge button:hover { background:#edf3ff; color:#3569e8; }
.xmind-note-content p:not([data-note-list=number]) { counter-reset:note-number; }
.xmind-gantt-day-off { position:absolute; background:rgb(111 124 139 / .09); pointer-events:none; z-index:0; }
.xmind-gantt-day.is-day-off { color:#929aa6; background:#eef0f3; }
.xmind-task-calendar-info { display:flex; justify-content:space-between; align-items:center; gap:10px; margin:12px 0; color:#7d8592; font-size:11px; }
.xmind-task-calendar-info button { padding:6px 10px; border:1px solid #dce1e8; border-radius:6px; background:#f7f8fa; color:#3b4452; }
.xmind-project-calendar-backdrop,.xmind-calendar-exception-backdrop { position:absolute; inset:0; z-index:100; display:flex; align-items:center; justify-content:center; background:rgb(29 36 46 / .22); padding:16px; }
.xmind-calendar-exception-backdrop { position:fixed; z-index:110; }
.xmind-project-calendar,.xmind-calendar-exception { display:flex; flex-direction:column; width:740px; max-width:100%; max-height:calc(100svh - 32px); color:#343b48; font-size:12px; background:#f7f8fa; border:1px solid #dce1e8; border-radius:14px; box-shadow:0 18px 60px rgb(0 0 0 / .17); overflow:hidden; }
.xmind-project-calendar>header,.xmind-calendar-exception>header { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; padding:18px 20px; }
.xmind-project-calendar h2,.xmind-calendar-exception h2 { display:flex; align-items:center; gap:9px; font-size:16px; font-weight:600; }
.xmind-project-calendar>header p { margin-top:6px; font-size:11px; color:#8b929d; }
.xmind-project-calendar>header button,.xmind-calendar-exception>header button { padding:4px; border-radius:5px; }
.xmind-project-calendar-content { display:grid; grid-template-columns:290px minmax(0,1fr); gap:12px; padding:0 20px 18px; min-height:0; overflow:auto; }
.xmind-calendar-preview,.xmind-calendar-settings { padding:15px; border:1px solid #e0e4ea; border-radius:10px; background:#fff; }
.xmind-project-calendar label,.xmind-calendar-exception label { display:flex; flex-direction:column; gap:6px; font-size:11px; }
.xmind-project-calendar input:not([type=checkbox]),.xmind-project-calendar select,.xmind-calendar-exception input,.xmind-calendar-exception select { height:31px; padding:0 9px; border:1px solid #dce1e8; background:#fff; border-radius:6px; font-size:12px; outline:none; min-width:0; width:100%; }
.xmind-project-calendar input:focus,.xmind-project-calendar select:focus,.xmind-calendar-exception input:focus,.xmind-calendar-exception select:focus { border-color:#6592ef; box-shadow:0 0 0 2px rgb(53 105 232 / .1); }
.xmind-project-calendar h3 { font-size:12px; font-weight:600; }
.xmind-calendar-import { display:flex; align-items:flex-end; gap:8px; margin-bottom:18px; }.xmind-calendar-import label { flex:1; min-width:0; }.xmind-calendar-import>button { color:#3569e8; font-size:11px; margin-bottom:8px; }
.xmind-calendar-month-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:9px; }.xmind-calendar-month-header button { padding:5px; }.xmind-calendar-month-header strong { font-weight:500; }
.xmind-calendar-month-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }.xmind-calendar-month-grid>button,.xmind-calendar-weekday { height:29px; display:grid; place-items:center; border-radius:5px; font-size:11px; }
.xmind-calendar-weekday { color:#8b929d; }.xmind-calendar-month-grid .is-day-off { background:repeating-linear-gradient(135deg,#f1f3f6,#f1f3f6 3px,#e9ecf0 3px,#e9ecf0 4px); color:#89929e; }.xmind-calendar-month-grid .is-other-month { opacity:.4; }.xmind-calendar-month-grid>button[aria-pressed=true] { background:#edf3ff; box-shadow:inset 0 0 0 1px #6592ef; color:#3569e8; }
.xmind-calendar-hint { color:#929aa6; font-size:10px; line-height:1.6; margin-top:9px; }
.xmind-project-calendar .xmind-calendar-skip { flex-direction:row; align-items:center; gap:7px; margin:18px 0; font-size:12px; }.xmind-calendar-working-week { display:flex; justify-content:space-between; gap:5px; padding:12px 0 18px; border-bottom:1px solid #e9edf2; }.xmind-calendar-working-week label { align-items:center; }
.xmind-calendar-rule-heading { display:flex; align-items:center; justify-content:space-between; margin:16px 0 9px; }.xmind-calendar-rule-heading button { padding:5px; background:#f2f4f7; border-radius:5px; }.xmind-calendar-rules { max-height:225px; overflow:auto; }.xmind-calendar-rules>div { display:flex; justify-content:space-between; align-items:center; gap:7px; padding:9px 0; border-bottom:1px solid #eef0f4; }.xmind-calendar-rules>div>button:first-child { flex:1; text-align:left; min-width:0; }.xmind-calendar-rules strong,.xmind-calendar-rules span { display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.xmind-calendar-rules strong { font-weight:500; }.xmind-calendar-rules span { font-size:10px; color:#8b929d; margin-top:5px; }.xmind-calendar-rules>div>button:last-child { padding:6px; color:#8b929d; }.xmind-calendar-rules>p { color:#929aa6; font-size:11px; padding:10px 0; }
.xmind-project-calendar>footer,.xmind-calendar-exception>footer { padding:13px 20px; display:flex; justify-content:space-between; gap:10px; border-top:1px solid #e2e6ec; background:#fff; }.xmind-project-calendar>footer>div { display:flex; gap:8px; }.xmind-project-calendar>footer button,.xmind-calendar-exception>footer button,.xmind-calendar-personal>button { padding:8px 12px; border:1px solid #dce1e8; border-radius:6px; font-size:11px; }.xmind-project-calendar>footer .xmind-primary-button,.xmind-calendar-exception>footer .xmind-primary-button { border-color:#3569e8; }
.xmind-calendar-personal { border-top:1px solid #e6e9ee; margin-top:14px; padding-top:12px; }.xmind-calendar-personal p { color:#929aa6; font-size:10px; margin:7px 0; }.xmind-calendar-personal>div { display:flex; justify-content:space-between; align-items:center; margin:9px 0; }.xmind-calendar-personal>label { margin:12px 0 8px; }
.xmind-calendar-exception { width:420px; }.xmind-calendar-exception-fields { display:flex; flex-direction:column; gap:12px; padding:0 20px 20px; overflow:auto; }.xmind-calendar-range { display:grid; grid-template-columns:1fr 1fr; gap:12px; }.xmind-calendar-exception>footer { justify-content:flex-end; }
@media(max-width:700px) { .xmind-project-calendar-content { grid-template-columns:1fr; }.xmind-calendar-preview { max-width:360px; width:100%; justify-self:center; }.xmind-project-calendar>footer { flex-wrap:wrap; }.xmind-project-calendar>footer>div:last-child { margin-left:auto; } }
.xmind-notes-popover { position:absolute; z-index:40; width:min(360px,calc(100vw - 32px)); padding:8px; background:#fafbfc; border:1px solid #e5e8ed; border-radius:18px; box-shadow:0 14px 44px rgb(0 0 0 / .14); color:#30343b; }
.xmind-notes-popover>header { display:flex; align-items:center; justify-content:space-between; padding:0 6px; height:24px; font-size:11px; color:#9198a3; }.xmind-notes-popover>header>span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.xmind-notes-popover>header>div { display:flex; align-items:center; gap:8px; }
.xmind-notes-popover .xmind-notes-editor { border:0; border-radius:12px; }.xmind-notes-popover .xmind-notes-toolbar { border-bottom:0; padding:4px; }.xmind-notes-popover .xmind-note-content { min-height:220px; max-height:280px; padding:8px; }.xmind-note-empty { min-height:220px; padding:16px; font-size:12px; color:#8b929d; }
.xmind-image-export { width:400px; max-width:calc(100vw - 32px); border:1px solid #e5e5e8; border-radius:14px; padding:24px; color:#303034; background:white; box-shadow:0 18px 60px #0002; font:14px NeverMind,Arial,"PingFang SC",sans-serif; }
.xmind-image-export::backdrop { background:#0004; }
.xmind-image-export header,.xmind-image-export footer { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.xmind-image-export h2 { margin:0; font-size:18px; font-weight:600; }
.xmind-image-export p { color:#737378; margin:18px 0 24px; line-height:1.6; }
.xmind-image-export button { display:inline-flex; gap:7px; align-items:center; justify-content:center; min-height:34px; border:0; border-radius:7px; padding:7px 12px; background:#f2f2f4; color:inherit; cursor:pointer; }
.xmind-image-export button:disabled { opacity:.55; cursor:wait; }
.xmind-image-export button.xmind-primary-button { background:#2463eb; color:white; }
.xmind-image-export-field { display:flex; align-items:center; justify-content:space-between; gap:20px; }
.xmind-image-export select { width:220px; height:36px; background:white; border:1px solid #dcdce2; border-radius:7px; padding:0 10px; color:inherit; }
.xmind-image-export-check { display:flex; align-items:center; gap:8px; margin:20px 0 28px; }
.xmind-image-export-check input { accent-color:#2463eb; }
.xmind-image-export footer { justify-content:flex-end; }

View File

@ -0,0 +1,318 @@
"use client";
import {
Bold,
Italic,
Underline,
Strikethrough,
List,
ListOrdered,
Link2,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
createEditor,
Editor,
Element,
Transforms,
type Descendant,
} from "slate";
import { withHistory } from "slate-history";
import {
Editable,
Slate,
withReact,
ReactEditor,
type RenderElementProps,
type RenderLeafProps,
} from "slate-react";
import {
type NoteDocument,
type NoteParagraph,
safeNoteLink,
serializeNoteDocument,
} from "@/lib/flow-board/topic-notes";
export function XmindNotesEditor({
value,
onChange,
}: {
value: NoteDocument;
onChange: (value: NoteDocument) => void;
}) {
const [editor] = useState(() => withHistory(withReact(createEditor())));
const sourceRef = useRef(value);
sourceRef.current = value;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const [revision, setRevision] = useState(0);
const [linkOpen, setLinkOpen] = useState(false),
[link, setLink] = useState("");
const [linkError, setLinkError] = useState("");
const savedSelection = useRef(editor.selection);
useEffect(() => {
if (
JSON.stringify(serializeNoteDocument(editor.children as NoteDocument)) ===
JSON.stringify(serializeNoteDocument(value))
)
return;
editor.children = value as Descendant[];
editor.selection = null;
editor.history = { undos: [], redos: [] };
editor.onChange();
}, [editor, value]);
const marks = Editor.marks(editor) as Record<string, unknown> | null;
// Toolbar presses can arrive before Slate's throttled selection listener.
const syncDOMSelection = () => {
const selection = window.getSelection();
if (
!selection?.anchorNode ||
!ReactEditor.hasDOMNode(editor, selection.anchorNode)
)
return;
const range = ReactEditor.toSlateRange(editor, selection, {
exactMatch: false,
suppressThrow: true,
});
if (range) Transforms.select(editor, range);
};
const toggleMark = (
mark: "bold" | "italic" | "underline" | "strikethrough"
) => {
syncDOMSelection();
const currentMarks = Editor.marks(editor) as Record<string, unknown> | null;
if (currentMarks?.[mark]) Editor.removeMark(editor, mark);
else Editor.addMark(editor, mark, true);
setRevision((v) => v + 1);
};
const toggleList = (list: "bullet" | "number") => {
syncDOMSelection();
const match = Array.from(
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
)[0];
Transforms.setNodes(
editor,
{
list: (match?.[0] as NoteParagraph)?.list === list ? undefined : list,
} as Partial<Descendant>,
{ match: (n) => Element.isElement(n) }
);
};
const applyLink = () => {
const href = link.trim() ? safeNoteLink(link.trim()) : "";
if (href === null) {
setLinkError("请输入完整的网页或邮件链接。");
return;
}
if (savedSelection.current)
Transforms.select(editor, savedSelection.current);
if (href) Editor.addMark(editor, "href", href);
else Editor.removeMark(editor, "href");
setLinkOpen(false);
editor.onChange();
};
const leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
const t = leaf as {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
href?: string;
};
const href = safeNoteLink(t.href || "");
return (
<span
{...attributes}
style={{
fontWeight: t.bold ? 700 : undefined,
fontStyle: t.italic ? "italic" : undefined,
textDecoration:
[
t.underline ? "underline" : "",
t.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || undefined,
}}
>
{href ? (
<a
href={href}
title={href}
rel="noopener noreferrer"
onClick={(e) => {
if (!e.metaKey && !e.ctrlKey) e.preventDefault();
}}
target="_blank"
>
{children}
</a>
) : (
children
)}
</span>
);
};
const element = ({ attributes, children, element }: RenderElementProps) => {
const p = element as NoteParagraph;
return (
<p
{...attributes}
data-note-list={p.list}
style={{ marginLeft: Math.max(0, Math.min(120, p.indent || 0)) }}
>
{children}
</p>
);
};
return (
<div className="xmind-notes-editor" data-editor-revision={revision}>
<Slate
editor={editor}
initialValue={value as Descendant[]}
onChange={(next) => {
setRevision((v) => v + 1);
if (editor.operations.every((o) => o.type === "set_selection"))
return;
const doc = next as NoteDocument;
if (
JSON.stringify(serializeNoteDocument(doc)) !==
JSON.stringify(serializeNoteDocument(sourceRef.current))
)
onChangeRef.current(doc);
}}
>
<div
className="xmind-notes-toolbar"
role="toolbar"
aria-label="笔记格式"
>
{(
[
["bold", "加粗", Bold],
["italic", "斜体", Italic],
["underline", "下划线", Underline],
["strikethrough", "删除线", Strikethrough],
] as const
).map(([mark, label, Icon]) => (
<button
key={mark}
type="button"
aria-label={`笔记${label}`}
aria-pressed={Boolean(marks?.[mark])}
onMouseDown={(e) => {
e.preventDefault();
toggleMark(mark);
}}
>
<Icon size={17} />
</button>
))}
<button
type="button"
aria-label="笔记无序列表"
onMouseDown={(e) => {
e.preventDefault();
toggleList("bullet");
}}
>
<List size={17} />
</button>
<button
type="button"
aria-label="笔记有序列表"
onMouseDown={(e) => {
e.preventDefault();
toggleList("number");
}}
>
<ListOrdered size={17} />
</button>
<button
type="button"
aria-label="笔记链接"
onMouseDown={(e) => {
e.preventDefault();
syncDOMSelection();
savedSelection.current = editor.selection;
setLink(String(marks?.href || ""));
setLinkError("");
setLinkOpen(!linkOpen);
}}
>
<Link2 size={17} />
</button>
</div>
{linkOpen ? (
<div className="xmind-note-link-form">
<input
aria-label="笔记链接地址"
placeholder="https://…"
value={link}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
}
}}
/>
<button type="button" onClick={applyLink}>
确定
</button>
<button
type="button"
aria-label="取消笔记链接"
onClick={() => setLinkOpen(false)}
>
<X size={14} />
</button>
{linkError ? <p role="alert">{linkError}</p> : null}
</div>
) : null}
<Editable
aria-label="主题备注"
className="xmind-note-content"
placeholder="为这个主题补充说明…"
renderLeaf={leaf}
renderElement={element}
onKeyDown={(e) => {
if (
(e.metaKey || e.ctrlKey) &&
["b", "i", "u"].includes(e.key.toLowerCase())
) {
e.preventDefault();
toggleMark(
(
{ b: "bold", i: "italic", u: "underline" } as Record<
string,
"bold" | "italic" | "underline"
>
)[e.key.toLowerCase()]
);
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
e.stopPropagation();
if (e.key === "Tab") {
e.preventDefault();
const match = Array.from(
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
)[0];
const indent = (match?.[0] as NoteParagraph)?.indent || 0;
Transforms.setNodes(
editor,
{
indent: Math.max(
0,
Math.min(120, indent + (e.shiftKey ? -20 : 20))
),
} as Partial<Descendant>,
{ match: (n) => Element.isElement(n) }
);
}
}}
/>
</Slate>
</div>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import {
type PlaitBoard,
type PlaitElement,
toHostPointFromViewBoxPoint,
toScreenPointFromHostPoint,
} from "@plait/core";
import { getRectangleByNode, MindElement } from "@plait/mind";
import { MoreHorizontal, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
getNoteDocument,
type NoteDocument,
} from "@/lib/flow-board/topic-notes";
import { getTopicTitle } from "@/lib/flow-board/topic-details";
import { XmindNotesEditor } from "./xmind-notes-editor";
export function XmindNotesPopover({
board,
topic,
viewport,
onChange,
onDetails,
onClose,
}: {
board: PlaitBoard | null;
topic: PlaitElement | null;
viewport: unknown;
onChange: (doc: NoteDocument) => void;
onDetails: () => void;
onClose: () => void;
}) {
const ref = useRef<HTMLElement>(null);
const [position, setPosition] = useState({ left: 400, top: 300 });
useEffect(() => {
if (!board || !topic) return;
const update = () => {
const node = MindElement.getNode(topic as MindElement);
if (!node) return;
const rect = getRectangleByNode(node),
point = toScreenPointFromHostPoint(
board,
toHostPointFromViewBoxPoint(board, [
rect.x + rect.width / 2,
rect.y + rect.height,
])
);
const host = ref.current?.parentElement?.getBoundingClientRect();
const width = Math.min(360, window.innerWidth - 32);
const right = window.innerWidth > 800 ? 240 : 0;
const left = Math.max(
12,
Math.min(window.innerWidth - right - width - 16, point[0] - width / 2)
);
const top = Math.max(
78,
Math.min(window.innerHeight - 340, point[1] + 12)
);
setPosition({ left: left - (host?.x || 0), top: top - (host?.y || 0) });
};
const frame = requestAnimationFrame(update);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
};
}, [board, topic?.id, topic?.data, viewport]);
return (
<aside
ref={ref}
className="xmind-notes-popover"
aria-label="主题笔记"
style={position}
data-flow-board-shell-control="true"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
}
}}
>
<header>
<span title={topic ? getTopicTitle(topic) : "笔记"}>
{topic ? getTopicTitle(topic) : "笔记"}
</span>
<div>
<button
type="button"
aria-label="主题详情"
title="主题详情"
onClick={onDetails}
>
<MoreHorizontal size={15} />
</button>
<button type="button" aria-label="关闭笔记" onClick={onClose}>
<X size={15} />
</button>
</div>
</header>
{topic ? (
<XmindNotesEditor
key={topic.id}
value={getNoteDocument(topic)}
onChange={onChange}
/>
) : (
<p className="xmind-note-empty">选择一个主题以编辑笔记。</p>
)}
</aside>
);
}

View File

@ -0,0 +1,310 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronRight,
GripVertical,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from "lucide-react";
import type { PlaitElement } from "@plait/core";
import {
getTopicDetails,
getTopicEntries,
type TopicEntry,
} from "@/lib/flow-board/topic-details";
import type {
OutlineCommand,
OutlineDrop,
} from "@/lib/flow-board/mind-outline";
type Props = {
zoomPercent: number;
elements: PlaitElement[];
selectedId: string | null;
onSelect: (id: string) => void;
onRename: (id: string, title: string) => void;
onToggle: (id: string) => void;
onCommand: (
id: string | null,
command: OutlineCommand | OutlineDrop
) => string | null;
onUndo: () => void;
onRedo: () => void;
};
function OutlineRow({
entry,
selected,
onSelect,
onRename,
onToggle,
onCommand,
onFocusTopic,
}: {
entry: TopicEntry;
selected: boolean;
onSelect: Props["onSelect"];
onRename: Props["onRename"];
onToggle: Props["onToggle"];
onCommand: Props["onCommand"];
onFocusTopic: (id: string | null, direction?: number) => void;
}) {
const { element, title, depth } = entry;
const [draft, setDraft] = useState(title);
const [drop, setDrop] = useState<OutlineDrop["placement"] | null>(null);
const ref = useRef<HTMLInputElement>(null);
const details = getTopicDetails(element);
useEffect(() => setDraft(title), [title]);
const commit = () => {
if (draft !== title) onRename(element.id, draft);
};
const act = (command: OutlineCommand) => {
commit();
onFocusTopic(onCommand(element.id, command));
};
return (
<div
data-outline-id={element.id}
data-depth={depth}
className={`group flex min-h-11 items-center gap-1 border-b border-[#f1f2f5] px-3 ${selected ? "bg-[#f0f5ff]" : "hover:bg-[#fafbfc]"} ${drop === "child" ? "ring-1 ring-inset ring-blue-400" : drop === "before" ? "border-t-2 border-t-blue-400" : drop === "after" ? "border-b-2 border-b-blue-400" : ""}`}
style={{ paddingLeft: 12 + Math.min(depth, 20) * 24 }}
onDragOver={(event) => {
event.preventDefault();
const box = event.currentTarget.getBoundingClientRect();
const y = (event.clientY - box.top) / box.height;
setDrop(y < 0.25 ? "before" : y > 0.75 ? "after" : "child");
}}
onDragLeave={() => setDrop(null)}
onDrop={(event) => {
event.preventDefault();
const source = event.dataTransfer.getData("application/x-mind-topic");
if (source && drop)
onFocusTopic(
onCommand(source, { targetId: element.id, placement: drop })
);
setDrop(null);
}}
>
<span
draggable
aria-label={`拖动 ${title}`}
className="cursor-grab text-[#9ca3af] opacity-30 group-hover:opacity-100"
onDragStart={(event) => {
event.dataTransfer.setData("application/x-mind-topic", element.id);
event.dataTransfer.effectAllowed = "move";
}}
>
<GripVertical size={13} />
</span>
{element.children?.length ? (
<button
type="button"
className="p-1 text-[#64748b]"
aria-label={`${element.isCollapsed ? "展开" : "折叠"} ${title}`}
onClick={() => onToggle(element.id)}
>
{element.isCollapsed ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
</button>
) : (
<span className="w-[22px] text-center text-[#a2a9b3]">·</span>
)}
<input
ref={ref}
aria-label={`大纲主题 ${title}`}
data-outline-input={element.id}
className={`min-w-0 flex-1 bg-transparent py-2 text-sm outline-none ${depth === 0 ? "font-semibold text-[#253046]" : "text-[#404958]"}`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onFocus={() => onSelect(element.id)}
onBlur={commit}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter") {
event.preventDefault();
act(event.shiftKey || depth === 0 ? "child" : "sibling");
} else if (event.key === "Tab") {
event.preventDefault();
act(event.shiftKey ? "outdent" : "indent");
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
commit();
if (event.altKey) act(event.key === "ArrowUp" ? "up" : "down");
else onFocusTopic(element.id, event.key === "ArrowUp" ? -1 : 1);
} else if (event.key === "Escape") {
event.preventDefault();
setDraft(title);
} else if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "z" &&
draft !== title
) {
event.preventDefault();
event.stopPropagation();
setDraft(title);
} else if (event.key === "Backspace" && !draft) {
event.preventDefault();
onFocusTopic(onCommand(element.id, "delete"));
}
}}
/>
{Number.isInteger(element.start) ? (
<span className="text-[10px] text-[#8d79b2]">概要</span>
) : null}
{details.notes ? (
<span title={details.notes} className="text-[10px] text-[#8a94a5]">
备注
</span>
) : null}
{details.labels.map((label) => (
<span
key={label}
className="hidden rounded bg-[#e8effc] px-1.5 py-0.5 text-[10px] text-[#5879b4] lg:inline"
>
{label}
</span>
))}
<button
type="button"
aria-label={`为 ${title} 添加子主题`}
className="p-1 text-[#8a94a5] opacity-0 focus:opacity-100 group-hover:opacity-100"
onClick={() => act("child")}
>
<Plus size={14} />
</button>
</div>
);
}
export function XmindOutline(props: Props) {
const { elements, selectedId, onCommand } = props;
const container = useRef<HTMLElement>(null);
const [focusId, setFocusId] = useState<string | null>(null);
const entries = getTopicEntries(elements);
const collapsedPaths = entries
.filter(({ element }) => element.isCollapsed)
.map((entry) => entry.path);
const visible = entries.filter(
(entry) =>
!collapsedPaths.some(
(path) =>
path.length < entry.path.length &&
path.every((index, i) => index === entry.path[i])
)
);
useEffect(() => {
if (!focusId) return;
const input = Array.from(
container.current?.querySelectorAll<HTMLInputElement>(
"[data-outline-input]"
) || []
).find((node) => node.dataset.outlineInput === focusId);
if (input) {
input.focus();
input.select();
setFocusId(null);
}
}, [focusId, elements]);
const focusTopic = (id: string | null, direction?: number) => {
if (!id) return;
const target = direction
? visible[
visible.findIndex((entry) => entry.element.id === id) + direction
]?.element.id
: id;
if (target) setFocusId(target);
};
const action = (command: OutlineCommand) =>
focusTopic(onCommand(selectedId, command));
return (
<section
ref={container}
aria-label="大纲视图"
className="h-full overflow-auto bg-white"
data-flow-board-shell-control="true"
onKeyDown={(event) => {
event.stopPropagation();
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "z"
) {
event.preventDefault();
if (event.shiftKey) props.onRedo();
else props.onUndo();
}
}}
>
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-[#e5e8ee] bg-white px-5 py-3">
<h2 className="mr-2 text-sm font-semibold">大纲</h2>
<span className="mr-auto text-xs text-[#949ba7]">
{entries.length} 个主题
</span>
{(
[
{ command: "child", label: "大纲新增子主题", icon: Plus },
{ command: "indent", label: "大纲缩进", icon: IndentIncrease },
{ command: "outdent", label: "大纲提升层级", icon: IndentDecrease },
{ command: "up", label: "大纲上移", icon: ArrowUp },
{ command: "down", label: "大纲下移", icon: ArrowDown },
{ command: "delete", label: "大纲删除主题", icon: Trash2 },
] as const
).map(({ command, label, icon: Icon }) => (
<button
key={command}
type="button"
title={label}
aria-label={label}
className="rounded p-1.5 text-[#64748b] hover:bg-[#edf3ff] disabled:opacity-30"
disabled={
(!selectedId && entries.length > 0) ||
(!selectedId && command !== "child")
}
onClick={() => action(command)}
>
<Icon size={16} />
</button>
))}
</div>
<div
className="mx-auto max-w-4xl px-4 py-6"
style={{ zoom: props.zoomPercent / 100 }}
>
{!entries.length ? (
<button
type="button"
onClick={() => action("child")}
className="w-full rounded border border-dashed border-[#cbd5e1] p-8 text-sm text-[#64748b]"
>
添加中心主题
</button>
) : (
visible.map((entry) => (
<OutlineRow
key={entry.element.id}
entry={entry}
selected={entry.element.id === selectedId}
onSelect={props.onSelect}
onRename={props.onRename}
onToggle={props.onToggle}
onCommand={onCommand}
onFocusTopic={focusTopic}
/>
))
)}
<p className="mt-6 text-xs text-[#a1a8b4]">
Enter 新增同级 · Shift+Enter 新增子主题 · Tab 缩进 · Shift+Tab
提升层级 · Alt+↑↓ 调整顺序
</p>
</div>
</section>
);
}

View File

@ -0,0 +1,28 @@
"use client";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { useEffect, useState } from "react";
import type { PlaitElement } from "@plait/core";
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
export function XmindPresentation({ elements, title, onClose }: { elements: PlaitElement[]; title: string; onClose: () => void }) {
const slides = getTopicEntries(elements).filter(({ element, depth }) => depth === 0 || element.children?.length);
const [index,setIndex] = useState(0);
const entry = slides[Math.min(index,slides.length - 1)];
const next = () => setIndex(value => Math.min(slides.length - 1,value + 1));
const previous = () => setIndex(value => Math.max(0,value - 1));
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); onClose(); }
if (["ArrowRight","ArrowDown"," ","PageDown"].includes(event.key)) { event.preventDefault(); next(); }
if (["ArrowLeft","ArrowUp","PageUp"].includes(event.key)) { event.preventDefault(); previous(); }
};
window.addEventListener("keydown",onKey);
return () => window.removeEventListener("keydown",onKey);
}, [onClose,slides.length]);
return <section className="xmind-presentation" aria-label="演示视图" data-flow-board-shell-control="true">
<header><span>{title}</span><button type="button" aria-label="退出演示" onClick={onClose}><X size={16} />退出演示</button></header>
<div className="xmind-presentation-slide"><h1>{entry?.title || title}</h1><ul>{(entry?.element.children || []).map(child => <li key={child.id}>{getTopicTitle(child)}</li>)}</ul></div>
<footer><button type="button" aria-label="上一页" disabled={index === 0} onClick={previous}><ChevronLeft size={18} /></button><span>{Math.min(index + 1,slides.length)} / {slides.length}</span><button type="button" aria-label="下一页" disabled={index >= slides.length - 1} onClick={next}><ChevronRight size={18} /></button></footer>
</section>;
}

View File

@ -0,0 +1,650 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
CalendarDays,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
X,
} from "lucide-react";
import {
CALENDAR_FREQUENCIES,
defaultWorkingCalendar,
isWorkingDate,
readWorkingCalendar,
validateWorkingCalendar,
type CalendarRule,
type ProjectCalendarConfig,
type ProjectWorkingCalendar,
} from "@/lib/flow-board/project-calendar";
import {
addDays,
dateIndex,
DAY,
indexDate,
todayDate,
} from "@/lib/flow-board/calendar-dates";
const STORAGE_KEY = "flow-board.personal-calendars.v1";
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"];
const FREQUENCY_LABELS: Record<CalendarRule["frequency"], string> = {
NONE: "不重复",
DAILY: "每天",
WEEKLY: "每周",
MONTHLY_BY_DATE: "每月(按日期)",
MONTHLY_BY_WEEKDAY: "每月(按星期)",
YEARLY: "每年",
};
const copy = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
function useCalendarDialogFocus(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
ref.current?.querySelector<HTMLElement>("input,button")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, [ref]);
}
function trapCalendarTab(
event: React.KeyboardEvent,
panel: HTMLElement | null
) {
if (event.key !== "Tab") return;
const controls = Array.from(
panel?.querySelectorAll<HTMLElement>(
"button:not(:disabled),input:not(:disabled),select:not(:disabled)"
) || []
);
const first = controls[0],
last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
}
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
function CalendarExceptionEditor({
rule,
onSave,
onClose,
}: {
rule: CalendarRule;
onSave: (rule: CalendarRule) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState(() => copy(rule)),
[error, setError] = useState("");
const ref = useRef<HTMLElement>(null);
useCalendarDialogFocus(ref);
const patch = (changes: Partial<CalendarRule>) =>
setDraft((value) => ({ ...value, ...changes }));
const save = () => {
const problem = validateWorkingCalendar({
id: "validation",
name: "",
defaultWorkingDays: [1],
rules: [draft],
});
if (problem) {
setError(problem);
return;
}
const selected = draft.selectedDates[0];
if (
(draft.effectiveRange?.start && selected < draft.effectiveRange.start) ||
(draft.effectiveRange?.end && selected > draft.effectiveRange.end)
) {
setError("例外日期必须在生效范围内。");
return;
}
onSave({ ...draft, name: draft.name.trim() || "例外" });
onClose();
};
return (
<div
className="xmind-calendar-exception-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
trapCalendarTab(event, ref.current);
if (event.key === "Escape") onClose();
}}
>
<section
ref={ref}
className="xmind-calendar-exception"
role="dialog"
aria-modal="true"
aria-label="工作日例外"
>
<header>
<h2>工作日例外</h2>
<button type="button" aria-label="关闭工作日例外" onClick={onClose}>
<X size={17} />
</button>
</header>
<div className="xmind-calendar-exception-fields">
<label>
名称
<input
aria-label="例外名称"
value={draft.name}
onChange={(event) => patch({ name: event.target.value })}
/>
</label>
<label>
日期
<input
type="date"
aria-label="例外日期"
value={draft.selectedDates[0] || ""}
onChange={(event) =>
patch({ selectedDates: [event.target.value] })
}
/>
</label>
<label>
类型
<select
aria-label="例外类型"
value={draft.type}
onChange={(event) =>
patch({ type: event.target.value as CalendarRule["type"] })
}
>
<option value="NON_WORKING_DAY">休息日</option>
<option value="WORKING_DAY">工作日(补班)</option>
</select>
</label>
<label>
重复
<select
aria-label="例外重复规则"
value={draft.frequency}
onChange={(event) =>
patch({
frequency: event.target.value as CalendarRule["frequency"],
})
}
>
{CALENDAR_FREQUENCIES.map((frequency) => (
<option key={frequency} value={frequency}>
{FREQUENCY_LABELS[frequency]}
</option>
))}
</select>
</label>
{draft.frequency !== "NONE" ? (
<label>
每隔
<input
type="number"
min={1}
max={100}
aria-label="例外重复间隔"
value={draft.interval || 1}
onChange={(event) =>
patch({ interval: Number(event.target.value) })
}
/>
</label>
) : null}
<div className="xmind-calendar-range">
<label>
生效开始
<input
type="date"
aria-label="例外生效开始"
value={draft.effectiveRange?.start || ""}
onChange={(event) =>
patch({
effectiveRange: {
...draft.effectiveRange,
start: event.target.value,
},
})
}
/>
</label>
<label>
生效结束
<input
type="date"
aria-label="例外生效结束"
value={draft.effectiveRange?.end || ""}
onChange={(event) =>
patch({
effectiveRange: {
...draft.effectiveRange,
end: event.target.value,
},
})
}
/>
</label>
</div>
<p className="xmind-calendar-hint">
留空表示不限制生效日期。后添加的例外优先。
</p>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
</div>
<footer>
<button type="button" onClick={onClose}>
取消
</button>
<button type="button" className="xmind-primary-button" onClick={save}>
保存例外
</button>
</footer>
</section>
</div>
);
}
export function XmindProjectCalendar({
config,
onSave,
onClose,
}: {
config: ProjectCalendarConfig;
onSave: (config: ProjectCalendarConfig) => boolean;
onClose: () => void;
}) {
const ref = useRef<HTMLElement>(null);
useCalendarDialogFocus(ref);
const [draft, setDraft] = useState(() =>
copy(config.settings || defaultWorkingCalendar())
);
const [skip, setSkip] = useState(config.settings ? config.skip : true),
[error, setError] = useState("");
const [selectedDate, setSelectedDate] = useState(todayDate()),
[month, setMonth] = useState(todayDate().slice(0, 7));
const [exception, setException] = useState<CalendarRule | null>(null),
[manage, setManage] = useState(false);
const [templates, setTemplates] = useState<ProjectWorkingCalendar[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
return Array.isArray(stored)
? stored
.map(readWorkingCalendar)
.filter((item): item is ProjectWorkingCalendar => Boolean(item))
: [];
} catch {
return [];
}
});
const [templateName, setTemplateName] = useState("");
const [message, setMessage] = useState("");
const storeTemplates = (value: ProjectWorkingCalendar[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
setTemplates(value);
return true;
} catch {
setError("个人日历保存失败,请检查浏览器存储空间。");
return false;
}
};
const save = () => {
const problem = validateWorkingCalendar(draft);
if (problem) {
setError(problem);
return;
}
if (
onSave({
settings: { ...draft, name: draft.name.trim() || "项目日历" },
skip,
})
)
onClose();
};
const saveTemplate = () => {
const problem = validateWorkingCalendar(draft);
if (problem) {
setError(problem);
return;
}
if (!templateName.trim()) {
setError("请输入个人日历名称。");
return;
}
const value = {
...copy(draft),
id: crypto.randomUUID(),
name: templateName.trim(),
};
if (storeTemplates([...templates, value])) {
setTemplateName("");
setError("");
setMessage(`已保存个人日历:${value.name}`);
}
};
const moveMonth = (step: number) => {
const date = new Date(`${month}-01T00:00:00Z`);
date.setUTCMonth(date.getUTCMonth() + step);
setMonth(date.toISOString().slice(0, 7));
};
const first = dateIndex(`${month}-01`),
offset = (new Date(first * DAY).getUTCDay() + 6) % 7;
const days = Array.from({ length: 42 }, (_, index) =>
indexDate(first - offset + index)
);
const addException = () =>
setException({
id: crypto.randomUUID(),
name: "",
type: isWorkingDate(selectedDate, draft)
? "NON_WORKING_DAY"
: "WORKING_DAY",
frequency: "NONE",
selectedDates: [selectedDate],
effectiveRange: { start: selectedDate, end: selectedDate },
});
return (
<div
className="xmind-project-calendar-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
if (exception) return;
trapCalendarTab(event, ref.current);
if (event.key === "Escape") onClose();
}}
>
<section
ref={ref}
className="xmind-project-calendar"
role="dialog"
aria-modal="true"
aria-label="项目工作日"
>
<header>
<div>
<h2>
<CalendarDays size={19} />
项目工作日
</h2>
<p>只应用于当前工作表,不影响其他工作表。</p>
</div>
<button type="button" aria-label="关闭项目工作日" onClick={onClose}>
<X size={18} />
</button>
</header>
<div className="xmind-project-calendar-content">
<div className="xmind-calendar-preview">
<div className="xmind-calendar-import">
<label>
导入日历
<select
aria-label="导入个人日历"
value=""
onChange={(event) => {
const value =
event.target.value === "basic"
? { ...defaultWorkingCalendar(), name: "基本日历" }
: templates.find(
(item) => item.id === event.target.value
);
if (value) {
setDraft(copy(value));
setError("");
}
}}
>
<option value="">当前项目:{draft.name}</option>
<option value="basic">基本日历(周一至周五)</option>
{templates.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => setManage((value) => !value)}
>
管理
</button>
</div>
<div className="xmind-calendar-month-header">
<button
type="button"
aria-label="上个月"
onClick={() => moveMonth(-1)}
>
<ChevronLeft size={16} />
</button>
<strong>{month.replace("-", "年")}月</strong>
<button
type="button"
aria-label="下个月"
onClick={() => moveMonth(1)}
>
<ChevronRight size={16} />
</button>
</div>
<div className="xmind-calendar-month-grid" aria-label="工作日预览">
{WEEKDAYS.map((day) => (
<span className="xmind-calendar-weekday" key={day}>
{day}
</span>
))}
{days.map((date) => (
<button
type="button"
key={date}
data-calendar-date={date}
data-working={isWorkingDate(date, draft)}
aria-label={`${date} ${isWorkingDate(date, draft) ? "工作日" : "休息日"}`}
aria-pressed={selectedDate === date}
className={`${date.slice(0, 7) !== month ? "is-other-month" : ""} ${isWorkingDate(date, draft) ? "" : "is-day-off"}`}
onClick={() => setSelectedDate(date)}
>
{Number(date.slice(8))}
</button>
))}
</div>
<p className="xmind-calendar-hint">
阴影为休息日。选择日期后可添加例外。
</p>
{manage ? (
<div className="xmind-calendar-personal">
<h3>个人日历</h3>
<p>保存在当前浏览器,可用于其他工作表。</p>
{templates.length ? (
templates.map((item) => (
<div key={item.id}>
<span>{item.name}</span>
<button
type="button"
aria-label={`删除个人日历 ${item.name}`}
onClick={() =>
storeTemplates(
templates.filter((value) => value.id !== item.id)
)
}
>
<Trash2 size={13} />
</button>
</div>
))
) : (
<p>还没有个人日历。</p>
)}
<label>
名称
<input
aria-label="个人日历名称"
value={templateName}
onChange={(event) => setTemplateName(event.target.value)}
/>
</label>
<button type="button" onClick={saveTemplate}>
保存为个人日历
</button>
</div>
) : null}
</div>
<div className="xmind-calendar-settings">
<label>
日历名称
<input
aria-label="项目日历名称"
value={draft.name}
onChange={(event) =>
setDraft((value) => ({ ...value, name: event.target.value }))
}
/>
</label>
<label className="xmind-calendar-skip">
<input
type="checkbox"
aria-label="跳过非工作日"
checked={skip}
onChange={(event) => setSkip(event.target.checked)}
/>
跳过非工作日
</label>
<h3>每周工作日</h3>
<div className="xmind-calendar-working-week">
{WEEKDAYS.map((day, index) => (
<label key={day}>
<input
type="checkbox"
aria-label={`周${day}为工作日`}
checked={draft.defaultWorkingDays.includes(index + 1)}
onChange={(event) => {
const value = event.target.checked
? [...draft.defaultWorkingDays, index + 1].sort()
: draft.defaultWorkingDays.filter(
(value) => value !== index + 1
);
if (!value.length) {
setError("请至少保留一个每周工作日。");
return;
}
setDraft((settings) => ({
...settings,
defaultWorkingDays: value,
}));
setError("");
}}
/>
{day}
</label>
))}
</div>
<div className="xmind-calendar-rule-heading">
<h3>例外</h3>
<button
type="button"
aria-label="添加工作日例外"
onClick={addException}
>
<Plus size={16} />
</button>
</div>
<div className="xmind-calendar-rules">
{[...draft.rules].reverse().map((rule) => (
<div key={rule.id}>
<button
type="button"
aria-label={`编辑工作日例外 ${rule.name}`}
onClick={() => setException(copy(rule))}
>
<strong>{rule.name}</strong>
<span>
{rule.type === "WORKING_DAY" ? "补班" : "休息日"} ·{" "}
{rule.selectedDates.join("、")} ·{" "}
{FREQUENCY_LABELS[rule.frequency]}
{rule.interval && rule.interval > 1
? `(间隔 ${rule.interval})`
: ""}
</span>
</button>
<button
type="button"
aria-label={`删除工作日例外 ${rule.name}`}
onClick={() =>
setDraft((value) => ({
...value,
rules: value.rules.filter(
(item) => item.id !== rule.id
),
}))
}
>
<Trash2 size={14} />
</button>
</div>
))}
{!draft.rules.length ? (
<p>可添加节假日、调休或重复例外。</p>
) : null}
</div>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
{message ? <p role="status">{message}</p> : null}
</div>
</div>
<footer>
<div>
{config.settings ? (
<button
type="button"
className="xmind-danger"
onClick={() => {
if (onSave({ settings: null, skip: false })) onClose();
}}
>
移除项目日历
</button>
) : null}
<button type="button" onClick={() => setManage(true)}>
保存为日历
</button>
</div>
<div>
<button type="button" onClick={onClose}>
取消
</button>
<button
type="button"
className="xmind-primary-button"
onClick={save}
>
保存工作日
</button>
</div>
</footer>
</section>
{exception ? (
<CalendarExceptionEditor
rule={exception}
onSave={(rule) =>
setDraft((value) => ({
...value,
rules: value.rules.some((item) => item.id === rule.id)
? value.rules.map((item) => (item.id === rule.id ? rule : item))
: [...value.rules, rule],
}))
}
onClose={() => setException(null)}
/>
) : null}
</div>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { searchTopics, type SearchSheet, type TopicSearchResult } from "@/lib/flow-board/topic-search";
export function XmindSearchPanel({ sheets, activeSheetId, onJump, onReplace, onClose }: {
sheets: SearchSheet[];
activeSheetId: string;
onJump: (result: TopicSearchResult) => void;
onReplace: (results: TopicSearchResult[], query: string, replacement: string, caseSensitive: boolean) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [replacement, setReplacement] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [scope, setScope] = useState("current");
const [activeIndex, setActiveIndex] = useState(0);
const input = useRef<HTMLInputElement>(null);
const results = searchTopics(scope === "all" ? sheets : sheets.filter(sheet => sheet.id === activeSheetId),query,caseSensitive);
useEffect(() => { input.current?.focus(); }, []);
useEffect(() => { setActiveIndex(0); }, [query,caseSensitive,scope]);
const navigate = (step: number) => {
if (!results.length) return;
const next = (Math.min(activeIndex,results.length - 1) + step + results.length) % results.length;
setActiveIndex(next); onJump(results[next]);
};
return <aside className="xmind-search-panel" aria-label="查找与替换" data-flow-board-shell-control="true" onKeyDown={event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); }
}}>
<div className="xmind-panel-heading"><span>查找与替换</span><button type="button" aria-label="关闭查找" onClick={onClose}><X size={16} /></button></div>
<div className="xmind-panel-content">
<label className="xmind-search-box"><Search size={15} /><input ref={input} aria-label="查找主题" placeholder="查找主题" value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); navigate(e.shiftKey ? -1 : 1); } }} /></label>
<div className="xmind-search-options"><label><input type="checkbox" checked={caseSensitive} onChange={e => setCaseSensitive(e.target.checked)} /> 区分大小写</label></div>
<select aria-label="查找范围" value={scope} onChange={e => setScope(e.target.value)}><option value="current">当前画布</option><option value="all">所有画布</option></select>
<label className="xmind-search-box"><input aria-label="替换为" placeholder="替换为" value={replacement} onChange={e => setReplacement(e.target.value)} /></label>
<div className="xmind-search-replace-buttons">
<button type="button" disabled={!results.length} onClick={() => onReplace([results[Math.min(activeIndex,results.length - 1)]],query,replacement,caseSensitive)}>替换</button>
<button type="button" disabled={!results.length} onClick={() => onReplace(results,query,replacement,caseSensitive)}>全部替换</button>
</div>
<div className="xmind-search-navigation"><span role="status">{query ? `${results.length} 个匹配主题` : "输入内容开始查找"}</span><button type="button" aria-label="上一个结果" disabled={!results.length} onClick={() => navigate(-1)}><ChevronUp size={16} /></button><button type="button" aria-label="下一个结果" disabled={!results.length} onClick={() => navigate(1)}><ChevronDown size={16} /></button></div>
<div aria-label="查找结果">{results.map((result,index) => <button type="button" key={`${result.sheetId}:${result.id}`} aria-label={`定位 ${result.title}`} className={`xmind-search-result ${index === activeIndex ? "is-active" : ""}`} onClick={() => { setActiveIndex(index); onJump(result); }}><span>{result.title}</span><small>{result.sheetTitle} · {result.occurrences} 处匹配</small></button>)}</div>
</div>
</aside>;
}

View File

@ -0,0 +1,384 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CalendarDays, X } from "lucide-react";
import type { PlaitElement } from "@plait/core";
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
import {
activeWorkingCalendar,
getProjectCalendar,
taskDueDate,
workingDaysBetween,
} from "@/lib/flow-board/project-calendar";
import {
DAY,
getTopicTask,
newTopicTask,
validateTopicTask,
type TopicTask,
type TaskDependency,
} from "@/lib/flow-board/topic-tasks";
export function XmindTaskEditor({
topic,
elements,
onSave,
onClose,
onGantt,
onCalendar,
}: {
topic: PlaitElement;
elements: PlaitElement[];
onSave: (id: string, task: TopicTask | null) => void;
onClose: () => void;
onGantt: () => void;
onCalendar: () => void;
}) {
const dialogRef = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
dialogRef.current?.querySelector<HTMLInputElement>("input")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, []);
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
const existing = getTopicTask(topic);
const [draft, setDraft] = useState<TopicTask>(
() => existing || newTopicTask()
);
const [error, setError] = useState("");
const [assigneesDraft, setAssigneesDraft] = useState(
(existing?.assignees || []).join(", ")
);
const [dependency, setDependency] = useState("");
const [dependencyType, setDependencyType] =
useState<TaskDependency["type"]>("FS");
const [lag, setLag] = useState("0");
const source = JSON.stringify(existing);
useEffect(() => {
setDraft(getTopicTask(topic) || newTopicTask());
setAssigneesDraft((getTopicTask(topic)?.assignees || []).join(", "));
setError("");
}, [topic.id, source]);
const tasks = getTopicEntries(elements).filter(
(e) => e.element.id !== topic.id && getTopicTask(e.element)
);
const patch = (p: Partial<TopicTask>) => setDraft((t) => ({ ...t, ...p }));
const withAssignees = () => ({
...draft,
assignees: [
...new Set(
assigneesDraft
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean)
),
],
});
const save = () => {
const task = withAssignees();
const problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
};
return (
<div
className="xmind-task-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
if (e.key === "Tab") {
const controls = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(
"button:not(:disabled),input,select"
) || []
);
const first = controls[0],
last = controls.at(-1);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
}
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="任务信息"
className="xmind-task-editor"
ref={dialogRef}
>
<header>
<h2>
<CalendarDays size={17} />
任务信息
</h2>
<button type="button" aria-label="关闭任务信息" onClick={onClose}>
<X size={17} />
</button>
</header>
<p className="xmind-task-name">{getTopicTitle(topic)}</p>
<div className="xmind-task-fields">
<label>
开始日期
<input
type="date"
aria-label="任务开始日期"
value={draft.start}
onChange={(e) => {
const start = e.target.value;
patch({
start,
due:
start && draft.duration >= 1
? taskDueDate(start, draft.duration, calendar)
: "",
});
}}
/>
</label>
<label>
截止日期
<input
type="date"
aria-label="任务截止日期"
value={draft.due}
onChange={(e) => {
const due = e.target.value;
patch({
due,
...(draft.start && due && due >= draft.start
? {
duration: Math.max(
1,
workingDaysBetween(draft.start, due, calendar)
),
}
: {}),
});
}}
/>
</label>
<label>
{calendar ? "时长(工作日)" : "时长(天)"}
<input
type="number"
aria-label="任务工期"
min={1}
max={36500}
value={draft.duration}
onChange={(e) => {
const duration = Number(e.target.value);
patch({
duration,
...(draft.start && duration >= 1 && duration <= 36500
? { due: taskDueDate(draft.start, duration, calendar) }
: {}),
});
}}
/>
</label>
<label>
进度(%)
<input
type="number"
aria-label="任务进度"
min={0}
max={100}
value={draft.progress}
onChange={(e) => patch({ progress: Number(e.target.value) })}
/>
</label>
<label>
优先级
<select
aria-label="任务优先级"
value={draft.priority ?? ""}
onChange={(e) =>
patch({
priority:
e.target.value === "" ? null : Number(e.target.value),
})
}
>
<option value="">无</option>
{Array.from({ length: 10 }, (_, i) => (
<option value={i} key={i}>
P{i}
</option>
))}
</select>
</label>
<label>
负责人
<input
aria-label="任务负责人"
placeholder="多人用逗号分隔"
value={assigneesDraft}
onChange={(e) => setAssigneesDraft(e.target.value)}
/>
</label>
</div>
<h3>前置任务</h3>
<div className="xmind-task-dependencies">
{draft.dependencies.map((dep, i) => (
<div key={`${dep.id}-${i}`}>
<span>
{tasks.find((e) => e.element.id === dep.id)?.title ||
"已删除的任务"}{" "}
· {dep.type}
{dep.lag ? ` · ${dep.lag / DAY} 天` : ""}
</span>
<button
type="button"
aria-label={`移除前置任务 ${tasks.find((e) => e.element.id === dep.id)?.title || dep.id}`}
onClick={() =>
patch({
dependencies: draft.dependencies.filter((_, n) => n !== i),
})
}
>
<X size={13} />
</button>
</div>
))}
</div>
<div className="xmind-task-add-dependency">
<select
aria-label="前置任务"
value={dependency}
onChange={(e) => setDependency(e.target.value)}
>
<option value="">选择任务</option>
{tasks.map((e) => (
<option value={e.element.id} key={e.element.id}>
{e.title}
</option>
))}
</select>
<select
aria-label="依赖类型"
value={dependencyType}
onChange={(e) =>
setDependencyType(e.target.value as TaskDependency["type"])
}
>
<option value="FS">完成 → 开始</option>
<option value="SS">开始 → 开始</option>
<option value="FF">完成 → 完成</option>
<option value="SF">开始 → 完成</option>
</select>
<input
type="number"
aria-label="依赖间隔天数"
value={lag}
onChange={(e) => setLag(e.target.value)}
title="间隔天数(负数为提前)"
/>
<button
type="button"
disabled={!dependency}
onClick={() => {
const next = {
...draft,
dependencies: [
...draft.dependencies.filter((d) => d.id !== dependency),
{
id: dependency,
type: dependencyType,
lag: Number(lag) * DAY,
},
],
};
const problem = validateTopicTask(topic.id, next, elements);
if (problem) setError(problem);
else {
setDraft(next);
setDependency("");
setError("");
}
}}
>
添加
</button>
</div>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
<div className="xmind-task-calendar-info">
<span>
{calendar ? `已跳过非工作日 · ${calendar.name}` : "包含非工作日"}
</span>
<button
type="button"
onClick={() => {
const task = withAssignees(),
problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
onCalendar();
}}
>
项目工作日
</button>
</div>
<footer>
{existing ? (
<button
type="button"
className="xmind-danger"
onClick={() => {
onSave(topic.id, null);
onClose();
}}
>
移除任务
</button>
) : (
<button type="button" onClick={onClose}>
取消
</button>
)}
<button
type="button"
onClick={() => {
const task = withAssignees();
const problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
onGantt();
}}
>
查看甘特图
</button>
<button type="button" className="xmind-primary-button" onClick={save}>
保存任务
</button>
</footer>
</section>
</div>
);
}

View File

@ -0,0 +1,139 @@
"use client";
import {
type PlaitBoard,
type PlaitElement,
toHostPointFromViewBoxPoint,
toScreenPointFromHostPoint,
} from "@plait/core";
import { MindElement, getRectangleByNode } from "@plait/mind";
import { FileText } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { getTopicDetails, getTopicTitle } from "@/lib/flow-board/topic-details";
import { getTopicTask } from "@/lib/flow-board/topic-tasks";
export function XmindTopicBadges({
board,
elements,
viewport,
onNotes,
onTask,
}: {
board: PlaitBoard | null;
elements: PlaitElement[];
viewport: unknown;
onNotes: (id: string) => void;
onTask: (id: string) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const [badges, setBadges] = useState<
{
id: string;
title: string;
notes: boolean;
progress: number | null;
left: number;
top: number;
}[]
>([]);
useEffect(() => {
if (!board) return;
const update = () => {
const host = hostRef.current;
if (!host) return;
const origin = host.getBoundingClientRect();
const result: typeof badges = [];
const visit = (children: PlaitElement[]) => {
for (const element of children) {
const notes = Boolean(getTopicDetails(element).notes.trim()),
task = getTopicTask(element);
if (notes || task) {
const node = MindElement.getNode(element as MindElement);
if (node) {
const rect = getRectangleByNode(node);
const point = toScreenPointFromHostPoint(
board,
toHostPointFromViewBoxPoint(board, [
rect.x + rect.width,
rect.y + rect.height / 2,
])
);
result.push({
id: element.id,
title: getTopicTitle(element),
notes,
progress: task?.progress ?? null,
left: point[0] - origin.x + 5,
top: point[1] - origin.y - 9,
});
}
}
if (!element.isCollapsed && element.children) visit(element.children);
}
};
visit(elements);
setBadges(result);
};
const frame = requestAnimationFrame(update);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
};
}, [board, elements, viewport]);
return (
<div ref={hostRef} className="xmind-topic-badges" aria-label="主题内容提示">
{badges.map((b) => (
<div
key={b.id}
className="xmind-topic-badge"
style={{ left: b.left, top: b.top }}
data-flow-board-shell-control="true"
>
{b.progress !== null ? (
<button
type="button"
aria-label={`编辑任务 ${b.title}`}
title={`任务进度 ${b.progress}%`}
onClick={() => onTask(b.id)}
>
<svg
aria-hidden="true"
width={16}
height={16}
viewBox="0 0 16 16"
>
<circle
cx={8}
cy={8}
r={6}
fill="#f8fafc"
stroke="#afb7c4"
strokeWidth={1.4}
/>
<circle
cx={8}
cy={8}
r={6}
fill="none"
stroke="#6b8ee4"
strokeWidth={3}
strokeDasharray={`${(b.progress * 37.7) / 100} 37.7`}
transform="rotate(-90 8 8)"
/>
</svg>
</button>
) : null}
{b.notes ? (
<button
type="button"
aria-label={`查看笔记 ${b.title}`}
title="查看笔记"
onClick={() => onNotes(b.id)}
>
<FileText size={15} />
</button>
) : null}
</div>
))}
</div>
);
}

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