Compare commits

..

362 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
398b2ec83c feat(batch): sync dedicated image tools 2026-08-11 14:05:38 +08:00
134
bbd0795fdc fix(image-tools): restore dedicated processing fallbacks 2026-08-11 13:48:33 +08:00
134
7d67bd1059 fix(claw): resume durable long-running tasks 2026-08-11 11:58:49 +08:00
134
aebc7edecb fix(claw): preserve archive preview metadata 2026-08-11 10:21:48 +08:00
134
bcff82aeca feat(claw): add secure forms and folder uploads 2026-08-11 10:16:02 +08:00
134
2fee4a4a8c fix: reconcile VIP98K media catalog 2026-08-10 23:20:54 +08:00
134
e225e06f0b fix: stabilize 3d workspace and canvas model controls 2026-08-10 20:54:23 +08:00
134
74ea0ca9fc fix(ai): remove VIP98K text-to-image models only 2026-08-10 16:31:23 +08:00
134
ab306e298b Revert "fix(ai): block costly and text-to-image VIP98K models"
This reverts commit 6b2c2c7b42d8a893b170736c06448f99aa059881.
2026-08-10 15:45:28 +08:00
root
65e9b26ad2 Merge branch 'codex/48-main-sync-20260714-150806' of http://git.1080p.hk/lunanting/ai-chatbot into codex/48-main-sync-20260714-150806 2026-08-10 14:22:27 +08:00
134
6b2c2c7b42 fix(ai): block costly and text-to-image VIP98K models 2026-08-10 14:21:18 +08:00
134
0a17a2842c fix(ai): block costly and text-to-image VIP98K models 2026-08-10 14:18:45 +08:00
134
0a1026c237 fix(ai): keep dedicated 3d provider frontend route 2026-08-09 18:46:56 +08:00
134
9438cf2f36 fix(ai): keep dedicated 3d provider frontend route 2026-08-09 18:45:09 +08:00
134
e1e7e0ccd4 fix(ai): store provider model metadata as jsonb objects 2026-08-09 18:39:07 +08:00
134
8fb4873eac fix(ai): store provider model metadata as jsonb objects 2026-08-09 18:37:05 +08:00
134
5decd202a1 fix(ai): preserve realtime canvas and normalize model types 2026-08-09 18:20:46 +08:00
134
2ad794d6e1 fix(ai): preserve realtime canvas and normalize model types 2026-08-09 18:07:01 +08:00
134
27736f0a5d fix(ai): preserve HiCAD provider integration 2026-08-09 17:30:33 +08:00
134
96c4b76250 fix(ai): preserve HiCAD provider integration 2026-08-09 17:29:01 +08:00
134
578f97b248 fix(model3d): preserve Tripo provider integration 2026-08-09 15:28:57 +08:00
134
c962fff811 fix(model3d): preserve Tripo provider integration 2026-08-09 15:26:30 +08:00
134
4c6ce023eb fix(ai): add VIP98K expansion fallback 2026-08-08 17:57:30 +08:00
134
658c3345f3 fix(ai): add VIP98K expansion fallback 2026-08-08 17:56:18 +08:00
134
4f43c7a8c7 fix(ai): honor VIP98K invocation contracts 2026-08-08 17:35:47 +08:00
134
93158e0b7b fix(ai): honor VIP98K invocation contracts 2026-08-08 17:33:44 +08:00
134
a326cf6935 fix(ai): route VIP98K reference edits through task API 2026-08-08 17:21:03 +08:00
134
2676d9a96f fix(ai): route VIP98K reference edits through task API 2026-08-08 17:18:55 +08:00
134
82088274be fix(ai): allow VIP98K catalog in server scripts 2026-08-08 17:02:15 +08:00
134
f9ded76a38 fix(ai): allow VIP98K catalog in server scripts 2026-08-08 16:59:25 +08:00
134
4a1536515e feat(ai): consolidate providers around VIP98K and FAL 2026-08-08 16:52:14 +08:00
134
725a93331a feat(ai): add VIP98K provider foundation on 48 baseline 2026-08-08 16:52:14 +08:00
134
f414150e73 feat(ai): consolidate providers around VIP98K and FAL 2026-08-08 16:40:18 +08:00
134
311d4a0ec3 feat(ai): add VIP98K provider foundation on 48 baseline 2026-08-08 16:40:01 +08:00
134
30b7ae168f fix(claw): use static thirty-minute route duration 2026-08-07 15:24:17 +08:00
134
54cd02d09d feat: improve Claw workflows and reliability 2026-08-07 14:52:17 +08:00
134
f1b769490a fix(claw): allow ten-minute chat tasks 2026-08-07 12:12:11 +08:00
root
7708b66e8d backup: preserve server changes before OpenClaw deploy 2026-08-07 11:43:46 +08:00
134
2f00645cd4 feat: add user-scoped OpenClaw MCP access 2026-08-07 11:31:00 +08:00
134
a68f9832ed fix(claw): persist and recover background chat tasks 2026-08-06 12:58:01 +08:00
134
c2fcf11280 fix(canvas): raise Claw skill tag limit 2026-08-05 21:36:11 +08:00
134
ee5b5d146d fix(canvas): dedupe Claw table images 2026-08-05 19:18:51 +08:00
134
c6c7c703b5 fix(canvas): handle expired sessions for Claw skills 2026-08-05 00:14:06 +08:00
134
233c7b3308 feat: add Claw skill search 2026-08-05 00:04:36 +08:00
134
d467694aa7 style: lighten homepage promo banner text 2026-08-04 23:38:09 +08:00
134
98c8dd20ee feat: add configurable homepage promo banner 2026-08-04 21:41:38 +08:00
134
31567af44e feat(canvas): allow resizing model selector 2026-08-04 21:41:00 +08:00
root
d3a6497672 fix: normalize WorldLabs VIP98K endpoint 2026-08-04 21:39:59 +08:00
134
e3180c23ac fix: persist Claw spreadsheet attachments 2026-07-28 21:12:18 +08:00
134
fb8d8232dc feat: support video attachments in Claw chat 2026-07-28 15:50:47 +08:00
134
21ba23c4ab fix: reconcile HiCAD manufacturing geometry 2026-07-28 14:26:05 +08:00
134
07aef767b9 fix: preserve HiCAD hole annotations 2026-07-28 13:53:07 +08:00
134
a2c9404a3d fix: stabilize HiCAD manufacturing dimensions 2026-07-28 13:53:07 +08:00
134
0397bf3a88 fix: correct HiCAD BOM geometry 2026-07-28 10:42:30 +08:00
134
8e89bb179e feat: add HiCAD editor project shortcuts 2026-07-27 23:55:51 +08:00
134
016076015e feat: add Claw product lifecycle shortcuts 2026-07-27 23:17:48 +08:00
134
43fd3d258a feat: simplify HiCAD project management 2026-07-27 22:58:10 +08:00
134
1a9888d825 fix: harden canvas image generation flows
(cherry picked from commit 78b0da908c4a0c22bd7eb66b5a661c07fcecb4fc)
2026-07-27 14:41:08 +00:00
134
2c81486862 feat: generate complete front and side part drawings 2026-07-27 22:36:42 +08:00
134
b5b5398b30 fix: render HiCAD exploded fallback and reposition trigger 2026-07-27 22:31:57 +08:00
134
cc113155dd fix: compact HiCAD engineering sheet header 2026-07-27 22:25:51 +08:00
134
5565829c65 test: validate HiCAD hardware production drawings 2026-07-27 22:20:56 +08:00
134
d84f3dc7d8 feat: add HiCAD hole dimension production drawings 2026-07-27 22:04:42 +08:00
134
e40c351f80 fix: render HiCAD orthographic views and Chinese BOM names 2026-07-27 21:48:02 +08:00
134
dc7f58d940 fix: correct HiCAD exploded previews and BOM templates 2026-07-27 21:37:19 +08:00
134
46f1d97593 fix: make HiCAD engineering analysis resilient 2026-07-27 21:06:25 +08:00
134
3b3f9e71ed fix: normalize HiCAD engine responses 2026-07-27 20:54:59 +08:00
134
9c9e462682 feat: restore isolated HiCAD engineering tools 2026-07-27 20:46:18 +08:00
134
4c21fd4844 fix: exclude HiCAD templates from app typecheck 2026-07-27 20:34:20 +08:00
134
623ab0f8b5 fix: isolate latest HiCAD startup 2026-07-27 20:24:53 +08:00
134
bbe27bd555 fix: isolate HiCAD manufacturing extension 2026-07-27 20:09:39 +08:00
134
d93b2e8ea7 feat: brand HiCAD production sheets 2026-07-27 19:58:35 +08:00
134
7435e7b0f0 feat: add HiCAD manufacturing drawings 2026-07-27 19:58:35 +08:00
134
08d2503e11 fix: use site favicon in HiCAD 2026-07-27 19:42:35 +08:00
134
240352362a feat: upgrade embedded HiCAD to latest official source 2026-07-27 19:33:59 +08:00
134
f65b9e0472 fix: frame complete HiCAD models after generation 2026-07-27 19:20:57 +08:00
134
11deb3bba6 perf: streamline HiCAD startup 2026-07-27 18:24:43 +08:00
134
de8b7670b2 fix: support Claw AVIF and TIFF uploads 2026-07-27 17:30:42 +08:00
134
80d05e177a fix: keep sent Claw messages out of composer 2026-07-27 10:22:49 +08:00
134
c889bfc381 feat: add product planning v2 claw skill 2026-07-24 14:10:53 +08:00
134
62c686a26b fix: fail over on upstream image provider errors 2026-07-24 13:51:35 +08:00
134
3096eb6752 feat: add AtlasCloud video model support 2026-07-23 10:52:29 +08:00
134
974a4b1b59 chore: rename hidden model control 2026-07-22 11:42:39 +08:00
134
6ccf04a786 fix: preserve AtlasCloud model aliases 2026-07-22 10:53:19 +08:00
134
1a91b8ada9 fix: hide stale AtlasCloud catalog entries 2026-07-22 10:35:05 +08:00
134
75cecdfcad fix: validate AtlasCloud frontend model requests 2026-07-22 10:32:28 +08:00
1167 changed files with 170109 additions and 16697 deletions

View File

@ -9,6 +9,13 @@
# Generate with: openssl rand -base64 32
AUTH_SECRET=replace-with-random-secret
# Stable pepper for user Access Key hashes. Generate independently from AUTH_SECRET.
ACCESS_KEY_PEPPER=replace-with-random-secret
# Authorizes trusted third-party services to provision user-scoped MCP keys.
# Must contain at least 32 characters. Never expose it to end users or browsers.
MCP_KEY_PROVISIONING_SECRET=replace-with-random-secret
# Public application URL. Required for production and self-hosted Auth.js.
AUTH_URL=https://your-domain.com
NEXTAUTH_URL=https://your-domain.com
@ -146,8 +153,9 @@ PRODUCT_PLANNING_DB_SSL=false
# Home Performance / Queues
# -----------------------------------------------------------------------------
CANVAS_IMAGE_TASK_CONCURRENCY=3
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
CANVAS_IMAGE_TASK_CONCURRENCY=0
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20
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-build/
.next-previous/
/.next-*/
out/
build
@ -50,6 +51,7 @@ yarn-error.log*
/playwright-report/
/blob-report/
/playwright/*
/output/
deploy.tar.gz
libvips-8.14.5-linux-x64.tar.br
.temp-packages/
@ -66,10 +68,19 @@ migration-backup-complete/
migration-*.log
migration-*.txt
# Local release backups, model credentials and migration scratch data.
/.model-config-backups/
/backups/
/tmp/
# 本地抓包/竞品参考产物,不能提交
artifacts/liblib-*/
analysis/qiaodou-extract/
# 本地运行时数据库与上传文件
.local/
public/uploads/local/
# 模型文件
*.model
*.pth
@ -100,3 +111,30 @@ public/vendor/zoo-modeling-app/
# Drizzle migrations must be versioned (override the global *.sql ignore above)
!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
git fetch github main --prune
git fetch origin --prune
pnpm check:branch
pnpm check:quality
pnpm audit:security

View File

@ -75,6 +75,8 @@ export function LocalAiImagePipelineSections({
aiWashConfig,
setAiWashConfig,
aiWashModeIconUploading,
canvasLocalEditConfig,
setCanvasLocalEditConfig,
extendConfig,
setExtendConfig,
watermarkRemoveConfig,
@ -103,6 +105,7 @@ export function LocalAiImagePipelineSections({
handleMoveEnhanceProPreset,
handleAiWashModeIconUpload,
handleSaveAiWashConfig,
handleSaveCanvasLocalEditConfig,
handleSaveExtendConfig,
handleSaveWatermarkRemoveConfig,
handleSaveAngleEditConfig,
@ -156,12 +159,15 @@ export function LocalAiImagePipelineSections({
},
{
label:
rmbgConfig.apiKey ||
rmbgConfig.provider === "vip98k"
? "服务商 Key 已配置"
: rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey
? "Key 已配置"
: "Key 待配置",
tone:
rmbgConfig.provider === "vip98k" ||
rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey
@ -176,7 +182,26 @@ export function LocalAiImagePipelineSections({
{/* 服务提供商选择 */}
<div className="space-y-3">
<Label>抠图服务提供商</Label>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "vip98k"
? "border-primary bg-accent ring-1 ring-primary"
: ""
}`}
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "vip98k" })}
>
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
rmbgConfig.provider === "vip98k"
? "bg-primary text-primary-foreground"
: "bg-transparent"
}`}
>
{rmbgConfig.provider === "vip98k" && <Check className="h-3 w-3" />}
</div>
<p className="font-medium text-sm leading-none">VIP98K 统一服务</p>
</div>
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "rmbg"
@ -263,6 +288,13 @@ export function LocalAiImagePipelineSections({
<Separator />
{rmbgConfig.provider === "vip98k" && (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 remove-background 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,不会在此重复保存。
</div>
)}
{/* RMBG 配置 */}
{rmbgConfig.provider === "rmbg" && (
<>
@ -468,7 +500,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell
description={
<>
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
配置图片变清晰服务,用于画图标注功能中的图片增强处理。
</>
}
expanded={isLocalAiSectionExpanded("enhance")}
@ -478,17 +510,21 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary
items={[
{
label: enhanceConfig.useBackupApi
label: enhanceConfig.provider === "vip98k"
? "VIP98K"
: enhanceConfig.useBackupApi
? "备用 Real-ESRGAN"
: "主 API",
tone: "neutral",
},
{
label:
enhanceConfig.provider === "vip98k" ||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "Key 已配置"
: "Key 待配置",
tone:
enhanceConfig.provider === "vip98k" ||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "enabled"
: "warning",
@ -498,6 +534,13 @@ export function LocalAiImagePipelineSections({
}
title="变清晰API配置"
>
{enhanceConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 enhance 模型,默认 4 倍增强。API Key 从“AI 模型设置”中的
VIP98K 服务商读取。
</div>
) : (
<>
{/* API切换开关 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
@ -622,7 +665,7 @@ export function LocalAiImagePipelineSections({
</p>
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
<p>
• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸)
• 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸)
</p>
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
</div>
@ -741,11 +784,13 @@ export function LocalAiImagePipelineSections({
<p>• 支持2x、4x、8x放大倍数</p>
<p>• 处理时间:通常1-10秒,取决于图片大小</p>
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
<p>• 文件限制:最大50MB</p>
<p>• 文件限制:最大20MB</p>
</div>
</div>
</>
)}
</>
)}
<Button onClick={handleSaveEnhanceConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
@ -1421,6 +1466,112 @@ export function LocalAiImagePipelineSections({
</Button>
</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
description={
<>
@ -2215,7 +2366,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell
description={
<>
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
</>
}
expanded={isLocalAiSectionExpanded("extend")}
@ -2225,8 +2376,11 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary
items={[
{
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
tone: extendConfig.apiKey ? "enabled" : "warning",
label:
extendConfig.provider === "vip98k"
? "VIP98K 服务商"
: "阿里云服务商",
tone: "enabled",
},
{
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
@ -2237,22 +2391,18 @@ export function LocalAiImagePipelineSections({
}
title="图像拓展API配置"
>
<div className="space-y-2">
<Label htmlFor="extendApiKey">API密钥 *</Label>
<Input
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
{extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。
</div>
</div>
) : (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用阿里云 DashScope 的 image-out-painting 模型。API Key
从“AI 模型设置”中已启用的阿里云官方生图服务商读取,
仅在这里维护拓展比例。
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
@ -2306,7 +2456,7 @@ export function LocalAiImagePipelineSections({
https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
</p>
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
<p>• 最大图片大小:4MB</p>
<p>• 最大图片大小:10MB</p>
<p>• 处理时间:约5-15秒/张</p>
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
</div>

View File

@ -1078,139 +1078,15 @@ export function LocalAiRuntimeSections({
</Button>
</LocalAiSectionShell>
{/* 全站生图单模型并发 */}
<LocalAiSectionShell
description={
<>
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
</>
}
description="生图任务直接发起,图片先显示,后台转存到存储桶。"
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
id="local-ai-section-canvas-image-task-queue"
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
summary={
<LocalAiSectionSummary
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>
}
summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { 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)]">
<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>
<p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p>
</LocalAiSectionShell>
{/* 批量处理配置 */}

View File

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

View File

@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
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 {
EnhanceProKreaConfig,
@ -91,6 +92,7 @@ export type AdminAiProvider = {
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image"
| "midjourney"
| "fal-video"
@ -247,7 +249,7 @@ export type EnhanceProKeyRow = {
};
export type RmbgConfig = {
provider: "rmbg" | "photoroom" | "aoscdn";
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
apiUrl: string;
apiKey: string;
photoroomApiKey: string;
@ -259,6 +261,7 @@ export type RmbgConfig = {
};
export type EnhanceConfig = {
provider: "vip98k" | "legacy";
apiUrl: string;
apiKey: string;
type: string;
@ -271,7 +274,7 @@ export type EnhanceConfig = {
};
export type ExtendConfig = {
apiKey: string;
provider: "vip98k" | "dashscope";
xScale: string;
yScale: string;
};
@ -330,6 +333,8 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
aiWashConfig: AiWashConfig;
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
aiWashModeIconUploading: boolean;
canvasLocalEditConfig: CanvasLocalEditConfig;
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
extendConfig: ExtendConfig;
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
watermarkRemoveConfig: WatermarkRemoveConfig;
@ -372,6 +377,7 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
handleAiWashModeIconUpload: (file: File) => Promise<void>;
handleSaveAiWashConfig: AsyncAction;
handleSaveCanvasLocalEditConfig: AsyncAction;
handleSaveExtendConfig: AsyncAction;
handleSaveWatermarkRemoveConfig: AsyncAction;
handleSaveAngleEditConfig: AsyncAction;

View File

@ -38,6 +38,7 @@ import {
ImagePlus,
Key,
Layers,
LayoutTemplate,
Layers3,
Loader2,
type LucideIcon,
@ -102,7 +103,10 @@ import {
} from "@/components/canvas/canvas-skill-icons";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
import {
DEFAULT_OPENCLAW_TASK_TIMEOUT_MS,
DEFAULT_OPENCLAW_CHAT_BASE_URL,
MAX_OPENCLAW_TASK_TIMEOUT_MS,
MIN_OPENCLAW_TASK_TIMEOUT_MS,
OPENCLAW_CHAT_CONFIG_KEY,
type OpenClawChatConfig,
} from "@/lib/openclaw-chat/config";
@ -198,9 +202,16 @@ import {
normalizeCanvasImageTaskQueueConfig,
} from "@/lib/canvas/image-task-config";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
defaultCanvasLocalEditConfig,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import {
type CanvasSkillCategory,
type CanvasSkillTag,
DEFAULT_CANVAS_SKILL_TAGS,
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
normalizeCanvasSkillCategories,
normalizeCanvasSkillTags,
} from "@/lib/canvas-skill-tags";
import {
@ -418,6 +429,13 @@ function createEmptyCanvasSkillTag(): CanvasSkillTag {
};
}
function createEmptyCanvasSkillCategory(): CanvasSkillCategory {
return {
id: `category-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: "新分类",
};
}
function createEmptyEnhanceProPreset(): EnhanceProPreset {
const timestamp = Date.now();
return {
@ -627,6 +645,7 @@ type AiProvider = {
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image"
| "midjourney"
| "fal-video"
@ -656,6 +675,9 @@ type ImageGenConfig = {
supportsSeed?: boolean; // 是否支持随机种子
supportedAspectRatios?: string[]; // 支持的图片比例
supportedImageSizes?: string[]; // 支持的分辨率/K 数
maxReferenceImages?: number; // 最大参考图数量
maxVideoReferences?: number; // 最大参考视频数量
supportsQuality?: boolean; // 是否支持质量档位
creditCostPerImage?: number; // 每张图片积分消耗
creditCostMultiplierBySize?: Record<string, number>; // 各图片尺寸积分倍率
creditDiscountRate?: number; // 积分折扣,按“折”配置,如 5 = 5折
@ -665,6 +687,7 @@ type ImageGenConfig = {
creditCostPerVideoByResolution?: Record<string, number>; // 各分辨率积分
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
vip98k?: Record<string, unknown>; // VIP98K 目录元数据
};
function createDefaultImageGenConfig(): ImageGenConfig {
@ -1039,6 +1062,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
imageRemoveBackground: Scissors,
imageBoxCutout: Scan,
imageLocalEdit: Wand2,
imageLayoutReplace: LayoutTemplate,
imageRemoveWatermark: Eraser,
imageResize: Maximize2,
imageTextEdit: Type,
@ -1160,6 +1184,7 @@ type SiteSettings = {
bannerEnabled: boolean;
bannerText: string;
bannerLink: string | null;
bannerActionText: string;
toolbox1Name: string;
toolbox1Enabled: boolean;
toolbox2Name: string;
@ -1587,7 +1612,8 @@ const PROVIDER_TYPES = [
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "bananaimage", label: "BananaImage 生图" },
{ value: "visionary", label: "Visionary 生图" },
{ value: "atlascloud", label: "Atlas Cloud 图片 / 3D 模型" },
{ value: "atlascloud", label: "Atlas Cloud 图片 / 视频 / 3D 模型" },
{ value: "vip98k", label: "VIP98K 混合模型服务" },
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
{ value: "bltcy-video", label: "柏拉图统一视频" },
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
@ -1625,6 +1651,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
bananaimage: "https://bananaimage.aigenmedia.art",
visionary: "https://api.visionary.beer",
atlascloud: "https://api.atlascloud.ai",
vip98k: "https://api.vip98k.com",
"stepfun-image": "https://api.stepfun.com",
"bltcy-video": "https://api.bltcy.ai",
"grsai-gpt-image": "https://grsai.dakka.com.cn",
@ -1632,7 +1659,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
midjourney: "https://your-midjourney-api.example.com",
"fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
"worldlabs-3d": "https://api.worldlabs.ai",
"worldlabs-3d": "https://api.vip98k.com",
"kittycad-cad": "https://api.zoo.dev",
runway: "https://api.dev.runwayml.com",
krea: "https://api.krea.ai",
@ -1881,6 +1908,13 @@ export function AdminSettingsClient() {
const { data: aiWashConfigData, mutate: mutateAiWashConfig } = useSWR<{
setting?: { value?: unknown };
}>(`/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 {
data: canvasImageTaskQueueConfigData,
mutate: mutateCanvasImageTaskQueueConfig,
@ -2148,11 +2182,12 @@ export function AdminSettingsClient() {
usageTimeEnabled: false,
usageTimeStart: "08:00",
usageTimeEnd: "20:00",
usageTimeMessage: "由壹点累了也是需要休息的",
usageTimeMessage: "BuYi 累了也是需要休息的",
defaultTheme: "system",
bannerEnabled: true,
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
bannerActionText: "立即查看",
toolbox1Name: "工具箱",
toolbox1Enabled: true,
toolbox2Name: "工具箱2",
@ -2189,7 +2224,7 @@ export function AdminSettingsClient() {
agentWorkflowDefaultModelId: null,
canvasDetailDefaultModelId: null,
canvasDetailAgentModelId: null,
lovartComposerPrefix: "让「由壹点AI」",
lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
lovartComposerPreviewTexts: [
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
@ -2205,8 +2240,8 @@ export function AdminSettingsClient() {
lovartPromoText:
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: "下达指令,即刻呈现",
surpriseCategories: ["全部", "精选"],
membershipConfig: defaultMembershipConfig,
membershipUserOverrides: defaultMembershipUserOverrides,
@ -2223,7 +2258,7 @@ export function AdminSettingsClient() {
enabled: false,
baseUrl: DEFAULT_OPENCLAW_CHAT_BASE_URL,
token: "",
timeoutMs: 180_000,
timeoutMs: DEFAULT_OPENCLAW_TASK_TIMEOUT_MS,
});
const [openClawChatTokenConfigured, setOpenClawChatTokenConfigured] =
useState(false);
@ -2267,12 +2302,13 @@ export function AdminSettingsClient() {
usageTimeStart: siteSettingsData.usageTimeStart || "08:00",
usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00",
usageTimeMessage:
siteSettingsData.usageTimeMessage || "由壹点累了也是需要休息的",
siteSettingsData.usageTimeMessage || "BuYi 累了也是需要休息的",
defaultTheme: siteSettingsData.defaultTheme || "system",
bannerEnabled: siteSettingsData.bannerEnabled ?? true,
bannerText:
siteSettingsData.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: siteSettingsData.bannerLink || null,
bannerActionText: siteSettingsData.bannerActionText || "立即查看",
toolbox1Name: siteSettingsData.toolbox1Name || "工具箱",
toolbox1Enabled: siteSettingsData.toolbox1Enabled ?? true,
toolbox2Name: siteSettingsData.toolbox2Name || "工具箱2",
@ -2362,7 +2398,7 @@ export function AdminSettingsClient() {
(siteSettingsData as any).canvasDetailAgentModelId || null,
lovartComposerPrefix:
String((siteSettingsData as any).lovartComposerPrefix || "").trim() ||
"让「由壹点AI」",
"让「𝗕𝘂𝗬𝗶」",
lovartComposerPreviewTexts: Array.isArray(
siteSettingsData.lovartComposerPreviewTexts
)
@ -2464,9 +2500,9 @@ export function AdminSettingsClient() {
lovartPromoActionText:
siteSettingsData.lovartPromoActionText || "立即升级",
lovartHeroTitle:
siteSettingsData.lovartHeroTitle || "由壹点AI 让设计更简单",
siteSettingsData.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle:
siteSettingsData.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
siteSettingsData.lovartHeroSubtitle || "下达指令,即刻呈现",
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
? siteSettingsData.surpriseCategories
.map((item: any) => String(item).trim())
@ -2515,8 +2551,11 @@ export function AdminSettingsClient() {
String(raw.baseUrl || "").trim() || DEFAULT_OPENCLAW_CHAT_BASE_URL,
token: "",
timeoutMs: Math.min(
300_000,
Math.max(5_000, Math.round(Number(raw.timeoutMs) || 180_000))
MAX_OPENCLAW_TASK_TIMEOUT_MS,
Math.max(
MIN_OPENCLAW_TASK_TIMEOUT_MS,
Math.round(Number(raw.timeoutMs) || DEFAULT_OPENCLAW_TASK_TIMEOUT_MS)
)
),
});
setOpenClawChatTokenConfigured(
@ -2549,7 +2588,7 @@ export function AdminSettingsClient() {
// 抠图API配置状态
const [rmbgConfig, setRmbgConfig] = useState({
provider: "rmbg" as "rmbg" | "photoroom" | "aoscdn", // 抠图服务提供商
provider: "vip98k" as "vip98k" | "rmbg" | "photoroom" | "aoscdn", // 抠图服务提供商
apiUrl: "",
apiKey: "",
// PhotoRoom 配置
@ -2564,6 +2603,7 @@ export function AdminSettingsClient() {
// 变清晰API配置状态
const [enhanceConfig, setEnhanceConfig] = useState({
provider: "vip98k" as "vip98k" | "legacy",
apiUrl: "",
apiKey: "",
type: "clean", // "clean" | "face"
@ -2587,10 +2627,13 @@ export function AdminSettingsClient() {
const [draggingEnhanceProPresetId, setDraggingEnhanceProPresetId] =
useState("");
const [aiWashConfig, setAiWashConfig] = useState(defaultAiWashConfig());
const [canvasLocalEditConfig, setCanvasLocalEditConfig] = useState(
defaultCanvasLocalEditConfig()
);
// 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({
apiKey: "",
provider: "dashscope" as "vip98k" | "dashscope",
xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数
});
@ -2754,7 +2797,7 @@ export function AdminSettingsClient() {
useEffect(() => {
if (rmbgConfigData?.setting?.value) {
const config = rmbgConfigData.setting.value as {
provider?: "rmbg" | "photoroom" | "aoscdn";
provider?: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
apiUrl?: string;
apiKey?: string;
photoroomApiKey?: string;
@ -2765,7 +2808,7 @@ export function AdminSettingsClient() {
aosFormat?: "png" | "jpg";
};
setRmbgConfig({
provider: config.provider || "rmbg",
provider: config.provider || "vip98k",
apiUrl: config.apiUrl || "",
apiKey: config.apiKey || "",
photoroomApiKey: config.photoroomApiKey || "",
@ -2791,6 +2834,7 @@ export function AdminSettingsClient() {
useEffect(() => {
if (enhanceConfigData?.setting?.value) {
const config = enhanceConfigData.setting.value as {
provider?: "vip98k" | "legacy";
apiUrl?: string;
apiKey?: string;
type?: string;
@ -2802,6 +2846,7 @@ export function AdminSettingsClient() {
backupModel?: string;
};
setEnhanceConfig({
provider: config.provider === "vip98k" ? "vip98k" : "legacy",
apiUrl: config.apiUrl || "",
apiKey: config.apiKey || "",
type: config.type || "clean",
@ -2828,6 +2873,13 @@ export function AdminSettingsClient() {
setAiWashConfig(normalizeAiWashConfig(aiWashConfigData.setting?.value));
}, [aiWashConfigData]);
useEffect(() => {
if (!canvasLocalEditConfigData) return;
setCanvasLocalEditConfig(
normalizeCanvasLocalEditConfig(canvasLocalEditConfigData.setting?.value)
);
}, [canvasLocalEditConfigData]);
const enhanceProApiKeysDraftCount = useMemo(
() =>
normalizeEnhanceProKreaConfig({
@ -2899,12 +2951,12 @@ export function AdminSettingsClient() {
useEffect(() => {
if (extendConfigData?.setting?.value) {
const config = extendConfigData.setting.value as {
apiKey?: string;
provider?: "vip98k" | "dashscope";
xScale?: number;
yScale?: number;
};
setExtendConfig({
apiKey: config.apiKey || "",
provider: config.provider === "vip98k" ? "vip98k" : "dashscope",
xScale: config.xScale ? String(config.xScale) : "1.5",
yScale: config.yScale ? String(config.yScale) : "1.5",
});
@ -3401,6 +3453,9 @@ export function AdminSettingsClient() {
DEFAULT_CANVAS_SKILL_TAGS_TITLE
);
const [canvasSkillTagsEnabled, setCanvasSkillTagsEnabled] = useState(true);
const [canvasSkillCategories, setCanvasSkillCategories] = useState<
CanvasSkillCategory[]
>([]);
const [canvasSkillTags, setCanvasSkillTags] = useState<CanvasSkillTag[]>(
DEFAULT_CANVAS_SKILL_TAGS
);
@ -3418,6 +3473,7 @@ export function AdminSettingsClient() {
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
enabled?: boolean;
title?: string;
categories?: CanvasSkillCategory[];
tags?: CanvasSkillTag[];
}>("/api/canvas-skill-tags", fetcher);
@ -3644,6 +3700,9 @@ export function AdminSettingsClient() {
String(canvasSkillTagsData.title || "").trim() ||
DEFAULT_CANVAS_SKILL_TAGS_TITLE
);
setCanvasSkillCategories(
normalizeCanvasSkillCategories(canvasSkillTagsData.categories)
);
setCanvasSkillTags(normalizeCanvasSkillTags(canvasSkillTagsData.tags));
}
}, [canvasSkillTagsData]);
@ -3680,7 +3739,13 @@ export function AdminSettingsClient() {
const handleCanvasSkillTagChange = (
index: number,
field: "name" | "prompt" | "placeholder" | "iconName" | "iconUrl",
field:
| "name"
| "prompt"
| "placeholder"
| "iconName"
| "iconUrl"
| "categoryId",
value: string
) => {
setCanvasSkillTags((prev) =>
@ -3933,6 +3998,36 @@ export function AdminSettingsClient() {
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
};
const handleAddCanvasSkillCategory = () => {
setCanvasSkillCategories((prev) => [
...prev,
createEmptyCanvasSkillCategory(),
]);
};
const handleCanvasSkillCategoryNameChange = (index: number, name: string) => {
setCanvasSkillCategories((prev) =>
prev.map((category, categoryIndex) =>
categoryIndex === index ? { ...category, name } : category
)
);
};
const handleRemoveCanvasSkillCategory = (index: number) => {
const categoryId = canvasSkillCategories[index]?.id;
setCanvasSkillCategories((prev) =>
prev.filter((_, categoryIndex) => categoryIndex !== index)
);
if (!categoryId) return;
setCanvasSkillTags((prev) =>
prev.map((tag) => {
if (tag.categoryId !== categoryId) return tag;
const { categoryId: _categoryId, ...rest } = tag;
return rest;
})
);
};
const handleRemoveCanvasSkillTag = (index: number) => {
setCanvasSkillTags((prev) =>
prev.filter((_, itemIndex) => itemIndex !== index)
@ -3941,6 +4036,7 @@ export function AdminSettingsClient() {
const handleSaveCanvasSkillTags = async () => {
try {
const categories = normalizeCanvasSkillCategories(canvasSkillCategories);
const payload = normalizeCanvasSkillTags(canvasSkillTags);
const res = await fetch("/api/canvas-skill-tags", {
method: "PUT",
@ -3948,6 +4044,7 @@ export function AdminSettingsClient() {
body: JSON.stringify({
enabled: canvasSkillTagsEnabled,
title: canvasSkillTagsTitle,
categories,
tags: payload,
}),
});
@ -3959,6 +4056,9 @@ export function AdminSettingsClient() {
String(data?.title || canvasSkillTagsTitle || "").trim() ||
DEFAULT_CANVAS_SKILL_TAGS_TITLE
);
setCanvasSkillCategories(
normalizeCanvasSkillCategories(data?.categories ?? categories)
);
setCanvasSkillTags(normalizeCanvasSkillTags(data?.tags ?? payload));
toast.success("快捷标签已更新");
mutateCanvasSkillTags();
@ -4057,6 +4157,7 @@ export function AdminSettingsClient() {
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image"
| "midjourney"
| "fal-video"
@ -4369,8 +4470,14 @@ export function AdminSettingsClient() {
openClawChatConfig.baseUrl.trim() || DEFAULT_OPENCLAW_CHAT_BASE_URL,
token: openClawChatConfig.token.trim(),
timeoutMs: Math.min(
300_000,
Math.max(5_000, Math.round(Number(openClawChatConfig.timeoutMs) || 180_000))
MAX_OPENCLAW_TASK_TIMEOUT_MS,
Math.max(
MIN_OPENCLAW_TASK_TIMEOUT_MS,
Math.round(
Number(openClawChatConfig.timeoutMs) ||
DEFAULT_OPENCLAW_TASK_TIMEOUT_MS
)
)
),
};
@ -5040,7 +5147,9 @@ export function AdminSettingsClient() {
const handleSaveEnhanceConfig = async () => {
try {
// 如果启用了备用API,验证备用API配置
if (enhanceConfig.useBackupApi) {
if (enhanceConfig.provider === "vip98k") {
// VIP98K credentials are read from AiProvider and never duplicated here.
} else if (enhanceConfig.useBackupApi) {
if (!enhanceConfig.backupApiUrl.trim()) {
toast.error("请输入备用API地址");
return;
@ -5067,6 +5176,7 @@ export function AdminSettingsClient() {
body: JSON.stringify({
key: "enhance_api_config",
value: {
provider: enhanceConfig.provider,
apiUrl: enhanceConfig.apiUrl.trim(),
apiKey: enhanceConfig.apiKey.trim(),
type: enhanceConfig.type,
@ -5213,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) => {
setEnhanceProConfig((prev) => ({
...prev,
@ -5415,11 +5553,6 @@ export function AdminSettingsClient() {
// --- 图像拓展API配置逻辑 ---
const handleSaveExtendConfig = async () => {
try {
if (!extendConfig.apiKey.trim()) {
toast.error("请输入API密钥");
return;
}
const xScale = Number.parseFloat(extendConfig.xScale);
const yScale = Number.parseFloat(extendConfig.yScale);
@ -5439,7 +5572,7 @@ export function AdminSettingsClient() {
body: JSON.stringify({
key: "extend_api_config",
value: {
apiKey: extendConfig.apiKey.trim(),
provider: extendConfig.provider,
xScale,
yScale,
},
@ -6303,6 +6436,7 @@ export function AdminSettingsClient() {
const isBananaImageProvider = providerForm.type === "bananaimage";
const isVisionaryProvider = providerForm.type === "visionary";
const isAtlasCloudProvider = providerForm.type === "atlascloud";
const isVip98kProvider = providerForm.type === "vip98k";
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
const isLemonProvider =
providerForm.type === "openai" &&
@ -6473,7 +6607,18 @@ export function AdminSettingsClient() {
} catch (modelError) {
console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
toast.warning(
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 图片 / 3D 模型"按钮'
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 模型"按钮'
);
}
}
if (isNewProvider && isVip98kProvider && providerId) {
try {
await handleQuickAddVip98kModels(providerId, { skipConfirm: true });
} catch (modelError) {
console.error("[Auto Sync VIP98K Models] Error:", modelError);
toast.warning(
'VIP98K 模型同步失败,请手动点击"同步 VIP98K 全部模型"按钮'
);
}
}
@ -7534,7 +7679,7 @@ export function AdminSettingsClient() {
if (
!options?.skipConfirm &&
!confirm(
"将从 Atlas Cloud 官方目录同步全部开放图片与 3D 模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
"将从 Atlas Cloud 官方目录同步图片、视频与 3D 模型,并读取每个模型的参数 Schema。已删除的模型不会被恢复。同步数量较多,可能需要几十秒。\n\n继续吗?"
)
) {
return;
@ -7553,7 +7698,7 @@ export function AdminSettingsClient() {
const totalSynced =
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
toast.success(
`已同步 ${totalSynced} 个 Atlas Cloud 图片 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
`已同步 ${totalSynced} 个 Atlas Cloud 图片 / 视频 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
);
mutateModels();
if (data.errors?.length) {
@ -7561,7 +7706,48 @@ export function AdminSettingsClient() {
}
} catch (error: any) {
console.error("[Quick Add Atlas Cloud Models] Error:", error);
toast.error(error.message || "同步 Atlas Cloud 图片 / 3D 模型失败");
toast.error(error.message || "同步 Atlas Cloud 模型失败");
throw error;
}
};
const handleQuickAddVip98kModels = async (
providerId: string,
options?: { skipConfirm?: boolean }
) => {
if (
!options?.skipConfirm &&
!confirm(
"将同步 VIP98K 当前可用目录,停用服务商已关闭的模型,并排除纯文生图和纯文生视频模型。图片编辑、图生视频、参考媒体、多模态、3D 与工具模型会保留;你此前手动删除的模型不会恢复。\n\n继续吗?"
)
) {
return;
}
try {
toast.info("正在读取 VIP98K 模型目录...");
const response = await fetch("/api/admin/vip98k-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerId }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "同步失败");
const totalSynced =
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
const totalCatalog = Number(data.counts?.total || totalSynced);
const totalDisabled = Number(data.disabledCount || 0);
toast.success(
`已同步 ${totalSynced} 个 VIP98K 模型,停用 ${totalDisabled} 个旧模型(可用目录 ${totalCatalog} 个)`
);
mutateModels();
if (data.errors?.length) {
toast.warning(`${data.errors.length} 个 VIP98K 模型同步失败`);
}
} catch (error: any) {
console.error("[Quick Add VIP98K Models] Error:", error);
toast.error(error.message || "同步 VIP98K 模型失败");
throw error;
}
};
@ -8720,9 +8906,7 @@ export function AdminSettingsClient() {
{
key: "realtime-canvas",
title: "实时与队列",
description: `单模型并发 ${canvasImageTaskQueueConfig.concurrency},批次间隔 ${(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒`,
description: "生图并发不限,任务即时发起",
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
icon: SlidersHorizontal,
@ -8805,7 +8989,7 @@ export function AdminSettingsClient() {
key: "extend",
label: "图像拓展",
group: "图像处理",
tone: extendConfig.apiKey ? "enabled" : "warning",
tone: "enabled",
},
{
key: "watermark-remove",
@ -10329,7 +10513,7 @@ export function AdminSettingsClient() {
lovartComposerPrefix: e.target.value.slice(0, 40),
})
}
placeholder="让「由壹点AI」"
placeholder="让「𝗕𝘂𝗬𝗶」"
value={siteSettings.lovartComposerPrefix}
/>
<div className="text-muted-foreground text-xs">
@ -10531,7 +10715,7 @@ export function AdminSettingsClient() {
lovartHeroTitle: e.target.value,
})
}
placeholder="由壹点AI 让设计更简单"
placeholder="𝗕𝘂𝗬𝗶 让设计触手可及"
value={siteSettings.lovartHeroTitle}
/>
</div>
@ -10545,7 +10729,7 @@ export function AdminSettingsClient() {
lovartHeroSubtitle: e.target.value,
})
}
placeholder="懂你的设计代理,帮你搞定一切"
placeholder="下达指令,即刻呈现"
value={siteSettings.lovartHeroSubtitle}
/>
</div>
@ -12254,7 +12438,7 @@ export function AdminSettingsClient() {
<div className="space-y-0.5">
<Label htmlFor="bannerEnabled">广告横幅</Label>
<div className="text-muted-foreground text-sm">
在无限画布等页面的聊天面板中显示广告横幅
在首页顶部和无限画布聊天面板中显示广告横幅
</div>
</div>
<Switch
@ -12284,6 +12468,21 @@ export function AdminSettingsClient() {
value={siteSettings.bannerText}
/>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor="bannerActionText">按钮文字</Label>
<Input
id="bannerActionText"
maxLength={32}
onChange={(e) =>
setSiteSettings({
...siteSettings,
bannerActionText: e.target.value,
})
}
placeholder="例如:立即升级"
value={siteSettings.bannerActionText}
/>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor="bannerLink">横幅链接(可选)</Label>
<Input
@ -12298,7 +12497,7 @@ export function AdminSettingsClient() {
value={siteSettings.bannerLink || ""}
/>
<div className="text-muted-foreground text-xs">
如果不填写链接,点击横幅将不会跳转
首页按钮和无限画布横幅将打开此链接;不填写时只展示文案
</div>
</div>
</>
@ -12671,21 +12870,27 @@ export function AdminSettingsClient() {
</div>
<div className="space-y-2">
<Label htmlFor="openClawChatTimeoutMs">
请求超时(毫秒)
任务最长执行时间(毫秒)
</Label>
<Input
id="openClawChatTimeoutMs"
min={5000}
max={300000}
min={MIN_OPENCLAW_TASK_TIMEOUT_MS}
max={MAX_OPENCLAW_TASK_TIMEOUT_MS}
onChange={(event) =>
setOpenClawChatConfig((prev) => ({
...prev,
timeoutMs: Number(event.target.value) || 180000,
timeoutMs:
Number(event.target.value) ||
DEFAULT_OPENCLAW_TASK_TIMEOUT_MS,
}))
}
step={60_000}
type="number"
value={openClawChatConfig.timeoutMs}
/>
<p className="text-muted-foreground text-xs">
网络断开会自动续接,该时间只限制业务任务的后台等待周期。
</p>
</div>
</div>
<div className="space-y-2">
@ -14764,6 +14969,61 @@ export function AdminSettingsClient() {
value={canvasSkillTagsTitle}
/>
</div>
<div className="space-y-3 rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<Label>技能分类</Label>
<p className="mt-1 text-muted-foreground text-xs">
分类会显示在 Claw 快捷技能区,用户可按分类筛选技能。
</p>
</div>
<Button
onClick={handleAddCanvasSkillCategory}
size="sm"
type="button"
variant="outline"
>
<Plus className="mr-1 h-4 w-4" /> 添加分类
</Button>
</div>
{canvasSkillCategories.length > 0 ? (
<div className="grid gap-2 md:grid-cols-2">
{canvasSkillCategories.map((category, categoryIndex) => (
<div
className="flex items-center gap-2"
key={category.id}
>
<Input
aria-label={`分类名称 ${categoryIndex + 1}`}
onChange={(event) =>
handleCanvasSkillCategoryNameChange(
categoryIndex,
event.target.value
)
}
placeholder={`分类名称 ${categoryIndex + 1}`}
value={category.name}
/>
<Button
aria-label={`删除分类 ${category.name}`}
onClick={() =>
handleRemoveCanvasSkillCategory(categoryIndex)
}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
</div>
) : (
<p className="rounded-md bg-muted/40 px-3 py-2 text-muted-foreground text-xs">
暂未配置分类,所有技能会显示在“全部”中。
</p>
)}
</div>
{canvasSkillTags.map((tag, index) => {
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
return (
@ -14771,7 +15031,7 @@ export function AdminSettingsClient() {
className="space-y-2 rounded-lg border p-3"
key={tag.id || index}
>
<div className="flex items-center gap-2">
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<Input
onChange={(event) =>
handleCanvasSkillTagChange(
@ -14783,7 +15043,32 @@ export function AdminSettingsClient() {
placeholder={`标签名称 ${index + 1}`}
value={tag.name}
/>
<Select
onValueChange={(value) =>
handleCanvasSkillTagChange(
index,
"categoryId",
value === "__uncategorized__" ? "" : value
)
}
value={tag.categoryId || "__uncategorized__"}
>
<SelectTrigger className="w-full shrink-0 md:w-[180px]">
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__uncategorized__">
未分类
</SelectItem>
{canvasSkillCategories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="self-end md:self-auto"
disabled={canvasSkillTags.length <= 1}
onClick={() => handleRemoveCanvasSkillTag(index)}
size="icon"
@ -15789,6 +16074,13 @@ export function AdminSettingsClient() {
并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
</p>
)}
{providerForm.type === "vip98k" && (
<p className="text-muted-foreground text-xs">
VIP98K 使用 Bearer API Key 认证,Base URL 只填写
https://api.vip98k.com。保存后会自动同步图片、视频、3D、
多模态和工具模型。
</p>
)}
{providerForm.type === "bfl-image" &&
isBltcyBflBaseUrl(providerForm.baseUrl) && (
<p className="text-muted-foreground text-xs">
@ -15943,7 +16235,18 @@ export function AdminSettingsClient() {
variant="secondary"
>
<ImageIcon className="mr-1 h-4 w-4" />
同步 Atlas Cloud 图片 / 3D 模型
同步 Atlas Cloud 模型
</Button>
)}
{provider.type === "vip98k" && (
<Button
className="mt-2"
onClick={() => handleQuickAddVip98kModels(provider.id)}
size="sm"
variant="secondary"
>
<Sparkles className="mr-1 h-4 w-4" />
同步 VIP98K 全部模型
</Button>
)}
{provider.type === "bltcy-video" && (
@ -16464,10 +16767,7 @@ export function AdminSettingsClient() {
})
}
/>
<Label>
前台隐藏(仅 Agent/故障迁移可用,第三方 API
不可用)
</Label>
<Label>vip模型</Label>
</div>
<p className="text-muted-foreground text-xs">
开启后,该模型不会出现在前端,也不会参与第三方生图
@ -18468,6 +18768,7 @@ export function AdminSettingsClient() {
angleEditConfig={angleEditConfig}
aiWashConfig={aiWashConfig}
aiWashModeIconUploading={aiWashModeIconUploading}
canvasLocalEditConfig={canvasLocalEditConfig}
draftEnhanceProKeyRows={draftEnhanceProKeyRows}
draggingEnhanceProPresetId={draggingEnhanceProPresetId}
enhanceConfig={enhanceConfig}
@ -18488,6 +18789,7 @@ export function AdminSettingsClient() {
handleResetEnhanceProPresets={handleResetEnhanceProPresets}
handleSaveAngleEditConfig={handleSaveAngleEditConfig}
handleSaveAiWashConfig={handleSaveAiWashConfig}
handleSaveCanvasLocalEditConfig={handleSaveCanvasLocalEditConfig}
handleSaveEnhanceConfig={handleSaveEnhanceConfig}
handleSaveEnhanceProConfig={handleSaveEnhanceProConfig}
handleSaveExtendConfig={handleSaveExtendConfig}
@ -18509,6 +18811,7 @@ export function AdminSettingsClient() {
selectedEnhanceProPresetIndex={selectedEnhanceProPresetIndex}
setAngleEditConfig={setAngleEditConfig}
setAiWashConfig={setAiWashConfig}
setCanvasLocalEditConfig={setCanvasLocalEditConfig}
setEnhanceConfig={setEnhanceConfig}
setEnhanceProApiKeysDraft={setEnhanceProApiKeysDraft}
setEnhanceProConfig={setEnhanceProConfig}

View File

@ -1,4 +1,5 @@
"use client";
import { filterCanvasModels } from "@/lib/video-canvas/model-matching";
import Link from "next/link";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
@ -24,10 +25,6 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { fetcher } from "@/lib/utils";
import {
getVideoCanvasFunctionBillingCopy,
@ -87,56 +84,23 @@ function getModelOptionLabel(model: AiModelOption) {
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({
allowInherit = false,
kind,
functionKey,
models,
onChange,
value,
}: {
allowInherit?: boolean;
kind: VideoCanvasFunctionDefinition["kind"];
functionKey?: string;
models: AiModelOption[];
onChange: (value: string) => void;
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 hasCurrent =
normalizedValue === "auto" ||
@ -851,13 +815,14 @@ export function VideoCanvasSettingsClient() {
<div className="grid gap-4 md:grid-cols-1">
<div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
<ModelSelect
{["canvas-generate-image", "canvas-generate-video", "canvas-workflow-analyze", "pick-object"].includes(item.route) ? <ModelSelect
allowInherit
functionKey={item.key}
kind={item.kind}
models={modelOptions}
onChange={(value) => updateFunction(item.key, { model: value })}
value={item.model || ""}
/>
/> : <p className="text-sm text-muted-foreground">{item.route === "local" ? "本地处理,无需生成模型" : "使用此功能的专用服务接口"}</p>}
</div>
<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">

View File

@ -4,7 +4,8 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html";
const ZOO_MODELING_APP_SRC =
"/vendor/zoo-modeling-app/index.html?v=f2b345db-eng1";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {

View File

@ -2,9 +2,31 @@ import { createLogger } from "@/lib/observability/logger";
import { generateText } from "ai";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
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 { resolveDetailReplacementVisionRuntime } from "@/lib/ai/detail-replacement-vision-runtime";
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 { bufferToArrayBuffer } from "./persistence";
import { isVideoProviderType } from "./video-models";
@ -234,6 +256,8 @@ export async function normalizeReplacementResultImage(params: {
referenceImageUrl: string;
referenceGeometry?: ReplacementReferenceGeometry | null;
scenePlan?: ReplacementScenePlan | null;
preserveReferenceOutsideSubject?: boolean;
preserveReferenceText?: boolean;
uploadPrefix: string;
}): Promise<{
originalUrl: string;
@ -242,6 +266,11 @@ export async function normalizeReplacementResultImage(params: {
}> {
const sharp = (await import("sharp")).default;
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 targetHeight = params.referenceGeometry?.height;
const generatedMetadata = await sharp(generatedBuffer, { failOn: "none" })
@ -250,8 +279,8 @@ export async function normalizeReplacementResultImage(params: {
if (!targetWidth || !targetHeight) {
try {
const referenceBuffer = await fetchImageBufferFromUrl(params.referenceImageUrl);
const referenceMetadata = await sharp(referenceBuffer, { failOn: "none" })
const resolvedReferenceBuffer = await getReferenceBuffer();
const referenceMetadata = await sharp(resolvedReferenceBuffer, { failOn: "none" })
.rotate()
.metadata();
targetWidth = referenceMetadata.width;
@ -298,7 +327,7 @@ export async function normalizeReplacementResultImage(params: {
: shouldPreserveFullGeneratedCanvas
? "contain"
: "cover";
const normalizedBuffer =
let normalizedBuffer =
targetWidth && targetHeight
? await imagePipeline
.resize(targetWidth, targetHeight, {
@ -312,6 +341,82 @@ export async function normalizeReplacementResultImage(params: {
.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 uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@ -399,6 +504,12 @@ export type ReplacementScenePlan = {
generationPrompt: string;
selectedProductImageIndexes: number[];
productSelectionReason: string;
productIdentitySummary?: string;
productStructureSummary?: string;
productStructuralRisks?: string;
subjectBoundingBox?: NormalizedReplacementBox | null;
textBoundingBoxes?: NormalizedReplacementBox[];
layoutLockAnalyzed?: boolean;
analysisModelName?: string | null;
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 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(
value: unknown,
): AgentDetailReplacementStrategy {
@ -1441,7 +1734,27 @@ export function parseReplacementScenePlan(
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 = {
pageKind,
generationRoute,
@ -1486,6 +1799,9 @@ export function parseReplacementScenePlan(
generationPrompt,
selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes,
productSelectionReason: productImageSelection.productSelectionReason,
subjectBoundingBox: layoutLocks.subjectBoundingBox,
textBoundingBoxes: layoutLocks.textBoundingBoxes,
layoutLockAnalyzed: layoutLocks.analyzed,
analysisModelName: null,
analysisSource: "heuristic",
};
@ -1844,6 +2160,27 @@ export async function resolveDirectReplacementModelRef(params: {
finalModelId?: string | null;
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();
if (explicitModel && explicitModel !== "auto") {
return explicitModel;
@ -1853,13 +2190,6 @@ export async function resolveDirectReplacementModelRef(params: {
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 =
imageModels.find(({ model }) => model.isDefault) ||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||
@ -2121,6 +2451,16 @@ export async function analyzeReplacementReferenceImage(params: {
export async function resolveReplacementScenePlanningRuntime(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}): 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 {
const siteSettings = await getSiteSettings().catch(() => null);
const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({
@ -2191,6 +2531,13 @@ export async function analyzeReplacementExecutionPlan(params: {
const referenceVisualProfile = isDetailReplacement
? await resolveReplacementReferenceVisualProfile(params.referenceImageUrl)
: null;
const productIdentityProfile =
params.planningRuntime && params.productImageUrls.length > 0
? await resolveReplacementProductIdentityProfile({
productImageUrls: params.productImageUrls,
planningRuntime: params.planningRuntime,
})
: null;
if (params.planningRuntime) {
try {
const replacementScenePlanningTimeoutMs =
@ -2221,7 +2568,10 @@ export async function analyzeReplacementExecutionPlan(params: {
? "你是电商详情参考图背景替换流程中的“版式与背景区域规划器”。"
: "你是电商详情替换流程中的“场景理解规划器”。",
`当前要分析的参考图:${params.referenceLabel}`,
"你现在只分析这一张参考图本身,不分析产品图,不分析模特图。",
"当前视觉输入只包含这一张详情参考图;产品图已经单独做过跨视角结构分析,请根据下面的产品结构画像选择最匹配当前机位的产品视角,不要臆测产品结构。",
productIdentityProfile
? `【产品结构画像】\n${formatReplacementProductIdentityProfile(productIdentityProfile)}`
: "产品结构画像暂不可用:仍需按当前参考图机位选择产品图,但不得把旧产品的腿型、支架或构件拓扑当成新产品结构。",
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}`
: "",
@ -2231,7 +2581,7 @@ export async function analyzeReplacementExecutionPlan(params: {
!referenceVisualProfile?.likelyWhiteLayout && referenceVisualProfile?.likelyWhiteScene
? "像素初筛已经命中白/浅色低饱和真实场景:这不是白底说明页。若画面存在真实空间、人物或产品照片,promptPackPolicy 应为 soft 或 allow,必须使用前置工作流词包重建图片框内产品、空间、软装和灯光,使差异清晰可见;但最终仍保持白/浅色低饱和基调,不要改造成深色厨房、黑墙、复杂吊灯或强软装。"
: "",
"产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。",
"产品图和模特图会在后续生图阶段单独提供给生图模型。你必须依据产品结构画像输出 selectedProductImageIndexes,选择最多两张最能匹配当前参考图机位并共同说明完整结构的产品图;不得固定选择前两张,也不要要求强行使用全部产品图。",
params.modelImageUrl && !isProductOnlyDetailReplacement
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
: "本轮没有模特图,useModelImage 必须为 false。",
@ -2278,12 +2628,18 @@ export async function analyzeReplacementExecutionPlan(params: {
isDetailReplacement
? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。"
: "",
isDetailReplacement
? "5a. 必须输出 subjectBoundingBox 和 textBoundingBoxes。所有坐标为 0到1000 归一化坐标 [x1,y1,x2,y2],不是 x/y/宽/高。subjectBoundingBox 紧密框住旧商品主体及自然阴影,不含文字,无商品时为 null;textBoundingBoxes 覆盖所有中英文文案、标题、卖点、参数、标签、数字、品牌字样、按钮文字及紧邻图标,无文字时为 []。"
: "",
isDetailReplacement
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
: "",
isDetailReplacement
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
: "",
isDetailReplacement
? "7a. 输出 selectedProductImageIndexes:根据当前详情图机位,从产品结构画像的图片编号中选择最多两张最相关的产品图;第一张应最接近当前机位,第二张补足腿、支架、横梁、侧框或其他容易变形的结构。输出 productSelectionReason 说明选择依据。"
: "",
isProductOnlyDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
: isDetailReplacement
@ -2309,7 +2665,7 @@ export async function analyzeReplacementExecutionPlan(params: {
: "",
"只返回严格 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)
.join("\n");
@ -2334,7 +2690,7 @@ export async function analyzeReplacementExecutionPlan(params: {
system:
"你是高精度商品详情图场景规划器。你只分析当前参考图本身,负责理解这一屏的场景、机位、主体位置和人物需求,不要臆测或依赖其他输入图片。",
temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1,
maxTokens: 1200,
maxTokens: 1600,
abortSignal: AbortSignal.timeout(replacementScenePlanningTimeoutMs),
} as any);
@ -2368,6 +2724,9 @@ export async function analyzeReplacementExecutionPlan(params: {
: "configured_vision",
scenePlan: {
...normalizedPlan.scenePlan,
productIdentitySummary: productIdentityProfile?.identitySummary || "",
productStructureSummary: productIdentityProfile?.structureSummary || "",
productStructuralRisks: productIdentityProfile?.structuralRisks || "",
analysisModelName: params.planningRuntime.modelName,
analysisSource:
params.planningRuntime.kind === "local_moonshot"
@ -2413,7 +2772,12 @@ export async function analyzeReplacementExecutionPlan(params: {
);
return {
...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 productStructureLockLines = buildReplacementProductStructureLockLines({
productImageCount: params.productImageCount,
identitySummary: params.scenePlan?.productIdentitySummary,
structureSummary: params.scenePlan?.productStructureSummary,
structuralRisks: params.scenePlan?.productStructuralRisks,
});
const normalizedBasePrompt = String(params.basePrompt || "").trim();
const promptPackInstructionLines =
params.modeType === "detail_replacement" &&
@ -2641,6 +3011,7 @@ export function buildAgentReplacementPrompt(params: {
...promptPackInstructionLines,
...scenePlanLines,
...marketplaceBrandRemovalLines,
...productStructureLockLines,
...productAngleLockLines,
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
@ -2670,6 +3041,7 @@ export function buildAgentReplacementPrompt(params: {
...promptPackInstructionLines,
...scenePlanLines,
...marketplaceBrandRemovalLines,
...productStructureLockLines,
...productAngleLockLines,
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
@ -2785,6 +3157,7 @@ export function buildAgentReplacementPrompt(params: {
"【图片顺序规则】",
...imageOrderLines,
...scenePlanLines,
...productStructureLockLines,
...productAngleLockLines,
params.pairedProductIndex !== undefined
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`

View File

@ -1,6 +1,6 @@
import { createLogger } from "@/lib/observability/logger";
import { NextResponse } from "next/server";
import { getRequestErrorMessage } from "@/lib/request-error";
import { getRequestErrorMessage, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
import { generateUUID } from "@/lib/utils";
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();
const normalized = rawMessage.toLowerCase();
if (isProviderBalanceError(error)) {
return "当前模型服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
}
if (isRateLimitError(error)) {
return "当前模型服务请求过于频繁,请稍后重试或切换模型。";
}
if (
normalized.includes("token quota is not enough") ||
normalized.includes("pre_consume_token_quota_failed") ||
@ -47,6 +55,16 @@ function resolveChatApiFailure(error: unknown) {
const detailMessage = getRequestErrorMessage(error);
const normalized = String(detailMessage || "").toLowerCase();
if (isProviderBalanceError(error)) {
return {
code: "server_error:chat",
message: "模型服务商线路余额或额度不足,请切换模型或联系管理员。",
status: 503,
category: "provider_balance",
detailMessage,
};
}
if (
normalized.includes("payload 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 type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media";
import { isProviderBalanceError } from "@/lib/request-error";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
const chatApiLogger = createLogger("chat.imageFailover");
@ -18,6 +20,7 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"runway",
"krea",
@ -56,7 +59,21 @@ export function resolveImageModelRuntimeId(
export function normalizeFailoverErrorMessage(error: unknown): string {
if (!error) return "";
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 {
return JSON.stringify(error);
} catch {
@ -67,6 +84,7 @@ export function normalizeFailoverErrorMessage(error: unknown): string {
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
const msg = errorMessage.toLowerCase();
return (
isProviderBalanceError(errorMessage) ||
msg.includes("model not found") ||
msg.includes("model_not_found") ||
msg.includes("no available channel for model") ||
@ -75,6 +93,13 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean
msg.includes("api key is missing") ||
msg.includes("invalid api key") ||
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("model maintenance") ||
msg.includes("maintenance") ||
@ -249,6 +274,7 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "stepfun-image":
case "midjourney":
case "runway":
@ -300,9 +326,9 @@ export function buildImageFallbackCandidates(
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false;
}
if (isImageFallbackModelCoolingDown(item.model.id)) {
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
const cooldown = imageFailoverModelCooldown.get(item.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false;
}
return true;
@ -462,9 +488,9 @@ export function getImageCrossProviderCandidates(
}
if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id)) {
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "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 true;

View File

@ -527,6 +527,7 @@ export async function convertMessagesWithImages(
normalizedProviderType === "bananaimage" ||
normalizedProviderType === "visionary" ||
normalizedProviderType === "atlascloud" ||
normalizedProviderType === "vip98k" ||
normalizedProviderType === "stepfun-image" ||
normalizedProviderType === "midjourney" ||
normalizedProviderType === "runway" ||

View File

@ -191,6 +191,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
normalized === "bananaimage" ||
normalized === "visionary" ||
normalized === "atlascloud" ||
normalized === "vip98k" ||
normalized === "stepfun-image" ||
normalized === "midjourney" ||
normalized === "runway" ||

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 { geolocation } from "@vercel/functions";
import {
@ -179,6 +181,7 @@ import { ChatSDKError } from "@/lib/errors";
import {
getFriendlyRateLimitMessage,
getRequestErrorMessage,
getUserFacingImageGenerationErrorMessage,
isRateLimitError,
} from "@/lib/request-error";
import {
@ -193,10 +196,12 @@ import {
} from "@/lib/ai/runtime/agent-media-markdown";
import { extractImageUrlsFromText } from "@/lib/image-url";
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
import { withAuthenticationFailover } from "@/lib/ai/runtime/authentication-failover";
import {
recordAiModelHealthFailure,
recordAiModelHealthSuccess,
shouldFailoverAiModelError,
shouldFailoverVip98kRequestRejection,
} from "@/lib/ai/runtime/model-health";
import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task";
import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence";
@ -363,6 +368,10 @@ import {
resolveReplacementReferenceGeometry,
resolveReplacementScenePlanningRuntime,
} from "./_lib/detail-replacement";
import {
selectDetailReplacementGenerationInputs,
shouldIncludeDetailReplacementModelImage,
} from "@/lib/ai/detail-replacement-inputs";
import {
normalizeAgentReportForDisplay,
sanitizeAgentOutputSettingsSection,
@ -417,8 +426,6 @@ const chatApiLogger = createLogger("chat.route");
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
const normalizedMarkdown = String(markdown || "").trim();
@ -435,41 +442,13 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
function getPublicImageGenerationErrorText(error: unknown) {
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 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(
1,
@ -928,6 +907,15 @@ export async function POST(request: Request) {
const json = await request.json();
chatApiLogger.info("[Chat API] Received request body:", JSON.stringify(json, null, 2));
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) {
chatApiLogger.error("[Chat API] Schema validation failed:", error);
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) {
const configuredAgentModel = await getAgentAiModelWithProvider().catch((agentModelError) => {
chatApiLogger.warn("[Chat API] Failed to load configured agent model:", agentModelError);
@ -1342,6 +1338,7 @@ export async function POST(request: Request) {
}
const allowAgentVideoGeneration =
(contextPayload?.source === "canvas" && inferVideoTaskRequirements(requestTextContent).expectsVideo) ||
Boolean(requestBody.videoParams) ||
isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined);
let smartAutoWorkflowDecision:
@ -5723,12 +5720,15 @@ export async function POST(request: Request) {
sourceImageUrls: string[];
streamLabel: string;
promptForPersistence?: string | null;
isFallback?: boolean;
}) => {
const persistedImageSize =
String(params.imageSizeLabel || "").trim() || resolvedImageSize;
const assistantText = prependImageModelBadge(
[
`<!-- 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}`,
String(params.bodyText || "").trim(),
buildAgentToolImagesMarkdown([
@ -5745,44 +5745,55 @@ export async function POST(request: Request) {
);
const assistantMessageId = generateUUID();
await saveMessages({
messages: [
{
id: assistantMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: assistantText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
try {
await saveMessages({
messages: [
{
id: assistantMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: assistantText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
await saveGeneratedImages(
assistantText,
session.user.id,
id,
assistantMessageId,
params.promptForPersistence || baseReplacementPrompt,
resolvedModelRef,
{
sourceAssetIds,
sourceImageUrls: params.sourceImageUrls,
relationType: "edited_from",
sourceType: "detail_replacement",
projectId: contextPayload?.projectId || null,
userPrompt: requestTextContent,
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
requestedImageCount: 1,
aspectRatio: resolvedAspectRatio,
imageSize: persistedImageSize,
},
);
if (!params.isFallback) {
await saveGeneratedImages(
assistantText,
session.user.id,
id,
assistantMessageId,
params.promptForPersistence || baseReplacementPrompt,
resolvedModelRef,
{
sourceAssetIds,
sourceImageUrls: params.sourceImageUrls,
relationType: "edited_from",
sourceType: "detail_replacement",
projectId: contextPayload?.projectId || null,
userPrompt: requestTextContent,
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
requestedImageCount: 1,
aspectRatio: resolvedAspectRatio,
imageSize: persistedImageSize,
},
);
}
} catch (persistError) {
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
chatId: id,
referenceId: params.reference.id,
referenceLabel: params.reference.label,
error: persistError,
});
}
writeAssistantTextChunk(
writer,
@ -5810,6 +5821,7 @@ export async function POST(request: Request) {
Array.from(replacementReferences.entries()),
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT,
async ([referenceIndex, reference]) => {
const itemStartedAt = Date.now();
if (reference.passthrough === true) {
const referenceSourceImageUrls = Array.from(
new Set([
@ -5830,9 +5842,29 @@ export async function POST(request: Request) {
streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
});
successCount += 1;
chatApiLogger.info("[Chat API] Direct detail replacement item passthrough completed:", {
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
referenceLabel: reference.label,
elapsedMs: Date.now() - itemStartedAt,
});
return;
}
const fallbackSourceImageUrls = Array.from(
new Set([
...productInputImageUrls,
...(modelInputImageUrl ? [modelInputImageUrl] : []),
...(reference.rerunReferenceImages || [])
.map((image) => String(image.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 6),
reference.imageUrl,
]),
);
try {
const planningStartedAt = Date.now();
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
modeType: "detail_replacement",
replacementStrategy: detailReplacementStrategy,
@ -5847,10 +5879,12 @@ export async function POST(request: Request) {
planningRuntime: replacementScenePlanningRuntime,
models: replacementVisionModels,
});
const planningElapsedMs = Date.now() - planningStartedAt;
chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", {
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
referenceLabel: reference.label,
planningElapsedMs,
shouldReplace: replacementExecutionPlan.shouldReplace,
reason: replacementExecutionPlan.reason,
detectionSource: replacementExecutionPlan.detectionSource,
@ -5901,20 +5935,18 @@ export async function POST(request: Request) {
const shouldIncludeModelImage =
detailReplacementStrategy !== "product_only" &&
Boolean(modelInputImageUrl) &&
(replacementScenePlanShouldIncludeModelImage(
(shouldIncludeDetailReplacementModelImage(
replacementExecutionPlan.scenePlan,
) ||
selectedProductContextImageUrls.length === 0);
const sourceImageUrls = Array.from(
new Set([
...selectedProductContextImageUrls,
...(shouldIncludeModelImage && modelInputImageUrl
? [modelInputImageUrl]
: []),
...rerunReferenceImageUrls,
reference.imageUrl,
]),
);
const generationInputs = selectDetailReplacementGenerationInputs({
productImageUrls: selectedProductContextImageUrls,
modelImageUrl: modelInputImageUrl,
includeModelImage: shouldIncludeModelImage,
rerunReferenceImageUrls,
referenceImageUrl: reference.imageUrl,
});
const sourceImageUrls = generationInputs.imageUrls;
chatApiLogger.info("[Chat API] Direct detail replacement image generation started:", {
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
@ -5922,10 +5954,10 @@ export async function POST(request: Request) {
pageKind: replacementExecutionPlan.scenePlan.pageKind,
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
packPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
selectedProductImages: selectedProductContextImageUrls.length,
selectedProductImages: generationInputs.productImageUrls.length,
sourceProductImages: productInputImageUrls.length,
includeModelImage: shouldIncludeModelImage,
rerunReferenceImages: rerunReferenceImageUrls.length,
includeModelImage: Boolean(generationInputs.modelImageUrl),
rerunReferenceImages: generationInputs.rerunImageUrls.length,
sourceImageCount: sourceImageUrls.length,
whiteLayout: replacementExecutionPlan.scenePlan.whiteBackgroundLikely,
copyCoupled: replacementExecutionPlan.scenePlan.copyImageCoupled,
@ -5940,10 +5972,10 @@ export async function POST(request: Request) {
referenceLabel: reference.label,
referenceIndex,
totalReferences: replacementReferences.length,
hasModelImage: shouldIncludeModelImage,
productImageCount: selectedProductContextImageUrls.length,
hasModelImage: Boolean(generationInputs.modelImageUrl),
productImageCount: generationInputs.productImageUrls.length,
originalProductImageCount: productInputImageUrls.length,
rerunReferenceCount: rerunReferenceImageUrls.length,
rerunReferenceCount: generationInputs.rerunImageUrls.length,
templateReferenceCount: 0,
scenePlan: replacementExecutionPlan.scenePlan,
referenceGeometry,
@ -5960,7 +5992,12 @@ export async function POST(request: Request) {
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
});
let generationStartedAt = 0;
let generationElapsedMs: number | null = null;
let normalizeStartedAt = 0;
let normalizeElapsedMs: number | null = null;
try {
generationStartedAt = Date.now();
const result = await generateCanvasImage({
prompt: replacementPrompt,
negativePrompt: effectiveImageParams?.negativePrompt,
@ -5970,6 +6007,10 @@ export async function POST(request: Request) {
imageSize: resolvedImageSize,
imageCount: 1,
referenceImages: sourceImageUrls,
promptExtend: false,
providerImageParams: {
inputImageRoleByUrl: generationInputs.inputImageRoleByUrl,
},
debugLabel: "agent-detail-replacement",
debugMeta: {
referenceLabel: reference.label,
@ -6000,6 +6041,7 @@ export async function POST(request: Request) {
}
: undefined,
});
generationElapsedMs = Date.now() - generationStartedAt;
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
const firstImage = result.images[0];
if (!firstImage) {
@ -6009,12 +6051,16 @@ export async function POST(request: Request) {
let finalImage = firstImage;
let finalImageSizeLabel: string = resolvedImageSize;
try {
normalizeStartedAt = Date.now();
const normalizedImage = await normalizeReplacementResultImage({
generatedImageUrl: firstImage.originalUrl,
generatedThumbnailUrl: firstImage.thumbnailUrl,
referenceImageUrl: reference.imageUrl,
referenceGeometry,
scenePlan: replacementExecutionPlan.scenePlan,
preserveReferenceOutsideSubject:
detailReplacementStrategy === "product_only",
preserveReferenceText: true,
uploadPrefix: "agent-detail-replacement",
});
finalImage = {
@ -6022,7 +6068,11 @@ export async function POST(request: Request) {
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
};
finalImageSizeLabel = normalizedImage.imageSize;
normalizeElapsedMs = Date.now() - normalizeStartedAt;
} catch (normalizeError) {
if (normalizeStartedAt > 0 && normalizeElapsedMs === null) {
normalizeElapsedMs = Date.now() - normalizeStartedAt;
}
chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", {
referenceLabel: reference.label,
referenceImageUrl: reference.imageUrl,
@ -6040,6 +6090,22 @@ export async function POST(request: Request) {
}
}
chatApiLogger.info("[Chat API] Direct detail replacement image generation completed:", {
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
referenceLabel: reference.label,
modelDisplayName: resolvedModelDisplayName,
resultImageSize: finalImageSizeLabel,
sourceImageCount: sourceImageUrls.length,
generationAspectRatio,
pageKind: replacementExecutionPlan.scenePlan.pageKind,
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
elapsedMs: Date.now() - itemStartedAt,
planningElapsedMs,
generationElapsedMs,
normalizeElapsedMs,
});
await emitReplacementResultMessage({
reference,
image: finalImage,
@ -6051,9 +6117,16 @@ export async function POST(request: Request) {
});
successCount += 1;
} catch (replacementItemError) {
if (generationStartedAt > 0 && generationElapsedMs === null) {
generationElapsedMs = Date.now() - generationStartedAt;
}
chatApiLogger.error("[Chat API] Direct detail replacement item failed:", {
referenceIndex,
referenceLabel: reference.label,
elapsedMs: Date.now() - itemStartedAt,
planningElapsedMs,
generationElapsedMs,
normalizeElapsedMs,
error: replacementItemError,
});
await emitReplacementResultMessage({
@ -6070,8 +6143,32 @@ export async function POST(request: Request) {
modelDisplayName: null,
sourceImageUrls: referenceSourceImageUrls,
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
isFallback: true,
});
}
} catch (replacementPlanError) {
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
referenceIndex,
referenceLabel: reference.label,
elapsedMs: Date.now() - itemStartedAt,
error: replacementPlanError,
});
await emitReplacementResultMessage({
reference,
bodyText: `生成失败,当前先沿用参考图:${
replacementPlanError instanceof Error
? replacementPlanError.message
: "请稍后重试"
}`,
image: {
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
},
modelDisplayName: null,
sourceImageUrls: fallbackSourceImageUrls,
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
isFallback: true,
});
successCount += 1;
}
});
} finally {
@ -6399,9 +6496,20 @@ export async function POST(request: Request) {
...(selectedAgentPreset.defaultParams || {}),
...(replacementSlot?.defaultParams || {}),
} as Record<string, any>;
const resolvedModelRef =
String(selectedChatModel || "").trim() &&
String(selectedChatModel || "").trim() !== "auto"
const replacementVisionModels = await getEnabledAiModelsWithProvider().catch(
(error) => {
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()
: presetModelId;
const configuredReplacementAspectRatio =
@ -6423,12 +6531,6 @@ export async function POST(request: Request) {
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 =
await resolveReplacementScenePlanningRuntime({
models: replacementVisionModels,
@ -6726,24 +6828,36 @@ export async function POST(request: Request) {
!(isDetailReplacementPreset &&
detailReplacementStrategy === "product_only") &&
Boolean(modelInputImageUrl) &&
(replacementScenePlanShouldIncludeModelImage(
(shouldIncludeDetailReplacementModelImage(
replacementExecutionPlan.scenePlan,
) ||
(isDetailReplacementPreset &&
selectedProductContextImageUrls.length === 0));
const sourceImageUrls = Array.from(
new Set(
[
...selectedProductContextImageUrls,
...(shouldIncludeModelImage && modelInputImageUrl
? [modelInputImageUrl]
: []),
...replacementTemplateReferenceImageUrls,
...rerunReferenceImageUrls,
reference.imageUrl,
].filter(Boolean),
),
);
const detailGenerationInputs = isDetailReplacementPreset
? selectDetailReplacementGenerationInputs({
productImageUrls: selectedProductContextImageUrls,
modelImageUrl: modelInputImageUrl,
includeModelImage: shouldIncludeModelImage,
templateReferenceImageUrls: replacementTemplateReferenceImageUrls,
rerunReferenceImageUrls,
referenceImageUrl: reference.imageUrl,
})
: null;
const sourceImageUrls = detailGenerationInputs
? detailGenerationInputs.imageUrls
: Array.from(
new Set(
[
...selectedProductContextImageUrls,
...(shouldIncludeModelImage && modelInputImageUrl
? [modelInputImageUrl]
: []),
...replacementTemplateReferenceImageUrls,
...rerunReferenceImageUrls,
reference.imageUrl,
].filter(Boolean),
),
);
const replacementPrompt = buildAgentReplacementPrompt({
basePrompt:
baseReplacementPrompt ||
@ -6760,11 +6874,19 @@ export async function POST(request: Request) {
referenceLabel: reference.label,
referenceIndex,
totalReferences: replacementReferences.length,
hasModelImage: shouldIncludeModelImage,
productImageCount: selectedProductContextImageUrls.length,
hasModelImage: detailGenerationInputs
? Boolean(detailGenerationInputs.modelImageUrl)
: shouldIncludeModelImage,
productImageCount: detailGenerationInputs
? detailGenerationInputs.productImageUrls.length
: selectedProductContextImageUrls.length,
originalProductImageCount: productInputImageUrls.length,
rerunReferenceCount: rerunReferenceImageUrls.length,
templateReferenceCount: replacementTemplateReferenceImageUrls.length,
rerunReferenceCount: detailGenerationInputs
? detailGenerationInputs.rerunImageUrls.length
: rerunReferenceImageUrls.length,
templateReferenceCount: detailGenerationInputs
? detailGenerationInputs.templateImageUrls.length
: replacementTemplateReferenceImageUrls.length,
pairedProductIndex,
scenePlan: replacementExecutionPlan.scenePlan,
referenceGeometry,
@ -6785,11 +6907,20 @@ export async function POST(request: Request) {
sourceImageCount: sourceImageUrls.length,
generationAspectRatio,
imageOrder: {
productImages: selectedProductContextImageUrls.length,
productImages:
detailGenerationInputs !== null
? detailGenerationInputs.productImageUrls.length
: selectedProductContextImageUrls.length,
originalProductImages: productInputImageUrls.length,
hasModelImage: shouldIncludeModelImage,
templateReferenceImages: replacementTemplateReferenceImageUrls.length,
rerunReferenceImages: rerunReferenceImageUrls.length,
hasModelImage: detailGenerationInputs
? Boolean(detailGenerationInputs.modelImageUrl)
: shouldIncludeModelImage,
templateReferenceImages: detailGenerationInputs
? detailGenerationInputs.templateImageUrls.length
: replacementTemplateReferenceImageUrls.length,
rerunReferenceImages: detailGenerationInputs
? detailGenerationInputs.rerunImageUrls.length
: rerunReferenceImageUrls.length,
finalReferenceImageIndex: sourceImageUrls.length,
},
});
@ -6807,6 +6938,17 @@ export async function POST(request: Request) {
imageSize: resolvedImageSize,
imageCount: 1,
referenceImages: sourceImageUrls,
promptExtend: isDetailReplacementPreset
? false
: typeof mergedImageParams.promptExtend === "boolean"
? mergedImageParams.promptExtend
: undefined,
providerImageParams: detailGenerationInputs
? {
inputImageRoleByUrl:
detailGenerationInputs.inputImageRoleByUrl,
}
: undefined,
debugLabel: `agent-${replacementSourceType}`,
debugMeta: {
referenceLabel: reference.label,
@ -6853,6 +6995,10 @@ export async function POST(request: Request) {
referenceImageUrl: reference.imageUrl,
referenceGeometry,
scenePlan: replacementExecutionPlan.scenePlan,
preserveReferenceOutsideSubject:
isDetailReplacementPreset &&
detailReplacementStrategy === "product_only",
preserveReferenceText: isDetailReplacementPreset,
uploadPrefix: isDetailReplacementPreset
? "agent-detail-replacement"
: "agent-sku-replacement",
@ -8740,7 +8886,7 @@ export async function POST(request: Request) {
.filter(Boolean)
.join("\n\n");
const activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
let activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
resolvedRuntimeModelLookupId,
).catch(() => null);
if (shouldRunAgentConversation && activeRuntimeModelDescriptor) {
@ -9132,6 +9278,7 @@ export async function POST(request: Request) {
bananaimage: 7,
visionary: 7,
atlascloud: 7,
vip98k: 7,
"stepfun-image": 7,
"grsai-gpt-image": 8,
runway: 9,
@ -9526,6 +9673,7 @@ export async function POST(request: Request) {
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"midjourney",
"runway",
@ -9543,6 +9691,11 @@ export async function POST(request: Request) {
alibabaModelConfig?.fallbackModelId2,
].filter(Boolean) as string[];
const allModels = await getEnabledAiModelsWithProvider();
const currentModelEntry = resolveModelEntryByRuntimeModelId(
allModels,
actualModelId,
finalModelId,
);
const fallbackCandidates = buildImageFallbackCandidates(allModels, {
currentProviderId: alibabaModelConfig?.providerId,
currentModelId: alibabaModelConfig?.id,
@ -9754,6 +9907,8 @@ export async function POST(request: Request) {
? "visionary"
: (languageModel as any).provider === "atlascloud"
? "atlascloud"
: (languageModel as any).provider === "vip98k"
? "vip98k"
: (languageModel as any).provider === "stepfun-image"
? "stepfun-image"
: (languageModel as any).provider === "midjourney"
@ -9786,6 +9941,7 @@ export async function POST(request: Request) {
let imageMarkdown = "";
let usedModelId = actualModelId;
const generationStartedAt = Date.now();
// 🆕 图片生成真正开始时发出标记(用于前端显示占位图)
writeAssistantTextChunk(
@ -9812,14 +9968,43 @@ export async function POST(request: Request) {
} catch (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.");
throw primaryError;
}
let fallbackSuccess = false;
const triedFallbackModelIds = new Set<string>();
for (const candidate of fallbackCandidates) {
try {
triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
const fallbackResult = await generateImageWithFailoverCandidate({
candidate,
@ -9844,6 +10029,22 @@ export async function POST(request: Request) {
if (shouldCooldownImageFallbackModel(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);
}
}
@ -9860,6 +10061,7 @@ export async function POST(request: Request) {
bananaimage: 7,
visionary: 7,
atlascloud: 7,
vip98k: 7,
"stepfun-image": 7,
runway: 8,
krea: 9,
@ -9871,10 +10073,11 @@ export async function POST(request: Request) {
currentModelKey: actualModelId,
currentProviderId: alibabaModelConfig?.providerId,
providerPriority,
});
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
for (const candidate of crossProviderCandidates) {
try {
triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
if (
candidate.provider.type === "fal-image" &&
@ -9909,6 +10112,8 @@ export async function POST(request: Request) {
? "visionary"
: candidate.provider.type === "atlascloud"
? "atlascloud"
: candidate.provider.type === "vip98k"
? "vip98k"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"
@ -9956,6 +10161,22 @@ export async function POST(request: Request) {
if (shouldCooldownImageFallbackModel(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);
}
}
@ -9969,6 +10190,19 @@ export async function POST(request: Request) {
chatApiLogger.info("[Chat API] Alibaba generated, length:", imageMarkdown.length);
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 =
resolveModelDisplayNameByModelId(allModels, usedModelId) ||
alibabaModelConfig?.displayName ||
@ -10298,6 +10532,7 @@ export async function POST(request: Request) {
bananaimage: 6,
visionary: 6,
atlascloud: 6,
vip98k: 6,
"stepfun-image": 6,
runway: 7,
krea: 8,
@ -10348,6 +10583,8 @@ export async function POST(request: Request) {
? "visionary"
: candidate.provider.type === "atlascloud"
? "atlascloud"
: candidate.provider.type === "vip98k"
? "vip98k"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"
@ -10561,7 +10798,7 @@ export async function POST(request: Request) {
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
if (
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video"].includes((languageModel as any).provider) &&
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video", "vip98k"].includes((languageModel as any).provider) &&
typeof (languageModel as any).doGenerate === "function"
) {
const currentVideoProvider = (languageModel as any).provider as
@ -10570,7 +10807,8 @@ export async function POST(request: Request) {
| "ruxa-video"
| "runway"
| "krea"
| "bltcy-video";
| "bltcy-video"
| "vip98k";
chatApiLogger.info(`[Chat API] Using ${currentVideoProvider} video generation`);
const videoGenerationStream = createUIMessageStream({
@ -11005,7 +11243,7 @@ export async function POST(request: Request) {
requestHeaders: request.headers,
requestUrl: request.url,
defaultModelId: finalModelId,
destructiveConfirmed: isCanvasOperationConfirmText(requestTextContent),
authorizeDestructive: (nodeIds) => isExplicitDestructiveIntent(requestTextContent) && nodeIds.length > 0 && nodeIds.every((nodeId) => selectedCanvasImageNodeIds.includes(nodeId)),
callbacks: {
onClientCommand: (command, capability) => {
writeCanvasCapabilityCommandChunk(
@ -11226,6 +11464,7 @@ export async function POST(request: Request) {
bananaimage: 7,
visionary: 7,
atlascloud: 7,
vip98k: 7,
"stepfun-image": 7,
runway: 8,
krea: 9,
@ -11278,6 +11517,8 @@ export async function POST(request: Request) {
? "visionary"
: candidate.provider.type === "atlascloud"
? "atlascloud"
: candidate.provider.type === "vip98k"
? "vip98k"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"
@ -12031,8 +12272,70 @@ export async function POST(request: Request) {
const shouldDisableSmoothStream =
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({
model: languageModel,
model: streamModel,
maxRetries: shouldRunAgentConversation ? 0 : undefined,
system: effectiveConversationSystemPrompt,
messages: convertedMessages,
stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),

View File

@ -3,6 +3,7 @@ import {
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
HeadObjectCommand,
ListPartsCommand,
PutObjectCommand,
UploadPartCommand,
} from "@aws-sdk/client-s3";
@ -12,6 +13,10 @@ import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
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 {
buildS3PublicUrl,
@ -21,12 +26,22 @@ import {
maybeEnqueueStorageMigrationTask,
resolveS3UploadTarget,
} from "@/lib/storage/service";
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 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 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";
function elapsedMs(startedAt: number) {
@ -39,6 +54,13 @@ const allowedDirectUploadTypes = new Set([
"image/png",
"image/webp",
]);
const allowedOpenClawVideoUploadTypes = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const InitiateSchema = z.object({
action: z.literal("initiate"),
@ -68,6 +90,18 @@ const CompleteSchema = z.object({
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({
action: z.literal("abort"),
key: z.string().min(1).max(512),
@ -77,6 +111,7 @@ const AbortSchema = z.object({
const RequestSchema = z.discriminatedUnion("action", [
InitiateSchema,
ResumeSchema,
CompleteSchema,
AbortSchema,
]);
@ -104,19 +139,52 @@ function assertUserOwnedKey(key: string, userId: string) {
const normalized = String(key || "").replace(/^\/+/, "");
const prefix = `${userUploadPrefix(userId)}/`;
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;
}
function isAllowedDirectUpload(contentType: string) {
return allowedDirectUploadTypes.has(contentType);
function isAllowedDirectUpload(
contentType: string,
purpose: string | undefined,
filename: string
) {
return (
allowedDirectUploadTypes.has(contentType) ||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
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) {
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024;
return 16 * 1024 * 1024;
if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
return 64 * 1024 * 1024;
}
async function createPartUrls(params: {
@ -124,24 +192,24 @@ async function createPartUrls(params: {
bucket: string;
key: string;
uploadId: string;
partCount: number;
partNumbers: number[];
}) {
const urls = [];
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) {
const command = new UploadPartCommand({
Bucket: params.bucket,
Key: params.key,
UploadId: params.uploadId,
PartNumber: partNumber,
});
urls.push({
partNumber,
url: await getSignedUrl(params.client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
}),
});
}
return urls;
return Promise.all(
params.partNumbers.map(async (partNumber) => {
const command = new UploadPartCommand({
Bucket: params.bucket,
Key: params.key,
UploadId: params.uploadId,
PartNumber: partNumber,
});
return {
partNumber,
url: await getSignedUrl(params.client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
}),
};
})
);
}
export async function POST(request: Request) {
@ -204,7 +272,7 @@ export async function POST(request: Request) {
payload.filename
);
if (!isAllowedDirectUpload(contentType)) {
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
logger.warn("unsupported_direct_upload_type", {
contentType,
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
@ -213,10 +281,24 @@ export async function POST(request: Request) {
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json(
{ error: "Direct upload only supports JPG, PNG and WebP images" },
{
error:
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
},
{ 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 key = buildStorageObjectKey(
@ -224,7 +306,13 @@ export async function POST(request: Request) {
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 command = new PutObjectCommand({
Bucket: bucket,
@ -284,7 +372,7 @@ export async function POST(request: Request) {
bucket,
key,
uploadId,
partCount,
partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
});
logger.info("direct_upload_multipart_prepared", {
elapsedMs: elapsedMs(multipartStartedAt),
@ -308,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") {
const key = assertUserOwnedKey(payload.key, session.user.id);
const abortStartedAt = Date.now();
@ -328,12 +515,25 @@ export async function POST(request: Request) {
const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType)) {
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
return NextResponse.json(
{ error: "Direct upload only supports JPG, PNG and WebP images" },
{
error:
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
},
{ 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) {
const completeMultipartStartedAt = Date.now();
@ -379,8 +579,10 @@ export async function POST(request: Request) {
const actualSize =
typeof head.ContentLength === "number" ? head.ContentLength : null;
if (actualSize !== null && actualSize <= 0) {
throw new Error("Uploaded object is empty");
if (actualSize !== null && actualSize !== payload.size) {
throw new Error(
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
);
}
const url = buildS3PublicUrl(target.config, key);
@ -389,15 +591,43 @@ export async function POST(request: Request) {
const assetStartedAt = Date.now();
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
sourceType: "upload",
mediaType:
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? "file"
: resolvedContentType.startsWith("video/")
? "video"
: "image",
sourceType:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_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",
originalUrl: url,
previewUrl: url,
thumbnailUrl: url,
previewUrl:
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,
displayName: payload.filename,
sourceTable: "direct_upload",
sourceTable:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_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: {
pathname: key,
size: actualSize ?? payload.size,
@ -444,7 +674,13 @@ export async function POST(request: Request) {
error: "Direct upload failed",
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 { z } from "zod";
import {
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
isImageUploadTooLarge,
} from "@/lib/image-upload-limits";
import { auth } from "@/app/(auth)/auth";
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
@ -7,6 +11,7 @@ import { upsertMediaAsset } from "@/lib/db/queries";
import { isSiteAdminType } from "@/lib/middleware/admin";
import { getContentType, uploadFile } from "@/lib/storage/service";
import { createLogger } from "@/lib/observability/logger";
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("upload-api");
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
@ -14,6 +19,9 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/jpg",
"image/png",
"image/webp",
"image/avif",
"image/tiff",
"image/x-tiff",
"image/svg+xml",
"image/heic",
"image/heif",
@ -23,8 +31,20 @@ const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
"video/quicktime",
"video/webm",
]);
const ALLOWED_OPENCLAW_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
"image/avif",
"image/tiff",
"image/x-tiff",
]);
function normalizeUploadContentType(contentType: string, filename: string) {
const normalized = contentType.trim().toLowerCase();
@ -40,8 +60,16 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
}
if (
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
extension === "avif" ||
extension === "tif" ||
extension === "tiff"
) {
return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。";
}
return "File type should be JPEG, PNG, WebP or SVG";
return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。";
}
function isHeicUpload(contentType: string, filename: string) {
@ -53,10 +81,20 @@ function isHeicUpload(contentType: string, filename: string) {
);
}
function isSharpCompatibilityUpload(contentType: string, filename: string) {
const extension = filename.toLowerCase().split(".").pop() || "";
return (
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
extension === "avif" ||
extension === "tif" ||
extension === "tiff"
);
}
function toJpegFilename(filename: string) {
const trimmed = filename.trim() || `upload-${Date.now()}`;
if (/\.(heic|heif)$/i.test(trimmed)) {
return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
}
if (/\.[a-z0-9]+$/i.test(trimmed)) {
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
@ -76,6 +114,21 @@ async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
}
return Buffer.from(output);
}
async function convertSharpCompatibilityImageToJpeg(
buffer: Buffer
): Promise<Buffer> {
const sharp = (await import("sharp")).default;
return sharp(buffer, { animated: false, failOn: "none" })
.rotate()
.flatten({ background: "#ffffff" })
.jpeg({
quality: 92,
mozjpeg: true,
progressive: true,
})
.toBuffer();
}
/**
* 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化
@ -205,9 +258,18 @@ export async function POST(request: Request) {
const isAtlasCloudReferenceVideo =
uploadPurpose === "canvas-video-process" &&
ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
const isOpenClawChatVideoUpload =
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
ALLOWED_OPENCLAW_VIDEO_TYPES.has(contentType);
const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null;
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 (
isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
@ -237,7 +299,8 @@ export async function POST(request: Request) {
if (
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
!isAtlasCloudReferenceVideo
!isAtlasCloudReferenceVideo &&
!isOpenClawChatVideoUpload
) {
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
logger.warn("[Upload API] Unsupported upload content type", {
@ -288,6 +351,38 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
} else if (isSharpCompatibilityUpload(contentType, filename)) {
try {
const convertedBuffer =
await convertSharpCompatibilityImageToJpeg(uploadBuffer);
logger.info("[Upload API] Compatibility image converted to JPEG", {
clientUploadId,
originalContentType: contentType,
originalSize: uploadBuffer.length,
convertedSize: convertedBuffer.length,
});
uploadBuffer = convertedBuffer;
filename = toJpegFilename(filename);
convertedFrom = contentType;
contentType = "image/jpeg";
} catch (conversionError) {
logger.warn("[Upload API] Compatibility image conversion failed", {
clientUploadId,
contentType,
size: uploadBuffer.length,
error:
conversionError instanceof Error
? conversionError.message
: String(conversionError),
});
return NextResponse.json(
{
error: createUnsupportedImageTypeMessage(contentType, filename),
clientUploadId,
},
{ status: 400 }
);
}
}
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
@ -372,15 +467,28 @@ export async function POST(request: Request) {
try {
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType: isAtlasCloudReferenceVideo ? "video" : "image",
sourceType: "upload",
mediaType:
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? "video"
: "image",
sourceType: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: "upload",
status: "ready",
originalUrl: data.url,
previewUrl: isAtlasCloudReferenceVideo ? null : data.url,
thumbnailUrl: isAtlasCloudReferenceVideo ? null : data.url,
previewUrl:
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? null
: data.url,
thumbnailUrl:
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? null
: data.url,
mimeType: data.contentType || contentType || null,
displayName: filename,
sourceTable: "upload",
sourceTable: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: "upload",
metadata: {
pathname: data.pathname,
size: optimizedBuffer.length,

View File

@ -102,8 +102,8 @@ export async function GET(request: Request) {
},
{
id: "local-e2e-video-model",
modelId: "kling-3-local",
displayName: "可灵 3.0",
modelId: "veo-3.1-fast",
displayName: "Veo 3.1 Fast(本地布局测试)",
avatarUrl: null,
description: "本地视频画布测试视频模型",
supportsVision: true,
@ -111,18 +111,18 @@ export async function GET(request: Request) {
isMultimodal: true,
isDefault: false,
providerName: "Local E2E",
providerType: "local",
providerType: "vip98k",
isSubModel: false,
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: {
supported: true,
durations: [5, 10],
durations: [8],
resolutions: ["720p", "1080p"],
},
imageGenConfig: {
creditCostPerVideo: 20,
videoDurationOptions: [5, 10],
videoDurationOptions: [8],
},
sortOrder: 1,
selectable: true,

View File

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

View File

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

View File

@ -1,79 +1,75 @@
import { randomUUID } from "node:crypto";
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 { 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 = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
type Props = { searchParams?: Promise<Record<string, string | string[] | undefined>> };
function getSearchParam(
params: Record<string, string | string[] | undefined>,
key: string
) {
const value = params[key];
function first(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function buildVideoCanvasUrl(
canvasId: string,
params: Record<string, string | string[] | undefined>
) {
const query = new URLSearchParams();
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);
function canvasHref(id: string, incoming: Record<string, string | string[] | undefined>) {
const params = new URLSearchParams({ mode: "video", workspaceType: "video" });
for (const [key, value] of Object.entries(incoming)) {
if (["new", "mode", "workspaceType"].includes(key) || value === undefined) continue;
for (const item of Array.isArray(value) ? value : [value]) params.append(key, item);
}
return `/canvas/${canvasId}?${query.toString()}`;
return `/canvas/${id}?${params.toString()}`;
}
async function getLatestVideoCanvasId(userId: string) {
const [latestProject] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, userId),
eq(canvasProject.workspaceType, "video")
)
)
.orderBy(desc(canvasProject.updatedAt))
.limit(1);
return latestProject?.id || null;
function projectPreview(thumbnail: string | null, canvasData: unknown) {
const nodes = canvasData && typeof canvasData === "object" && "nodes" in canvasData
? (canvasData as { nodes?: unknown }).nodes : null;
const candidates = [thumbnail];
if (Array.isArray(nodes)) for (const node of [...nodes].reverse()) {
if (!node || typeof node !== "object") continue;
const item = node as { inputType?: string; draftValues?: Record<string, unknown> };
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] : "") : "") || ""),
);
}
return candidates.find(value => typeof value === "string" && (value.startsWith("https://") || value.startsWith("/"))) || null;
}
export default async function VideoCanvasRedirectPage({
searchParams,
}: VideoCanvasRedirectPageProps) {
const params = (await searchParams) || {};
const shouldCreateNew = getSearchParam(params, "new") === "1";
if (shouldCreateNew) {
redirect(buildVideoCanvasUrl(randomUUID(), params));
}
export default async function VideoCanvasHomePage({ searchParams }: Props) {
const session = await auth();
const latestCanvasId = session?.user?.id
? await getLatestVideoCanvasId(session.user.id).catch(() => null)
: null;
if (!session?.user?.id) redirect("/login?callbackUrl=%2Fvideo-canvas");
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

@ -33,6 +33,11 @@ const imageGenConfigSchema = z.object({
supportsNegativePrompt: z.boolean().optional(),
supportsWatermark: z.boolean().optional(),
supportsSeed: z.boolean().optional(),
supportsQuality: z.boolean().optional(),
supportedAspectRatios: z.array(z.string().min(1).max(32)).max(32).optional(),
supportedImageSizes: z.array(z.string().min(1).max(32)).max(32).optional(),
maxReferenceImages: z.number().min(0).max(64).optional(),
maxVideoReferences: z.number().min(0).max(16).optional(),
ruxaTaskInputMode: z
.enum(["size-image", "resolution-based"])
.optional(),
@ -45,6 +50,7 @@ const imageGenConfigSchema = z.object({
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
enableLoadingPlaceholder: z.boolean().optional(),
loadingColor: z.string().optional(),
vip98k: z.record(z.string(), z.unknown()).optional(),
}).optional();
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();

View File

@ -9,6 +9,7 @@ import {
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
import { VIP98K_BASE_URL } from "@/lib/ai/vip98k-models";
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
const providerTypes = [
@ -35,6 +36,7 @@ const providerTypes = [
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"midjourney",
"fal-video",
@ -79,11 +81,12 @@ const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
const VISIONARY_BASE_URL = "https://api.visionary.beer";
const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
const VIP98K_PROVIDER_BASE_URL = VIP98K_BASE_URL;
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
const WORLDLABS_3D_BASE_URL = "https://api.vip98k.com";
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
const KREA_BASE_URL = "https://api.krea.ai";
@ -249,6 +252,8 @@ export async function POST(request: Request) {
? data.baseUrl || VISIONARY_BASE_URL
: data.type === "atlascloud"
? data.baseUrl || ATLASCLOUD_BASE_URL
: data.type === "vip98k"
? data.baseUrl || VIP98K_PROVIDER_BASE_URL
: data.type === "stepfun-image"
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
: data.type === "midjourney"
@ -311,6 +316,12 @@ export async function PUT(request: Request) {
...data,
apiKey: normalizedApiKey,
type: normalizeProviderTypeForStorage(data.type),
baseUrl:
data.baseUrl !== undefined
? data.baseUrl
: data.type === "vip98k"
? VIP98K_PROVIDER_BASE_URL
: undefined,
updatedBy: session.user.id,
});

View File

@ -1,6 +1,10 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { fetchAtlasCloudImagePresets } from "@/lib/ai/atlascloud-models";
import {
fetchAtlasCloudModelPresets,
resolveAtlasCloudDisplayName,
shouldInsertMissingAtlasCloudPreset,
} from "@/lib/ai/atlascloud-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
@ -12,7 +16,7 @@ export const maxDuration = 300;
export async function GET() {
try {
await requireAdmin();
const result = await fetchAtlasCloudImagePresets();
const result = await fetchAtlasCloudModelPresets();
return NextResponse.json({
presets: result.presets,
errors: result.errors.length > 0 ? result.errors : undefined,
@ -29,7 +33,7 @@ export async function GET() {
error:
error instanceof Error
? error.message
: "Failed to fetch Atlas Cloud image models",
: "Failed to fetch Atlas Cloud models",
},
{ status: 500 }
);
@ -58,31 +62,50 @@ export async function POST(request: Request) {
);
}
const catalog = await fetchAtlasCloudImagePresets();
const catalog = await fetchAtlasCloudModelPresets();
const presets = requestedModelIds
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
: catalog.presets;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors = [...catalog.errors];
const providerModels = await db.query.aiModel.findMany({
where: eq(aiModel.providerId, providerId),
});
const existingByModelId = new Map(
providerModels.map((model) => [model.modelId, model])
);
const existingMediaKinds = new Set(
providerModels.map((model) => {
const capabilities = model.capabilities as Record<string, unknown> | null;
return String(capabilities?.mediaType || "image");
})
);
let preservedDeletedCount = 0;
for (const preset of presets) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
),
});
const existing = existingByModelId.get(preset.modelId);
const presetCapabilities = preset.capabilities as Record<string, unknown>;
const mediaKind = String(presetCapabilities.mediaType || "image");
if (
!existing &&
!shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
) {
preservedDeletedCount += 1;
continue;
}
const values = {
displayName: preset.displayName,
displayName: resolveAtlasCloudDisplayName(
existing?.displayName,
preset.displayName,
preset.modelId
),
avatarUrl: preset.avatarUrl || null,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
sortOrder: existing?.sortOrder ?? preset.sortOrder,
capabilities: preset.capabilities,
defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig,
@ -108,6 +131,8 @@ export async function POST(request: Request) {
providerId,
modelId: preset.modelId,
...values,
isEnabled: true,
isHidden: false,
isDefault: false,
isSubModel: false,
parentModelId: null,
@ -138,6 +163,8 @@ export async function POST(request: Request) {
requested: presets.length,
total: catalog.catalogCount,
available: catalog.presets.length,
skippedUnsupported: catalog.skipped.length,
preservedDeleted: preservedDeletedCount,
},
});
} catch (error) {
@ -147,7 +174,7 @@ export async function POST(request: Request) {
error:
error instanceof Error
? error.message
: "Failed to sync Atlas Cloud image models",
: "Failed to sync Atlas Cloud models",
},
{ status: 500 }
);

View File

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

View File

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

View File

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

View File

@ -0,0 +1,213 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import {
fetchVip98kModelPresets,
type Vip98kModelPreset,
} from "@/lib/ai/vip98k-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { aiModel, type AiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
export const runtime = "nodejs";
export const maxDuration = 300;
function summarizeByType(presets: Vip98kModelPreset[]) {
return presets.reduce<Record<string, number>>((acc, preset) => {
const type = String(preset.type || "model");
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {});
}
export async function GET(request: Request) {
try {
await requireAdmin();
const { searchParams } = new URL(request.url);
const providerId = String(searchParams.get("providerId") || "").trim();
if (!providerId) {
return NextResponse.json(
{ error: "Provider ID is required to fetch VIP98K catalog" },
{ status: 400 },
);
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "vip98k") {
return NextResponse.json(
{ error: "Provider must be a VIP98K provider" },
{ status: 400 },
);
}
const presets = await fetchVip98kModelPresets({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
return NextResponse.json({
presets,
counts: {
total: presets.length,
byType: summarizeByType(presets),
},
});
} catch (error) {
console.error("[VIP98K Models API] GET error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to fetch VIP98K models",
},
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const requestedModelIds = Array.isArray(body?.modelIds)
? new Set(
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean),
)
: null;
const preserveDeletedModels = body?.preserveDeletedModels !== false;
if (!providerId) {
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "vip98k") {
return NextResponse.json(
{ error: "Provider must be a VIP98K provider" },
{ status: 400 },
);
}
const catalogPresets = await fetchVip98kModelPresets({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
const existingModels = await db.query.aiModel.findMany({
where: eq(aiModel.providerId, providerId),
});
const existingModelIds = new Set(existingModels.map((model) => model.modelId));
const preserveExistingCatalog =
requestedModelIds === null &&
preserveDeletedModels &&
existingModels.length > 0;
const presets = requestedModelIds
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
: preserveExistingCatalog
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
: catalogPresets;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const disabledModels: AiModel[] = [];
const errors: Array<{ modelId: string; error: string }> = [];
if (requestedModelIds === null) {
const catalogModelIds = new Set(catalogPresets.map((preset) => preset.modelId));
for (const existing of existingModels) {
if (catalogModelIds.has(existing.modelId)) continue;
const [disabled] = await db
.update(aiModel)
.set({ isEnabled: false, isHidden: true, updatedAt: new Date() })
.where(eq(aiModel.id, existing.id))
.returning();
if (disabled) disabledModels.push(disabled);
}
}
for (const preset of presets) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId),
),
});
const values = {
displayName: preset.displayName,
avatarUrl: null,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled:
requestedModelIds !== null ? true : (existing?.isEnabled ?? true),
isHidden: preset.isHidden,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities,
defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig,
updatedAt: new Date(),
};
if (existing) {
const [updated] = await db
.update(aiModel)
.set(values)
.where(
and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId),
),
)
.returning();
if (updated) updatedModels.push(updated);
} else {
const [created] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
...values,
isDefault: false,
isSubModel: false,
parentModelId: null,
})
.returning();
if (created) addedModels.push(created);
}
} catch (error) {
console.error(`[VIP98K Models API] Failed to sync ${preset.modelId}:`, error);
errors.push({
modelId: preset.modelId,
error: error instanceof Error ? error.message : "Sync failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
disabledCount: disabledModels.length,
addedModels,
updatedModels,
disabledModelIds: disabledModels.map((model) => model.modelId),
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presets.length,
total: catalogPresets.length,
byType: summarizeByType(catalogPresets),
},
});
} catch (error) {
console.error("[VIP98K Models API] POST error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to sync VIP98K models",
},
{ status: 500 },
);
}
}

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,
getStyleProImageModelEntries,
} from "@/lib/style-pro/model-options";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
const requestSchema = z.object({
prompt: z.string().min(1).max(5000),
@ -52,6 +53,16 @@ export async function POST(request: Request) {
{ 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 unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {

View File

@ -1,385 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
getPointsSystemConfig,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
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秒)
import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit";
export const runtime = "nodejs";
export const maxDuration = 180;
/**
* 压缩图片生成缩略图
*/
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 }
);
}
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);
}

View File

@ -7,9 +7,9 @@ import {
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
CANVAS_SKILL_TAGS_CONFIG_VERSION,
CANVAS_SKILL_TAGS_SETTING_KEY,
type CanvasSkillTag,
normalizeCanvasSkillTagSettings,
} from "@/lib/canvas-skill-tags";
import { applyCanvasSkillTagOrder } from "@/lib/canvas-skill-tag-order";
function getUserSkillTagOrderKey(userId: string) {
return `${CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX}${userId}`;
@ -32,18 +32,6 @@ function normalizeSkillTagOrder(value: unknown) {
});
}
function applySkillTagOrder(tags: CanvasSkillTag[], order: string[]) {
if (tags.length < 2 || order.length === 0) return tags;
const tagById = new Map(tags.map((tag) => [tag.id, tag]));
const ordered = order
.map((id) => tagById.get(id))
.filter((tag): tag is CanvasSkillTag => Boolean(tag));
const orderedIds = new Set(ordered.map((tag) => tag.id));
const remaining = tags.filter((tag) => !orderedIds.has(tag.id));
return [...ordered, ...remaining];
}
export async function GET() {
try {
const session = await auth().catch(() => null);
@ -71,7 +59,7 @@ export async function GET() {
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
return NextResponse.json({
...settings,
tags: applySkillTagOrder(settings.tags, userOrder),
tags: applyCanvasSkillTagOrder(settings.tags, userOrder),
userOrder,
});
} catch (error) {
@ -133,7 +121,7 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({
success: true,
userOrder: tagIds,
tags: applySkillTagOrder(settings.tags, tagIds),
tags: applyCanvasSkillTagOrder(settings.tags, tagIds),
});
} catch (error) {
console.error("[CanvasSkillTags API] Save order error:", error);
@ -152,10 +140,12 @@ export async function PUT(request: NextRequest) {
const normalized = normalizeCanvasSkillTagSettings(body);
const enabled = normalized.enabled;
const title = normalized.title;
const categories = normalized.categories;
const tags = normalized.tags;
const nextValue = {
enabled,
title,
categories,
tags,
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
};
@ -183,7 +173,7 @@ export async function PUT(request: NextRequest) {
});
}
return NextResponse.json({ success: true, enabled, title, tags });
return NextResponse.json({ success: true, enabled, title, categories, tags });
} catch (error) {
console.error("[CanvasSkillTags API] Update error:", error);
return NextResponse.json({ error: "Failed to update" }, { status: 500 });

View File

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

@ -0,0 +1,53 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
const canvasIdSchema = z.string().uuid();
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return Response.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { id: rawId } = await params;
const parsedId = canvasIdSchema.safeParse(rawId);
if (!parsedId.success) {
return Response.json(
{ success: false, error: "画布 ID 无效" },
{ status: 400 }
);
}
const id = parsedId.data;
const [project] = await db
.select({ updatedAt: canvasProject.updatedAt })
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id)
)
)
.limit(1);
if (!project) {
return Response.json(
{ success: false, error: "画布不存在" },
{ status: 404 }
);
}
return Response.json(
{ success: true, data: { updatedAt: project.updatedAt.toISOString() } },
{ headers: { "Cache-Control": "no-store" } }
);
}

View File

@ -3,6 +3,7 @@ import { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
import { eq, and } from 'drizzle-orm';
import {
buildStaleCanvasSaveConflictPayload,
@ -102,7 +103,7 @@ export async function GET(
// 更新画布(支持 upsert:如果不存在则创建)
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
@ -116,29 +117,34 @@ export async function PUT(
const session = await auth();
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 baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const { title, description, canvasData } = body;
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
const baseCanvasRevision =
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
? resolveWorkspaceType(body.workspaceType)
: undefined;
// 先检查画布是否存在
const [existingProject] = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id)
return await db.transaction(async (tx) => {
const [existingProject] = await tx
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
),
)
);
if (existingProject) {
.for("update");
if (
existingProject &&
canvasData !== undefined &&
baseCanvasRevision &&
isStaleCanvasSaveBaseRevision({
@ -149,71 +155,66 @@ export async function PUT(
) {
return NextResponse.json(
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 =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
? await syncCanvasNodesForProject(
{ projectId: id, userId: session.user.id, canvasData },
tx,
)
: undefined;
// 更新现有画布
const updateData: any = { updatedAt: new Date() };
const updateData: any = { updatedAt: new Date(Math.max(Date.now(), (existingProject?.updatedAt.getTime() || 0) + 1)) };
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData;
const [updatedProject] = await db
if (syncedCanvasData !== undefined)
updateData.canvasData = syncedCanvasData;
const [saved] = await tx
.update(canvasProject)
.set(updateData)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id)
)
eq(canvasProject.userId, session.user.id),
),
)
.returning();
return NextResponse.json({ success: true, data: updatedProject });
} else {
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
// 创建新画布(使用指定 ID)
const [newProject] = await db
.insert(canvasProject)
.values({
id, // 使用指定的 ID
userId: session.user.id,
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
description,
workspaceType: workspaceType || "canvas",
canvasData: syncedCanvasData || {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
.returning();
return NextResponse.json({ success: true, data: newProject });
}
return NextResponse.json({ success: true, data: saved });
});
} catch (error) {
console.error('更新/创建画布失败:', error);
return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 });
console.error("更新/创建画布失败:", error);
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

@ -1,11 +1,27 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById } from "@/lib/db/queries";
import { getChatById, getSystemSetting } from "@/lib/db/queries";
import {
buildClawdCancelKey,
cancelClawdRequest,
} from "@/lib/openclaw-chat/cancel-registry";
import {
OPENCLAW_AUTH_REQUIRED_CODE,
OPENCLAW_AUTH_REQUIRED_MESSAGE,
} from "@/lib/openclaw-chat/errors";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import {
deletePersistedClawdTask,
getPersistedClawdTask,
} from "@/lib/openclaw-chat/task-store";
import {
getPersistedUpstreamTaskState,
stopOpenClawDurableTask,
} from "@/lib/openclaw-chat/upstream-task-client";
const cancelSchema = z.object({
id: z.string().uuid(),
@ -15,7 +31,13 @@ const cancelSchema = z.object({
export async function POST(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return NextResponse.json(
{
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
code: OPENCLAW_AUTH_REQUIRED_CODE,
},
{ status: 401 }
);
}
const parsed = cancelSchema.safeParse(await request.json().catch(() => null));
@ -28,13 +50,43 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const active = cancelClawdRequest(
buildClawdCancelKey({
userId: session.user.id,
chatId: parsed.data.id,
userMessageId: parsed.data.userMessageId,
})
);
const cancelKey = buildClawdCancelKey({
userId: session.user.id,
chatId: parsed.data.id,
userMessageId: parsed.data.userMessageId,
});
const task = await getPersistedClawdTask({
chatId: parsed.data.id,
userMessageId: parsed.data.userMessageId,
}).catch(() => null);
const active = cancelClawdRequest(cancelKey);
const upstreamTask = task
? getPersistedUpstreamTaskState(task.metadata)
: null;
if (upstreamTask) {
const setting = await getSystemSetting({
key: OPENCLAW_CHAT_CONFIG_KEY,
}).catch(() => null);
const config = normalizeOpenClawChatConfig(setting?.value);
if (config.enabled && config.baseUrl && config.token) {
await stopOpenClawDurableTask({ config, upstreamTask }).catch((error) => {
console.warn("[ClawdChat Cancel] Upstream stop deferred:", {
chatId: parsed.data.id,
taskId: upstreamTask.taskId,
error,
});
});
}
}
return NextResponse.json({ canceled: true, active });
const deleted = await deletePersistedClawdTask({
chatId: parsed.data.id,
userMessageId: parsed.data.userMessageId,
}).catch(() => []);
return NextResponse.json({
canceled: true,
active,
deleted: deleted.length > 0,
});
}

View File

@ -0,0 +1,141 @@
import JSZip from "jszip";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getAllStorageConfigs } from "@/lib/db/queries";
import {
countOpenClawArchiveRootFolders,
sanitizeOpenClawArchiveEntryPath,
sanitizeOpenClawArchiveName,
} from "@/lib/openclaw-chat/folder-archive";
import {
OPENCLAW_CHAT_FOLDER_MAX_FILES,
} from "@/lib/openclaw-chat/media-attachments";
import { createLogger } from "@/lib/observability/logger";
import {
toDbS3UploadConfig,
uploadFileWithS3Config,
} from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 600;
const logger = createLogger("openclaw-folder-upload");
const LEGACY_FOLDER_UPLOAD_MAX_BYTES = 300 * 1024 * 1024;
function isUploadedFile(value: FormDataEntryValue): value is File {
return (
typeof value === "object" &&
value !== null &&
"arrayBuffer" in value &&
"name" in value &&
"size" in value
);
}
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
try {
const formData = await request.formData();
const files = formData
.getAll("files")
.filter((entry): entry is File => isUploadedFile(entry));
const relativePaths = formData
.getAll("relativePaths")
.map((entry) => (typeof entry === "string" ? entry : ""));
if (files.length === 0) {
return NextResponse.json(
{ error: "所选文件夹中没有可上传文件" },
{ status: 400 }
);
}
if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) {
return NextResponse.json(
{ error: `单次最多打包 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件` },
{ status: 413 }
);
}
const originalSize = files.reduce((sum, file) => sum + file.size, 0);
if (originalSize > LEGACY_FOLDER_UPLOAD_MAX_BYTES) {
return NextResponse.json(
{ error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" },
{ status: 413 }
);
}
const archivePaths = files.map((file, index) =>
sanitizeOpenClawArchiveEntryPath(
relativePaths[index] || file.name,
file.name,
index
)
);
const zip = new JSZip();
for (const [index, file] of files.entries()) {
zip.file(archivePaths[index], await file.arrayBuffer(), {
date: new Date(file.lastModified || Date.now()),
});
}
const archiveBaseName = sanitizeOpenClawArchiveName(
String(formData.get("archiveName") || archivePaths[0]?.split("/")[0] || "")
);
const archiveName = `${archiveBaseName.replace(/\.zip$/i, "")}.zip`;
const archiveBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
platform: "UNIX",
});
const storageConfigs = await getAllStorageConfigs();
const qiniuStorage = storageConfigs
.filter((config) => config.type === "qiniu-kodo" && config.isEnabled)
.sort((left, right) => Number(right.isDefault) - Number(left.isDefault))
.find((config) => toDbS3UploadConfig(config));
const qiniuConfig = toDbS3UploadConfig(qiniuStorage);
if (!qiniuConfig) {
return NextResponse.json(
{ error: "系统尚未配置可用的七牛云储存桶" },
{ status: 503 }
);
}
const uploaded = await uploadFileWithS3Config(
archiveName,
toArrayBuffer(archiveBuffer),
qiniuConfig
);
if (!/^https?:\/\//i.test(uploaded.url)) {
throw new Error("七牛云未返回可访问的公网地址");
}
return NextResponse.json({
url: uploaded.url,
name: archiveName,
contentType: "application/zip",
size: archiveBuffer.byteLength,
originalSize,
fileCount: files.length,
folderCount: countOpenClawArchiveRootFolders(archivePaths),
});
} catch (error) {
logger.error("Failed to package and upload Claw folders", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "文件夹上传失败" },
{ status: 500 }
);
}
}

View File

@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getClawdChatsByUserId } from "@/lib/db/queries";
import {
OPENCLAW_AUTH_REQUIRED_CODE,
OPENCLAW_AUTH_REQUIRED_MESSAGE,
} from "@/lib/openclaw-chat/errors";
export const dynamic = "force-dynamic";
export const revalidate = 0;
@ -9,13 +13,19 @@ export async function GET(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return NextResponse.json(
{
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
code: OPENCLAW_AUTH_REQUIRED_CODE,
},
{ status: 401 }
);
}
const url = new URL(request.url);
const limit = Math.max(
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({

View File

@ -1,34 +1,60 @@
import { NextResponse } from "next/server";
import { after, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
getChatById,
getMessagesByChatId,
getSystemSetting,
getUserById,
saveChat,
saveMessages,
} from "@/lib/db/queries";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import {
buildClawdCancelKey,
cancelClawdRequest,
clearClawdRequestCancellation,
isClawdRequestCanceled,
registerActiveClawdRequest,
unregisterActiveClawdRequest,
} from "@/lib/openclaw-chat/cancel-registry";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import {
getOpenClawTaskFailureMessage,
OPENCLAW_AUTH_REQUIRED_CODE,
OPENCLAW_AUTH_REQUIRED_MESSAGE,
OPENCLAW_UPGRADE_RETRY_MESSAGE,
} from "@/lib/openclaw-chat/errors";
import {
normalizeOpenClawChatMediaAttachments,
OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
type OpenClawChatMediaAttachment,
} from "@/lib/openclaw-chat/media-attachments";
import { extractOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts";
import {
buildOpenClawDisplayMessage,
buildOpenClawMessageWithSpreadsheetMarkdown,
buildOpenClawSpreadsheetMarkdown,
} from "@/lib/openclaw-chat/spreadsheet-markdown";
import {
appendPersistedClawdTaskProgress,
getPersistedClawdTask,
markClawdTaskCanceled,
markClawdTaskCompleted,
markClawdTaskFailed,
markClawdTaskRunning,
persistPendingClawdTask,
} from "@/lib/openclaw-chat/task-store";
import {
OpenClawTaskDetachedError,
submitOpenClawDurableTask,
waitForOpenClawDurableTask,
} from "@/lib/openclaw-chat/upstream-task-client";
import { generateUUID } from "@/lib/utils";
const CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试";
const DEFAULT_PROCESSING_HINT =
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
export const maxDuration = 1800;
const requestSchema = z.object({
id: z.string().uuid(),
@ -36,12 +62,63 @@ const requestSchema = z.object({
displayMessage: z.string().trim().max(8000).optional(),
userMessageId: z.string().uuid().optional(),
metadata: z.record(z.unknown()).optional(),
sensitiveInput: z
.discriminatedUnion("type", [
z.object({
type: z.literal("taobao_credentials"),
username: z.string().min(1).max(160),
password: z.string().min(1).max(256),
}),
z.object({
type: z.literal("taobao_verification"),
verification_code: z.string().min(4).max(12),
}),
])
.optional(),
attachments: z
.array(z.unknown())
.max(OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS)
.optional()
.default([]),
});
type ParsedClawdChatRequest = z.infer<typeof requestSchema> & {
type ParsedClawdChatRequest = Omit<
z.infer<typeof requestSchema>,
"attachments"
> & {
attachments: OpenClawChatMediaAttachment[];
tableFiles: File[];
};
type ClawdSpreadsheetAttachment = {
name: string;
size: number;
contentType: string;
sheetCount: number;
rowCount: number;
truncated: boolean;
};
type ExecuteClawdTaskParams = {
assistantMessageId: string;
cancelKey: string;
chatId: string;
config: { baseUrl: string; token: string; timeoutMs: number };
conversationId: string;
departmentName: string;
metadata: Record<string, unknown> | undefined;
sensitiveInput?: z.infer<typeof requestSchema>["sensitiveInput"];
mediaAttachments: OpenClawChatMediaAttachment[];
skillTagId: string;
skillTagName: string;
spreadsheetFiles: unknown[];
spreadsheetMarkdownTruncated: boolean;
upstreamMessage: string;
userId: string;
userMessageId: string;
userName: string;
};
function buildChatTitle(message: string) {
const normalized = message.replace(/\s+/g, " ").trim();
if (!normalized) return "Claw 对话";
@ -57,17 +134,15 @@ function getOpenClawUserName(user: {
name?: string | null;
email?: string | null;
}) {
const name = String(user.name || "").trim();
if (name) return name;
const email = String(user.email || "").trim();
if (email) return email;
return user.id;
return String(user.name || user.email || user.id).trim();
}
function getOpenClawDepartmentName(department?: string | null) {
return String(department || "").trim();
function getMetadataString(
metadata: Record<string, unknown> | undefined,
key: string
) {
const value = metadata?.[key];
return typeof value === "string" ? value.trim() : "";
}
function isRequestFile(value: FormDataEntryValue): value is File {
@ -86,25 +161,39 @@ async function parseClawdChatRequest(
const contentType = request.headers.get("content-type") || "";
if (!contentType.includes("multipart/form-data")) {
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
return parsed.success ? { ...parsed.data, tableFiles: [] } : null;
if (!parsed.success) return null;
const attachments = normalizeOpenClawChatMediaAttachments(
parsed.data.attachments
);
if (attachments.length !== parsed.data.attachments.length) return null;
return { ...parsed.data, attachments, tableFiles: [] };
}
const formData = await request.formData().catch(() => null);
if (!formData) return null;
const metadataValue = formData.get("metadata");
let metadata: Record<string, unknown> | undefined;
if (typeof metadataValue === "string" && metadataValue.trim()) {
try {
let attachments: unknown[] = [];
let sensitiveInput: unknown;
const metadataValue = formData.get("metadata");
const attachmentsValue = formData.get("attachments");
const sensitiveInputValue = formData.get("sensitiveInput");
try {
if (typeof metadataValue === "string" && metadataValue.trim()) {
const parsedMetadata = JSON.parse(metadataValue);
if (parsedMetadata && typeof parsedMetadata === "object") {
metadata = parsedMetadata as Record<string, unknown>;
} else {
return null;
}
} catch {
return null;
if (!parsedMetadata || typeof parsedMetadata !== "object") return null;
metadata = parsedMetadata as Record<string, unknown>;
}
if (typeof attachmentsValue === "string" && attachmentsValue.trim()) {
const parsedAttachments = JSON.parse(attachmentsValue);
if (!Array.isArray(parsedAttachments)) return null;
attachments = parsedAttachments;
}
if (typeof sensitiveInputValue === "string" && sensitiveInputValue.trim()) {
sensitiveInput = JSON.parse(sensitiveInputValue);
}
} catch {
return null;
}
const parsed = requestSchema.safeParse({
@ -113,68 +202,279 @@ async function parseClawdChatRequest(
displayMessage: formData.get("displayMessage") || undefined,
userMessageId: formData.get("userMessageId") || undefined,
metadata,
attachments,
sensitiveInput,
});
if (!parsed.success) return null;
const normalizedAttachments = normalizeOpenClawChatMediaAttachments(
parsed.data.attachments
);
if (normalizedAttachments.length !== parsed.data.attachments.length) {
return null;
}
const tableFiles = formData
.getAll("tableFiles")
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0);
return { ...parsed.data, tableFiles };
return {
...parsed.data,
attachments: normalizedAttachments,
tableFiles: formData
.getAll("tableFiles")
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0),
};
}
async function readOpenClawConfig() {
const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
() => null
);
const setting = await getSystemSetting({
key: OPENCLAW_CHAT_CONFIG_KEY,
}).catch(() => null);
return normalizeOpenClawChatConfig(setting?.value);
}
function getOpenClawErrorCode(payload: unknown) {
if (!payload || typeof payload !== "object") return "";
const raw = payload as Record<string, unknown>;
const nestedError =
raw.error && typeof raw.error === "object"
? (raw.error as Record<string, unknown>)
: null;
const code = nestedError?.code || raw.code;
return typeof code === "string" ? code.trim() : "";
function getAnswer(payload: unknown) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return "";
}
return String((payload as Record<string, unknown>).answer || "").trim();
}
function getMetadataString(
metadata: Record<string, unknown> | undefined,
key: string
) {
const value = metadata?.[key];
return typeof value === "string" ? value.trim() : "";
function getRequestId(payload: unknown) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const value = (payload as Record<string, unknown>).request_id;
return value ? String(value) : null;
}
function createCanceledResponse() {
return NextResponse.json({ canceled: true }, { status: 499 });
async function executeClawdTask(params: ExecuteClawdTaskParams) {
const controller = new AbortController();
registerActiveClawdRequest(
params.cancelKey,
controller,
Math.max(params.config.timeoutMs, 10 * 60 * 1000)
);
try {
if (isClawdRequestCanceled(params.cancelKey)) return;
await markClawdTaskRunning({
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
});
const upstreamBody: Record<string, unknown> = {
conversation_id: params.conversationId,
user_id: params.userId,
user_name: params.userName,
username: params.userName,
department: params.departmentName,
department_name: params.departmentName,
user_department: params.departmentName,
skill_tag_id: params.skillTagId || undefined,
skill_tag_name: params.skillTagName || undefined,
message: params.upstreamMessage,
attachments:
params.mediaAttachments.length > 0
? params.mediaAttachments
: undefined,
metadata: {
source: "ai_chatbot_canvas",
page: "/canvas",
tenant: "default",
chat_id: params.chatId,
user_id: params.userId,
user_name: params.userName,
username: params.userName,
department: params.departmentName,
department_name: params.departmentName,
user_department: params.departmentName,
skill_tag_id: params.skillTagId || undefined,
skill_tag_name: params.skillTagName || undefined,
media_attachments:
params.mediaAttachments.length > 0
? params.mediaAttachments
: undefined,
spreadsheet_files:
params.spreadsheetFiles.length > 0
? params.spreadsheetFiles
: undefined,
spreadsheet_markdown_truncated:
params.spreadsheetMarkdownTruncated || undefined,
...params.metadata,
},
stream_progress: !params.sensitiveInput,
sensitive_input: params.sensitiveInput,
};
if (params.sensitiveInput) {
const timeout = setTimeout(
() =>
controller.abort(
new DOMException("OpenClaw request timed out", "TimeoutError")
),
params.config.timeoutMs
);
try {
const response = await fetch(`${params.config.baseUrl}/api/chat`, {
method: "POST",
headers: {
Authorization: `Bearer ${params.config.token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(upstreamBody),
signal: controller.signal,
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!response.ok) {
throw new Error(`OpenClaw request failed: ${response.status}`);
}
if (!payload) throw new Error("OpenClaw returned an invalid response");
const answer = getAnswer(payload);
if (!answer) throw new Error("OpenClaw returned an empty answer");
await markClawdTaskCompleted({
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
answer,
requestId: getRequestId(payload),
artifacts: extractOpenClawArtifacts(payload, {
baseUrl: params.config.baseUrl,
}),
});
clearClawdRequestCancellation(params.cancelKey);
return;
} finally {
clearTimeout(timeout);
}
}
const target = {
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
};
const upstreamTask = await submitOpenClawDurableTask({
config: params.config,
body: upstreamBody,
idempotencyKey: `${params.userId}:${params.chatId}:${params.userMessageId}`,
target,
signal: controller.signal,
});
const outcome = await waitForOpenClawDurableTask({
config: params.config,
upstreamTask,
target,
deadlineAt: Date.now() + params.config.timeoutMs,
signal: controller.signal,
});
if (isClawdRequestCanceled(params.cancelKey)) return;
if (outcome.status === "completed") {
await markClawdTaskCompleted({
...target,
answer: outcome.answer,
requestId: outcome.requestId,
artifacts: outcome.artifacts,
});
} else if (outcome.status === "canceled") {
await markClawdTaskCanceled({
...target,
message: outcome.error,
});
} else if (outcome.status === "failed") {
await markClawdTaskFailed({
...target,
error: outcome.error,
});
}
clearClawdRequestCancellation(params.cancelKey);
} catch (error) {
if (isClawdRequestCanceled(params.cancelKey)) return;
if (error instanceof OpenClawTaskDetachedError) {
await appendPersistedClawdTaskProgress({
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
progress: {
step_code: "task.detached",
step_state: "waiting",
message: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
display: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
},
}).catch(() => null);
return;
}
console.error("[ClawdChat API] Durable task failed:", error);
await markClawdTaskFailed({
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
error: getOpenClawTaskFailureMessage(error),
}).catch(() => null);
} finally {
if (params.sensitiveInput?.type === "taobao_credentials") {
params.sensitiveInput.username = "";
params.sensitiveInput.password = "";
} else if (params.sensitiveInput?.type === "taobao_verification") {
params.sensitiveInput.verification_code = "";
}
params.sensitiveInput = undefined;
unregisterActiveClawdRequest(params.cancelKey);
}
}
function buildTaskResponse(params: {
chatId: string;
userMessageId: string;
assistantMessageId: string;
status: string;
processingHint: string;
}) {
const statusUrl = `/api/canvas/clawd-chat/status?id=${encodeURIComponent(
params.chatId
)}&userMessageId=${encodeURIComponent(params.userMessageId)}`;
return {
accepted: true,
task: {
chatId: params.chatId,
userMessageId: params.userMessageId,
assistantMessageId: params.assistantMessageId,
status: params.status,
processingHint: params.processingHint,
statusUrl,
},
};
}
export async function POST(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return NextResponse.json(
{
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
code: OPENCLAW_AUTH_REQUIRED_CODE,
},
{ status: 401 }
);
}
const parsed = await parseClawdChatRequest(request);
if (!parsed) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
if (!parsed.message && parsed.tableFiles.length === 0) {
return NextResponse.json({ error: "请输入问题或上传表格" }, { status: 400 });
if (
!parsed.message &&
parsed.tableFiles.length === 0 &&
parsed.attachments.length === 0
) {
return NextResponse.json(
{ error: "请输入问题或上传附件" },
{ status: 400 }
);
}
const config = await readOpenClawConfig();
if (!config.enabled) {
return NextResponse.json({ error: CLAW_UPGRADE_RETRY_MESSAGE }, { status: 503 });
}
if (!config.baseUrl || !config.token) {
if (!config.enabled || !config.baseUrl || !config.token) {
return NextResponse.json(
{ error: CLAW_UPGRADE_RETRY_MESSAGE },
{ error: OPENCLAW_UPGRADE_RETRY_MESSAGE },
{ status: 503 }
);
}
@ -185,13 +485,22 @@ export async function POST(request: Request) {
}
const userMessageId = parsed.userMessageId || generateUUID();
const cancelKey = buildClawdCancelKey({
userId: session.user.id,
const existingTask = await getPersistedClawdTask({
chatId: parsed.id,
userMessageId,
});
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}).catch(() => null);
if (existingTask) {
return NextResponse.json(
buildTaskResponse({
chatId: parsed.id,
userMessageId,
assistantMessageId: existingTask.metadata.assistantMessageId,
status: existingTask.metadata.status,
processingHint:
existingTask.metadata.processingHint || DEFAULT_PROCESSING_HINT,
}),
{ status: 202, headers: { "Retry-After": "2" } }
);
}
let spreadsheetMarkdownResult: Awaited<
@ -221,10 +530,23 @@ export async function POST(request: Request) {
message: parsed.message,
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
});
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
const spreadsheetAttachments: ClawdSpreadsheetAttachment[] = (
spreadsheetMarkdownResult?.files || []
).map((summary, index) => {
const sourceFile = parsed.tableFiles[index];
return {
name: summary.fileName,
size: sourceFile?.size || 0,
contentType:
sourceFile?.type ||
(summary.fileName.toLowerCase().endsWith(".xlsx")
? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
: "text/csv"),
sheetCount: summary.sheetCount,
rowCount: summary.rowCount,
truncated: summary.truncated,
};
});
if (!existingChat) {
await saveChat({
@ -236,220 +558,107 @@ export async function POST(request: Request) {
}
const assistantMessageId = generateUUID();
const conversationId = createConversationId(session.user.id, parsed.id);
const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
const openClawUserName = getOpenClawUserName({
const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
const processingHint =
getMetadataString(parsed.metadata, "processingHint") ||
DEFAULT_PROCESSING_HINT;
const messageAttachments = parsed.attachments.map((attachment) => ({
name:
attachment.name ||
(attachment.type === "video"
? "视频附件"
: attachment.type === "file"
? "文件附件"
: "图片附件"),
url: attachment.url,
contentType:
attachment.content_type ||
(attachment.type === "video"
? "video/mp4"
: attachment.type === "file"
? "application/octet-stream"
: "image/jpeg"),
}));
const persistedTask = await persistPendingClawdTask({
chatId: parsed.id,
userMessageId,
assistantMessageId,
displayMessage,
attachments: messageAttachments,
spreadsheetAttachments,
skillTagId,
skillTagName,
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(
() => null
);
const userName = getOpenClawUserName({
id: session.user.id,
name: userProfile?.name ?? session.user.name,
email: userProfile?.email ?? session.user.email,
});
const openClawDepartmentName = getOpenClawDepartmentName(userProfile?.department);
const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
const endpoint = `${config.baseUrl}/api/chat`;
const cancelKey = buildClawdCancelKey({
userId: session.user.id,
chatId: parsed.id,
userMessageId,
});
let upstreamPayload: unknown = null;
let upstreamStatus = 502;
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
try {
const controller = new AbortController();
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
const handleRequestAbort = () => {
cancelClawdRequest(cancelKey);
};
request.signal.addEventListener("abort", handleRequestAbort, { once: true });
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
conversation_id: conversationId,
user_id: session.user.id,
user_name: openClawUserName,
username: openClawUserName,
department: openClawDepartmentName,
department_name: openClawDepartmentName,
user_department: openClawDepartmentName,
skill_tag_id: skillTagId || undefined,
skill_tag_name: skillTagName || undefined,
message: upstreamMessage,
metadata: {
source: "ai_chatbot_canvas",
page: "/canvas",
tenant: "default",
chat_id: parsed.id,
user_id: session.user.id,
user_name: openClawUserName,
username: openClawUserName,
department: openClawDepartmentName,
department_name: openClawDepartmentName,
user_department: openClawDepartmentName,
skill_tag_id: skillTagId || undefined,
skill_tag_name: skillTagName || undefined,
spreadsheet_files: spreadsheetMarkdownResult?.files || undefined,
spreadsheet_markdown_truncated:
spreadsheetMarkdownResult?.truncated || undefined,
...parsed.metadata,
},
}),
signal: controller.signal,
}).finally(() => {
clearTimeout(timeout);
request.signal.removeEventListener("abort", handleRequestAbort);
unregisterActiveClawdRequest(cancelKey);
after(async () => {
await executeClawdTask({
assistantMessageId,
cancelKey,
chatId: parsed.id,
config,
conversationId: createConversationId(session.user.id, parsed.id),
departmentName: String(userProfile?.department || "").trim(),
metadata: parsed.metadata,
sensitiveInput: parsed.sensitiveInput,
mediaAttachments: parsed.attachments,
skillTagId,
skillTagName,
spreadsheetFiles: spreadsheetMarkdownResult?.files || [],
spreadsheetMarkdownTruncated:
spreadsheetMarkdownResult?.truncated || false,
upstreamMessage,
userId: session.user.id,
userMessageId,
userName,
});
});
upstreamStatus = response.status;
upstreamPayload = await response.json().catch(() => null);
if (!response.ok) {
const errorCode = getOpenClawErrorCode(upstreamPayload);
console.error("[ClawdChat API] OpenClaw upstream error:", {
status: response.status,
code: errorCode || undefined,
payload: upstreamPayload,
});
return NextResponse.json(
{
error: CLAW_UPGRADE_RETRY_MESSAGE,
code: errorCode || undefined,
},
{ status: response.status }
);
}
} catch (error) {
unregisterActiveClawdRequest(cancelKey);
const errorName =
error && typeof error === "object" && "name" in error
? String((error as { name?: unknown }).name || "")
: "";
const isAbortError = errorName === "AbortError";
if (isClawdRequestCanceled(cancelKey)) {
console.info("[ClawdChat API] OpenClaw request canceled:", {
chatId: parsed.id,
userMessageId,
});
return createCanceledResponse();
}
console.error("[ClawdChat API] OpenClaw request failed:", error);
return NextResponse.json(
{
error: CLAW_UPGRADE_RETRY_MESSAGE,
},
{ status: isAbortError ? 504 : upstreamStatus }
);
}
const answer =
upstreamPayload && typeof upstreamPayload === "object"
? String((upstreamPayload as Record<string, unknown>).answer || "").trim()
: "";
if (!answer) {
console.error("[ClawdChat API] OpenClaw empty answer:", upstreamPayload);
return NextResponse.json(
{ error: CLAW_UPGRADE_RETRY_MESSAGE },
{ status: 502 }
);
}
if (isClawdRequestCanceled(cancelKey)) {
return createCanceledResponse();
}
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
() => []
);
const latestMessageTimestamp = existingMessages.reduce((latest, item) => {
const timestamp =
item.createdAt instanceof Date
? item.createdAt.getTime()
: Date.parse(String(item.createdAt || ""));
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
}, 0);
const userCreatedAt = new Date(
Math.max(Date.now(), latestMessageTimestamp + 1)
);
const assistantCreatedAt = new Date(userCreatedAt.getTime() + 1);
const userMessage = {
id: userMessageId,
const responseBody = buildTaskResponse({
chatId: parsed.id,
role: "user",
parts: [{ type: "text", text: displayMessage }],
attachments: [],
createdAt: userCreatedAt,
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: {
composerMode: "clawd",
source: "openclaw",
skillTagId: skillTagId || null,
skillTagName: skillTagName || null,
userMessageId,
assistantMessageId,
status: "queued",
processingHint,
});
return NextResponse.json(responseBody, {
status: 202,
headers: {
"Cache-Control": "no-store, max-age=0",
Location: responseBody.task.statusUrl,
"Retry-After": "2",
},
};
const assistantMessage = {
id: assistantMessageId,
chatId: parsed.id,
role: "assistant",
parts: [{ type: "text", text: answer }],
attachments: [],
createdAt: assistantCreatedAt,
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: {
composerMode: "clawd",
source: "openclaw",
requestId:
upstreamPayload && typeof upstreamPayload === "object"
? (upstreamPayload as Record<string, unknown>).request_id || null
: null,
},
};
await saveMessages({ messages: [userMessage, assistantMessage] });
clearClawdRequestCancellation(cancelKey);
return NextResponse.json({
conversationId,
requestId:
upstreamPayload && typeof upstreamPayload === "object"
? (upstreamPayload as Record<string, unknown>).request_id || null
: null,
messages: [
{
id: userMessage.id,
role: "user",
parts: userMessage.parts,
createdAt: userMessage.createdAt.toISOString(),
metadata: {
createdAt: userMessage.createdAt.toISOString(),
composerMode: "clawd",
},
videoMetadata: userMessage.videoMetadata,
},
{
id: assistantMessage.id,
role: "assistant",
parts: assistantMessage.parts,
createdAt: assistantMessage.createdAt.toISOString(),
metadata: {
createdAt: assistantMessage.createdAt.toISOString(),
composerMode: "clawd",
},
videoMetadata: assistantMessage.videoMetadata,
},
],
});
}

View File

@ -0,0 +1,191 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById, getSystemSetting } from "@/lib/db/queries";
import {
buildClawdCancelKey,
hasActiveClawdRequest,
registerActiveClawdRequest,
unregisterActiveClawdRequest,
} from "@/lib/openclaw-chat/cancel-registry";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import {
OPENCLAW_AUTH_REQUIRED_CODE,
OPENCLAW_AUTH_REQUIRED_MESSAGE,
} from "@/lib/openclaw-chat/errors";
import {
getPersistedClawdTask,
markClawdTaskCanceled,
markClawdTaskCompleted,
markClawdTaskFailed,
} from "@/lib/openclaw-chat/task-store";
import { isPendingClawdTaskStatus } from "@/lib/openclaw-chat/task-state";
import {
getPersistedUpstreamTaskState,
refreshOpenClawDurableTask,
} from "@/lib/openclaw-chat/upstream-task-client";
const statusSchema = z.object({
id: z.string().uuid(),
userMessageId: z.string().uuid().optional(),
});
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const maxDuration = 60;
async function reconcileDetachedOpenClawTask(params: {
chatId: string;
userId: string;
task: NonNullable<Awaited<ReturnType<typeof getPersistedClawdTask>>>;
}) {
if (!isPendingClawdTaskStatus(params.task.metadata.status)) return;
const upstreamTask = getPersistedUpstreamTaskState(params.task.metadata);
if (!upstreamTask) return;
const cancelKey = buildClawdCancelKey({
userId: params.userId,
chatId: params.chatId,
userMessageId: params.task.metadata.userMessageId,
});
if (hasActiveClawdRequest(cancelKey)) return;
const controller = new AbortController();
registerActiveClawdRequest(cancelKey, controller, 30_000);
const target = {
chatId: params.chatId,
assistantMessageId: params.task.metadata.assistantMessageId,
};
try {
const setting = await getSystemSetting({
key: OPENCLAW_CHAT_CONFIG_KEY,
}).catch(() => null);
const config = normalizeOpenClawChatConfig(setting?.value);
if (!config.enabled || !config.baseUrl || !config.token) return;
const outcome = await refreshOpenClawDurableTask({
config,
upstreamTask,
target,
signal: controller.signal,
});
if (outcome.status === "completed") {
await markClawdTaskCompleted({
...target,
answer: outcome.answer,
requestId: outcome.requestId,
artifacts: outcome.artifacts,
});
} else if (outcome.status === "failed") {
await markClawdTaskFailed({ ...target, error: outcome.error });
} else if (outcome.status === "canceled") {
await markClawdTaskCanceled({ ...target, message: outcome.error });
}
} catch (error) {
if (!controller.signal.aborted) {
console.warn("[ClawdChat Status] Upstream reconciliation deferred:", {
chatId: params.chatId,
taskId: upstreamTask.taskId,
error,
});
}
} finally {
unregisterActiveClawdRequest(cancelKey);
}
}
export async function GET(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json(
{
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
code: OPENCLAW_AUTH_REQUIRED_CODE,
},
{ status: 401 }
);
}
const url = new URL(request.url);
const parsed = statusSchema.safeParse({
id: url.searchParams.get("id"),
userMessageId: url.searchParams.get("userMessageId") || undefined,
});
if (!parsed.success) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
const chat = await getChatById({ id: parsed.data.id }).catch(() => null);
if (!chat) {
return NextResponse.json({ task: null }, { status: 404 });
}
if (chat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let task = await getPersistedClawdTask({
chatId: parsed.data.id,
userMessageId: parsed.data.userMessageId,
});
if (!task) {
return NextResponse.json(
{ task: null },
{ headers: { "Cache-Control": "no-store, max-age=0" } }
);
}
await reconcileDetachedOpenClawTask({
chatId: parsed.data.id,
userId: session.user.id,
task,
});
task =
(await getPersistedClawdTask({
chatId: parsed.data.id,
userMessageId: parsed.data.userMessageId,
})) || task;
const userTextPart = Array.isArray(task.userMessage?.parts)
? task.userMessage.parts.find(
(part) =>
part &&
typeof part === "object" &&
!Array.isArray(part) &&
(part as { type?: unknown }).type === "text"
)
: null;
const userText =
userTextPart && typeof userTextPart === "object"
? String((userTextPart as { text?: unknown }).text || "")
: "";
return NextResponse.json(
{
task: {
chatId: parsed.data.id,
userMessageId: task.metadata.userMessageId,
assistantMessageId: task.metadata.assistantMessageId,
status: task.metadata.status,
processingHint: task.metadata.processingHint,
progressEvents: task.metadata.progressEvents,
skillTagId: task.metadata.skillTagId || null,
skillTagName: task.metadata.skillTagName || null,
requestId: task.metadata.requestId || null,
error: task.metadata.error || null,
createdAt: task.metadata.createdAt,
updatedAt: task.metadata.updatedAt,
userMessage: {
text: userText,
attachments: task.userMessage?.attachments || [],
},
},
},
{
headers: {
"Cache-Control": "no-store, max-age=0",
"Retry-After": "2",
},
}
);
}

View File

@ -1956,7 +1956,7 @@ export async function POST(request: Request) {
(item) =>
!item.model.isSubModel &&
item.model.isEnabled &&
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
);
const configuredDetailAgentConfig =
currentSiteSettings?.canvasDetailAgentModelId &&

View File

@ -204,7 +204,7 @@ export async function POST(request: Request) {
(item) =>
!item.model.isSubModel &&
item.model.isEnabled &&
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
);
const configuredDetailAgentConfig =
currentSiteSettings?.canvasDetailAgentModelId &&

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 { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
import {
buildAttachmentContentDisposition,
fetchCanvasDownloadAsset,
@ -9,6 +13,31 @@ import {
export const runtime = "nodejs";
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 {
const forwardedHost = (request.headers.get("x-forwarded-host") || "").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 requestOrigin = getRequestOrigin(request);
const urls = resolveDownloadUrls(body, requestOrigin);
const requestedUrls = [...urls];
if (urls.length === 0) {
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
}
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") || "";
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;
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 { NextRequest, NextResponse } from "next/server";
import { after, NextRequest, NextResponse } from "next/server";
import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
export const runtime = "nodejs";
export const maxDuration = 300;
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;
type DownloadFileItem = { url: string; name?: string };
const MAX_DOWNLOAD_FILES = 200;
const TASK_TTL_MS = 30 * 60 * 1000;
const globalTaskStore = globalThis as typeof globalThis & {
__canvasDownloadZipTasks?: Map<string, ZipTask>;
};
function getTaskStore() {
if (!globalTaskStore.__canvasDownloadZipTasks) {
globalTaskStore.__canvasDownloadZipTasks = new Map<string, ZipTask>();
}
return globalTaskStore.__canvasDownloadZipTasks;
}
const TASK_TTL_MS = 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const active = running.__canvasZipRunning ??= new Set<string>();
function sanitizeFolderName(value: string): string {
const cleaned = String(value || "")
.replace(/[\\/:*?"<>|]+/g, "-")
@ -56,7 +24,7 @@ function sanitizeFolderName(value: string): string {
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.trim();
return cleaned || `canvas-images-${Date.now()}`;
return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `canvas-images-${Date.now()}`;
}
function buildContentDisposition(folderName: string): string {
@ -110,258 +78,161 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
.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) {
const downloadUrl =
task.status === "completed"
? `/api/canvas/download-zip-task?taskId=${encodeURIComponent(task.id)}&download=1`
: "";
return {
id: task.id,
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,
};
return { id: task.id, folderName: task.folderName, status: task.status, stage: task.stage,
totalCount: task.totalCount, processedCount: task.processedCount, successCount: task.successCount,
failedCount: task.failedCount, progress: task.status === "completed" ? 100 : Math.min(99,
Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)),
error: task.error || "", expiresAt: task.expiresAt,
downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" };
}
async function processZipTask(params: {
task: ZipTask;
requestOrigin: string;
cookie: string;
}) {
const { task, requestOrigin, cookie } = params;
const store = getTaskStore();
async function processTask(id: string, userId: string, requestOrigin: string, cookie: string) {
if (active.has(id) || active.size >= 2) return;
active.add(id);
let release: (() => Promise<void>) | null = null;
let task: ZipTask | null = null;
try {
task.status = "running";
task.stage = "正在读取源图";
task.updatedAt = Date.now();
const zip = new JSZip();
const folder = zip.folder(task.folderName) || zip;
const results: Array<
| Awaited<ReturnType<typeof fetchCanvasDownloadAsset>>
| null
> = new Array(task.files.length).fill(null);
release = await claimZipTask(id);
if (!release) return;
task = await loadZipTask(id, userId);
if (!task || task.status === "completed" || task.status === "failed") return;
await assertZipDiskSpace();
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
await saveZipTask(task);
// Stage each image on disk, not all PNG buffers in the web process heap.
// A restart resumes from these files using the current owner's session.
let cursor = 0;
const workerCount = Math.min(MAX_CONCURRENCY, task.files.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (true) {
const index = cursor;
cursor += 1;
if (index >= task.files.length) break;
const file = task.files[index]!;
results[index] = await fetchCanvasDownloadAsset({
file,
index,
requestOrigin,
cookie,
});
const result = results[index];
task.processedCount += 1;
if (result?.ok) {
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;
let writes = Promise.resolve();
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
while (cursor < task!.files.length) {
const index = cursor++; const file = task!.files[index];
const path = join(zipTaskPath(id), `${index}.image`);
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
delete file.prepared; delete file.filename;
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
if (result.ok) {
await assertZipDiskSpace(result.bytes.length);
await writeFile(path, result.bytes, { mode: 0o600 });
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
} else file.error = result.error;
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
task!.successCount = task!.files.filter(f => f.prepared).length;
task!.failedCount = task!.files.filter(f => f.error).length;
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
task!.updatedAt = Date.now(); await persist();
}
folder.file(item.filename, item.bytes);
}
if (failures.length > 0) {
folder.file(
"_download-failures.txt",
[
"以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。",
"",
...failures.map((item) => {
const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`;
return `${label}\nURL: ${item.url}\n原因: ${item.error}`;
}),
].join("\n\n")
);
console.warn("[CanvasDownloadZipTask] Partial download failures:", failures);
}
task.stage = "正在生成 ZIP 文件";
task.updatedAt = Date.now();
task.zipBuffer = await zip.generateAsync(
{
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
},
(metadata) => {
task.compressPercent = Math.max(0, Math.min(100, metadata.percent || 0));
task.updatedAt = Date.now();
}
);
}));
const rejected = results.find(result => result.status === "rejected");
if (rejected?.status === "rejected") throw rejected.reason;
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
for (const file of task.files) if (file.prepared && file.filename)
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName);
task.status = "completed";
task.stage = "打包完成";
task.compressPercent = 100;
task.updatedAt = Date.now();
task.expiresAt = Date.now() + TASK_TTL_MS;
} catch (error) {
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 });
task.stage = "正在准备下载链接"; await saveZipTask(task);
try {
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (cdn.url) task.cdnUrl = cdn.url;
}
return new NextResponse(task.zipBuffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName),
"Content-Length": String(task.zipBuffer.length),
"Cache-Control": "no-store",
"X-Download-Success-Count": String(task.successCount),
"X-Download-Failed-Count": String(task.failedCount),
},
});
}
return NextResponse.json({
success: true,
task: serializeTask(task),
});
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes.
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
} catch (error) {
console.error("[CanvasDownloadZipTask] Query failed:", error);
return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 });
if (task) {
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 JSZip from "jszip";
import { auth } from "@/app/(auth)/auth";
@ -159,13 +160,7 @@ export async function POST(request: NextRequest) {
console.warn("[CanvasDownloadZip] Partial download failures:", failures);
}
const zipBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
return new NextResponse(zipBuffer, {
return new NextResponse(streamDownloadZip(zip), {
status: 200,
headers: {
"Content-Type": "application/zip",

View File

@ -1,62 +1,10 @@
import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
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";
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
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) {
try {
const session = await auth();
@ -67,10 +15,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const taskId = String(searchParams.get("taskId") || "").trim();
if (!taskId) {
return NextResponse.json(
{ success: false, error: "缺少 taskId 参数" },
{ status: 400 }
);
return NextResponse.json({ success: false, error: "缺少 taskId 参数" }, { status: 400 });
}
const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
@ -99,105 +44,15 @@ export async function GET(request: Request) {
success: false,
error: error instanceof Error ? error.message : "查询任务失败",
},
{ status: 500 }
{ 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: 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 }
);
}
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
const body = await request.json().catch(() => ({}));
return executeCanvasImageResponse(body, session.user.id);
}

View File

@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
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";
export const maxDuration = 300;
@ -10,6 +11,8 @@ interface GenerateVideoRequest {
prompt?: string;
mode?: VideoGenerationMode;
images?: string[];
audioUrls?: string[];
generateAudio?: boolean;
resolution?: VideoResolution;
ratio?: VideoRatio;
duration?: number;
@ -46,23 +49,18 @@ export async function POST(request: Request) {
);
}
const inputImages = Array.isArray(body.images)
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
: [];
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 referenceCheck = await validateVideoReferences(body.images, url => probeRemoteUrl(url, { timeoutMs: 8000 }));
if (referenceCheck.error) {
return NextResponse.json({ success: false, error: referenceCheck.error }, { status: referenceCheck.status });
}
const reachableImages = referenceCheck.images;
const mode = body.mode || inferMode(reachableImages);
const result = await executeDirectVideoGeneration({
prompt,
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,
resolution: body.resolution,
ratio: body.ratio,
@ -90,13 +88,13 @@ export async function POST(request: Request) {
success: true,
data: {
url: result.videoUrl,
metadata: result.metadata || null,
metadata: { ...result.metadata, temporaryVideoUrl: result.videoUrl, storageStatus: result.metadata?.permanentVideoUrl === result.videoUrl ? "archived" : "temporary" },
modelDisplayName: result.modelDisplayName,
modelId: result.modelId,
modelRef: result.modelRef,
mode,
images: reachableImages,
droppedImages: unreachableImages,
droppedImages: [],
},
});
} 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";
import {
getPointsSystemConfig,
getSiteSettings,
getSystemSetting,
} from "@/lib/db/queries";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import { getImageToolCreditCost } from "@/lib/points/config";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
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({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
@ -787,7 +809,7 @@ export async function POST(request: NextRequest) {
operation,
requestedModel: requestedModel || null,
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
resolvedModel: SCALE_ADJUST_REPAIR_MODEL_REF,
resolvedModel: configuredScaleAdjustModelId,
scaleAdjustProductLockMaskEditApplied: true,
scaleAdjustPersonExclusionApplied:
productLockAssets.personExclusionApplied,
@ -814,7 +836,7 @@ export async function POST(request: NextRequest) {
displayPrompt,
imageCount: 1,
imageSize: requestedImageSize,
model: SCALE_ADJUST_REPAIR_MODEL_REF,
model: configuredScaleAdjustModelId,
negativePrompt:
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
prompt,
@ -847,7 +869,7 @@ export async function POST(request: NextRequest) {
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
target: region.targetRect,
})),
relightModel: SCALE_ADJUST_REPAIR_MODEL_REF,
relightModel: configuredScaleAdjustModelId,
sourceImageUrl,
},
}
@ -876,7 +898,7 @@ export async function POST(request: NextRequest) {
...(hasPositionMove
? {
scaleAdjustModelRelight: {
model: SCALE_ADJUST_REPAIR_MODEL_REF,
model: configuredScaleAdjustModelId,
overlayImageUrl: overlayUpload.url,
quality: "medium" as const,
regions: scaleAdjustIntentRegions.map((region) => ({
@ -975,6 +997,7 @@ export async function POST(request: NextRequest) {
displayPrompt,
operation,
requestedModel: requestedModel || null,
resolvedModel: requestedModel || configuredLocalEditModelId || null,
resolvedAspectRatio,
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
regionReferenceImageCount: referenceImages.length,
@ -986,7 +1009,7 @@ export async function POST(request: NextRequest) {
displayPrompt,
imageCount: 1,
imageSize: requestedImageSize,
model: requestedModel || undefined,
model: requestedModel || configuredLocalEditModelId || undefined,
negativePrompt:
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
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)
.values({
userId: session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
title: title || getDefaultWorkspaceTitle(workspaceType),
description,
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

@ -14,6 +14,7 @@ import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
import { createVisionary } from "@/lib/ai/providers/visionary";
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
import { createVip98k } from "@/lib/ai/providers/vip98k";
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
import { createMidjourney } from "@/lib/ai/providers/midjourney";
import { createRunway } from "@/lib/ai/providers/runway";
@ -28,8 +29,9 @@ export const runtime = "nodejs";
export const maxDuration = 1200;
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_GENERATION_TIMEOUT_MS = 180_000;
const textItemSchema = z.object({
id: z.string().min(1),
@ -57,6 +59,7 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"midjourney",
"runway",
@ -66,6 +69,13 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
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(
models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>
) {
@ -80,16 +90,19 @@ function getTextEditModelCandidates(
}) &&
item.model.isEnabled &&
item.provider.isEnabled &&
(!item.model.excludeFromFailover || item.model.isDefaultTextEditModel) &&
SUPPORTED_TEXT_EDIT_PROVIDER_TYPES.has(item.provider.type),
)
.sort((a, b) => {
const aScore =
(a.model.isDefaultTextEditModel ? 100 : 0) +
(isExplicitTextEditModel(a) ? 30 : 0) +
(a.model.isDefault ? 10 : 0) +
(a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
(a.model.displayName?.includes("火山") ? 3 : 0);
const bScore =
(b.model.isDefaultTextEditModel ? 100 : 0) +
(isExplicitTextEditModel(b) ? 30 : 0) +
(b.model.isDefault ? 10 : 0) +
(b.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
(b.model.displayName?.includes("火山") ? 3 : 0);
@ -127,6 +140,8 @@ function createTextEditProvider(
return createVisionary({ baseURL, apiKey });
case "atlascloud":
return createAtlasCloud({ baseURL, apiKey });
case "vip98k":
return createVip98k({ baseURL, apiKey });
case "stepfun-image":
return createStepfunImage({ baseURL, apiKey });
case "midjourney":
@ -216,6 +231,13 @@ async function resolveTextEditSourceImageUrl(imageUrl: string) {
} catch (error) {
logger.warn("source_image_archive_failed", {
error: error instanceof Error ? error.message : String(error),
sourceHost: (() => {
try {
return new URL(imageUrl).host;
} catch {
return "invalid-url";
}
})(),
});
return imageUrl;
}
@ -491,12 +513,20 @@ async function generateTextEditImageWithFailover(params: {
],
imageParams: {
imageCount: 1,
timeoutMs: TEXT_EDIT_GENERATION_TIMEOUT_MS,
},
};
if (selected.provider.type === "atlascloud") {
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
generationOptions.imageGenConfig = selected.model.imageGenConfig || null;
}
const result = await provider.chat(runtimeModelId).doGenerate(generationOptions);
const modelInstance =
selected.provider.type === "vip98k"
? (provider as any).chat(runtimeModelId, {
capabilities: selected.model.capabilities || null,
imageGenConfig: selected.model.imageGenConfig || null,
})
: provider.chat(runtimeModelId);
const result = await modelInstance.doGenerate(generationOptions);
const resultText = String(result?.text || "").trim();
const finishReason = String(result?.finishReason || "").toLowerCase();

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

@ -7,9 +7,16 @@ import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
import {
isWorldLabsExternalTaskApi,
WORLDLABS_EXTERNAL_TASK_MODEL,
WorldLabsApiError,
worldLabsRequest,
} from "@/lib/worldlabs/client";
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
import type {
WorldLabsExternalTaskResponse,
WorldLabsExternalTaskResultData,
WorldLabsOperation,
WorldLabsWorld,
WorldLabsWorldAssets,
@ -183,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: {
cacheKey: string;
sourceUrl?: string | null;
@ -242,6 +291,11 @@ async function archiveRemoteAssetIfNeeded(params: {
);
const filename = `${params.filenamePrefix}.${ext}`;
const uploaded = await uploadFile(filename, assetBuffer);
await verifyArchivedAssetUpload({
url: uploaded.url,
expectedBytes: assetBuffer.byteLength,
validateAs: params.validateAs,
});
cache.set(params.cacheKey, {
sourceUrl,
archivedUrl: uploaded.url,
@ -292,7 +346,8 @@ async function archiveRemoteAssetMapIfNeeded(params: {
)
).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[]) {
@ -361,33 +416,96 @@ async function updatePersistedWorldAsset(params: {
function extractWorldId(operation: WorldLabsOperation) {
const response = getObjectValue(operation.response);
const metadata = getObjectValue(operation.metadata);
return getStringValue(
response?.world_id,
response?.worldId,
metadata?.world_id,
metadata?.worldId
);
const nestedWorldId =
getStringValue(response?.world_id, response?.worldId) ||
findNestedString(response, ["world_id", "worldId"]);
const metadataWorldId =
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) {
const response = operation.response;
if (response && typeof response === "object" && "world_marble_url" in (response as Record<string, unknown>)) {
return response as WorldLabsWorld;
}
return null;
return extractWorldCandidate(operation.response);
}
function hasUsableWorldAssets(world?: WorldLabsWorld | null) {
const assets = world?.assets || null;
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(
getStringValue(assets.thumbnail_url) ||
getStringValue(assets.imagery?.pano_url) ||
getStringValue(assets.mesh?.collider_mesh_url) ||
getStringValue(
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 &&
typeof spzUrls === "object" &&
Object.values(spzUrls).some((value) => typeof value === "string" && value.trim()))
Object.values(spzUrls).some(
(value) => typeof value === "string" && value.trim()
))
);
}
@ -419,6 +537,214 @@ function buildAssetPayload(
};
}
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
const root = getObjectValue(payload);
const data = getObjectValue(root?.data);
return (data || root || {}) as T;
}
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
const normalized = String(status || "").trim().toLowerCase();
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
return "success" as const;
}
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
return "failed" as const;
}
return "running" as const;
}
function readStringRecord(value: unknown) {
const record = getObjectValue(value);
if (!record) return null;
const entries = Object.entries(record).filter(
(entry): entry is [string, string] =>
Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0
);
return entries.length > 0 ? Object.fromEntries(entries) : null;
}
function findNestedString(
value: unknown,
keys: string[],
depth = 0
): string | null {
if (depth > 8) return null;
const record = getObjectValue(value);
if (!record) return null;
for (const key of keys) {
const directValue = getStringValue(record[key]);
if (directValue) return directValue;
}
for (const child of Object.values(record)) {
const found = findNestedString(child, keys, depth + 1);
if (found) return found;
}
return null;
}
function findNestedObject(
value: unknown,
keys: string[],
depth = 0
): Record<string, unknown> | null {
if (depth > 8) return null;
const record = getObjectValue(value);
if (!record) return null;
for (const key of keys) {
const directValue = getObjectValue(record[key]);
if (directValue) return directValue;
}
for (const child of Object.values(record)) {
const found = findNestedObject(child, keys, depth + 1);
if (found) return found;
}
return null;
}
function findNestedStringRecord(
value: unknown,
keys: string[],
depth = 0
): Record<string, string> | null {
if (depth > 8) return null;
const record = getObjectValue(value);
if (!record) return null;
for (const key of keys) {
const directValue = readStringRecord(record[key]);
if (directValue) return directValue;
}
for (const child of Object.values(record)) {
const found = findNestedStringRecord(child, keys, depth + 1);
if (found) return found;
}
return null;
}
function extractExternalTaskErrorMessage(
data: WorldLabsExternalTaskResultData,
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
) {
const dataError = getObjectValue(data.error);
const payloadError = getObjectValue(payload.error);
return (
getStringValue(
data.errorMessage,
data.message,
dataError?.message,
payload.message,
payloadError?.message,
payload.error
) || "World Labs 外部任务失败"
);
}
function extractExternalWorldAssetPayload(data: WorldLabsExternalTaskResultData) {
const root = getObjectValue(data) || {};
const resultRoot =
getObjectValue(root.result) ||
getObjectValue(root.output) ||
getObjectValue(root.world) ||
root;
const assets = findNestedObject(resultRoot, ["assets"]);
const worldAssets = assets as Partial<WorldLabsWorldAssets> | null;
const splats = getObjectValue(worldAssets?.splats);
return {
worldId: getStringValue(
findNestedString(resultRoot, ["worldId", "world_id"]),
findNestedString(root, ["worldId", "world_id"])
),
worldUrl: getStringValue(
findNestedString(resultRoot, [
"worldUrl",
"world_marble_url",
"sourceWorldUrl",
"viewerUrl",
"marbleUrl",
]),
findNestedString(root, [
"worldUrl",
"world_marble_url",
"sourceWorldUrl",
"viewerUrl",
"marbleUrl",
])
),
displayName: getStringValue(
findNestedString(resultRoot, ["displayName", "display_name"]),
findNestedString(root, ["displayName", "display_name"])
),
model:
getStringValue(
findNestedString(resultRoot, ["model", "modelId", "model_id"]),
findNestedString(root, ["model", "modelId", "model_id"])
) || WORLDLABS_EXTERNAL_TASK_MODEL,
caption: getStringValue(
worldAssets?.caption,
findNestedString(resultRoot, ["caption"]),
findNestedString(root, ["caption"])
),
thumbnailUrl: getStringValue(
worldAssets?.thumbnail_url,
findNestedString(resultRoot, [
"thumbnailUrl",
"thumbnail_url",
"previewImageUrl",
"preview_image_url",
]),
findNestedString(root, [
"thumbnailUrl",
"thumbnail_url",
"previewImageUrl",
"preview_image_url",
])
),
panoUrl: getStringValue(
worldAssets?.imagery?.pano_url,
findNestedString(resultRoot, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"]),
findNestedString(root, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"])
),
meshUrl: getStringValue(
worldAssets?.mesh?.collider_mesh_url,
findNestedString(resultRoot, [
"meshUrl",
"mesh_url",
"modelUrl",
"model_url",
"glbUrl",
"glb_url",
"colliderMeshUrl",
"collider_mesh_url",
]),
findNestedString(root, [
"meshUrl",
"mesh_url",
"modelUrl",
"model_url",
"glbUrl",
"glb_url",
"colliderMeshUrl",
"collider_mesh_url",
])
),
spzUrls:
readStringRecord(splats?.spz_urls) ||
findNestedStringRecord(resultRoot, ["spzUrls", "spz_urls"]) ||
findNestedStringRecord(root, ["spzUrls", "spz_urls"]),
semanticsMetadata:
getObjectValue(splats?.semantics_metadata) ||
findNestedObject(resultRoot, ["semanticsMetadata", "semantics_metadata"]) ||
findNestedObject(root, ["semanticsMetadata", "semantics_metadata"]),
};
}
interface RouteParams {
params: Promise<{ taskId: string }>;
}
@ -452,6 +778,226 @@ export async function GET(request: Request, { params }: RouteParams) {
});
providerForLog = config.providerId || null;
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
let persistedAssetPayload =
getObjectValue(persistedWorldAsset?.assetPayload) || {};
let shouldUseExternalTaskResultApi =
isWorldLabsExternalTaskApi(config.baseUrl) &&
persistedAssetPayload.externalTaskApi !== false &&
persistedAssetPayload.marbleApi !== true;
if (shouldUseExternalTaskResultApi) {
try {
const externalPayload = await worldLabsRequest<
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
>(config, {
method: "GET",
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,
},
});
}
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({
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),
assetsArchivingPending,
});
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,
},
});
} 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 operation = await worldLabsRequest<WorldLabsOperation>(config, {
method: "GET",
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
@ -512,22 +1058,68 @@ export async function GET(request: Request, { params }: RouteParams) {
let world = extractWorldFromOperation(operation);
const worldId = extractWorldId(operation);
if ((!world || !hasUsableWorldAssets(world)) && worldId) {
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
method: "GET",
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
});
world = world
? {
...world,
...detailedWorld,
assets: detailedWorld.assets || world.assets || null,
}
: detailedWorld;
try {
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
method: "GET",
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
});
world = world
? {
...world,
...detailedWorld,
assets: detailedWorld.assets || world.assets || null,
}
: 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) {
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)) {
@ -643,24 +1235,15 @@ export async function GET(request: Request, { params }: RouteParams) {
: 0;
const hasMissingSpzArchive =
rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length;
const storageArchivedAt =
[
[rawWorldUrl, stableWorldUrl],
[rawMeshUrl, stableModelUrl],
[rawThumbnailUrl, stableThumbnailUrl],
[rawPanoUrl, stablePanoUrl],
].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) ||
Boolean(
stableSpzUrls &&
assetPayload.spzUrls &&
Object.entries(stableSpzUrls).some(
([key, value]) =>
typeof assetPayload.spzUrls?.[key] === "string" &&
assetPayload.spzUrls[key] !== value
)
)
? new Date().toISOString()
: null;
const assetsArchivingPending = Boolean(
(rawMeshUrl && !stableModelUrl) ||
(rawThumbnailUrl && !stableThumbnailUrl) ||
(rawPanoUrl && !stablePanoUrl) ||
hasMissingSpzArchive
);
const storageArchivedAt = assetsArchivingPending
? null
: new Date().toISOString();
logger.info("task success status", {
elapsedMs: Date.now() - startedAt,
@ -672,10 +1255,7 @@ export async function GET(request: Request, { params }: RouteParams) {
hasThumbnail: Boolean(stableThumbnailUrl),
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
storageArchivedAt,
assetsArchivingPending: Boolean(
(rawMeshUrl && !stableModelUrl) ||
hasMissingSpzArchive
),
assetsArchivingPending,
});
const responseAssetPayload = {
@ -685,12 +1265,9 @@ export async function GET(request: Request, { params }: RouteParams) {
panoUrl: stablePanoUrl || assetPayload.panoUrl,
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null,
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
storageArchivedAt,
assetsArchivingPending: Boolean(
(rawMeshUrl && !stableModelUrl) ||
hasMissingSpzArchive
),
assetsArchivingPending,
sourceWorldUrl: rawWorldUrl || null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl

View File

@ -12,13 +12,23 @@ import {
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { createLogger } from "@/lib/observability/logger";
import { get3DWorldCreditCost } from "@/lib/points/config";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
import {
isWorldLabsExternalTaskApi,
WORLDLABS_EXTERNAL_TASK_MODEL,
WorldLabsApiError,
worldLabsRequest,
} from "@/lib/worldlabs/client";
import {
getWorldLabsProviderConfig,
getWorldLabsProviderConfigs,
reportWorldLabsProviderRuntimeFailure,
} from "@/lib/worldlabs/server";
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
import type {
WorldLabsExternalTaskCreateData,
WorldLabsExternalTaskResponse,
WorldLabsModel,
WorldLabsOperation,
} from "@/lib/worldlabs/types";
export const runtime = "nodejs";
export const maxDuration = 180;
@ -47,8 +57,10 @@ type CreateWorldTaskRequest = {
makePublic?: boolean;
reconstructImages?: boolean;
mediaAssetId?: string;
sourceUrl?: string;
mediaAssets?: Array<{
mediaAssetId?: string;
sourceUrl?: string;
azimuth?: number | null;
}>;
};
@ -58,10 +70,152 @@ function readOptionalTrimmedText(input: unknown) {
return value || null;
}
function readOptionalHttpUrl(input: unknown) {
const value = readOptionalTrimmedText(input);
if (!value) return null;
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString()
: null;
} catch {
return null;
}
}
function getObjectValue(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
const root = getObjectValue(payload);
const data = getObjectValue(root?.data);
return (data || root || {}) as T;
}
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
const normalized = String(status || "").trim().toLowerCase();
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
return "success" as const;
}
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
return "failed" as const;
}
return "running" as const;
}
function normalizeExternalTaskCreateOperation(
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
): WorldLabsOperation {
const data = unwrapExternalTaskData<WorldLabsExternalTaskCreateData>(payload);
const taskId = readOptionalTrimmedText(data.taskId || data.task_id || data.id);
const status = readOptionalTrimmedText(data.status) || "accepted";
const normalizedStatus = normalizeExternalTaskStatus(status, data.done === true);
return {
operation_id: taskId || undefined,
done: normalizedStatus === "success",
error:
normalizedStatus === "failed"
? {
message:
readOptionalTrimmedText(
(payload as Record<string, unknown>)?.message ||
(payload as Record<string, unknown>)?.error
) || "World Labs 外部任务创建失败",
}
: null,
metadata: {
externalTaskApi: true,
status,
resultPath: readOptionalTrimmedText(data.resultPath || data.result_path),
requestId: readOptionalTrimmedText(payload.requestId || payload.request_id),
},
response: data,
};
}
function shouldCharge3DPoints(mode: WorldTaskMode) {
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
}
function shouldUseExternalTaskCreateApi(
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
mode: WorldTaskMode
) {
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) {
const normalized = readOptionalTrimmedText(model);
if (!normalized || normalized === WORLDLABS_EXTERNAL_TASK_MODEL) {
return "marble-1.1";
}
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) {
const textPrompt = readOptionalTrimmedText(body.prompt);
@ -80,15 +234,21 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
if (mode === "image") {
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
if (!mediaAssetId) {
throw new Error("图生世界缺少 mediaAssetId");
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
if (!mediaAssetId && !sourceUrl) {
throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
}
return {
type: "image",
image_prompt: {
source: "media_asset",
media_asset_id: mediaAssetId,
},
image_prompt: mediaAssetId
? {
source: "media_asset",
media_asset_id: mediaAssetId,
}
: {
source: "uri",
uri: sourceUrl,
},
...(textPrompt ? { text_prompt: textPrompt } : {}),
...(body.disableRecaption !== undefined
? { disable_recaption: body.disableRecaption }
@ -102,7 +262,8 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
const multiImagePrompt = mediaAssets
.map((item) => {
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
if (!mediaAssetId) return null;
const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
if (!mediaAssetId && !sourceUrl) return null;
const azimuth =
item?.azimuth === null || item?.azimuth === undefined
? null
@ -111,10 +272,15 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
: null;
return {
...(azimuth === null ? {} : { azimuth }),
content: {
source: "media_asset",
media_asset_id: mediaAssetId,
},
content: mediaAssetId
? {
source: "media_asset",
media_asset_id: mediaAssetId,
}
: {
source: "uri",
uri: sourceUrl,
},
};
})
.filter(Boolean);
@ -137,16 +303,22 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
}
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
if (!mediaAssetId) {
throw new Error("视频生成世界缺少 mediaAssetId");
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
if (!mediaAssetId && !sourceUrl) {
throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
}
return {
type: "video",
video_prompt: {
source: "media_asset",
media_asset_id: mediaAssetId,
},
video_prompt: mediaAssetId
? {
source: "media_asset",
media_asset_id: mediaAssetId,
}
: {
source: "uri",
uri: sourceUrl,
},
...(textPrompt ? { text_prompt: textPrompt } : {}),
};
}
@ -187,12 +359,29 @@ export async function POST(request: Request) {
);
modelForLog = resolvedWorldModel || "marble-1.1";
const preferredProviderId = readOptionalTrimmedText(body.providerId);
const displayName =
readOptionalTrimmedText(body.displayName) ||
readOptionalTrimmedText(body.prompt) ||
`World ${new Date().toLocaleString("zh-CN")}`;
const worldPrompt = buildWorldPrompt(body, mode);
const candidates = preferredProviderId
? [await getWorldLabsProviderConfig(preferredProviderId)]
: await getWorldLabsProviderConfigs();
if (candidates.length === 0) {
throw new Error("未找到可用的 World Labs Key");
}
const officialMarbleModel = resolveOfficialMarbleModel(resolvedWorldModel);
const predictedBillingModel = candidates.some((candidate) =>
shouldUseExternalTaskCreateApi(candidate, mode)
)
? WORLDLABS_EXTERNAL_TASK_MODEL
: officialMarbleModel;
const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(mode);
const unitCost = get3DWorldCreditCost(
pointsConfig,
resolvedWorldModel || "marble-1.1",
);
const unitCost = get3DWorldCreditCost(pointsConfig, predictedBillingModel);
if (
shouldChargePoints &&
@ -220,42 +409,83 @@ export async function POST(request: Request) {
}
}
const preferredProviderId = readOptionalTrimmedText(body.providerId);
const displayName =
readOptionalTrimmedText(body.displayName) ||
readOptionalTrimmedText(body.prompt) ||
`World ${new Date().toLocaleString("zh-CN")}`;
const worldPrompt = buildWorldPrompt(body, mode);
const candidates = preferredProviderId
? [await getWorldLabsProviderConfig(preferredProviderId)]
: await getWorldLabsProviderConfigs();
if (candidates.length === 0) {
throw new Error("未找到可用的 World Labs Key");
}
let result: WorldLabsOperation | null = null;
let usedProvider = null as Awaited<
ReturnType<typeof getWorldLabsProviderConfig>
> | null;
let usedExternalTaskApi = false;
let lastError: unknown = null;
for (const candidate of candidates) {
try {
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
method: "POST",
path: "/marble/v1/worlds:generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: {
display_name: displayName,
model: resolvedWorldModel || "marble-1.1",
permission: {
public: body.makePublic === true,
},
world_prompt: worldPrompt,
},
});
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
assertWorldModeSupportedByCandidate(candidate, mode);
let requestUsedExternalTaskApi = false;
if (useExternalTaskApi) {
const payload = buildExternalWorldTaskPayload(
mode,
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;
}
} else {
requestUsedExternalTaskApi = false;
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
method: "POST",
path: "/marble/v1/worlds:generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: buildOfficialWorldTaskPayload(
displayName,
worldPrompt,
officialMarbleModel,
body
),
});
}
if (!result?.operation_id) {
throw new Error("创建 World Labs 世界任务返回缺少 operation_id");
}
usedProvider = candidate;
usedExternalTaskApi = requestUsedExternalTaskApi;
providerForLog = candidate.providerId || null;
break;
} catch (error) {
@ -292,12 +522,18 @@ export async function POST(request: Request) {
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
try {
const usedBillingModel = usedExternalTaskApi
? WORLDLABS_EXTERNAL_TASK_MODEL
: officialMarbleModel;
const usedBillingDisplayName = usedBillingModel === WORLDLABS_EXTERNAL_TASK_MODEL
? WORLDLABS_EXTERNAL_TASK_MODEL
: worldModelDisplayName;
await consumeUserPoints({
userId: session.user.id,
amount: unitCost,
reason: "world3d_generation",
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
note: `3D世界生成消耗积分(${worldModelDisplayName})`,
modelId: `worldlabs-3d:${usedBillingModel}`,
note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
});
} catch (error) {
logger.error("points consume failed", error, {
@ -313,10 +549,13 @@ export async function POST(request: Request) {
elapsedMs: Date.now() - startedAt,
taskIdLength: taskId.length,
mode,
model: resolvedWorldModel || "marble-1.1",
model: usedExternalTaskApi
? WORLDLABS_EXTERNAL_TASK_MODEL
: officialMarbleModel,
providerId: usedProvider.providerId || null,
status: result.done ? "success" : "running",
unitCost,
externalTaskApi: usedExternalTaskApi,
});
return NextResponse.json({
@ -327,6 +566,8 @@ export async function POST(request: Request) {
assetPayload: {
providerId: usedProvider.providerId || null,
providerName: usedProvider.providerName || null,
externalTaskApi: usedExternalTaskApi,
marbleApi: !usedExternalTaskApi,
},
raw: result,
},

View File

@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { createLogger } from "@/lib/observability/logger";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
import { uploadFile } from "@/lib/storage/service";
import {
WorldLabsApiError,
worldLabsRequest,
} from "@/lib/worldlabs/client";
import {
getWorldLabsProviderConfig,
getWorldLabsProviderConfigs,
@ -112,6 +116,81 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
}
function readOptionalString(value: unknown) {
const text = String(value || "").trim();
return text || null;
}
function extractUploadInfoFromCurlExample(curlExample: unknown) {
const curl = String(curlExample || "");
const uploadUrl =
[...curl.matchAll(/https?:\/\/[^\s'"\\]+/g)]
.map((match) => match[0])
.find(Boolean) || null;
const requiredHeaders: Record<string, string> = {};
const headerPattern = /(?:-H|--header)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
let headerMatch: RegExpExecArray | null;
while ((headerMatch = headerPattern.exec(curl))) {
const header = String(
headerMatch[1] || headerMatch[2] || headerMatch[3] || ""
).trim();
const colonIndex = header.indexOf(":");
if (colonIndex <= 0) continue;
const name = header.slice(0, colonIndex).trim();
const value = header.slice(colonIndex + 1).trim();
if (name && value) {
requiredHeaders[name] = value;
}
}
return { uploadUrl, requiredHeaders };
}
function resolvePrepareUploadDetails(data: WorldLabsPrepareUploadResponse) {
const uploadInfo = data.upload_info || {};
const uploadInfoRecord = uploadInfo as Record<string, unknown>;
const curlDetails = extractUploadInfoFromCurlExample(uploadInfo.curl_example);
const explicitHeaders =
uploadInfo.required_headers && typeof uploadInfo.required_headers === "object"
? uploadInfo.required_headers
: {};
return {
uploadUrl:
readOptionalString(uploadInfo.upload_url) ||
readOptionalString(uploadInfoRecord.uploadUrl) ||
readOptionalString(uploadInfoRecord.url) ||
curlDetails.uploadUrl,
uploadMethod:
readOptionalString(uploadInfo.upload_method) ||
readOptionalString(uploadInfoRecord.method) ||
"PUT",
requiredHeaders: {
...curlDetails.requiredHeaders,
...explicitHeaders,
},
};
}
function isSignedUploadFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return (
message.includes("SignatureDoesNotMatch") ||
message.includes("Access denied") ||
message.includes("Google secret key") ||
message.includes("签名")
);
}
async function uploadWorldLabsInputToStorage(file: File, extension: string) {
const buffer = await file.arrayBuffer();
const safeExtension = extension && extension !== "bin" ? extension : "bin";
const filename = `worldlabs-input-${Date.now()}-${crypto.randomUUID()}.${safeExtension}`;
const uploaded = await uploadFile(filename, buffer);
return uploaded.url;
}
function isCredentialOrBillingProviderError(error: unknown) {
return (
error instanceof WorldLabsApiError &&
@ -202,24 +281,24 @@ export async function POST(request: Request) {
);
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
const { uploadUrl, uploadMethod, requiredHeaders } =
resolvePrepareUploadDetails(prepareUpload);
if (!mediaAssetId || !uploadUrl) {
throw new Error("World Labs 返回的上传信息不完整");
}
const uploadHeaders = new Headers();
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
const requiredHeaders = prepareUpload.upload_info?.required_headers || {};
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
if (!headerName || headerValue === undefined || headerValue === null) continue;
uploadHeaders.set(headerName, String(headerValue));
}
if (!uploadHeaders.has("Content-Type") && !prepareUpload.upload_info?.curl_example) {
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
}
const uploadMethod = String(
prepareUpload.upload_info?.upload_method || "PUT"
).toUpperCase();
const normalizedUploadMethod = uploadMethod.toUpperCase();
const uploadResponse = await fetch(uploadUrl, {
method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
headers: uploadHeaders,
body: file,
cache: "no-store",
@ -252,6 +331,28 @@ export async function POST(request: Request) {
});
} catch (error) {
lastError = error;
if (isSignedUploadFailure(error)) {
try {
const sourceUrl = await uploadWorldLabsInputToStorage(file, extension);
logger.warn("worldlabs signed upload failed; using storage source url", {
elapsedMs: Date.now() - startedAt,
providerId: config.providerId || null,
kind: isImage ? "image" : "video",
size: file.size,
});
return NextResponse.json({
success: true,
data: {
sourceUrl,
providerId: config.providerId || null,
providerName: config.providerName || null,
uploadFallback: "storage-source-url",
},
});
} catch (fallbackError) {
lastError = fallbackError;
}
}
if (isCredentialOrBillingProviderError(error)) {
await reportWorldLabsProviderRuntimeFailure(
config.providerId,

View File

@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
import { db } from "@/lib/db";
import { clientPerformanceEvent } from "@/lib/db/schema";
@ -167,9 +168,12 @@ export async function POST(request: Request) {
console.warn("[ClientPerformance] prune failed", error);
});
const persistedChatId =
event.chatId && !isAgentModeScopedChatId(event.chatId) ? event.chatId : null;
await db.insert(clientPerformanceEvent).values({
userId: session?.user?.id || null,
chatId: event.chatId || null,
chatId: persistedChatId,
projectId: clampText(event.projectId, 64),
eventType: clampText(event.eventType, 64) || "unknown",
section: event.section || "unknown",

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 { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import {
getFriendlyRateLimitMessage,
getUserFacingRequestErrorMessage,
isRateLimitError,
} from "@/lib/request-error";
import { executeExtendPOST } from "@/lib/canvas/operations/extend";
export const runtime = "nodejs";
export const maxDuration = 180;
function parseIncludeBase64(value: unknown): boolean {
if (typeof value === "boolean") return value;
const normalized = String(value ?? "").trim().toLowerCase();
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 },
);
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
return executeExtendPOST(request, session.user.id);
}

View File

@ -21,103 +21,6 @@ import {
export const runtime = "nodejs";
export const maxDuration = 1200;
const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__";
const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2;
const DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE = 8;
const DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS = 15_000;
class ExternalImageApiBusyError extends Error {
readonly retryAfterSeconds: number;
constructor(message: string, retryAfterSeconds = 15) {
super(message);
this.name = "ExternalImageApiBusyError";
this.retryAfterSeconds = retryAfterSeconds;
}
}
type ExternalImageApiWaiter = {
resolve: (release: () => void) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
type ExternalImageApiLimiterState = {
running: number;
queue: ExternalImageApiWaiter[];
};
function parsePositiveInteger(value: unknown, fallback: number) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getExternalImageApiLimiterState(): ExternalImageApiLimiterState {
const globalState = globalThis as typeof globalThis & {
[EXTERNAL_IMAGE_API_LIMITER_KEY]?: ExternalImageApiLimiterState;
};
if (!globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]) {
globalState[EXTERNAL_IMAGE_API_LIMITER_KEY] = {
running: 0,
queue: [],
};
}
return globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]!;
}
function acquireExternalImageApiSlot(): Promise<() => void> {
const state = getExternalImageApiLimiterState();
const maxConcurrent = parsePositiveInteger(
process.env.EXTERNAL_IMAGE_API_MAX_CONCURRENT,
DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT,
);
const maxQueue = parsePositiveInteger(
process.env.EXTERNAL_IMAGE_API_MAX_QUEUE,
DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE,
);
const queueTimeoutMs = parsePositiveInteger(
process.env.EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
);
const release = () => {
state.running = Math.max(0, state.running - 1);
const next = state.queue.shift();
if (!next) return;
clearTimeout(next.timer);
state.running += 1;
next.resolve(release);
};
if (state.running < maxConcurrent) {
state.running += 1;
return Promise.resolve(release);
}
if (state.queue.length >= maxQueue) {
return Promise.reject(
new ExternalImageApiBusyError("图片生成服务繁忙,请稍后重试"),
);
}
return new Promise((resolve, reject) => {
const waiter: ExternalImageApiWaiter = {
resolve,
reject,
timer: setTimeout(() => {
const index = state.queue.indexOf(waiter);
if (index >= 0) {
state.queue.splice(index, 1);
}
reject(new ExternalImageApiBusyError("图片生成排队超时,请稍后重试"));
}, queueTimeoutMs),
};
state.queue.push(waiter);
});
}
const requestSchema = z.object({
prompt: z.string().trim().min(1, "prompt 不能为空"),
negativePrompt: z.string().trim().optional(),
@ -294,10 +197,8 @@ export async function POST(request: Request) {
const startedAt = Date.now();
let logId: string | null = null;
let releaseExternalImageApiSlot: (() => void) | null = null;
try {
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
const payload = await parseRequestPayload(request);
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
const candidateModels = await buildExternalImageApiModelCandidates({
@ -344,6 +245,7 @@ export async function POST(request: Request) {
const generationResult = await generateImagesWithFailover({
candidates: candidateModels,
ownerKey: `external-image-api:${authResult.accessKey.id}`,
prompt: payload.prompt,
negativePrompt: payload.negativePrompt || null,
imageCount: payload.imageCount || 1,
@ -467,21 +369,6 @@ export async function POST(request: Request) {
.where(eq(externalImageApiRequestLog.id, logId));
}
if (error instanceof ExternalImageApiBusyError) {
return NextResponse.json(
{
success: false,
error: error.message,
},
{
status: 429,
headers: {
"Retry-After": String(error.retryAfterSeconds),
},
},
);
}
return NextResponse.json(
{
success: false,
@ -489,9 +376,5 @@ export async function POST(request: Request) {
},
{ status: 500 },
);
} finally {
if (releaseExternalImageApiSlot) {
releaseExternalImageApiSlot();
}
}
}

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> {
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;
}
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) {
const title = String(value || "").trim();
return title.slice(0, 256) || "未命名流程白板";
@ -17,15 +88,67 @@ export function normalizeFlowBoardSnapshot(
value: unknown
): FlowBoardSnapshotData {
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 =
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
? raw.updatedAt
: new Date().toISOString();
return {
children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [],
viewport: normalizeRecord(raw.viewport),
theme: normalizeRecord(raw.theme),
version: 2,
activeSheetId: activeSheet.id,
sheets: nextSheets,
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
updatedAt,
};
}

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { eq, desc, sql } from "drizzle-orm";
import { generatedImage, imageArchiveTask } from "@/lib/db/schema";
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
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 pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
const offset = (page - 1) * pageSize;
const visibleImageFilter = and(
eq(generatedImage.userId, userId),
isNull(imageArchiveTask.id)
);
// 查询总数
const countResult = await db
.select({ count: sql<number>`count(*)::int` })
.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;
@ -42,7 +53,14 @@ export async function GET(request: NextRequest) {
modelId: generatedImage.modelId, // 添加 modelId 字段
})
.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))
.limit(pageSize)
.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,
} from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema";
import {
buildOpenClawArtifactProxyUrl,
normalizeOpenClawArtifacts,
} from "@/lib/openclaw-chat/artifacts";
import {
getFalImageTaskStatus,
resolveFalImageTaskToMarkdown,
@ -32,6 +36,32 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
const falTaskLastPolledAt = new Map<string, number>();
const falTaskSyncInFlight = new Set<string>();
function toPublicVideoMetadata(params: {
message: DBMessage;
requestOrigin: string;
}) {
const value = params.message.videoMetadata;
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const metadata = value as Record<string, unknown>;
if (metadata.taskKind !== "clawd_chat") return value;
const publicMetadata = { ...metadata };
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;
}
function createEmptyMessagesResponse() {
return NextResponse.json(
{ messages: [] },
@ -277,7 +307,11 @@ export async function GET(request: NextRequest) {
createdAt: msg.createdAt,
parts: parts,
attachments: msg.attachments || [],
videoMetadata: msg.videoMetadata || null,
videoMetadata:
toPublicVideoMetadata({
message: msg,
requestOrigin: request.nextUrl.origin,
}) || null,
};
});

View File

@ -0,0 +1,148 @@
import { and, eq, isNotNull } from "drizzle-orm";
import { z } from "zod";
import {
createUserAccessKey,
listUserAccessKeys,
} from "@/lib/access-keys/service";
import {
MIN_PROVISIONING_SECRET_LENGTH,
parseProvisioningBearerSecret,
verifyProvisioningSecret,
} from "@/lib/access-keys/provisioning-secret";
import { db } from "@/lib/db";
import { user } from "@/lib/db/schema";
const provisionAccessKeySchema = z.object({
dingTalkUsername: z.string().trim().min(1).max(100),
dingTalkUserId: z.string().trim().min(1).max(128).optional(),
keyName: z.string().trim().min(1).max(100).optional(),
scopes: z
.array(z.enum(["canvas:read", "canvas:write"]))
.min(1)
.optional(),
expiresInDays: z.number().int().min(1).max(365).optional(),
});
function jsonError(error: string, code: string, status: number) {
return Response.json(
{ success: false, error, code },
{ status, headers: { "Cache-Control": "no-store" } }
);
}
export async function POST(request: Request) {
const expectedSecret = String(
process.env.MCP_KEY_PROVISIONING_SECRET || ""
).trim();
if (expectedSecret.length < MIN_PROVISIONING_SECRET_LENGTH) {
return jsonError(
"Access Key provisioning is not configured",
"PROVISIONING_NOT_CONFIGURED",
503
);
}
const providedSecret = parseProvisioningBearerSecret(
request.headers.get("authorization")
);
if (!verifyProvisioningSecret(providedSecret, expectedSecret)) {
return jsonError(
"Missing or invalid provisioning credential",
"PROVISIONING_UNAUTHORIZED",
401
);
}
const parsedBody = provisionAccessKeySchema.safeParse(
await request.json().catch(() => null)
);
if (!parsedBody.success) {
return jsonError(
"Invalid provisioning request",
"PROVISIONING_REQUEST_INVALID",
400
);
}
const body = parsedBody.data;
const conditions = [
eq(user.name, body.dingTalkUsername),
eq(user.isActive, true),
isNotNull(user.dingtalkUserId),
];
if (body.dingTalkUserId) {
conditions.push(eq(user.dingtalkUserId, body.dingTalkUserId));
}
const matches = await db
.select({
id: user.id,
name: user.name,
dingtalkUserId: user.dingtalkUserId,
})
.from(user)
.where(and(...conditions))
.limit(2);
if (matches.length === 0) {
return jsonError(
"No active DingTalk user matched the supplied identity",
"DINGTALK_USER_NOT_FOUND",
404
);
}
if (matches.length > 1) {
return jsonError(
"DingTalk username is not unique; provide dingTalkUserId",
"DINGTALK_USERNAME_AMBIGUOUS",
409
);
}
const matchedUser = matches[0];
const existingKeys = await listUserAccessKeys(matchedUser.id);
const activeKeyCount = existingKeys.filter(
(item) =>
!item.revokedAt &&
(!item.expiresAt || new Date(item.expiresAt).getTime() > Date.now())
).length;
if (activeKeyCount >= 10) {
return jsonError(
"The user already has 10 active Access Keys",
"ACCESS_KEY_LIMIT_REACHED",
409
);
}
try {
const accessKey = await createUserAccessKey({
userId: matchedUser.id,
name: body.keyName || "第三方 OpenClaw",
scopes: body.scopes,
expiresInDays: body.expiresInDays,
});
return Response.json(
{
success: true,
data: {
user: matchedUser,
accessKey,
},
},
{
status: 201,
headers: {
"Cache-Control": "no-store",
Pragma: "no-cache",
},
}
);
} catch {
return jsonError(
"Failed to provision Access Key",
"ACCESS_KEY_PROVISION_FAILED",
500
);
}
}

View File

@ -0,0 +1,48 @@
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route";
import { z } from "zod";
import {
openApiErrorResponse,
requireOpenApiPrincipal,
} from "@/lib/access-keys/openapi-auth";
const canvasIdSchema = z.string().uuid();
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const principal = await requireOpenApiPrincipal(request, "canvas:write");
const { id: rawId } = await params;
const parsedId = canvasIdSchema.safeParse(rawId);
if (!parsedId.success) {
return Response.json(
{
success: false,
error: "Invalid canvas ID",
code: "INVALID_CANVAS_ID",
},
{ status: 400 }
);
}
const id = parsedId.data;
const response = await handleCanvasAgentOperationRouteRequest({
userId: principal.userId,
projectId: id,
readBody: () => request.json(),
features: await getCanvasAgentFeatureFlagsFromSystemSettings(),
applyOperation: applyCanvasOperationPlan,
logger: console,
});
return Response.json(response.body, {
status: response.status,
headers: { "Cache-Control": "no-store" },
});
} catch (error) {
return openApiErrorResponse(error);
}
}

View File

@ -0,0 +1,57 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import {
openApiErrorResponse,
requireOpenApiPrincipal,
} from "@/lib/access-keys/openapi-auth";
const canvasIdSchema = z.string().uuid();
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const principal = await requireOpenApiPrincipal(request, "canvas:read");
const { id: rawId } = await params;
const parsedId = canvasIdSchema.safeParse(rawId);
if (!parsedId.success) {
return Response.json(
{
success: false,
error: "Invalid canvas ID",
code: "INVALID_CANVAS_ID",
},
{ status: 400 }
);
}
const id = parsedId.data;
const [project] = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, principal.userId)
)
)
.limit(1);
if (!project) {
return Response.json(
{ success: false, error: "Canvas not found", code: "CANVAS_NOT_FOUND" },
{ status: 404 }
);
}
return Response.json(
{ success: true, data: project },
{ headers: { "Cache-Control": "no-store" } }
);
} catch (error) {
return openApiErrorResponse(error);
}
}

View File

@ -0,0 +1,52 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import {
openApiErrorResponse,
requireOpenApiPrincipal,
} from "@/lib/access-keys/openapi-auth";
function normalizeWorkspaceType(value: string | null) {
return value === "agent" || value === "video" ? value : "canvas";
}
export async function GET(request: Request) {
try {
const principal = await requireOpenApiPrincipal(request, "canvas:read");
const url = new URL(request.url);
const workspaceType = normalizeWorkspaceType(
url.searchParams.get("workspaceType")
);
const requestedLimit = Number(url.searchParams.get("limit") || 24);
const limit = Number.isFinite(requestedLimit)
? Math.min(50, Math.max(1, Math.round(requestedLimit)))
: 24;
const projects = await db
.select({
id: canvasProject.id,
title: canvasProject.title,
description: canvasProject.description,
thumbnail: canvasProject.thumbnail,
workspaceType: canvasProject.workspaceType,
createdAt: canvasProject.createdAt,
updatedAt: canvasProject.updatedAt,
})
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, principal.userId),
eq(canvasProject.workspaceType, workspaceType)
)
)
.orderBy(desc(canvasProject.updatedAt))
.limit(limit);
return Response.json(
{ success: true, data: projects },
{ headers: { "Cache-Control": "no-store" } }
);
} catch (error) {
return openApiErrorResponse(error);
}
}

View File

@ -0,0 +1,41 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { user } from "@/lib/db/schema";
import {
openApiErrorResponse,
requireOpenApiPrincipal,
} from "@/lib/access-keys/openapi-auth";
export async function GET(request: Request) {
try {
const principal = await requireOpenApiPrincipal(request, "canvas:read");
const [currentUser] = await db
.select({
id: user.id,
email: user.email,
name: user.name,
department: user.department,
role: user.role,
})
.from(user)
.where(eq(user.id, principal.userId))
.limit(1);
return Response.json(
{
success: true,
data: {
user: currentUser,
accessKey: {
id: principal.accessKeyId,
name: principal.accessKeyName,
scopes: principal.scopes,
},
},
},
{ headers: { "Cache-Control": "no-store" } }
);
} catch (error) {
return openApiErrorResponse(error);
}
}

View File

@ -1,138 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
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";
import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
export const runtime = "nodejs";
export const maxDuration = 60;
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 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 }
);
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
return executeRemoveBackgroundPOST(request, session.user.id);
}

View File

@ -28,6 +28,7 @@ import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
import { createVisionary } from "@/lib/ai/providers/visionary";
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
import { createVip98k } from "@/lib/ai/providers/vip98k";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import {
canProcessSceneFusionProviderType,
@ -1565,6 +1566,18 @@ async function processSceneWithProvider(
imageGenConfig,
);
case "vip98k":
return processWithVip98k(
productImageUrl,
sceneImageUrl,
prompt,
provider,
runtimeModelId || modelId,
aspectRatio,
imageSize,
imageGenConfig,
);
case "gemini":
case "gemini-image":
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
@ -1933,6 +1946,45 @@ async function processWithAtlasCloud(
return { thumbnail, original, prompt };
}
async function processWithVip98k(
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
provider: { baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string,
imageGenConfig?: Record<string, any> | null
): Promise<{ thumbnail: string; original: string; prompt: string }> {
logger.info("provider_processing", { providerType: "vip98k", modelId });
const vip98kProvider = createVip98k({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
const result = await vip98kProvider.chat(modelId, imageGenConfig || null).doGenerate({
messages: [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: productImageUrl } },
{ type: "image_url", image_url: { url: sceneImageUrl } },
{ type: "text", text: prompt },
],
},
],
imageParams: {
aspectRatio: aspectRatio || "auto",
imageSize: imageSize || "2K",
imageCount: 1,
},
imageGenConfig,
});
const { thumbnail, original } = parseImageFromMarkdown(result.text);
return { thumbnail, original, prompt };
}
/**
* 使用既梦/火山模型处理
*/

View File

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

View File

@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
import { getSystemSetting } from "@/lib/db/queries";
import { getSystemSetting, getUserById } from "@/lib/db/queries";
import {
findStyleProImageModelByRef,
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] =
await Promise.all([
getStyleProImageModelDisplayEntries({ includeHidden: false }),
getStyleProImageModelEntries({ includeHidden: false }),
getStyleProImageModelDisplayEntries({
includeHidden: allowHiddenModels,
}),
getStyleProImageModelEntries({ includeHidden: allowHiddenModels }),
getStyleProImageModelEntries({ includeHidden: true }),
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

@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { revokeUserAccessKey } from "@/lib/access-keys/service";
const idSchema = z.string().uuid();
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id || session.user.type === "guest") {
return NextResponse.json(
{ success: false, error: "请先登录正式账号" },
{ status: 401 }
);
}
const parsedId = idSchema.safeParse((await params).id);
if (!parsedId.success) {
return NextResponse.json(
{ success: false, error: "Access Key ID 无效" },
{ status: 400 }
);
}
const revoked = await revokeUserAccessKey({
userId: session.user.id,
accessKeyId: parsedId.data,
});
if (!revoked) {
return NextResponse.json(
{ success: false, error: "Access Key 不存在" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
createUserAccessKey,
listUserAccessKeys,
} from "@/lib/access-keys/service";
const createAccessKeySchema = z.object({
name: z.string().trim().min(1).max(100).optional(),
scopes: z
.array(z.enum(["canvas:read", "canvas:write"]))
.min(1)
.optional(),
expiresInDays: z.number().int().min(1).max(365).optional(),
});
export async function GET() {
const session = await auth();
if (!session?.user?.id || session.user.type === "guest") {
return NextResponse.json(
{ success: false, error: "请先登录正式账号" },
{ status: 401 }
);
}
const keys = await listUserAccessKeys(session.user.id);
return NextResponse.json(
{ success: true, data: keys },
{ headers: { "Cache-Control": "no-store" } }
);
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id || session.user.type === "guest") {
return NextResponse.json(
{ success: false, error: "请先登录正式账号" },
{ status: 401 }
);
}
const existingKeys = await listUserAccessKeys(session.user.id);
const activeKeyCount = existingKeys.filter(
(item) =>
!item.revokedAt &&
(!item.expiresAt || new Date(item.expiresAt).getTime() > Date.now())
).length;
if (activeKeyCount >= 10) {
return NextResponse.json(
{ success: false, error: "最多保留 10 个有效 Access Key" },
{ status: 409 }
);
}
try {
const body = createAccessKeySchema.parse(
await request.json().catch(() => ({}))
);
const created = await createUserAccessKey({
userId: session.user.id,
name: body?.name,
scopes: body?.scopes,
expiresInDays: body?.expiresInDays,
});
return NextResponse.json({ success: true, data: created }, { status: 201 });
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "创建 Access Key 失败",
},
{ status: 400 }
);
}
}

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