Compare commits

...

278 Commits

Author SHA1 Message Date
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
134
e7be5af3f2 feat(video-canvas): add TVstar creation and session workflows 2026-10-01 22:58:20 +08:00
134
365bad8539 fix(canvas): distinguish provider balance from image rate limits 2026-10-01 07:45:39 +08:00
134
08eecb0190 docs: record the UUID validation regression that broke detail replacement 2026-09-27 19:19:20 +08:00
134
f75d99baf6 fix(agent): repair the UUID check that made every detail replacement request 400
新增的批次接口把一个 UUID 正则写成了 8-4-4-12(少一组 -{4}),标准 UUID 是
8-4-4-4-12。结果是任何真实项目编号和请求编号都过不了校验:查询接口恒返 400
「项目编号无效」,提交接口恒返 400「任务编号或项目编号无效」,详情替换完全无法使用。

- 校验抽成 isDetailReplacementUuid 并修正为规范的 8-4-4-4-12,路由不再自带正则
- 回归测试覆盖真实线上项目编号、浏览器生成的请求编号,以及导致故障的 8-4-4-12 形状
- 增加源码守卫,防止该路由再次出现自带的错误 UUID 正则
- 前端在项目尚未保存时给出明确提示,而不是把空编号丢给接口
2026-09-27 19:14:56 +08:00
134
db3a038b00 docs: record the detail replacement durable batch incident and rollout 2026-09-26 02:53:01 +08:00
134
0907aaff67 fix(agent): make detail replacement results survive page and server restarts
详情替换原来把整批生图挂在浏览器保持的对话流上,切换标签页、断网或连接中断后
结果就再也回不来,前端还会继续播放按时间推算的 96% 假进度。

现在改为服务器端后台批次:提交时先落任务,再按参考图逐张生成并把每张结果写进
项目,页面刷新、切页或服务重启后都能按任务状态恢复,不再依赖前端连接。

- 新增 /api/agents/detail-replacement/batches(提交/查询、幂等去重、重试上限)
- 前端只在服务端确认批次后显示处理中,卡片显示排队/生成/完成/失败真实状态
- 去掉假进度动画;没有产品输入图时提交前直接拦截
- 服务重启后自动恢复未完成批次,长批次不再被通用 30 分钟清理误判
- 单张失败只落到该卡片并继续后续图片,已完成的图片不重复生成和扣费
- 详情替换默认模型由持续 404 的 gpt-image-2.5-flare 改回 gpt-image-2.5
2026-09-26 02:39:11 +08:00
134
1c2acee547 Preserve uploaded media aspect ratios in video canvas 2026-09-24 14:36:22 +08:00
134
439af60c2a Update video canvas home and Aliyun analysis flow 2026-09-24 13:36:31 +08:00
134
e97296a9e8 Add share entry to the canvas top bar
The share popover (publish to site, copy link) and publish modal had
no reachable entry; add a share button next to the save status,
matching the reference product's top bar.
2026-09-19 17:09:39 +08:00
134
94b49b7fad Document shortcut system rollout and server-side verification 2026-09-19 14:57:29 +08:00
134
ea4f34bf89 Re-enable the video toolbar HD entry
The P40 upscale flow (popover, HD node creation, task submission) is
fully wired; the toolbar entry had been left disabled and mislabeled
as not connected.
2026-09-19 14:42:52 +08:00
134
1141ecc43a Bound separation input WAV duration to the probed length
The unbounded apad filter made ffmpeg pad the 3-second test clip
endlessly (a 22.9GB WAV) until the disk filled, which was the real
cause of the voice separation failures. Trim to the probed duration
and drop apad.
2026-09-19 14:33:06 +08:00
134
8b2955eb0b Add canvas undo/redo and full shortcut system
Snapshot-based undo/redo (max 50 steps) with Cmd+Z / Cmd+Shift+Z,
ignoring volatile generation progress fields. Bind the remaining
reference shortcuts: Cmd+D duplicate, Cmd+Enter generate selection,
Tab new-node palette, Cmd+F canvas node search panel, Cmd+0 fit view,
Cmd+=/- zoom, V pan-tool toggle, H hold-to-pan, plain G to group.
Expand the shortcut help panel to the full four-group layout.
2026-09-19 12:53:07 +08:00
134
94b3ac9cb3 Restyle story script nodes to dark theme
Align the story script generator table and script text node with the
reference product: dark cards with subtle white borders, muted header
row, dark status pills, white primary button and dark textarea card,
replacing the previous light table chrome.
2026-09-18 22:57:07 +08:00
134
8a1876e016 Render canvas image nodes with object-contain
Show the full picture inside image resource nodes instead of cropping
with object-cover; matches the reference product where node frames
adapt to the image and no content is clipped, and fixes existing
nodes created before aspect fitting was added.
2026-09-18 18:45:39 +08:00
134
b4b6e1a738 Size dropped image nodes to intrinsic aspect ratio
Dropped images landed in fixed 420x280 nodes and rendered with
object-cover, cropping portraits and wide photos. Read the image's
natural size during ingestion and fit the node frame to the aspect
ratio (landscape caps height, portrait caps width) so the full
picture stays visible like the reference product.
2026-09-18 18:34:23 +08:00
134
885cab9fca Document voice separation rollout 2026-09-18 18:21:47 +08:00
134
6ed8879b28 Wire canvas voice/background separation to remote HTDemucs service
The private FunASR/HTDemucs audio service was already live and used by
the smart edit pipeline, but the canvas video toolbar still claimed
voice/background separation was unavailable. Add a dedicated
/api/canvas/audio-separate route (download, ffmpeg stem extraction,
remote separation, storage upload) and route the toolbar's voice and
background modes through it, replacing the stale not-connected notice.
2026-09-18 18:16:44 +08:00
134
c1975942ac Document video canvas shell cleanup and media upload fix rollout 2026-09-18 18:04:51 +08:00
134
a4354dc535 Remove video canvas demo shells and fix media upload timeouts
- Starter/toolbox presets no longer seed fake generated results; demo
  fallback media URLs in tool actions now fail with honest guidance
- Delete dead demo scene components, fake director console, demo
  history constants and unreachable submit toast fallbacks
- Smart edit dialog accepts desktop drag-and-drop video files
- Raise media upload client timeout to 10 minutes and surface
  friendly timeout errors instead of raw 'signal timed out'
2026-09-18 17:40:49 +08:00
134
3002704d77 Document video canvas production rollout 2026-09-18 15:24:10 +08:00
134
fe1c9b60f3 Fix video canvas media imports and node layout 2026-09-18 15:09:34 +08:00
134
4f9453b8b2 Add durable remote audio inference and verified offline speech models 2026-09-18 14:12:28 +08:00
134
229f4d72cb Record verified production rollout of canvas groups and video upscaling 2026-09-18 13:37:54 +08:00
134
12576c565f Complete durable canvas groups and add isolated P40 video upscaling 2026-09-18 13:18:48 +08:00
134
b17b8b7689 Make canvas connections resolve live inputs and respect disconnects 2026-09-18 13:18:48 +08:00
134
0d5558c50c Fix handwritten storyboard entry and generic grid presets; record GPU allocation audit 2026-09-18 13:18:48 +08:00
134
c6533e92f3 Complete smart edit audio processing and font catalogue with main app validation 2026-09-18 13:18:48 +08:00
134
17e17b9ad5 Fix smart edit captions, audio gain and timeline parity 2026-09-18 13:18:48 +08:00
134
689fa615e8 Audit smart edit parity and fix caption timing and outline previews 2026-09-18 13:18:47 +08:00
134
725c1ec3d2 Match smart edit transition previews and centered effects to reference 2026-09-18 13:18:47 +08:00
134
84bfd6b9f4 Support local preview transforms and detached smart edit audio tracks 2026-09-18 13:18:47 +08:00
134
6869bd8c18 Align smart edit canvas and timeline UI with reference workflow 2026-09-17 18:05:29 +08:00
134
a5f4ad0385 Bound per-input decoder and scene detection threads 2026-09-17 16:42:06 +08:00
134
6eff0a7332 Use static import in remote rendering helper test 2026-09-17 16:39:51 +08:00
134
a00a910227 Verify remote render recovery identity and endpoint binding 2026-09-17 16:39:51 +08:00
134
721de36692 Remove duplicate canvas definitions introduced by release merge 2026-09-17 16:34:47 +08:00
134
be8bdb4dab Merge verified video canvas updates with production hotfix history 2026-09-17 16:33:36 +08:00
134
bbac6289b3 Add smart editing workflow and isolated durable video rendering service 2026-09-17 16:32:04 +08:00
134
cb42f27ee9 Add official Alibaba audio generation and editable director object tracks 2026-09-17 15:43:32 +08:00
134
292eddc5a6 fix(video-canvas): resume provider jobs and recover editing and 3d state 2026-09-17 15:01:02 +08:00
134
69dcf9b745 fix(video-canvas): persist story analysis and resume accepted enhance tasks 2026-09-17 13:51:15 +08:00
134
86b66172b1 fix(video-canvas): persist image operations and story subtasks 2026-09-17 13:30:32 +08:00
134
0d17638a8d fix(video-canvas): persist generation tasks and repair workflow controls 2026-09-17 12:32:47 +08:00
134
23f86dfce1 fix(video-canvas): repair audited controls and document remaining validation gaps 2026-09-17 11:22:49 +08:00
134
f10e6b7c8d fix(video-canvas): connect generation controls and repair script media director workflows 2026-09-17 10:28:18 +08:00
134
e5b2de3c1b docs(video-canvas): audit 231 nested LibTV feature checkpoints 2026-09-17 09:48:11 +08:00
134
d5593ac3f0 feat(video-canvas): implement camera motion timeline and real MP4 export 2026-09-17 00:31:36 +08:00
134
38be09de9c feat(video-canvas): connect real 3d director and validate media chain 2026-09-17 00:02:03 +08:00
134
598f5f617a feat(video-canvas): add tested Aliyun Wan first-frame adapter 2026-09-16 23:03:37 +08:00
134
f7550251ec fix(video-canvas): preserve story length and archive generated videos 2026-09-16 22:12:35 +08:00
134
2ba093b714 feat(video-canvas): connect prompt tools and durable media processing 2026-09-16 21:37:07 +08:00
134
5b0d75a738 fix(video-canvas): persist edits and record live provider regression results 2026-09-16 18:56:03 +08:00
134
027f0a4201 docs: record official Qwen3.8 Max canvas configuration 2026-09-16 18:10:25 +08:00
134
96b8e1662c docs: record official Qwen3.8 Max canvas configuration 2026-09-16 18:10:05 +08:00
134
290f6d2c99 docs: record video canvas production rollout and verification 2026-09-16 16:35:48 +08:00
134
7c276fe43a docs: record video canvas production rollout and verification 2026-09-16 16:35:27 +08:00
134
741fe3663e fix: exclude build artifacts from canvas stylesheet scanning 2026-09-16 16:29:31 +08:00
134
924c8065b6 fix: match video canvas models to real capabilities and remove simulated processing 2026-09-16 16:29:31 +08:00
134
50d782850f fix: exclude build artifacts from canvas stylesheet scanning 2026-09-16 15:11:50 +08:00
134
60b7900bf9 fix: match video canvas models to real capabilities and remove simulated processing 2026-09-16 15:06:05 +08:00
134
0c1a2e6fee docs: record Claw 3D loading rollout verification 2026-09-15 16:24:43 +08:00
134
73593051fe fix: render Claw models without waiting for external HDR lighting 2026-09-15 16:21:06 +08:00
134
3df44f1f8c fix: render Claw models without waiting for external HDR lighting 2026-09-15 16:20:23 +08:00
134
54c857d714 docs: record Claw history recovery rollout checks 2026-09-15 14:39:08 +08:00
134
3bf0fec097 fix: restore Claw history before waiting for active tasks 2026-09-15 14:37:26 +08:00
134
d31079e26d fix: restore Claw history before waiting for active tasks 2026-09-15 14:34:58 +08:00
134
2bf5f6232d fix: separate Claw markdown table columns 2026-09-15 13:36:52 +08:00
134
c024ae31b9 fix: separate Claw markdown table columns 2026-09-15 13:34:41 +08:00
134
8f0dff35ed docs: record Claw production rollout verification 2026-09-14 09:16:01 +08:00
134
f3cded51fd fix: stop Claw artifact request loops and restore CAD actions 2026-09-14 09:10:16 +08:00
134
b45d9ee314 fix: stop Claw artifact request loops and restore CAD actions 2026-09-14 09:09:31 +08:00
134
0aff83f61a docs: record VIP98K live matrix and deployment evidence 2026-09-11 15:10:20 +08:00
134
b26e265083 fix: verify VIP98K sizes and recover replacement results 2026-09-11 14:56:00 +08:00
134
aaa650265c fix: verify VIP98K sizes and recover replacement results 2026-09-11 14:52:38 +08:00
134
a2a9e7d8b2 fix: stabilize detail replacement generation 2026-09-11 11:36:53 +08:00
134
dcff190c62 fix(claw): prevent new chat history rebound 2026-09-11 11:19:04 +08:00
134
a7d994d93b fix: advertise verified vip98k image sizes 2026-09-10 16:32:25 +08:00
134
a1973ae202 fix: honor vip98k image contracts 2026-09-10 16:19:49 +08:00
134
f9d1b46d81 fix: bind image extension to Alibaba provider key 2026-09-10 12:48:27 +08:00
134
821b8658a8 feat: add verified VIP98K multi-image models 2026-09-10 10:08:13 +08:00
134
6f5a444756 fix: preserve detail replacement product geometry 2026-09-09 19:50:03 +08:00
134
2e340c004a fix: restore bananaimage models 2026-09-08 10:14:29 +08:00
134
2a9b05de80 fix: hide unsupported vip98k image editors 2026-09-08 10:00:19 +08:00
134
08ed9ac3ef perf: cache vip98k reference staging 2026-09-07 20:50:20 +08:00
134
2fb53b4279 test: accept synchronous vip98k image results 2026-09-07 20:28:21 +08:00
134
42359fbf0f fix: support vip98k image array contracts 2026-09-07 20:20:07 +08:00
134
6ce7c31a1e fix: poll qwen image generation asynchronously 2026-09-07 19:46:18 +08:00
134
e49b0707e9 fix: enforce vip98k multi-image contracts 2026-09-07 18:17:01 +08:00
134
77f8774a55 fix: restore vip98k text image models 2026-09-07 15:06:40 +08:00
134
3a1c445c0b fix: stabilize vip98k image generation uploads 2026-09-07 14:45:06 +08:00
134
cfd3418bb7 fix: accept large canvas references for vip98k 2026-09-07 13:17:39 +08:00
134
faf3a2dbb2 fix: retry vip98k reconciliation responses 2026-09-07 13:14:50 +08:00
134
002ab8b98d fix: bypass vip98k media fetch cache on retry 2026-09-07 12:41:41 +08:00
134
c69ddb1bb3 fix: retry vip98k while staged media propagates 2026-09-07 12:32:38 +08:00
134
01b3f6eec1 fix: use public upload path for vip98k staging 2026-09-07 12:24:25 +08:00
134
6a30795bb8 fix: stage vip98k media on reachable storage 2026-09-07 12:18:26 +08:00
134
0a364f537f fix: normalize vip98k references for provider limits 2026-09-07 12:04:46 +08:00
134
d133a48866 fix: stabilize vip98k reference image delivery 2026-09-07 11:48:40 +08:00
134
434d0a63cd fix: keep verified vip98k image models available 2026-09-07 11:16:52 +08:00
134
608bd25679 fix: trust vip98k published image contracts 2026-09-07 00:32:33 +08:00
134
47909e39a9 fix: update vip98k image task integration 2026-09-07 00:22:45 +08:00
134
740fc90dfd feat: support updated OpenClaw 3D task protocol 2026-09-06 12:45:39 +08:00
134
272592488b fix(claw): persist 3d preview and restore chat history 2026-09-06 09:19:09 +08:00
134
6269aefee5 fix(ai): preserve relational image references 2026-09-05 18:46:25 +08:00
134
9717196264 fix(claw): assemble legacy 3d models by default 2026-09-03 17:43:01 +08:00
134
2817aab197 Merge Claw interactive 3D preview support 2026-09-02 20:22:41 +08:00
134
bbfece5720 feat(claw): support interactive 3D previews 2026-09-02 20:21:40 +08:00
134
e0501bbbd1 Merge remote-tracking branch 'origin/codex/bbyy-production-156-20260820' into codex/deploy-48-bloub-20260902 2026-09-02 19:14:09 +08:00
134
6ab68e8ac9 fix(canvas): show Bloub in chat and image placeholders 2026-09-02 19:12:58 +08:00
134
0ed21b1b76 Merge remote-tracking branch 'origin/codex/bbyy-production-156-20260820' into codex/deploy-48-bloub-20260902 2026-09-02 18:08:52 +08:00
134
02017a2531 feat(ui): add official Bloub assistant states 2026-09-02 18:06:46 +08:00
134
0a375de55c feat(claw): preview 3D artifacts in chat 2026-09-02 12:46:05 +08:00
134
4d8c984d08 feat(claw): preview 3D artifacts in chat 2026-09-02 12:42:02 +08:00
134
c39d7260fb fix(model3d): restore archived world assets and previews 2026-08-28 22:17:20 +08:00
134
cb9a90d9ac fix(ai): restore models after successful circuit probe 2026-08-27 17:10:09 +08:00
134
412fa85a81 fix(ai): restore models after successful circuit probe 2026-08-27 17:09:33 +08:00
134
ac1e586156 fix(ai): fail over forbidden image requests 2026-08-27 16:32:47 +08:00
134
e1c155be9e fix(ai): fail over forbidden image requests 2026-08-27 16:31:27 +08:00
134
2a4f8f631b feat(ai): support Qwen Image 3.0 2026-08-26 16:10:47 +08:00
134
ba232f3385 feat(ai): support Qwen Image 3.0 2026-08-26 16:10:00 +08:00
134
5f6341a318 fix: preserve manually disabled image models 2026-08-26 13:44:42 +08:00
134
d005b3477a fix: preserve manually disabled image models 2026-08-26 13:43:58 +08:00
134
07a9a61743 fix(ai): fail over failed VIP98K image tasks 2026-08-25 17:04:50 +08:00
root
86c8e4995d Merge remote-tracking branch 'origin/codex/48-ai-template-integration-20260813' into codex/48-ai-template-integration-20260813 2026-08-25 17:04:04 +08:00
134
e5dc650443 fix(ai): fail over failed VIP98K image tasks 2026-08-25 17:00:38 +08:00
134
9596b0a667 fix(canvas): honor text edit failover exclusions 2026-08-25 15:44:25 +08:00
134
4a30cad91f fix(canvas): honor text edit failover exclusions 2026-08-25 15:43:50 +08:00
134
7752ec42ee fix(canvas): restore reliable text editing 2026-08-25 14:46:01 +08:00
134
3a88fb0df0 fix(canvas): restore reliable text editing 2026-08-25 14:44:50 +08:00
134
f95c6b2b2b fix(canvas): decouple text detection from image provider 2026-08-25 14:03:51 +08:00
134
a0999bfcde fix(canvas): decouple text detection from image provider 2026-08-25 14:03:25 +08:00
134
4b5540f3c2 fix(ai): route vip98k image tools correctly 2026-08-25 13:30:33 +08:00
134
d120e0ddb6 fix(ai): route vip98k image tools correctly 2026-08-25 13:27:31 +08:00
134
b73afcdfdc fix(ai): mark vip98k images as async tasks 2026-08-25 13:11:40 +08:00
134
c21a86beea fix(ai): mark vip98k images as async tasks 2026-08-25 13:07:58 +08:00
134
bcb21344ba fix(ai): update vip98k image task contract 2026-08-25 13:02:39 +08:00
134
e87724499b fix(ai): update vip98k image task contract 2026-08-25 12:59:53 +08:00
134
42e1175830 fix: persist generated images before gallery save 2026-08-22 17:25:34 +08:00
134
afac47add2 fix(home): harden Qiniu featured previews 2026-08-20 22:41:01 +08:00
134
d1582f33e8 fix(bbyy): isolate production branding from E2E defaults 2026-08-20 22:12:33 +08:00
134
1d84b1b1c0 fix(ops): wait for production container startup 2026-08-20 19:52:05 +08:00
134
f0a24a1a99 fix(canvas): optimize Qiniu image previews 2026-08-20 19:47:02 +08:00
134
3d12c508d3 fix: stop retrying expired image archives forever 2026-08-20 19:34:56 +08:00
134
88dec71a41 ops: make production builds atomic 2026-08-20 19:32:49 +08:00
134
6180c5fc49 fix: restore production model availability helpers 2026-08-20 19:28:51 +08:00
root
dfb50c83fa fix: make chat layout dynamic for stable homepage bundles 2026-08-19 16:01:52 +08:00
134
3ad353b97f fix: make chat layout dynamic to avoid broken home chunk artifacts 2026-08-19 16:00:38 +08:00
134
e05b42942d fix(world): fallback status polling from failed external task result 2026-08-19 13:48:07 +08:00
134
e601f8079f fix(world): harden world task create external-marble fallback 2026-08-19 13:45:10 +08:00
134
fd49843c5a fix(world): align vip98k world task mode support 2026-08-19 13:17:37 +08:00
134
a9a9f8e138 fix(world): guard unsupported modes for external world task provider 2026-08-19 12:34:13 +08:00
134
15c3b519a7 Fix image proxy public access and model avatar fallback 2026-08-19 09:27:10 +08:00
134
c27a707776 fix world labs task polling fallback for dynamic response payloads 2026-08-18 18:43:17 +08:00
134
7602365bb8 fix(canvas3d): retry transient poll failures before fail state 2026-08-18 16:53:28 +08:00
134
1fa126cecf fix(canvas): allow repeated layout source picking 2026-08-14 12:10:23 +08:00
134
398a4bbefd feat(canvas): add direct canvas image picking 2026-08-14 11:47:42 +08:00
134
3010e51f72 feat(canvas): add durable batch template replacement 2026-08-14 10:20:01 +08:00
134
71b3ce56cf fix(canvas): restore AI layout replacement 2026-08-14 00:18:15 +08:00
134
bf6bd01fcb feat(canvas): add batch template replacement 2026-08-13 23:29:08 +08:00
134
3009727ec1 chore(git): ignore local runtime data 2026-08-13 18:38:28 +08:00
134
e2806578c0 fix(release): align template flow with production baseline 2026-08-13 18:34:46 +08:00
134
231650dc00 chore(git): enforce deployment branch and feature guards 2026-08-13 18:31:44 +08:00
134
675966a783 feat(agent): add AI template reuse to main results 2026-08-13 18:31:19 +08:00
134
9ff3dc68ce test(canvas): guard PSD template toolbar entry 2026-08-13 18:28:53 +08:00
134
01722af2fb fix(canvas): restore PSD template replacement entry 2026-08-13 18:28:53 +08:00
134
46a28f77f0 fix(claw): restore spreadsheet attachment badges 2026-08-11 21:24:44 +08:00
134
aafc970ae8 fix(claw): preserve per-task credentials on reconnect 2026-08-11 18:22:32 +08:00
134
e2a8f2dda3 merge: preserve server-only production updates 2026-08-11 15:47:02 +08:00
134
731aa9725a fix: preserve main behavior after Claw merge 2026-08-11 15:28:11 +08:00
134
1dd489100d feat(claw): support resumable 2GB file uploads 2026-08-11 15:13:36 +08:00
134
3f2b18d18d merge: sync Claw production updates into main
# Conflicts:
#	app/(admin)/settings/admin-settings-client.tsx
#	app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
#	playwright.helpers.config.ts
2026-08-11 15:11:59 +08:00
134
e225e06f0b fix: stabilize 3d workspace and canvas model controls 2026-08-10 20:54:23 +08:00
134
1a9888d825 fix: harden canvas image generation flows
(cherry picked from commit 78b0da908c4a0c22bd7eb66b5a661c07fcecb4fc)
2026-07-27 14:41:08 +00:00
1069 changed files with 149783 additions and 16209 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

38
.gitignore vendored
View File

@ -13,6 +13,7 @@ coverage
.next/ .next/
.next-build/ .next-build/
.next-previous/ .next-previous/
/.next-*/
out/ out/
build build
@ -50,6 +51,7 @@ yarn-error.log*
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/* /playwright/*
/output/
deploy.tar.gz deploy.tar.gz
libvips-8.14.5-linux-x64.tar.br libvips-8.14.5-linux-x64.tar.br
.temp-packages/ .temp-packages/
@ -66,10 +68,19 @@ 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/
# 本地运行时数据库与上传文件
.local/
public/uploads/local/
# 模型文件 # 模型文件
*.model *.model
*.pth *.pth
@ -100,3 +111,30 @@ public/vendor/zoo-modeling-app/
# Drizzle migrations must be versioned (override the global *.sql ignore above) # Drizzle migrations must be versioned (override the global *.sql ignore above)
!lib/db/migrations/*.sql !lib/db/migrations/*.sql
# Private smart edit runtime and media/model caches
.venv-smart-edit/
.smart-edit-models/
.cache/smart-edit/
services/video-render/dist/
# Downloaded and normalized subtitle fonts are runtime assets.
.cache/smart-edit-fonts/
services/smart-edit/__pycache__/
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
.qa/
.next-video-*/
.next-tvstar-*/
services/video-upscale/dist/
services/video-upscale/__pycache__/
services/video-upscale/vendor/__pycache__/
/services/audio-processing/dist/
/services/audio-processing/__pycache__/
# Python release-tool and test bytecode.
__pycache__/
*.pyc

View File

@ -94,7 +94,7 @@ pnpm db:generate
推荐提交前至少运行: 推荐提交前至少运行:
```bash ```bash
git fetch github main --prune git fetch origin --prune
pnpm check:branch pnpm check:branch
pnpm check:quality pnpm check:quality
pnpm audit:security pnpm audit:security

View File

@ -75,6 +75,8 @@ export function LocalAiImagePipelineSections({
aiWashConfig, aiWashConfig,
setAiWashConfig, setAiWashConfig,
aiWashModeIconUploading, aiWashModeIconUploading,
canvasLocalEditConfig,
setCanvasLocalEditConfig,
extendConfig, extendConfig,
setExtendConfig, setExtendConfig,
watermarkRemoveConfig, watermarkRemoveConfig,
@ -103,6 +105,7 @@ export function LocalAiImagePipelineSections({
handleMoveEnhanceProPreset, handleMoveEnhanceProPreset,
handleAiWashModeIconUpload, handleAiWashModeIconUpload,
handleSaveAiWashConfig, handleSaveAiWashConfig,
handleSaveCanvasLocalEditConfig,
handleSaveExtendConfig, handleSaveExtendConfig,
handleSaveWatermarkRemoveConfig, handleSaveWatermarkRemoveConfig,
handleSaveAngleEditConfig, handleSaveAngleEditConfig,
@ -662,7 +665,7 @@ export function LocalAiImagePipelineSections({
</p> </p>
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p> <p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
<p> <p>
• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸) • 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸)
</p> </p>
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p> <p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
</div> </div>
@ -781,7 +784,7 @@ export function LocalAiImagePipelineSections({
<p>• 支持2x、4x、8x放大倍数</p> <p>• 支持2x、4x、8x放大倍数</p>
<p>• 处理时间:通常1-10秒,取决于图片大小</p> <p>• 处理时间:通常1-10秒,取决于图片大小</p>
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p> <p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
<p>• 文件限制:最大50MB</p> <p>• 文件限制:最大20MB</p>
</div> </div>
</div> </div>
</> </>
@ -1463,6 +1466,112 @@ export function LocalAiImagePipelineSections({
</Button> </Button>
</LocalAiSectionShell> </LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。
</>
}
expanded={isLocalAiSectionExpanded("canvas-local-edit")}
id="local-ai-section-canvas-local-edit"
onToggle={() => toggleLocalAiSection("canvas-local-edit")}
summary={
<LocalAiSectionSummary
items={[
{
label: canvasLocalEditConfig.modelId
? "普通局部编辑已指定"
: "普通局部编辑继承默认",
tone: canvasLocalEditConfig.modelId ? "enabled" : "neutral",
},
{
label: canvasLocalEditConfig.scaleAdjustModelId
? "比例调整已指定"
: "比例调整兼容旧默认",
tone: canvasLocalEditConfig.scaleAdjustModelId
? "enabled"
: "neutral",
},
]}
/>
}
title="局部编辑模型配置"
>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="canvasLocalEditModel">普通局部编辑默认模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
modelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.modelId || "__none__"}
>
<SelectTrigger id="canvasLocalEditModel">
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于手动画框并填写提示词的局部编辑。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasScaleAdjustModel">比例调整/目标框模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
scaleAdjustModelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
>
<SelectTrigger id="canvasScaleAdjustModel">
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。
</div>
</div>
</div>
<Button onClick={handleSaveCanvasLocalEditConfig}>
<Save className="mr-2 h-4 w-4" /> 保存局部编辑模型配置
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
@ -2268,13 +2377,10 @@ export function LocalAiImagePipelineSections({
items={[ items={[
{ {
label: label:
extendConfig.provider === "vip98k" || extendConfig.apiKey extendConfig.provider === "vip98k"
? "Key 已配置" ? "VIP98K 服务商"
: "Key 待配置", : "阿里云服务商",
tone: tone: "enabled",
extendConfig.provider === "vip98k" || extendConfig.apiKey
? "enabled"
: "warning",
}, },
{ {
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`, label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
@ -2291,22 +2397,11 @@ export function LocalAiImagePipelineSections({
VIP98K 服务商读取,仅在这里维护扩展比例。 VIP98K 服务商读取,仅在这里维护扩展比例。
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="rounded-lg border bg-muted/40 p-4 text-sm">
<Label htmlFor="extendApiKey">API密钥 *</Label> 使用阿里云 DashScope 的 image-out-painting 模型。API Key
<Input 从“AI 模型设置”中已启用的阿里云官方生图服务商读取,
id="extendApiKey" 仅在这里维护拓展比例。
onChange={(e) =>
setExtendConfig({ ...extendConfig, apiKey: e.target.value })
}
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
type="password"
value={extendConfig.apiKey}
/>
<div className="text-muted-foreground text-xs">
请输入阿里云Dashscope API密钥,例如:
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</div> </div>
</div>
)} )}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
@ -2361,7 +2456,7 @@ export function LocalAiImagePipelineSections({
https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
</p> </p>
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p> <p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
<p>• 最大图片大小:4MB</p> <p>• 最大图片大小:10MB</p>
<p>• 处理时间:约5-15秒/张</p> <p>• 处理时间:约5-15秒/张</p>
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p> <p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
</div> </div>

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>
{/* 批量处理配置 */} {/* 批量处理配置 */}

View File

@ -12,6 +12,7 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
rmbg: false, rmbg: false,
enhance: false, enhance: false,
"enhance-pro": false, "enhance-pro": false,
"canvas-local-edit": false,
"ai-wash": false, "ai-wash": false,
extend: false, extend: false,
"watermark-remove": false, "watermark-remove": false,

View File

@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react"; import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config"; import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
import type { AiWashConfig } from "@/lib/ai-wash/config"; import type { AiWashConfig } from "@/lib/ai-wash/config";
import type { import type {
EnhanceProKreaConfig, EnhanceProKreaConfig,
@ -274,7 +275,6 @@ export type EnhanceConfig = {
export type ExtendConfig = { export type ExtendConfig = {
provider: "vip98k" | "dashscope"; provider: "vip98k" | "dashscope";
apiKey: string;
xScale: string; xScale: string;
yScale: string; yScale: string;
}; };
@ -333,6 +333,8 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
aiWashConfig: AiWashConfig; aiWashConfig: AiWashConfig;
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>; setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
aiWashModeIconUploading: boolean; aiWashModeIconUploading: boolean;
canvasLocalEditConfig: CanvasLocalEditConfig;
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
extendConfig: ExtendConfig; extendConfig: ExtendConfig;
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>; setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
watermarkRemoveConfig: WatermarkRemoveConfig; watermarkRemoveConfig: WatermarkRemoveConfig;
@ -375,6 +377,7 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void; handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
handleAiWashModeIconUpload: (file: File) => Promise<void>; handleAiWashModeIconUpload: (file: File) => Promise<void>;
handleSaveAiWashConfig: AsyncAction; handleSaveAiWashConfig: AsyncAction;
handleSaveCanvasLocalEditConfig: AsyncAction;
handleSaveExtendConfig: AsyncAction; handleSaveExtendConfig: AsyncAction;
handleSaveWatermarkRemoveConfig: AsyncAction; handleSaveWatermarkRemoveConfig: AsyncAction;
handleSaveAngleEditConfig: AsyncAction; handleSaveAngleEditConfig: AsyncAction;

View File

@ -38,6 +38,7 @@ import {
ImagePlus, ImagePlus,
Key, Key,
Layers, Layers,
LayoutTemplate,
Layers3, Layers3,
Loader2, Loader2,
type LucideIcon, type LucideIcon,
@ -200,6 +201,11 @@ import {
MIN_CANVAS_IMAGE_TASK_CONCURRENCY, MIN_CANVAS_IMAGE_TASK_CONCURRENCY,
normalizeCanvasImageTaskQueueConfig, normalizeCanvasImageTaskQueueConfig,
} from "@/lib/canvas/image-task-config"; } from "@/lib/canvas/image-task-config";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
defaultCanvasLocalEditConfig,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import { import {
type CanvasSkillCategory, type CanvasSkillCategory,
type CanvasSkillTag, type CanvasSkillTag,
@ -1056,6 +1062,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
imageRemoveBackground: Scissors, imageRemoveBackground: Scissors,
imageBoxCutout: Scan, imageBoxCutout: Scan,
imageLocalEdit: Wand2, imageLocalEdit: Wand2,
imageLayoutReplace: LayoutTemplate,
imageRemoveWatermark: Eraser, imageRemoveWatermark: Eraser,
imageResize: Maximize2, imageResize: Maximize2,
imageTextEdit: Type, imageTextEdit: Type,
@ -1901,6 +1908,13 @@ export function AdminSettingsClient() {
const { data: aiWashConfigData, mutate: mutateAiWashConfig } = useSWR<{ const { data: aiWashConfigData, mutate: mutateAiWashConfig } = useSWR<{
setting?: { value?: unknown }; setting?: { value?: unknown };
}>(`/api/admin/settings?key=${AI_WASH_CONFIG_KEY}`, fetcher); }>(`/api/admin/settings?key=${AI_WASH_CONFIG_KEY}`, fetcher);
const {
data: canvasLocalEditConfigData,
mutate: mutateCanvasLocalEditConfig,
} = useSWR<{ setting?: { value?: unknown } }>(
`/api/admin/settings?key=${CANVAS_LOCAL_EDIT_CONFIG_KEY}`,
fetcher
);
const { const {
data: canvasImageTaskQueueConfigData, data: canvasImageTaskQueueConfigData,
mutate: mutateCanvasImageTaskQueueConfig, mutate: mutateCanvasImageTaskQueueConfig,
@ -2168,7 +2182,7 @@ export function AdminSettingsClient() {
usageTimeEnabled: false, usageTimeEnabled: false,
usageTimeStart: "08:00", usageTimeStart: "08:00",
usageTimeEnd: "20:00", usageTimeEnd: "20:00",
usageTimeMessage: "由壹点累了也是需要休息的", usageTimeMessage: "BuYi 累了也是需要休息的",
defaultTheme: "system", defaultTheme: "system",
bannerEnabled: true, bannerEnabled: true,
bannerText: "限时特惠:升级会员最高立享 45% OFF!", bannerText: "限时特惠:升级会员最高立享 45% OFF!",
@ -2210,7 +2224,7 @@ export function AdminSettingsClient() {
agentWorkflowDefaultModelId: null, agentWorkflowDefaultModelId: null,
canvasDetailDefaultModelId: null, canvasDetailDefaultModelId: null,
canvasDetailAgentModelId: null, canvasDetailAgentModelId: null,
lovartComposerPrefix: "让「由壹点AI」", lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
lovartComposerPreviewTexts: [ lovartComposerPreviewTexts: [
"打造引人注目的社交媒体视觉", "打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报", "帮我把这张产品图做成高级电商海报",
@ -2226,8 +2240,8 @@ export function AdminSettingsClient() {
lovartPromoText: lovartPromoText:
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级", lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单", lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切", lovartHeroSubtitle: "下达指令,即刻呈现",
surpriseCategories: ["全部", "精选"], surpriseCategories: ["全部", "精选"],
membershipConfig: defaultMembershipConfig, membershipConfig: defaultMembershipConfig,
membershipUserOverrides: defaultMembershipUserOverrides, membershipUserOverrides: defaultMembershipUserOverrides,
@ -2288,7 +2302,7 @@ export function AdminSettingsClient() {
usageTimeStart: siteSettingsData.usageTimeStart || "08:00", usageTimeStart: siteSettingsData.usageTimeStart || "08:00",
usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00", usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00",
usageTimeMessage: usageTimeMessage:
siteSettingsData.usageTimeMessage || "由壹点累了也是需要休息的", siteSettingsData.usageTimeMessage || "BuYi 累了也是需要休息的",
defaultTheme: siteSettingsData.defaultTheme || "system", defaultTheme: siteSettingsData.defaultTheme || "system",
bannerEnabled: siteSettingsData.bannerEnabled ?? true, bannerEnabled: siteSettingsData.bannerEnabled ?? true,
bannerText: bannerText:
@ -2384,7 +2398,7 @@ export function AdminSettingsClient() {
(siteSettingsData as any).canvasDetailAgentModelId || null, (siteSettingsData as any).canvasDetailAgentModelId || null,
lovartComposerPrefix: lovartComposerPrefix:
String((siteSettingsData as any).lovartComposerPrefix || "").trim() || String((siteSettingsData as any).lovartComposerPrefix || "").trim() ||
"让「由壹点AI」", "让「𝗕𝘂𝗬𝗶」",
lovartComposerPreviewTexts: Array.isArray( lovartComposerPreviewTexts: Array.isArray(
siteSettingsData.lovartComposerPreviewTexts siteSettingsData.lovartComposerPreviewTexts
) )
@ -2486,9 +2500,9 @@ export function AdminSettingsClient() {
lovartPromoActionText: lovartPromoActionText:
siteSettingsData.lovartPromoActionText || "立即升级", siteSettingsData.lovartPromoActionText || "立即升级",
lovartHeroTitle: lovartHeroTitle:
siteSettingsData.lovartHeroTitle || "由壹点AI 让设计更简单", siteSettingsData.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: lovartHeroSubtitle:
siteSettingsData.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切", siteSettingsData.lovartHeroSubtitle || "下达指令,即刻呈现",
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories) surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
? siteSettingsData.surpriseCategories ? siteSettingsData.surpriseCategories
.map((item: any) => String(item).trim()) .map((item: any) => String(item).trim())
@ -2613,11 +2627,13 @@ export function AdminSettingsClient() {
const [draggingEnhanceProPresetId, setDraggingEnhanceProPresetId] = const [draggingEnhanceProPresetId, setDraggingEnhanceProPresetId] =
useState(""); useState("");
const [aiWashConfig, setAiWashConfig] = useState(defaultAiWashConfig()); const [aiWashConfig, setAiWashConfig] = useState(defaultAiWashConfig());
const [canvasLocalEditConfig, setCanvasLocalEditConfig] = useState(
defaultCanvasLocalEditConfig()
);
// 图像拓展API配置状态 // 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({ const [extendConfig, setExtendConfig] = useState({
provider: "vip98k" as "vip98k" | "dashscope", provider: "dashscope" as "vip98k" | "dashscope",
apiKey: "",
xScale: "1.5", // X轴放大倍数 xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数 yScale: "1.5", // Y轴放大倍数
}); });
@ -2857,6 +2873,13 @@ export function AdminSettingsClient() {
setAiWashConfig(normalizeAiWashConfig(aiWashConfigData.setting?.value)); setAiWashConfig(normalizeAiWashConfig(aiWashConfigData.setting?.value));
}, [aiWashConfigData]); }, [aiWashConfigData]);
useEffect(() => {
if (!canvasLocalEditConfigData) return;
setCanvasLocalEditConfig(
normalizeCanvasLocalEditConfig(canvasLocalEditConfigData.setting?.value)
);
}, [canvasLocalEditConfigData]);
const enhanceProApiKeysDraftCount = useMemo( const enhanceProApiKeysDraftCount = useMemo(
() => () =>
normalizeEnhanceProKreaConfig({ normalizeEnhanceProKreaConfig({
@ -2929,13 +2952,11 @@ export function AdminSettingsClient() {
if (extendConfigData?.setting?.value) { if (extendConfigData?.setting?.value) {
const config = extendConfigData.setting.value as { const config = extendConfigData.setting.value as {
provider?: "vip98k" | "dashscope"; provider?: "vip98k" | "dashscope";
apiKey?: string;
xScale?: number; xScale?: number;
yScale?: number; yScale?: number;
}; };
setExtendConfig({ setExtendConfig({
provider: config.provider === "vip98k" ? "vip98k" : "dashscope", provider: config.provider === "vip98k" ? "vip98k" : "dashscope",
apiKey: config.apiKey || "",
xScale: config.xScale ? String(config.xScale) : "1.5", xScale: config.xScale ? String(config.xScale) : "1.5",
yScale: config.yScale ? String(config.yScale) : "1.5", yScale: config.yScale ? String(config.yScale) : "1.5",
}); });
@ -5302,6 +5323,34 @@ export function AdminSettingsClient() {
} }
}; };
const handleSaveCanvasLocalEditConfig = async () => {
try {
const normalizedConfig =
normalizeCanvasLocalEditConfig(canvasLocalEditConfig);
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: CANVAS_LOCAL_EDIT_CONFIG_KEY,
value: normalizedConfig,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(
String(payload?.message || payload?.error || "保存失败")
);
}
mutateCanvasLocalEditConfig();
toast.success("局部编辑模型配置保存成功");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存局部编辑模型配置失败"
);
}
};
const handleDeleteEnhanceProPoolKey = (index: number) => { const handleDeleteEnhanceProPoolKey = (index: number) => {
setEnhanceProConfig((prev) => ({ setEnhanceProConfig((prev) => ({
...prev, ...prev,
@ -5504,11 +5553,6 @@ export function AdminSettingsClient() {
// --- 图像拓展API配置逻辑 --- // --- 图像拓展API配置逻辑 ---
const handleSaveExtendConfig = async () => { const handleSaveExtendConfig = async () => {
try { try {
if (extendConfig.provider !== "vip98k" && !extendConfig.apiKey.trim()) {
toast.error("请输入API密钥");
return;
}
const xScale = Number.parseFloat(extendConfig.xScale); const xScale = Number.parseFloat(extendConfig.xScale);
const yScale = Number.parseFloat(extendConfig.yScale); const yScale = Number.parseFloat(extendConfig.yScale);
@ -5529,7 +5573,6 @@ export function AdminSettingsClient() {
key: "extend_api_config", key: "extend_api_config",
value: { value: {
provider: extendConfig.provider, provider: extendConfig.provider,
apiKey: extendConfig.apiKey.trim(),
xScale, xScale,
yScale, yScale,
}, },
@ -8863,9 +8906,7 @@ export function AdminSettingsClient() {
{ {
key: "realtime-canvas", key: "realtime-canvas",
title: "实时与队列", title: "实时与队列",
description: `单模型并发 ${canvasImageTaskQueueConfig.concurrency},批次间隔 ${( description: "生图并发不限,任务即时发起",
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒`,
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭", status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral", tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
icon: SlidersHorizontal, icon: SlidersHorizontal,
@ -8948,7 +8989,7 @@ export function AdminSettingsClient() {
key: "extend", key: "extend",
label: "图像拓展", label: "图像拓展",
group: "图像处理", group: "图像处理",
tone: extendConfig.apiKey ? "enabled" : "warning", tone: "enabled",
}, },
{ {
key: "watermark-remove", key: "watermark-remove",
@ -10472,7 +10513,7 @@ export function AdminSettingsClient() {
lovartComposerPrefix: e.target.value.slice(0, 40), lovartComposerPrefix: e.target.value.slice(0, 40),
}) })
} }
placeholder="让「由壹点AI」" placeholder="让「𝗕𝘂𝗬𝗶」"
value={siteSettings.lovartComposerPrefix} value={siteSettings.lovartComposerPrefix}
/> />
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
@ -10674,7 +10715,7 @@ export function AdminSettingsClient() {
lovartHeroTitle: e.target.value, lovartHeroTitle: e.target.value,
}) })
} }
placeholder="由壹点AI 让设计更简单" placeholder="𝗕𝘂𝗬𝗶 让设计触手可及"
value={siteSettings.lovartHeroTitle} value={siteSettings.lovartHeroTitle}
/> />
</div> </div>
@ -10688,7 +10729,7 @@ export function AdminSettingsClient() {
lovartHeroSubtitle: e.target.value, lovartHeroSubtitle: e.target.value,
}) })
} }
placeholder="懂你的设计代理,帮你搞定一切" placeholder="下达指令,即刻呈现"
value={siteSettings.lovartHeroSubtitle} value={siteSettings.lovartHeroSubtitle}
/> />
</div> </div>
@ -18727,6 +18768,7 @@ export function AdminSettingsClient() {
angleEditConfig={angleEditConfig} angleEditConfig={angleEditConfig}
aiWashConfig={aiWashConfig} aiWashConfig={aiWashConfig}
aiWashModeIconUploading={aiWashModeIconUploading} aiWashModeIconUploading={aiWashModeIconUploading}
canvasLocalEditConfig={canvasLocalEditConfig}
draftEnhanceProKeyRows={draftEnhanceProKeyRows} draftEnhanceProKeyRows={draftEnhanceProKeyRows}
draggingEnhanceProPresetId={draggingEnhanceProPresetId} draggingEnhanceProPresetId={draggingEnhanceProPresetId}
enhanceConfig={enhanceConfig} enhanceConfig={enhanceConfig}
@ -18747,6 +18789,7 @@ export function AdminSettingsClient() {
handleResetEnhanceProPresets={handleResetEnhanceProPresets} handleResetEnhanceProPresets={handleResetEnhanceProPresets}
handleSaveAngleEditConfig={handleSaveAngleEditConfig} handleSaveAngleEditConfig={handleSaveAngleEditConfig}
handleSaveAiWashConfig={handleSaveAiWashConfig} handleSaveAiWashConfig={handleSaveAiWashConfig}
handleSaveCanvasLocalEditConfig={handleSaveCanvasLocalEditConfig}
handleSaveEnhanceConfig={handleSaveEnhanceConfig} handleSaveEnhanceConfig={handleSaveEnhanceConfig}
handleSaveEnhanceProConfig={handleSaveEnhanceProConfig} handleSaveEnhanceProConfig={handleSaveEnhanceProConfig}
handleSaveExtendConfig={handleSaveExtendConfig} handleSaveExtendConfig={handleSaveExtendConfig}
@ -18768,6 +18811,7 @@ export function AdminSettingsClient() {
selectedEnhanceProPresetIndex={selectedEnhanceProPresetIndex} selectedEnhanceProPresetIndex={selectedEnhanceProPresetIndex}
setAngleEditConfig={setAngleEditConfig} setAngleEditConfig={setAngleEditConfig}
setAiWashConfig={setAiWashConfig} setAiWashConfig={setAiWashConfig}
setCanvasLocalEditConfig={setCanvasLocalEditConfig}
setEnhanceConfig={setEnhanceConfig} setEnhanceConfig={setEnhanceConfig}
setEnhanceProApiKeysDraft={setEnhanceProApiKeysDraft} setEnhanceProApiKeysDraft={setEnhanceProApiKeysDraft}
setEnhanceProConfig={setEnhanceProConfig} setEnhanceProConfig={setEnhanceProConfig}

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { filterCanvasModels } from "@/lib/video-canvas/model-matching";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, Plus, Trash2 } from "lucide-react"; import { ArrowLeft, Plus, Trash2 } from "lucide-react";
@ -24,10 +25,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { fetcher } from "@/lib/utils"; import { fetcher } from "@/lib/utils";
import { import {
getVideoCanvasFunctionBillingCopy, getVideoCanvasFunctionBillingCopy,
@ -87,56 +84,23 @@ function getModelOptionLabel(model: AiModelOption) {
return `${name}${id}`; return `${name}${id}`;
} }
function isImageModelOption(model: AiModelOption) {
return isImageGenerationModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function isVideoModelOption(model: AiModelOption) {
return isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function filterModelsForFunctionKind(
models: AiModelOption[],
kind: VideoCanvasFunctionDefinition["kind"],
) {
const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model));
let filtered = enabled;
if (kind === "image" || kind === "image-edit") {
filtered = enabled.filter(isImageModelOption);
} else if (kind === "video") {
filtered = enabled.filter(isVideoModelOption);
} else if (kind === "multimodal") {
filtered = enabled.filter(
(model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)),
);
}
return filtered.length ? filtered : enabled;
}
function ModelSelect({ function ModelSelect({
allowInherit = false, allowInherit = false,
kind, kind,
functionKey,
models, models,
onChange, onChange,
value, value,
}: { }: {
allowInherit?: boolean; allowInherit?: boolean;
kind: VideoCanvasFunctionDefinition["kind"]; kind: VideoCanvasFunctionDefinition["kind"];
functionKey?: string;
models: AiModelOption[]; models: AiModelOption[];
onChange: (value: string) => void; onChange: (value: string) => void;
value: string; value: string;
}) { }) {
const filteredModels = filterModelsForFunctionKind(models, kind); const modelKind = functionKey === "audio-to-video" ? "audio-video" : kind === "image" || kind === "image-edit" || kind === "video" || kind === "multimodal" ? kind : "text";
const filteredModels = filterCanvasModels(models, modelKind);
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto"); const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
const hasCurrent = const hasCurrent =
normalizedValue === "auto" || normalizedValue === "auto" ||
@ -851,13 +815,14 @@ export function VideoCanvasSettingsClient() {
<div className="grid gap-4 md:grid-cols-1"> <div className="grid gap-4 md:grid-cols-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label> <Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
<ModelSelect {["canvas-generate-image", "canvas-generate-video", "canvas-workflow-analyze", "pick-object"].includes(item.route) ? <ModelSelect
allowInherit allowInherit
functionKey={item.key}
kind={item.kind} kind={item.kind}
models={modelOptions} models={modelOptions}
onChange={(value) => updateFunction(item.key, { model: value })} onChange={(value) => updateFunction(item.key, { model: value })}
value={item.model || ""} value={item.model || ""}
/> /> : <p className="text-sm text-muted-foreground">{item.route === "local" ? "本地处理,无需生成模型" : "使用此功能的专用服务接口"}</p>}
</div> </div>
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2"> <div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
<div className="text-xs font-medium text-foreground"> <div className="text-xs font-medium text-foreground">

View File

@ -2,9 +2,31 @@ import { createLogger } from "@/lib/observability/logger";
import { generateText } from "ai"; import { generateText } from "ai";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
normalizeDetailReplacementImageModelConfig,
} from "@/lib/ai/detail-replacement-image-model-config";
import {
compositeReplacementWithReferenceLocks,
parseReplacementLayoutLockRegions,
type NormalizedReplacementBox,
} from "@/lib/ai/replacement-layout-lock";
import {
buildReplacementProductIdentityAnalysisPrompt,
buildReplacementProductStructureLockLines,
formatReplacementProductIdentityProfile,
normalizeReplacementProductImageIndexes,
parseReplacementProductIdentityProfile,
type ReplacementProductIdentityProfile,
} from "@/lib/ai/replacement-product-identity";
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime"; import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
import { resolveDetailReplacementVisionRuntime } from "@/lib/ai/detail-replacement-vision-runtime";
import { inlineVisionImages } from "@/lib/ai/vision-inline"; import { inlineVisionImages } from "@/lib/ai/vision-inline";
import { getEnabledAiModelsWithProvider, getSiteSettings } from "@/lib/db/queries"; import {
getEnabledAiModelsWithProvider,
getSiteSettings,
getSystemSetting,
} from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { bufferToArrayBuffer } from "./persistence"; import { bufferToArrayBuffer } from "./persistence";
import { isVideoProviderType } from "./video-models"; import { isVideoProviderType } from "./video-models";
@ -234,6 +256,8 @@ export async function normalizeReplacementResultImage(params: {
referenceImageUrl: string; referenceImageUrl: string;
referenceGeometry?: ReplacementReferenceGeometry | null; referenceGeometry?: ReplacementReferenceGeometry | null;
scenePlan?: ReplacementScenePlan | null; scenePlan?: ReplacementScenePlan | null;
preserveReferenceOutsideSubject?: boolean;
preserveReferenceText?: boolean;
uploadPrefix: string; uploadPrefix: string;
}): Promise<{ }): Promise<{
originalUrl: string; originalUrl: string;
@ -242,6 +266,11 @@ export async function normalizeReplacementResultImage(params: {
}> { }> {
const sharp = (await import("sharp")).default; const sharp = (await import("sharp")).default;
const generatedBuffer = await fetchImageBufferFromUrl(params.generatedImageUrl); const generatedBuffer = await fetchImageBufferFromUrl(params.generatedImageUrl);
let referenceBuffer: Buffer | null = null;
const getReferenceBuffer = async () => {
referenceBuffer ||= await fetchImageBufferFromUrl(params.referenceImageUrl);
return referenceBuffer;
};
let targetWidth = params.referenceGeometry?.width; let targetWidth = params.referenceGeometry?.width;
let targetHeight = params.referenceGeometry?.height; let targetHeight = params.referenceGeometry?.height;
const generatedMetadata = await sharp(generatedBuffer, { failOn: "none" }) const generatedMetadata = await sharp(generatedBuffer, { failOn: "none" })
@ -250,8 +279,8 @@ export async function normalizeReplacementResultImage(params: {
if (!targetWidth || !targetHeight) { if (!targetWidth || !targetHeight) {
try { try {
const referenceBuffer = await fetchImageBufferFromUrl(params.referenceImageUrl); const resolvedReferenceBuffer = await getReferenceBuffer();
const referenceMetadata = await sharp(referenceBuffer, { failOn: "none" }) const referenceMetadata = await sharp(resolvedReferenceBuffer, { failOn: "none" })
.rotate() .rotate()
.metadata(); .metadata();
targetWidth = referenceMetadata.width; targetWidth = referenceMetadata.width;
@ -298,7 +327,7 @@ export async function normalizeReplacementResultImage(params: {
: shouldPreserveFullGeneratedCanvas : shouldPreserveFullGeneratedCanvas
? "contain" ? "contain"
: "cover"; : "cover";
const normalizedBuffer = let normalizedBuffer =
targetWidth && targetHeight targetWidth && targetHeight
? await imagePipeline ? await imagePipeline
.resize(targetWidth, targetHeight, { .resize(targetWidth, targetHeight, {
@ -312,6 +341,82 @@ export async function normalizeReplacementResultImage(params: {
.png() .png()
.toBuffer() .toBuffer()
: await imagePipeline.png().toBuffer(); : await imagePipeline.png().toBuffer();
if (
targetWidth &&
targetHeight &&
(params.preserveReferenceOutsideSubject || params.preserveReferenceText)
) {
let layoutLocks = {
subjectBoundingBox: params.scenePlan?.subjectBoundingBox || null,
textBoundingBoxes: params.scenePlan?.textBoundingBoxes || [],
analyzed: params.scenePlan?.layoutLockAnalyzed === true,
};
const needsLayoutLockFallback =
!layoutLocks.analyzed ||
(params.preserveReferenceOutsideSubject && !layoutLocks.subjectBoundingBox);
if (needsLayoutLockFallback) {
const fallbackLocks = await resolveReplacementLayoutLockRegions({
referenceImageUrl: params.referenceImageUrl,
referenceBuffer: await getReferenceBuffer(),
});
layoutLocks = {
subjectBoundingBox:
layoutLocks.subjectBoundingBox || fallbackLocks.subjectBoundingBox,
textBoundingBoxes:
layoutLocks.textBoundingBoxes.length > 0
? layoutLocks.textBoundingBoxes
: fallbackLocks.textBoundingBoxes,
analyzed: layoutLocks.analyzed || fallbackLocks.analyzed,
};
}
const shouldUseSafeReferenceFallback = Boolean(
(params.preserveReferenceOutsideSubject && !layoutLocks.subjectBoundingBox) ||
(params.preserveReferenceText && !layoutLocks.analyzed),
);
normalizedBuffer = shouldUseSafeReferenceFallback
? await sharp(await getReferenceBuffer(), { failOn: "none" })
.rotate()
.resize(targetWidth, targetHeight, { fit: "fill" })
.png()
.toBuffer()
: await compositeReplacementWithReferenceLocks({
generatedBuffer: normalizedBuffer,
referenceBuffer: await getReferenceBuffer(),
width: targetWidth,
height: targetHeight,
preserveReferenceOutsideSubject: Boolean(
params.preserveReferenceOutsideSubject && layoutLocks.subjectBoundingBox,
),
subjectBoundingBox: layoutLocks.subjectBoundingBox,
textBoundingBoxes: params.preserveReferenceText
? layoutLocks.textBoundingBoxes
: [],
});
if (shouldUseSafeReferenceFallback) {
chatApiLogger.warn(
"[Chat API] Replacement layout lock analysis unavailable; preserved the reference image instead of returning a layout-damaged result",
{
preserveReferenceOutsideSubject: params.preserveReferenceOutsideSubject,
preserveReferenceText: params.preserveReferenceText,
hasSubjectBoundingBox: Boolean(layoutLocks.subjectBoundingBox),
layoutLockAnalyzed: layoutLocks.analyzed,
},
);
}
chatApiLogger.info("[Chat API] Replacement reference layout locks applied:", {
preserveReferenceOutsideSubject: Boolean(
params.preserveReferenceOutsideSubject && layoutLocks.subjectBoundingBox,
),
preservedTextRegionCount: params.preserveReferenceText
? layoutLocks.textBoundingBoxes.length
: 0,
usedFallbackAnalysis: needsLayoutLockFallback,
usedSafeReferenceFallback: shouldUseSafeReferenceFallback,
});
}
const thumbnailBuffer = await createAgentImageThumbnailBuffer(normalizedBuffer); const thumbnailBuffer = await createAgentImageThumbnailBuffer(normalizedBuffer);
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@ -399,6 +504,12 @@ export type ReplacementScenePlan = {
generationPrompt: string; generationPrompt: string;
selectedProductImageIndexes: number[]; selectedProductImageIndexes: number[];
productSelectionReason: string; productSelectionReason: string;
productIdentitySummary?: string;
productStructureSummary?: string;
productStructuralRisks?: string;
subjectBoundingBox?: NormalizedReplacementBox | null;
textBoundingBoxes?: NormalizedReplacementBox[];
layoutLockAnalyzed?: boolean;
analysisModelName?: string | null; analysisModelName?: string | null;
analysisSource: "local_moonshot" | "configured_vision" | "heuristic"; analysisSource: "local_moonshot" | "configured_vision" | "heuristic";
}; };
@ -439,6 +550,188 @@ export type ReplacementScenePlanningRuntime =
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i; const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
const replacementProductIdentityProfileCache = new Map<
string,
{
expiresAt: number;
promise: Promise<ReplacementProductIdentityProfile | null>;
}
>();
async function resolveReplacementProductIdentityProfile(params: {
productImageUrls: string[];
planningRuntime: Exclude<ReplacementScenePlanningRuntime, null>;
}) {
const productImageUrls = Array.from(
new Set(
params.productImageUrls
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url)),
),
).slice(0, 8);
if (productImageUrls.length === 0) return null;
const cacheKey = [
params.planningRuntime.kind,
params.planningRuntime.modelName,
...productImageUrls,
].join("|");
const now = Date.now();
const cached = replacementProductIdentityProfileCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.promise;
}
if (replacementProductIdentityProfileCache.size >= 100) {
const oldestKey = replacementProductIdentityProfileCache.keys().next().value;
if (oldestKey) replacementProductIdentityProfileCache.delete(oldestKey);
}
const promise = (async () => {
try {
const { inlineDataUrls } = await inlineVisionImages(productImageUrls, {
maxImages: productImageUrls.length,
maxSide: 800,
timeoutMs: 12_000,
retries: 2,
loggerPrefix: "[Chat API] Replacement product identity",
trimTransparentPadding: true,
});
const effectiveImages =
inlineDataUrls.length === productImageUrls.length
? inlineDataUrls
: productImageUrls;
const { text } = await generateText({
model: params.planningRuntime.model,
messages: [
{
role: "user",
content: [
{
type: "text",
text: buildReplacementProductIdentityAnalysisPrompt(
productImageUrls.length,
),
},
...effectiveImages.map((image) => ({
type: "image" as const,
image,
})),
],
},
],
system:
"你是高精度商品结构分析器。多张图片属于同一商品;请跨视角还原唯一结构身份,并明确每个视角最清楚的构件。",
temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1,
maxTokens: 1800,
abortSignal: AbortSignal.timeout(
params.planningRuntime.kind === "local_moonshot" ? 90_000 : 60_000,
),
} as any);
const profile = parseReplacementProductIdentityProfile(
text,
productImageUrls.length,
);
if (!profile) {
throw new Error("商品结构画像缺少必需字段");
}
return profile;
} catch (error) {
chatApiLogger.warn("[Chat API] Replacement product identity analysis failed:", {
productImageCount: productImageUrls.length,
modelName: params.planningRuntime.modelName,
error: error instanceof Error ? error.message : "unknown",
});
return null;
}
})();
replacementProductIdentityProfileCache.set(cacheKey, {
expiresAt: now + 30 * 60 * 1000,
promise,
});
return promise;
}
const replacementLayoutLockCache = new Map<
string,
{
expiresAt: number;
promise: Promise<ReturnType<typeof parseReplacementLayoutLockRegions>>;
}
>();
async function resolveReplacementLayoutLockRegions(params: {
referenceImageUrl: string;
referenceBuffer: Buffer;
}) {
const now = Date.now();
const cached = replacementLayoutLockCache.get(params.referenceImageUrl);
if (cached && cached.expiresAt > now) {
return cached.promise;
}
if (replacementLayoutLockCache.size >= 200) {
const oldestKey = replacementLayoutLockCache.keys().next().value;
if (oldestKey) {
replacementLayoutLockCache.delete(oldestKey);
}
}
const promise = (async () => {
try {
const sharp = (await import("sharp")).default;
const inlineBuffer = await sharp(params.referenceBuffer, { failOn: "none" })
.rotate()
.resize(1600, 1600, {
fit: "inside",
withoutEnlargement: true,
})
.flatten({ background: "#ffffff" })
.jpeg({ quality: 88 })
.toBuffer();
const { callQwen3Vl } = await import("@/lib/ai/qwen3-vl");
const result = await callQwen3Vl({
imageUrl: `data:image/jpeg;base64,${inlineBuffer.toString("base64")}`,
prompt: [
"你是电商详情图的布局区域定位器。",
"只分析当前图像,返回严格 JSON,不要解释。",
"所有坐标使用 0到1000 的归一化坐标,格式必须是 [x1,y1,x2,y2],绝对不是 x/y/宽/高。",
"subjectBoundingBox:只框出图中当前旧商品主体的可视外轮廓区域,包含完整商品和它的自然阴影,不要包含文字;没有商品则为 null。",
"textBoundingBoxes:框出所有可见文案块,包括中英文标题、卖点、参数、标签、数字、品牌字样、按钮文字和紧邻的图标;没有则返回空数组。",
'{"subjectBoundingBox":[0,0,1000,1000],"textBoundingBoxes":[[0,0,1000,1000]]}',
].join("\n"),
temperature: 0,
maxTokens: 1600,
timeoutMs: 60_000,
retries: 1,
});
const parsedLocks = parseReplacementLayoutLockRegions(result.text);
if (!parsedLocks.analyzed) {
throw new Error("布局区域定位结果缺少必需字段");
}
return parsedLocks;
} catch (error) {
replacementLayoutLockCache.delete(params.referenceImageUrl);
chatApiLogger.warn("[Chat API] Replacement layout lock fallback analysis failed:", {
referenceImageUrl: params.referenceImageUrl,
error: error instanceof Error ? error.message : "unknown",
});
return {
subjectBoundingBox: null,
textBoundingBoxes: [],
analyzed: false,
};
}
})();
replacementLayoutLockCache.set(params.referenceImageUrl, {
expiresAt: now + 30 * 60 * 1000,
promise,
});
return promise;
}
export function normalizeDetailReplacementStrategy( export function normalizeDetailReplacementStrategy(
value: unknown, value: unknown,
): AgentDetailReplacementStrategy { ): AgentDetailReplacementStrategy {
@ -1441,7 +1734,27 @@ export function parseReplacementScenePlan(
visualProfile: params.visualProfile, visualProfile: params.visualProfile,
}); });
const productImageSelection = buildDefaultProductImageSelection(params.productImageCount); const defaultProductImageSelection = buildDefaultProductImageSelection(
params.productImageCount,
);
const selectedProductImageIndexes = normalizeReplacementProductImageIndexes(
parsed.selectedProductImageIndexes ??
parsed.selectedProductIndexes ??
parsed.productImageIndexes,
params.productImageCount,
);
const productImageSelection =
selectedProductImageIndexes.length > 0
? {
selectedProductImageIndexes,
productSelectionReason:
truncateReplacementAnalysisText(
parsed.productSelectionReason ?? parsed.selectionReason,
240,
) || "按当前详情底板机位选择最匹配的产品视角。",
}
: defaultProductImageSelection;
const layoutLocks = parseReplacementLayoutLockRegions(parsed);
let scenePlan: ReplacementScenePlan = { let scenePlan: ReplacementScenePlan = {
pageKind, pageKind,
generationRoute, generationRoute,
@ -1486,6 +1799,9 @@ export function parseReplacementScenePlan(
generationPrompt, generationPrompt,
selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes, selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes,
productSelectionReason: productImageSelection.productSelectionReason, productSelectionReason: productImageSelection.productSelectionReason,
subjectBoundingBox: layoutLocks.subjectBoundingBox,
textBoundingBoxes: layoutLocks.textBoundingBoxes,
layoutLockAnalyzed: layoutLocks.analyzed,
analysisModelName: null, analysisModelName: null,
analysisSource: "heuristic", analysisSource: "heuristic",
}; };
@ -1844,6 +2160,27 @@ export async function resolveDirectReplacementModelRef(params: {
finalModelId?: string | null; finalModelId?: string | null;
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>; models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) { }) {
const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const configuredModelSetting = await getSystemSetting({
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
}).catch(() => null);
const configuredModelId = normalizeDetailReplacementImageModelConfig(
configuredModelSetting?.value,
).modelId;
const configuredModel = imageModels.find(
({ model }) =>
model.id === configuredModelId || model.modelId === configuredModelId,
);
if (configuredModel) {
return configuredModel.model.id;
}
const explicitModel = String(params.selectedChatModel || "").trim(); const explicitModel = String(params.selectedChatModel || "").trim();
if (explicitModel && explicitModel !== "auto") { if (explicitModel && explicitModel !== "auto") {
return explicitModel; return explicitModel;
@ -1853,13 +2190,6 @@ export async function resolveDirectReplacementModelRef(params: {
return routedModel; return routedModel;
} }
const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const preferred = const preferred =
imageModels.find(({ model }) => model.isDefault) || imageModels.find(({ model }) => model.isDefault) ||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) || imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||
@ -2121,6 +2451,16 @@ export async function analyzeReplacementReferenceImage(params: {
export async function resolveReplacementScenePlanningRuntime(params: { export async function resolveReplacementScenePlanningRuntime(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>; models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}): Promise<ReplacementScenePlanningRuntime> { }): Promise<ReplacementScenePlanningRuntime> {
try {
const dedicatedVisionRuntime = await resolveDetailReplacementVisionRuntime();
if (dedicatedVisionRuntime) return dedicatedVisionRuntime;
} catch (error) {
chatApiLogger.warn(
"[Chat API] Failed to resolve dedicated detail replacement vision model:",
error,
);
}
try { try {
const siteSettings = await getSiteSettings().catch(() => null); const siteSettings = await getSiteSettings().catch(() => null);
const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({ const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({
@ -2191,6 +2531,13 @@ export async function analyzeReplacementExecutionPlan(params: {
const referenceVisualProfile = isDetailReplacement const referenceVisualProfile = isDetailReplacement
? await resolveReplacementReferenceVisualProfile(params.referenceImageUrl) ? await resolveReplacementReferenceVisualProfile(params.referenceImageUrl)
: null; : null;
const productIdentityProfile =
params.planningRuntime && params.productImageUrls.length > 0
? await resolveReplacementProductIdentityProfile({
productImageUrls: params.productImageUrls,
planningRuntime: params.planningRuntime,
})
: null;
if (params.planningRuntime) { if (params.planningRuntime) {
try { try {
const replacementScenePlanningTimeoutMs = const replacementScenePlanningTimeoutMs =
@ -2221,7 +2568,10 @@ export async function analyzeReplacementExecutionPlan(params: {
? "你是电商详情参考图背景替换流程中的“版式与背景区域规划器”。" ? "你是电商详情参考图背景替换流程中的“版式与背景区域规划器”。"
: "你是电商详情替换流程中的“场景理解规划器”。", : "你是电商详情替换流程中的“场景理解规划器”。",
`当前要分析的参考图:${params.referenceLabel}`, `当前要分析的参考图:${params.referenceLabel}`,
"你现在只分析这一张参考图本身,不分析产品图,不分析模特图。", "当前视觉输入只包含这一张详情参考图;产品图已经单独做过跨视角结构分析,请根据下面的产品结构画像选择最匹配当前机位的产品视角,不要臆测产品结构。",
productIdentityProfile
? `【产品结构画像】\n${formatReplacementProductIdentityProfile(productIdentityProfile)}`
: "产品结构画像暂不可用:仍需按当前参考图机位选择产品图,但不得把旧产品的腿型、支架或构件拓扑当成新产品结构。",
referenceVisualProfile referenceVisualProfile
? `像素初筛:nearWhite=${referenceVisualProfile.nearWhitePixelRatio.toFixed(2)}, lightLowSaturation=${referenceVisualProfile.lightLowSaturationPixelRatio.toFixed(2)}, topLight=${referenceVisualProfile.topLightLowSaturationPixelRatio.toFixed(2)}, borderLight=${referenceVisualProfile.borderLightLowSaturationPixelRatio.toFixed(2)}, dark=${referenceVisualProfile.darkPixelRatio.toFixed(2)}, likelyWhiteLayout=${referenceVisualProfile.likelyWhiteLayout ? "true" : "false"}, likelyWhiteScene=${referenceVisualProfile.likelyWhiteScene ? "true" : "false"}。${referenceVisualProfile.reason}` ? `像素初筛:nearWhite=${referenceVisualProfile.nearWhitePixelRatio.toFixed(2)}, lightLowSaturation=${referenceVisualProfile.lightLowSaturationPixelRatio.toFixed(2)}, topLight=${referenceVisualProfile.topLightLowSaturationPixelRatio.toFixed(2)}, borderLight=${referenceVisualProfile.borderLightLowSaturationPixelRatio.toFixed(2)}, dark=${referenceVisualProfile.darkPixelRatio.toFixed(2)}, likelyWhiteLayout=${referenceVisualProfile.likelyWhiteLayout ? "true" : "false"}, likelyWhiteScene=${referenceVisualProfile.likelyWhiteScene ? "true" : "false"}。${referenceVisualProfile.reason}`
: "", : "",
@ -2231,7 +2581,7 @@ export async function analyzeReplacementExecutionPlan(params: {
!referenceVisualProfile?.likelyWhiteLayout && referenceVisualProfile?.likelyWhiteScene !referenceVisualProfile?.likelyWhiteLayout && referenceVisualProfile?.likelyWhiteScene
? "像素初筛已经命中白/浅色低饱和真实场景:这不是白底说明页。若画面存在真实空间、人物或产品照片,promptPackPolicy 应为 soft 或 allow,必须使用前置工作流词包重建图片框内产品、空间、软装和灯光,使差异清晰可见;但最终仍保持白/浅色低饱和基调,不要改造成深色厨房、黑墙、复杂吊灯或强软装。" ? "像素初筛已经命中白/浅色低饱和真实场景:这不是白底说明页。若画面存在真实空间、人物或产品照片,promptPackPolicy 应为 soft 或 allow,必须使用前置工作流词包重建图片框内产品、空间、软装和灯光,使差异清晰可见;但最终仍保持白/浅色低饱和基调,不要改造成深色厨房、黑墙、复杂吊灯或强软装。"
: "", : "",
"产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。", "产品图和模特图会在后续生图阶段单独提供给生图模型。你必须依据产品结构画像输出 selectedProductImageIndexes,选择最多两张最能匹配当前参考图机位并共同说明完整结构的产品图;不得固定选择前两张,也不要要求强行使用全部产品图。",
params.modelImageUrl && !isProductOnlyDetailReplacement params.modelImageUrl && !isProductOnlyDetailReplacement
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。" ? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
: "本轮没有模特图,useModelImage 必须为 false。", : "本轮没有模特图,useModelImage 必须为 false。",
@ -2278,12 +2628,18 @@ export async function analyzeReplacementExecutionPlan(params: {
isDetailReplacement isDetailReplacement
? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。" ? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。"
: "", : "",
isDetailReplacement
? "5a. 必须输出 subjectBoundingBox 和 textBoundingBoxes。所有坐标为 0到1000 归一化坐标 [x1,y1,x2,y2],不是 x/y/宽/高。subjectBoundingBox 紧密框住旧商品主体及自然阴影,不含文字,无商品时为 null;textBoundingBoxes 覆盖所有中英文文案、标题、卖点、参数、标签、数字、品牌字样、按钮文字及紧邻图标,无文字时为 []。"
: "",
isDetailReplacement isDetailReplacement
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。" ? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
: "", : "",
isDetailReplacement isDetailReplacement
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。" ? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
: "", : "",
isDetailReplacement
? "7a. 输出 selectedProductImageIndexes:根据当前详情图机位,从产品结构画像的图片编号中选择最多两张最相关的产品图;第一张应最接近当前机位,第二张补足腿、支架、横梁、侧框或其他容易变形的结构。输出 productSelectionReason 说明选择依据。"
: "",
isProductOnlyDetailReplacement isProductOnlyDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。" ? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
: isDetailReplacement : isDetailReplacement
@ -2309,7 +2665,7 @@ export async function analyzeReplacementExecutionPlan(params: {
: "", : "",
"只返回严格 JSON,不要任何额外解释。", "只返回严格 JSON,不要任何额外解释。",
`JSON 格式: `JSON 格式:
{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","pageKind":"prompt_pack_scene|white_scene|white_layout|copy_locked|people_scene|action_poster|dimension_spec|material_detail|unknown","generationRoute":"${isDetailReplacement ? "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked" : "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked|direct_passthrough"}","promptPackPolicy":"allow|soft|forbid","textPolicy":"free|preserve|lock","peoplePolicy":"none|preserve_relation|require_user_model","whiteBackgroundLikely":false,"copyImageCoupled":false,"containsPeople":false,"containsMultiplePeople":false,"routeReason":"不超过50字","useModelImage":false,"modelImageReason":"不超过40字","sceneSummary":"不超过80字","cameraSummary":"不超过60字","placementSummary":"不超过60字","preserveSummary":"不超过80字","styleSummary":"不超过80字","avoidSummary":"不超过80字","generationPrompt":"不超过300字"}`, {"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","pageKind":"prompt_pack_scene|white_scene|white_layout|copy_locked|people_scene|action_poster|dimension_spec|material_detail|unknown","generationRoute":"${isDetailReplacement ? "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked" : "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked|direct_passthrough"}","promptPackPolicy":"allow|soft|forbid","textPolicy":"free|preserve|lock","peoplePolicy":"none|preserve_relation|require_user_model","whiteBackgroundLikely":false,"copyImageCoupled":false,"containsPeople":false,"containsMultiplePeople":false,"subjectBoundingBox":[0,0,1000,1000],"textBoundingBoxes":[[0,0,1000,1000]],"selectedProductImageIndexes":[1,2],"productSelectionReason":"不超过80字","routeReason":"不超过50字","useModelImage":false,"modelImageReason":"不超过40字","sceneSummary":"不超过80字","cameraSummary":"不超过60字","placementSummary":"不超过60字","preserveSummary":"不超过80字","styleSummary":"不超过80字","avoidSummary":"不超过80字","generationPrompt":"不超过300字"}`,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
@ -2334,7 +2690,7 @@ export async function analyzeReplacementExecutionPlan(params: {
system: system:
"你是高精度商品详情图场景规划器。你只分析当前参考图本身,负责理解这一屏的场景、机位、主体位置和人物需求,不要臆测或依赖其他输入图片。", "你是高精度商品详情图场景规划器。你只分析当前参考图本身,负责理解这一屏的场景、机位、主体位置和人物需求,不要臆测或依赖其他输入图片。",
temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1, temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1,
maxTokens: 1200, maxTokens: 1600,
abortSignal: AbortSignal.timeout(replacementScenePlanningTimeoutMs), abortSignal: AbortSignal.timeout(replacementScenePlanningTimeoutMs),
} as any); } as any);
@ -2368,6 +2724,9 @@ export async function analyzeReplacementExecutionPlan(params: {
: "configured_vision", : "configured_vision",
scenePlan: { scenePlan: {
...normalizedPlan.scenePlan, ...normalizedPlan.scenePlan,
productIdentitySummary: productIdentityProfile?.identitySummary || "",
productStructureSummary: productIdentityProfile?.structureSummary || "",
productStructuralRisks: productIdentityProfile?.structuralRisks || "",
analysisModelName: params.planningRuntime.modelName, analysisModelName: params.planningRuntime.modelName,
analysisSource: analysisSource:
params.planningRuntime.kind === "local_moonshot" params.planningRuntime.kind === "local_moonshot"
@ -2413,7 +2772,12 @@ export async function analyzeReplacementExecutionPlan(params: {
); );
return { return {
...normalizedFallbackPlan.decision, ...normalizedFallbackPlan.decision,
scenePlan: normalizedFallbackPlan.scenePlan, scenePlan: {
...normalizedFallbackPlan.scenePlan,
productIdentitySummary: productIdentityProfile?.identitySummary || "",
productStructureSummary: productIdentityProfile?.structureSummary || "",
productStructuralRisks: productIdentityProfile?.structuralRisks || "",
},
}; };
} }
@ -2537,11 +2901,17 @@ export function buildAgentReplacementPrompt(params: {
] ]
: []; : [];
const productAngleLockLines = [ const productAngleLockLines = [
"【产品角度锁定规则】最终商品/主体的拍摄角度以最后一张参考图为准,而不是以前面的产品输入图为准;前面的产品图只提供款式、材质、颜色、结构和细节。", "【产品角度锁定规则】最终商品/主体的拍摄角度、朝向、占比、重心和透视消失方向以最后一张参考图为准;商品身份、外轮廓、腿型、支架拓扑、构件数量和连接方式以前面的产品输入图为准。",
"必须逐项对齐最后一张参考图中的产品姿态:朝向、正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向、可见侧面数量、支架/腿部/把手/转轴位置、主体外轮廓、占比、重心和透视消失方向。", "必须对齐最后一张参考图的正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向和可见侧面数量,但不得照搬旧商品的腿部、横梁、侧框、把手、转轴或内部构件设计。",
"如果最后一张参考图的产品是侧视或低视角,不允许改成俯视三分之四;如果是俯视三分之四,不允许改成正侧视;不允许镜像、旋转、翻转或把产品重画成另一个角度。", "如果最后一张参考图的产品是侧视或低视角,不允许改成俯视三分之四;如果是俯视三分之四,不允许改成正侧视;不允许镜像、旋转、翻转或把产品重画成另一个角度。",
"若参考图含功能标签、箭头、尺寸线、编号或引导线,它们指向的实际结构部位必须保持一致,不得因产品转角改变而让标签指向错误位置。", "若参考图含功能标签、箭头、尺寸线、编号或引导线,它们指向的实际结构部位必须保持一致,不得因产品转角改变而让标签指向错误位置。",
]; ];
const productStructureLockLines = buildReplacementProductStructureLockLines({
productImageCount: params.productImageCount,
identitySummary: params.scenePlan?.productIdentitySummary,
structureSummary: params.scenePlan?.productStructureSummary,
structuralRisks: params.scenePlan?.productStructuralRisks,
});
const normalizedBasePrompt = String(params.basePrompt || "").trim(); const normalizedBasePrompt = String(params.basePrompt || "").trim();
const promptPackInstructionLines = const promptPackInstructionLines =
params.modeType === "detail_replacement" && params.modeType === "detail_replacement" &&
@ -2641,6 +3011,7 @@ export function buildAgentReplacementPrompt(params: {
...promptPackInstructionLines, ...promptPackInstructionLines,
...scenePlanLines, ...scenePlanLines,
...marketplaceBrandRemovalLines, ...marketplaceBrandRemovalLines,
...productStructureLockLines,
...productAngleLockLines, ...productAngleLockLines,
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。", "你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。", "忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
@ -2670,6 +3041,7 @@ export function buildAgentReplacementPrompt(params: {
...promptPackInstructionLines, ...promptPackInstructionLines,
...scenePlanLines, ...scenePlanLines,
...marketplaceBrandRemovalLines, ...marketplaceBrandRemovalLines,
...productStructureLockLines,
...productAngleLockLines, ...productAngleLockLines,
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。", "你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。", "【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
@ -2785,6 +3157,7 @@ export function buildAgentReplacementPrompt(params: {
"【图片顺序规则】", "【图片顺序规则】",
...imageOrderLines, ...imageOrderLines,
...scenePlanLines, ...scenePlanLines,
...productStructureLockLines,
...productAngleLockLines, ...productAngleLockLines,
params.pairedProductIndex !== undefined params.pairedProductIndex !== undefined
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。` ? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`

View File

@ -1,6 +1,6 @@
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getRequestErrorMessage } from "@/lib/request-error"; import { getRequestErrorMessage, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
import { generateUUID } from "@/lib/utils"; import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.errors"); const chatApiLogger = createLogger("chat.errors");
@ -20,6 +20,14 @@ export function getChatStreamErrorMessage(error: unknown): string {
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim(); error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
const normalized = rawMessage.toLowerCase(); const normalized = rawMessage.toLowerCase();
if (isProviderBalanceError(error)) {
return "当前模型服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
}
if (isRateLimitError(error)) {
return "当前模型服务请求过于频繁,请稍后重试或切换模型。";
}
if ( if (
normalized.includes("token quota is not enough") || normalized.includes("token quota is not enough") ||
normalized.includes("pre_consume_token_quota_failed") || normalized.includes("pre_consume_token_quota_failed") ||
@ -47,6 +55,16 @@ function resolveChatApiFailure(error: unknown) {
const detailMessage = getRequestErrorMessage(error); const detailMessage = getRequestErrorMessage(error);
const normalized = String(detailMessage || "").toLowerCase(); const normalized = String(detailMessage || "").toLowerCase();
if (isProviderBalanceError(error)) {
return {
code: "server_error:chat",
message: "模型服务商线路余额或额度不足,请切换模型或联系管理员。",
status: 503,
category: "provider_balance",
detailMessage,
};
}
if ( if (
normalized.includes("payload too large") || normalized.includes("payload too large") ||
normalized.includes("request entity too large") || normalized.includes("request entity too large") ||

View File

@ -4,6 +4,8 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability"; import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
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 { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
const chatApiLogger = createLogger("chat.imageFailover"); const chatApiLogger = createLogger("chat.imageFailover");
@ -57,7 +59,21 @@ export function resolveImageModelRuntimeId(
export function normalizeFailoverErrorMessage(error: unknown): string { export function normalizeFailoverErrorMessage(error: unknown): string {
if (!error) return ""; if (!error) return "";
if (typeof error === "string") return error; if (typeof error === "string") return error;
if (error instanceof Error) return error.message || String(error); if (error instanceof Error) {
const record = error as Error & {
status?: unknown;
statusCode?: unknown;
httpStatus?: unknown;
response?: { status?: unknown };
};
const status =
record.status ??
record.statusCode ??
record.httpStatus ??
record.response?.status;
const message = error.message || String(error);
return status ? `[HTTP ${String(status)}] ${message}` : message;
}
try { try {
return JSON.stringify(error); return JSON.stringify(error);
} catch { } catch {
@ -68,6 +84,7 @@ export function normalizeFailoverErrorMessage(error: unknown): string {
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean { export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
const msg = errorMessage.toLowerCase(); const msg = errorMessage.toLowerCase();
return ( return (
isProviderBalanceError(errorMessage) ||
msg.includes("model not found") || msg.includes("model not found") ||
msg.includes("model_not_found") || msg.includes("model_not_found") ||
msg.includes("no available channel for model") || msg.includes("no available channel for model") ||
@ -76,6 +93,13 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean
msg.includes("api key is missing") || msg.includes("api key is missing") ||
msg.includes("invalid api key") || msg.includes("invalid api key") ||
msg.includes("unauthorized") || msg.includes("unauthorized") ||
/(?:^|\D)401(?:\D|$)/.test(msg) ||
msg === "forbidden" ||
msg.includes("access forbidden") ||
msg.includes("access denied") ||
msg.includes("permission denied") ||
/(?:^|\D)403(?:\D|$)/.test(msg) ||
msg.includes("vip98k task failed") ||
msg.includes("cannot convert argument to a bytestring") || msg.includes("cannot convert argument to a bytestring") ||
msg.includes("model maintenance") || msg.includes("model maintenance") ||
msg.includes("maintenance") || msg.includes("maintenance") ||
@ -302,9 +326,9 @@ 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)) { if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(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 || "cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; return false;
} }
return true; return true;
@ -464,9 +488,9 @@ 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)) { if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(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 || "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;
} }
return true; return true;

View File

@ -1,3 +1,5 @@
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 { geolocation } from "@vercel/functions"; import { geolocation } from "@vercel/functions";
import { import {
@ -179,6 +181,7 @@ import { ChatSDKError } from "@/lib/errors";
import { import {
getFriendlyRateLimitMessage, getFriendlyRateLimitMessage,
getRequestErrorMessage, getRequestErrorMessage,
getUserFacingImageGenerationErrorMessage,
isRateLimitError, isRateLimitError,
} from "@/lib/request-error"; } from "@/lib/request-error";
import { import {
@ -193,10 +196,12 @@ 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,
shouldFailoverAiModelError, shouldFailoverAiModelError,
shouldFailoverVip98kRequestRejection,
} from "@/lib/ai/runtime/model-health"; } from "@/lib/ai/runtime/model-health";
import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task"; import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task";
import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence"; import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence";
@ -363,6 +368,10 @@ import {
resolveReplacementReferenceGeometry, resolveReplacementReferenceGeometry,
resolveReplacementScenePlanningRuntime, resolveReplacementScenePlanningRuntime,
} from "./_lib/detail-replacement"; } from "./_lib/detail-replacement";
import {
selectDetailReplacementGenerationInputs,
shouldIncludeDetailReplacementModelImage,
} from "@/lib/ai/detail-replacement-inputs";
import { import {
normalizeAgentReportForDisplay, normalizeAgentReportForDisplay,
sanitizeAgentOutputSettingsSection, sanitizeAgentOutputSettingsSection,
@ -417,8 +426,6 @@ const chatApiLogger = createLogger("chat.route");
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN = const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i; /生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) { function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
const normalizedMarkdown = String(markdown || "").trim(); const normalizedMarkdown = String(markdown || "").trim();
@ -435,41 +442,13 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
function getPublicImageGenerationErrorText(error: unknown) { function getPublicImageGenerationErrorText(error: unknown) {
const message = error instanceof Error ? error.message : String(error || ""); const message = error instanceof Error ? error.message : String(error || "");
if (!message || INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message)) { const publicMessage = getUserFacingImageGenerationErrorMessage(error);
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
return "图片生成失败,请稍后重试"; return "图片生成失败,请稍后重试";
} }
return message; return publicMessage;
} }
function replacementScenePlanShouldIncludeModelImage(scenePlan: any) {
if (!scenePlan) return false;
if (scenePlan.useModelImage === true) return true;
if (
scenePlan.peoplePolicy &&
scenePlan.peoplePolicy !== "none"
) {
return true;
}
if (scenePlan.generationRoute === "people_relation_locked") return true;
if (scenePlan.pageKind === "people_scene" || scenePlan.pageKind === "action_poster") {
return true;
}
const text = [
scenePlan.modelImageReason,
scenePlan.routeReason,
scenePlan.sceneSummary,
scenePlan.cameraSummary,
scenePlan.placementSummary,
scenePlan.preserveSummary,
scenePlan.styleSummary,
scenePlan.avoidSummary,
scenePlan.generationPrompt,
]
.filter(Boolean)
.join(" ");
return DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN.test(text);
}
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max( const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
1, 1,
@ -928,6 +907,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();
@ -1222,6 +1210,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);
@ -1342,6 +1338,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:
@ -5723,12 +5720,15 @@ export async function POST(request: Request) {
sourceImageUrls: string[]; sourceImageUrls: string[];
streamLabel: string; streamLabel: string;
promptForPersistence?: string | null; promptForPersistence?: string | null;
isFallback?: boolean;
}) => { }) => {
const persistedImageSize = const persistedImageSize =
String(params.imageSizeLabel || "").trim() || resolvedImageSize; String(params.imageSizeLabel || "").trim() || resolvedImageSize;
const assistantText = prependImageModelBadge( const assistantText = prependImageModelBadge(
[ [
`<!-- AGENT_REPLACEMENT_CARD_ID:${String(params.reference.id || "").trim()} -->`, `<!-- AGENT_REPLACEMENT_CARD_ID:${String(params.reference.id || "").trim()} -->`,
`<!-- AGENT_REPLACEMENT_RUN_ID:${message.id} -->`,
`<!-- AGENT_REPLACEMENT_STATUS:${params.isFallback ? "failed" : "completed"} -->`,
`### ${params.reference.label}`, `### ${params.reference.label}`,
String(params.bodyText || "").trim(), String(params.bodyText || "").trim(),
buildAgentToolImagesMarkdown([ buildAgentToolImagesMarkdown([
@ -5764,26 +5764,28 @@ export async function POST(request: Request) {
], ],
}); });
await saveGeneratedImages( if (!params.isFallback) {
assistantText, await saveGeneratedImages(
session.user.id, assistantText,
id, session.user.id,
assistantMessageId, id,
params.promptForPersistence || baseReplacementPrompt, assistantMessageId,
resolvedModelRef, params.promptForPersistence || baseReplacementPrompt,
{ resolvedModelRef,
sourceAssetIds, {
sourceImageUrls: params.sourceImageUrls, sourceAssetIds,
relationType: "edited_from", sourceImageUrls: params.sourceImageUrls,
sourceType: "detail_replacement", relationType: "edited_from",
projectId: contextPayload?.projectId || null, sourceType: "detail_replacement",
userPrompt: requestTextContent, projectId: contextPayload?.projectId || null,
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt, userPrompt: requestTextContent,
requestedImageCount: 1, optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
aspectRatio: resolvedAspectRatio, requestedImageCount: 1,
imageSize: persistedImageSize, aspectRatio: resolvedAspectRatio,
}, imageSize: persistedImageSize,
); },
);
}
} catch (persistError) { } catch (persistError) {
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", { chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
chatId: id, chatId: id,
@ -5933,20 +5935,18 @@ export async function POST(request: Request) {
const shouldIncludeModelImage = const shouldIncludeModelImage =
detailReplacementStrategy !== "product_only" && detailReplacementStrategy !== "product_only" &&
Boolean(modelInputImageUrl) && Boolean(modelInputImageUrl) &&
(replacementScenePlanShouldIncludeModelImage( (shouldIncludeDetailReplacementModelImage(
replacementExecutionPlan.scenePlan, replacementExecutionPlan.scenePlan,
) || ) ||
selectedProductContextImageUrls.length === 0); selectedProductContextImageUrls.length === 0);
const sourceImageUrls = Array.from( const generationInputs = selectDetailReplacementGenerationInputs({
new Set([ productImageUrls: selectedProductContextImageUrls,
...selectedProductContextImageUrls, modelImageUrl: modelInputImageUrl,
...(shouldIncludeModelImage && modelInputImageUrl includeModelImage: shouldIncludeModelImage,
? [modelInputImageUrl] rerunReferenceImageUrls,
: []), referenceImageUrl: reference.imageUrl,
...rerunReferenceImageUrls, });
reference.imageUrl, const sourceImageUrls = generationInputs.imageUrls;
]),
);
chatApiLogger.info("[Chat API] Direct detail replacement image generation started:", { chatApiLogger.info("[Chat API] Direct detail replacement image generation started:", {
referenceIndex: referenceIndex + 1, referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length, totalReferences: replacementReferences.length,
@ -5954,10 +5954,10 @@ export async function POST(request: Request) {
pageKind: replacementExecutionPlan.scenePlan.pageKind, pageKind: replacementExecutionPlan.scenePlan.pageKind,
generationRoute: replacementExecutionPlan.scenePlan.generationRoute, generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
packPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy, packPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
selectedProductImages: selectedProductContextImageUrls.length, selectedProductImages: generationInputs.productImageUrls.length,
sourceProductImages: productInputImageUrls.length, sourceProductImages: productInputImageUrls.length,
includeModelImage: shouldIncludeModelImage, includeModelImage: Boolean(generationInputs.modelImageUrl),
rerunReferenceImages: rerunReferenceImageUrls.length, rerunReferenceImages: generationInputs.rerunImageUrls.length,
sourceImageCount: sourceImageUrls.length, sourceImageCount: sourceImageUrls.length,
whiteLayout: replacementExecutionPlan.scenePlan.whiteBackgroundLikely, whiteLayout: replacementExecutionPlan.scenePlan.whiteBackgroundLikely,
copyCoupled: replacementExecutionPlan.scenePlan.copyImageCoupled, copyCoupled: replacementExecutionPlan.scenePlan.copyImageCoupled,
@ -5972,10 +5972,10 @@ export async function POST(request: Request) {
referenceLabel: reference.label, referenceLabel: reference.label,
referenceIndex, referenceIndex,
totalReferences: replacementReferences.length, totalReferences: replacementReferences.length,
hasModelImage: shouldIncludeModelImage, hasModelImage: Boolean(generationInputs.modelImageUrl),
productImageCount: selectedProductContextImageUrls.length, productImageCount: generationInputs.productImageUrls.length,
originalProductImageCount: productInputImageUrls.length, originalProductImageCount: productInputImageUrls.length,
rerunReferenceCount: rerunReferenceImageUrls.length, rerunReferenceCount: generationInputs.rerunImageUrls.length,
templateReferenceCount: 0, templateReferenceCount: 0,
scenePlan: replacementExecutionPlan.scenePlan, scenePlan: replacementExecutionPlan.scenePlan,
referenceGeometry, referenceGeometry,
@ -6007,6 +6007,10 @@ export async function POST(request: Request) {
imageSize: resolvedImageSize, imageSize: resolvedImageSize,
imageCount: 1, imageCount: 1,
referenceImages: sourceImageUrls, referenceImages: sourceImageUrls,
promptExtend: false,
providerImageParams: {
inputImageRoleByUrl: generationInputs.inputImageRoleByUrl,
},
debugLabel: "agent-detail-replacement", debugLabel: "agent-detail-replacement",
debugMeta: { debugMeta: {
referenceLabel: reference.label, referenceLabel: reference.label,
@ -6054,6 +6058,9 @@ export async function POST(request: Request) {
referenceImageUrl: reference.imageUrl, referenceImageUrl: reference.imageUrl,
referenceGeometry, referenceGeometry,
scenePlan: replacementExecutionPlan.scenePlan, scenePlan: replacementExecutionPlan.scenePlan,
preserveReferenceOutsideSubject:
detailReplacementStrategy === "product_only",
preserveReferenceText: true,
uploadPrefix: "agent-detail-replacement", uploadPrefix: "agent-detail-replacement",
}); });
finalImage = { finalImage = {
@ -6136,8 +6143,8 @@ export async function POST(request: Request) {
modelDisplayName: null, modelDisplayName: null,
sourceImageUrls: referenceSourceImageUrls, sourceImageUrls: referenceSourceImageUrls,
streamLabel: "agent-mode:image:detail-replacement:item-fallback", streamLabel: "agent-mode:image:detail-replacement:item-fallback",
isFallback: true,
}); });
successCount += 1;
} }
} catch (replacementPlanError) { } catch (replacementPlanError) {
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", { chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
@ -6160,8 +6167,8 @@ export async function POST(request: Request) {
modelDisplayName: null, modelDisplayName: null,
sourceImageUrls: fallbackSourceImageUrls, sourceImageUrls: fallbackSourceImageUrls,
streamLabel: "agent-mode:image:detail-replacement:item-fallback", streamLabel: "agent-mode:image:detail-replacement:item-fallback",
isFallback: true,
}); });
successCount += 1;
} }
}); });
} finally { } finally {
@ -6489,9 +6496,20 @@ export async function POST(request: Request) {
...(selectedAgentPreset.defaultParams || {}), ...(selectedAgentPreset.defaultParams || {}),
...(replacementSlot?.defaultParams || {}), ...(replacementSlot?.defaultParams || {}),
} as Record<string, any>; } as Record<string, any>;
const resolvedModelRef = const replacementVisionModels = await getEnabledAiModelsWithProvider().catch(
String(selectedChatModel || "").trim() && (error) => {
String(selectedChatModel || "").trim() !== "auto" chatApiLogger.warn("[Chat API] Failed to load configured vision models for replacement analysis:", error);
return undefined;
},
);
const resolvedModelRef = isDetailReplacementPreset
? await resolveDirectReplacementModelRef({
selectedChatModel,
finalModelId: presetModelId,
models: replacementVisionModels,
})
: String(selectedChatModel || "").trim() &&
String(selectedChatModel || "").trim() !== "auto"
? String(selectedChatModel || "").trim() ? String(selectedChatModel || "").trim()
: presetModelId; : presetModelId;
const configuredReplacementAspectRatio = const configuredReplacementAspectRatio =
@ -6513,12 +6531,6 @@ export async function POST(request: Request) {
configuredAspectRatio: configuredReplacementAspectRatio, configuredAspectRatio: configuredReplacementAspectRatio,
}); });
} }
const replacementVisionModels = await getEnabledAiModelsWithProvider().catch(
(error) => {
chatApiLogger.warn("[Chat API] Failed to load configured vision models for replacement analysis:", error);
return undefined;
},
);
const replacementScenePlanningRuntime = const replacementScenePlanningRuntime =
await resolveReplacementScenePlanningRuntime({ await resolveReplacementScenePlanningRuntime({
models: replacementVisionModels, models: replacementVisionModels,
@ -6816,24 +6828,36 @@ export async function POST(request: Request) {
!(isDetailReplacementPreset && !(isDetailReplacementPreset &&
detailReplacementStrategy === "product_only") && detailReplacementStrategy === "product_only") &&
Boolean(modelInputImageUrl) && Boolean(modelInputImageUrl) &&
(replacementScenePlanShouldIncludeModelImage( (shouldIncludeDetailReplacementModelImage(
replacementExecutionPlan.scenePlan, replacementExecutionPlan.scenePlan,
) || ) ||
(isDetailReplacementPreset && (isDetailReplacementPreset &&
selectedProductContextImageUrls.length === 0)); selectedProductContextImageUrls.length === 0));
const sourceImageUrls = Array.from( const detailGenerationInputs = isDetailReplacementPreset
new Set( ? selectDetailReplacementGenerationInputs({
[ productImageUrls: selectedProductContextImageUrls,
...selectedProductContextImageUrls, modelImageUrl: modelInputImageUrl,
...(shouldIncludeModelImage && modelInputImageUrl includeModelImage: shouldIncludeModelImage,
? [modelInputImageUrl] templateReferenceImageUrls: replacementTemplateReferenceImageUrls,
: []), rerunReferenceImageUrls,
...replacementTemplateReferenceImageUrls, referenceImageUrl: reference.imageUrl,
...rerunReferenceImageUrls, })
reference.imageUrl, : null;
].filter(Boolean), const sourceImageUrls = detailGenerationInputs
), ? detailGenerationInputs.imageUrls
); : Array.from(
new Set(
[
...selectedProductContextImageUrls,
...(shouldIncludeModelImage && modelInputImageUrl
? [modelInputImageUrl]
: []),
...replacementTemplateReferenceImageUrls,
...rerunReferenceImageUrls,
reference.imageUrl,
].filter(Boolean),
),
);
const replacementPrompt = buildAgentReplacementPrompt({ const replacementPrompt = buildAgentReplacementPrompt({
basePrompt: basePrompt:
baseReplacementPrompt || baseReplacementPrompt ||
@ -6850,11 +6874,19 @@ export async function POST(request: Request) {
referenceLabel: reference.label, referenceLabel: reference.label,
referenceIndex, referenceIndex,
totalReferences: replacementReferences.length, totalReferences: replacementReferences.length,
hasModelImage: shouldIncludeModelImage, hasModelImage: detailGenerationInputs
productImageCount: selectedProductContextImageUrls.length, ? Boolean(detailGenerationInputs.modelImageUrl)
: shouldIncludeModelImage,
productImageCount: detailGenerationInputs
? detailGenerationInputs.productImageUrls.length
: selectedProductContextImageUrls.length,
originalProductImageCount: productInputImageUrls.length, originalProductImageCount: productInputImageUrls.length,
rerunReferenceCount: rerunReferenceImageUrls.length, rerunReferenceCount: detailGenerationInputs
templateReferenceCount: replacementTemplateReferenceImageUrls.length, ? detailGenerationInputs.rerunImageUrls.length
: rerunReferenceImageUrls.length,
templateReferenceCount: detailGenerationInputs
? detailGenerationInputs.templateImageUrls.length
: replacementTemplateReferenceImageUrls.length,
pairedProductIndex, pairedProductIndex,
scenePlan: replacementExecutionPlan.scenePlan, scenePlan: replacementExecutionPlan.scenePlan,
referenceGeometry, referenceGeometry,
@ -6875,11 +6907,20 @@ export async function POST(request: Request) {
sourceImageCount: sourceImageUrls.length, sourceImageCount: sourceImageUrls.length,
generationAspectRatio, generationAspectRatio,
imageOrder: { imageOrder: {
productImages: selectedProductContextImageUrls.length, productImages:
detailGenerationInputs !== null
? detailGenerationInputs.productImageUrls.length
: selectedProductContextImageUrls.length,
originalProductImages: productInputImageUrls.length, originalProductImages: productInputImageUrls.length,
hasModelImage: shouldIncludeModelImage, hasModelImage: detailGenerationInputs
templateReferenceImages: replacementTemplateReferenceImageUrls.length, ? Boolean(detailGenerationInputs.modelImageUrl)
rerunReferenceImages: rerunReferenceImageUrls.length, : shouldIncludeModelImage,
templateReferenceImages: detailGenerationInputs
? detailGenerationInputs.templateImageUrls.length
: replacementTemplateReferenceImageUrls.length,
rerunReferenceImages: detailGenerationInputs
? detailGenerationInputs.rerunImageUrls.length
: rerunReferenceImageUrls.length,
finalReferenceImageIndex: sourceImageUrls.length, finalReferenceImageIndex: sourceImageUrls.length,
}, },
}); });
@ -6897,6 +6938,17 @@ export async function POST(request: Request) {
imageSize: resolvedImageSize, imageSize: resolvedImageSize,
imageCount: 1, imageCount: 1,
referenceImages: sourceImageUrls, referenceImages: sourceImageUrls,
promptExtend: isDetailReplacementPreset
? false
: typeof mergedImageParams.promptExtend === "boolean"
? mergedImageParams.promptExtend
: undefined,
providerImageParams: detailGenerationInputs
? {
inputImageRoleByUrl:
detailGenerationInputs.inputImageRoleByUrl,
}
: undefined,
debugLabel: `agent-${replacementSourceType}`, debugLabel: `agent-${replacementSourceType}`,
debugMeta: { debugMeta: {
referenceLabel: reference.label, referenceLabel: reference.label,
@ -6943,6 +6995,10 @@ export async function POST(request: Request) {
referenceImageUrl: reference.imageUrl, referenceImageUrl: reference.imageUrl,
referenceGeometry, referenceGeometry,
scenePlan: replacementExecutionPlan.scenePlan, scenePlan: replacementExecutionPlan.scenePlan,
preserveReferenceOutsideSubject:
isDetailReplacementPreset &&
detailReplacementStrategy === "product_only",
preserveReferenceText: isDetailReplacementPreset,
uploadPrefix: isDetailReplacementPreset uploadPrefix: isDetailReplacementPreset
? "agent-detail-replacement" ? "agent-detail-replacement"
: "agent-sku-replacement", : "agent-sku-replacement",
@ -8830,7 +8886,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) {
@ -9635,6 +9691,11 @@ export async function POST(request: Request) {
alibabaModelConfig?.fallbackModelId2, alibabaModelConfig?.fallbackModelId2,
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
const allModels = await getEnabledAiModelsWithProvider(); const allModels = await getEnabledAiModelsWithProvider();
const currentModelEntry = resolveModelEntryByRuntimeModelId(
allModels,
actualModelId,
finalModelId,
);
const fallbackCandidates = buildImageFallbackCandidates(allModels, { const fallbackCandidates = buildImageFallbackCandidates(allModels, {
currentProviderId: alibabaModelConfig?.providerId, currentProviderId: alibabaModelConfig?.providerId,
currentModelId: alibabaModelConfig?.id, currentModelId: alibabaModelConfig?.id,
@ -9880,6 +9941,7 @@ export async function POST(request: Request) {
let imageMarkdown = ""; let imageMarkdown = "";
let usedModelId = actualModelId; let usedModelId = actualModelId;
const generationStartedAt = Date.now();
// 🆕 图片生成真正开始时发出标记(用于前端显示占位图) // 🆕 图片生成真正开始时发出标记(用于前端显示占位图)
writeAssistantTextChunk( writeAssistantTextChunk(
@ -9906,14 +9968,43 @@ export async function POST(request: Request) {
} catch (primaryError) { } catch (primaryError) {
chatApiLogger.error("[Chat API] Alibaba primary model failed:", primaryError); chatApiLogger.error("[Chat API] Alibaba primary model failed:", primaryError);
if (!shouldFailoverAiModelError(primaryError)) { await recordAiModelHealthFailure({
model: currentModelEntry?.model || alibabaModelConfig,
provider: currentModelEntry?.provider,
error: primaryError,
context: "chat_image_primary",
elapsedMs: Date.now() - generationStartedAt,
metadata: {
runtimeModelId: actualModelId,
finalModelId,
providerType: (languageModel as any).provider,
aspectRatio: effectiveImageGenerationParams.aspectRatio,
imageSize: effectiveImageGenerationParams.imageSize,
imageCount:
effectiveImageGenerationParams.imageCount ||
defaultImageQualityParams.imageCount ||
1,
},
}).catch((healthError) => {
chatApiLogger.warn("[Chat API] Record image primary health failure failed:", healthError);
});
if (
!shouldFailoverAiModelError(primaryError) &&
!shouldFailoverVip98kRequestRejection(
primaryError,
(languageModel as any).provider,
)
) {
chatApiLogger.info("[Chat API] Alibaba primary error is not eligible for image failover; returning original error."); chatApiLogger.info("[Chat API] Alibaba primary error is not eligible for image failover; returning original error.");
throw primaryError; throw primaryError;
} }
let fallbackSuccess = false; let fallbackSuccess = false;
const triedFallbackModelIds = new Set<string>();
for (const candidate of fallbackCandidates) { for (const candidate of fallbackCandidates) {
try { try {
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})`);
const fallbackResult = await generateImageWithFailoverCandidate({ const fallbackResult = await generateImageWithFailoverCandidate({
candidate, candidate,
@ -9938,6 +10029,22 @@ export async function POST(request: Request) {
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) { if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
} }
await recordAiModelHealthFailure({
model: candidate.model,
provider: candidate.provider,
error: fallbackError,
context: "chat_image_fallback",
elapsedMs: Date.now() - generationStartedAt,
metadata: {
primaryModelId: actualModelId,
fallbackModelId: candidate.model.modelId,
providerType: candidate.provider.type,
aspectRatio: effectiveImageGenerationParams.aspectRatio,
imageSize: effectiveImageGenerationParams.imageSize,
},
}).catch((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);
} }
} }
@ -9966,10 +10073,11 @@ 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 || "")));
for (const candidate of crossProviderCandidates) { for (const candidate of crossProviderCandidates) {
try { try {
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})`);
if ( if (
candidate.provider.type === "fal-image" && candidate.provider.type === "fal-image" &&
@ -10053,6 +10161,22 @@ export async function POST(request: Request) {
if (shouldCooldownImageFallbackModel(crossErrorMessage)) { if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage); setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
} }
await recordAiModelHealthFailure({
model: candidate.model,
provider: candidate.provider,
error: crossError,
context: "chat_image_cross_fallback",
elapsedMs: Date.now() - generationStartedAt,
metadata: {
primaryModelId: actualModelId,
fallbackModelId: candidate.model.modelId,
providerType: candidate.provider.type,
aspectRatio: effectiveImageGenerationParams.aspectRatio,
imageSize: effectiveImageGenerationParams.imageSize,
},
}).catch((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);
} }
} }
@ -10066,6 +10190,19 @@ export async function POST(request: Request) {
chatApiLogger.info("[Chat API] Alibaba generated, length:", imageMarkdown.length); chatApiLogger.info("[Chat API] Alibaba generated, length:", imageMarkdown.length);
chatApiLogger.info("[Chat API] Alibaba used model:", usedModelId); chatApiLogger.info("[Chat API] Alibaba used model:", usedModelId);
const usedModelEntry = resolveModelEntryByRuntimeModelId(
allModels,
usedModelId,
usedModelId === actualModelId ? finalModelId : undefined,
);
await recordAiModelHealthSuccess({
model:
usedModelEntry?.model ||
(usedModelId === actualModelId ? alibabaModelConfig : null),
}).catch((healthError) => {
chatApiLogger.warn("[Chat API] Record image health success failed:", healthError);
});
const usedModelDisplayName = const usedModelDisplayName =
resolveModelDisplayNameByModelId(allModels, usedModelId) || resolveModelDisplayNameByModelId(allModels, usedModelId) ||
alibabaModelConfig?.displayName || alibabaModelConfig?.displayName ||
@ -11106,7 +11243,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(
@ -12135,8 +12272,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

@ -3,6 +3,7 @@ import {
CompleteMultipartUploadCommand, CompleteMultipartUploadCommand,
CreateMultipartUploadCommand, CreateMultipartUploadCommand,
HeadObjectCommand, HeadObjectCommand,
ListPartsCommand,
PutObjectCommand, PutObjectCommand,
UploadPartCommand, UploadPartCommand,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
@ -12,6 +13,10 @@ import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { upsertMediaAsset } from "@/lib/db/queries"; import { upsertMediaAsset } from "@/lib/db/queries";
import {
IMAGE_UPLOAD_MAX_BYTES,
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
} from "@/lib/image-upload-limits";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { import {
buildS3PublicUrl, buildS3PublicUrl,
@ -21,13 +26,22 @@ import {
maybeEnqueueStorageMigrationTask, maybeEnqueueStorageMigrationTask,
resolveS3UploadTarget, resolveS3UploadTarget,
} from "@/lib/storage/service"; } from "@/lib/storage/service";
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments"; import {
getOpenClawChatFileContentType,
isOpenClawChatFolderFileAllowed,
OPENCLAW_CHAT_FILE_MAX_BYTES,
OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE,
OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
OPENCLAW_CHAT_VIDEO_MAX_BYTES,
OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE,
} from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("direct-upload-api"); const logger = createLogger("direct-upload-api");
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024; const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
const DIRECT_UPLOAD_MEDIA_MAX_BYTES = 500 * 1024 * 1024;
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024; const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024; const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60; const PRESIGNED_URL_EXPIRES_SECONDS = 6 * 60 * 60;
const DIRECT_UPLOAD_PREFIX = "uploads/direct"; const DIRECT_UPLOAD_PREFIX = "uploads/direct";
function elapsedMs(startedAt: number) { function elapsedMs(startedAt: number) {
@ -76,6 +90,18 @@ const CompleteSchema = z.object({
clientUploadId: z.string().max(128).optional(), clientUploadId: z.string().max(128).optional(),
}); });
const ResumeSchema = z.object({
action: z.literal("resume"),
key: z.string().min(1).max(512),
uploadId: z.string().min(1).max(512),
filename: z.string().min(1).max(256),
contentType: z.string().min(1).max(128),
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
partSize: z.number().int().min(5 * 1024 * 1024).max(128 * 1024 * 1024),
purpose: z.string().max(64).optional(),
clientUploadId: z.string().max(128).optional(),
});
const AbortSchema = z.object({ const AbortSchema = z.object({
action: z.literal("abort"), action: z.literal("abort"),
key: z.string().min(1).max(512), key: z.string().min(1).max(512),
@ -85,6 +111,7 @@ const AbortSchema = z.object({
const RequestSchema = z.discriminatedUnion("action", [ const RequestSchema = z.discriminatedUnion("action", [
InitiateSchema, InitiateSchema,
ResumeSchema,
CompleteSchema, CompleteSchema,
AbortSchema, AbortSchema,
]); ]);
@ -112,23 +139,52 @@ function assertUserOwnedKey(key: string, userId: string) {
const normalized = String(key || "").replace(/^\/+/, ""); const normalized = String(key || "").replace(/^\/+/, "");
const prefix = `${userUploadPrefix(userId)}/`; const prefix = `${userUploadPrefix(userId)}/`;
if (!normalized.startsWith(prefix)) { if (!normalized.startsWith(prefix)) {
throw new Error("Invalid upload object key"); const error = new Error("Invalid upload object key");
error.name = "InvalidUploadObjectKeyError";
throw error;
} }
return normalized; return normalized;
} }
function isAllowedDirectUpload(contentType: string, purpose?: string) { function isAllowedDirectUpload(
contentType: string,
purpose: string | undefined,
filename: string
) {
return ( return (
allowedDirectUploadTypes.has(contentType) || allowedDirectUploadTypes.has(contentType) ||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE && (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
allowedOpenClawVideoUploadTypes.has(contentType)) allowedOpenClawVideoUploadTypes.has(contentType)) ||
(purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
Boolean(
getOpenClawChatFileContentType({ name: filename, type: contentType })
)) ||
(purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
isOpenClawChatFolderFileAllowed({ name: filename, type: contentType }))
); );
} }
function getPurposeMaxBytes(purpose?: string, contentType?: string) {
if (String(contentType || "").toLowerCase().startsWith("image/")) {
return IMAGE_UPLOAD_MAX_BYTES;
}
if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) {
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
}
if (
purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
) {
return DIRECT_UPLOAD_MAX_BYTES;
}
return DIRECT_UPLOAD_MEDIA_MAX_BYTES;
}
function computePartSize(size: number) { function computePartSize(size: number) {
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES; if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024; if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
return 16 * 1024 * 1024; if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
return 64 * 1024 * 1024;
} }
async function createPartUrls(params: { async function createPartUrls(params: {
@ -136,24 +192,24 @@ async function createPartUrls(params: {
bucket: string; bucket: string;
key: string; key: string;
uploadId: string; uploadId: string;
partCount: number; partNumbers: number[];
}) { }) {
const urls = []; return Promise.all(
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) { params.partNumbers.map(async (partNumber) => {
const command = new UploadPartCommand({ const command = new UploadPartCommand({
Bucket: params.bucket, Bucket: params.bucket,
Key: params.key, Key: params.key,
UploadId: params.uploadId, UploadId: params.uploadId,
PartNumber: partNumber, PartNumber: partNumber,
}); });
urls.push({ return {
partNumber, partNumber,
url: await getSignedUrl(params.client, command, { url: await getSignedUrl(params.client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
}), }),
}); };
} })
return urls; );
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@ -216,7 +272,7 @@ export async function POST(request: Request) {
payload.filename payload.filename
); );
if (!isAllowedDirectUpload(contentType, payload.purpose)) { if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
logger.warn("unsupported_direct_upload_type", { logger.warn("unsupported_direct_upload_type", {
contentType, contentType,
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null, filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
@ -227,11 +283,22 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ {
error: error:
"直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频", "直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
}, },
{ status: 400 } { status: 400 }
); );
} }
const maxBytes = getPurposeMaxBytes(payload.purpose, contentType);
if (payload.size > maxBytes) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: `文件不能超过 ${maxBytes / 1024 / 1024}MB`,
},
{ status: 413 }
);
}
const displayName = sanitizeDisplayName(payload.filename); const displayName = sanitizeDisplayName(payload.filename);
const key = buildStorageObjectKey( const key = buildStorageObjectKey(
@ -239,7 +306,13 @@ export async function POST(request: Request) {
userUploadPrefix(session.user.id) userUploadPrefix(session.user.id)
); );
if (payload.size < MULTIPART_THRESHOLD_BYTES) { const multipartThreshold =
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? DEFAULT_PART_SIZE_BYTES
: MULTIPART_THRESHOLD_BYTES;
if (payload.size < multipartThreshold) {
const presignStartedAt = Date.now(); const presignStartedAt = Date.now();
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: bucket, Bucket: bucket,
@ -299,7 +372,7 @@ export async function POST(request: Request) {
bucket, bucket,
key, key,
uploadId, uploadId,
partCount, partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
}); });
logger.info("direct_upload_multipart_prepared", { logger.info("direct_upload_multipart_prepared", {
elapsedMs: elapsedMs(multipartStartedAt), elapsedMs: elapsedMs(multipartStartedAt),
@ -323,6 +396,105 @@ export async function POST(request: Request) {
}); });
} }
if (payload.action === "resume") {
const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 });
}
if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: "文件超过允许的上传大小",
},
{ status: 413 }
);
}
const expectedPartSize = computePartSize(payload.size);
if (payload.partSize !== expectedPartSize) {
return NextResponse.json(
{ error: "续传分片参数已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
{ status: 409 }
);
}
try {
const listed = await client.send(
new ListPartsCommand({
Bucket: bucket,
Key: key,
UploadId: payload.uploadId,
MaxParts: 1000,
})
);
const uploadedParts = (listed.Parts || []).flatMap((part) => {
if (!part.ETag || typeof part.PartNumber !== "number") return [];
return [
{
ETag: part.ETag,
PartNumber: part.PartNumber,
size: Number(part.Size || 0),
},
];
});
const uploadedPartNumbers = new Set(
uploadedParts.map((part) => part.PartNumber)
);
const partCount = Math.ceil(payload.size / expectedPartSize);
const missingPartNumbers = Array.from(
{ length: partCount },
(_, index) => index + 1
).filter((partNumber) => !uploadedPartNumbers.has(partNumber));
const parts = await createPartUrls({
client,
bucket,
key,
uploadId: payload.uploadId,
partNumbers: missingPartNumbers,
});
logger.info("direct_upload_multipart_resumed", {
totalElapsedMs: elapsedMs(requestStartedAt),
size: payload.size,
uploadedPartCount: uploadedParts.length,
missingPartCount: missingPartNumbers.length,
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json({
mode: "multipart",
key,
uploadId: payload.uploadId,
partSize: expectedPartSize,
partCount,
parts,
uploadedParts,
resumed: true,
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
clientUploadId: payload.clientUploadId || null,
});
} catch (error) {
const head = await client
.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
.catch(() => null);
if (head?.ContentLength === payload.size) {
return NextResponse.json({
mode: "completed",
key,
clientUploadId: payload.clientUploadId || null,
});
}
logger.warn("direct_upload_resume_session_missing", error, {
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json(
{ error: "续传会话已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
{ status: 409 }
);
}
}
if (payload.action === "abort") { if (payload.action === "abort") {
const key = assertUserOwnedKey(payload.key, session.user.id); const key = assertUserOwnedKey(payload.key, session.user.id);
const abortStartedAt = Date.now(); const abortStartedAt = Date.now();
@ -343,15 +515,25 @@ export async function POST(request: Request) {
const key = assertUserOwnedKey(payload.key, session.user.id); const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename); const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType, payload.purpose)) { if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
return NextResponse.json( return NextResponse.json(
{ {
error: error:
"直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频", "直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
}, },
{ status: 400 } { status: 400 }
); );
} }
if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: "文件超过允许的上传大小",
},
{ status: 413 }
);
}
if (payload.uploadId) { if (payload.uploadId) {
const completeMultipartStartedAt = Date.now(); const completeMultipartStartedAt = Date.now();
@ -397,8 +579,10 @@ export async function POST(request: Request) {
const actualSize = const actualSize =
typeof head.ContentLength === "number" ? head.ContentLength : null; typeof head.ContentLength === "number" ? head.ContentLength : null;
if (actualSize !== null && actualSize <= 0) { if (actualSize !== null && actualSize !== payload.size) {
throw new Error("Uploaded object is empty"); throw new Error(
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
);
} }
const url = buildS3PublicUrl(target.config, key); const url = buildS3PublicUrl(target.config, key);
@ -407,21 +591,43 @@ export async function POST(request: Request) {
const assetStartedAt = Date.now(); const assetStartedAt = Date.now();
const asset = await upsertMediaAsset({ const asset = await upsertMediaAsset({
userId: session.user.id, userId: session.user.id,
mediaType: resolvedContentType.startsWith("video/") ? "video" : "image", mediaType:
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? "file"
: resolvedContentType.startsWith("video/")
? "video"
: "image",
sourceType: sourceType:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_upload" ? "openclaw_chat_video_upload"
: "upload", : payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
? "openclaw_chat_file_upload"
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? "openclaw_chat_folder_file_upload"
: "upload",
status: "ready", status: "ready",
originalUrl: url, originalUrl: url,
previewUrl: url, previewUrl:
thumbnailUrl: url, payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? null
: url,
thumbnailUrl:
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? null
: url,
mimeType: resolvedContentType, mimeType: resolvedContentType,
displayName: payload.filename, displayName: payload.filename,
sourceTable: sourceTable:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_upload" ? "openclaw_chat_video_upload"
: "direct_upload", : payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
? "openclaw_chat_file_upload"
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? "openclaw_chat_folder_file_upload"
: "direct_upload",
metadata: { metadata: {
pathname: key, pathname: key,
size: actualSize ?? payload.size, size: actualSize ?? payload.size,
@ -468,7 +674,13 @@ export async function POST(request: Request) {
error: "Direct upload failed", error: "Direct upload failed",
details: error instanceof Error ? error.message : String(error), details: error instanceof Error ? error.message : String(error),
}, },
{ status: 500 } {
status:
error instanceof Error &&
error.name === "InvalidUploadObjectKeyError"
? 403
: 500,
}
); );
} }
} }

View File

@ -1,5 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import {
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
isImageUploadTooLarge,
} from "@/lib/image-upload-limits";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images"; import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
@ -260,6 +264,12 @@ export async function POST(request: Request) {
const clientUploadId = const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null; String(formData.get("clientUploadId") || "").trim() || null;
const isSvgUpload = contentType === "image/svg+xml"; const isSvgUpload = contentType === "image/svg+xml";
if (contentType.startsWith("image/") && isImageUploadTooLarge(file.size)) {
return NextResponse.json(
{ error: IMAGE_UPLOAD_TOO_LARGE_MESSAGE },
{ status: 413 }
);
}
if ( if (
isSvgUpload && isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type)) (uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))

View File

@ -102,8 +102,8 @@ export async function GET(request: Request) {
}, },
{ {
id: "local-e2e-video-model", id: "local-e2e-video-model",
modelId: "kling-3-local", modelId: "veo-3.1-fast",
displayName: "可灵 3.0", displayName: "Veo 3.1 Fast(本地布局测试)",
avatarUrl: null, avatarUrl: null,
description: "本地视频画布测试视频模型", description: "本地视频画布测试视频模型",
supportsVision: true, supportsVision: true,
@ -111,18 +111,18 @@ export async function GET(request: Request) {
isMultimodal: true, isMultimodal: true,
isDefault: false, isDefault: false,
providerName: "Local E2E", providerName: "Local E2E",
providerType: "local", providerType: "vip98k",
isSubModel: false, isSubModel: false,
parentModelId: null, parentModelId: null,
capabilities: { video: true }, capabilities: { kind: "video", supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"], supportedRatios:["16:9"], supportedResolutions:["720p","1080p"], durationOptions:[8], maxReferenceImages:3 },
videoToolCapability: { videoToolCapability: {
supported: true, supported: true,
durations: [5, 10], durations: [8],
resolutions: ["720p", "1080p"], resolutions: ["720p", "1080p"],
}, },
imageGenConfig: { imageGenConfig: {
creditCostPerVideo: 20, creditCostPerVideo: 20,
videoDurationOptions: [5, 10], videoDurationOptions: [8],
}, },
sortOrder: 1, sortOrder: 1,
selectable: true, selectable: true,

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,5 +1,6 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { ChatShell } from "@/components/chat-shell"; import { ChatShell } from "@/components/chat-shell";
import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading";
import { DataStreamProvider } from "@/components/data-stream-provider"; import { DataStreamProvider } from "@/components/data-stream-provider";
import { auth } from "../(auth)/auth"; import { auth } from "../(auth)/auth";
import { import {
@ -8,10 +9,14 @@ import {
getTutorialVideos, getTutorialVideos,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
export const dynamic = "force-dynamic";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<DataStreamProvider> <DataStreamProvider>
<Suspense fallback={<div className="flex h-dvh" />}> <Suspense
fallback={<CanvasLogoLoadingView label="正在加载页面..." />}
>
<SidebarWrapper>{children}</SidebarWrapper> <SidebarWrapper>{children}</SidebarWrapper>
</Suspense> </Suspense>
</DataStreamProvider> </DataStreamProvider>

View File

@ -1,79 +1,75 @@
import { randomUUID } from "node:crypto";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { VideoCanvasHome, type VideoCanvasHomeProject } from "@/components/canvas/VideoCanvasHome";
import { db } from "@/lib/db";
import { getSiteSettings } from "@/lib/db/queries";
import { canvasProject } from "@/lib/db/schema";
type VideoCanvasRedirectPageProps = { type Props = { searchParams?: Promise<Record<string, string | string[] | undefined>> };
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
function getSearchParam( function first(value: string | string[] | undefined) {
params: Record<string, string | string[] | undefined>,
key: string
) {
const value = params[key];
return Array.isArray(value) ? value[0] : value; return Array.isArray(value) ? value[0] : value;
} }
function buildVideoCanvasUrl( function canvasHref(id: string, incoming: Record<string, string | string[] | undefined>) {
canvasId: string, const params = new URLSearchParams({ mode: "video", workspaceType: "video" });
params: Record<string, string | string[] | undefined> for (const [key, value] of Object.entries(incoming)) {
) { if (["new", "mode", "workspaceType"].includes(key) || value === undefined) continue;
const query = new URLSearchParams(); for (const item of Array.isArray(value) ? value : [value]) params.append(key, item);
query.set("mode", "video");
query.set("workspaceType", "video");
for (const [key, value] of Object.entries(params)) {
if (
key === "new" ||
key === "mode" ||
key === "workspaceType" ||
value === undefined
) {
continue;
}
if (Array.isArray(value)) {
value.forEach((item) => query.append(key, item));
continue;
}
query.set(key, value);
} }
return `/canvas/${id}?${params.toString()}`;
return `/canvas/${canvasId}?${query.toString()}`;
} }
async function getLatestVideoCanvasId(userId: string) { function projectPreview(thumbnail: string | null, canvasData: unknown) {
const [latestProject] = await db const nodes = canvasData && typeof canvasData === "object" && "nodes" in canvasData
.select({ id: canvasProject.id }) ? (canvasData as { nodes?: unknown }).nodes : null;
.from(canvasProject) const candidates = [thumbnail];
.where( if (Array.isArray(nodes)) for (const node of [...nodes].reverse()) {
and( if (!node || typeof node !== "object") continue;
eq(canvasProject.userId, userId), const item = node as { inputType?: string; draftValues?: Record<string, unknown> };
eq(canvasProject.workspaceType, "video") const draft = item.draftValues || {};
) candidates.push(
) String(draft.poster || draft.previewImageUrl || draft.thumbnailUrl || draft.resultImage || (item.inputType === "image" ? draft.mediaUrl || (Array.isArray(draft.url) ? draft.url[0] : "") : "") || ""),
.orderBy(desc(canvasProject.updatedAt)) );
.limit(1); }
return candidates.find(value => typeof value === "string" && (value.startsWith("https://") || value.startsWith("/"))) || null;
return latestProject?.id || null;
} }
export default async function VideoCanvasRedirectPage({ export default async function VideoCanvasHomePage({ searchParams }: Props) {
searchParams,
}: VideoCanvasRedirectPageProps) {
const params = (await searchParams) || {};
const shouldCreateNew = getSearchParam(params, "new") === "1";
if (shouldCreateNew) {
redirect(buildVideoCanvasUrl(randomUUID(), params));
}
const session = await auth(); const session = await auth();
const latestCanvasId = session?.user?.id if (!session?.user?.id) redirect("/login?callbackUrl=%2Fvideo-canvas");
? await getLatestVideoCanvasId(session.user.id).catch(() => null)
: null;
redirect(buildVideoCanvasUrl(latestCanvasId || randomUUID(), params)); const params = (await searchParams) || {};
if (first(params.new) === "1") {
const [created] = await db.insert(canvasProject).values({
userId: session.user.id,
title: "未命名视频画布",
workspaceType: "video",
canvasData: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
}).returning({ id: canvasProject.id });
redirect(canvasHref(created.id, params));
}
const [rows, siteSettings] = await Promise.all([db.select({
id: canvasProject.id,
title: canvasProject.title,
thumbnail: canvasProject.thumbnail,
canvasData: canvasProject.canvasData,
createdAt: canvasProject.createdAt,
updatedAt: canvasProject.updatedAt,
}).from(canvasProject).where(and(
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "video"),
)).orderBy(desc(canvasProject.updatedAt)).limit(25), getSiteSettings()]);
const projects: VideoCanvasHomeProject[] = rows.slice(0, 24).map(row => ({
id: row.id,
title: row.title,
thumbnail: projectPreview(row.thumbnail, row.canvasData),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}));
return <VideoCanvasHome initialHasMore={rows.length > 24} initialProjects={projects} siteLogo={siteSettings.siteLogo} siteLogoDark={siteSettings.siteLogoDark} siteName={siteSettings.siteName} userName={session.user.name || ""} />;
} }

View File

@ -94,6 +94,8 @@ export async function POST(request: Request) {
providerId, providerId,
modelId: preset.modelId, modelId: preset.modelId,
...values, ...values,
isEnabled: true,
isHidden: false,
isDefault: false, isDefault: false,
isSubModel: false, isSubModel: false,
parentModelId: null, parentModelId: null,

View File

@ -53,6 +53,10 @@ import {
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY, CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
normalizeCanvasImageTaskQueueConfig, normalizeCanvasImageTaskQueueConfig,
} from "@/lib/canvas/image-task-config"; } from "@/lib/canvas/image-task-config";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import { import {
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
normalizeDingTalkRobotNotificationConfig, normalizeDingTalkRobotNotificationConfig,
@ -126,6 +130,12 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value }, setting: setting ? { ...setting, value } : { key, value },
}); });
} }
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
const value = normalizeCanvasLocalEditConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
const value = normalizeDingTalkRobotNotificationConfig(setting?.value); const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
return Response.json({ return Response.json({
@ -369,6 +379,10 @@ export async function POST(request: Request) {
nextValue = normalizeCanvasImageTaskQueueConfig(value); nextValue = normalizeCanvasImageTaskQueueConfig(value);
} }
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
nextValue = normalizeCanvasLocalEditConfig(value);
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
nextValue = normalizeDingTalkRobotNotificationConfig(value); nextValue = normalizeDingTalkRobotNotificationConfig(value);
} }

View File

@ -76,8 +76,6 @@ export async function POST(request: Request) {
description: preset.description, description: preset.description,
supportsVision: preset.supportsVision, supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen, supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder, sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null, capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null, defaultParams: preset.defaultParams || null,

View File

@ -0,0 +1,295 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
import {
createCanvasImageTask,
enqueueCanvasImageTask,
ensureCanvasImageTaskActive,
getCanvasImageTaskForUser,
} from "@/lib/canvas/image-task-queue";
import {
checkUserImageQuota,
getChatById,
getEnabledAiModelsWithProvider,
getPointsSystemConfig,
getUserById,
saveChat,
} from "@/lib/db/queries";
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
import { resolveDirectReplacementModelRef } from "@/app/(chat)/api/chat/_lib/detail-replacement";
import {
buildAgentSlotPrompt,
normalizeAgentSupplementPrompt,
} from "@/app/(chat)/api/chat/_lib/agent-scene-workflow";
import {
detailReplacementSubmissionKey,
isDetailReplacementBatchRequest,
isDetailReplacementUuid,
type DetailReplacementBatchReference,
type DetailReplacementBatchRequest,
type DetailReplacementBatchResult,
} from "@/lib/agents/detail-replacement-batch";
export const maxDuration = 1200;
function scopedChatId(projectId: string) {
const normalized = projectId.toLowerCase().replace(/-/g, "");
const scoped = `${normalized.slice(0, 20)}a93e17a60001`;
return [scoped.slice(0, 8), scoped.slice(8, 12), scoped.slice(12, 16), scoped.slice(16, 20), scoped.slice(20, 32)].join("-");
}
function taskResponse(task: Awaited<ReturnType<typeof getCanvasImageTaskForUser>>) {
const result = task?.result as DetailReplacementBatchResult | null;
const request = task?.request as DetailReplacementBatchRequest | null;
return {
id: task?.id,
status: task?.status,
attempts: task?.attempts,
error: task?.error || null,
cards: Array.isArray(result?.cards) ? result.cards : [],
currentCardId: result?.currentCardId || null,
requestedCardIds: request?.references?.map((item) => item.id) || [],
createdAt: task?.createdAt || null,
queueStatus: task?.queueStatus || null,
};
}
async function findActiveBatchId(params: {
userId: string;
projectId: string;
submissionKey: string;
}) {
const [row] = await db.select({ id: canvasImageTask.id })
.from(canvasImageTask)
.where(and(
eq(canvasImageTask.userId, params.userId),
inArray(canvasImageTask.status, ["pending", "running"]),
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
sql`${canvasImageTask.request}->>'projectId' = ${params.projectId}`,
sql`${canvasImageTask.request}->>'submissionKey' = ${params.submissionKey}`,
))
.orderBy(desc(canvasImageTask.createdAt)).limit(1);
return row?.id || null;
}
function isUniqueViolation(error: unknown) {
for (let cursor: unknown = error; cursor && typeof cursor === "object"; ) {
if ((cursor as { code?: unknown }).code === "23505") return true;
cursor = (cursor as { cause?: unknown }).cause;
}
return false;
}
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const params = new URL(request.url).searchParams;
const projectId = String(params.get("projectId") || "").trim();
const taskId = String(params.get("taskId") || "").trim();
if (!isDetailReplacementUuid(projectId)) {
return NextResponse.json({ error: "项目编号无效" }, { status: 400 });
}
const [project] = await db.select({ id: canvasProject.id }).from(canvasProject).where(and(
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
)).limit(1);
if (!project) return NextResponse.json({ error: "项目不存在" }, { status: 404 });
const [candidate] = await db.select({ id: canvasImageTask.id, request: canvasImageTask.request })
.from(canvasImageTask)
.where(and(
eq(canvasImageTask.userId, session.user.id),
...(isDetailReplacementUuid(taskId) ? [eq(canvasImageTask.id, taskId)] : []),
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
sql`${canvasImageTask.request}->>'projectId' = ${projectId}`,
))
// A batch that is still working matters more than a newer finished one, so the
// workspace keeps showing live progress after a rerun of a single card.
.orderBy(
sql`case when ${canvasImageTask.status} in ('pending', 'running') then 0 else 1 end`,
desc(canvasImageTask.createdAt),
).limit(1);
if (!candidate || !isDetailReplacementBatchRequest(candidate.request)) {
return NextResponse.json({ task: null });
}
let task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
if (task && (task.status === "failed" ||
(task.status === "running" && Date.now() - task.updatedAt.getTime() > 90_000))) {
await ensureCanvasImageTaskActive({ taskId: task.id, userId: session.user.id });
task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
}
return NextResponse.json({ task: taskResponse(task) });
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const body = await request.json();
const projectId = String(body.projectId || "").trim();
const taskId = String(body.requestId || "").trim();
if (!isDetailReplacementUuid(projectId) || !isDetailReplacementUuid(taskId)) {
return NextResponse.json({ error: "任务编号或项目编号无效" }, { status: 400 });
}
const [project] = await db.select().from(canvasProject).where(and(
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
)).limit(1);
if (!project || !isAgentProjectRow(project)) {
return NextResponse.json({ error: "风格化项目不存在" }, { status: 404 });
}
const state = extractAgentProjectStateFromCanvasData(project.canvasData);
const knownReferences = new Map(
(state.workflowState.agentModeState?.detailReplacement?.references || [])
.map((item) => [String(item.id), String(item.imageUrl)]),
);
const requested = Array.isArray(body.references) ? body.references : [];
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
return {
id: String(item.id || "").slice(0, 128),
label: String(item.label || "详情参考图").slice(0, 255),
imageUrl: String(item.imageUrl || "").trim(),
candidateId: String(item.candidateId || "").slice(0, 128) || null,
passthrough: item.passthrough === true,
rerunReferenceImages: Array.isArray(item.rerunReferenceImages)
? item.rerunReferenceImages.slice(0, 6).map((entry: { url?: string }) => ({ url: String(entry?.url || "") }))
: [],
};
});
if (
references.length === 0 || references.length > 40 ||
new Set(references.map((item) => item.id)).size !== references.length ||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
) {
return NextResponse.json({ error: "详情参考图尚未保存或已发生变化,请刷新后重试" }, { status: 400 });
}
const productImageUrls = Array.isArray(body.productImageUrls)
? Array.from(new Set(body.productImageUrls.map((url: unknown) => String(url || "").trim())
.filter((url: string) => /^https?:\/\//i.test(url)))).slice(0, 16) as string[]
: [];
if (productImageUrls.length === 0 && !references.every((item) => item.passthrough)) {
return NextResponse.json({ error: "请先在输入图区选择至少一张产品图" }, { status: 400 });
}
const strategy = body.replacementStrategy === "product_only" ? "product_only" : "scene_replace";
const prompt = String(body.prompt || "").trim().slice(0, 24_000);
if (strategy !== "product_only" && !prompt) {
return NextResponse.json({ error: "请先配置风格化提示词" }, { status: 400 });
}
const models = await getEnabledAiModelsWithProvider();
const model = await resolveDirectReplacementModelRef({
selectedChatModel: String(body.model || "auto"),
finalModelId: String(body.model || "auto"),
models,
});
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
}
const quota = await checkUserImageQuota(session.user.id);
if (!quota.allowed) {
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });
}
const requestedImageCount = references.filter((item) => !item.passthrough).length;
if (quota.limit != null && quota.used + requestedImageCount > quota.limit) {
return NextResponse.json({ error: `本月剩余生图配额不足 ${requestedImageCount} 张` }, { status: 429 });
}
const pointsConfig = await getPointsSystemConfig();
if (pointsConfig.enabled && pointsConfig.blockOnInsufficient !== false) {
const selectedModel = models.find((entry) => entry.model.id === model)?.model;
const unitCost = getModelGenerationCreditCost(
selectedModel as Parameters<typeof getModelGenerationCreditCost>[0], "image", {
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
});
const user = await getUserById({ id: session.user.id });
const expectedCost = unitCost * requestedImageCount;
if (!user?.isHighVip && expectedCost > Number(user?.pointsBalance || 0)) {
return NextResponse.json({ error: `积分不足,本批次预计消耗 ${expectedCost} 积分` }, { status: 402 });
}
}
const chatId = scopedChatId(projectId);
const existingChat = await getChatById({ id: chatId });
if (existingChat && existingChat.userId !== session.user.id) {
return NextResponse.json({ error: "无权访问项目对话" }, { status: 403 });
}
if (!existingChat) await saveChat({
id: chatId, userId: session.user.id, title: project.title || "详情替换", visibility: "private",
});
const taskRequest: DetailReplacementBatchRequest = {
taskKind: "agent-detail-replacement-batch",
projectId,
chatId,
model,
prompt: strategy === "product_only"
? "仅替换参考详情图中的产品主体"
: buildAgentSlotPrompt({
workflowPrompt: prompt,
slotPrompt: "",
userSupplementPrompt: normalizeAgentSupplementPrompt(
String(body.userPrompt || ""), "详情参考图替换",
),
includeSlotTitle: false,
modeType: "detail_replacement",
}),
userPrompt: String(body.userPrompt || "").slice(0, 12_000),
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
replacementStrategy: strategy,
productImageUrls,
modelImageUrl: /^https?:\/\//i.test(String(body.modelImageUrl || ""))
? String(body.modelImageUrl) : null,
references,
};
const submissionKey = detailReplacementSubmissionKey(taskRequest);
taskRequest.submissionKey = submissionKey;
const duplicateId = await findActiveBatchId({
userId: session.user.id, projectId, submissionKey,
});
if (duplicateId) {
await ensureCanvasImageTaskActive({ taskId: duplicateId, userId: session.user.id });
const duplicate = await getCanvasImageTaskForUser({ taskId: duplicateId, userId: session.user.id });
return NextResponse.json({ task: taskResponse(duplicate) });
}
let task: Awaited<ReturnType<typeof createCanvasImageTask>>;
try {
task = await createCanvasImageTask({
taskId, userId: session.user.id, request: taskRequest as unknown as Record<string, unknown>,
});
} catch (error) {
if (!isUniqueViolation(error)) throw error;
// Two identical submissions raced past the lookup above; the database
// unique index kept one of them, so answer with that batch.
const racedId = await findActiveBatchId({
userId: session.user.id, projectId, submissionKey,
});
if (!racedId) throw error;
const raced = await getCanvasImageTaskForUser({ taskId: racedId, userId: session.user.id });
return NextResponse.json({ task: taskResponse(raced) });
}
const existingTask = await getCanvasImageTaskForUser({ taskId: task.id, userId: session.user.id });
const storedRequest = existingTask?.request as DetailReplacementBatchRequest | null;
if (
!isDetailReplacementBatchRequest(storedRequest) ||
storedRequest.projectId !== projectId ||
(storedRequest.submissionKey && storedRequest.submissionKey !== submissionKey)
) {
return NextResponse.json({ error: "任务编号已被其他请求使用" }, { status: 409 });
}
await db.insert(message).values({
id: taskId,
chatId,
role: "user",
parts: [{ type: "text", text: taskRequest.userPrompt || `批量替换 ${references.length} 张详情图` }],
attachments: [],
createdAt: new Date(),
}).onConflictDoNothing({ target: message.id });
await enqueueCanvasImageTask(task.id);
const current = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
return NextResponse.json({ task: taskResponse(current) });
} catch (error) {
console.error("[Detail Replacement Batch] Failed to start", error);
return NextResponse.json({ error: error instanceof Error ? error.message : "提交详情替换任务失败" }, { status: 500 });
}
}

View File

@ -13,6 +13,7 @@ import {
findStyleProImageModelByRef, findStyleProImageModelByRef,
getStyleProImageModelEntries, getStyleProImageModelEntries,
} from "@/lib/style-pro/model-options"; } from "@/lib/style-pro/model-options";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
const requestSchema = z.object({ const requestSchema = z.object({
prompt: z.string().min(1).max(5000), prompt: z.string().min(1).max(5000),
@ -52,6 +53,16 @@ export async function POST(request: Request) {
{ status: 400 } { status: 400 }
); );
} }
const availability = getAiModelFrontendAvailability(selectedModel.model);
if (availability.selectable === false) {
return NextResponse.json(
{
success: false,
error: availability.disabledReason || "所选 AI洗图 生图模型当前不可用",
},
{ status: 400 }
);
}
const imageSize = body.imageSize?.trim() || "2K"; const imageSize = body.imageSize?.trim() || "2K";
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", { const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {

View File

@ -1,385 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
getPointsSystemConfig,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service"; import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit";
import { db } from "@/lib/db"; export const runtime = "nodejs";
import { generatedImage } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import { getImageToolCreditCost } from "@/lib/points/config";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
const logger = createLogger("angle-edit-api");
// 角度编辑API配置类型
type AngleEditConfig = {
enabled?: boolean;
falApiKey?: string;
loraScale?: number;
inferenceSteps?: number;
guidanceScale?: number;
maxImageSize?: number;
timeout?: number;
};
// 设置API路由最大执行时间为3分钟(180秒)
export const maxDuration = 180; export const maxDuration = 180;
export async function POST(request: Request) {
/** const session = await auth();
* 压缩图片生成缩略图 if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
*/ return executeAngleEditPOST(request, session.user.id);
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
try {
const sharp = await import("sharp");
const thumbnailBuffer = await sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 80 })
.toBuffer();
logger.debug("thumbnail_created", {
originalBytes: buffer.length,
thumbnailBytes: thumbnailBuffer.length,
});
return thumbnailBuffer;
} catch (error) {
logger.warn("thumbnail_creation_failed", error);
return buffer;
}
}
/**
* 将 base64 图片上传到 S3
*/
async function uploadBase64ToS3(base64Data: string, prefix: string = "angle-edit"): Promise<{ thumbnail: string; original: string }> {
try {
const buffer = Buffer.from(base64Data, "base64");
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `${prefix}-thumb-${timestamp}-${randomStr}.png`;
const originalFilename = `${prefix}-orig-${timestamp}-${randomStr}.png`;
logger.debug("source_image_prepared", { originalBytes: buffer.length });
const thumbnailBuffer = await createThumbnail(buffer);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
logger.debug("storage_upload_started", {
hasOriginal: originalArrayBuffer.byteLength > 0,
hasThumbnail: thumbnailArrayBuffer.byteLength > 0,
prefix,
});
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
logger.info("storage_upload_completed", {
originalUrl: originalResult.url,
thumbnailUrl: thumbnailResult.url,
});
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
logger.error("storage_upload_failed", error);
throw new Error(`图片上传失败: ${error instanceof Error ? error.message : "未知错误"}`);
}
}
export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
// 1. 验证用户登录
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
logger.info("request_received", { authenticated: true });
// 2. 从数据库获取配置
const angleEditConfig = await getSystemSetting({ key: "angle_edit_config" });
const config = (angleEditConfig?.value as AngleEditConfig) || {};
// 验证功能是否启用
if (!config.enabled) {
return NextResponse.json(
{ error: "角度编辑功能未启用,请联系管理员在系统设置中开启" },
{ status: 400 }
);
}
// 验证 API Key
if (!config.falApiKey) {
return NextResponse.json(
{ error: "fal.ai API Key 未配置,请联系管理员配置" },
{ status: 500 }
);
}
logger.debug("config_loaded", {
enabled: Boolean(config.enabled),
hasApiKey: Boolean(config.falApiKey),
loraScale: config.loraScale || 1,
});
// 3. 解析请求
const body = await request.json();
const {
image_url,
image_base64,
// 数值参数 (官方 API 格式)
horizontal_angle, // 0-360 度
vertical_angle, // -30 到 90 度
zoom, // 0-10
lora_scale,
} = body;
if (!image_url && !image_base64) {
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 });
}
// 使用请求中的参数或配置中的默认值
const finalLoraScale = lora_scale ?? config.loraScale ?? 1;
const finalHorizontalAngle = horizontal_angle ?? 0;
const finalVerticalAngle = vertical_angle ?? 0;
const finalZoom = zoom ?? 5;
logger.info("request_parameters_normalized", {
hasBase64Input: Boolean(image_base64),
hasUrlInput: Boolean(image_url),
horizontalAngle: finalHorizontalAngle,
loraScale: finalLoraScale,
verticalAngle: finalVerticalAngle,
zoom: finalZoom,
});
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "angle-edit"),
reason: "image_angle_edit",
modelId: "qwen-image-edit-2511-multiple-angles",
note: "多角度编辑消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
// 4. 准备图片输入
let imageInput: string;
if (image_url) {
imageInput = image_url;
} else {
const mimeType = image_base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
imageInput = `data:${mimeType};base64,${image_base64}`;
}
// 5. 动态导入 fal.ai SDK 并调用官方多角度 API
logger.info("provider_call_started", {
modelId: "fal-ai/qwen-image-edit-2511-multiple-angles",
});
const fal = await import("@fal-ai/serverless-client");
fal.config({
credentials: config.falApiKey,
});
// 使用官方多角度 API 端点
// 文档: https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles
const result = await fal.subscribe("fal-ai/qwen-image-edit-2511-multiple-angles", {
input: {
image_urls: [imageInput], // 官方使用 image_urls (数组)
horizontal_angle: finalHorizontalAngle, // 水平旋转角度 0-360
vertical_angle: finalVerticalAngle, // 垂直角度 -30 到 90
zoom: finalZoom, // 缩放 0-10
lora_scale: finalLoraScale, // LoRA 强度
num_inference_steps: config.inferenceSteps || 28,
guidance_scale: config.guidanceScale || 4.5, // 官方默认 4.5
enable_safety_checker: true,
output_format: "png",
},
logs: true,
onQueueUpdate: (update) => {
if (update.status === "IN_PROGRESS") {
logger.debug("provider_progress", { status: update.status });
}
},
});
// 6. 处理结果
const apiResult = result as { images?: Array<{ url?: string }>; prompt?: string };
const outputImage = apiResult.images?.[0];
if (!outputImage?.url) {
throw new Error("API 未返回有效图片");
}
const outputImageUrl = outputImage.url;
logger.info("provider_call_completed", {
hasGeneratedPrompt: Boolean(apiResult.prompt),
imageCount: apiResult.images?.length ?? 0,
outputImageUrl,
});
// 7. 下载图片并上传到我们的 S3
const imageResponse = await fetch(outputImageUrl);
if (!imageResponse.ok) {
throw new Error(`下载输出图片失败: ${imageResponse.status}`);
}
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = imageBuffer.toString("base64");
const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit");
logger.info("result_image_persisted", {
originalUrl: s3Result.original,
thumbnailUrl: s3Result.thumbnail,
});
// 8. 保存到数据库
const processingTime = (Date.now() - startTime) / 1000;
const [savedImage] = await db
.insert(generatedImage)
.values({
userId: session.user.id,
thumbnailUrl: s3Result.thumbnail,
originalUrl: s3Result.original,
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
modelId: "qwen-image-edit-2511-multiple-angles",
createdAt: new Date(),
})
.returning();
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `多角度编辑消耗积分(${savedImage.id})`,
});
} catch (pointsError) {
logger.error("points_consume_failed", pointsError, {
imageId: savedImage.id,
userId: session.user.id,
});
}
}
try {
const sourceAssets =
typeof image_url === "string" && image_url.trim()
? await getMediaAssetsByUserAndUrls({
userId: session.user.id,
urls: [image_url],
})
: [];
const media = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: s3Result.original,
previewUrl: s3Result.original,
thumbnailUrl: s3Result.thumbnail,
displayName: `角度编辑 ${finalHorizontalAngle}/${finalVerticalAngle}`.slice(0, 80),
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
modelId: "qwen-image-edit-2511-multiple-angles",
sourceTable: "GeneratedImage",
sourceRowId: savedImage.id,
primaryParentAssetId: sourceAssets[0]?.id ?? null,
metadata: {
horizontalAngle: finalHorizontalAngle,
verticalAngle: finalVerticalAngle,
zoom: finalZoom,
},
});
if (media) {
for (const sourceAsset of sourceAssets) {
await createMediaAssetRelation({
userId: session.user.id,
fromAssetId: sourceAsset.id,
toAssetId: media.id,
relationType: "edited_from",
metadata: {
horizontalAngle: finalHorizontalAngle,
verticalAngle: finalVerticalAngle,
zoom: finalZoom,
},
});
}
}
} catch (assetError) {
logger.warn("media_asset_registration_failed", assetError);
}
logger.info("request_completed", {
processingSeconds: Number(processingTime.toFixed(2)),
});
// 9. 返回结果
return NextResponse.json({
success: true,
image_url: s3Result.original,
thumbnail_url: s3Result.thumbnail,
image_base64: imageBase64Result,
processing_time: processingTime,
angles: {
horizontal_angle: finalHorizontalAngle,
vertical_angle: finalVerticalAngle,
zoom: finalZoom,
},
prompt: apiResult.prompt,
});
} catch (error) {
logger.error("request_failed", error);
// 输出详细的错误信息
if (error && typeof error === "object" && "status" in error) {
const apiError = error as { status?: number; body?: unknown; message?: string };
logger.warn("provider_error_summary", {
bodyType: typeof apiError.body,
hasBody: apiError.body != null,
message: apiError.message,
status: apiError.status,
});
if (apiError.status === 403) {
return NextResponse.json(
{
error: "API 访问被拒绝 (403 Forbidden)。请检查 API Key 是否正确,以及是否有权限访问该模型。",
details: apiError.body,
},
{ status: 403 }
);
}
}
return NextResponse.json(
{
error: error instanceof Error ? error.message : "角度编辑处理失败",
},
{ status: 500 }
);
}
} }

View File

@ -14,6 +14,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
"worldlabs.ai", "worldlabs.ai",
"s3.1080p.hk", "s3.1080p.hk",
"img.yydd.ai", "img.yydd.ai",
"scqny.jiansun.vip",
"blob.vercel-storage.com", "blob.vercel-storage.com",
"aliyuncs.com", "aliyuncs.com",
"amazonaws.com", "amazonaws.com",

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,
@ -102,7 +103,7 @@ export async function GET(
// 更新画布(支持 upsert:如果不存在则创建) // 更新画布(支持 upsert:如果不存在则创建)
export async function PUT( export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const { id } = await params; const { id } = await params;
@ -116,29 +117,34 @@ export async function PUT(
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 },
);
} }
const { title, description, thumbnail, canvasData } = body; const { title, description, canvasData } = body;
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision; const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
const baseCanvasRevision =
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType = const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType") body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
? resolveWorkspaceType(body.workspaceType) ? resolveWorkspaceType(body.workspaceType)
: undefined; : undefined;
// 先检查画布是否存在 return await db.transaction(async (tx) => {
const [existingProject] = await db const [existingProject] = await tx
.select() .select()
.from(canvasProject) .from(canvasProject)
.where( .where(
and( and(
eq(canvasProject.id, id), eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id) eq(canvasProject.userId, session.user.id),
),
) )
); .for("update");
if (existingProject) {
if ( if (
existingProject &&
canvasData !== undefined && canvasData !== undefined &&
baseCanvasRevision && baseCanvasRevision &&
isStaleCanvasSaveBaseRevision({ isStaleCanvasSaveBaseRevision({
@ -149,101 +155,66 @@ export async function PUT(
) { ) {
return NextResponse.json( return NextResponse.json(
buildStaleCanvasSaveConflictPayload(existingProject.updatedAt), buildStaleCanvasSaveConflictPayload(existingProject.updatedAt),
{ status: 409 } { status: 409 },
); );
} }
if (!existingProject) {
// The parent must exist before node/asset foreign keys are synchronized.
const [created] = await tx
.insert(canvasProject)
.values({
id,
userId: session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
workspaceType: workspaceType || "canvas",
canvasData: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
.onConflictDoNothing({ target: canvasProject.id })
.returning();
if (!created)
return NextResponse.json(
{ success: false, error: "画布已被创建,请重新加载后保存" },
{ status: 409 },
);
}
const syncedCanvasData = const syncedCanvasData =
canvasData !== undefined canvasData !== undefined
? await syncCanvasNodesForProject({ ? await syncCanvasNodesForProject(
projectId: id, { projectId: id, userId: session.user.id, canvasData },
userId: session.user.id, tx,
canvasData, )
})
: undefined; : undefined;
const updateData: any = { updatedAt: new Date(Math.max(Date.now(), (existingProject?.updatedAt.getTime() || 0) + 1)) };
// 更新现有画布
const updateData: any = { updatedAt: new Date() };
if (title !== undefined) updateData.title = title; if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description; if (description !== undefined) updateData.description = description;
if (thumbnail !== undefined) updateData.thumbnail = thumbnail; if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
if (workspaceType !== undefined) updateData.workspaceType = workspaceType; if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData; if (syncedCanvasData !== undefined)
updateData.canvasData = syncedCanvasData;
const [updatedProject] = await db const [saved] = await tx
.update(canvasProject) .update(canvasProject)
.set(updateData) .set(updateData)
.where( .where(
and( and(
eq(canvasProject.id, id), eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id) eq(canvasProject.userId, session.user.id),
) ),
) )
.returning(); .returning();
return NextResponse.json({ success: true, data: saved });
return NextResponse.json({ success: true, data: updatedProject }); });
} else {
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
const nextWorkspaceType = workspaceType || "canvas";
const nextCanvasData = syncedCanvasData || {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
};
const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
const insertData: any = {
id, // 使用指定的 ID
userId: session.user.id,
title: nextTitle,
workspaceType: nextWorkspaceType,
canvasData: nextCanvasData,
};
const conflictUpdateData: any = {
title: nextTitle,
workspaceType: nextWorkspaceType,
canvasData: nextCanvasData,
updatedAt: new Date(),
};
if (description !== undefined) {
insertData.description = description;
conflictUpdateData.description = description;
}
if (thumbnail !== undefined) {
insertData.thumbnail = thumbnail;
conflictUpdateData.thumbnail = thumbnail;
}
// 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
const [newProject] = await db
.insert(canvasProject)
.values(insertData)
.onConflictDoUpdate({
target: canvasProject.id,
set: conflictUpdateData,
where: eq(canvasProject.userId, session.user.id),
})
.returning();
if (!newProject) {
return NextResponse.json(
{ success: false, error: '画布ID已被占用' },
{ status: 409 }
);
}
return NextResponse.json({ success: true, data: newProject });
}
} catch (error) { } catch (error) {
console.error('更新/创建画布失败:', error); console.error("更新/创建画布失败:", error);
return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 }); return NextResponse.json(
{ success: false, error: "更新画布失败" },
{ status: 500 },
);
} }
} }

View File

@ -0,0 +1,57 @@
import { and, desc, eq, lte } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { tvstarSession } from "@/lib/db/schema";
import { ownsTaskProject } from "@/lib/video-canvas/task-store";
import { tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session";
export const dynamic = "force-dynamic";
async function authorize(projectId: string) {
const session = await auth();
if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) };
if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) {
return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) };
}
return { userId: session.user.id };
}
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const access = await authorize(id);
if (access.error) return access.error;
const rows = await db.select({ snapshot: tvstarSession.snapshot, shareToken: tvstarSession.shareToken })
.from(tvstarSession)
.where(and(eq(tvstarSession.userId, access.userId!), eq(tvstarSession.projectId, id)))
.orderBy(desc(tvstarSession.clientUpdatedAt)).limit(20);
return NextResponse.json({ sessions: rows.map(({ snapshot, shareToken }) => ({
...(snapshot as Record<string, unknown>), shareToken: shareToken || undefined,
})) }, { headers: { "Cache-Control": "no-store" } });
}
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const access = await authorize(id);
if (access.error) return access.error;
const raw = await request.text();
if (raw.length > 900_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 });
let body: unknown;
try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); }
const parsed = z.object({ sessions: z.array(tvstarSessionSchema).max(20) }).safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 });
for (const session of parsed.data.sessions) {
const clientUpdatedAt = new Date(Math.min(session.updatedAt, Date.now() + 60_000));
await db.insert(tvstarSession).values({
id: session.id, projectId: id, userId: access.userId!, title: session.title,
snapshot: session, clientUpdatedAt,
}).onConflictDoNothing({ target: tvstarSession.id });
await db.update(tvstarSession).set({ title: session.title, snapshot: session, clientUpdatedAt, updatedAt: new Date() })
.where(and(
eq(tvstarSession.id, session.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!),
lte(tvstarSession.clientUpdatedAt, clientUpdatedAt),
));
}
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,58 @@
import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { tvstarSession } from "@/lib/db/schema";
import { ownsTaskProject } from "@/lib/video-canvas/task-store";
import { publicTVstarSnapshot, tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session";
export const dynamic = "force-dynamic";
async function owner(projectId: string) {
const session = await auth();
if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) };
if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) {
return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) };
}
return { userId: session.user.id };
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const access = await owner(id);
if (access.error) return access.error;
const raw = await request.text();
if (raw.length > 300_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 });
let body: unknown;
try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); }
const parsed = z.object({ session: tvstarSessionSchema }).safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 });
const current = parsed.data.session;
const condition = and(eq(tvstarSession.id, current.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!));
await db.insert(tvstarSession).values({
id: current.id, projectId: id, userId: access.userId!, title: current.title,
snapshot: current, clientUpdatedAt: new Date(Math.min(current.updatedAt, Date.now() + 60_000)),
}).onConflictDoNothing({ target: tvstarSession.id });
const existing = (await db.select({ shareToken: tvstarSession.shareToken }).from(tvstarSession).where(condition).limit(1))[0];
if (!existing) return NextResponse.json({ error: "对话不存在" }, { status: 404 });
const token = existing.shareToken || randomBytes(24).toString("base64url");
if (!existing.shareToken) {
await db.update(tvstarSession).set({
shareToken: token, sharedSnapshot: publicTVstarSnapshot(current), sharedAt: new Date(), updatedAt: new Date(),
}).where(condition);
}
return NextResponse.json({ url: `/tvstar/share/${token}`, token });
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const access = await owner(id);
if (access.error) return access.error;
const sessionId = new URL(request.url).searchParams.get("sessionId") || "";
if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "对话 ID 无效" }, { status: 400 });
await db.update(tvstarSession).set({ shareToken: null, sharedSnapshot: null, sharedAt: null, updatedAt: new Date() })
.where(and(eq(tvstarSession.id, sessionId), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!)));
return NextResponse.json({ ok: true });
}

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

@ -0,0 +1,112 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { ffmpeg, mediaSafety, probeMedia } from "@/lib/video-canvas/smart-edit/local";
import {
remoteAudio,
remoteAudioConfig,
} from "@/lib/video-canvas/smart-edit/remote-audio";
export const runtime = "nodejs";
export const maxDuration = 600;
const running = new Set<string>();
const allowedStems = ["vocals", "background"] as const;
export async function POST(request: Request) {
const session = await auth();
const userId = session?.user?.id;
if (!userId)
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
const body = (await request.json().catch(() => null)) as {
url?: unknown;
stem?: unknown;
} | null;
const url = typeof body?.url === "string" ? body.url.trim() : "";
const stem = typeof body?.stem === "string" ? body.stem : "";
if (!/^https?:\/\//i.test(url) || !allowedStems.includes(stem as (typeof allowedStems)[number]))
return NextResponse.json({ success: false, error: "参数不完整" }, { status: 400 });
if (!remoteAudioConfig())
return NextResponse.json(
{ success: false, error: "音频分离服务未配置,请联系管理员" },
{ status: 503 }
);
const lockKey = `${userId}:${url}:${stem}`;
if (running.has(lockKey) || running.size >= 3)
return NextResponse.json(
{ success: false, error: "已有分离任务在进行,请稍候" },
{ status: 429 }
);
running.add(lockKey);
let dir = "";
try {
dir = await mkdtemp(path.join(tmpdir(), "canvas-separate-"));
const input = path.join(dir, "input");
const response = await fetch(url, {
signal: AbortSignal.timeout(180000),
cache: "no-store",
});
if (!response.ok) throw new Error("素材下载失败,请确认素材仍可访问");
const bytes = Buffer.from(await response.arrayBuffer());
if (!bytes.length || bytes.length > 150 * 1024 * 1024)
throw new Error("素材必须非空且不超过 125 MiB");
await writeFile(input, bytes);
const info = await probeMedia(input, 600);
if (!info.hasAudio) throw new Error("当前视频没有音轨");
const inputAudio = path.join(dir, "separation-input.wav");
const durationSeconds = Math.round(info.duration / 1e6);
await ffmpeg(
[
...mediaSafety,
"-i",
input,
"-vn",
"-t",
String(durationSeconds),
"-ac",
"2",
"-ar",
"44100",
"-c:a",
"pcm_f32le",
inputAudio,
],
undefined,
240000
);
const requestKey = createHash("sha256")
.update(`${url}:${stem}:${(await stat(inputAudio)).size}`)
.digest("hex");
await remoteAudio("separate", inputAudio, userId, requestKey, dir);
const stemFile = path.join(dir, `${stem}.wav`);
const wav = await readFile(stemFile);
if (wav.length < 1024) throw new Error("分离结果异常,请稍后重试");
const uploaded = await uploadFile(
`canvas-separate-${randomUUID()}.wav`,
await new Blob([wav]).arrayBuffer()
);
return NextResponse.json({
success: true,
data: {
url: uploaded.url,
duration: Math.round(info.duration / 1e6),
},
});
} catch (error) {
return NextResponse.json(
{
success: false,
error:
error instanceof Error ? error.message : "人声分离失败,原音轨已保留",
},
{ status: 500 }
);
} finally {
running.delete(lockKey);
if (dir) await rm(dir, { recursive: true, force: true });
}
}

View File

@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { listCanvasAudioModels } from "@/lib/video-canvas/audio-generation";
import { audioContract } from "@/lib/video-canvas/audio-contract";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const models = await listCanvasAudioModels();
return NextResponse.json(
{
models: models.map(({ model }) => ({
id: model.id,
modelId: model.modelId,
name: model.displayName,
...audioContract(model.modelId),
})),
},
{ headers: { "Cache-Control": "no-store" } },
);
} catch {
return NextResponse.json({ error: "音频模型读取失败,请稍后重试" }, { status: 503 });
}
}

View File

@ -0,0 +1,266 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById, getMessageById, getSystemSetting } from "@/lib/db/queries";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 120;
const querySchema = z.object({
chatId: z.string().uuid(),
messageId: z.string().uuid(),
index: z.coerce.number().int().min(0).max(127),
download: z.enum(["0", "1"]).optional().default("0"),
});
const MAX_REDIRECTS = 4;
const RESPONSE_HEADERS = [
"accept-ranges",
"content-length",
"content-range",
"etag",
"last-modified",
] as const;
function isPrivateIpv4(address: string) {
const octets = address.split(".").map(Number);
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value))) {
return true;
}
const [a, b] = octets;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
a >= 224
);
}
function isPrivateIpAddress(address: string) {
if (isIP(address) === 4) return isPrivateIpv4(address);
if (isIP(address) !== 6) return true;
const normalized = address.toLowerCase();
if (normalized.startsWith("::ffff:")) {
return isPrivateIpv4(normalized.slice("::ffff:".length));
}
return (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe8") ||
normalized.startsWith("fe9") ||
normalized.startsWith("fea") ||
normalized.startsWith("feb") ||
normalized.startsWith("ff")
);
}
async function validateTargetUrl(target: URL, trustedOrigin: string) {
if (target.username || target.password) return false;
if (target.origin === trustedOrigin) {
return target.protocol === "http:" || target.protocol === "https:";
}
if (target.protocol !== "https:") return false;
const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local")
) {
return false;
}
if (isIP(hostname)) return !isPrivateIpAddress(hostname);
const addresses = await lookup(hostname, { all: true, verbatim: true }).catch(
() => []
);
return (
addresses.length > 0 &&
addresses.every((entry) => !isPrivateIpAddress(entry.address))
);
}
function normalizeRangeHeader(value: string | null) {
const range = String(value || "").trim();
if (!range) return null;
const match = range.match(/^bytes=(\d*)-(\d*)$/);
if (!match || (!match[1] && !match[2])) return "";
if (match[1] && match[2] && Number(match[1]) > Number(match[2])) return "";
return range;
}
function inferContentType(filename: string, declaredType: string) {
if (declaredType) return declaredType;
const lower = filename.toLowerCase();
if (lower.endsWith(".glb")) return "model/gltf-binary";
if (lower.endsWith(".gltf")) return "model/gltf+json";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
return "application/octet-stream";
}
function safeDownloadName(value: string) {
const normalized = value.replace(/[\r\n"\\/]/g, "_").trim() || "artifact";
return normalized.slice(0, 180);
}
async function fetchArtifact(params: {
request: Request;
initialUrl: URL;
method: "GET" | "HEAD";
range: string | null;
token: string;
trustedOrigin: string;
}) {
let target = params.initialUrl;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
if (!(await validateTargetUrl(target, params.trustedOrigin))) {
throw new Error("不允许访问该文件地址");
}
const headers = new Headers({ Accept: "*/*" });
if (params.range) headers.set("Range", params.range);
if (target.origin === params.trustedOrigin && params.token) {
headers.set("Authorization", `Bearer ${params.token}`);
}
const response = await fetch(target, {
method: params.method,
headers,
cache: "no-store",
redirect: "manual",
signal: params.request.signal,
});
if (![301, 302, 303, 307, 308].includes(response.status)) {
return { response, target };
}
if (redirects === MAX_REDIRECTS) throw new Error("文件重定向次数过多");
const location = response.headers.get("location");
if (!location) throw new Error("文件重定向地址无效");
target = new URL(location, target);
}
throw new Error("文件地址无效");
}
async function handleRequest(request: Request, method: "GET" | "HEAD") {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const url = new URL(request.url);
const parsed = querySchema.safeParse({
chatId: url.searchParams.get("chatId"),
messageId: url.searchParams.get("messageId"),
index: url.searchParams.get("index"),
download: url.searchParams.get("download") || undefined,
});
if (!parsed.success) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
const chat = await getChatById({ id: parsed.data.chatId }).catch(() => null);
if (!chat || chat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const rows = await getMessageById({ id: parsed.data.messageId }).catch(
() => []
);
const message = rows.find((row) => row.chatId === parsed.data.chatId);
const metadata = normalizeClawdTaskMetadata(message?.videoMetadata);
const artifact = metadata?.artifacts?.[parsed.data.index];
if (!message || !artifact) {
return NextResponse.json({ error: "文件不存在" }, { status: 404 });
}
const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
() => null
);
const config = normalizeOpenClawChatConfig(setting?.value);
let target: URL;
let trustedOrigin: string;
try {
target = new URL(artifact.downloadUrl);
trustedOrigin = new URL(config.baseUrl).origin;
} catch {
return NextResponse.json({ error: "文件地址无效" }, { status: 400 });
}
const range = normalizeRangeHeader(request.headers.get("range"));
if (range === "") {
return NextResponse.json({ error: "Range 请求格式无效" }, { status: 400 });
}
try {
const { response, target: finalTarget } = await fetchArtifact({
request,
initialUrl: target,
method,
range,
token: config.token,
trustedOrigin,
});
if (!response.ok && response.status !== 206) {
return NextResponse.json(
{ error: `文件读取失败(HTTP ${response.status})` },
{ status: response.status >= 400 && response.status < 600 ? response.status : 502 }
);
}
const headers = new Headers();
for (const name of RESPONSE_HEADERS) {
const value = response.headers.get(name);
if (value) headers.set(name, value);
}
headers.set(
"content-type",
response.headers.get("content-type") ||
inferContentType(artifact.filename || finalTarget.pathname, artifact.mimeType)
);
headers.set("cache-control", "private, max-age=3600, must-revalidate");
headers.set("x-content-type-options", "nosniff");
if (parsed.data.download === "1") {
const filename = safeDownloadName(artifact.filename || artifact.displayName);
headers.set(
"content-disposition",
`attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
);
}
return new Response(method === "HEAD" ? null : response.body, {
status: response.status,
headers,
});
} catch (error) {
console.warn("[Clawd Artifact] Proxy failed:", {
chatId: parsed.data.chatId,
messageId: parsed.data.messageId,
artifactIndex: parsed.data.index,
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: error instanceof Error ? error.message : "文件读取失败" },
{ status: 502 }
);
}
}
export async function GET(request: Request) {
return handleRequest(request, "GET");
}
export async function HEAD(request: Request) {
return handleRequest(request, "HEAD");
}

View File

@ -9,7 +9,6 @@ import {
} from "@/lib/openclaw-chat/folder-archive"; } from "@/lib/openclaw-chat/folder-archive";
import { import {
OPENCLAW_CHAT_FOLDER_MAX_FILES, OPENCLAW_CHAT_FOLDER_MAX_FILES,
OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES,
} from "@/lib/openclaw-chat/media-attachments"; } from "@/lib/openclaw-chat/media-attachments";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { import {
@ -21,6 +20,7 @@ export const runtime = "nodejs";
export const maxDuration = 600; export const maxDuration = 600;
const logger = createLogger("openclaw-folder-upload"); const logger = createLogger("openclaw-folder-upload");
const LEGACY_FOLDER_UPLOAD_MAX_BYTES = 300 * 1024 * 1024;
function isUploadedFile(value: FormDataEntryValue): value is File { function isUploadedFile(value: FormDataEntryValue): value is File {
return ( return (
@ -68,9 +68,9 @@ export async function POST(request: Request) {
} }
const originalSize = files.reduce((sum, file) => sum + file.size, 0); const originalSize = files.reduce((sum, file) => sum + file.size, 0);
if (originalSize > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) { if (originalSize > LEGACY_FOLDER_UPLOAD_MAX_BYTES) {
return NextResponse.json( return NextResponse.json(
{ error: "文件夹总大小不能超过 300MB" }, { error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" },
{ status: 413 } { status: 413 }
); );
} }

View File

@ -25,7 +25,7 @@ export async function GET(request: Request) {
const url = new URL(request.url); const url = new URL(request.url);
const limit = Math.max( const limit = Math.max(
1, 1,
Math.min(100, Math.round(Number(url.searchParams.get("limit") || 50))) Math.min(500, Math.round(Number(url.searchParams.get("limit") || 100)))
); );
const chats = await getClawdChatsByUserId({ const chats = await getClawdChatsByUserId({

View File

@ -29,6 +29,7 @@ import {
OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS, OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
type OpenClawChatMediaAttachment, type OpenClawChatMediaAttachment,
} from "@/lib/openclaw-chat/media-attachments"; } from "@/lib/openclaw-chat/media-attachments";
import { extractOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts";
import { import {
buildOpenClawDisplayMessage, buildOpenClawDisplayMessage,
buildOpenClawMessageWithSpreadsheetMarkdown, buildOpenClawMessageWithSpreadsheetMarkdown,
@ -298,7 +299,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
params.spreadsheetMarkdownTruncated || undefined, params.spreadsheetMarkdownTruncated || undefined,
...params.metadata, ...params.metadata,
}, },
stream_progress: false, stream_progress: !params.sensitiveInput,
sensitive_input: params.sensitiveInput, sensitive_input: params.sensitiveInput,
}; };
@ -337,6 +338,9 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
assistantMessageId: params.assistantMessageId, assistantMessageId: params.assistantMessageId,
answer, answer,
requestId: getRequestId(payload), requestId: getRequestId(payload),
artifacts: extractOpenClawArtifacts(payload, {
baseUrl: params.config.baseUrl,
}),
}); });
clearClawdRequestCancellation(params.cancelKey); clearClawdRequestCancellation(params.cancelKey);
return; return;
@ -370,6 +374,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
...target, ...target,
answer: outcome.answer, answer: outcome.answer,
requestId: outcome.requestId, requestId: outcome.requestId,
artifacts: outcome.artifacts,
}); });
} else if (outcome.status === "canceled") { } else if (outcome.status === "canceled") {
await markClawdTaskCanceled({ await markClawdTaskCanceled({
@ -564,7 +569,7 @@ export async function POST(request: Request) {
(attachment.type === "video" (attachment.type === "video"
? "视频附件" ? "视频附件"
: attachment.type === "file" : attachment.type === "file"
? "文件夹压缩包.zip" ? "文件附件"
: "图片附件"), : "图片附件"),
url: attachment.url, url: attachment.url,
contentType: contentType:
@ -572,11 +577,11 @@ export async function POST(request: Request) {
(attachment.type === "video" (attachment.type === "video"
? "video/mp4" ? "video/mp4"
: attachment.type === "file" : attachment.type === "file"
? "application/zip" ? "application/octet-stream"
: "image/jpeg"), : "image/jpeg"),
})); }));
await persistPendingClawdTask({ const persistedTask = await persistPendingClawdTask({
chatId: parsed.id, chatId: parsed.id,
userMessageId, userMessageId,
assistantMessageId, assistantMessageId,
@ -587,6 +592,22 @@ export async function POST(request: Request) {
skillTagName, skillTagName,
processingHint, processingHint,
}); });
if (
persistedTask &&
persistedTask.assistantMessageId !== assistantMessageId
) {
return NextResponse.json(
buildTaskResponse({
chatId: parsed.id,
userMessageId,
assistantMessageId: persistedTask.assistantMessageId,
status: persistedTask.status,
processingHint:
persistedTask.processingHint || DEFAULT_PROCESSING_HINT,
}),
{ status: 202, headers: { "Retry-After": "2" } }
);
}
const userProfile = await getUserById({ id: session.user.id }).catch( const userProfile = await getUserById({ id: session.user.id }).catch(
() => null () => null

View File

@ -75,6 +75,7 @@ async function reconcileDetachedOpenClawTask(params: {
...target, ...target,
answer: outcome.answer, answer: outcome.answer,
requestId: outcome.requestId, requestId: outcome.requestId,
artifacts: outcome.artifacts,
}); });
} else if (outcome.status === "failed") { } else if (outcome.status === "failed") {
await markClawdTaskFailed({ ...target, error: outcome.error }); await markClawdTaskFailed({ ...target, error: outcome.error });

View File

@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import {
DIRECTOR_MAX_GLB_BYTES,
validateDirectorGlb,
} from "@/lib/video-canvas/director-glb";
export const runtime = "nodejs";
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const form = await request.formData();
const file = form.get("file");
if (
!(file instanceof File) ||
!/\.glb$/i.test(file.name) ||
file.size > DIRECTOR_MAX_GLB_BYTES
)
return NextResponse.json(
{ error: "请上传 25 MiB 以内的 GLB 模型" },
{ status: 400 },
);
const buffer = await file.arrayBuffer();
try {
validateDirectorGlb(buffer);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "无效 GLB" },
{ status: 400 },
);
}
const result = await uploadFile(
`director-${crypto.randomUUID()}.glb`,
buffer,
);
return NextResponse.json({ success: true, data: result });
} catch {
return NextResponse.json(
{ error: "模型上传失败,请重试" },
{ status: 500 },
);
}
}

View File

@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
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,
@ -9,6 +13,31 @@ import {
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 120; export const maxDuration = 120;
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;
}))];
for (let attempt = 0; attempt < 3; attempt++) {
signal.throwIfAborted();
for (const url of sources) {
signal.throwIfAborted();
const result = await fetchCanvasDownloadAsset({
file: { url, name: "original" }, index: 0,
requestOrigin: input.requestOrigin, cookie: input.cookie,
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
});
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
}
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
// No AI/model request is involved and a completed source fetch is shared.
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
}
throw new Error("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();
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim(); const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
@ -55,12 +84,41 @@ export async function POST(request: NextRequest) {
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 urls = resolveDownloadUrls(body, requestOrigin);
const requestedUrls = [...urls];
if (urls.length === 0) { if (urls.length === 0) {
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 }); return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
} }
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image"); const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
if (body.preferDirect === true) {
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
if (task?.targetUrl) {
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
after(() => prepareImageArchiveDownload(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 NextResponse.json({ delivery: "preparing", retryAfter: 3,
message: "正在准备原图,完成后将自动下载" }, {
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
});
}
if (result.status === "failed") {
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
}
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[] = [];

View File

@ -1,54 +1,22 @@
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";
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 = 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const globalTaskStore = globalThis as typeof globalThis & { const active = running.__canvasZipRunning ??= new Set<string>();
__canvasDownloadZipTasks?: Map<string, ZipTask>;
};
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 +24,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 +78,161 @@ 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; task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
const results: Array< await saveZipTask(task);
| Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> // Stage each image on disk, not all PNG buffers in the web process heap.
| null // A restart resumes from these files using the current owner's session.
> = new Array(task.files.length).fill(null);
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 failures: Array<{ index: number; url: string; name?: string; error: string }> = [];
for (const [index, item] of results.entries()) {
if (!item || !item.ok) {
failures.push({
index: index + 1,
url: item?.ok === false ? item.url : task.files[index]?.url || "",
name: item?.ok === false ? item.name : task.files[index]?.name,
error: item?.ok === false ? item.error : "未知错误",
});
continue;
} }
folder.file(item.filename, item.bytes); }));
} const rejected = results.find(result => result.status === "rejected");
if (rejected?.status === "rejected") throw rejected.reason;
if (failures.length > 0) { if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
folder.file( const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
"_download-failures.txt", for (const file of task.files) if (file.prepared && file.filename)
[ folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
"以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。", .once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
"", if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
...failures.map((item) => { .map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`; task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
return `${label}\nURL: ${item.url}\n原因: ${item.error}`; task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
}),
].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 = "正在准备下载链接"; await saveZipTask(task);
task.stage = "打包完成"; try {
task.compressPercent = 100; const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
task.updatedAt = Date.now(); if (cdn) {
task.expiresAt = Date.now() + TASK_TTL_MS; task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
} catch (error) { if (cdn.url) task.cdnUrl = cdn.url;
console.error("[CanvasDownloadZipTask] Failed:", error);
task.status = "failed";
task.stage = "打包失败";
task.error = error instanceof Error ? error.message : "打包下载失败";
task.updatedAt = Date.now();
task.expiresAt = Date.now() + TASK_TTL_MS;
} finally {
store.set(task.id, task);
}
}
export async function POST(request: NextRequest) {
try {
cleanupExpiredTasks();
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request);
const files = toDownloadItems(body.files, requestOrigin);
const folderName = sanitizeFolderName(String(body.folderName || ""));
if (files.length === 0) {
return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 });
}
const task: ZipTask = {
id: randomUUID(),
userId: session.user.id,
folderName,
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),
});
} catch (error) {
console.error("[CanvasDownloadZipTask] Create failed:", error);
return NextResponse.json({ success: false, error: "创建打包任务失败" }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
try {
cleanupExpiredTasks();
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const taskId = String(request.nextUrl.searchParams.get("taskId") || "").trim();
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 (task.status !== "completed" || !task.zipBuffer) {
return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 });
} }
return new NextResponse(task.zipBuffer, { } catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
status: 200, task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
headers: { task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
"Content-Type": "application/zip", await saveZipTask(task);
"Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName), // The complete ZIP is enough for retries; release staged duplicate bytes.
"Content-Length": String(task.zipBuffer.length), await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
"Cache-Control": "no-store", console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
"X-Download-Success-Count": String(task.successCount), bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
"X-Download-Failed-Count": String(task.failedCount),
},
});
}
return NextResponse.json({
success: true,
task: serializeTask(task),
});
} catch (error) { } catch (error) {
console.error("[CanvasDownloadZipTask] Query failed:", error); if (task) {
return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 }); task.status = "failed"; task.stage = "打包未完成";
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
}
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 = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "");
after(run);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = await request.json();
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
let task = await loadZipTask(id, session.user.id);
if (!task) {
await assertZipTaskCapacity(session.user.id);
if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID();
const files = toDownloadItems(body.files, getRequestOrigin(request));
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.
await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => {
if (error.code !== "ENOENT") throw error;
await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 });
await mkdir(zipTaskPath(id), { mode: 0o700 });
});
const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS };
await saveZipTask(task);
} else if (task.status === "failed" && body.retry === true) {
task.status = "pending"; task.error = undefined;
await assertZipTaskCapacity(session.user.id);
for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
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) {
console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message });
return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 });
}
}
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const id = request.nextUrl.searchParams.get("taskId") || "";
const task = await loadZipTask(id, session.user.id);
if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 });
if (request.nextUrl.searchParams.get("download") === "1") {
if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ 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 });
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
} catch {
return NextResponse.json({ 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

@ -1,62 +1,10 @@
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 { import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
createCanvasImageTask,
enqueueCanvasImageTask,
getCanvasImageTaskForUser,
} from "@/lib/canvas/image-task-queue";
import {
generateCanvasImage,
type CanvasGenerateImageRequest,
} from "@/lib/canvas/generate-image";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import {
getImageToolCreditCost,
type ImageToolCreditFeature,
} from "@/lib/points/config";
export const maxDuration = 1200; export const maxDuration = 1200;
interface GenerateImageRequest extends CanvasGenerateImageRequest {
async?: boolean;
}
function normalizeBillingFeature(
value: unknown
): Extract<ImageToolCreditFeature, "multi-view" | "repaint"> | null {
if (value === "multi-view" || value === "repaint") return value;
return null;
}
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
return {
prompt: String(body.prompt || "").trim(),
negativePrompt:
typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
model: typeof body.model === "string" ? body.model : undefined,
aspectRatio:
typeof body.aspectRatio === "string" && body.aspectRatio.trim()
? body.aspectRatio.trim()
: "auto",
imageSize:
typeof body.imageSize === "string" && body.imageSize.trim()
? body.imageSize.trim()
: undefined,
imageCount:
typeof body.imageCount === "number" && Number.isFinite(body.imageCount)
? body.imageCount
: 1,
promptExtend:
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
referenceImage:
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
referenceImages: Array.isArray(body.referenceImages)
? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean)
: [],
};
}
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
const session = await auth(); const session = await auth();
@ -67,10 +15,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const taskId = String(searchParams.get("taskId") || "").trim(); const taskId = String(searchParams.get("taskId") || "").trim();
if (!taskId) { if (!taskId) {
return NextResponse.json( return NextResponse.json({ success: false, error: "缺少 taskId 参数" }, { status: 400 });
{ success: false, error: "缺少 taskId 参数" },
{ status: 400 }
);
} }
const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id }); const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
@ -99,105 +44,15 @@ export async function GET(request: Request) {
success: false, success: false,
error: error instanceof Error ? error.message : "查询任务失败", error: error instanceof Error ? error.message : "查询任务失败",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
export async function POST(request: Request) { export async function POST(request: Request) {
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( const body = await request.json().catch(() => ({}));
{ success: false, error: "未登录" }, return executeCanvasImageResponse(body, session.user.id);
{ status: 401 }
);
}
const body: GenerateImageRequest = await request.json();
const payload: CanvasGenerateImageRequest = {
...toGenerateImagePayload(body),
ownerKey: session.user.id,
};
if (!payload.prompt) {
return NextResponse.json(
{ success: false, error: "请输入提示词" },
{ status: 400 }
);
}
const billingFeature = normalizeBillingFeature(body.billingFeature);
const pointsCharge = billingFeature
? await (async () => {
const pointsConfig = await getPointsSystemConfig();
return prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, billingFeature),
reason:
billingFeature === "multi-view"
? "image_multi_view"
: "image_repaint",
modelId: "canvas-generate-image",
note:
billingFeature === "multi-view"
? "图片多视角生成消耗积分"
: "图片重绘消耗积分",
pointsConfig,
});
})()
: null;
if (pointsCharge && !pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const asyncMode = body.async === true;
if (asyncMode) {
const task = await createCanvasImageTask({
userId: session.user.id,
request: payload as unknown as Record<string, unknown>,
});
await enqueueCanvasImageTask(task.id);
const currentTask = await getCanvasImageTaskForUser({
taskId: task.id,
userId: session.user.id,
});
if (pointsCharge?.chargeable) {
try {
await pointsCharge.consume({
note:
billingFeature === "multi-view"
? `图片多视角生成消耗积分(${task.id})`
: `图片重绘消耗积分(${task.id})`,
});
} catch (pointsError) {
console.error("[Points] Failed to consume canvas image task points:", pointsError);
}
}
return NextResponse.json({
success: true,
taskId: task.id,
status: currentTask?.status || task.status,
queueStatus: currentTask?.queueStatus,
});
}
const result = await generateCanvasImage(payload);
if (pointsCharge?.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume canvas image points:", pointsError);
}
}
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
console.error("[Canvas Generate Image] Error:", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "生成失败" },
{ status: 500 }
);
}
} }

View File

@ -1,7 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video"; import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
import { filterReachableRemoteUrls } from "@/lib/net/url-access"; import { probeRemoteUrl } from "@/lib/net/url-access";
import { validateVideoReferences } from "@/lib/video-canvas/reference-input";
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai"; import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
export const maxDuration = 300; export const maxDuration = 300;
@ -10,6 +11,8 @@ interface GenerateVideoRequest {
prompt?: string; prompt?: string;
mode?: VideoGenerationMode; mode?: VideoGenerationMode;
images?: string[]; images?: string[];
audioUrls?: string[];
generateAudio?: boolean;
resolution?: VideoResolution; resolution?: VideoResolution;
ratio?: VideoRatio; ratio?: VideoRatio;
duration?: number; duration?: number;
@ -46,23 +49,18 @@ export async function POST(request: Request) {
); );
} }
const inputImages = Array.isArray(body.images) const referenceCheck = await validateVideoReferences(body.images, url => probeRemoteUrl(url, { timeoutMs: 8000 }));
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4) if (referenceCheck.error) {
: []; return NextResponse.json({ success: false, error: referenceCheck.error }, { status: referenceCheck.status });
const { kept: reachableImages, dropped: unreachableImages } =
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
if (inputImages.length > 0 && reachableImages.length === 0) {
return NextResponse.json(
{ success: false, error: "参考图片链接不可访问,请重新上传后再试" },
{ status: 400 }
);
} }
const reachableImages = referenceCheck.images;
const mode = body.mode || inferMode(reachableImages); const mode = body.mode || inferMode(reachableImages);
const result = await executeDirectVideoGeneration({ const result = await executeDirectVideoGeneration({
prompt, prompt,
mode, mode,
audioUrls: Array.isArray(body.audioUrls) ? body.audioUrls.filter(url => typeof url === "string" && /^https?:\/\//i.test(url)).slice(0, 3) : undefined,
generateAudio: body.generateAudio,
images: reachableImages, images: reachableImages,
resolution: body.resolution, resolution: body.resolution,
ratio: body.ratio, ratio: body.ratio,
@ -90,13 +88,13 @@ export async function POST(request: Request) {
success: true, success: true,
data: { data: {
url: result.videoUrl, url: result.videoUrl,
metadata: result.metadata || null, metadata: { ...result.metadata, temporaryVideoUrl: result.videoUrl, storageStatus: result.metadata?.permanentVideoUrl === result.videoUrl ? "archived" : "temporary" },
modelDisplayName: result.modelDisplayName, modelDisplayName: result.modelDisplayName,
modelId: result.modelId, modelId: result.modelId,
modelRef: result.modelRef, modelRef: result.modelRef,
mode, mode,
images: reachableImages, images: reachableImages,
droppedImages: unreachableImages, droppedImages: [],
}, },
}); });
} catch (error) { } catch (error) {

View File

@ -0,0 +1,130 @@
import { videoUpscaleSchema } from "@/lib/video-canvas/video-upscale-contract";
import { smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract";
import { audioRequestSchema } from "@/lib/video-canvas/audio-contract";
import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery";
import { imageOperationSchema } from "@/lib/video-canvas/image-operation";
import { after, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { z } from "zod";
import { canvasTaskRequestSchema } from "@/lib/video-canvas/task-contract";
import {
cancelCanvasGenerationTask,
createCanvasGenerationTask,
listCanvasGenerationTasks,
ownsTaskProject,
} from "@/lib/video-canvas/task-store";
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
export const runtime = "nodejs";
export const maxDuration = 900;
async function owner(projectId: unknown) {
const session = await auth();
if (!session?.user?.id) throw Object.assign(Error("未登录"), { status: 401 });
if (!z.string().uuid().safeParse(projectId).success)
throw Object.assign(Error("画布 ID 无效"), { status: 400 });
if (!(await ownsTaskProject(session.user.id, projectId as string)))
throw Object.assign(Error("画布不存在"), { status: 404 });
return session.user.id;
}
const fail = (error: unknown) =>
NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "任务操作失败", code: (error as any)?.code },
{ status: Number((error as any)?.status) || (error instanceof SyntaxError ? 400 : 500) },
);
const kick = (userId: string, projectId: string) =>
after(() =>
runCanvasGenerationQueue(userId, projectId).catch((error) =>
console.error("[Canvas tasks]", error),
),
);
export async function POST(request: Request) {
try {
const text = await request.text();
if (new TextEncoder().encode(text).byteLength > 262144)
throw Object.assign(Error("任务数据过大,请使用已上传的素材链接"), { status: 413 });
const parsed = canvasTaskRequestSchema.safeParse(JSON.parse(text));
if (!parsed.success) throw Object.assign(Error("任务参数不合法"), { status: 400 });
if(parsed.data.workflow)throw Object.assign(Error("编组任务必须使用整组提交接口"),{status:400});
if (
parsed.data.kind === "image-operation" &&
!imageOperationSchema.safeParse(parsed.data.params).success
)
throw Object.assign(Error("图片处理参数不合法"), { status: 400 });
if (
parsed.data.attachment &&
((parsed.data.attachment.type === "story-asset" && parsed.data.kind !== "image") ||
(parsed.data.attachment.type === "story-prompts" && parsed.data.kind !== "analysis"))
)
throw Object.assign(Error("脚本结果类型不匹配"), { status: 400 });
if (parsed.data.kind === "audio" && !audioRequestSchema.safeParse(parsed.data.params).success)
throw Object.assign(Error("音频参数不合法"), { status: 400 });
if (
parsed.data.kind === "smart-edit" &&
!smartEditRequestSchema.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 task = await createCanvasGenerationTask(userId, parsed.data,
() => preflightCanvasProductionRequests([parsed.data]));
kick(userId, parsed.data.projectId);
return NextResponse.json({ success: true, task }, { status: 202 });
} catch (error) {
return fail(error);
}
}
export async function GET(request: Request) {
try {
const projectId = new URL(request.url).searchParams.get("projectId") || "";
const userId = await owner(projectId);
const tasks = await listCanvasGenerationTasks(userId, projectId);
if (tasks.some((task) => task.status === "pending" || canResumeCanvasTask(task)))
kick(userId, projectId);
return NextResponse.json(
{ success: true, tasks },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
return fail(error);
}
}
export async function DELETE(request: Request) {
try {
const { projectId, taskId } = await request.json();
const userId = await owner(projectId);
if (!z.string().uuid().safeParse(taskId).success)
throw Object.assign(Error("任务 ID 无效"), { status: 400 });
const result = await cancelCanvasGenerationTask(userId, projectId, taskId);
if (!result.task)
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
if (!result.ok)
return NextResponse.json(
{
success: false,
error:
result.task.status === "running"
? "任务已提交供应商,当前线路不支持中止。结果会继续保存,请勿重复生成。"
: "任务已结束或状态待核实,请先刷新任务状态。",
task: result.task,
},
{ status: 409 },
);
return NextResponse.json({ success: true, task: result.task });
} catch (error) {
return fail(error);
}
}
export async function PATCH(request: Request) {
try {
const { projectId, taskId, nodeId } = await request.json();
const userId = await owner(projectId);
if (!z.string().uuid().safeParse(taskId).success || typeof nodeId !== "string")
return NextResponse.json({ success: false }, { status: 400 });
const { hideCanvasGenerationNode } = await import("@/lib/video-canvas/task-store");
const success = await hideCanvasGenerationNode(userId, projectId, taskId, nodeId);
return NextResponse.json({ success }, { status: success ? 200 : 404 });
} catch (error) {
return fail(error);
}
}

View File

@ -0,0 +1,41 @@
import { auth } from "@/app/(auth)/auth";
import { after, NextResponse } from "next/server";
import { canvasTaskRequestSchema } from "@/lib/video-canvas/task-contract";
import {
createCanvasGroupTasks,
ownsTaskProject,
} from "@/lib/video-canvas/task-store";
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
export const runtime = "nodejs",
maxDuration = 900;
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
const text = await request.text();
if (Buffer.byteLength(text) > 1048576)
return NextResponse.json({ error: "编组数据过大" }, { status: 413 });
const parsed = canvasTaskRequestSchema
.array()
.min(1)
.max(40)
.safeParse(JSON.parse(text));
if (!parsed.success)
return NextResponse.json({ error: "编组参数无效" }, { status: 400 });
const projectId = parsed.data[0].projectId,
userId = session.user.id;
if (!(await ownsTaskProject(userId, projectId)))
return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const tasks = await createCanvasGroupTasks(userId, parsed.data,
() => preflightCanvasProductionRequests(parsed.data));
after(() => runCanvasGenerationQueue(userId, projectId));
return NextResponse.json({ success: true, tasks }, { status: 202 });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "编组执行失败", code: (e as any)?.code },
{ status: Number((e as any)?.status) || 400 }
);
}
}

View File

@ -0,0 +1,40 @@
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { eq, and, sql, desc } from "drizzle-orm";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
const rows = await db
.select({
projectId: canvasProject.id,
title: canvasProject.title,
templates: sql<any[]>`${canvasProject.canvasData}->'groupTemplates'`,
})
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "video"),
sql`${canvasProject.canvasData} ? 'groupTemplates'`
)
)
.orderBy(desc(canvasProject.updatedAt))
.limit(100);
return NextResponse.json(
{
templates: rows.flatMap((row) =>
Array.isArray(row.templates)
? row.templates.map((template) => ({
...template,
projectId: row.projectId,
projectTitle: row.title,
}))
: []
),
},
{ headers: { "Cache-Control": "no-store" } }
);
}

View File

@ -0,0 +1,143 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
getCanvasLayoutReplaceBatchActiveTaskId,
getCanvasLayoutReplaceBatchForUser,
markCanvasLayoutReplaceBatchItemsAdded,
retryCanvasLayoutReplaceBatchItem,
} from "@/lib/canvas/layout-replace-batch";
import {
enqueueCanvasImageTask,
ensureCanvasImageTaskActive,
} from "@/lib/canvas/image-task-queue";
function normalizeUuid(value: unknown) {
const id = String(value || "").trim();
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
? id
: "";
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ batchId: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const batchId = normalizeUuid((await params).batchId);
if (!batchId) {
return NextResponse.json({ success: false, error: "批次ID无效" }, { status: 400 });
}
let batch = await getCanvasLayoutReplaceBatchForUser({
batchId,
userId: session.user.id,
});
if (!batch) {
return NextResponse.json({ success: false, error: "批次不存在" }, { status: 404 });
}
const activeTaskId = getCanvasLayoutReplaceBatchActiveTaskId(batch);
if (activeTaskId) {
await ensureCanvasImageTaskActive({
taskId: activeTaskId,
userId: session.user.id,
});
batch = await getCanvasLayoutReplaceBatchForUser({
batchId,
userId: session.user.id,
});
}
return NextResponse.json({ success: true, batch });
} catch (error) {
console.error("[Canvas Layout Replace Batch][Detail] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询批次失败",
},
{ status: 500 }
);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ batchId: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const batchId = normalizeUuid((await params).batchId);
const body = await request.json().catch(() => ({}));
if (body?.action === "retry") {
const itemId = normalizeUuid(body?.itemId);
if (!batchId || !itemId) {
return NextResponse.json(
{ success: false, error: "缺少需要重试的结果" },
{ status: 400 }
);
}
const taskId = await retryCanvasLayoutReplaceBatchItem({
batchId,
itemId,
userId: session.user.id,
});
if (!taskId) {
return NextResponse.json(
{ success: false, error: "该结果当前无法重试" },
{ status: 409 }
);
}
await enqueueCanvasImageTask(taskId);
return NextResponse.json({ success: true, taskId });
}
const items = Array.isArray(body?.items)
? body.items
.map((item: unknown) => {
const record =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
return {
itemId: normalizeUuid(record.itemId),
canvasNodeId: String(record.canvasNodeId || "").trim(),
};
})
.filter(
(item: { itemId: string; canvasNodeId: string }) =>
Boolean(item.itemId && item.canvasNodeId)
)
: [];
if (!batchId || items.length === 0) {
return NextResponse.json(
{ success: false, error: "缺少已添加的结果信息" },
{ status: 400 }
);
}
const updated = await markCanvasLayoutReplaceBatchItemsAdded({
batchId,
userId: session.user.id,
items,
});
if (!updated) {
return NextResponse.json({ success: false, error: "批次不存在" }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("[Canvas Layout Replace Batch][PATCH] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "更新批次失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,201 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
CANVAS_LAYOUT_REPLACE_BATCH_LIMIT,
createCanvasLayoutReplaceBatch,
getCanvasLayoutReplaceBatchActiveTaskId,
getLatestCanvasLayoutReplaceBatchForUser,
type CanvasLayoutReplaceBatchSource,
} from "@/lib/canvas/layout-replace-batch";
import {
enqueueCanvasImageTask,
ensureCanvasImageTaskActive,
} from "@/lib/canvas/image-task-queue";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
export const maxDuration = 1200;
function normalizeHttpUrl(value: unknown) {
const url = String(value || "").trim();
return /^https?:\/\//i.test(url) ? url : "";
}
function normalizeUuid(value: unknown) {
const id = String(value || "").trim();
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
? id
: "";
}
function normalizeSources(value: unknown): CanvasLayoutReplaceBatchSource[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const sources: CanvasLayoutReplaceBatchSource[] = [];
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const record = item as Record<string, unknown>;
const sourceImageUrl = normalizeHttpUrl(record.sourceImageUrl);
if (!sourceImageUrl || seen.has(sourceImageUrl)) continue;
seen.add(sourceImageUrl);
sources.push({
sourceImageUrl,
sourceKind: record.sourceKind === "upload" ? "upload" : "canvas",
sourceNodeId: String(record.sourceNodeId || "").trim() || undefined,
sourceTitle: String(record.sourceTitle || "").trim().slice(0, 256) || undefined,
});
if (sources.length >= CANVAS_LAYOUT_REPLACE_BATCH_LIMIT) break;
}
return sources;
}
async function getOwnedProject(projectId: string, userId: string) {
const [project] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
.limit(1);
return project || null;
}
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const projectId = normalizeUuid(searchParams.get("projectId"));
const sourceNodeId = String(searchParams.get("sourceNodeId") || "").trim();
if (!projectId) {
return NextResponse.json(
{ success: false, error: "缺少有效的画布项目" },
{ status: 400 }
);
}
if (!(await getOwnedProject(projectId, session.user.id))) {
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
}
let batch = await getLatestCanvasLayoutReplaceBatchForUser({
projectId,
userId: session.user.id,
sourceNodeId: sourceNodeId || undefined,
});
const activeTaskId = batch
? getCanvasLayoutReplaceBatchActiveTaskId(batch)
: null;
if (activeTaskId) {
await ensureCanvasImageTaskActive({
taskId: activeTaskId,
userId: session.user.id,
});
batch = await getLatestCanvasLayoutReplaceBatchForUser({
projectId,
userId: session.user.id,
sourceNodeId: sourceNodeId || undefined,
});
}
return NextResponse.json({ success: true, batch: batch || null });
} catch (error) {
console.error("[Canvas Layout Replace Batch][GET] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询批次失败",
},
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const projectId = normalizeUuid(body?.projectId);
const referenceImageUrl = normalizeHttpUrl(body?.referenceImageUrl);
const sources = normalizeSources(body?.sources);
if (!projectId || !(await getOwnedProject(projectId, session.user.id))) {
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
}
if (sources.length === 0) {
return NextResponse.json(
{ success: false, error: "请至少添加一张待处理图片" },
{ status: 400 }
);
}
if (!referenceImageUrl) {
return NextResponse.json(
{ success: false, error: "请上传目标模版图" },
{ status: 400 }
);
}
const pointsConfig = await getPointsSystemConfig();
const perImageCost = getImageToolCreditCost(pointsConfig, "repaint");
const totalCost = perImageCost * sources.length;
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: totalCost,
reason: "image_repaint",
modelId: "canvas-layout-replace-batch",
note: `批量排版替换 ${sources.length} 张`,
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const created = await createCanvasLayoutReplaceBatch({
userId: session.user.id,
projectId,
sources,
referenceImageUrl,
supplementalPrompt:
typeof body?.supplementalPrompt === "string"
? body.supplementalPrompt
: undefined,
imageSize: typeof body?.imageSize === "string" ? body.imageSize : "2K",
model: typeof body?.model === "string" ? body.model : undefined,
});
await enqueueCanvasImageTask(created.firstTaskId);
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `批量排版替换 ${sources.length} 张(${created.batchId})`,
});
} catch (error) {
console.error("[Points] Failed to consume layout batch points:", error);
}
}
return NextResponse.json({
success: true,
batchId: created.batchId,
firstTaskId: created.firstTaskId,
sourceCount: sources.length,
pointsCost: totalCost,
});
} catch (error) {
console.error("[Canvas Layout Replace Batch][POST] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "创建批次失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,124 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
createCanvasImageTask,
enqueueCanvasImageTask,
} from "@/lib/canvas/image-task-queue";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import type { CanvasLayoutReplaceTaskRequest } from "@/lib/canvas/layout-replace-task";
export const maxDuration = 1200;
function normalizeHttpUrl(value: unknown) {
const url = String(value || "").trim();
return /^https?:\/\//i.test(url) ? url : "";
}
function normalizeUuid(value: unknown) {
const uuid = String(value || "").trim();
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)
? uuid
: undefined;
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 },
);
}
const body = await request.json().catch(() => ({}));
const sourceImageUrl = normalizeHttpUrl(body?.sourceImageUrl);
const referenceImageUrl = normalizeHttpUrl(body?.referenceImageUrl);
if (!sourceImageUrl) {
return NextResponse.json(
{ success: false, error: "缺少当前产品图片" },
{ status: 400 },
);
}
if (!referenceImageUrl) {
return NextResponse.json(
{ success: false, error: "缺少排版参考图" },
{ status: 400 },
);
}
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
reason: "image_repaint",
modelId: "canvas-layout-replace",
note: "排版替换消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, {
status: pointsCharge.status,
});
}
const taskRequest: CanvasLayoutReplaceTaskRequest = {
taskKind: "canvas-layout-replace",
sourceImageUrl,
referenceImageUrl,
supplementalPrompt:
typeof body?.supplementalPrompt === "string"
? body.supplementalPrompt
: undefined,
model: typeof body?.model === "string" ? body.model : undefined,
imageSize:
typeof body?.imageSize === "string" && body.imageSize.trim()
? body.imageSize.trim()
: "2K",
sourceNodeId:
typeof body?.sourceNodeId === "string" ? body.sourceNodeId : undefined,
agentProjectId: normalizeUuid(body?.agentProjectId),
referenceLabel:
typeof body?.referenceLabel === "string" ? body.referenceLabel : undefined,
debugMeta:
body?.debugMeta &&
typeof body.debugMeta === "object" &&
!Array.isArray(body.debugMeta)
? body.debugMeta
: undefined,
};
const task = await createCanvasImageTask({
userId: session.user.id,
request: taskRequest as unknown as Record<string, unknown>,
});
await enqueueCanvasImageTask(task.id);
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `排版替换消耗积分(${task.id})`,
});
} catch (pointsError) {
console.error("[Points] Failed to consume canvas layout replace points:", pointsError);
}
}
return NextResponse.json({
success: true,
taskId: task.id,
status: task.status,
});
} catch (error) {
console.error("[Canvas Layout Replace] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "排版替换提交失败",
},
{ status: 500 },
);
}
}

View File

@ -21,7 +21,13 @@ import {
} from "@/lib/canvas/scale-adjust-semantic"; } from "@/lib/canvas/scale-adjust-semantic";
import { import {
getPointsSystemConfig, getPointsSystemConfig,
getSiteSettings,
getSystemSetting,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import { getImageToolCreditCost } from "@/lib/points/config"; import { getImageToolCreditCost } from "@/lib/points/config";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
@ -618,7 +624,23 @@ export async function POST(request: NextRequest) {
); );
} }
const pointsConfig = await getPointsSystemConfig(); const [pointsConfig, siteSettingsForModel, localEditConfigSetting] = await Promise.all([
getPointsSystemConfig(),
getSiteSettings().catch(() => null),
getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch(() => null),
]);
const localEditConfig = normalizeCanvasLocalEditConfig(
localEditConfigSetting?.value
);
const siteDetailDefaultModelId =
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
? siteSettingsForModel.canvasDetailDefaultModelId.trim()
: "";
const configuredLocalEditModelId = localEditConfig.modelId;
const configuredScaleAdjustModelId =
localEditConfig.scaleAdjustModelId ||
siteDetailDefaultModelId ||
SCALE_ADJUST_REPAIR_MODEL_REF;
const pointsCharge = await prepareFeaturePointsCharge({ const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id, userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "repaint"), unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
@ -787,7 +809,7 @@ export async function POST(request: NextRequest) {
operation, operation,
requestedModel: requestedModel || null, requestedModel: requestedModel || null,
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio, resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
resolvedModel: SCALE_ADJUST_REPAIR_MODEL_REF, resolvedModel: configuredScaleAdjustModelId,
scaleAdjustProductLockMaskEditApplied: true, scaleAdjustProductLockMaskEditApplied: true,
scaleAdjustPersonExclusionApplied: scaleAdjustPersonExclusionApplied:
productLockAssets.personExclusionApplied, productLockAssets.personExclusionApplied,
@ -814,7 +836,7 @@ export async function POST(request: NextRequest) {
displayPrompt, displayPrompt,
imageCount: 1, imageCount: 1,
imageSize: requestedImageSize, imageSize: requestedImageSize,
model: SCALE_ADJUST_REPAIR_MODEL_REF, model: configuredScaleAdjustModelId,
negativePrompt: negativePrompt:
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影", "框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
prompt, prompt,
@ -847,7 +869,7 @@ export async function POST(request: NextRequest) {
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "", sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
target: region.targetRect, target: region.targetRect,
})), })),
relightModel: SCALE_ADJUST_REPAIR_MODEL_REF, relightModel: configuredScaleAdjustModelId,
sourceImageUrl, sourceImageUrl,
}, },
} }
@ -876,7 +898,7 @@ export async function POST(request: NextRequest) {
...(hasPositionMove ...(hasPositionMove
? { ? {
scaleAdjustModelRelight: { scaleAdjustModelRelight: {
model: SCALE_ADJUST_REPAIR_MODEL_REF, model: configuredScaleAdjustModelId,
overlayImageUrl: overlayUpload.url, overlayImageUrl: overlayUpload.url,
quality: "medium" as const, quality: "medium" as const,
regions: scaleAdjustIntentRegions.map((region) => ({ regions: scaleAdjustIntentRegions.map((region) => ({
@ -975,6 +997,7 @@ export async function POST(request: NextRequest) {
displayPrompt, displayPrompt,
operation, operation,
requestedModel: requestedModel || null, requestedModel: requestedModel || null,
resolvedModel: requestedModel || configuredLocalEditModelId || null,
resolvedAspectRatio, resolvedAspectRatio,
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length, manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
regionReferenceImageCount: referenceImages.length, regionReferenceImageCount: referenceImages.length,
@ -986,7 +1009,7 @@ export async function POST(request: NextRequest) {
displayPrompt, displayPrompt,
imageCount: 1, imageCount: 1,
imageSize: requestedImageSize, imageSize: requestedImageSize,
model: requestedModel || undefined, model: requestedModel || configuredLocalEditModelId || undefined,
negativePrompt: negativePrompt:
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位", "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
prompt, prompt,

View File

@ -0,0 +1,65 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 180;
const probe = promisify(execFile);
const running = new Set<string>();
const formats: Record<string, string> = {
"video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm",
"audio/mpeg": "mp3", "audio/mp3": "mp3", "audio/mp4": "m4a", "audio/x-m4a": "m4a",
"audio/wav": "wav", "audio/x-wav": "wav", "audio/ogg": "ogg", "audio/flac": "flac", "audio/webm": "webm",
};
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
if (running.has(session.user.id) || running.size >= 4) return NextResponse.json({ success: false, error: "正在上传,请稍后重试" }, { status: 429 });
running.add(session.user.id);
let dir = "";
try {
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File) || !formats[file.type]) return NextResponse.json({ success: false, error: "请上传 MP4、MOV、WebM 视频,或 MP3、M4A、WAV、OGG、FLAC 音频" }, { status: 400 });
if (!file.size || file.size > 125 * 1024 * 1024) return NextResponse.json({ success: false, error: "素材必须非空且不超过 125 MiB" }, { status: 413 });
dir = await mkdtemp(join(tmpdir(), "canvas-upload-"));
const input = join(dir, "input");
const buffer = await file.arrayBuffer();
await writeFile(input, Buffer.from(buffer));
let stdout: string;
try {
({ stdout } = await probe("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm,mp3,wav,ogg,flac", "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 }));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return NextResponse.json({ success: false, error: "服务器缺少 ffprobe 视频解析组件,请联系管理员安装 FFmpeg" }, { status: 503 });
}
return NextResponse.json({ success: false, error: "视频文件无法解析,请确认素材完整且格式受支持" }, { status: 400 });
}
const info = JSON.parse(stdout);
const kind = file.type.startsWith("video/") ? "video" : "audio";
const stream = info.streams?.find((item: { codec_type: string }) => item.codec_type === kind);
const duration = Number(info.format?.duration);
if (!stream || !Number.isFinite(duration) || duration <= 0 || duration > 600) return NextResponse.json({ success: false, error: "请上传内容完整、10 分钟以内的音视频" }, { status: 400 });
let uploaded;
try {
uploaded = await uploadFile(`canvas-${randomUUID()}.${formats[file.type]}`, buffer);
} catch (error) {
console.error("[Canvas media upload] Storage unavailable:", error);
return NextResponse.json({ success: false, error: "媒体存储暂不可用,请稍后重试" }, { status: 503 });
}
return NextResponse.json({ success: true, data: { url: uploaded.url, duration, width: stream.width, height: stream.height } });
} catch (error) {
console.error("[Canvas media upload] Failed:", error);
return NextResponse.json({ success: false, error: "素材上传失败,请确认文件完整或稍后重试" }, { status: 400 });
} finally {
running.delete(session.user.id);
if (dir) await rm(dir, { recursive: true, force: true });
}
}

View File

@ -0,0 +1,61 @@
import { generateText } from "ai";
import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("canvas-prompt-tools");
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
import { VIDEO_CANVAS_FUNCTION_CONFIG_KEY, normalizeVideoCanvasFunctionConfig } from "@/lib/video-canvas/config";
import { promptToolErrorMessage, promptToolFunctionKey, promptToolSchema, selectPromptToolModel } from "@/lib/video-canvas/prompt-tools";
export const runtime = "nodejs";
export const maxDuration = 120;
const running = new Set<string>();
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
const parsed = promptToolSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ success: false, error: parsed.error.issues[0]?.message || "参数无效" }, { status: 400 });
if (running.has(session.user.id)) return NextResponse.json({ success: false, error: "提示词处理中,请稍候" }, { status: 429 });
running.add(session.user.id);
try {
const [setting, available] = await Promise.all([
getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }),
getEnabledAiModelsWithProvider(false),
]);
const config = normalizeVideoCanvasFunctionConfig(setting?.value);
const definition = config.functions[promptToolFunctionKey(parsed.data.mode)];
if (!config.enabled || !definition?.enabled) return NextResponse.json({ success: false, error: "此提示词功能已停用" }, { status: 403 });
const requested = parsed.data.model && parsed.data.model !== "auto"
? parsed.data.model : definition.model && definition.model !== "auto"
? definition.model : config.defaultMultimodalModel;
let selected;
try {
selected = selectPromptToolModel(available
.filter(item => item.provider.isEnabled && item.model.isEnabled && !item.model.isSubModel)
.map(item => ({ ...item.model, providerType: item.provider.type })), requested);
} catch (error) {
return NextResponse.json({ success: false, error: (error as Error).message }, { status: 400 });
}
const model = await getLanguageModelById(selected.id);
if (!model) return NextResponse.json({ success: false, error: "提示词模型初始化失败" }, { status: 502 });
const { text } = await generateText({
model,
system: definition.prompt,
prompt: parsed.data.prompt,
temperature: parsed.data.mode === "translate" ? 0.1 : 0.4,
maxOutputTokens: 4000,
maxRetries: 0,
abortSignal: AbortSignal.any([request.signal, AbortSignal.timeout(90_000)]),
});
if (!text.trim()) throw new Error("empty model output");
return NextResponse.json({ success: true, data: { text: text.trim(), model: selected.id, modelName: selected.displayName || selected.modelId } });
} catch (error) {
const detail = error as { name?: string; statusCode?: number; cause?: { code?: string }; data?: { error?: { code?: string } } };
logger.warn("Prompt tool request failed", { name: detail?.name, statusCode: detail?.statusCode, code: detail?.data?.error?.code || detail?.cause?.code });
return NextResponse.json({ success: false, error: promptToolErrorMessage(error) }, { status: 502 });
} finally {
running.delete(session.user.id);
}
}

View File

@ -100,6 +100,8 @@ export async function POST(request: NextRequest) {
.insert(canvasProject) .insert(canvasProject)
.values({ .values({
userId: session.user.id, userId: session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
title: title || getDefaultWorkspaceTitle(workspaceType), title: title || getDefaultWorkspaceTitle(workspaceType),
description, description,
workspaceType, workspaceType,

View File

@ -0,0 +1,68 @@
import { remoteAudioCapabilities } from "@/lib/video-canvas/smart-edit/remote-audio";
import { auth } from "@/app/(auth)/auth";
import { NextResponse } from "next/server";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { isCanvasVisionModel } from "@/lib/video-canvas/model-matching";
import { remoteRenderConfig } from "@/lib/video-canvas/smart-edit/remote-render";
export async function GET() {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
let capabilities = {
separation: false,
scenes: false,
beats: false,
asr: false,
ffmpeg: false,
ffprobe: false,
};
try {
const { stdout } = await promisify(execFile)(
process.env.SMART_EDIT_PYTHON || "python3",
[path.resolve("services/smart-edit/analyze.py"), "--check"],
{ timeout: 10000, maxBuffer: 65536 }
);
capabilities = JSON.parse(stdout);
} catch {}
const audio = await remoteAudioCapabilities();
if (audio) capabilities = { ...capabilities, ...audio };
let renderAvailable = capabilities.ffmpeg;
const remote = remoteRenderConfig();
if (remote) {
try {
const response = await fetch(remote.base + "/health", {
headers: { authorization: "Bearer " + remote.token },
signal: AbortSignal.timeout(3000),
redirect: "error",
cache: "no-store",
});
renderAvailable =
response.ok && (await response.json()).status === "ok";
} catch {
renderAvailable = false;
}
}
const models = (await getEnabledAiModelsWithProvider(false))
.filter((item) =>
isCanvasVisionModel({ ...item.model, providerType: item.provider.type })
)
.map(({ model }) => ({
id: model.id,
name: model.displayName,
modelId: model.modelId,
}));
return NextResponse.json(
{ capabilities: { ...capabilities, render: renderAvailable }, models },
{ headers: { "Cache-Control": "no-store" } }
);
} catch {
return NextResponse.json(
{ error: "剪辑服务配置读取失败" },
{ status: 503 }
);
}
}

View File

@ -0,0 +1,22 @@
import { auth } from "@/app/(auth)/auth";
import { captionFont } from "@/lib/video-canvas/smart-edit/fonts";
import { captionFontFile } from "@/lib/video-canvas/smart-edit/font-files";
import { readFile } from "node:fs/promises";
export const runtime = "nodejs";
export const maxDuration = 180;
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
if (!(await auth())?.user?.id) return Response.json({ error: "未登录" }, { status: 401 });
const { id } = await params;
if (!captionFont(id)) return Response.json({ error: "字体不存在" }, { status: 404 });
try {
const data = await readFile(await captionFontFile(id));
return new Response(new Uint8Array(data), {
headers: {
"Content-Type": "font/ttf",
"Cache-Control": "private, max-age=31536000, immutable",
},
});
} catch {
return Response.json({ error: "字体加载失败,请检查字体服务配置后重试" }, { status: 503 });
}
}

View File

@ -29,8 +29,9 @@ export const runtime = "nodejs";
export const maxDuration = 1200; export const maxDuration = 1200;
const logger = createLogger("canvas-text-edit"); const logger = createLogger("canvas-text-edit");
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000; const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 120_000;
const TEXT_EDIT_MODEL_FALLBACK_LIMIT = 3; const TEXT_EDIT_MODEL_FALLBACK_LIMIT = 3;
const TEXT_EDIT_GENERATION_TIMEOUT_MS = 180_000;
const textItemSchema = z.object({ const textItemSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
@ -68,6 +69,13 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
type TextEditModelCandidate = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number]; type TextEditModelCandidate = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
function isExplicitTextEditModel(candidate: TextEditModelCandidate) {
const capabilities = candidate.model.capabilities as Record<string, unknown> | null;
const kind = String(capabilities?.kind || "").toLowerCase();
const modelId = String(candidate.model.modelId || "").toLowerCase();
return kind === "image-edit" || /(?:^|[/_-])edit(?:$|[/_-])/.test(modelId);
}
function getTextEditModelCandidates( function getTextEditModelCandidates(
models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>> models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>
) { ) {
@ -82,16 +90,19 @@ function getTextEditModelCandidates(
}) && }) &&
item.model.isEnabled && item.model.isEnabled &&
item.provider.isEnabled && item.provider.isEnabled &&
(!item.model.excludeFromFailover || item.model.isDefaultTextEditModel) &&
SUPPORTED_TEXT_EDIT_PROVIDER_TYPES.has(item.provider.type), SUPPORTED_TEXT_EDIT_PROVIDER_TYPES.has(item.provider.type),
) )
.sort((a, b) => { .sort((a, b) => {
const aScore = const aScore =
(a.model.isDefaultTextEditModel ? 100 : 0) + (a.model.isDefaultTextEditModel ? 100 : 0) +
(isExplicitTextEditModel(a) ? 30 : 0) +
(a.model.isDefault ? 10 : 0) + (a.model.isDefault ? 10 : 0) +
(a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) + (a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
(a.model.displayName?.includes("火山") ? 3 : 0); (a.model.displayName?.includes("火山") ? 3 : 0);
const bScore = const bScore =
(b.model.isDefaultTextEditModel ? 100 : 0) + (b.model.isDefaultTextEditModel ? 100 : 0) +
(isExplicitTextEditModel(b) ? 30 : 0) +
(b.model.isDefault ? 10 : 0) + (b.model.isDefault ? 10 : 0) +
(b.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) + (b.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
(b.model.displayName?.includes("火山") ? 3 : 0); (b.model.displayName?.includes("火山") ? 3 : 0);
@ -220,6 +231,13 @@ async function resolveTextEditSourceImageUrl(imageUrl: string) {
} catch (error) { } catch (error) {
logger.warn("source_image_archive_failed", { logger.warn("source_image_archive_failed", {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
sourceHost: (() => {
try {
return new URL(imageUrl).host;
} catch {
return "invalid-url";
}
})(),
}); });
return imageUrl; return imageUrl;
} }
@ -495,6 +513,7 @@ async function generateTextEditImageWithFailover(params: {
], ],
imageParams: { imageParams: {
imageCount: 1, imageCount: 1,
timeoutMs: TEXT_EDIT_GENERATION_TIMEOUT_MS,
}, },
}; };
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") { if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {

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

@ -0,0 +1,39 @@
import { auth } from "@/app/(auth)/auth";
import { upscaleConfig } from "@/lib/video-canvas/video-upscale-remote";
import { NextResponse } from "next/server";
export async function GET() {
if (!(await auth())?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const config = upscaleConfig();
if (!config)
return NextResponse.json({
available: false,
message: "视频高清服务尚未接通",
});
const response = await fetch(config.base + "/health", {
headers: { authorization: "Bearer " + config.token },
signal: AbortSignal.timeout(5000),
redirect: "error",
cache: "no-store",
});
if (!response.ok) throw Error("unavailable");
const data = await response.json();
return NextResponse.json(
{
available: data.status === "ok",
engine: "Real-ESRGAN",
resolutions: ["1080P", "2K", "4K"],
maxScale: 4,
maxSeconds: 120,
queued: data.queued,
},
{ headers: { "Cache-Control": "no-store" } }
);
} catch {
return NextResponse.json(
{ available: false, message: "视频高清服务暂时不可用,请稍后重试" },
{ headers: { "Cache-Control": "no-store" } }
);
}
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { archiveCanvasVideo } from "@/lib/video-canvas/archive-video";
export const runtime = "nodejs";
export const maxDuration = 300;
const running = new Set<string>();
const schema = z.object({ url: z.string().url().max(12000) });
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ success: false, error: "视频地址无效" }, { status: 400 });
if (running.has(session.user.id)) return NextResponse.json({ success: false, error: "视频正在保存,请稍候" }, { status: 429 });
running.add(session.user.id);
try {
const url = await archiveCanvasVideo(parsed.data.url);
return NextResponse.json({ success: true, data: { url, storageStatus: "archived", permanentVideoUrl: url } });
} catch {
return NextResponse.json({ success: false, error: "视频转存失败,原视频仍保留;请稍后重试保存,无需重新生成" }, { status: 502 });
} finally { running.delete(session.user.id); }
}

View File

@ -0,0 +1,96 @@
import { auth } from "@/app/(auth)/auth";
import { NextResponse } from "next/server";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { uploadFile } from "@/lib/storage/service";
import { downloadCanvasMedia } from "@/lib/video-canvas/download-media";
import { processVideoFile } from "@/lib/video-canvas/media-processing";
import { videoProcessSchema } from "@/lib/video-canvas/media-contract";
export const runtime = "nodejs";
export const maxDuration = 300;
const running = new Set<string>();
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
const parsed = videoProcessSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success)
return NextResponse.json({ success: false, error: "视频处理参数无效" }, { status: 400 });
if (running.has(session.user.id) || running.size >= 2)
return NextResponse.json(
{ success: false, error: "视频处理中,请稍后再提交" },
{ status: 429 },
);
running.add(session.user.id);
let dir = "";
try {
dir = await mkdtemp(join(tmpdir(), "canvas-video-"));
const input = join(dir, "input");
await downloadCanvasMedia(parsed.data.url, input);
const save = async (name: string) => {
const buffer = await readFile(join(dir, name));
return (
await uploadFile(`canvas-video-${randomUUID()}-${name}`, new Uint8Array(buffer).buffer)
).url;
};
if (parsed.data.operation === "frame" || parsed.data.operation === "slow") {
const name = parsed.data.operation === "frame" ? "frame.png" : "slow.mp4";
const metadata = await processVideoFile(
input,
join(dir, name),
parsed.data.operation,
undefined,
undefined,
parsed.data,
);
return NextResponse.json({ success: true, data: { url: await save(name), ...metadata } });
}
if (parsed.data.operation === "trim") {
const metadata = await processVideoFile(
input,
join(dir, "clip.mp4"),
"trim",
undefined,
parsed.data.range,
);
return NextResponse.json({
success: true,
data: { url: await save("clip.mp4"), ...metadata },
});
}
if (parsed.data.operation === "crop") {
const metadata = await processVideoFile(
input,
join(dir, "crop.mp4"),
"crop",
parsed.data.rect,
);
return NextResponse.json({
success: true,
data: { url: await save("crop.mp4"), ...metadata },
});
}
const metadata = await processVideoFile(input, join(dir, "audio.m4a"), "extract-audio");
await processVideoFile(input, join(dir, "silent.mp4"), "silent-video");
return NextResponse.json({
success: true,
data: { audioUrl: await save("audio.m4a"), url: await save("silent.mp4"), ...metadata },
});
} catch (error) {
const message = error instanceof Error ? error.message : "视频处理失败";
return NextResponse.json(
{
success: false,
error: message.startsWith("Command failed")
? "视频处理失败,请确认文件完整且格式受支持"
: message,
},
{ status: 400 },
);
} finally {
running.delete(session.user.id);
if (dir) await rm(dir, { recursive: true, force: true });
}
}

File diff suppressed because it is too large Load Diff

View File

@ -190,6 +190,48 @@ function assertArchivedAssetBuffer(params: {
} }
} }
async function verifyArchivedAssetUpload(params: {
url: string;
expectedBytes: number;
validateAs?: ArchiveValidationKind;
}) {
const headResponse = await fetch(params.url, {
method: "HEAD",
cache: "no-store",
});
if (!headResponse.ok) {
throw new Error(`归档文件校验失败(HTTP ${headResponse.status})`);
}
const archivedLength = Number(headResponse.headers.get("content-length"));
if (
Number.isFinite(archivedLength) &&
archivedLength > 0 &&
archivedLength !== params.expectedBytes
) {
throw new Error(
`归档文件大小不一致(源文件 ${params.expectedBytes},归档文件 ${archivedLength})`
);
}
if (!params.validateAs) return;
const rangeResponse = await fetch(params.url, {
method: "GET",
headers: { Range: "bytes=0-63" },
cache: "no-store",
});
if (!rangeResponse.ok) {
throw new Error(`归档文件头校验失败(HTTP ${rangeResponse.status})`);
}
const headerBuffer = await rangeResponse.arrayBuffer();
assertArchivedAssetBuffer({
buffer: headerBuffer,
contentType: rangeResponse.headers.get("content-type"),
validateAs: params.validateAs,
});
}
async function archiveRemoteAssetIfNeeded(params: { async function archiveRemoteAssetIfNeeded(params: {
cacheKey: string; cacheKey: string;
sourceUrl?: string | null; sourceUrl?: string | null;
@ -249,6 +291,11 @@ async function archiveRemoteAssetIfNeeded(params: {
); );
const filename = `${params.filenamePrefix}.${ext}`; const filename = `${params.filenamePrefix}.${ext}`;
const uploaded = await uploadFile(filename, assetBuffer); const uploaded = await uploadFile(filename, assetBuffer);
await verifyArchivedAssetUpload({
url: uploaded.url,
expectedBytes: assetBuffer.byteLength,
validateAs: params.validateAs,
});
cache.set(params.cacheKey, { cache.set(params.cacheKey, {
sourceUrl, sourceUrl,
archivedUrl: uploaded.url, archivedUrl: uploaded.url,
@ -299,7 +346,8 @@ async function archiveRemoteAssetMapIfNeeded(params: {
) )
).filter((entry): entry is readonly [string, string] => Boolean(entry)); ).filter((entry): entry is readonly [string, string] => Boolean(entry));
return archivedEntries.length > 0 ? Object.fromEntries(archivedEntries) : null; if (archivedEntries.length !== entries.length) return null;
return Object.fromEntries(archivedEntries);
} }
function getStringValue(...values: unknown[]) { function getStringValue(...values: unknown[]) {
@ -368,33 +416,96 @@ async function updatePersistedWorldAsset(params: {
function extractWorldId(operation: WorldLabsOperation) { function extractWorldId(operation: WorldLabsOperation) {
const response = getObjectValue(operation.response); const response = getObjectValue(operation.response);
const metadata = getObjectValue(operation.metadata); const metadata = getObjectValue(operation.metadata);
return getStringValue( const nestedWorldId =
response?.world_id, getStringValue(response?.world_id, response?.worldId) ||
response?.worldId, findNestedString(response, ["world_id", "worldId"]);
metadata?.world_id, const metadataWorldId =
metadata?.worldId getStringValue(metadata?.world_id, metadata?.worldId) ||
); findNestedString(metadata, ["world_id", "worldId"]);
return getStringValue(nestedWorldId, metadataWorldId);
}
function isWorldLikePayload(value: unknown): value is WorldLabsWorld {
const record = getObjectValue(value);
if (!record) return false;
const hasCoreWorldField =
typeof record.world_id === "string" ||
typeof record.world_marble_url === "string" ||
typeof record.display_name === "string" ||
typeof record.worldId === "string";
const hasPossibleWorldContainerFields = record.assets || record.permission || record.tags || record.world_prompt;
const hasModelAliasOnly =
typeof record.model === "string" && /world/i.test(record.model);
return Boolean(hasCoreWorldField || hasPossibleWorldContainerFields || hasModelAliasOnly);
}
function extractWorldCandidate(value: unknown, depth = 0): WorldLabsWorld | null {
if (depth > 8) return null;
const record = getObjectValue(value);
if (!record) return null;
if (isWorldLikePayload(record)) return record;
const childCandidates: Array<Record<string, unknown> | null> = [
getObjectValue(record.world),
getObjectValue(record.result),
getObjectValue(record.output),
getObjectValue(record.data),
getObjectValue(record.payload),
];
for (const child of childCandidates) {
if (!child) continue;
if (isWorldLikePayload(child)) return child;
const nested = extractWorldCandidate(child, depth + 1);
if (nested) return nested;
}
for (const child of Object.values(record)) {
const nested = extractWorldCandidate(child, depth + 1);
if (nested) return nested;
}
return null;
} }
function extractWorldFromOperation(operation: WorldLabsOperation) { function extractWorldFromOperation(operation: WorldLabsOperation) {
const response = operation.response; return extractWorldCandidate(operation.response);
if (response && typeof response === "object" && "world_marble_url" in (response as Record<string, unknown>)) {
return response as WorldLabsWorld;
}
return null;
} }
function hasUsableWorldAssets(world?: WorldLabsWorld | null) { function hasUsableWorldAssets(world?: WorldLabsWorld | null) {
const assets = world?.assets || null; const assets = world?.assets || null;
if (!assets) return false; if (!assets) return false;
const spzUrls = assets.splats?.spz_urls; const meshRecord = getObjectValue(assets.mesh);
const imageryRecord = getObjectValue(assets.imagery);
const splatsRecord = getObjectValue(assets.splats);
const spzUrls =
splatsRecord?.spz_urls || splatsRecord?.spzUrls || splatsRecord?.splat_urls;
return Boolean( return Boolean(
getStringValue(assets.thumbnail_url) || getStringValue(assets.thumbnail_url) ||
getStringValue(assets.imagery?.pano_url) || getStringValue(
getStringValue(assets.mesh?.collider_mesh_url) || imageryRecord?.pano_url,
imageryRecord?.panoUrl,
imageryRecord?.panorama_url,
imageryRecord?.panoramaUrl
) ||
getStringValue(
meshRecord?.collider_mesh_url,
meshRecord?.colliderMeshUrl,
meshRecord?.collider_mesh,
meshRecord?.mesh_url,
meshRecord?.meshUrl,
meshRecord?.model_url,
meshRecord?.modelUrl
) ||
(spzUrls && (spzUrls &&
typeof spzUrls === "object" && typeof spzUrls === "object" &&
Object.values(spzUrls).some((value) => typeof value === "string" && value.trim())) Object.values(spzUrls).some(
(value) => typeof value === "string" && value.trim()
))
); );
} }
@ -667,185 +778,224 @@ export async function GET(request: Request, { params }: RouteParams) {
}); });
providerForLog = config.providerId || null; providerForLog = config.providerId || null;
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
const persistedAssetPayload = let persistedAssetPayload =
getObjectValue(persistedWorldAsset?.assetPayload) || {}; getObjectValue(persistedWorldAsset?.assetPayload) || {};
const shouldUseExternalTaskResultApi = let shouldUseExternalTaskResultApi =
isWorldLabsExternalTaskApi(config.baseUrl) && isWorldLabsExternalTaskApi(config.baseUrl) &&
persistedAssetPayload.externalTaskApi !== false && persistedAssetPayload.externalTaskApi !== false &&
persistedAssetPayload.marbleApi !== true; persistedAssetPayload.marbleApi !== true;
if (shouldUseExternalTaskResultApi) { if (shouldUseExternalTaskResultApi) {
const externalPayload = await worldLabsRequest< try {
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData> const externalPayload = await worldLabsRequest<
>(config, { WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
method: "GET", >(config, {
path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`, method: "GET",
timeoutMs: resolveWorldLabsStatusTimeoutMs(), path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
}); timeoutMs: resolveWorldLabsStatusTimeoutMs(),
const externalData =
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(externalPayload);
const externalStatusText = getStringValue(externalData.status) || "running";
const externalStatus = normalizeExternalTaskStatus(
externalStatusText,
externalData.done === true
);
const externalAssetPayloadBase = {
...persistedAssetPayload,
providerId: config.providerId || null,
providerName: config.providerName || null,
externalTaskApi: true,
externalTaskStatus: externalStatusText,
requestId: getStringValue(externalPayload.requestId, externalPayload.request_id),
};
if (externalStatus === "running") {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "running",
progress: null,
output: externalData,
assetPayload: externalAssetPayloadBase,
},
}); });
} const externalData =
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(externalPayload);
if (externalStatus === "failed") { const externalStatusText = getStringValue(externalData.status) || "running";
const errorMessage = extractExternalTaskErrorMessage( const externalStatus = normalizeExternalTaskStatus(
externalData, externalStatusText,
externalPayload externalData.done === true
); );
const externalAssetPayloadBase = {
...persistedAssetPayload,
providerId: config.providerId || null,
providerName: config.providerName || null,
externalTaskApi: true,
externalTaskStatus: externalStatusText,
requestId: getStringValue(externalPayload.requestId, externalPayload.request_id),
};
if (externalStatus === "running") {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "running",
progress: null,
output: externalData,
assetPayload: externalAssetPayloadBase,
},
});
}
if (externalStatus === "failed") {
const errorMessage = extractExternalTaskErrorMessage(
externalData,
externalPayload
);
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "failed",
progress: null,
error: errorMessage,
assetPayload: externalAssetPayloadBase,
});
logger.warn("external task failed status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
error: errorMessage,
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "failed",
progress: null,
errorMessage,
output: externalData,
assetPayload: externalAssetPayloadBase,
},
});
}
const extractedAssetPayload = extractExternalWorldAssetPayload(externalData);
const rawMeshUrl = extractedAssetPayload.meshUrl;
const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl;
const rawPanoUrl = extractedAssetPayload.panoUrl;
const stableWorldUrl: string | null = null;
const stableModelUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
sourceUrl: rawMeshUrl,
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
fallbackExtension: "glb",
validateAs: "glb",
});
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
sourceUrl: rawThumbnailUrl,
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
sourceUrl: rawPanoUrl,
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
sourceMap: extractedAssetPayload.spzUrls,
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
fallbackExtension: "spz",
validateAs: "spz",
});
const sourceSpzEntryCount = Object.keys(
extractedAssetPayload.spzUrls || {}
).length;
const stableSpzEntryCount = Object.keys(stableSpzUrls || {}).length;
const assetsArchivingPending = Boolean(
(rawMeshUrl && !stableModelUrl) ||
(rawThumbnailUrl && !stableThumbnailUrl) ||
(rawPanoUrl && !stablePanoUrl) ||
(sourceSpzEntryCount > 0 && stableSpzEntryCount !== sourceSpzEntryCount)
);
const storageArchivedAt = assetsArchivingPending
? null
: new Date().toISOString();
const assetPayload = {
...externalAssetPayloadBase,
worldId: extractedAssetPayload.worldId,
worldUrl: stableWorldUrl,
displayName: extractedAssetPayload.displayName,
model: resolveThreeDModelAliasId(
extractedAssetPayload.model,
modelAliasConfig.worldModels
),
caption: extractedAssetPayload.caption,
thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
storageArchivedAt,
assetsArchivingPending,
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
? rawMeshUrl
: null,
sourceThumbnailUrl:
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
? rawThumbnailUrl
: null,
sourcePanoUrl:
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
? rawPanoUrl
: null,
sourceSpzUrls:
extractedAssetPayload.spzUrls &&
stableSpzUrls !== extractedAssetPayload.spzUrls
? extractedAssetPayload.spzUrls
: null,
};
await updatePersistedWorldAsset({ await updatePersistedWorldAsset({
id: persistedWorldAsset?.id, id: persistedWorldAsset?.id,
status: "failed", status: "success",
progress: null, progress: 100,
error: errorMessage, modelUrl: stableModelUrl,
assetPayload: externalAssetPayloadBase, previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
error: null,
assetPayload,
}); });
logger.warn("external task failed status", {
logger.info("external task success status", {
elapsedMs: Date.now() - startedAt, elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length, taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null, providerId: config.providerId || null,
error: errorMessage, hasMesh: Boolean(stableModelUrl),
hasPano: Boolean(stablePanoUrl),
hasThumbnail: Boolean(stableThumbnailUrl),
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
assetsArchivingPending,
}); });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { data: {
taskId: normalizedTaskId, taskId: normalizedTaskId,
type: "world", type: "world",
status: "failed", status: "success",
progress: null, progress: 100,
errorMessage,
output: externalData, output: externalData,
assetPayload: externalAssetPayloadBase, modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
worldId: assetPayload.worldId,
worldUrl: stableWorldUrl,
panoUrl: stablePanoUrl,
caption: assetPayload.caption,
assetPayload,
}, },
}); });
} catch (error) {
const fallbackAssetPayload = {
...persistedAssetPayload,
providerId: config.providerId || null,
providerName: config.providerName || null,
marbleApi: true,
externalTaskApi: false,
};
shouldUseExternalTaskResultApi = false;
persistedAssetPayload = fallbackAssetPayload;
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "running",
assetPayload: fallbackAssetPayload,
}).catch(() => {});
logger.warn("external task result failed, fallback to marble status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
error: error instanceof Error ? error.message : String(error),
});
} }
const extractedAssetPayload = extractExternalWorldAssetPayload(externalData);
const rawMeshUrl = extractedAssetPayload.meshUrl;
const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl;
const rawPanoUrl = extractedAssetPayload.panoUrl;
const stableWorldUrl: string | null = null;
const stableModelUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
sourceUrl: rawMeshUrl,
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
fallbackExtension: "glb",
validateAs: "glb",
});
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
sourceUrl: rawThumbnailUrl,
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
sourceUrl: rawPanoUrl,
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
sourceMap: extractedAssetPayload.spzUrls,
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
fallbackExtension: "spz",
validateAs: "spz",
});
const assetPayload = {
...externalAssetPayloadBase,
worldId: extractedAssetPayload.worldId,
worldUrl: stableWorldUrl,
displayName: extractedAssetPayload.displayName,
model: resolveThreeDModelAliasId(
extractedAssetPayload.model,
modelAliasConfig.worldModels
),
caption: extractedAssetPayload.caption,
thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
storageArchivedAt: new Date().toISOString(),
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
? rawMeshUrl
: null,
sourceThumbnailUrl:
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
? rawThumbnailUrl
: null,
sourcePanoUrl:
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
? rawPanoUrl
: null,
sourceSpzUrls:
extractedAssetPayload.spzUrls && stableSpzUrls !== extractedAssetPayload.spzUrls
? extractedAssetPayload.spzUrls
: null,
};
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "success",
progress: 100,
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
error: null,
assetPayload,
});
logger.info("external task success status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
hasMesh: Boolean(stableModelUrl),
hasPano: Boolean(stablePanoUrl),
hasThumbnail: Boolean(stableThumbnailUrl),
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "success",
progress: 100,
output: externalData,
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
worldId: assetPayload.worldId,
worldUrl: stableWorldUrl,
panoUrl: stablePanoUrl,
caption: assetPayload.caption,
assetPayload,
},
});
} }
const operation = await worldLabsRequest<WorldLabsOperation>(config, { const operation = await worldLabsRequest<WorldLabsOperation>(config, {
@ -908,22 +1058,68 @@ export async function GET(request: Request, { params }: RouteParams) {
let world = extractWorldFromOperation(operation); let world = extractWorldFromOperation(operation);
const worldId = extractWorldId(operation); const worldId = extractWorldId(operation);
if ((!world || !hasUsableWorldAssets(world)) && worldId) { if ((!world || !hasUsableWorldAssets(world)) && worldId) {
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, { try {
method: "GET", const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`, method: "GET",
timeoutMs: resolveWorldLabsStatusTimeoutMs(), path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
}); timeoutMs: resolveWorldLabsStatusTimeoutMs(),
world = world });
? { world = world
...world, ? {
...detailedWorld, ...world,
assets: detailedWorld.assets || world.assets || null, ...detailedWorld,
} assets: detailedWorld.assets || world.assets || null,
: detailedWorld; }
: detailedWorld;
} catch (error) {
logger.warn("world detail fetch failed, retry with running status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
worldId,
error: error instanceof Error ? error.message : "unknown",
});
}
} }
if (!world) { if (!world) {
throw new Error("World Labs 任务已完成,但未返回 world 数据"); const pendingPayload = {
...(getObjectValue(persistedWorldAsset?.assetPayload) || {}),
providerId: config.providerId || null,
providerName: config.providerName || null,
worldId: worldId || null,
worldUrl: null,
displayName: null,
model: null,
assetsPending: true,
operationMetadata: operation.metadata ?? null,
};
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "running",
progress: 95,
assetPayload: pendingPayload,
});
logger.warn("task completed but world payload is missing", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
worldId: worldId || null,
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "running",
progress: 95,
output: operation.response ?? null,
worldId: worldId || null,
worldUrl: null,
previewImageUrl: null,
assetPayload: pendingPayload,
},
});
} }
if (!hasUsableWorldAssets(world)) { if (!hasUsableWorldAssets(world)) {
@ -1039,24 +1235,15 @@ export async function GET(request: Request, { params }: RouteParams) {
: 0; : 0;
const hasMissingSpzArchive = const hasMissingSpzArchive =
rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length; rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length;
const storageArchivedAt = const assetsArchivingPending = Boolean(
[ (rawMeshUrl && !stableModelUrl) ||
[rawWorldUrl, stableWorldUrl], (rawThumbnailUrl && !stableThumbnailUrl) ||
[rawMeshUrl, stableModelUrl], (rawPanoUrl && !stablePanoUrl) ||
[rawThumbnailUrl, stableThumbnailUrl], hasMissingSpzArchive
[rawPanoUrl, stablePanoUrl], );
].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) || const storageArchivedAt = assetsArchivingPending
Boolean( ? null
stableSpzUrls && : new Date().toISOString();
assetPayload.spzUrls &&
Object.entries(stableSpzUrls).some(
([key, value]) =>
typeof assetPayload.spzUrls?.[key] === "string" &&
assetPayload.spzUrls[key] !== value
)
)
? new Date().toISOString()
: null;
logger.info("task success status", { logger.info("task success status", {
elapsedMs: Date.now() - startedAt, elapsedMs: Date.now() - startedAt,
@ -1068,10 +1255,7 @@ export async function GET(request: Request, { params }: RouteParams) {
hasThumbnail: Boolean(stableThumbnailUrl), hasThumbnail: Boolean(stableThumbnailUrl),
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0), hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
storageArchivedAt, storageArchivedAt,
assetsArchivingPending: Boolean( assetsArchivingPending,
(rawMeshUrl && !stableModelUrl) ||
hasMissingSpzArchive
),
}); });
const responseAssetPayload = { const responseAssetPayload = {
@ -1081,12 +1265,9 @@ export async function GET(request: Request, { params }: RouteParams) {
panoUrl: stablePanoUrl || assetPayload.panoUrl, panoUrl: stablePanoUrl || assetPayload.panoUrl,
meshUrl: stableModelUrl, meshUrl: stableModelUrl,
spzUrls: stableSpzUrls, spzUrls: stableSpzUrls,
spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null, spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
storageArchivedAt, storageArchivedAt,
assetsArchivingPending: Boolean( assetsArchivingPending,
(rawMeshUrl && !stableModelUrl) ||
hasMissingSpzArchive
),
sourceWorldUrl: rawWorldUrl || null, sourceWorldUrl: rawWorldUrl || null,
sourceMeshUrl: sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl

View File

@ -142,7 +142,27 @@ function shouldUseExternalTaskCreateApi(
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>, config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
mode: WorldTaskMode mode: WorldTaskMode
) { ) {
return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "text"; return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "image";
}
function isWorldLabsModeUnsupportedByExternalProvider(
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
mode: WorldTaskMode
) {
return isWorldLabsExternalTaskApi(config.baseUrl) && mode !== "image";
}
function assertWorldModeSupportedByCandidate(
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
mode: WorldTaskMode,
) {
if (isWorldLabsModeUnsupportedByExternalProvider(config, mode)) {
const normalizedBaseUrl = String(config.baseUrl || "").trim() || "未知服务地址";
const normalizedMode = String(mode || "").trim() || "未知模式";
throw new Error(
`${normalizedBaseUrl} 的 world-generation 当前仅支持图生世界(image)。当前提交的是 ${normalizedMode},请先切换供应商或使用图生世界。`
);
}
} }
function resolveOfficialMarbleModel(model?: string | null) { function resolveOfficialMarbleModel(model?: string | null) {
@ -153,6 +173,49 @@ function resolveOfficialMarbleModel(model?: string | null) {
return normalized; return normalized;
} }
function buildExternalWorldTaskPayload(
mode: WorldTaskMode,
worldPrompt: Record<string, unknown>,
prompt: string | null,
displayName: string,
) {
const normalizedPrompt = readOptionalTrimmedText(prompt);
if (!normalizedPrompt && mode === "text") {
throw new Error("请输入世界描述");
}
const payload: Record<string, unknown> = {
model: WORLDLABS_EXTERNAL_TASK_MODEL,
displayName,
};
if (normalizedPrompt) {
payload.prompt = normalizedPrompt;
} else if (mode !== "text") {
payload.prompt = "图生世界";
}
if (mode === "image" || mode === "multi-image") {
payload.world_prompt = worldPrompt;
}
return payload;
}
function buildOfficialWorldTaskPayload(
displayName: string,
worldPrompt: Record<string, unknown>,
officialMarbleModel: string,
body: CreateWorldTaskRequest
) {
return {
display_name: displayName,
model: officialMarbleModel,
permission: {
public: body.makePublic === true,
},
world_prompt: worldPrompt,
};
}
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) { function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
const textPrompt = readOptionalTrimmedText(body.prompt); const textPrompt = readOptionalTrimmedText(body.prompt);
@ -356,41 +419,73 @@ export async function POST(request: Request) {
for (const candidate of candidates) { for (const candidate of candidates) {
try { try {
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode); const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
assertWorldModeSupportedByCandidate(candidate, mode);
let requestUsedExternalTaskApi = false;
if (useExternalTaskApi) { if (useExternalTaskApi) {
const prompt = readOptionalTrimmedText(body.prompt); const payload = buildExternalWorldTaskPayload(
if (!prompt) { mode,
throw new Error("请输入世界描述"); worldPrompt,
readOptionalTrimmedText(body.prompt),
displayName,
);
try {
const externalResult = await worldLabsRequest<
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
>(candidate, {
method: "POST",
path: "/api/external/task/generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: payload,
});
result = normalizeExternalTaskCreateOperation(externalResult);
requestUsedExternalTaskApi = true;
} catch (error) {
const externalError = error instanceof Error ? error.message : String(error);
logger.warn("external world create failed, fallback to marble endpoint", {
providerId: candidate.providerId || null,
error: externalError,
});
if (isCredentialOrBillingProviderError(error)) {
await reportWorldLabsProviderRuntimeFailure(
candidate.providerId,
externalError
);
}
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
method: "POST",
path: "/marble/v1/worlds:generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: buildOfficialWorldTaskPayload(
displayName,
worldPrompt,
officialMarbleModel,
body
),
});
requestUsedExternalTaskApi = false;
} }
const externalResult = await worldLabsRequest<
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
>(candidate, {
method: "POST",
path: "/api/external/task/generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: {
model: WORLDLABS_EXTERNAL_TASK_MODEL,
displayName,
prompt,
},
});
result = normalizeExternalTaskCreateOperation(externalResult);
} else { } else {
requestUsedExternalTaskApi = false;
result = await worldLabsRequest<WorldLabsOperation>(candidate, { result = await worldLabsRequest<WorldLabsOperation>(candidate, {
method: "POST", method: "POST",
path: "/marble/v1/worlds:generate", path: "/marble/v1/worlds:generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(), timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: { json: buildOfficialWorldTaskPayload(
display_name: displayName, displayName,
model: officialMarbleModel, worldPrompt,
permission: { officialMarbleModel,
public: body.makePublic === true, body
}, ),
world_prompt: worldPrompt,
},
}); });
} }
if (!result?.operation_id) {
throw new Error("创建 World Labs 世界任务返回缺少 operation_id");
}
usedProvider = candidate; usedProvider = candidate;
usedExternalTaskApi = useExternalTaskApi; usedExternalTaskApi = requestUsedExternalTaskApi;
providerForLog = candidate.providerId || null; providerForLog = candidate.providerId || null;
break; break;
} catch (error) { } catch (error) {

File diff suppressed because it is too large Load Diff

View File

@ -1,234 +1,10 @@
import { type NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service"; import { executeExtendPOST } from "@/lib/canvas/operations/extend";
import { getPointsSystemConfig } from "@/lib/db/queries"; export const runtime = "nodejs";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import {
getFriendlyRateLimitMessage,
getUserFacingRequestErrorMessage,
isRateLimitError,
} from "@/lib/request-error";
export const maxDuration = 180; export const maxDuration = 180;
export async function POST(request: Request) {
function parseIncludeBase64(value: unknown): boolean { const session = await auth();
if (typeof value === "boolean") return value; if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const normalized = String(value ?? "").trim().toLowerCase(); return executeExtendPOST(request, session.user.id);
return normalized === "true" || normalized === "1" || normalized === "yes";
}
function buildExtendSuccessPayload(result: Awaited<ReturnType<typeof executeExtendJob>>) {
return {
success: true,
original_url: result.original_url,
thumbnail_url: result.thumbnail_url,
...(result.image_base64 ? { base64: result.image_base64 } : {}),
processing_time: result.processing_time,
};
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
}
const contentType = request.headers.get("content-type") || "";
let imageUrl: string | null = null;
let imageBase64: string | null = null;
let prompt = "图像拓展处理";
let chatId: string | null = null;
let messageId: string | null = null;
let includeBase64 = false;
const requestId = `extend-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
if (contentType.includes("application/json")) {
const body = await request.json();
imageUrl = body.image_url ? String(body.image_url).trim() : null;
imageBase64 = body.image_base64 ? String(body.image_base64).trim() : null;
prompt = body.prompt || prompt;
chatId = body.chatId || null;
messageId = body.messageId || null;
includeBase64 = parseIncludeBase64(body.includeBase64);
if (!imageUrl && !imageBase64) {
return NextResponse.json(
{ error: "缺少image_base64或image_url参数" },
{ status: 400 },
);
}
console.log("[Extend API] Request received:", {
requestId,
source: imageUrl ? "url" : "base64",
includeBase64,
chatId,
messageId,
xScale: body.xScale,
yScale: body.yScale,
});
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
reason: "image_extend",
modelId: "image-extend",
note: "图片扩图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeExtendJob({
userId: session.user.id,
imageUrl,
imageBase64,
prompt,
chatId,
messageId,
xScale: body.xScale ? Number(body.xScale) : undefined,
yScale: body.yScale ? Number(body.yScale) : undefined,
includeBase64,
requestId,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume extend points:", pointsError);
}
}
console.log("[Extend API] Request completed:", {
requestId,
includeBase64,
processingTime: result.processing_time,
originalUrl: result.original_url.slice(0, 120),
});
return NextResponse.json(buildExtendSuccessPayload(result));
}
if (contentType.includes("multipart/form-data")) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const multipartChatId = String(formData.get("chatId") || "") || null;
const multipartMessageId = String(formData.get("messageId") || "") || null;
includeBase64 = parseIncludeBase64(formData.get("includeBase64"));
console.log("[Extend API] Multipart request received:", {
requestId,
includeBase64,
chatId: multipartChatId,
messageId: multipartMessageId,
bytes: buffer.length,
});
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
reason: "image_extend",
modelId: "image-extend",
note: "图片扩图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeExtendJob({
userId: session.user.id,
imageBase64: buffer.toString("base64"),
prompt: String(formData.get("prompt") || prompt),
chatId: multipartChatId,
messageId: multipartMessageId,
includeBase64,
requestId,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume extend points:", pointsError);
}
}
console.log("[Extend API] Multipart request completed:", {
requestId,
includeBase64,
processingTime: result.processing_time,
originalUrl: result.original_url.slice(0, 120),
});
return NextResponse.json(buildExtendSuccessPayload(result));
}
return NextResponse.json(
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
{ status: 400 },
);
} catch (error) {
if (error instanceof ExtendServiceError) {
if (error.status === 429 || isRateLimitError(error)) {
return NextResponse.json(
{
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
detail: error.detail || error.message,
retryAfter: 10,
},
{
status: 429,
headers: {
"Retry-After": "10",
},
},
);
}
return NextResponse.json(
{
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
detail: error.detail || error.message,
},
{ status: error.status },
);
}
console.error("[Extend API] 错误:", error);
if (isRateLimitError(error)) {
return NextResponse.json(
{
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
detail: error instanceof Error ? error.message : String(error),
retryAfter: 10,
},
{
status: 429,
headers: {
"Retry-After": "10",
},
},
);
}
return NextResponse.json(
{
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
detail: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
} }

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

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage, imageArchiveTask } from "@/lib/db/schema";
import { eq, desc, sql } from "drizzle-orm"; import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@ -19,12 +19,23 @@ export async function GET(request: NextRequest) {
const page = Number.parseInt(searchParams.get("page") || "1"); const page = Number.parseInt(searchParams.get("page") || "1");
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24"); const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const visibleImageFilter = and(
eq(generatedImage.userId, userId),
isNull(imageArchiveTask.id)
);
// 查询总数 // 查询总数
const countResult = await db const countResult = await db
.select({ count: sql<number>`count(*)::int` }) .select({ count: sql<number>`count(*)::int` })
.from(generatedImage) .from(generatedImage)
.where(eq(generatedImage.userId, userId)); .leftJoin(
imageArchiveTask,
and(
eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl),
eq(imageArchiveTask.status, "abandoned")
)
)
.where(visibleImageFilter);
const total = countResult[0]?.count || 0; const total = countResult[0]?.count || 0;
@ -42,7 +53,14 @@ export async function GET(request: NextRequest) {
modelId: generatedImage.modelId, // 添加 modelId 字段 modelId: generatedImage.modelId, // 添加 modelId 字段
}) })
.from(generatedImage) .from(generatedImage)
.where(eq(generatedImage.userId, userId)) .leftJoin(
imageArchiveTask,
and(
eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl),
eq(imageArchiveTask.status, "abandoned")
)
)
.where(visibleImageFilter)
.orderBy(desc(generatedImage.createdAt)) .orderBy(desc(generatedImage.createdAt))
.limit(pageSize) .limit(pageSize)
.offset(offset); .offset(offset);

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

@ -14,6 +14,10 @@ import {
updateMessageById, updateMessageById,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema"; import type { DBMessage } from "@/lib/db/schema";
import {
buildOpenClawArtifactProxyUrl,
normalizeOpenClawArtifacts,
} from "@/lib/openclaw-chat/artifacts";
import { import {
getFalImageTaskStatus, getFalImageTaskStatus,
resolveFalImageTaskToMarkdown, resolveFalImageTaskToMarkdown,
@ -32,12 +36,29 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
const falTaskLastPolledAt = new Map<string, number>(); const falTaskLastPolledAt = new Map<string, number>();
const falTaskSyncInFlight = new Set<string>(); const falTaskSyncInFlight = new Set<string>();
function toPublicVideoMetadata(value: DBMessage["videoMetadata"]) { function toPublicVideoMetadata(params: {
message: DBMessage;
requestOrigin: string;
}) {
const value = params.message.videoMetadata;
if (!value || typeof value !== "object" || Array.isArray(value)) return value; if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const metadata = value as Record<string, unknown>; const metadata = value as Record<string, unknown>;
if (metadata.taskKind !== "clawd_chat") return value; if (metadata.taskKind !== "clawd_chat") return value;
const publicMetadata = { ...metadata }; const publicMetadata = { ...metadata };
delete publicMetadata.upstreamTaskKey; delete publicMetadata.upstreamTaskKey;
publicMetadata.artifacts = normalizeOpenClawArtifacts(metadata.artifacts).map(
(artifact, artifactIndex) => ({
...artifact,
downloadUrl: new URL(
buildOpenClawArtifactProxyUrl({
chatId: params.message.chatId,
messageId: params.message.id,
artifactIndex,
}),
params.requestOrigin
).toString(),
})
);
return publicMetadata; return publicMetadata;
} }
@ -286,7 +307,11 @@ export async function GET(request: NextRequest) {
createdAt: msg.createdAt, createdAt: msg.createdAt,
parts: parts, parts: parts,
attachments: msg.attachments || [], attachments: msg.attachments || [],
videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null, videoMetadata:
toPublicVideoMetadata({
message: msg,
requestOrigin: request.nextUrl.origin,
}) || null,
}; };
}); });

View File

@ -1,138 +1,10 @@
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 { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
executeRmbgJob,
fetchImageBufferFromUrl,
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;
export async function POST(request: Request) {
export async function POST(request: NextRequest) { const session = await auth();
try { if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const session = await auth(); return executeRemoveBackgroundPOST(request, session.user.id);
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
}
const contentType = request.headers.get("content-type") || "";
let imageBuffer: Buffer;
let imageBase64: string | undefined;
let prompt = "抠图处理";
let chatId: string | null = null;
let messageId: string | null = null;
let skipGallerySave = false;
let sourceImageUrl: string | null = null;
if (contentType.includes("application/json")) {
const body = await request.json();
prompt = body.prompt || "抠图处理";
chatId = body.chatId || null;
messageId = body.messageId || null;
skipGallerySave = body.skipGallerySave === true || body.skip_gallery_save === 1;
const imageUrl = body.imageUrl || body.image_url;
const explicitSourceImageUrl = body.sourceImageUrl || body.source_image_url;
if (imageUrl) {
sourceImageUrl = String(imageUrl);
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
} else if (body.image_base64) {
imageBase64 = String(body.image_base64);
imageBuffer = Buffer.from(imageBase64, "base64");
if (explicitSourceImageUrl) {
sourceImageUrl = String(explicitSourceImageUrl);
}
} else {
return NextResponse.json(
{ error: "缺少 imageUrl 或 image_base64 参数" },
{ status: 400 }
);
}
} else if (contentType.includes("multipart/form-data")) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
imageBuffer = Buffer.from(arrayBuffer);
imageBase64 = imageBuffer.toString("base64");
prompt = (formData.get("prompt") as string) || "抠图处理";
chatId = (formData.get("chatId") as string) || null;
messageId = (formData.get("messageId") as string) || null;
skipGallerySave =
formData.get("skipGallerySave") === "true" || formData.get("skip_gallery_save") === "1";
sourceImageUrl =
(formData.get("sourceImageUrl") as string) ||
(formData.get("source_image_url") as string) ||
null;
} else {
return NextResponse.json(
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
{ status: 400 }
);
}
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "remove-background"),
reason: "image_remove_background",
modelId: "rmbg",
note: "图片抠图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeRmbgJob({
userId: session.user.id,
imageBuffer,
imageBase64,
prompt,
chatId,
messageId,
skipGallerySave,
sourceImageUrl,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume RMBG points:", pointsError);
}
}
return NextResponse.json({
success: true,
...result,
});
} catch (error) {
if (error instanceof RmbgServiceError) {
return NextResponse.json(
{
error: error.message,
detail: error.detail || error.message,
},
{ status: error.status }
);
}
console.error("[RMBG API] 处理错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
{ status: 500 }
);
}
} }

View File

@ -399,7 +399,7 @@ function normalizeLovartComposerPreviewTexts(value: unknown): string[] {
function normalizeLovartComposerPrefix(value: unknown): string { function normalizeLovartComposerPrefix(value: unknown): string {
const normalized = String(value || "").trim(); const normalized = String(value || "").trim();
if (!normalized) { if (!normalized) {
return "让「由壹点AI」"; return "让「𝗕𝘂𝗬𝗶」";
} }
return normalized.slice(0, 40); return normalized.slice(0, 40);
} }
@ -573,7 +573,7 @@ const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
]); ]);
const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = { const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
siteName: "AI Chatbot", siteName: "𝗕𝘂𝗬𝗶",
siteLogo: null, siteLogo: null,
siteLogoDark: null, siteLogoDark: null,
favicon: null, favicon: null,
@ -583,11 +583,11 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
bannerText: "限时特惠:升级会员最高立享 45% OFF!", bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null, bannerLink: null,
bannerActionText: "立即查看", bannerActionText: "立即查看",
lovartHeroTitle: "由壹点AI 让设计更简单", lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切", lovartHeroSubtitle: "下达指令,即刻呈现",
surpriseCategories: ["全部", "精选"], surpriseCategories: ["全部", "精选"],
canvasDefaultModelId: null, canvasDefaultModelId: null,
lovartComposerPrefix: "让「由壹点AI」", lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
lovartComposerPreviewTexts: [], lovartComposerPreviewTexts: [],
lovartPromptTagVisibility: {}, lovartPromptTagVisibility: {},
contactConfig: normalizeContactConfig(undefined), contactConfig: normalizeContactConfig(undefined),
@ -664,7 +664,7 @@ async function readScopedSiteSettings(keys: string[]) {
function createBrandingSettings(settings: Record<string, unknown> | undefined) { function createBrandingSettings(settings: Record<string, unknown> | undefined) {
return { return {
siteName: String(settings?.siteName || "AI Chatbot"), siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
siteLogo: settings?.siteLogo || null, siteLogo: settings?.siteLogo || null,
siteLogoDark: settings?.siteLogoDark || null, siteLogoDark: settings?.siteLogoDark || null,
favicon: settings?.favicon || null, favicon: settings?.favicon || null,
@ -822,7 +822,7 @@ async function getScopedPublicSiteSettings(
usageTimeStart: typedSettings?.usageTimeStart || "08:00", usageTimeStart: typedSettings?.usageTimeStart || "08:00",
usageTimeEnd: typedSettings?.usageTimeEnd || "20:00", usageTimeEnd: typedSettings?.usageTimeEnd || "20:00",
usageTimeMessage: usageTimeMessage:
typedSettings?.usageTimeMessage || "由壹点累了也是需要休息的", typedSettings?.usageTimeMessage || "BuYi 累了也是需要休息的",
}); });
} }
@ -953,9 +953,9 @@ async function getScopedPublicSiteSettings(
typedSettings?.lovartPromoText || typedSettings?.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: typedSettings?.lovartPromoActionText || "立即升级", lovartPromoActionText: typedSettings?.lovartPromoActionText || "立即升级",
lovartHeroTitle: typedSettings?.lovartHeroTitle || "由壹点AI 让设计更简单", lovartHeroTitle: typedSettings?.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: lovartHeroSubtitle:
typedSettings?.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切", typedSettings?.lovartHeroSubtitle || "下达指令,即刻呈现",
surpriseCategories: normalizeSurpriseCategories( surpriseCategories: normalizeSurpriseCategories(
valueByKey.get(HOME_SURPRISE_CATEGORIES_KEY) valueByKey.get(HOME_SURPRISE_CATEGORIES_KEY)
), ),
@ -1476,7 +1476,7 @@ export async function GET(request: NextRequest) {
usageTimeEnabled: false, usageTimeEnabled: false,
usageTimeStart: "08:00", usageTimeStart: "08:00",
usageTimeEnd: "20:00", usageTimeEnd: "20:00",
usageTimeMessage: "由壹点累了也是需要休息的", usageTimeMessage: "BuYi 累了也是需要休息的",
bannerEnabled: true, bannerEnabled: true,
bannerText: "限时特惠:升级会员最高立享 45% OFF!", bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null, bannerLink: null,
@ -1525,8 +1525,8 @@ export async function GET(request: NextRequest) {
canvasDetailAgentModelId: null, canvasDetailAgentModelId: null,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级", lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单", lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切", lovartHeroSubtitle: "下达指令,即刻呈现",
surpriseCategories, surpriseCategories,
aiDetailGenerationEnabled, aiDetailGenerationEnabled,
lovartComposerPrefix, lovartComposerPrefix,
@ -1546,8 +1546,8 @@ export async function GET(request: NextRequest) {
baseSettings.lovartPromoText || baseSettings.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: baseSettings.lovartPromoActionText || "立即升级", lovartPromoActionText: baseSettings.lovartPromoActionText || "立即升级",
lovartHeroTitle: baseSettings.lovartHeroTitle || "由壹点AI 让设计更简单", lovartHeroTitle: baseSettings.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切", lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "下达指令,即刻呈现",
notification, notification,
surpriseCategories, surpriseCategories,
aiDetailGenerationEnabled, aiDetailGenerationEnabled,
@ -3119,7 +3119,7 @@ export async function PUT(request: NextRequest) {
usageTimeEnabled: siteSettingsPayload.usageTimeEnabled ?? false, usageTimeEnabled: siteSettingsPayload.usageTimeEnabled ?? false,
usageTimeStart: siteSettingsPayload.usageTimeStart ?? "08:00", usageTimeStart: siteSettingsPayload.usageTimeStart ?? "08:00",
usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00", usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00",
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "由壹点累了也是需要休息的", usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "BuYi 累了也是需要休息的",
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true, bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!", bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: siteSettingsPayload.bannerLink, bannerLink: siteSettingsPayload.bannerLink,
@ -3144,8 +3144,8 @@ export async function PUT(request: NextRequest) {
siteSettingsPayload.lovartPromoText ?? siteSettingsPayload.lovartPromoText ??
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: siteSettingsPayload.lovartPromoActionText ?? "立即升级", lovartPromoActionText: siteSettingsPayload.lovartPromoActionText ?? "立即升级",
lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "由壹点AI 让设计更简单", lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "懂你的设计代理,帮你搞定一切", lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "下达指令,即刻呈现",
updatedBy: session.user.id, updatedBy: session.user.id,
}) })
.returning(); .returning();

View File

@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing"; import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
import { getSystemSetting } from "@/lib/db/queries"; import { getSystemSetting, getUserById } from "@/lib/db/queries";
import { import {
findStyleProImageModelByRef, findStyleProImageModelByRef,
getStyleProImageModelDisplayEntries, getStyleProImageModelDisplayEntries,
@ -26,10 +26,16 @@ export async function GET() {
); );
} }
const currentUser = await getUserById({ id: session.user.id });
const allowHiddenModels =
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
const [displayEntries, entries, realisticModelEntries, setting] = const [displayEntries, entries, realisticModelEntries, setting] =
await Promise.all([ await Promise.all([
getStyleProImageModelDisplayEntries({ includeHidden: false }), getStyleProImageModelDisplayEntries({
getStyleProImageModelEntries({ includeHidden: false }), includeHidden: allowHiddenModels,
}),
getStyleProImageModelEntries({ includeHidden: allowHiddenModels }),
getStyleProImageModelEntries({ includeHidden: true }), getStyleProImageModelEntries({ includeHidden: true }),
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null), getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
]); ]);

View File

@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import JSZip from "jszip";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
export const runtime = "nodejs";
const packageFiles = [
"package.json", "package-lock.json", "api-client.mjs", "cli.mjs", "mcp-server.mjs",
"yedir-canvas/SKILL.md", "yedir-canvas/agents/openai.yaml",
"yedir-canvas/references/operation-protocol.md",
];
export async function GET() {
const session = await auth();
if (!session?.user?.id || session.user.type === "guest") {
return NextResponse.json({ error: "请先登录正式账号" }, { status: 401 });
}
const zip = new JSZip();
const base = path.join(process.cwd(), "integrations", "openclaw");
try {
await Promise.all(packageFiles.map(async file => {
zip.file(`tvstar-mcp/${file}`, await readFile(path.join(base, file)));
}));
const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
return new NextResponse(archive, { headers: {
"Content-Type": "application/zip",
"Content-Disposition": 'attachment; filename="tvstar-mcp.zip"',
"Cache-Control": "private, no-store",
} });
} catch {
return NextResponse.json({ error: "插件安装包暂不可用" }, { status: 503 });
}
}

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@ -74,6 +75,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCanvasImageArchives } from "@/hooks/use-canvas-image-archives";
import useSWR from "swr"; import useSWR from "swr";
// 深色主题组件 V2 (新版布局) // 深色主题组件 V2 (新版布局)
import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2"; import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
@ -86,7 +88,9 @@ import {
CanvasAddImageDialog, CanvasAddImageDialog,
type CanvasAddImageDialogImage, type CanvasAddImageDialogImage,
} from "@/components/canvas/CanvasAddImageDialog"; } from "@/components/canvas/CanvasAddImageDialog";
import { CanvasAgentUiListener } from "@/components/canvas/chat-panel/canvas-agent-ui-listener";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasSurfaceHeader } from "@/components/canvas/CanvasSurfaceHeader";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -192,6 +196,11 @@ import {
getAgentWorkflowPageFeatureSettings, getAgentWorkflowPageFeatureSettings,
sanitizeAgentWorkflowSelections, sanitizeAgentWorkflowSelections,
} from "@/lib/agents/workflow-ui-config"; } from "@/lib/agents/workflow-ui-config";
import {
loadCanvasChatPanelModule,
preloadCanvasChatPanel,
} from "@/components/canvas/canvas-chat-panel-loader";
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import { import {
buildVideoWorkflowCanvasNode, buildVideoWorkflowCanvasNode,
@ -244,7 +253,6 @@ import type {
AgentWorkflowUiConfig, AgentWorkflowUiConfig,
} from "@/types/agent"; } from "@/types/agent";
import type { import type {
AgentSlotNodeData,
CanvasEdge, CanvasEdge,
CanvasInitialProject, CanvasInitialProject,
CanvasNode, CanvasNode,
@ -257,7 +265,7 @@ import type {
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000; const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000; const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
const CANVAS_SURFACE_BACKGROUND = "#f4f4f5"; const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
const fetchCanvasSiteSettings = async (url: string) => { const fetchCanvasSiteSettings = async (url: string) => {
const separator = url.includes("?") ? "&" : "?"; const separator = url.includes("?") ? "&" : "?";
@ -313,7 +321,7 @@ const CanvasWorkspace = dynamic(
const CanvasChatPanel = dynamic( const CanvasChatPanel = dynamic(
() => () =>
import("@/components/canvas/CanvasChatPanel").then( loadCanvasChatPanelModule().then(
(module) => module.CanvasChatPanel (module) => module.CanvasChatPanel
), ),
{ {
@ -1901,6 +1909,8 @@ export default function CanvasEditorLegacyPage({
[isVideoMode] [isVideoMode]
); );
useCanvasImageArchives(id);
// Store state // Store state
const projectTitle = useCanvasStore((state) => state.projectTitle); const projectTitle = useCanvasStore((state) => state.projectTitle);
const viewport = useCanvasStore(canvasSelectors.viewport); const viewport = useCanvasStore(canvasSelectors.viewport);
@ -2029,7 +2039,7 @@ export default function CanvasEditorLegacyPage({
); );
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
useState<string | null>(null); useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode); const [isPanelOpen, setIsPanelOpen] = useState(true);
const [floatingPanelWidth, setFloatingPanelWidth] = const [floatingPanelWidth, setFloatingPanelWidth] =
useState(FLOATING_PANEL_WIDTH); useState(FLOATING_PANEL_WIDTH);
const [ const [
@ -3136,8 +3146,7 @@ export default function CanvasEditorLegacyPage({
const updateWidthFromClientX = (clientX: number) => { const updateWidthFromClientX = (clientX: number) => {
const currentViewportWidth = window.innerWidth; const currentViewportWidth = window.innerWidth;
const nextWidth = const nextWidth = currentViewportWidth - DOCKED_PANEL_INSET - clientX;
currentViewportWidth - DOCKED_PANEL_INSET - clientX;
setFloatingPanelWidth( setFloatingPanelWidth(
clampFloatingPanelWidth(nextWidth, currentViewportWidth) clampFloatingPanelWidth(nextWidth, currentViewportWidth)
@ -3350,6 +3359,15 @@ export default function CanvasEditorLegacyPage({
}; };
}, [checkCanvasAuthSession]); }, [checkCanvasAuthSession]);
useEffect(() => {
preloadCanvasChatPanel();
preloadCanvasChatData(id);
return scheduleCanvasIdleTask(() => {
void import("@/components/canvas/CanvasFileListPanel");
}, 700);
}, [id]);
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
setAreSecondaryPanelsReady(true); setAreSecondaryPanelsReady(true);
@ -5058,59 +5076,84 @@ export default function CanvasEditorLegacyPage({
workflowInputs.brand, workflowInputs.brand,
]); ]);
const canvasSaveQueue = useRef<ReturnType<typeof createCanvasSaveQueue> | null>(null);
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const canvasPersistenceScope = useRef(id);
canvasPersistenceScope.current = id;
const persistCanvasState = useCallback( const persistCanvasState = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => { (reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
const canvasData = buildPersistedCanvasData(); if (canvasPersistenceScope.current !== id) return false;
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); setIsSaving(true);
const result = await canvasService.saveCanvasState( try {
id, const canvasData = buildPersistedCanvasData();
canvasData, const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
activeCanvasWorkspaceType, const result = await canvasService.saveCanvasState(
{ id,
baseCanvasRevision: canvasRevision, canvasData,
baseCanvasUpdatedAt: canvasUpdatedAt, activeCanvasWorkspaceType,
} {
); baseCanvasRevision: canvasRevision,
if (!result.success) { baseCanvasUpdatedAt: canvasUpdatedAt,
console.error(`[Canvas] ${reason} save failed:`, result.error);
if (result.status === 409) {
externalCanvasConflictRef.current = true;
console.warn("[Canvas] stale save revision handled without user prompt", {
reason,
canvasUpdatedAt: result.canvasUpdatedAt,
});
if (!externalCanvasConflictNotifiedRef.current) {
externalCanvasConflictNotifiedRef.current = true;
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
} }
);
if (canvasPersistenceScope.current !== id) return false;
if (!result.success) {
console.error(`[Canvas] ${reason} save failed:`, result.error);
if (result.status === 409) {
externalCanvasConflictRef.current = true;
console.warn("[Canvas] stale save revision handled without user prompt", {
reason,
canvasUpdatedAt: result.canvasUpdatedAt,
});
if (!externalCanvasConflictNotifiedRef.current) {
externalCanvasConflictNotifiedRef.current = true;
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
}
}
} else if (result.data?.updatedAt) {
externalCanvasConflictRef.current = false;
externalCanvasConflictNotifiedRef.current = false;
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
} }
} else if (result.data?.updatedAt) { return result.success;
externalCanvasConflictRef.current = false; } finally {
externalCanvasConflictNotifiedRef.current = false; if (canvasPersistenceScope.current === id) setIsSaving(false);
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
} }
return result.success; }),
},
[activeCanvasWorkspaceType, buildPersistedCanvasData, id] [activeCanvasWorkspaceType, buildPersistedCanvasData, id]
); );
const persistCanvasBeforeAgent = useCallback(() => {
// Flush the pending local edit once instead of racing its scheduled autosave.
if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null;
}
return persistCanvasState("autosave");
}, [persistCanvasState]);
// 自动保存逻辑 // 自动保存逻辑
useEffect(() => { useEffect(() => {
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return; if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return;
const saveTimer = setTimeout(async () => { const saveTimer = setTimeout(async () => {
setIsSaving(true); canvasAutosaveTimer.current = null;
if (isAgentOperationRefreshActive()) return;
try { try {
await persistCanvasState("autosave"); await persistCanvasState("autosave");
} catch (error) { } catch (error) {
console.error("Auto save failed:", error); console.error("Auto save failed:", error);
} }
setIsSaving(false);
}, 3000); // 3秒自动保存 }, 3000); // 3秒自动保存
return () => clearTimeout(saveTimer); canvasAutosaveTimer.current = saveTimer;
return () => {
clearTimeout(saveTimer);
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
};
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]); }, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
useEffect(() => { useEffect(() => {
@ -7501,7 +7544,7 @@ export default function CanvasEditorLegacyPage({
materialMimeType: image.mimeType || undefined, materialMimeType: image.mimeType || undefined,
originalHeight: dimensions.originalHeight, originalHeight: dimensions.originalHeight,
originalWidth: dimensions.originalWidth, originalWidth: dimensions.originalWidth,
source: "agent-workflow-material-search", source: image.sourceType === "upload" ? "local-upload" : "agent-workflow-material-search",
}, },
}, },
connections: [], connections: [],
@ -7513,7 +7556,7 @@ export default function CanvasEditorLegacyPage({
if (createdNodeIds.length > 0) { if (createdNodeIds.length > 0) {
selectNodes(createdNodeIds); selectNodes(createdNodeIds);
toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`); toast.success(`已添加 ${createdNodeIds.length} 张${validImages.every((image) => image.sourceType === "upload") ? "图片" : "白底图"}到画布`);
} }
}, },
[createCanvasNode, getVisibleCanvasOrigin, selectNodes] [createCanvasNode, getVisibleCanvasOrigin, selectNodes]
@ -7529,64 +7572,6 @@ export default function CanvasEditorLegacyPage({
setIsAddImageDialogOpen(true); setIsAddImageDialogOpen(true);
}, [handleAddWorkflowNode, isVideoMode]); }, [handleAddWorkflowNode, isVideoMode]);
const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => {
if (isVideoMode) return;
const slotDefaults: Record<
AgentSlotNodeData["slotType"],
{
label: string;
size: CanvasNode["size"];
aspectRatio: string;
}
> = {
image: {
label: "图片槽位",
size: { width: 320, height: 320 },
aspectRatio: "1:1",
},
video: {
label: "视频槽位",
size: { width: 360, height: 220 },
aspectRatio: "16:9",
},
"ui-screen": {
label: "UI 槽位",
size: { width: 360, height: 260 },
aspectRatio: "16:9",
},
};
const defaults = slotDefaults[slotType];
const origin = getVisibleCanvasOrigin();
createCanvasNode({
type: "agent-slot",
position: {
x: origin.x + 40,
y: origin.y + 40,
},
size: defaults.size,
data: {
slotType,
label: defaults.label,
status: "empty",
aspectRatio: defaults.aspectRatio,
fillPolicy: "replace-empty",
acceptedSources: ["text", "image", "selection", "annotation"],
filledNodeIds: [],
metadata: {
createdBy: "toolbar",
createdSlotType: slotType,
createdAt: new Date().toISOString(),
},
},
connections: [],
});
}, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]);
const handleAddAgentImageSlotNode = useCallback(() => {
handleAddAgentSlotNode("image");
}, [handleAddAgentSlotNode]);
const handleAddVideoNode = useCallback(() => { const handleAddVideoNode = useCallback(() => {
if (isVideoMode) { if (isVideoMode) {
handleAddWorkflowNode("video-input"); handleAddWorkflowNode("video-input");
@ -9360,6 +9345,7 @@ export default function CanvasEditorLegacyPage({
// 键盘快捷键 // 键盘快捷键
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (useCanvasStore.getState().canvasImageSelectionSession) return;
// 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素) // 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素)
if (isCanvasTextEntryTarget(e.target)) { if (isCanvasTextEntryTarget(e.target)) {
return; return;
@ -10297,7 +10283,11 @@ export default function CanvasEditorLegacyPage({
return ( return (
<TooltipProvider> <TooltipProvider>
<div <div
className="relative h-dvh w-full overflow-hidden overscroll-none" data-canvas-panel-open={isPanelOpen ? "true" : "false"}
className={cn(
"h-dvh w-full overflow-hidden overscroll-none",
isVideoMode ? "relative" : "lovart-canvas-surface fixed inset-0"
)}
style={ style={
{ {
"--canvas-surface-background": canvasSurfaceBackground, "--canvas-surface-background": canvasSurfaceBackground,
@ -10305,10 +10295,12 @@ export default function CanvasEditorLegacyPage({
} as React.CSSProperties } as React.CSSProperties
} }
> >
<CanvasAgentUiListener projectId={id} workspace="canvas" />
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */} {/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
<div <div
className="absolute inset-0 z-0" className="absolute inset-0 z-0"
data-canvas-stage="true" data-canvas-stage="true"
style={isVideoMode ? undefined : { right: panelOcclusionRight, top: 40 }}
key={`workspace-${id}`} key={`workspace-${id}`}
> >
<CanvasWorkspace <CanvasWorkspace
@ -10319,9 +10311,10 @@ export default function CanvasEditorLegacyPage({
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成" ? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
: '点击顶部"添加"按钮开始创作' : '点击顶部"添加"按钮开始创作'
} }
occlusionRight={panelOcclusionRight} occlusionRight={0}
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu} onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
showEmptyState={!isVideoMode} showEmptyState={false}
showZoomControls={isVideoMode}
workspaceLabel={workspaceLabel} workspaceLabel={workspaceLabel}
workspaceVariant={isVideoMode ? "video" : "canvas"} workspaceVariant={isVideoMode ? "video" : "canvas"}
layerSplitEnabled={isLayerSplitEnabled} layerSplitEnabled={isLayerSplitEnabled}
@ -11010,14 +11003,27 @@ export default function CanvasEditorLegacyPage({
</div> </div>
) : null} ) : null}
{brandEntry} {isVideoMode ? brandEntry : (
<CanvasSurfaceHeader
isSaving={isSaving}
onAddImage={handleAddImageNode}
onHome={goHome}
onOpenFiles={() => {
setIsPanelOpen(true);
setPanelMode("fileList");
}}
onSave={() => void handleSave()}
projectTitle={projectTitle}
rightInset={panelOcclusionRight}
/>
)}
{agentDisabledNotice} {agentDisabledNotice}
{/* 2. Floating Actions (Top Right) */} {/* 2. Floating Actions (Top Right) */}
{!isVideoMode && !isPanelOpen && ( {!isVideoMode && !isPanelOpen && (
<div <div
className="absolute z-20" className="absolute z-20"
style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }} style={{ top: 46, right: 12 }}
> >
<Button <Button
className={cn( className={cn(
@ -11049,7 +11055,7 @@ export default function CanvasEditorLegacyPage({
)} )}
data-canvas-right-panel="true" data-canvas-right-panel="true"
style={{ style={{
top: DOCKED_PANEL_INSET, top: 0,
right: DOCKED_PANEL_INSET, right: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET, bottom: DOCKED_PANEL_INSET,
width: resolvedPanelWidth, width: resolvedPanelWidth,
@ -11080,7 +11086,7 @@ export default function CanvasEditorLegacyPage({
/> />
</div> </div>
) : null} ) : null}
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950"> <div className="flex h-full w-full flex-col overflow-hidden border-l border-[#ededed] bg-white dark:border-zinc-800 dark:bg-zinc-950">
{!areSecondaryPanelsReady ? ( {!areSecondaryPanelsReady ? (
<CanvasLazyPanelLoader label="正在准备侧边面板..." /> <CanvasLazyPanelLoader label="正在准备侧边面板..." />
) : panelMode === "chat" ? ( ) : panelMode === "chat" ? (
@ -11097,6 +11103,7 @@ export default function CanvasEditorLegacyPage({
onClose={() => setIsPanelOpen(false)} onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode("fileList")} onOpenFileList={() => setPanelMode("fileList")}
onRequireLogin={openLoginDialog} onRequireLogin={openLoginDialog}
onBeforeAgentRequest={persistCanvasBeforeAgent}
selectedAgentPresetId={ selectedAgentPresetId={
isCanvasAgentModeEnabled ? selectedAgentPresetId : null isCanvasAgentModeEnabled ? selectedAgentPresetId : null
} }
@ -11186,11 +11193,11 @@ export default function CanvasEditorLegacyPage({
className={cn( className={cn(
isVideoMode isVideoMode
? VIDEO_CANVAS_TOOLBAR_CLASS ? VIDEO_CANVAS_TOOLBAR_CLASS
: "border bg-white shadow-2xl dark:bg-zinc-950", : "border border-[#e9e9e9] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.035)] dark:border-white/10 dark:bg-zinc-950",
!isVideoMode && !isVideoMode &&
(shouldDockFloatingToolbarToSide (shouldDockFloatingToolbarToSide
? "flex flex-col items-center gap-1 rounded-[24px] px-1.5 py-2.5" ? "flex flex-col items-center gap-1 rounded-[24px] px-1.5 py-2.5"
: "flex items-center gap-1 rounded-[24px] px-2.5 py-1.5") : "flex items-center gap-1 rounded-[14px] px-1.5 py-1.5")
)} )}
style={ style={
shouldDockFloatingToolbarToSide shouldDockFloatingToolbarToSide
@ -11208,7 +11215,7 @@ export default function CanvasEditorLegacyPage({
tool === "select" && tool === "select" &&
(isVideoMode (isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS ? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-primary text-primary-foreground hover:bg-primary/90") : "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
)} )}
onClick={() => setTool("select")} onClick={() => setTool("select")}
size="icon" size="icon"
@ -11218,6 +11225,8 @@ export default function CanvasEditorLegacyPage({
active={tool === "select"} active={tool === "select"}
className="h-4 w-4" className="h-4 w-4"
fallback={MousePointer} fallback={MousePointer}
referenceName={isVideoMode ? undefined : "cursor"}
referenceSize={20}
item={getCanvasToolbarItem( item={getCanvasToolbarItem(
canvasToolbarConfig, canvasToolbarConfig,
"selectTool" "selectTool"
@ -11231,63 +11240,6 @@ export default function CanvasEditorLegacyPage({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{!isVideoMode &&
isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"addAgentImageSlot"
) ? (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button className="h-9 w-9 rounded-xl" size="icon" variant="ghost">
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ImagePlus}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"addAgentImageSlot"
)}
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side={floatingToolbarTooltipSide}>
{
getCanvasToolbarItem(
canvasToolbarConfig,
"addAgentImageSlot"
).label
}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="start"
side={floatingToolbarDropdownSide}
>
<DropdownMenuItem
onSelect={() => handleAddAgentImageSlotNode()}
>
<ImagePlus className="h-4 w-4" />
图片槽位
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleAddAgentSlotNode("video")}
>
<Video className="h-4 w-4" />
视频槽位
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleAddAgentSlotNode("ui-screen")}
>
<MonitorSmartphone className="h-4 w-4" />
UI 槽位
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -11298,7 +11250,7 @@ export default function CanvasEditorLegacyPage({
tool === "pan" && tool === "pan" &&
(isVideoMode (isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS ? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-primary text-primary-foreground hover:bg-primary/90") : "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
)} )}
onClick={() => setTool("pan")} onClick={() => setTool("pan")}
size="icon" size="icon"
@ -11308,6 +11260,8 @@ export default function CanvasEditorLegacyPage({
active={tool === "pan"} active={tool === "pan"}
className="h-4 w-4" className="h-4 w-4"
fallback={Hand} fallback={Hand}
referenceName={isVideoMode ? undefined : "pan"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")} item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")}
/> />
</Button> </Button>
@ -11428,7 +11382,7 @@ export default function CanvasEditorLegacyPage({
tool === "draw" && tool === "draw" &&
(isVideoMode (isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS ? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-primary text-primary-foreground hover:bg-primary/90") : "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
)} )}
onClick={() => setTool("draw")} onClick={() => setTool("draw")}
size="icon" size="icon"
@ -11469,7 +11423,7 @@ export default function CanvasEditorLegacyPage({
isAgentMode && isAgentMode &&
(isVideoMode (isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS ? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-primary text-primary-foreground hover:bg-primary/90") : "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
)} )}
onClick={handleStyleModeToolbarClick} onClick={handleStyleModeToolbarClick}
size="icon" size="icon"
@ -11485,6 +11439,14 @@ export default function CanvasEditorLegacyPage({
fallback={ fallback={
shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2 shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2
} }
referenceName={
isVideoMode
? undefined
: shouldUseStyleModeButtonForStylePro
? "style-controls"
: "creativity"
}
referenceSize={20}
item={getCanvasToolbarItem( item={getCanvasToolbarItem(
canvasToolbarConfig, canvasToolbarConfig,
"styleMode" "styleMode"
@ -11514,6 +11476,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon <CanvasToolbarIcon
className="h-4 w-4" className="h-4 w-4"
fallback={Sparkles} fallback={Sparkles}
referenceName={isVideoMode ? undefined : "style-controls"}
referenceSize={20}
item={getCanvasToolbarItem( item={getCanvasToolbarItem(
canvasToolbarConfig, canvasToolbarConfig,
"stylePro" "stylePro"
@ -11543,6 +11507,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon <CanvasToolbarIcon
className="h-4 w-4" className="h-4 w-4"
fallback={Layers} fallback={Layers}
referenceName={isVideoMode ? undefined : "layers"}
referenceSize={20}
item={getCanvasToolbarItem( item={getCanvasToolbarItem(
canvasToolbarConfig, canvasToolbarConfig,
"batchProcessing" "batchProcessing"
@ -11574,6 +11540,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon <CanvasToolbarIcon
className="h-4 w-4" className="h-4 w-4"
fallback={ImagePlus} fallback={ImagePlus}
referenceName={isVideoMode ? undefined : "image"}
referenceSize={20}
item={getCanvasToolbarItem( item={getCanvasToolbarItem(
canvasToolbarConfig, canvasToolbarConfig,
isVideoMode ? "addImageInput" : "addImage" isVideoMode ? "addImageInput" : "addImage"
@ -11606,6 +11574,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon <CanvasToolbarIcon
className="h-4 w-4" className="h-4 w-4"
fallback={Type} fallback={Type}
referenceName={isVideoMode ? undefined : "text"}
referenceSize={20}
item={getCanvasToolbarItem( item={getCanvasToolbarItem(
canvasToolbarConfig, canvasToolbarConfig,
isVideoMode ? "addScriptNode" : "addText" isVideoMode ? "addScriptNode" : "addText"
@ -11763,6 +11733,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon <CanvasToolbarIcon
className="h-4 w-4" className="h-4 w-4"
fallback={PenTool} fallback={PenTool}
referenceName={isVideoMode ? undefined : "draw"}
referenceSize={20}
item={resolveCanvasToolbarItemWithFallback( item={resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig, canvasToolbarConfig,
"realtimeCanvas", "realtimeCanvas",
@ -11810,6 +11782,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon <CanvasToolbarIcon
className="h-4 w-4" className="h-4 w-4"
fallback={Sparkles} fallback={Sparkles}
referenceName={isVideoMode ? undefined : "image-enhance"}
referenceSize={20}
item={resolveCanvasToolbarItemWithFallback( item={resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig, canvasToolbarConfig,
"enhanceCanvas", "enhanceCanvas",
@ -11863,6 +11837,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon <CanvasToolbarIcon
className="h-4 w-4" className="h-4 w-4"
fallback={Box} fallback={Box}
referenceName={isVideoMode ? undefined : "model-auto"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")} item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
/> />
</Button> </Button>
@ -11952,6 +11928,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon <CanvasToolbarIcon
className="h-4 w-4" className="h-4 w-4"
fallback={Plus} fallback={Plus}
referenceName={isVideoMode ? undefined : "plus"}
referenceSize={20}
item={getCanvasToolbarItem( item={getCanvasToolbarItem(
canvasToolbarConfig, canvasToolbarConfig,
isVideoMode ? "newVideoCanvas" : "newCanvas" isVideoMode ? "newVideoCanvas" : "newCanvas"
@ -11979,12 +11957,15 @@ export default function CanvasEditorLegacyPage({
type="file" type="file"
/> />
{/* 6. Bottom Left Compact Controls */} {/* Compact zoom and history controls. */}
<div <div
data-canvas-compact-controls="true"
className="absolute z-20" className="absolute z-20"
style={{ style={{
bottom: `${FLOATING_BOTTOM_INSET + 4}px`, bottom: `${FLOATING_BOTTOM_INSET + 4}px`,
left: `${compactControlsLeft + 8}px`, ...(isVideoMode
? { left: `${compactControlsLeft + 8}px` }
: { right: `${panelOcclusionRight + 16}px` }),
}} }}
> >
<div <div

View File

@ -365,6 +365,62 @@ function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) {
); );
} }
function getTaskPreviewCandidates(task?: TaskRecord | null) {
if (!task) return [];
const candidates = [
task.previewImageUrl,
task.assetPayload?.thumbnailUrl,
task.assetPayload?.panoUrl,
task.assetPayload?.sourcePreviewImageUrl,
task.assetPayload?.sourceThumbnailUrl,
task.assetPayload?.sourcePanoUrl,
];
const seen = new Set<string>();
return candidates
.map((value) => String(value || "").trim())
.filter((value) => {
if (!value || seen.has(value)) return false;
seen.add(value);
return true;
});
}
function isPersistableTaskPreviewUrl(value?: string | null) {
return /^https?:\/\//i.test(String(value || "").trim());
}
function TaskAssetPreview({ task }: { task: TaskRecord }) {
const candidates = getTaskPreviewCandidates(task);
const signature = candidates.join("|");
const [candidateIndex, setCandidateIndex] = useState(0);
useEffect(() => {
setCandidateIndex(0);
}, [signature]);
const previewUrl = candidates[candidateIndex] || null;
if (!previewUrl) {
return (
<div className="flex h-full w-full items-center justify-center text-zinc-700">
{task.status === "queued" || task.status === "running" ? (
<Loader2 className="h-7 w-7 animate-spin text-yellow-400/70" />
) : (
<Box className="h-8 w-8" />
)}
</div>
);
}
return (
<img
alt="3D 资产预览"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
onError={() => setCandidateIndex((index) => index + 1)}
src={previewUrl}
/>
);
}
function canImportTaskToCanvas(task?: TaskRecord | null) { function canImportTaskToCanvas(task?: TaskRecord | null) {
if (!task || task.status !== "success") return false; if (!task || task.status !== "success") return false;
if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task)); if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task));
@ -850,9 +906,21 @@ export default function CanvasModel3DPage() {
); );
const pollAttemptsRef = useRef(0); const pollAttemptsRef = useRef(0);
const pollErrorCountRef = useRef(0);
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]); const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]); const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
const MAX_RETRYABLE_TASK_POLL_FAILURES = 12;
const isRetryableTaskPollFailure = (message: string) => {
const normalized = String(message || "").toLowerCase();
return (
/http[^0-9]*5\d\d/.test(normalized) ||
normalized.includes("超时") ||
normalized.includes("timeout")
);
};
// Fetch site settings for Logo // Fetch site settings for Logo
const { data: siteSettingsData } = useSWR( const { data: siteSettingsData } = useSWR(
"/api/site-settings?scope=branding", "/api/site-settings?scope=branding",
@ -1114,7 +1182,9 @@ export default function CanvasModel3DPage() {
modelVersion: task.modelVersion, modelVersion: task.modelVersion,
progress: task.progress ?? null, progress: task.progress ?? null,
modelUrl: task.modelUrl ?? null, modelUrl: task.modelUrl ?? null,
previewImageUrl: task.previewImageUrl ?? null, previewImageUrl: isPersistableTaskPreviewUrl(task.previewImageUrl)
? task.previewImageUrl
: null,
error: task.error ?? null, error: task.error ?? null,
sourceTaskId: task.sourceTaskId ?? null, sourceTaskId: task.sourceTaskId ?? null,
exportFormat: task.exportFormat ?? null, exportFormat: task.exportFormat ?? null,
@ -1434,6 +1504,33 @@ export default function CanvasModel3DPage() {
}); });
if (!result.success || !result.data) { if (!result.success || !result.data) {
const failMessage = result.error || "任务查询失败"; const failMessage = result.error || "任务查询失败";
if (isRetryableTaskPollFailure(failMessage)) {
pollErrorCountRef.current += 1;
if (pollErrorCountRef.current === 1) {
console.warn("[Model3DPage] 任务查询临时异常,继续重试", {
taskId: currentTask.taskId,
provider: currentTask.provider,
error: failMessage,
attempt: pollAttemptsRef.current,
});
toast.info(`任务查询异常,正在重试...`);
}
if (pollErrorCountRef.current >= MAX_RETRYABLE_TASK_POLL_FAILURES) {
const failedRecord: TaskRecord = {
...currentTask,
status: "failed",
error: `${failMessage}(重试仍异常)`,
};
setCurrentTask(failedRecord);
upsertTaskHistory(failedRecord);
}
return;
}
pollErrorCountRef.current = 0;
const failedRecord: TaskRecord = { const failedRecord: TaskRecord = {
...currentTask, ...currentTask,
status: "failed", status: "failed",
@ -1479,6 +1576,8 @@ export default function CanvasModel3DPage() {
error: error:
status === "failed" ? result.data.errorMessage || "任务失败" : null, status === "failed" ? result.data.errorMessage || "任务失败" : null,
}; };
pollErrorCountRef.current = 0;
if (taskRecordChanged(currentTask, nextRecord)) { if (taskRecordChanged(currentTask, nextRecord)) {
setCurrentTask(nextRecord); setCurrentTask(nextRecord);
upsertTaskHistory(nextRecord); upsertTaskHistory(nextRecord);
@ -1652,6 +1751,7 @@ export default function CanvasModel3DPage() {
sourceTaskId?: string | null; sourceTaskId?: string | null;
exportFormat?: string | null; exportFormat?: string | null;
assetPayload?: Model3DTaskAssetPayload | null; assetPayload?: Model3DTaskAssetPayload | null;
previewImageUrl?: string | null;
}) => { }) => {
const normalizedPayload = const normalizedPayload =
params.provider === "tripo-3d" && params.provider === "tripo-3d" &&
@ -1706,6 +1806,7 @@ export default function CanvasModel3DPage() {
createdAt: Date.now(), createdAt: Date.now(),
sourceTaskId: params.sourceTaskId || null, sourceTaskId: params.sourceTaskId || null,
exportFormat: params.exportFormat || null, exportFormat: params.exportFormat || null,
previewImageUrl: params.previewImageUrl || null,
assetPayload: assetPayload:
params.provider === "worldlabs-3d" params.provider === "worldlabs-3d"
? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null ? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null
@ -1821,6 +1922,7 @@ export default function CanvasModel3DPage() {
providerId: uploadResult.data.providerId || null, providerId: uploadResult.data.providerId || null,
providerName: uploadResult.data.providerName || null, providerName: uploadResult.data.providerName || null,
}, },
previewImageUrl: referencePreviewUrl,
}); });
toast.success("已提交图生3D任务"); toast.success("已提交图生3D任务");
return; return;
@ -1855,6 +1957,8 @@ export default function CanvasModel3DPage() {
providerId: uploadResult.data.providerId || null, providerId: uploadResult.data.providerId || null,
providerName: uploadResult.data.providerName || null, providerName: uploadResult.data.providerName || null,
}, },
previewImageUrl:
batchImageSlots.find((slot) => slot.file === file)?.previewUrl || null,
}); });
successCount += 1; successCount += 1;
} }
@ -1910,6 +2014,7 @@ export default function CanvasModel3DPage() {
providerId: boundTripoProviderId, providerId: boundTripoProviderId,
providerName: boundTripoProviderName, providerName: boundTripoProviderName,
}, },
previewImageUrl: multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl || null,
}); });
toast.success("已提交多视图3D任务"); toast.success("已提交多视图3D任务");
} catch (error) { } catch (error) {
@ -1992,6 +2097,7 @@ export default function CanvasModel3DPage() {
mode: "world-image", mode: "world-image",
promptText: normalizedPrompt || "图生世界", promptText: normalizedPrompt || "图生世界",
modelLabel: worldModel, modelLabel: worldModel,
previewImageUrl: sourceUrl || worldImagePreviewUrl,
}); });
toast.success("已提交图生世界任务"); toast.success("已提交图生世界任务");
return; return;
@ -2076,6 +2182,10 @@ export default function CanvasModel3DPage() {
mode: "world-multi-image", mode: "world-multi-image",
promptText: normalizedPrompt || "多图世界", promptText: normalizedPrompt || "多图世界",
modelLabel: worldModel, modelLabel: worldModel,
previewImageUrl:
mediaAssets.find((item) => "sourceUrl" in item)?.sourceUrl ||
selectedWorldImages.find((slot) => slot.previewUrl)?.previewUrl ||
null,
}); });
toast.success("已提交多图世界任务"); toast.success("已提交多图世界任务");
} catch (error) { } catch (error) {
@ -2798,9 +2908,9 @@ export default function CanvasModel3DPage() {
]); ]);
useEffect(() => { useEffect(() => {
if (workspaceMode !== "world" || worldViewportMode !== "splat") { setWorldFirstPerson(
setWorldFirstPerson(false); workspaceMode === "world" && worldViewportMode === "splat"
} );
}, [workspaceMode, worldViewportMode]); }, [workspaceMode, worldViewportMode]);
return ( return (
@ -4008,6 +4118,7 @@ export default function CanvasModel3DPage() {
previewImageUrl={currentPreviewImage} previewImageUrl={currentPreviewImage}
resetViewToken={viewerResetToken} resetViewToken={viewerResetToken}
semanticsMetadata={currentWorldAsset?.semanticsMetadata} semanticsMetadata={currentWorldAsset?.semanticsMetadata}
showInfoOverlay={false}
showMovementControls={ showMovementControls={
worldViewportMode === "splat" || worldViewportMode === "mesh" worldViewportMode === "splat" || worldViewportMode === "mesh"
} }
@ -4176,8 +4287,6 @@ export default function CanvasModel3DPage() {
const canImportWorld = const canImportWorld =
task.provider === "worldlabs-3d" && canImportTaskToCanvas(task); task.provider === "worldlabs-3d" && canImportTaskToCanvas(task);
const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId); const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId);
const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl;
return ( return (
<div <div
className={cn( className={cn(
@ -4194,17 +4303,7 @@ export default function CanvasModel3DPage() {
onClick={() => handleSelectTask(task)} onClick={() => handleSelectTask(task)}
type="button" type="button"
> >
{previewUrl ? ( <TaskAssetPreview task={task} />
<img
alt="preview"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
src={previewUrl}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-700">
<Box className="h-8 w-8" />
</div>
)}
</button> </button>
<div className="border-white/10 border-t bg-zinc-950/95 px-1.5 py-2"> <div className="border-white/10 border-t bg-zinc-950/95 px-1.5 py-2">
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">

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

@ -3,6 +3,9 @@
/* include utility classes in streamdown */ /* include utility classes in streamdown */
@source '../node_modules/streamdown/dist/index.js'; @source '../node_modules/streamdown/dist/index.js';
@source '../components/canvas/**/*.tsx';
@source not '../.next*/**';
@source not '../.qa/**';
@source not '../services/pascal-editor'; @source not '../services/pascal-editor';
/* custom variant for setting dark mode programmatically */ /* custom variant for setting dark mode programmatically */
@ -405,6 +408,15 @@
} }
/* Dense, shared system-settings language. Scoped so canvas tools keep their own scale. */ /* Dense, shared system-settings language. Scoped so canvas tools keep their own scale. */
.video-canvas-workspace {
/* Match the compact typography used by the reference canvas. */
--canvas-font-caption: 12px;
--canvas-font-body: 13px;
--canvas-font-heading: 14px;
font-size: var(--canvas-font-body);
line-height: 1.45;
}
.settings-workspace { .settings-workspace {
font-size: 13px; font-size: 13px;
line-height: 1.45; line-height: 1.45;
@ -507,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

@ -94,18 +94,33 @@ 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;
} catch (e) {
return false;
}
}
var RECOVERY_KEY = 'chunk-reload-attempted-v2';
var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0; var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0;
var recoveryNoticeShown = false;
function isFlowBoardEditing() {
var path = window.location.pathname;
return path === '/flow-board' || path.indexOf('/flow-board/') === 0;
}
function showManualRecoveryNotice() {
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;
var normalized = String(message).toLowerCase(); var normalized = String(message).toLowerCase();
@ -117,28 +132,33 @@ 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);
} }
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 +177,6 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
} }
}); });
// 页面稳定一段时间后清除标记,避免旧资源异常无限刷新。
window.addEventListener('load', function() {
setTimeout(function() {
safeRemoveSessionStorage(RECOVERY_KEY);
}, 120000);
});
})();`; })();`;
export default function RootLayout({ export default function RootLayout({

View File

@ -0,0 +1,83 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
type KeyItem = { id: string; name: string; tokenPrefix: string; scopes: string[]; revokedAt: string | null; expiresAt: string | null };
export default function TVstarPluginPage() {
const [keys, setKeys] = useState<KeyItem[]>([]);
const [createdToken, setCreatedToken] = useState("");
const [busy, setBusy] = useState(false);
const [origin, setOrigin] = useState("https://ai.yydd.ai");
useEffect(() => { setOrigin(window.location.origin); }, []);
const loadKeys = useCallback(async () => {
const response = await fetch("/api/user/access-keys", { cache: "no-store" });
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || "读取密钥失败");
setKeys(Array.isArray(data.data) ? data.data : []);
}, []);
useEffect(() => { void loadKeys().catch(error => toast.error(error.message)); }, [loadKeys]);
const createKey = async () => {
setBusy(true);
try {
const response = await fetch("/api/user/access-keys", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "TVstar MCP", scopes: ["canvas:read", "canvas:write"], expiresInDays: 90 }) });
const data = await response.json();
if (!response.ok || !data.success || !data.data?.token) throw new Error(data.error || "创建密钥失败");
setCreatedToken(data.data.token);
await loadKeys();
toast.success("密钥已创建;请立即复制,关闭后不会再次显示");
} catch (error) { toast.error(error instanceof Error ? error.message : "创建密钥失败"); }
finally { setBusy(false); }
};
const revokeKey = async (id: string) => {
if (!window.confirm("撤销后使用此密钥的 Agent 将失去画布访问权限,确定继续吗?")) return;
setBusy(true);
try {
const response = await fetch(`/api/user/access-keys/${id}`, { method: "DELETE" });
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || "撤销失败");
setCreatedToken("");
await loadKeys();
toast.success("密钥已撤销");
} catch (error) { toast.error(error instanceof Error ? error.message : "撤销失败"); }
finally { setBusy(false); }
};
const example = JSON.stringify({ mcpServers: { tvstar: { command: "node", args: ["/替换为解压目录/tvstar-mcp/mcp-server.mjs"],
env: { YEDIR_BASE_URL: origin, YEDIR_ACCESS_TOKEN: "粘贴新创建的 Access Key" } } } }, null, 2);
return <main className="min-h-screen bg-[#151515] px-4 py-10 text-white">
<div className="mx-auto max-w-4xl">
<p className="text-sm text-cyan-300">TVstar Plugin</p>
<h1 className="mt-2 text-3xl font-semibold">让 Agent 操作视频画布</h1>
<p className="mt-3 text-sm leading-7 text-white/60">当前插件提供画布列表、读取和有版本保护的节点操作。视频生成与合成仍由 TVstar 画布内的任务系统执行,MCP 暂未提供直接发起生成的工具。</p>
<div className="mt-8 grid gap-4 md:grid-cols-2">
<section className="rounded-2xl border border-white/10 bg-[#222] p-5">
<h2 className="font-medium">1. 下载并安装</h2>
<p className="mt-2 text-sm text-white/50">需要 Node.js 20 或更新版本。解压后在 tvstar-mcp 目录运行:</p>
<a className="mt-4 inline-block rounded-lg bg-white px-4 py-2 text-sm font-medium text-black" href="/api/tvstar/plugin/download">下载 TVstar MCP 安装包</a>
<pre className="mt-4 overflow-x-auto rounded-lg bg-black/40 p-3 text-xs">cd tvstar-mcp{"\n"}npm ci</pre>
</section>
<section className="rounded-2xl border border-white/10 bg-[#222] p-5">
<h2 className="font-medium">2. 创建画布 Access Key</h2>
<p className="mt-2 text-sm text-white/50">读取和修改画布需要对应权限。密钥只在创建时显示一次。</p>
<button className="mt-4 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black disabled:opacity-50" disabled={busy} onClick={() => void createKey()}>{busy ? "处理中…" : "创建 Access Key"}</button>
{createdToken ? <div className="mt-3 break-all rounded-lg border border-amber-300/30 bg-amber-300/5 p-3 text-xs text-amber-100"><p>{createdToken}</p><button className="mt-2 underline" onClick={() => void navigator.clipboard.writeText(createdToken).then(() => toast.success("已复制"))}>复制密钥</button></div> : null}
</section>
</div>
<section className="mt-4 rounded-2xl border border-white/10 bg-[#222] p-5">
<h2 className="font-medium">3. 配置支持 STDIO MCP 的 Agent</h2>
<p className="mt-2 text-sm text-white/50">将安装包的绝对路径和密钥填入客户端配置。不同 Agent 的配置文件位置和外层字段可能不同,请以所用客户端的说明为准。</p>
<pre className="mt-4 overflow-x-auto rounded-lg bg-black/40 p-4 text-xs leading-6 text-cyan-100">{example}</pre>
<button className="mt-3 text-sm text-cyan-300 underline" onClick={() => void navigator.clipboard.writeText(example).then(() => toast.success("配置示例已复制"))}>复制配置示例</button>
</section>
<section className="mt-4 rounded-2xl border border-white/10 bg-[#222] p-5">
<h2 className="font-medium">已创建的 Access Key</h2>
<div className="mt-3 space-y-2">{keys.length ? keys.map(key => <div className="flex items-center justify-between gap-4 rounded-lg bg-black/20 p-3 text-sm" key={key.id}><span className="min-w-0 truncate">{key.name} · {key.tokenPrefix} · {key.revokedAt ? "已撤销" : "有效"}</span>{!key.revokedAt ? <button className="shrink-0 text-rose-300 disabled:opacity-40" disabled={busy} onClick={() => void revokeKey(key.id)}>撤销</button> : null}</div>) : <p className="text-sm text-white/40">暂无密钥</p>}</div>
</section>
</div>
</main>;
}

View File

@ -0,0 +1,41 @@
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { tvstarSession } from "@/lib/db/schema";
export const dynamic = "force-dynamic";
type SharedSnapshot = {
title: string;
goal: string;
createdAt: number;
messages: { role: "user" | "assistant"; content: string; createdAt: number }[];
plan: { summary: string; deliverables: string[] } | null;
shots: { shot: string; duration: string; sceneDescription: string }[];
};
export default async function TVstarSharedConversation({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
if (!/^[A-Za-z0-9_-]{32}$/.test(token || "")) notFound();
const row = (await db.select({ snapshot: tvstarSession.sharedSnapshot }).from(tvstarSession)
.where(eq(tvstarSession.shareToken, token)).limit(1))[0];
if (!row?.snapshot) notFound();
const shared = row.snapshot as SharedSnapshot;
return <main className="min-h-screen bg-[#141414] px-4 py-12 text-white">
<article className="mx-auto max-w-2xl rounded-2xl border border-white/10 bg-[#1d1d1d] p-6 shadow-2xl">
<p className="text-xs tracking-widest text-cyan-300">TVstar · 只读分享</p>
<h1 className="mt-3 text-2xl font-semibold">{shared.title}</h1>
<p className="mt-2 text-sm text-white/50">{new Date(shared.createdAt).toLocaleString("zh-CN")}</p>
<p className="mt-6 whitespace-pre-wrap border-l-2 border-cyan-400 pl-4 text-sm text-white/70">{shared.goal}</p>
<section aria-label="分享的对话" className="mt-8 space-y-3">
{(shared.messages || []).map((message, index) => <div className={`rounded-xl p-4 text-sm leading-6 ${message.role === "user" ? "ml-8 bg-[#303846]" : "mr-8 bg-[#282828]"}`} key={`${message.createdAt}-${index}`}>
<p className="mb-1 text-xs text-white/40">{message.role === "user" ? "用户" : "TVstar"}</p>
<p className="whitespace-pre-wrap">{message.content}</p>
</div>)}
</section>
{shared.plan ? <section className="mt-8 border-t border-white/10 pt-5"><h2 className="text-lg">创作规划</h2><p className="mt-2 whitespace-pre-wrap text-sm text-white/70">{shared.plan.summary}</p></section> : null}
{shared.shots?.length ? <section className="mt-8 border-t border-white/10 pt-5"><h2 className="text-lg">分镜</h2><ol className="mt-3 space-y-3">{shared.shots.map((shot, index) => <li className="rounded-lg bg-white/5 p-3 text-sm" key={`${shot.shot}-${index}`}><strong>{shot.shot} · {shot.duration}</strong><p className="mt-1 whitespace-pre-wrap text-white/65">{shot.sceneDescription}</p></li>)}</ol></section> : null}
<p className="mt-8 border-t border-white/10 pt-5 text-xs text-white/40">此页面是分享时的对话快照,无法编辑画布;对话正文中的文字和链接会原样显示。</p>
</article>
</main>;
}

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,315 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { LayoutTemplate, Loader2, Upload } from "lucide-react";
import { toast } from "sonner";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { imageGenerationService } from "@/services/canvas";
const POLL_INTERVAL_MS = 2500;
const POLL_LIMIT = 240;
export type AgentAiTemplateResult = {
imageUrl: string;
sourceImageUrl: string;
referenceImageUrl: string;
};
type AgentAiTemplateDialogProps = {
open: boolean;
sourceImageUrl?: string | null;
model?: string | null;
imageSize?: string | null;
projectId: string;
onOpenChange: (open: boolean) => void;
onProcessingChange?: (processing: boolean, sourceImageUrl: string) => void;
onResultCreated: (result: AgentAiTemplateResult) => void | Promise<void>;
};
function isHttpImageUrl(value?: string | null) {
return /^https?:\/\//i.test(String(value || "").trim());
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function pollAiTemplateTask(taskId: string) {
for (let attempt = 0; attempt < POLL_LIMIT; attempt += 1) {
await sleep(POLL_INTERVAL_MS);
const taskResult = await imageGenerationService.getImageTask(taskId);
const task = taskResult.data?.task;
if (!taskResult.success || !task) {
continue;
}
const status = String(task.status || "").toLowerCase();
if (status === "succeeded" || status === "completed") {
const image = task.result?.images?.[0];
const imageUrl = String(image?.originalUrl || image?.thumbnailUrl || "").trim();
if (!isHttpImageUrl(imageUrl)) {
throw new Error("AI套模版已完成但未返回结果图片");
}
return imageUrl;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || "AI套模版生成失败");
}
}
throw new Error("AI套模版任务等待超时,请稍后在结果区查看");
}
export function AgentAiTemplateDialog({
open,
sourceImageUrl,
model,
imageSize,
projectId,
onOpenChange,
onProcessingChange,
onResultCreated,
}: AgentAiTemplateDialogProps) {
const referenceUploadId = useId();
const [referenceImageUrl, setReferenceImageUrl] = useState("");
const [supplementalPrompt, setSupplementalPrompt] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const normalizedSourceUrl = String(sourceImageUrl || "").trim();
const onProcessingChangeRef = useRef(onProcessingChange);
const onResultCreatedRef = useRef(onResultCreated);
useEffect(() => {
onProcessingChangeRef.current = onProcessingChange;
onResultCreatedRef.current = onResultCreated;
}, [onProcessingChange, onResultCreated]);
useEffect(() => {
if (!open || isSubmitting) return;
setReferenceImageUrl("");
setSupplementalPrompt("");
setIsUploading(false);
}, [isSubmitting, open, normalizedSourceUrl]);
const handleReferenceUpload = async (file?: File) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const payload = await response.json().catch(() => null);
const uploadedUrl = String(payload?.url || "").trim();
if (!response.ok || !isHttpImageUrl(uploadedUrl)) {
throw new Error(payload?.error || "参考图上传失败");
}
setReferenceImageUrl(uploadedUrl);
toast.success("版式参考图已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图上传失败");
} finally {
setIsUploading(false);
}
};
const handleSubmit = async () => {
const normalizedReferenceUrl = referenceImageUrl.trim();
if (!isHttpImageUrl(normalizedSourceUrl)) {
toast.error("当前主图结果地址不可用");
return;
}
if (!isHttpImageUrl(normalizedReferenceUrl)) {
toast.error("请先上传或填写版式参考图");
return;
}
if (isSubmitting) return;
setIsSubmitting(true);
onProcessingChangeRef.current?.(true, normalizedSourceUrl);
try {
const submitResult = await imageGenerationService.layoutReplace({
sourceImageUrl: normalizedSourceUrl,
referenceImageUrl: normalizedReferenceUrl,
supplementalPrompt: supplementalPrompt.trim() || undefined,
model: model && model !== "auto" ? model : undefined,
imageSize: imageSize || "2K",
agentProjectId: projectId,
referenceLabel: "风格化主图 AI 套模版参考图",
debugMeta: {
operation: "agent-main-ai-template",
source: "agent-main-result-card",
},
});
const taskId = String(submitResult.data?.taskId || "").trim();
if (!submitResult.success || !taskId) {
throw new Error(submitResult.error || "AI套模版提交失败");
}
onOpenChange(false);
toast.success("AI套模版已进入生图队列");
const resultImageUrl = await pollAiTemplateTask(taskId);
await onResultCreatedRef.current({
imageUrl: resultImageUrl,
sourceImageUrl: normalizedSourceUrl,
referenceImageUrl: normalizedReferenceUrl,
});
toast.success("AI套模版结果已加入主图结果区");
} catch (error) {
toast.error(error instanceof Error ? error.message : "AI套模版生成失败");
} finally {
setIsSubmitting(false);
onProcessingChangeRef.current?.(false, normalizedSourceUrl);
}
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!isUploading) {
onOpenChange(nextOpen);
}
}}
>
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[780px] flex-col gap-0 overflow-hidden border-white/10 bg-[#09090b] p-0 text-white sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
<DialogHeader className="shrink-0 border-b border-white/10 px-4 py-3 pr-12 text-left sm:px-6 sm:py-4">
<DialogTitle className="flex items-center gap-2 text-base">
<LayoutTemplate className="h-4 w-4" />
AI套模版
</DialogTitle>
<DialogDescription className="text-white/55">
保留参考图的版式、文字和图标,仅将产品主体替换为当前主图。
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="min-w-0 space-y-2">
<Label>当前主图</Label>
<div className="flex h-[clamp(180px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-white/10 bg-zinc-900">
{isHttpImageUrl(normalizedSourceUrl) ? (
<AgentPreviewImage
alt="当前主图结果"
className="max-h-full max-w-full object-contain"
eager
previewQuality={86}
previewWidth={720}
src={normalizedSourceUrl}
/>
) : null}
</div>
</div>
<div className="min-w-0 space-y-2">
<Label htmlFor={referenceUploadId}>版式参考图</Label>
<div className="flex gap-2">
<Input
className="min-w-0 border-white/10 bg-white/[0.04]"
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
placeholder="填写图片链接或上传"
value={referenceImageUrl}
/>
<Button
asChild
className="shrink-0 border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
variant="outline"
>
<label htmlFor={referenceUploadId}>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传
</label>
</Button>
<input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="sr-only"
disabled={isUploading || isSubmitting}
id={referenceUploadId}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
void handleReferenceUpload(file);
}}
type="file"
/>
</div>
<div className="flex h-[clamp(180px,34dvh,340px)] items-center justify-center overflow-hidden rounded-lg border border-dashed border-white/10 bg-zinc-900">
{isUploading ? (
<Loader2 className="h-5 w-5 animate-spin text-white/45" />
) : isHttpImageUrl(referenceImageUrl) ? (
<AgentPreviewImage
alt="AI套模版参考图"
className="max-h-full max-w-full object-contain"
eager
previewQuality={86}
previewWidth={720}
src={referenceImageUrl}
/>
) : (
<LayoutTemplate className="h-7 w-7 text-white/20" />
)}
</div>
</div>
</div>
<div className="mt-4 space-y-2">
<Label htmlFor={`${referenceUploadId}-prompt`}>补充要求</Label>
<Textarea
className="min-h-[84px] max-h-[180px] border-white/10 bg-white/[0.04]"
id={`${referenceUploadId}-prompt`}
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
placeholder="可选:补充产品材质、颜色或替换细节"
value={supplementalPrompt}
/>
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t border-white/10 bg-black/25 px-4 py-3 sm:px-6">
<Button
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.10] hover:text-white"
disabled={isUploading}
onClick={() => onOpenChange(false)}
type="button"
variant="outline"
>
取消
</Button>
<Button
disabled={isSubmitting || isUploading || !isHttpImageUrl(referenceImageUrl)}
onClick={() => void handleSubmit()}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LayoutTemplate className="mr-2 h-4 w-4" />
)}
{isSubmitting ? "提交中" : "开始AI套模版"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,6 +1,8 @@
"use client"; "use client";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { import {
Fragment, Fragment,
@ -26,6 +28,7 @@ import {
Download, Download,
GitCompareArrows, GitCompareArrows,
ImagePlus, ImagePlus,
LayoutTemplate,
Loader2, Loader2,
Maximize2, Maximize2,
Sparkles, Sparkles,
@ -37,6 +40,10 @@ import {
Settings2, Settings2,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import {
IMAGE_UPLOAD_MAX_BYTES,
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
} from "@/lib/image-upload-limits";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ClientErrorBoundary } from "@/components/ui/client-error-boundary"; import { ClientErrorBoundary } from "@/components/ui/client-error-boundary";
import { import {
@ -64,6 +71,10 @@ import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-p
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner"; import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog"; import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog"; import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog";
import {
AgentAiTemplateDialog,
type AgentAiTemplateResult,
} from "@/components/agents/AgentAiTemplateDialog";
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog"; import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { import {
@ -300,7 +311,7 @@ const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000;
const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
const MAX_AGENT_MODE_QUERY_LENGTH = 64; const MAX_AGENT_MODE_QUERY_LENGTH = 64;
const MAX_AGENT_MODE_REFERENCE_COUNT = 40; const MAX_AGENT_MODE_REFERENCE_COUNT = 40;
const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = 30 * 1024 * 1024; const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES;
const REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES = 4; const REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES = 4;
const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025; const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025;
const DETAIL_SCALE_TARGET_MIN_PERCENT = -80; const DETAIL_SCALE_TARGET_MIN_PERCENT = -80;
@ -579,33 +590,7 @@ function areAttachmentValuesEqual(
); );
} }
function mergePersistedHistoryMessages( const mergePersistedHistoryMessages = mergeGenerationHistory;
currentMessages: ChatMessage[],
persistedMessages: ChatMessage[]
) {
if (persistedMessages.length === 0) {
return currentMessages;
}
if (currentMessages.length === 0) {
return persistedMessages;
}
const persistedIds = new Set(persistedMessages.map((message) => message.id));
const extraLocalMessages = currentMessages.filter(
(message) => !persistedIds.has(message.id)
);
const mergedMessages = [...persistedMessages, ...extraLocalMessages];
if (
mergedMessages.length === currentMessages.length &&
mergedMessages.every((message, index) => currentMessages[index]?.id === message.id)
) {
return currentMessages;
}
return mergedMessages;
}
function getAttachmentIdentityKey( function getAttachmentIdentityKey(
attachment: attachment:
@ -662,9 +647,10 @@ const AGENT_DEFERRED_SECTION_STYLE = {
containIntrinsicSize: "1px 760px", containIntrinsicSize: "1px 760px",
} as CSSProperties; } as CSSProperties;
const AGENT_DEFERRED_RESULTS_STYLE = { // Dynamic result content must stay painted while scrolling. Deferring this subtree
contentVisibility: "auto", // makes Chromium briefly discard image and animation layers at viewport boundaries.
containIntrinsicSize: "1px 900px", const AGENT_STABLE_RESULTS_STYLE = {
contentVisibility: "visible",
} as CSSProperties; } as CSSProperties;
const AGENT_DEFERRED_LIST_CARD_STYLE = { const AGENT_DEFERRED_LIST_CARD_STYLE = {
@ -672,6 +658,10 @@ const AGENT_DEFERRED_LIST_CARD_STYLE = {
containIntrinsicSize: "1px 320px", containIntrinsicSize: "1px 320px",
} as CSSProperties; } as CSSProperties;
const AGENT_STABLE_RESULT_CARD_STYLE = {
contentVisibility: "visible",
} as CSSProperties;
function handlePreviewImageLoadError( function handlePreviewImageLoadError(
event: SyntheticEvent<HTMLImageElement, Event>, event: SyntheticEvent<HTMLImageElement, Event>,
originalUrl: string originalUrl: string
@ -709,7 +699,7 @@ function normalizeAssistantText(value: string) {
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "") .replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "") .replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "") .replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.replace(/\n*<!-- AGENT_REPLACEMENT_CARD_ID:[^>]*-->\n*/g, "") .replace(/\n*<!-- AGENT_REPLACEMENT_(?:CARD_ID|RUN_ID|STATUS):[^>]*-->\n*/g, "")
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "") .replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
.trim(); .trim();
} }
@ -832,7 +822,7 @@ async function uploadDetailLocalEditReferenceImage(
throw new Error("请上传图片文件"); throw new Error("请上传图片文件");
} }
if (file.size > DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES) { if (file.size > DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES) {
throw new Error("参考图过大,请先压缩到 30MB 以内"); throw new Error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
} }
const formData = new FormData(); const formData = new FormData();
@ -1238,7 +1228,7 @@ function DetailResultLocalEditDialog({
); );
if (unchangedRegion) { if (unchangedRegion) {
setActiveRegionId(unchangedRegion.id); setActiveRegionId(unchangedRegion.id);
toast.error(`区域 ${unchangedRegion.index} 的目标框大小变化不明显,请重新框选`); toast.error(`区域 ${unchangedRegion.index} 的目标框大小和位置均未变化`);
return; return;
} }
} else { } else {
@ -2574,10 +2564,24 @@ interface LocalGenerationPendingState {
modeType: AgentGenerationResultModeType; modeType: AgentGenerationResultModeType;
expectedCount: number; expectedCount: number;
userTurnCountAtStart: number; userTurnCountAtStart: number;
requestMessageId?: string | null;
backendTaskId?: string | null;
timestamp: number; timestamp: number;
requestedCardIds?: string[] | null; requestedCardIds?: string[] | null;
} }
type DetailReplacementBatchTaskView = {
id: string;
status: "pending" | "running" | "succeeded" | "failed";
error: string | null;
attempts: number;
cards: DetailReplacementBatchCard[];
currentCardId: string | null;
requestedCardIds: string[];
createdAt: string;
queueStatus?: { queuePosition?: number } | null;
};
const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations"; const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations";
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000; const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000; const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
@ -2628,6 +2632,8 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType, modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType,
expectedCount: Number(record.expectedCount), expectedCount: Number(record.expectedCount),
userTurnCountAtStart: Number(record.userTurnCountAtStart), userTurnCountAtStart: Number(record.userTurnCountAtStart),
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
backendTaskId: typeof record.backendTaskId === "string" ? record.backendTaskId : null,
timestamp: Number(record.timestamp), timestamp: Number(record.timestamp),
requestedCardIds: Array.isArray(record.requestedCardIds) requestedCardIds: Array.isArray(record.requestedCardIds)
? record.requestedCardIds ? record.requestedCardIds
@ -2660,16 +2666,6 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
} }
} }
const DETAIL_RESULT_PROCESSING_STAGES = [
"解码图像",
"定位选区",
"分离主体",
"重绘背景",
"融合边缘",
"检查细节",
"输出结果",
];
function DetailResultProcessingScanOverlay({ function DetailResultProcessingScanOverlay({
imageUrl, imageUrl,
label, label,
@ -2677,55 +2673,43 @@ function DetailResultProcessingScanOverlay({
imageUrl?: string; imageUrl?: string;
label: string; label: string;
}) { }) {
const [progress, setProgress] = useState(6);
useEffect(() => {
const startedAt = Date.now();
const durationMs = 240_000;
const timer = window.setInterval(() => {
const elapsed = Date.now() - startedAt;
const linear = Math.min(1, elapsed / durationMs);
const eased = 1 - Math.pow(1 - linear, 1.8);
const drift = Math.sin(elapsed / 3200) * 1.2;
const next = Math.max(6, Math.min(96, Math.round(eased * 96 + drift)));
setProgress((current) => Math.max(current, next));
}, 720);
return () => window.clearInterval(timer);
}, [imageUrl, label]);
const stageIndex = Math.min(
DETAIL_RESULT_PROCESSING_STAGES.length - 1,
Math.floor((progress / 100) * DETAIL_RESULT_PROCESSING_STAGES.length)
);
const stageName = DETAIL_RESULT_PROCESSING_STAGES[stageIndex] || "处理中";
return ( return (
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]"> <div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
<style>{` <style>{`
@keyframes detailResultScanBreatheBlur { @keyframes detailResultScanBreathe {
0%, 100% { filter: blur(14px) brightness(0.8) saturate(0.9); transform: scale(1.08); } 0%, 100% { opacity: 0.68; transform: translate3d(0, 0, 0) scale(1.06); }
50% { filter: blur(17px) brightness(0.9) saturate(0.98); transform: scale(1.1); } 50% { opacity: 0.8; transform: translate3d(0, 0, 0) scale(1.075); }
} }
@keyframes detailResultScanSweep { @keyframes detailResultScanSweep {
0% { top: -7%; opacity: 0; } 0% { opacity: 0; transform: translate3d(0, -100%, 0); }
8% { opacity: 1; } 8% { opacity: 1; }
92% { opacity: 1; } 92% { opacity: 1; }
100% { top: 107%; opacity: 0; } 100% { opacity: 0; transform: translate3d(0, 0, 0); }
} }
@keyframes detailResultScanPulseRing { @keyframes detailResultScanPulseRing {
0% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0.48); } 0% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0.48); }
70% { box-shadow: 0 0 0 8px rgba(125, 211, 252, 0); } 70% { box-shadow: 0 0 0 8px rgba(125, 211, 252, 0); }
100% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0); } 100% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0); }
} }
@media (prefers-reduced-motion: reduce) {
[data-detail-result-scan-animation] { animation: none !important; }
}
`}</style> `}</style>
{isHttpImageUrl(imageUrl) ? ( {isHttpImageUrl(imageUrl) ? (
<img <AgentPreviewImage
alt="" alt=""
aria-hidden aria-hidden
data-detail-result-scan-animation
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
src={imageUrl} src={imageUrl}
style={{ animation: "detailResultScanBreatheBlur 6s ease-in-out infinite" }} previewWidth={720}
previewQuality={62}
rootMargin="320px 0px"
style={{
animation: "detailResultScanBreathe 6s ease-in-out infinite",
filter: "blur(12px) brightness(0.84) saturate(0.92)",
willChange: "opacity, transform",
}}
/> />
) : ( ) : (
<div <div
@ -2747,8 +2731,12 @@ function DetailResultProcessingScanOverlay({
/> />
<div <div
aria-hidden aria-hidden
className="absolute inset-x-0 top-0 z-40 h-px pointer-events-none" data-detail-result-scan-animation
style={{ animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite" }} className="pointer-events-none absolute inset-0 z-40"
style={{
animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite",
willChange: "opacity, transform",
}}
> >
<div <div
className="absolute inset-x-0 bottom-0 h-[110px]" className="absolute inset-x-0 bottom-0 h-[110px]"
@ -2759,7 +2747,7 @@ function DetailResultProcessingScanOverlay({
}} }}
/> />
<div <div
className="absolute inset-x-0 top-0 h-[38px]" className="absolute inset-x-0 -bottom-[38px] h-[38px]"
style={{ style={{
background: background:
"linear-gradient(0deg, transparent, rgba(255,255,255,0.1))", "linear-gradient(0deg, transparent, rgba(255,255,255,0.1))",
@ -2769,15 +2757,23 @@ function DetailResultProcessingScanOverlay({
</div> </div>
<div <div
aria-hidden aria-hidden
className="absolute inset-x-0 top-0 z-50 h-px pointer-events-none" data-detail-result-scan-animation
className="pointer-events-none absolute inset-0 z-50"
style={{ style={{
animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite", animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite",
background: willChange: "opacity, transform",
"linear-gradient(90deg, transparent, rgba(167,139,250,0.7) 18%, rgba(255,255,255,0.98) 50%, rgba(125,211,252,0.7) 82%, transparent)",
boxShadow:
"0 0 6px rgba(255,255,255,0.7), 0 0 18px rgba(125,211,252,0.55), 0 0 34px rgba(167,139,250,0.35)",
}} }}
/> >
<div
className="absolute inset-x-0 bottom-0 h-px"
style={{
background:
"linear-gradient(90deg, transparent, rgba(167,139,250,0.7) 18%, rgba(255,255,255,0.98) 50%, rgba(125,211,252,0.7) 82%, transparent)",
boxShadow:
"0 0 6px rgba(255,255,255,0.7), 0 0 18px rgba(125,211,252,0.55), 0 0 34px rgba(167,139,250,0.35)",
}}
/>
</div>
<div <div
className="absolute right-3 top-3 z-[70] isolate inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-white shadow-[0_12px_28px_rgba(0,0,0,0.34)]" className="absolute right-3 top-3 z-[70] isolate inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-white shadow-[0_12px_28px_rgba(0,0,0,0.34)]"
style={{ style={{
@ -2790,26 +2786,6 @@ function DetailResultProcessingScanOverlay({
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }} style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
/> />
<span>{label}</span> <span>{label}</span>
<span className="min-w-[34px] text-right tabular-nums text-slate-300">{progress}%</span>
</div>
<div
className="absolute bottom-3 left-3 z-[70] isolate inline-flex items-center gap-2 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-slate-100 shadow-[0_12px_28px_rgba(0,0,0,0.32)]"
style={{
WebkitFontSmoothing: "antialiased",
textShadow: "0 1px 1px rgba(0,0,0,0.72)",
}}
>
<span className="tabular-nums text-sky-300">{String(stageIndex + 1).padStart(2, "0")}</span>
<span>{stageName}</span>
</div>
<div className="absolute inset-x-0 bottom-0 z-[60] h-0.5 bg-white/5">
<div
className="h-full rounded-r-full shadow-[0_0_8px_rgba(125,211,252,0.6)] transition-[width] duration-300 ease-linear"
style={{
background: "linear-gradient(90deg, #7dd3fc, #a78bfa)",
width: `${progress}%`,
}}
/>
</div> </div>
</div> </div>
); );
@ -3152,6 +3128,8 @@ export function AgentGenerationWorkspaceDarkV2({
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null); const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
const detailGenerationCancelRef = useRef(false); const detailGenerationCancelRef = useRef(false);
const workspaceStateSignatureRef = useRef<string>(""); const workspaceStateSignatureRef = useRef<string>("");
const detailBatchSubmittingRef = useRef(false);
const reportedDetailBatchFailureRef = useRef<string | null>(null);
const generationRequestSnapshotRef = useRef<{ const generationRequestSnapshotRef = useRef<{
hiddenPrompt: string | null; hiddenPrompt: string | null;
replacementExecutionCardIds: string[] | null; replacementExecutionCardIds: string[] | null;
@ -3236,6 +3214,10 @@ export function AgentGenerationWorkspaceDarkV2({
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main"); const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] = const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] =
useState<AgentGenerationResultModeType>("scene_generation"); useState<AgentGenerationResultModeType>("scene_generation");
const [aiTemplateDialogSourceUrl, setAiTemplateDialogSourceUrl] =
useState<string | null>(null);
const [aiTemplateProcessingSourceUrl, setAiTemplateProcessingSourceUrl] =
useState<string | null>(null);
const [batchDownloadState, setBatchDownloadState] = const [batchDownloadState, setBatchDownloadState] =
useState<null | BatchDownloadScope>(null); useState<null | BatchDownloadScope>(null);
const [batchDownloadDialog, setBatchDownloadDialog] = const [batchDownloadDialog, setBatchDownloadDialog] =
@ -3388,7 +3370,7 @@ export function AgentGenerationWorkspaceDarkV2({
} }
}, [closePsdTemplateDialog, isPsdTemplateDialogOpen, isPsdTemplateEnabled]); }, [closePsdTemplateDialog, isPsdTemplateDialogOpen, isPsdTemplateEnabled]);
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>( const { data: historyData, mutate: refreshHistory } = useSWR<{ messages: ChatMessage[] }>(
rawChatId ? `/api/messages?chatId=${rawChatId}` : null, rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
fetcher, fetcher,
{ {
@ -3482,6 +3464,75 @@ export function AgentGenerationWorkspaceDarkV2({
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation"; const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
const isReplacementPreset = const isReplacementPreset =
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement"; currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
const { data: detailBatchData, mutate: refreshDetailBatch } = useSWR<{
task: DetailReplacementBatchTaskView | null;
}>(
projectId && currentResultModeType === "detail_replacement"
? `/api/agents/detail-replacement/batches?projectId=${encodeURIComponent(projectId)}`
: null,
fetcher,
{ refreshInterval: 3000, revalidateOnFocus: true },
);
const detailBatchTask = detailBatchData?.task || null;
useEffect(() => {
if (!detailBatchData || currentResultModeType !== "detail_replacement") return;
const task = detailBatchData.task;
if (task && (task.status === "pending" || task.status === "running")) {
setPendingGeneration((current) => {
if (current?.backendTaskId === task.id) return current;
return {
chatId,
requestMessageId: task.id,
backendTaskId: task.id,
modeType: "detail_replacement",
expectedCount: task.requestedCardIds.length,
requestedCardIds: task.requestedCardIds,
userTurnCountAtStart: 0,
timestamp: Date.parse(task.createdAt) || Date.now(),
};
});
} else {
setPendingGeneration((current) =>
current?.modeType === "detail_replacement" &&
(!current.backendTaskId || current.backendTaskId === task?.id)
? null
: current,
);
}
}, [chatId, currentResultModeType, detailBatchData]);
useEffect(() => {
const cards = detailBatchTask?.cards || [];
if (cards.length === 0) return;
setReplacementResultState((current) => {
const next = { ...current.detail_replacement };
let changed = false;
for (const card of cards) {
const reference = detailReplacementReferenceCards.find((item) => item.id === card.cardId);
if (!reference || !card.imageUrl || next[card.cardId]?.url === card.imageUrl) continue;
next[card.cardId] = {
url: card.imageUrl,
text: card.text,
status: card.status,
updatedAt: Date.now(),
compareSourceUrl: reference.imageUrl,
referenceImageUrl: reference.imageUrl,
label: reference.label,
};
changed = true;
}
return changed ? { ...current, detail_replacement: next } : current;
});
}, [detailBatchTask?.cards, detailReplacementReferenceCards]);
useEffect(() => {
if (
detailBatchTask?.status !== "failed" ||
detailBatchTask.attempts < 3 ||
reportedDetailBatchFailureRef.current === detailBatchTask.id
) return;
reportedDetailBatchFailureRef.current = detailBatchTask.id;
toast.error(detailBatchTask.error || "详情替换批次中断,已保留生成成功的图片,可继续处理剩余图片");
}, [detailBatchTask]);
const isProductOnlyDetailReplacementMode = const isProductOnlyDetailReplacementMode =
currentResultModeType === "detail_replacement" && currentResultModeType === "detail_replacement" &&
detailReplacementStrategy === "product_only"; detailReplacementStrategy === "product_only";
@ -4129,6 +4180,16 @@ export function AgentGenerationWorkspaceDarkV2({
} = useChat<ChatMessage>({ } = useChat<ChatMessage>({
id: chatId, id: chatId,
messages: historyMessages, messages: historyMessages,
onData(part) {
if (part.type !== "data-appendMessage") return;
try {
const appended = parseAppendedGenerationMessage(part.data);
if (appended) setMessages((current) => appendGenerationMessage(current, appended as ChatMessage));
} catch {
void refreshHistory();
}
},
onFinish() { void refreshHistory(); },
experimental_throttle: 100, experimental_throttle: 100,
generateId: generateUUID, generateId: generateUUID,
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
@ -4262,6 +4323,20 @@ export function AgentGenerationWorkspaceDarkV2({
}), }),
}); });
useEffect(() => {
const reconcile = () => {
if (document.visibilityState === "visible") void refreshHistory();
};
document.addEventListener("visibilitychange", reconcile);
window.addEventListener("online", reconcile);
window.addEventListener("pageshow", reconcile);
return () => {
document.removeEventListener("visibilitychange", reconcile);
window.removeEventListener("online", reconcile);
window.removeEventListener("pageshow", reconcile);
};
}, [refreshHistory]);
useEffect(() => { useEffect(() => {
if (!historyMessages.length) { if (!historyMessages.length) {
return; return;
@ -4482,8 +4557,7 @@ export function AgentGenerationWorkspaceDarkV2({
if (!hasLikelyGenerationErrorText(text)) { if (!hasLikelyGenerationErrorText(text)) {
return false; return false;
} }
const imageUrls = extractImageUrlsFromText(text); return true;
return imageUrls.length === 0;
}), }),
[assistantMessagesAfterLastUser] [assistantMessagesAfterLastUser]
); );
@ -4791,6 +4865,12 @@ export function AgentGenerationWorkspaceDarkV2({
: currentResultModeType === "sku_replacement" : currentResultModeType === "sku_replacement"
? skuReplacementResultUrls ? skuReplacementResultUrls
: currentSceneGeneratedUrls; : currentSceneGeneratedUrls;
const replacementRunRecovery = useMemo(() => {
if (!pendingGeneration || !["detail_replacement", "sku_replacement"].includes(pendingGeneration.modeType)) {
return { completedCardIds: [] as string[], failedCardIds: [] as string[], done: false };
}
return recoverReplacementRun({ messages, ...pendingGeneration });
}, [messages, pendingGeneration]);
const transportIsGenerating = status === "streaming" || status === "submitted"; const transportIsGenerating = status === "streaming" || status === "submitted";
const pendingGenerationProgressCount = useMemo(() => { const pendingGenerationProgressCount = useMemo(() => {
if (!pendingGeneration) { if (!pendingGeneration) {
@ -4812,13 +4892,14 @@ export function AgentGenerationWorkspaceDarkV2({
return detailGenerationRunResults.length; return detailGenerationRunResults.length;
} }
return latestReplacementResults.length; return replacementRunRecovery.completedCardIds.length;
}, [ }, [
currentSceneGeneratedUrls.length, currentSceneGeneratedUrls.length,
detailGenerationRunResults.length, detailGenerationRunResults.length,
latestReplacementResults.length, latestReplacementResults.length,
pendingGeneration, pendingGeneration,
userMessageCount, userMessageCount,
replacementRunRecovery.completedCardIds.length,
]); ]);
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration); const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
const isSceneResultModeGenerating = const isSceneResultModeGenerating =
@ -4948,21 +5029,33 @@ export function AgentGenerationWorkspaceDarkV2({
Array.isArray(pendingGeneration.requestedCardIds) Array.isArray(pendingGeneration.requestedCardIds)
? pendingGeneration.requestedCardIds.length ? pendingGeneration.requestedCardIds.length
: null, : null,
status: getReplacementCardStatus({ status: detailBatchTask?.requestedCardIds.includes(card.id)
? resultMeta?.url
? resultMeta.status
: detailBatchTask.status === "running"
? detailBatchTask.currentCardId === card.id ? "generating" : "queued"
: detailBatchTask.status === "pending"
? "queued"
: detailBatchTask.status === "failed"
? "missing"
: "waiting"
: getReplacementCardStatus({
index, index,
resultUrl, resultUrl,
resultText, resultText,
generatedCount: pendingGeneration?.modeType === "detail_replacement" generatedCount: pendingGeneration?.modeType === "detail_replacement"
? latestReplacementResults.length ? latestReplacementResults.length
: 0, : 0,
isGenerating: isDetailResultModeGenerating, isGenerating: isDetailResultModeGenerating && !replacementRunRecovery.completedCardIds.includes(card.id),
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null, activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
}), }),
}; };
}), }),
[ [
detailReplacementReferenceCards, detailReplacementReferenceCards,
detailBatchTask,
isDetailResultModeGenerating, isDetailResultModeGenerating,
replacementRunRecovery.completedCardIds,
latestReplacementResults, latestReplacementResults,
normalizedDeletedResultCollections.detail_replacement, normalizedDeletedResultCollections.detail_replacement,
pendingGeneration?.modeType, pendingGeneration?.modeType,
@ -5014,13 +5107,14 @@ export function AgentGenerationWorkspaceDarkV2({
generatedCount: pendingGeneration?.modeType === "sku_replacement" generatedCount: pendingGeneration?.modeType === "sku_replacement"
? latestReplacementResults.length ? latestReplacementResults.length
: 0, : 0,
isGenerating: isSkuResultModeGenerating, isGenerating: isSkuResultModeGenerating && !replacementRunRecovery.completedCardIds.includes(card.id),
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null, activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
}), }),
}; };
}), }),
[ [
isSkuResultModeGenerating, isSkuResultModeGenerating,
replacementRunRecovery.completedCardIds,
latestReplacementResults, latestReplacementResults,
normalizedDeletedResultCollections.sku_replacement, normalizedDeletedResultCollections.sku_replacement,
pendingGeneration?.modeType, pendingGeneration?.modeType,
@ -5114,7 +5208,7 @@ export function AgentGenerationWorkspaceDarkV2({
const detailRemainingCount = useMemo( const detailRemainingCount = useMemo(
() => () =>
detailResultCards.filter( detailResultCards.filter(
(card) => !["completed", "passthrough", "fallback"].includes(card.status) (card) => !["completed", "passthrough"].includes(card.status)
).length, ).length,
[detailResultCards] [detailResultCards]
); );
@ -5389,7 +5483,15 @@ export function AgentGenerationWorkspaceDarkV2({
}, [currentResultModeType]); }, [currentResultModeType]);
useEffect(() => { useEffect(() => {
if (!pendingGeneration) { if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
// The backend has settled every requested card; an open SSE connection is no longer work.
stop();
transportGenerationStartedAtRef.current = null;
setPendingGeneration(null);
}, [pendingGeneration, replacementRunRecovery.done, stop]);
useEffect(() => {
if (!pendingGeneration || pendingGeneration.backendTaskId) {
return; return;
} }
@ -5399,7 +5501,7 @@ export function AgentGenerationWorkspaceDarkV2({
}, [pendingGeneration, pendingGenerationProgressCount]); }, [pendingGeneration, pendingGenerationProgressCount]);
useEffect(() => { useEffect(() => {
if (!pendingGeneration || !chatError) { if (!pendingGeneration || pendingGeneration.backendTaskId || !chatError) {
return; return;
} }
@ -5414,7 +5516,7 @@ export function AgentGenerationWorkspaceDarkV2({
}, [chatError, clearError, pendingGeneration]); }, [chatError, clearError, pendingGeneration]);
useEffect(() => { useEffect(() => {
if (!pendingGeneration) { if (!pendingGeneration || pendingGeneration.backendTaskId) {
return; return;
} }
@ -6140,7 +6242,6 @@ export function AgentGenerationWorkspaceDarkV2({
const processedStatuses = new Set<ReplacementCardStatus>([ const processedStatuses = new Set<ReplacementCardStatus>([
"completed", "completed",
"passthrough", "passthrough",
"fallback",
]); ]);
const remainingIds = resultCards const remainingIds = resultCards
.filter((card) => !card.passthrough && !processedStatuses.has(card.status)) .filter((card) => !card.passthrough && !processedStatuses.has(card.status))
@ -6246,6 +6347,86 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置"); toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
return; return;
} }
if (replacementModeType === "detail_replacement") {
if (detailBatchSubmittingRef.current) return;
if (!isValidChatUuid(projectId)) {
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
return;
}
const productImageUrls = Array.from(new Set(
(detailReplacementProductSourceImages.length > 0
? detailReplacementProductSourceImages.map((item) => item.url)
: selectedProductAttachments.map((item) => item.url))
.filter((url) => isValidChatHttpUrl(url)),
));
if (productImageUrls.length === 0 && !replacementExecutionCards.every((card) => card.passthrough)) {
toast.error("请先在输入图区选择至少一张产品图");
return;
}
detailBatchSubmittingRef.current = true;
void (async () => {
try {
const saved = await onPersistProject?.();
if (saved === false) throw new Error("项目尚未保存,请重试");
const requestId = generateUUID();
const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
requestId,
model: resolvedModelId,
imageSize: imageParams.imageSize,
replacementStrategy: detailReplacementStrategy,
prompt: detailReplacementStrategy === "product_only"
? "仅替换参考详情图中的产品主体"
: String(resolvedWorkflowContext?.promptText || "").trim(),
userPrompt: String(options?.promptText || input || "").trim(),
productImageUrls,
modelImageUrl: detailReplacementModelSourceImage?.url || selectedModelAttachment?.url || null,
references: replacementExecutionCards.map((card) => ({
id: card.id,
label: card.label,
imageUrl: card.imageUrl,
candidateId: card.candidateId,
passthrough: card.passthrough,
rerunReferenceImages:
options?.replacementRerunReferenceImages?.cardId === card.id
? options.replacementRerunReferenceImages.images
: [],
})),
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.task?.id) {
throw new Error(payload?.error || "详情替换任务提交失败");
}
setPendingGeneration({
chatId,
requestMessageId: requestId,
backendTaskId: payload.task.id,
modeType: "detail_replacement",
expectedCount: replacementExecutionCards.length,
userTurnCountAtStart: userMessageCount,
timestamp: Date.now(),
requestedCardIds: replacementExecutionCards.map((card) => card.id),
});
setReplacementExecutionContext({
modeType: "detail_replacement",
cardIds: replacementExecutionCards.map((card) => card.id),
});
setActiveResultModeType("detail_replacement");
setSelectedResultUrl(null);
await refreshDetailBatch();
toast.success(`已提交 ${replacementExecutionCards.length} 张详情图,离开页面后仍会继续处理`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "详情替换任务提交失败");
} finally {
detailBatchSubmittingRef.current = false;
}
})();
return;
}
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = []; const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
const sanitizedAttachments = sceneMessageAttachments; const sanitizedAttachments = sceneMessageAttachments;
if ( if (
@ -6344,8 +6525,10 @@ export function AgentGenerationWorkspaceDarkV2({
); );
setActiveResultModeType(currentResultModeType); setActiveResultModeType(currentResultModeType);
setSelectedResultUrl(null); setSelectedResultUrl(null);
const requestMessageId = generateUUID();
setPendingGeneration({ setPendingGeneration({
chatId, chatId,
requestMessageId,
modeType: currentResultModeType, modeType: currentResultModeType,
expectedCount: expectedCount:
currentResultModeType === "scene_generation" currentResultModeType === "scene_generation"
@ -6366,7 +6549,7 @@ export function AgentGenerationWorkspaceDarkV2({
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`); toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
} }
try { try {
void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => { void Promise.resolve(sendMessage({ id: requestMessageId, role: "user", parts })).catch((error) => {
generationRequestSnapshotRef.current = { generationRequestSnapshotRef.current = {
hiddenPrompt: null, hiddenPrompt: null,
replacementExecutionCardIds: null, replacementExecutionCardIds: null,
@ -6393,6 +6576,8 @@ export function AgentGenerationWorkspaceDarkV2({
detailResultCards, detailResultCards,
detailReplacementReferenceCards.length, detailReplacementReferenceCards.length,
detailReplacementStrategy, detailReplacementStrategy,
detailReplacementProductSourceImages,
detailReplacementModelSourceImage,
handleRunAiDetailGeneration, handleRunAiDetailGeneration,
currentResultModeType, currentResultModeType,
getReplacementExecutionCards, getReplacementExecutionCards,
@ -6423,6 +6608,10 @@ export function AgentGenerationWorkspaceDarkV2({
scenePrimaryReferenceCount, scenePrimaryReferenceCount,
sceneSecondaryReferenceCount, sceneSecondaryReferenceCount,
selectedModelAttachment, selectedModelAttachment,
selectedProductAttachments,
onPersistProject,
projectId,
refreshDetailBatch,
]); ]);
const handleStopGeneration = useCallback(() => { const handleStopGeneration = useCallback(() => {
detailGenerationCancelRef.current = true; detailGenerationCancelRef.current = true;
@ -6818,6 +7007,23 @@ export function AgentGenerationWorkspaceDarkV2({
}, 2800); }, 2800);
}, []); }, []);
const handleAiTemplateResultCreated = useCallback(
(result: AgentAiTemplateResult) => {
const appended = appendResultUrlToCollection("scene_generation", result.imageUrl, {
compareSourceUrl: result.sourceImageUrl,
});
if (!appended) {
toast.error("当前项目未接入结果保存能力");
return;
}
setActiveResultModeType("scene_generation");
setSelectedResultUrl(result.imageUrl);
showProcessedResultHint(result.imageUrl, "AI套模版完成");
},
[appendResultUrlToCollection, showProcessedResultHint]
);
const updateDetailGenerationProcessedResult = useCallback( const updateDetailGenerationProcessedResult = useCallback(
(params: { (params: {
cardId?: string | null; cardId?: string | null;
@ -9262,7 +9468,7 @@ export function AgentGenerationWorkspaceDarkV2({
</> </>
)} )}
</Button> </Button>
{isAnyGenerationRunning ? ( {isAnyGenerationRunning && !pendingGeneration?.backendTaskId ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -9640,7 +9846,7 @@ export function AgentGenerationWorkspaceDarkV2({
initial={{ opacity: 0, scale: 0.98 }} initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.12 }} transition={{ delay: 0.12 }}
style={AGENT_DEFERRED_RESULTS_STYLE} style={AGENT_STABLE_RESULTS_STYLE}
data-agent-style-section="results" data-agent-style-section="results"
> >
<div className="flex items-center justify-between border-b border-white/12 px-5 py-4"> <div className="flex items-center justify-between border-b border-white/12 px-5 py-4">
@ -9826,7 +10032,7 @@ export function AgentGenerationWorkspaceDarkV2({
selectedProductAttachments.map((attachment, index) => ( selectedProductAttachments.map((attachment, index) => (
<div <div
key={`${attachment?.url || "input"}-${index}`} key={`${attachment?.url || "input"}-${index}`}
style={AGENT_DEFERRED_LIST_CARD_STYLE} style={AGENT_STABLE_RESULT_CARD_STYLE}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20" className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
> >
<div className="aspect-square overflow-hidden bg-black/30"> <div className="aspect-square overflow-hidden bg-black/30">
@ -9942,7 +10148,7 @@ export function AgentGenerationWorkspaceDarkV2({
return ( return (
<div <div
key={card.id} key={card.id}
style={AGENT_DEFERRED_LIST_CARD_STYLE} style={AGENT_STABLE_RESULT_CARD_STYLE}
className={cn( className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors", "w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
isActive isActive
@ -10107,7 +10313,7 @@ export function AgentGenerationWorkspaceDarkV2({
return ( return (
<div <div
key={`${card.id}-result`} key={`${card.id}-result`}
style={AGENT_DEFERRED_LIST_CARD_STYLE} style={AGENT_STABLE_RESULT_CARD_STYLE}
className={cn( className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors", "w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
hasResult && isActive hasResult && isActive
@ -10378,7 +10584,7 @@ export function AgentGenerationWorkspaceDarkV2({
selectedProductAttachments.map((attachment, index) => ( selectedProductAttachments.map((attachment, index) => (
<div <div
key={`${attachment?.url || "sku-input"}-${index}`} key={`${attachment?.url || "sku-input"}-${index}`}
style={AGENT_DEFERRED_LIST_CARD_STYLE} style={AGENT_STABLE_RESULT_CARD_STYLE}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20" className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
> >
<div className="aspect-square overflow-hidden bg-black/30"> <div className="aspect-square overflow-hidden bg-black/30">
@ -10550,7 +10756,7 @@ export function AgentGenerationWorkspaceDarkV2({
key={candidate.id} key={candidate.id}
type="button" type="button"
onClick={() => handleSelectSkuReplacementCandidate(candidate.id)} onClick={() => handleSelectSkuReplacementCandidate(candidate.id)}
style={AGENT_DEFERRED_LIST_CARD_STYLE} style={AGENT_STABLE_RESULT_CARD_STYLE}
className={cn( className={cn(
"min-w-[280px] overflow-hidden rounded-[20px] border text-left transition-colors", "min-w-[280px] overflow-hidden rounded-[20px] border text-left transition-colors",
isSelected isSelected
@ -10617,7 +10823,7 @@ export function AgentGenerationWorkspaceDarkV2({
skuReplacementReferenceCards.map((card, index) => ( skuReplacementReferenceCards.map((card, index) => (
<div <div
key={card.id} key={card.id}
style={AGENT_DEFERRED_LIST_CARD_STYLE} style={AGENT_STABLE_RESULT_CARD_STYLE}
className={cn( className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors", "w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
activeSkuReferenceCardId === card.id activeSkuReferenceCardId === card.id
@ -10746,7 +10952,7 @@ export function AgentGenerationWorkspaceDarkV2({
return ( return (
<div <div
key={`${card.id}-result`} key={`${card.id}-result`}
style={AGENT_DEFERRED_LIST_CARD_STYLE} style={AGENT_STABLE_RESULT_CARD_STYLE}
className={cn( className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors", "w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
hasResult && activeSkuReferenceCardId === card.id hasResult && activeSkuReferenceCardId === card.id
@ -10885,12 +11091,21 @@ export function AgentGenerationWorkspaceDarkV2({
className="flex flex-1 flex-col px-6 pb-6 pt-5 md:px-8" className="flex flex-1 flex-col px-6 pb-6 pt-5 md:px-8"
> >
<div className="mx-auto w-full max-w-[1200px]"> <div className="mx-auto w-full max-w-[1200px]">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> <div
className={cn(
"grid gap-4",
activeResultModeType === "scene_generation"
? "grid-cols-1 md:grid-cols-2"
: "grid-cols-2 md:grid-cols-4"
)}
>
{gridResultCards.map((card) => { {gridResultCards.map((card) => {
const isActive = card.url && displayedResultUrl === card.url; const isActive = card.url && displayedResultUrl === card.url;
const isSceneResultCard = activeResultModeType === "scene_generation"; const isSceneResultCard = activeResultModeType === "scene_generation";
const isDetailGenerationCard = const isDetailGenerationCard =
activeResultModeType === "detail_generation"; activeResultModeType === "detail_generation";
const isAiTemplateProcessing =
aiTemplateProcessingSourceUrl === card.url;
const isDetailEnhancing = detailEnhancingCardIds.has(card.id); const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
const isDetailLocalEditBusy = const isDetailLocalEditBusy =
detailLocalEditSubmittingId === card.id; detailLocalEditSubmittingId === card.id;
@ -10913,7 +11128,7 @@ export function AgentGenerationWorkspaceDarkV2({
key={card.id} key={card.id}
role={card.url ? "button" : undefined} role={card.url ? "button" : undefined}
tabIndex={card.url ? 0 : -1} tabIndex={card.url ? 0 : -1}
style={AGENT_DEFERRED_LIST_CARD_STYLE} style={AGENT_STABLE_RESULT_CARD_STYLE}
onClick={() => { onClick={() => {
if (!card.url) return; if (!card.url) return;
setSelectedResultUrl(card.url); setSelectedResultUrl(card.url);
@ -10953,36 +11168,70 @@ export function AgentGenerationWorkspaceDarkV2({
/> />
{isSceneResultCard ? ( {isSceneResultCard ? (
<> <>
<button <div className="absolute left-3 right-14 top-3 flex flex-wrap items-center gap-2">
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
setSceneResizeEditorTargetUrl(card.url!);
}}
className={cn("absolute left-3 top-3 flex h-9 w-9 items-center justify-center", IMAGE_OVERLAY_ACTIVE_BADGE_CLASS, "px-0 py-0")}
aria-label="调整尺寸"
>
<Maximize2 className="h-4 w-4" />
</button>
{isPsdTemplateEnabled ? (
<button <button
type="button" type="button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
setSelectedResultUrl(card.url!); setSelectedResultUrl(card.url!);
openPsdTemplateDialog({ setSceneResizeEditorTargetUrl(card.url!);
sourceImageUrl: card.url!,
mode: getPsdTemplateModeByResultMode(activeResultModeType),
});
}} }}
className={cn("absolute left-14 top-3 flex items-center gap-1", IMAGE_OVERLAY_ACTION_CLASS)} className={cn(
aria-label="套用 PSD 模板" "flex h-9 w-9 items-center justify-center px-0 py-0",
IMAGE_OVERLAY_ACTIVE_BADGE_CLASS
)}
aria-label="调整尺寸"
> >
<ImagePlus className="h-3.5 w-3.5" /> <Maximize2 className="h-4 w-4" />
<span>套PSD</span>
</button> </button>
) : null} {isPsdTemplateEnabled ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
openPsdTemplateDialog({
sourceImageUrl: card.url!,
mode: getPsdTemplateModeByResultMode(
activeResultModeType
),
});
}}
className={cn(
"flex items-center gap-1",
IMAGE_OVERLAY_ACTION_CLASS
)}
aria-label="套用 PSD 模板"
>
<ImagePlus className="h-3.5 w-3.5" />
<span>套PSD</span>
</button>
) : null}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
setAiTemplateDialogSourceUrl(card.url!);
}}
disabled={Boolean(aiTemplateProcessingSourceUrl)}
className={cn(
"flex items-center gap-1 disabled:cursor-not-allowed disabled:opacity-55",
IMAGE_OVERLAY_ACTION_CLASS
)}
aria-label="AI套模版"
aria-busy={isAiTemplateProcessing}
>
{isAiTemplateProcessing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<LayoutTemplate className="h-3.5 w-3.5" />
)}
<span>
{isAiTemplateProcessing ? "AI套版中" : "AI套模版"}
</span>
</button>
</div>
<div className="pointer-events-none absolute inset-x-3 bottom-3 rounded-2xl border border-white/25 bg-zinc-950/82 px-3 py-2 font-semibold text-[11px] text-white shadow-[0_12px_34px_rgba(0,0,0,0.48)] backdrop-blur-md"> <div className="pointer-events-none absolute inset-x-3 bottom-3 rounded-2xl border border-white/25 bg-zinc-950/82 px-3 py-2 font-semibold text-[11px] text-white shadow-[0_12px_34px_rgba(0,0,0,0.48)] backdrop-blur-md">
双击查看大图 双击查看大图
</div> </div>
@ -11230,6 +11479,29 @@ export function AgentGenerationWorkspaceDarkV2({
</div> </div>
</main> </main>
<AgentAiTemplateDialog
imageSize={imageParams.imageSize}
model={selectedModelId !== "auto" ? selectedModelId : effectiveDefaultModelId}
projectId={projectId}
onOpenChange={(open) => {
if (!open) {
setAiTemplateDialogSourceUrl(null);
}
}}
onProcessingChange={(processing, sourceImageUrl) => {
setAiTemplateProcessingSourceUrl((current) =>
processing
? sourceImageUrl
: current === sourceImageUrl
? null
: current
);
}}
onResultCreated={handleAiTemplateResultCreated}
open={Boolean(aiTemplateDialogSourceUrl)}
sourceImageUrl={aiTemplateDialogSourceUrl}
/>
{sceneResizeEditorTargetUrl ? ( {sceneResizeEditorTargetUrl ? (
<ImageAnnotator <ImageAnnotator
key={`agent-scene-resize-${sceneResizeEditorTargetUrl}`} key={`agent-scene-resize-${sceneResizeEditorTargetUrl}`}

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

@ -1,7 +1,11 @@
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
IMAGE_UPLOAD_MAX_BYTES,
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
} from "@/lib/image-upload-limits";
import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES;
export interface UploadedDetailReferenceImage { export interface UploadedDetailReferenceImage {
url: string; url: string;
@ -64,7 +68,7 @@ export async function uploadDetailReferenceImageFile(
throw new Error("请上传图片文件"); throw new Error("请上传图片文件");
} }
if (file.size > DETAIL_REFERENCE_UPLOAD_MAX_BYTES) { if (file.size > DETAIL_REFERENCE_UPLOAD_MAX_BYTES) {
throw new Error("目标详情图过大,请压缩到 50MB 以内"); throw new Error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
} }
const dimensionsPromise = readLocalImageDimensions(file); const dimensionsPromise = readLocalImageDimensions(file);

259
components/bloub-avatar.tsx Normal file
View File

@ -0,0 +1,259 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { defaultCycle } from "@/lib/bloub-official/cycles";
import { NOTIF_BLUE } from "@/lib/bloub-official/decor";
import { type BotFrame, BotEngine } from "@/lib/bloub-official/engine";
import {
DEFAULT_EXPRESSION,
EXPRESSION_BY_ID,
} from "@/lib/bloub-official/expressions";
import { DEMI_VIEWBOX, RAYON } from "@/lib/bloub-official/repere";
import { SHAPE_BY_ID } from "@/lib/bloub-official/skins";
import type { StateId } from "@/lib/bloub-official/states";
import { cn } from "@/lib/utils";
export type BloubAvatarProps = {
animated?: boolean;
className?: string;
label?: string;
playSequence?: boolean;
size?: number;
state?: StateId;
surface?: "chat" | "page";
};
export function BloubAvatar({
animated = true,
className,
label = "AI 助手",
playSequence = false,
size = 32,
state = "idle",
surface = "chat",
}: BloubAvatarProps) {
const rawId = useId();
const uid = rawId.replace(/[^a-zA-Z0-9_-]/g, "");
const maskId = `bloub-mask-${uid}`;
const engineRef = useRef<BotEngine | null>(null);
const clockRef = useRef(0);
if (!engineRef.current) {
engineRef.current = new BotEngine(
RAYON,
state,
SHAPE_BY_ID.get("squircle")?.radii ?? null,
EXPRESSION_BY_ID.get(DEFAULT_EXPRESSION) ?? null
);
}
const [frame, setFrame] = useState<BotFrame>(() =>
engineRef.current!.sample(0)
);
useEffect(() => {
if (playSequence) return;
const engine = engineRef.current;
if (!engine || engine.state === state) return;
engine.setState(state, clockRef.current);
setFrame(engine.sample(clockRef.current));
}, [playSequence, state]);
useEffect(() => {
const engine = engineRef.current;
if (!engine) return;
const cycle = defaultCycle().blocks;
const firstState = playSequence
? cycle[0]?.state ?? "idle"
: engine.state;
engine.reset(firstState, 0);
clockRef.current = 0;
setFrame(engine.sample(0));
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (!animated || reducedMotion) return;
let animationFrame = 0;
let lastFrameAt = 0;
let blockIndex = 0;
let nextBlockAt = cycle[0]?.duration ?? Number.POSITIVE_INFINITY;
const tick = (timestamp: number) => {
const delta = lastFrameAt
? Math.min((timestamp - lastFrameAt) / 1000, 0.064)
: 0;
lastFrameAt = timestamp;
clockRef.current += delta;
if (playSequence && cycle.length > 0) {
while (clockRef.current >= nextBlockAt) {
blockIndex = (blockIndex + 1) % cycle.length;
const nextBlock = cycle[blockIndex]!;
engine.setState(nextBlock.state, nextBlockAt);
nextBlockAt += nextBlock.duration;
}
}
setFrame(engine.sample(clockRef.current));
animationFrame = window.requestAnimationFrame(tick);
};
animationFrame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(animationFrame);
}, [animated, playSequence]);
const renderDot = (
dot: BotFrame["dots"][number],
index: number,
layer: "back" | "front"
) => {
const fill = dot.color ?? "currentColor";
const common = {
fill,
opacity:
dot.color || dot.depth === undefined
? dot.opacity
: dot.opacity * dot.depth,
};
const key = `${layer}-${index}`;
if (dot.d) {
return (
<path
{...common}
d={dot.d}
key={key}
transform={`translate(${dot.x} ${dot.y}) rotate(${dot.rot ?? 0}) scale(${RAYON})`}
/>
);
}
return (
<circle {...common} cx={dot.x} cy={dot.y} key={key} r={dot.r} />
);
};
return (
<svg
aria-label={label}
className={cn(
"block shrink-0 text-[#0a0a0c] dark:text-[#3ecf8e]",
surface === "page"
? "[--bloub-paper:#f4f4f5] dark:[--bloub-paper:#08090b]"
: "[--bloub-paper:#ffffff] dark:[--bloub-paper:#09090b]",
className
)}
height={size}
role="img"
viewBox={`${-DEMI_VIEWBOX} ${-DEMI_VIEWBOX} ${DEMI_VIEWBOX * 2} ${DEMI_VIEWBOX * 2}`}
width={size}
>
<defs>
<mask
height={DEMI_VIEWBOX * 2}
id={maskId}
maskUnits="userSpaceOnUse"
width={DEMI_VIEWBOX * 2}
x={-DEMI_VIEWBOX}
y={-DEMI_VIEWBOX}
>
<path d={frame.bodyPath} fill="#fff" />
{frame.eyes.map((eye, index) => (
<path
d={eye.d}
fill="#000"
key={`eye-${index}`}
opacity={eye.alpha}
transform={eye.matrix}
/>
))}
{frame.notch ? (
<circle
cx={frame.notch.x}
cy={frame.notch.y}
fill="#000"
r={frame.notch.r}
/>
) : null}
</mask>
{frame.arcs.map((arc) => (
<linearGradient
gradientUnits="userSpaceOnUse"
id={`${uid}-${arc.id}`}
key={`gradient-${arc.id}`}
x1={arc.grad.x1}
x2={arc.grad.x2}
y1={arc.grad.y1}
y2={arc.grad.y2}
>
{arc.grad.stops.map((color, index) => (
<stop
key={`${arc.id}-${color}-${index}`}
offset={index / (arc.grad.stops.length - 1)}
stopColor={color}
/>
))}
</linearGradient>
))}
</defs>
<g fill="none" strokeLinecap="round">
{frame.arcs.map((arc) => (
<path
d={arc.back}
key={`arc-back-${arc.id}`}
opacity={arc.opacity}
stroke={`url(#${uid}-${arc.id})`}
strokeWidth={arc.width}
/>
))}
</g>
{frame.dotsBehind ? (
<g>{frame.dots.map((dot, index) => renderDot(dot, index, "back"))}</g>
) : null}
<g opacity={frame.bodyAlpha}>
<path d={frame.bodyPath} fill="var(--bloub-paper)" />
<g mask={`url(#${maskId})`}>
<rect
fill="currentColor"
height={DEMI_VIEWBOX * 2}
width={DEMI_VIEWBOX * 2}
x={-DEMI_VIEWBOX}
y={-DEMI_VIEWBOX}
/>
</g>
</g>
{!frame.dotsBehind ? (
<g>{frame.dots.map((dot, index) => renderDot(dot, index, "front"))}</g>
) : null}
{frame.notif ? (
<circle
cx={frame.notif.x}
cy={frame.notif.y}
fill={NOTIF_BLUE}
r={frame.notif.r}
/>
) : null}
<g fill="none" strokeLinecap="round">
{frame.arcs.map((arc) => (
<path
d={arc.front}
key={`arc-front-${arc.id}`}
opacity={arc.opacity}
stroke={`url(#${uid}-${arc.id})`}
strokeWidth={arc.width}
/>
))}
</g>
</svg>
);
}

View File

@ -46,7 +46,10 @@ import {
type AiWashControlLevel, type AiWashControlLevel,
type AiWashPreset, type AiWashPreset,
} from "@/lib/ai-wash/config"; } from "@/lib/ai-wash/config";
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download"; import {
downloadCanvasImageAsPng,
getCanvasImageDownloadErrorMessage,
} from "@/lib/client/canvas-image-download";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { imageGenerationService } from "@/services/canvas"; import { imageGenerationService } from "@/services/canvas";
@ -610,7 +613,9 @@ export function AiWashWorkspace({
[presetExecutionStates, selectedPreviewResult?.presetId, selectedResultId] [presetExecutionStates, selectedPreviewResult?.presetId, selectedResultId]
); );
const selectedPresetHasExecutableModel = Boolean( const selectedPresetHasExecutableModel = Boolean(
selectedExecutionState?.effectiveModelRef selectedExecutionState?.effectiveModelRef &&
selectedExecutionState?.resolvedModel &&
selectedExecutionState.resolvedModel.selectable !== false
); );
const shouldShowUserModelSelector = Boolean( const shouldShowUserModelSelector = Boolean(
!config.hideModelSelector && selectedExecutionState?.usesUserSelectableModel !config.hideModelSelector && selectedExecutionState?.usesUserSelectableModel
@ -757,7 +762,10 @@ export function AiWashWorkspace({
return; return;
} }
if (!selectedPresetHasExecutableModel) { if (!selectedPresetHasExecutableModel) {
toast.error("当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"); toast.error(
selectedExecutionState?.resolvedModel?.disabledReason ||
"当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"
);
return; return;
} }
@ -818,6 +826,15 @@ export function AiWashWorkspace({
if (!executionState.effectiveModelRef) { if (!executionState.effectiveModelRef) {
throw new Error(`${preset.title} 未配置可用模型`); throw new Error(`${preset.title} 未配置可用模型`);
} }
if (
!executionState.resolvedModel ||
executionState.resolvedModel.selectable === false
) {
throw new Error(
executionState.resolvedModel?.disabledReason ||
`${preset.title} 配置的模型不可用`
);
}
const submitResponse = await fetch("/api/ai-wash/generate", { const submitResponse = await fetch("/api/ai-wash/generate", {
method: "POST", method: "POST",
@ -825,7 +842,9 @@ export function AiWashWorkspace({
body: JSON.stringify({ body: JSON.stringify({
prompt: executionState.resolvedPrompt, prompt: executionState.resolvedPrompt,
referenceImage: requestReferenceImage, referenceImage: requestReferenceImage,
selectedModelId: executionState.effectiveModelRef, selectedModelId:
executionState.resolvedModel.id ||
executionState.effectiveModelRef,
imageSize: config.imageSize, imageSize: config.imageSize,
promptExtend: config.promptExtend, promptExtend: config.promptExtend,
title: preset.title, title: preset.title,
@ -1003,7 +1022,7 @@ export function AiWashWorkspace({
}); });
toast.success("图片已开始下载"); toast.success("图片已开始下载");
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败"); toast.error(getCanvasImageDownloadErrorMessage(error));
} finally { } finally {
setDownloadingResultId((current) => setDownloadingResultId((current) =>
current === downloadId ? null : current current === downloadId ? null : current

View File

@ -0,0 +1,311 @@
"use client";
import { useEffect, useState } from "react";
import {
audioContract,
audioRequestSchema,
buildAudioRequest,
type AudioRequest,
} from "@/lib/video-canvas/audio-contract";
type Model = { id: string; modelId: string; name: string; kind: string };
export function AudioGenerationComposer({
initial,
connectedText = "",
busy,
onSubmit,
onUpload,
onDraft,
}: {
initial?: Record<string, unknown>;
connectedText?: string;
busy: boolean;
onSubmit: (params: AudioRequest, draft: AudioRequest) => Promise<unknown>;
onUpload: (file: File) => Promise<void>;
onDraft: (params: Record<string, unknown>) => void;
}) {
const [models, setModels] = useState<Model[]>([]),
[error, setError] = useState(""),
[loading, setLoading] = useState(true),
[sending, setSending] = useState(false);
const [draft, setDraft] = useState<Record<string, any>>(() => ({
mode: "tts",
text: "",
voice: "Cherry",
format: "wav",
language: "Chinese",
instrumental: true,
gender: "female",
rate: 1,
pitch: 1,
volume: 50,
...initial,
}));
const patch = (value: Record<string, unknown>) => {
const next = { ...draft, ...value };
setDraft(next);
onDraft(next);
};
useEffect(() => {
const controller = new AbortController();
fetch("/api/canvas/audio/models", { signal: controller.signal })
.then(async (r) => {
const data = await r.json();
if (!r.ok) throw Error(data.error || "音频模型读取失败");
setModels(data.models || []);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, []);
const options = models.filter((m) => m.kind === draft.mode),
model = options.find((m) => m.id === draft.modelRef) || options[0],
contract = model ? audioContract(model.modelId) : undefined;
const voice = contract?.voices.some((v) => v.id === draft.voice)
? draft.voice
: contract?.voices[0]?.id;
const format = contract?.formats.includes(draft.format) ? draft.format : contract?.formats[0];
const input = "mt-1 w-full rounded-lg border border-white/15 bg-[#303030] p-2 text-sm";
const run = async () => {
setError("");
setSending(true);
try {
if (!model) throw Error("当前模式没有已启用的官方模型");
const params = audioRequestSchema.parse({
...draft,
text: [draft.text, connectedText].filter(Boolean).join("\n\n"),
modelRef: model.id,
voice,
format,
...(!contract?.controls ? { rate: 1, pitch: 1, volume: 50 } : {}),
...(!contract?.instructions ? { instructions: "" } : {}),
});
buildAudioRequest(params, model.modelId);
const savedDraft = { ...params, text: String(draft.text || "") };
patch(savedDraft);
await onSubmit(params, savedDraft);
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败");
} finally {
setSending(false);
}
};
return (
<section
className="fixed bottom-8 left-1/2 z-[100] max-h-[75vh] w-[min(620px,94vw)] -translate-x-1/2 overflow-y-auto rounded-2xl border border-white/10 bg-[#222] p-5 text-white shadow-xl"
data-canvas-overlay="true"
aria-label="阿里云音频生成"
>
<h3 className="font-semibold">语音与音乐 · 阿里云官方</h3>
<fieldset disabled={busy || sending} className="mt-3 space-y-3 disabled:opacity-60">
{connectedText && <p className="mb-3 whitespace-pre-wrap rounded bg-white/5 p-2 text-xs" aria-label="已连接文本">已连接文本:{connectedText}</p>}
<div className="grid grid-cols-2 gap-3">
<label>
生成类型
<select
aria-label="音频生成类型"
className={input}
value={draft.mode}
onChange={(e) => patch({ mode: e.target.value, modelRef: "" })}
>
<option value="tts">文字配音</option>
<option value="music">音乐 / 歌曲</option>
</select>
</label>
<label>
模型
<select
aria-label="音频模型"
className={input}
value={model?.id || ""}
onChange={(e) => patch({ modelRef: e.target.value })}
>
{!options.length ? (
<option value="">{loading ? "读取模型中…" : "暂无已开通模型"}</option>
) : (
options.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))
)}
</select>
</label>
</div>
<label className="block">
{draft.mode === "tts" ? "配音文本(最多 600 字)" : "音乐描述"}
<textarea
aria-label="音频内容"
className={input}
rows={4}
maxLength={draft.mode === "tts" ? 600 : 2000}
value={draft.text}
onChange={(e) => patch({ text: e.target.value })}
/>
</label>
{draft.mode === "tts" ? (
<>
<div className="grid grid-cols-2 gap-3">
<label>
音色
<select
aria-label="配音音色"
className={input}
value={voice || ""}
onChange={(e) => patch({ voice: e.target.value })}
>
{contract?.voices.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</label>
<label>
语言
<select
aria-label="配音语言"
className={input}
value={draft.language}
onChange={(e) => patch({ language: e.target.value })}
>
<option value="Chinese">中文</option>
<option value="English">英文</option>
<option value="Auto">自动 / 中英混合</option>
</select>
</label>
</div>
{contract?.instructions ? (
<label className="block">
声音风格指令
<input
className={input}
aria-label="声音风格指令"
placeholder="例如:温暖自然,语速略慢"
maxLength={1200}
value={draft.instructions || ""}
onChange={(e) => patch({ instructions: e.target.value })}
/>
</label>
) : null}
{contract?.controls ? (
<div className="grid grid-cols-3 gap-2">
{(["rate", "pitch", "volume"] as const).map((key, i) => (
<label key={key}>
{["语速", "音调", "音量"][i]}
<input
aria-label={["语速", "音调", "音量"][i]}
className={input}
type="number"
min={key === "volume" ? 0 : 0.5}
max={key === "volume" ? 100 : 2}
step={key === "volume" ? 1 : 0.1}
value={draft[key]}
onChange={(e) => patch({ [key]: Number(e.target.value) })}
/>
</label>
))}
</div>
) : null}
</>
) : (
<>
<label className="flex gap-2">
<input
type="checkbox"
checked={draft.instrumental}
onChange={(e) => patch({ instrumental: e.target.checked })}
/>
纯音乐(无人声)
</label>
{!draft.instrumental ? (
<>
<label className="block">
自定义歌词(选填,填写后优先于描述)
<textarea
aria-label="音乐歌词"
className={input}
rows={3}
maxLength={2000}
value={draft.lyrics || ""}
onChange={(e) => patch({ lyrics: e.target.value })}
/>
</label>
<label>
演唱声音
<select
className={input}
aria-label="演唱声音"
value={draft.gender}
onChange={(e) => patch({ gender: e.target.value })}
>
<option value="female">女声</option>
<option value="male">男声</option>
</select>
</label>
</>
) : null}
{!options.length ? (
<p className="text-sm text-amber-200">
Fun-Music 需要阿里云邀测权限,开通并启用模型后即可生成。
</p>
) : null}
</>
)}
<div className="flex items-end gap-3">
<label>
格式
<select
aria-label="音频格式"
className={input}
value={format || "wav"}
onChange={(e) => patch({ format: e.target.value })}
>
{(contract?.formats || ["wav"]).map((f) => (
<option key={f} value={f}>
{f.toUpperCase()}
</option>
))}
</select>
</label>
<button
type="button"
disabled={!model || loading}
className="rounded-lg bg-white px-4 py-2 text-black disabled:opacity-40"
onClick={() => void run()}
>
{busy || sending ? "处理中…" : "生成音频"}
</button>
<label className="cursor-pointer rounded-lg border border-white/20 px-3 py-2">
上传音频
<input
type="file"
accept="audio/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setSending(true);
try {
await onUpload(file);
} catch (e) {
setError(e instanceof Error ? e.message : "上传失败");
} finally {
setSending(false);
}
}}
/>
</label>
</div>
</fieldset>
{error ? (
<p role="alert" className="mt-3 text-sm text-red-300">
{error}
</p>
) : null}
</section>
);
}

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

@ -0,0 +1,125 @@
"use client";
import {
Download,
Play,
Ungroup,
Share2,
Grid3X3,
ScanLine,
} from "lucide-react";
export function CanvasGroupToolbar({
title,
color,
status,
busy,
storyboard,
canStoryboard,
onRename,
onColor,
onLayout,
onRun,
onUngroup,
onSave,
onDownload,
onStoryboard,
onCancel,
}: {
title: string;
color: string;
status: string;
busy: boolean;
storyboard: boolean;
canStoryboard: boolean;
onRename: (s: string) => void;
onColor: (s: string) => void;
onLayout: (s: "grid" | "horizontal" | "vertical") => void;
onRun: () => void;
onUngroup: () => void;
onSave: () => void;
onDownload: () => void;
onStoryboard: () => void;
onCancel: () => void;
}) {
return (
<div
data-canvas-overlay="true"
data-group-toolbar="true"
className="absolute left-1/2 top-[80px] z-[70] -translate-x-1/2 rounded-[22px] border border-white/10 bg-[#252525] p-4 text-white shadow-xl"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-5 whitespace-nowrap text-[16px]">
<input
aria-label="编组颜色"
type="color"
value={color}
onChange={(e) => onColor(e.target.value)}
className="h-7 w-7 cursor-pointer rounded-full border-0 bg-transparent"
/>
<label title="编组布局" className="flex items-center gap-1">
<Grid3X3 size={20} />
<select
aria-label="编组布局"
defaultValue=""
onChange={(e) => {
onLayout(e.target.value as any);
e.target.value = "";
}}
className="w-5 bg-[#252525]"
>
<option value="" disabled>
⌄
</option>
<option value="grid">网格布局</option>
<option value="horizontal">横向布局</option>
<option value="vertical">纵向布局</option>
</select>
</label>
<span className="h-8 w-px bg-white/10" />
<button
type="button"
disabled={busy}
onClick={onRun}
className="flex items-center gap-2 disabled:opacity-40"
>
<Play size={22} />
整组执行
</button>
<button onClick={onSave} className="flex items-center gap-2">
<Share2 size={22} />
添加到工具箱
</button>
<button
type="button"
disabled={!canStoryboard}
title={
canStoryboard ? "" : "分镜组需要全部成员都是已就绪的图片或视频"
}
onClick={onStoryboard}
className="flex items-center gap-2 disabled:opacity-35"
>
<ScanLine size={22} />
{storyboard ? "预览分镜" : "转分镜组"}
</button>
<button onClick={onUngroup} className="flex items-center gap-2">
<Ungroup size={22} />
解组
</button>
<span className="h-8 w-px bg-white/10" />
<button title="下载编组模板" onClick={onDownload}>
<Download size={22} />
</button>
</div>
<div className="mt-3 flex items-center justify-between gap-4 text-xs text-white/55">
<input
aria-label="编组名称"
value={title}
onChange={(e) => onRename(e.target.value)}
maxLength={120}
className="w-48 border-b border-white/10 bg-transparent py-1"
/>
<span>{status || "Shift 点击多选 · ⌘/Ctrl+G 编组"}</span>
{busy && <button onClick={onCancel}>取消未开始任务</button>}
</div>
</div>
);
}

View File

@ -0,0 +1,953 @@
"use client";
import {
CircleAlert,
ImagePlus,
Images,
LayoutTemplate,
Loader2,
Plus,
RefreshCw,
Trash2,
Upload,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils";
import {
getAvailableCanvasLayoutReplaceSources,
getLayoutReplaceSourceIdentity,
getReadyCanvasLayoutReplaceSource,
type LayoutReplaceSource,
mergeUniqueLayoutReplaceSources,
} from "@/lib/canvas/layout-replace-source-selection";
import {
imageGenerationService,
type CanvasLayoutReplaceBatchItemSnapshot,
type CanvasLayoutReplaceBatchSnapshot,
} from "@/services/canvas";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, Size } from "@/types/canvas";
const MAX_BATCH_SOURCES = 5;
const ACTIVE_BATCH_STATUSES = new Set(["pending", "running"]);
type CanvasLayoutReplaceBatchDialogProps = {
costLabel?: string | null;
onOpenChange: (open: boolean) => void;
open: boolean;
primaryNode: CanvasNode;
};
function isHttpImageUrl(value: unknown) {
return /^https?:\/\//i.test(String(value || "").trim());
}
function buildInitialSources(
primaryNode: CanvasNode,
nodes: CanvasNode[],
selectedNodeIds: string[]
) {
const selectedById = new Map(nodes.map((node) => [node.id, node]));
const orderedIds = [primaryNode.id, ...selectedNodeIds.filter((id) => id !== primaryNode.id)];
const sources: LayoutReplaceSource[] = [];
const seen = new Set<string>();
for (const id of orderedIds) {
const node = id === primaryNode.id ? primaryNode : selectedById.get(id);
if (!node) continue;
const source = getReadyCanvasLayoutReplaceSource(node);
if (!source) continue;
const identity = getLayoutReplaceSourceIdentity(source);
if (seen.has(identity)) continue;
seen.add(identity);
sources.push(source);
if (sources.length >= MAX_BATCH_SOURCES) break;
}
return sources;
}
function sourcesFromBatch(batch: CanvasLayoutReplaceBatchSnapshot) {
return batch.items.map((item) => ({
id: item.sourceNodeId ? `canvas:${item.sourceNodeId}` : `upload:${item.id}`,
imageUrl: item.sourceImageUrl,
kind: item.sourceKind === "upload" ? ("upload" as const) : ("canvas" as const),
nodeId: item.sourceNodeId || undefined,
title: item.sourceTitle || `图片 ${item.position}`,
}));
}
function getBatchStatusLabel(status: string) {
if (status === "succeeded") return "全部完成";
if (status === "partial_failed") return "部分失败";
if (status === "failed") return "处理失败";
if (status === "running") return "处理中";
return "排队中";
}
function getItemStatusLabel(item: CanvasLayoutReplaceBatchItemSnapshot) {
if (item.status === "succeeded") return item.addedToCanvas ? "已加入画布" : "生成完成";
if (item.status === "failed") return "生成失败";
if (item.status === "running") return "正在生成";
return "排队中";
}
async function resolveImageSize(url: string, fallback: Size) {
if (typeof window === "undefined") return fallback;
return new Promise<Size>((resolve) => {
const image = new window.Image();
const timeout = window.setTimeout(() => resolve(fallback), 6000);
image.onload = () => {
window.clearTimeout(timeout);
const naturalWidth = image.naturalWidth || fallback.width;
const naturalHeight = image.naturalHeight || fallback.height;
const width = Math.max(180, Math.min(720, fallback.width || 360));
resolve({
width,
height: Math.max(120, Math.min(1000, Math.round((width * naturalHeight) / naturalWidth))),
});
};
image.onerror = () => {
window.clearTimeout(timeout);
resolve(fallback);
};
image.src = buildImageProxyUrl(url, { delivery: "proxy" });
});
}
export function CanvasLayoutReplaceBatchDialog({
costLabel,
onOpenChange,
open,
primaryNode,
}: CanvasLayoutReplaceBatchDialogProps) {
const allNodes = useCanvasStore((state) => state.nodes);
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
const projectId = useCanvasStore((state) => state.projectId);
const addNode = useCanvasStore((state) => state.addNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const canvasImageSelectionSession = useCanvasStore(
(state) => state.canvasImageSelectionSession
);
const startCanvasImageSelection = useCanvasStore(
(state) => state.startCanvasImageSelection
);
const endCanvasImageSelection = useCanvasStore(
(state) => state.endCanvasImageSelection
);
const sourceUploadInputRef = useRef<HTMLInputElement>(null);
const referenceUploadInputRef = useRef<HTMLInputElement>(null);
const seenSucceededItemIdsRef = useRef(new Set<string>());
const [sources, setSources] = useState<LayoutReplaceSource[]>(() =>
buildInitialSources(primaryNode, allNodes, selectedNodeIds)
);
const [referenceImageUrl, setReferenceImageUrl] = useState("");
const [supplementalPrompt, setSupplementalPrompt] = useState("");
const [batch, setBatch] = useState<CanvasLayoutReplaceBatchSnapshot | null>(null);
const [selectedResultIds, setSelectedResultIds] = useState<Set<string>>(new Set());
const [isLoadingBatch, setIsLoadingBatch] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isUploadingSources, setIsUploadingSources] = useState(false);
const [isUploadingReference, setIsUploadingReference] = useState(false);
const [isAddingResults, setIsAddingResults] = useState(false);
const [retryingItemId, setRetryingItemId] = useState<string | null>(null);
const readyCanvasSources = useMemo(
() =>
allNodes
.map(getReadyCanvasLayoutReplaceSource)
.filter(Boolean) as LayoutReplaceSource[],
[allNodes]
);
const activeCanvasImageSelectionSession =
canvasImageSelectionSession?.ownerNodeId === primaryNode.id
? canvasImageSelectionSession
: null;
const successfulItems = useMemo(
() => batch?.items.filter((item) => item.status === "succeeded" && item.resultImageUrl) || [],
[batch]
);
const batchIsActive = Boolean(batch && ACTIVE_BATCH_STATUSES.has(batch.status));
const terminalItemCount = batch
? batch.items.filter((item) => item.status === "succeeded" || item.status === "failed").length
: 0;
const batchProgress = batch
? Math.round((terminalItemCount / Math.max(1, batch.totalCount)) * 100)
: 0;
const applyBatchSnapshot = useCallback((nextBatch: CanvasLayoutReplaceBatchSnapshot) => {
setBatch(nextBatch);
setSources(sourcesFromBatch(nextBatch));
setReferenceImageUrl(nextBatch.referenceImageUrl);
setSupplementalPrompt(nextBatch.supplementalPrompt || "");
const newlySucceeded = nextBatch.items
.filter(
(item) =>
item.status === "succeeded" &&
!item.addedToCanvas &&
!seenSucceededItemIdsRef.current.has(item.id)
)
.map((item) => item.id);
if (newlySucceeded.length > 0) {
setSelectedResultIds((current) => new Set([...current, ...newlySucceeded]));
}
nextBatch.items
.filter((item) => item.status === "succeeded")
.forEach((item) => seenSucceededItemIdsRef.current.add(item.id));
}, []);
const loadBatch = useCallback(
async (batchId: string) => {
const result = await imageGenerationService.getLayoutReplaceBatch(batchId);
if (result.success && result.data?.batch) {
applyBatchSnapshot(result.data.batch);
return result.data.batch;
}
return null;
},
[applyBatchSnapshot]
);
useEffect(() => {
if (!open) return;
const currentCanvas = useCanvasStore.getState();
const currentPrimaryNode =
currentCanvas.nodes.find((node) => node.id === primaryNode.id) || primaryNode;
const initialSources = buildInitialSources(
currentPrimaryNode,
currentCanvas.nodes,
currentCanvas.selectedNodeIds
);
setSources(initialSources);
setSelectedResultIds(new Set());
seenSucceededItemIdsRef.current = new Set();
if (!projectId) {
setBatch(null);
return;
}
let cancelled = false;
setIsLoadingBatch(true);
void imageGenerationService
.getLatestLayoutReplaceBatch({ projectId, sourceNodeId: primaryNode.id })
.then((result) => {
if (cancelled) return;
if (result.success && result.data?.batch) {
applyBatchSnapshot(result.data.batch);
} else {
setBatch(null);
}
})
.finally(() => {
if (!cancelled) setIsLoadingBatch(false);
});
return () => {
cancelled = true;
};
}, [applyBatchSnapshot, open, primaryNode.id, projectId]);
useEffect(
() => () => {
endCanvasImageSelection(primaryNode.id);
},
[endCanvasImageSelection, primaryNode.id]
);
useEffect(() => {
if (!activeCanvasImageSelectionSession) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
endCanvasImageSelection(primaryNode.id);
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [activeCanvasImageSelectionSession, endCanvasImageSelection, primaryNode.id]);
useEffect(() => {
if (!open || !batch?.id || !batchIsActive) return;
const interval = window.setInterval(() => {
void loadBatch(batch.id);
}, 2500);
return () => window.clearInterval(interval);
}, [batch?.id, batchIsActive, loadBatch, open]);
const startNewBatchDraft = useCallback(() => {
setBatch(null);
setSelectedResultIds(new Set());
seenSucceededItemIdsRef.current = new Set();
setSources(buildInitialSources(primaryNode, allNodes, selectedNodeIds));
}, [allNodes, primaryNode, selectedNodeIds]);
const removeSource = useCallback((sourceId: string) => {
setSources((current) => current.filter((source) => source.id !== sourceId));
}, []);
const startCanvasSourceSelection = useCallback(() => {
if (canvasImageSelectionSession) return;
const remainingCount = MAX_BATCH_SOURCES - sources.length;
if (remainingCount <= 0) {
toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`);
return;
}
const availableSources = getAvailableCanvasLayoutReplaceSources(
readyCanvasSources,
sources
);
if (availableSources.length === 0) {
toast.info("画布中暂无其他可添加的图片");
return;
}
startCanvasImageSelection({
ownerNodeId: primaryNode.id,
allowedNodeIds: availableSources.flatMap((source) =>
source.nodeId ? [source.nodeId] : []
),
maxCount: remainingCount,
});
}, [
canvasImageSelectionSession,
primaryNode.id,
readyCanvasSources,
sources,
startCanvasImageSelection,
]);
const cancelCanvasSourceSelection = useCallback(() => {
endCanvasImageSelection(primaryNode.id);
}, [endCanvasImageSelection, primaryNode.id]);
const confirmCanvasSourceSelection = useCallback(() => {
if (!activeCanvasImageSelectionSession) return;
const selectedIds = new Set(activeCanvasImageSelectionSession.selectedNodeIds);
const selectedSources = readyCanvasSources.filter(
(source) => source.nodeId && selectedIds.has(source.nodeId)
);
if (selectedSources.length === 0) return;
setSources((current) => {
return mergeUniqueLayoutReplaceSources(
current,
selectedSources,
MAX_BATCH_SOURCES
);
});
endCanvasImageSelection(primaryNode.id);
toast.success(`已从画布添加 ${selectedSources.length} 张图片`);
}, [
activeCanvasImageSelectionSession,
endCanvasImageSelection,
primaryNode.id,
readyCanvasSources,
]);
const handleSourceUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.currentTarget.files || []).filter((file) =>
file.type.startsWith("image/")
);
event.currentTarget.value = "";
const remaining = Math.max(0, MAX_BATCH_SOURCES - sources.length);
if (remaining === 0 || files.length === 0) return;
const selectedFiles = files.slice(0, remaining);
if (selectedFiles.length < files.length) {
toast.info(`最多添加 ${MAX_BATCH_SOURCES} 张图片`);
}
setIsUploadingSources(true);
try {
const uploaded: LayoutReplaceSource[] = [];
for (const file of selectedFiles) {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.url) {
throw new Error(result.error || `${file.name} 上传失败`);
}
uploaded.push({
id: `upload:${crypto.randomUUID()}`,
imageUrl: result.data.url,
kind: "upload",
title: file.name.replace(/\.[^.]+$/, "") || "上传图片",
});
}
setSources((current) => {
return mergeUniqueLayoutReplaceSources(
current,
uploaded,
MAX_BATCH_SOURCES
);
});
toast.success(`已上传 ${uploaded.length} 张图片`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "图片上传失败");
} finally {
setIsUploadingSources(false);
}
},
[sources.length]
);
const handleReferenceUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (!file || !file.type.startsWith("image/")) return;
setIsUploadingReference(true);
try {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.url) {
throw new Error(result.error || "目标模版图上传失败");
}
setReferenceImageUrl(result.data.url);
toast.success("目标模版图已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "目标模版图上传失败");
} finally {
setIsUploadingReference(false);
}
}, []);
const handleSubmit = useCallback(async () => {
if (!projectId) {
toast.error("当前画布尚未保存,无法创建批次");
return;
}
if (sources.length === 0) {
toast.error("请至少添加一张待处理图片");
return;
}
if (!isHttpImageUrl(referenceImageUrl)) {
toast.error("请上传目标模版图");
return;
}
if (isSubmitting || isUploadingSources || isUploadingReference) return;
setIsSubmitting(true);
try {
const result = await imageGenerationService.createLayoutReplaceBatch({
projectId,
sources: sources.map((source) => ({
sourceImageUrl: source.imageUrl,
sourceKind: source.kind,
sourceNodeId: source.nodeId,
sourceTitle: source.title,
})),
referenceImageUrl,
supplementalPrompt,
imageSize: "2K",
});
if (!result.success || !result.data?.batchId) {
throw new Error(result.error || "创建批次失败");
}
const nextBatch = await loadBatch(result.data.batchId);
if (!nextBatch) throw new Error("批次已创建,但状态读取失败");
toast.success(`已提交 ${sources.length} 张图片,正在顺序处理`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建批次失败");
} finally {
setIsSubmitting(false);
}
}, [
isSubmitting,
isUploadingReference,
isUploadingSources,
loadBatch,
projectId,
referenceImageUrl,
sources,
supplementalPrompt,
]);
const addItemsToCanvas = useCallback(
async (items: CanvasLayoutReplaceBatchItemSnapshot[]) => {
if (!batch || items.length === 0 || isAddingResults) return;
setIsAddingResults(true);
try {
const created: Array<{ itemId: string; canvasNodeId: string }> = [];
for (const [index, item] of items.entries()) {
if (!item.resultImageUrl || item.addedToCanvas) continue;
const sourceNode = allNodes.find((candidate) => candidate.id === item.sourceNodeId);
const anchor = sourceNode || primaryNode;
const resultSize = await resolveImageSize(item.resultImageUrl, anchor.size);
const canvasNodeId = addNode({
type: "image",
position: {
x: anchor.position.x + anchor.size.width + 20,
y: anchor.position.y + index * 24,
},
size: resultSize,
data: {
src: item.resultImageUrl,
thumbnailUrl: item.resultThumbnailUrl || item.resultImageUrl,
status: "completed",
prompt: "批量排版替换结果",
title: `${item.sourceTitle || `图片 ${item.position}`}-排版替换`,
originalSrc: item.sourceImageUrl,
model: item.resultModelId || undefined,
metadata: {
editMode: "layout-replace",
batchId: batch.id,
batchItemId: item.id,
taskId: item.taskId,
sourceNodeId: item.sourceNodeId,
layoutReferenceUrl: batch.referenceImageUrl,
layoutReplaceCompletedAt: new Date().toISOString(),
},
},
connections: item.sourceNodeId ? [item.sourceNodeId] : [],
});
created.push({ itemId: item.id, canvasNodeId });
}
if (created.length === 0) return;
selectNodes(created.map((item) => item.canvasNodeId));
const marked = await imageGenerationService.markLayoutReplaceBatchItemsAdded({
batchId: batch.id,
items: created,
});
if (!marked.success) {
toast.warning("结果已加入画布,但同步批次状态失败");
}
await loadBatch(batch.id);
setSelectedResultIds((current) => {
const next = new Set(current);
created.forEach((item) => next.delete(item.itemId));
return next;
});
toast.success(`已将 ${created.length} 张结果加入画布`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "添加到画布失败");
} finally {
setIsAddingResults(false);
}
},
[addNode, allNodes, batch, isAddingResults, loadBatch, primaryNode, selectNodes]
);
const selectedSuccessfulItems = successfulItems.filter(
(item) => selectedResultIds.has(item.id) && !item.addedToCanvas
);
const retryItem = useCallback(
async (item: CanvasLayoutReplaceBatchItemSnapshot) => {
if (!batch || batchIsActive || retryingItemId) return;
setRetryingItemId(item.id);
try {
const result = await imageGenerationService.retryLayoutReplaceBatchItem({
batchId: batch.id,
itemId: item.id,
});
if (!result.success) throw new Error(result.error || "重试失败");
await loadBatch(batch.id);
toast.success("已重新加入生成队列");
} catch (error) {
toast.error(error instanceof Error ? error.message : "重试失败");
} finally {
setRetryingItemId(null);
}
},
[batch, batchIsActive, loadBatch, retryingItemId]
);
return (
<>
<Dialog
onOpenChange={(nextOpen) => {
if (!activeCanvasImageSelectionSession) onOpenChange(nextOpen);
}}
open={open && !activeCanvasImageSelectionSession}
>
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0 sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)]">
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12 text-left sm:px-6 sm:py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<DialogTitle className="flex items-center gap-2 text-base">
<LayoutTemplate className="h-4 w-4" />
AI 模版替换
</DialogTitle>
{batch ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{getBatchStatusLabel(batch.status)}</span>
<span>{terminalItemCount}/{batch.totalCount}</span>
</div>
) : null}
</div>
<DialogDescription className="sr-only">
最多选择五张图片,按照目标模版顺序生成并添加到画布
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">
{isLoadingBatch ? (
<div className="flex min-h-[360px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label>待处理图片 {sources.length}/{MAX_BATCH_SOURCES}</Label>
{!batch ? (
<div className="flex items-center gap-2">
<Button
disabled={
sources.length >= MAX_BATCH_SOURCES ||
Boolean(canvasImageSelectionSession)
}
onClick={startCanvasSourceSelection}
size="sm"
type="button"
variant="outline"
>
<Images className="mr-1.5 h-4 w-4" />
从画布中添加
</Button>
<input
accept="image/*"
className="hidden"
multiple
onChange={(event) => void handleSourceUpload(event)}
ref={sourceUploadInputRef}
type="file"
/>
<Button
disabled={isUploadingSources || sources.length >= MAX_BATCH_SOURCES}
onClick={() => sourceUploadInputRef.current?.click()}
size="sm"
type="button"
variant="outline"
>
{isUploadingSources ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-1.5 h-4 w-4" />
)}
上传图片
</Button>
</div>
) : null}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: MAX_BATCH_SOURCES }, (_, index) => {
const source = sources[index];
return (
<div
className="relative aspect-square min-w-0 overflow-hidden rounded-md border bg-muted/25"
key={source?.id || `empty-${index}`}
>
<span className="absolute left-1.5 top-1.5 z-10 flex h-5 min-w-5 items-center justify-center rounded bg-black/65 px-1 text-[10px] text-white">
{index + 1}
</span>
{source ? (
<>
<img
alt={source.title}
className="h-full w-full object-cover"
src={buildImageProxyUrl(source.imageUrl, { width: 240 })}
/>
{!batch && index > 0 ? (
<button
aria-label={`移除第 ${index + 1} 张图片`}
className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-md bg-black/65 text-white hover:bg-black/80"
onClick={() => removeSource(source.id)}
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
<div className="absolute inset-x-0 bottom-0 truncate bg-black/65 px-2 py-1.5 text-[11px] text-white">
{source.title}
</div>
</>
) : (
<button
aria-label={`添加第 ${index + 1} 张图片`}
className="flex h-full w-full items-center justify-center text-muted-foreground hover:bg-muted/50"
disabled={Boolean(batch)}
onClick={() => sourceUploadInputRef.current?.click()}
type="button"
>
<Plus className="h-5 w-5" />
</button>
)}
</div>
);
})}
</div>
<div className="space-y-2">
<Label htmlFor={`layout-batch-prompt-${primaryNode.id}`}>补充要求</Label>
<Textarea
className="min-h-[88px] max-h-[180px]"
disabled={Boolean(batch)}
id={`layout-batch-prompt-${primaryNode.id}`}
onChange={(event) => setSupplementalPrompt(event.currentTarget.value)}
placeholder="可选,只写产品替换细节"
value={supplementalPrompt}
/>
</div>
</section>
<section className="min-w-0 space-y-2">
<Label htmlFor={`layout-batch-reference-${primaryNode.id}`}>目标模版图</Label>
<Input
disabled={Boolean(batch)}
id={`layout-batch-reference-${primaryNode.id}`}
onChange={(event) => setReferenceImageUrl(event.currentTarget.value)}
placeholder="https://..."
value={referenceImageUrl}
/>
<input
accept="image/*"
className="hidden"
onChange={(event) => void handleReferenceUpload(event)}
ref={referenceUploadInputRef}
type="file"
/>
<button
className="flex h-[clamp(180px,34dvh,360px)] w-full items-center justify-center overflow-hidden rounded-md border border-dashed bg-muted/20"
disabled={Boolean(batch) || isUploadingReference}
onClick={() => referenceUploadInputRef.current?.click()}
type="button"
>
{isUploadingReference ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : isHttpImageUrl(referenceImageUrl) ? (
<img
alt="目标模版图"
className="h-full w-full object-contain"
src={buildImageProxyUrl(referenceImageUrl, { width: 720 })}
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImagePlus className="h-6 w-6" />
<span className="text-xs">上传目标模版图</span>
</div>
)}
</button>
</section>
</div>
{batch ? (
<section className="space-y-3 border-t pt-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<Label>生成结果</Label>
<div className="text-xs text-muted-foreground">
{getBatchStatusLabel(batch.status)} · {terminalItemCount}/{batch.totalCount}
</div>
</div>
{!batchIsActive ? (
<Button onClick={startNewBatchDraft} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
新建批次
</Button>
) : null}
</div>
<Progress className="h-1.5" value={batchProgress} />
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{batch.items.map((item) => {
const canAdd = item.status === "succeeded" && Boolean(item.resultImageUrl) && !item.addedToCanvas;
const checked = selectedResultIds.has(item.id);
return (
<div className="overflow-hidden rounded-md border" key={item.id}>
<div className="grid grid-cols-2 gap-px bg-border">
<div className="aspect-square bg-muted">
<img
alt={item.sourceTitle || `图片 ${item.position}`}
className="h-full w-full object-cover"
src={buildImageProxyUrl(item.sourceImageUrl, { width: 280 })}
/>
</div>
<div className="relative flex aspect-square items-center justify-center bg-muted/40">
{item.resultImageUrl ? (
<img
alt={`结果 ${item.position}`}
className="h-full w-full object-cover"
src={buildImageProxyUrl(item.resultThumbnailUrl || item.resultImageUrl, { width: 280 })}
/>
) : item.status === "failed" ? (
<CircleAlert className="h-6 w-6 text-destructive" />
) : item.status === "running" ? (
<Loader2 className="h-6 w-6 animate-spin text-primary" />
) : (
<Loader2 className="h-5 w-5 text-muted-foreground" />
)}
{canAdd ? (
<label className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded bg-background/90 shadow-sm">
<input
checked={checked}
className="h-4 w-4"
onChange={(event) => {
setSelectedResultIds((current) => {
const next = new Set(current);
if (event.currentTarget.checked) next.add(item.id);
else next.delete(item.id);
return next;
});
}}
type="checkbox"
/>
</label>
) : null}
</div>
</div>
<div className="flex min-h-12 items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium">
{item.sourceTitle || `图片 ${item.position}`}
</div>
<div
className={cn(
"text-[11px] text-muted-foreground",
item.status === "failed" && "text-destructive"
)}
>
{getItemStatusLabel(item)}
</div>
</div>
{canAdd ? (
<Button
disabled={isAddingResults}
onClick={() => void addItemsToCanvas([item])}
size="sm"
type="button"
variant="outline"
>
加入画布
</Button>
) : item.status === "failed" && !batchIsActive ? (
<Button
disabled={Boolean(retryingItemId)}
onClick={() => void retryItem(item)}
size="sm"
type="button"
variant="outline"
>
{retryingItemId === item.id ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
重试
</Button>
) : null}
</div>
{item.error ? (
<div className="border-t px-3 py-2 text-xs text-destructive">
{item.error}
</div>
) : null}
</div>
);
})}
</div>
</section>
) : null}
</div>
)}
</div>
<DialogFooter className="shrink-0 gap-2 border-t bg-background px-4 py-3 sm:px-6">
<Button onClick={() => onOpenChange(false)} type="button" variant="outline">
关闭
</Button>
{batch ? (
<Button
disabled={selectedSuccessfulItems.length === 0 || isAddingResults}
onClick={() => void addItemsToCanvas(selectedSuccessfulItems)}
type="button"
>
{isAddingResults ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Images className="mr-2 h-4 w-4" />
)}
添加所选到画布 ({selectedSuccessfulItems.length})
</Button>
) : (
<Button
disabled={
sources.length === 0 ||
!isHttpImageUrl(referenceImageUrl) ||
isSubmitting ||
isUploadingReference ||
isUploadingSources
}
onClick={() => void handleSubmit()}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LayoutTemplate className="mr-2 h-4 w-4" />
)}
批量处理 {sources.length} 张
{costLabel ? (
<span className="ml-1 rounded-full bg-white/18 px-1.5 py-0.5 text-[10px] text-white">
{sources.length} × {costLabel}
</span>
) : null}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{activeCanvasImageSelectionSession && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed inset-x-0 bottom-4 z-[10020] flex justify-center px-3 sm:bottom-6"
data-canvas-layout-replace-picker="true"
>
<div
className="pointer-events-auto flex w-full max-w-[720px] flex-wrap items-center gap-2 rounded-lg border border-border bg-background px-3 py-2.5 shadow-2xl sm:flex-nowrap sm:px-4"
data-canvas-overlay="true"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-[#168cff] text-white">
<Images className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium">选择画布图片</div>
<div className="truncate text-xs text-muted-foreground">
已选 {activeCanvasImageSelectionSession.selectedNodeIds.length}/
{activeCanvasImageSelectionSession.maxCount}
</div>
</div>
</div>
<Button
onClick={cancelCanvasSourceSelection}
size="sm"
type="button"
variant="outline"
>
取消
</Button>
<Button
disabled={activeCanvasImageSelectionSession.selectedNodeIds.length === 0}
onClick={confirmCanvasSourceSelection}
size="sm"
type="button"
>
确认添加到图片卡槽
</Button>
</div>
</div>,
document.body
)
: null}
</>
);
}

View File

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

View File

@ -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} />;
} }

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