Compare commits

...

690 Commits

Author SHA1 Message Date
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
134
758be188cf test: preserve image model classification coverage 2026-07-21 12:00:25 +08:00
134
fd47e0ae61 fix: classify media models and open canvas chat 2026-07-21 12:00:09 +08:00
134
e8d1aa25aa feat: add Atlas Cloud image provider 2026-07-21 11:08:13 +08:00
134
31cc75823c chore: ignore staged production builds 2026-07-15 11:35:24 +08:00
134
3c1b84ccd0 fix: prevent white screens during production rebuilds 2026-07-15 11:26:41 +08:00
134
abc6a7266a fix: show canvas enhance processing placeholder 2026-07-14 16:15:49 +08:00
134
1fd9be49c7 fix: route canvas capabilities through local runtime 2026-07-14 16:01:44 +08:00
134
452effecbc fix: classify reference image models correctly 2026-07-14 15:26:46 +08:00
134
f52ab780f1 fix(canvas): render generated image thumbnails first 2026-07-14 14:22:55 +08:00
134
80e9771790 feat: add Visionary image provider 2026-07-14 14:12:27 +08:00
134
1b70c1d678 feat(canvas): add capability runtime and MCP tools 2026-07-14 12:51:10 +08:00
134
5a7121dceb fix: handle complex canvas scale adjustments 2026-07-14 09:03:14 +08:00
134
ede6f080a2 feat: add BananaImage image provider 2026-07-13 23:04:54 +08:00
134
5a16072a3d fix: avoid IPv6 retries for Zoo websockets 2026-07-13 21:08:06 +08:00
134
8e88f02214 fix: keep CAD stream aligned with viewport 2026-07-13 20:44:24 +08:00
134
a9390f0abc fix: keep generated CAD models in frame 2026-07-13 19:47:20 +08:00
134
43837e5048 feat: add canvas product position adjustment 2026-07-13 19:12:53 +08:00
134
8119219c15 backup: preserve verified canvas scale adjustment 2026-07-13 19:12:53 +08:00
134
752d1f99d2 refactor: remove legacy AI-CAD workbench 2026-07-13 19:00:52 +08:00
134
08d8a1d4af fix: version the complete HiCAD asset graph 2026-07-13 18:44:03 +08:00
134
beb6ff4f04 fix: enforce read-only Pascal agent requests 2026-07-13 18:43:19 +08:00
134
42bd2827a6 fix: version HiCAD entry assets 2026-07-13 18:38:46 +08:00
134
2484b5e5a4 fix: open HiCAD as top-level app 2026-07-13 18:21:49 +08:00
134
c6cabe5296 fix: keep Pascal agent running in background 2026-07-13 18:14:23 +08:00
134
59c57e7cf8 perf: precompress HiCAD frontend assets 2026-07-13 17:33:21 +08:00
134
6ca4239f16 fix: resolve Webpack aliases across Bun installs 2026-07-13 17:14:19 +08:00
134
ea27d83ea9 fix: dedupe React Three Fiber in Webpack build 2026-07-13 17:08:31 +08:00
134
00cbfce56d fix: translate HiCAD input and spaced copy 2026-07-13 17:07:30 +08:00
134
f29433a91b fix: prevent stale Pascal chunk failures 2026-07-13 16:49:05 +08:00
134
392c5b2a00 feat: complete HiCAD Chinese welcome copy 2026-07-13 16:46:06 +08:00
134
46e5135592 feat: localize and brand HiCAD workspace 2026-07-13 16:20:30 +08:00
134
ae76c45e27 fix: dismiss HiCAD loading state reliably 2026-07-13 15:55:22 +08:00
134
e308142afd fix: serve versioned HiCAD assets directly 2026-07-13 15:43:19 +08:00
134
96f2999d66 fix: resolve embedded HiCAD websocket URLs 2026-07-13 15:25:28 +08:00
134
663cb7c67a fix: resolve Pascal editor icons under base path 2026-07-13 15:17:53 +08:00
134
9cf84c324e feat: replace ai-cad with official HiCAD workspace 2026-07-13 14:56:27 +08:00
134
07ae16d0ed fix: resolve Pascal HDR assets under base path 2026-07-13 14:53:45 +08:00
134
62a6a4a7f0 fix: use WebGL fallback for Safari scenes 2026-07-13 14:31:23 +08:00
134
47067bf278 fix: serve Pascal static assets without session auth 2026-07-13 13:04:26 +08:00
134
cb47ebf568 fix: restore ai-cad model outputs 2026-07-13 12:00:53 +08:00
134
aedf590e1d fix: prevent Pascal chunk load failures 2026-07-13 11:42:04 +08:00
134
7ccbc87afd test: cover Pascal scene rebuild state 2026-07-13 11:05:52 +08:00
134
f832e08293 fix: restore Pascal 3D navigation and walls 2026-07-13 10:55:31 +08:00
134
603163d6ec fix: keep Pascal agent progress visible 2026-07-13 10:09:55 +08:00
134
e820df7eb6 fix: refresh Pascal scene interactions live 2026-07-13 03:57:43 +08:00
134
64dec38ff3 feat: persist Pascal agent conversations 2026-07-13 03:12:17 +08:00
134
cf1091fc2c fix: increase Pascal reasoning output budget 2026-07-12 10:49:18 +08:00
134
4f5dbb4589 fix: continue Pascal agents until a scene write 2026-07-12 10:43:33 +08:00
134
cc6c4b547c fix: bootstrap empty Pascal scenes for agents 2026-07-12 10:39:05 +08:00
134
16bfd0bd38 fix: constrain Pascal tools after initial inspection 2026-07-12 10:35:55 +08:00
134
ab657330ea fix: require Pascal design agents to mutate scenes 2026-07-12 10:32:20 +08:00
134
3d35858c05 fix: resolve Pascal toolbar icons under base path 2026-07-12 10:23:21 +08:00
134
4c1b97f210 fix: allow proxied Pascal scene creation 2026-07-12 08:48:25 +08:00
134
98dddbcc2e fix: keep Pascal agent bound to active scene 2026-07-12 01:13:45 +08:00
134
28c817429a fix: preserve completed Pascal agent streams 2026-07-12 01:08:45 +08:00
134
9efb9e8237 fix: persist Pascal agent scene after live updates 2026-07-12 01:04:23 +08:00
134
5260502507 feat: deploy Pascal from vendored source 2026-07-12 00:55:11 +08:00
134
40ff3ca851 Merge commit 'c3faeba1c8c1275c156c8739b50b8f0ca9425fd3' as 'services/pascal-editor' 2026-07-12 00:43:59 +08:00
134
c3faeba1c8 Squashed 'services/pascal-editor/' content from commit 312064e6
git-subtree-dir: services/pascal-editor
git-subtree-split: 312064e6e8122f958d2961e28ea85ad8b9db40c5
2026-07-12 00:43:59 +08:00
134
4bf6ec2b38 fix: install build dependencies during deployment 2026-07-12 00:26:10 +08:00
134
279121615b feat: add configurable scene sidebar entry 2026-07-12 00:22:45 +08:00
134
73e00b4d6a fix: default Pascal smoke base path 2026-07-11 23:14:03 +08:00
134
22069ada66 fix: skip incompatible Pascal model providers 2026-07-11 23:10:44 +08:00
134
7bb1f06b11 fix: route Pascal through multimodal model 2026-07-11 22:59:56 +08:00
134
af3d17f4a2 feat: add repeatable Pascal production deployment 2026-07-11 22:43:32 +08:00
134
12ae15ed3a feat: integrate authenticated Pascal service 2026-07-11 16:24:42 +08:00
134
5636b2789e fix: unify settings dark mode and layout 2026-07-10 22:20:28 +08:00
134
2ce64dd49b merge: integrate missing canvas and UI updates 2026-07-10 21:50:29 +08:00
134
363bd1d2fe fix: keep uploaded image attachments visible 2026-07-10 21:23:22 +08:00
134
748273d917 feat: defer generated image archiving 2026-07-10 20:27:09 +08:00
134
103ca18031 perf: trim canvas first-open bundles 2026-07-10 18:52:41 +08:00
134
78f14edbfc perf: lazy load canvas 3d renderers 2026-07-10 18:33:49 +08:00
134
6c83826e1d fix: improve analytics dark mode 2026-07-10 18:16:12 +08:00
134
b50d3eb295 perf: speed up canvas chat entry 2026-07-10 18:09:49 +08:00
134
0d67e2935c feat: update video canvas frontend workflow 2026-07-10 17:59:12 +08:00
134
988e1fefed fix: preserve canvas image context and compact overlays 2026-07-10 14:49:45 +08:00
134
c044ee2c40 fix: prevent local fallbacks in production 2026-07-10 14:10:40 +08:00
134
04c08ff256 fix: keep local test models out of production 2026-07-10 13:55:53 +08:00
134
eca1c70edb fix: route internal access checks over local HTTP 2026-07-10 13:31:56 +08:00
134
48134a1bbe merge: integrate canvas and UI refinements
# Conflicts:
#	app/(chat)/api/files/direct-upload/route.ts
#	app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
#	components/canvas/CanvasChatPanel.tsx
#	components/home/lovart-home.tsx
#	components/home/lovart/lovart-featured-gallery.tsx
#	components/home/lovart/lovart-hero-composer.tsx
#	components/home/lovart/lovart-projects-section.tsx
#	components/home/lovart/lovart-sidebar.tsx
#	components/layout/brand-top-bar.tsx
2026-07-10 13:18:21 +08:00
134
3252cbd2c8 feat: refine canvas workflows and unify application UI 2026-07-10 12:57:01 +08:00
Codex
c52cf9aa90 Apply canvas scale adjust rewrite 2026-07-09 17:25:40 +08:00
134
adbbe4442a Use canvas default for scale adjust model fallback 2026-07-09 15:42:33 +08:00
134
65f5a13f13 Rewrite canvas scale adjust local edit 2026-07-09 14:47:18 +08:00
root
8151e57661 Merge remote-tracking branch 'origin/main' into codex/scale-adjust-main-merge 2026-07-09 14:42:51 +08:00
134
a5995ca0c4 fix: keep image attachment add button visible 2026-07-09 14:41:51 +08:00
134
72b1f8f1cf fix: keep image attachment add button visible 2026-07-09 14:39:38 +08:00
134
c9ed662809 Fix canvas scale adjust local edit build 2026-07-09 12:00:42 +08:00
root
22f314f6ad Merge remote-tracking branch 'origin/main' into codex/scale-adjust-main-merge 2026-07-08 22:43:53 +08:00
134
2c318360b3 fix: avoid route refresh when switching claw history 2026-07-08 22:43:03 +08:00
134
accaaad8f1 fix: avoid route refresh when switching claw history 2026-07-08 22:42:41 +08:00
root
2398930ecb Merge remote-tracking branch 'origin/main' into codex/scale-adjust-main-merge 2026-07-08 22:16:02 +08:00
134
ea3ee3a5d7 feat: allow canceling claw submissions 2026-07-08 22:14:47 +08:00
134
e1f514536b feat: allow canceling claw submissions 2026-07-08 22:14:20 +08:00
134
b49519ec56 improve direct upload stall feedback 2026-07-08 21:28:43 +08:00
134
9575873efb speed up direct upload preparation 2026-07-08 21:18:01 +08:00
134
81a0b29633 timeout direct upload control requests 2026-07-08 21:08:02 +08:00
134
6fe69de8de fix: upload fal edit inputs to fal storage 2026-07-08 20:56:03 +08:00
134
2b875847dd close upload progress before image sizing 2026-07-08 20:54:00 +08:00
134
3aa031a4e4 fix: upload normalized source for fal scale edit 2026-07-08 20:41:19 +08:00
134
d00eb6d0e4 keep upload progress above canvas toolbar 2026-07-08 20:35:06 +08:00
134
ff71e3935c fix: reject scale adjust old footprint remnants 2026-07-08 20:33:10 +08:00
134
7ed0f08269 polish canvas toolbar and upload stall handling 2026-07-08 20:29:46 +08:00
134
05b21707c1 bypass proxy for direct storage images 2026-07-08 20:16:48 +08:00
134
7ac4a25a5f fix: honor requested fal scale adjust model 2026-07-08 20:13:36 +08:00
134
8acf1f60ec show canvas reference upload progress 2026-07-08 20:04:46 +08:00
134
66cdf6d3d1 fix safari direct upload feedback 2026-07-08 19:39:10 +08:00
134
91ce0ff5f8 show image upload progress 2026-07-08 19:26:45 +08:00
134
607491fc9e standardize direct storage uploads 2026-07-08 18:52:50 +08:00
134
fef8947e73 fix agent user-facing image edit preface 2026-07-08 15:52:21 +08:00
134
fd5b483bad fix agent user-facing image edit preface 2026-07-08 15:51:08 +08:00
134
778fc1c0b8 preserve colorful brand logos 2026-07-08 15:23:05 +08:00
134
2bbdd0aa61 preserve colorful brand logos 2026-07-08 15:22:17 +08:00
134
d48e26fe9a allow agent multi image generation 2026-07-08 14:48:29 +08:00
134
77531aa7b5 improve agent planning brief 2026-07-08 14:48:29 +08:00
134
599abc5021 allow agent multi image generation 2026-07-08 14:37:07 +08:00
134
e0c277c76a improve agent planning brief 2026-07-08 14:23:38 +08:00
134
d69346de42 fix: keep product out of scale repair crop prompt 2026-07-08 01:38:51 +08:00
134
2cb6f51a63 fix: repair product locked scale adjust in crop workspace 2026-07-08 01:23:46 +08:00
134
8fad43c597 fix: refine scale adjust product lock repair mask 2026-07-08 01:11:14 +08:00
134
0add599427 fix: use alpha channel for product lock repair mask 2026-07-08 00:55:36 +08:00
134
e67aab87c5 fix: rebase product lock scale composite on source 2026-07-08 00:37:28 +08:00
134
67310e9ae5 fix: timeout scale adjust prep uploads 2026-07-08 00:25:48 +08:00
134
7b73a9c7f5 fix: constrain product lock scale repair to source footprint 2026-07-08 00:09:11 +08:00
134
17f5485522 fix: harden scale adjust product lock uploads 2026-07-07 23:40:34 +08:00
134
30eef41dc4 fix: restore longer s3 upload timeout 2026-07-07 23:09:38 +08:00
134
0405113d52 fix: stabilize scale adjust fal timeout and uploads 2026-07-07 22:53:09 +08:00
134
134411f7f8 perf: improve home and canvas initial load 2026-07-07 22:29:04 +08:00
root
6b25171ce1 Merge remote-tracking branch 'origin/main' into codex/scale-adjust-main-merge 2026-07-07 22:28:09 +08:00
134
6c89df0f8b feat: add global claw chat history 2026-07-07 22:26:47 +08:00
134
ebfc7589a9 feat: add global claw chat history 2026-07-07 22:26:04 +08:00
134
6bc43ffc3f perf: improve home and canvas initial load 2026-07-07 22:16:07 +08:00
134
f692425607 fix: persist claw chat sessions 2026-07-07 22:14:06 +08:00
134
a03ea2cb47 fix: persist claw chat sessions 2026-07-07 22:13:39 +08:00
134
16f23b3f7c fix canvas cutout intent routing 2026-07-07 21:39:56 +08:00
134
51283b81fb fix canvas cutout intent routing 2026-07-07 21:38:24 +08:00
134
dab44f3825 fix canvas agent duplicate user message 2026-07-07 21:21:46 +08:00
134
e2e373ce08 fix canvas agent duplicate user message 2026-07-07 21:21:12 +08:00
134
574c259812 fix canvas agent image generation status 2026-07-07 21:08:18 +08:00
134
4ac4ac26cf fix canvas agent image generation status 2026-07-07 21:07:18 +08:00
134
654829c4fd fix: restore canvas pending feedback and scoped capabilities 2026-07-07 20:36:52 +08:00
134
5923149dd6 fix: restore canvas pending feedback and scoped capabilities 2026-07-07 20:36:18 +08:00
134
f7676716bb fix: keep canvas user message visible while pending 2026-07-07 16:55:29 +08:00
134
2e4cb0cf73 fix: keep canvas user message visible while pending 2026-07-07 16:54:43 +08:00
134
b17779e034 fix: stabilize recent dialogue memory typing 2026-07-07 16:33:21 +08:00
134
8c719c44ac fix: stabilize recent dialogue memory typing 2026-07-07 16:33:03 +08:00
134
c17de9def5 fix: remove agent progress bubble and add dialogue memory 2026-07-07 16:30:56 +08:00
134
8c159a3828 fix: remove agent progress bubble and add dialogue memory 2026-07-07 16:30:03 +08:00
134
6a2b928080 fix: resolve unselected canvas image targets 2026-07-07 16:03:15 +08:00
134
4b9f3a25c9 fix: resolve unselected canvas image targets 2026-07-07 16:02:33 +08:00
134
eab3f0372f feat: add canvas agent progress timeline 2026-07-07 15:43:44 +08:00
134
f88ada378e feat: add canvas agent progress timeline 2026-07-07 15:42:54 +08:00
134
8f679a100c feat: add canvas agent orchestration kernel 2026-07-07 15:19:45 +08:00
134
610d2aed9b feat: add canvas agent orchestration kernel 2026-07-07 15:17:43 +08:00
134
7e1fd15ebd fix: stabilize canvas agent chat feedback 2026-07-07 14:55:24 +08:00
134
15f9e1910a fix: stabilize canvas agent chat feedback 2026-07-07 14:54:58 +08:00
134
2d6289f0fa fix: add agent preface before generation 2026-07-07 14:39:02 +08:00
134
ee7d104734 fix: add agent preface before generation 2026-07-07 14:38:26 +08:00
134
1781977486 fix: keep canvas chat messages ordered 2026-07-07 14:28:36 +08:00
134
57a4840df2 fix: keep canvas chat messages ordered 2026-07-07 14:27:48 +08:00
134
690d04ec71 feat: add downloads for style pro and ai wash results 2026-07-07 14:16:09 +08:00
134
77174f4e21 feat: add downloads for style pro and ai wash results 2026-07-07 14:12:18 +08:00
134
819bb92488 fix: use product lock draft as fal repair input 2026-07-06 06:12:27 +08:00
134
d94f6dea8c fix: send opaque scale adjust masks to fal 2026-07-06 05:54:52 +08:00
134
aad838a8e0 fix: guide product lock background repair with tight mask 2026-07-06 05:29:57 +08:00
134
00a6ac08df fix: tighten product lock scale adjust clip mask 2026-07-06 05:11:23 +08:00
134
fb9f0541fa fix: restrict scale adjust mask failover 2026-07-06 04:49:56 +08:00
134
831ce10a5b fix: use mask edit for product lock scale adjust 2026-07-06 04:28:56 +08:00
134
c79da3e1d0 fix: preserve single-channel scale adjust masks after blur 2026-07-06 03:41:56 +08:00
134
7640e6b5ca fix: clean product lock masks before scale adjust overlay 2026-07-06 03:22:59 +08:00
134
6694846c58 fix: harden scale adjust person occlusion masks 2026-07-06 03:00:28 +08:00
134
f7a5accb61 fix: protect people in product locked scale adjust 2026-07-06 02:29:49 +08:00
134
f6faf6bb44 fix: constrain product lock scale adjust to source patch 2026-07-06 01:30:01 +08:00
134
26aa4fb12f fix: lock scale adjust product over repaired background 2026-07-06 01:08:37 +08:00
134
deee79c5c2 fix: avoid compositing dirty full-frame scale drafts 2026-07-05 22:53:11 +08:00
134
fa5212a33e fix: keep furniture scale drafts mask based 2026-07-05 22:25:56 +08:00
134
14e75197cf fix: composite full-frame scale edits 2026-07-05 21:52:48 +08:00
134
c3d8c5d56c fix: keep scale guide source-first 2026-07-05 14:44:34 +08:00
134
c0f376c4cf fix: erase old footprint in scale guide 2026-07-05 14:23:41 +08:00
134
28080924f8 fix: use source plate for scale target guide 2026-07-05 14:07:49 +08:00
134
0f27bf1b10 fix: clear protected side from scale drafts 2026-07-05 13:34:54 +08:00
134
acdc197b7e fix: fall back when scale reference upload fails 2026-07-05 13:17:03 +08:00
134
20693db8b9 fix: aggressively clear protected draft dark residue 2026-07-05 12:52:44 +08:00
134
729ccec2f8 fix: clear dark artifacts from protected scale drafts 2026-07-05 12:39:07 +08:00
134
5262b085c2 fix: suppress protected-side scale draft artifacts 2026-07-05 12:26:47 +08:00
134
f16c54c3a9 fix: avoid protected person mask rectangle fill 2026-07-05 11:53:40 +08:00
134
4ab60cc7a3 fix: ignore protected person direction in scale drafts 2026-07-05 11:42:50 +08:00
134
c9b6df55b0 fix: reduce scale adjust reference uploads 2026-07-05 11:19:24 +08:00
134
a3a69fed43 fix: use full frame draft for furniture scale adjust 2026-07-05 11:09:07 +08:00
134
dde159ea7c fix: tighten scale adjust patch compositing 2026-07-04 22:06:35 +08:00
134
290f36b44e fix: timeout stalled canvas scale providers 2026-07-04 21:48:09 +08:00
134
8e2c6fea1b fix: timeout stalled s3 uploads 2026-07-04 20:20:21 +08:00
134
4d4507489f fix: use fal edit as final scale adjust failover 2026-07-04 20:05:31 +08:00
134
d724a542bb fix: avoid failed providers during canvas image failover 2026-07-04 19:33:18 +08:00
134
2ae5ceb234 fix: preserve furniture component when clipping protected people 2026-07-04 19:18:23 +08:00
134
19c4367fb8 fix: clip protected person pixels without dropping scale target 2026-07-04 19:05:52 +08:00
134
fa7d9d81ff fix: exclude protected people from furniture scale masks 2026-07-04 18:58:30 +08:00
134
970bd89638 fix: allow scale adjust provider failover 2026-07-04 18:38:27 +08:00
134
739dc091ab fix: prefer pixel structure for furniture scale edits 2026-07-04 18:25:11 +08:00
134
50a59b82af fix: clip scale targets by subject alpha 2026-07-04 18:08:52 +08:00
134
9ee2d2b50b fix: clip scale adjust patch composites 2026-07-04 17:13:39 +08:00
134
40fa7b1e26 fix: filter scale adjust haze in patch composites 2026-07-04 16:46:04 +08:00
134
1a60671255 fix: retry image downloads for scale composites 2026-07-04 16:21:46 +08:00
134
28d73fd042 fix: erase old scale footprint in structure drafts 2026-07-04 15:58:43 +08:00
134
fb11a4becd fix: stabilize snapgen reference downloads 2026-07-04 15:38:51 +08:00
134
fec71038c5 fix: harden scale adjust patch workflow 2026-07-04 15:26:31 +08:00
134
f483fab33a fix: force shrink scale edits through locked patch 2026-07-04 14:11:49 +08:00
134
38961de760 fix: simplify fal scale patch prompt 2026-07-04 13:58:41 +08:00
134
56d319af66 fix: force fal scale shrink through locked patch workflow 2026-07-04 13:43:55 +08:00
134
77b25662b4 fix: send layout drafts to fal scale edits 2026-07-04 13:40:02 +08:00
134
83e911f698 fix: stabilize scale adjust subject resizing
# Conflicts:
#	app/api/canvas/local-edit/route.ts
#	lib/canvas/generate-image.ts
2026-07-04 13:26:58 +08:00
134
2a8b303508 fix: route scale shrink through fal edit fallback 2026-07-04 13:23:05 +08:00
134
2e167d38f0 fix: prefer grsai for scale shrink patch 2026-07-04 13:05:25 +08:00
134
254bd08345 fix: avoid degraded mask models for scale shrink 2026-07-04 12:48:48 +08:00
134
87f85e78c5 fix: prefer mask edit for scale shrink 2026-07-04 12:36:32 +08:00
134
04f440574b fix: use cutout drafts for scale shrink 2026-07-04 12:21:17 +08:00
134
392a03a2ed fix: strengthen scale shrink erase guide 2026-07-04 12:11:59 +08:00
134
ae176cba44 fix: timeout remote image downloads 2026-07-04 12:02:29 +08:00
134
d8e2d65186 fix: soften scale shrink layout wipe 2026-07-04 11:47:52 +08:00
134
360cba0873 fix: avoid false seam rejection on scale shrink 2026-07-04 11:26:29 +08:00
134
6baeb57cde fix: accept visual shrink scale edits 2026-07-04 11:06:21 +08:00
134
48a0f2ee73 fix: use structure locked drafts for scale shrink 2026-07-04 10:44:45 +08:00
134
53be242e89 fix: clamp scale patch alpha to edit regions 2026-07-03 23:19:17 +08:00
134
b9e1671777 fix: apply scale diff masks as alpha 2026-07-03 22:44:18 +08:00
134
b4db755c45 fix: composite scale patches with diff masks 2026-07-03 22:33:27 +08:00
134
026164f86d fix: ignore top background sheets in scale masks 2026-07-03 22:07:46 +08:00
134
bb78cb5d4a fix: disable striped rmbg scale drafts by default 2026-07-03 22:01:35 +08:00
134
88878c3307 fix: refine rmbg alpha for scale drafts 2026-07-03 21:57:26 +08:00
134
5ec43fa8a3 fix: reject banded rmbg scale drafts 2026-07-03 21:50:34 +08:00
134
01e17361c8 fix: avoid rectangular region composite for scale edits 2026-07-03 21:20:53 +08:00
134
0a591456c4 fix: limit scale patch composite to edit regions 2026-07-03 20:58:30 +08:00
134
5a4e087b3d fix: preserve source crop for structure scale edits 2026-07-03 20:35:52 +08:00
134
8ecece64e1 fix: filter cutout matte artifacts 2026-07-03 20:20:54 +08:00
134
2875b02d2a fix: denoise cutout alpha masks 2026-07-03 20:10:52 +08:00
134
27b0098bf9 fix: clean cutout alpha for scale drafts 2026-07-03 20:02:00 +08:00
134
ea52d65971 fix: use cutout drafts for scale adjustment 2026-07-03 19:47:54 +08:00
134
280c356ed2 Revert "fix: allow scale adjust target people"
This reverts commit 4db7cc9ada5b7365f41c746d9847f449ddcc193d.
2026-07-03 18:23:10 +08:00
134
ce4e056f20 fix: improve structure locked scale drafts 2026-07-03 18:12:01 +08:00
134
4db7cc9ada fix: allow scale adjust target people 2026-07-03 17:48:03 +08:00
134
d3cfb4a6b7 fix: allow scale adjust target people 2026-07-03 17:47:34 +08:00
134
dc499d0d6f fix: expose detail scale adjust entry 2026-07-03 17:26:21 +08:00
134
7e98f42462 fix: expose detail scale adjust entry 2026-07-03 17:26:00 +08:00
134
d058ff32e6 fix: support scale adjust provider mask params 2026-07-03 14:04:49 +08:00
134
84d843a8a4 fix: lock scale adjust enlargement structure 2026-07-03 13:59:14 +08:00
134
cf85caeb5a fix: lock scale adjust enlargement structure 2026-07-03 13:55:53 +08:00
134
39ee88bd12 fix: keep enlargement patch single-reference by default 2026-07-03 10:53:40 +08:00
134
97720a55e5 fix: honor requested model for scale enlargement 2026-07-03 10:38:04 +08:00
134
e0e57c6865 fix: preserve people during scale enlargement 2026-07-03 10:24:32 +08:00
134
18035d4272 fix: reorder skill tags while dragging 2026-07-03 00:42:09 +08:00
134
4f5a986162 fix: reorder skill tags while dragging 2026-07-03 00:41:38 +08:00
134
273379b18d fix: keep agent text image routing safe 2026-07-03 00:37:25 +08:00
134
ec1bdb7396 fix: keep agent text image routing safe 2026-07-03 00:36:54 +08:00
134
c698901509 feat: persist claw skill tag order 2026-07-03 00:19:27 +08:00
134
469ae7b02d feat: persist claw skill tag order 2026-07-03 00:18:52 +08:00
134
b21b4cdea0 fix: constrain claw table images 2026-07-03 00:02:31 +08:00
134
c789dd7a69 fix: constrain claw table images 2026-07-03 00:01:47 +08:00
134
2ca0c54de2 fix: accept visible scale shrink footprints 2026-07-02 23:04:43 +08:00
134
8c0d4a0930 fix: preload canvas project on route load 2026-07-02 22:41:02 +08:00
134
c4e4981a24 fix: preload canvas project on route load 2026-07-02 22:40:19 +08:00
134
c38003a382 fix: allow scale shrink footprint tolerance 2026-07-02 22:40:04 +08:00
134
0bab5d1c8e fix: prefer gpt image model for scale adjust 2026-07-02 22:30:12 +08:00
134
da7fbb1284 fix: send only target draft for scale patch 2026-07-02 22:12:18 +08:00
134
0904fdf98a fix: avoid source crop reversion in scale adjust 2026-07-02 22:01:03 +08:00
134
d1231365d0 fix: refine claw markdown composer display 2026-07-02 21:59:41 +08:00
134
71fd765ebc fix: refine claw markdown composer display 2026-07-02 21:59:12 +08:00
134
ed63eb77b7 fix: prioritize scale adjust target draft 2026-07-02 21:46:11 +08:00
134
90a9edcd56 fix: edit scale adjust through ai patch workspace 2026-07-02 21:31:32 +08:00
134
89fca5d654 fix: avoid visual terms in alibaba scale prompt 2026-07-02 21:06:20 +08:00
134
11e6aca724 fix: use text coordinate guide for alibaba scale adjust 2026-07-02 20:57:00 +08:00
134
56240d4a6d fix: use marked guide for alibaba scale adjust 2026-07-02 20:44:14 +08:00
134
7c8ae2fe8b fix: honor requested scale adjust model 2026-07-02 20:31:13 +08:00
134
2f312a01df fix: support mask guided scale adjust 2026-07-02 20:20:01 +08:00
134
7152435a3d fix: add focused crop reference for scale adjust 2026-07-02 19:54:33 +08:00
134
98e987e75b fix: focus scale adjust subject guide 2026-07-02 19:38:24 +08:00
134
956f9e0fed fix: guide scale adjust with layout draft 2026-07-02 18:59:01 +08:00
134
1ae9b5a89b fix: verify scale adjust footprint shrink 2026-07-02 18:40:09 +08:00
134
0515fd3e46 fix: add deterministic scale adjust fallback 2026-07-02 18:27:59 +08:00
134
87d2960713 fix: include scale adjust region prompt 2026-07-02 18:14:51 +08:00
134
a8c5c14656 fix: use marked photo guide for scale adjust models 2026-07-02 18:04:51 +08:00
134
ee403bff11 fix: simplify fal scale adjust guide marks 2026-07-02 17:55:57 +08:00
134
2cccac34ff fix: guide fal scale adjust with marked photo 2026-07-02 17:49:03 +08:00
134
291597a69f fix: use fal-safe scale adjust prompt 2026-07-02 17:36:43 +08:00
134
f5a318beef fix: neutralize scale adjust coordinate prompt 2026-07-02 17:25:47 +08:00
134
b3b4fa8ed2 fix: enlarge scale adjust coordinate map 2026-07-02 17:14:06 +08:00
134
ffd13c0f31 fix: keep scale adjust single model attempt 2026-07-02 17:01:43 +08:00
134
6f0fb9fe4b fix: use coordinate map for scale adjust 2026-07-02 16:56:25 +08:00
134
22b007c2d9 fix: avoid old crop reference for scale adjust 2026-07-02 16:44:09 +08:00
134
2ad8bc0073 fix: strengthen scale adjust semantic guide 2026-07-02 16:26:05 +08:00
134
fb4ef202d8 fix: respect requested scale adjust provider 2026-07-02 16:03:29 +08:00
134
354fa98477 fix: strengthen scale adjust target hint 2026-07-02 15:53:42 +08:00
134
183d47fba0 chore: log rejected scale adjust previews 2026-07-02 15:44:57 +08:00
134
ba1bb165c3 fix: hint old footprint for scale adjust 2026-07-02 15:38:29 +08:00
134
cd19fe0b8d fix: reduce scale adjust guide recompose bias 2026-07-02 15:20:58 +08:00
134
62e3bc4611 fix: constrain scale adjust full-frame guide 2026-07-02 15:06:30 +08:00
134
2241e0f27e fix: guide scale adjust with visual target layout 2026-07-02 14:48:51 +08:00
134
08b439b7f6 fix: stop scale adjust quality failover 2026-07-02 13:24:43 +08:00
134
bf8197d884 fix: avoid extra scale adjust retry cost 2026-07-02 11:59:50 +08:00
134
c0ddd420ba fix: retry weak scale adjust edits with stronger prompt 2026-07-02 11:33:39 +08:00
134
0ec99e8ffb fix: constrain scale adjust failover family 2026-07-02 10:34:41 +08:00
134
d44a5d4060 fix: soft failover weak scale adjust outputs 2026-07-02 00:28:51 +08:00
134
8fd93bfd29 fix: keep fal as scale adjust fallback 2026-07-02 00:15:45 +08:00
134
0c919e851a fix: prefer low-cost scale adjust providers 2026-07-02 00:11:25 +08:00
134
f5a409fdde fix: tune fal scale adjust prompt 2026-07-01 23:41:34 +08:00
134
69f2f7957d feat: support claw quick reply actions 2026-07-01 19:05:10 +08:00
134
e7f3df1f5d fix: strengthen fal scale adjust guide 2026-07-01 18:56:06 +08:00
134
6e5c608df8 fix: clarify fal scale adjust targets 2026-07-01 18:22:32 +08:00
134
e5ddc416e9 fix: stabilize fal scale adjust guidance 2026-07-01 18:06:56 +08:00
134
672139e35a fix: align ai wash compare frame 2026-07-01 01:04:34 +08:00
134
91fe35e3b6 fix: stabilize ai wash model handling 2026-07-01 00:30:56 +08:00
134
7aca7a764d feat: add self-hosted pascal editor and 3d modeling entry 2026-06-30 19:57:52 +08:00
134
b87b44c7b4 fix: align image comparison sliders 2026-06-30 17:27:17 +08:00
134
e7cd3d6e73 fix: recognize BFL reference image support 2026-06-30 14:38:37 +08:00
134
d001dba00c fix: clarify scale adjust guide and retry failed FAL edits 2026-06-30 10:32:46 +08:00
134
d9f63bd00c fix: pass FAL scale adjust system prompt 2026-06-29 20:08:31 +08:00
134
5f5c19f313 fix: preserve claw skill tag settings on upgrades 2026-06-29 18:51:06 +08:00
134
0a792c7e26 fix: simplify FAL scale adjust inputs 2026-06-29 18:09:22 +08:00
134
8a9fba7eab fix: keep clawd submitted text out of composer restore 2026-06-29 18:07:08 +08:00
134
02772bc343 fix: preserve custom canvas skill tags on upgrade 2026-06-29 17:30:50 +08:00
134
164d9f97e7 fix: remove FAL scale adjust mockup guide 2026-06-29 17:03:43 +08:00
134
12c93e1154 feat: update canvas skills and project list rename 2026-06-29 15:59:01 +08:00
134
16a6134c77 fix: improve single-image scale adjustment 2026-06-29 14:38:49 +08:00
134
8ded477edd fix: avoid rejecting final scale adjust candidate 2026-06-29 11:24:27 +08:00
134
be0b398e5c fix: prefer cleaner FAL guide retry 2026-06-28 15:06:17 +08:00
134
d37cb6cffc fix: show claw image upload progress 2026-06-28 14:47:05 +08:00
134
36d575b825 fix: respect FAL edit image count limit 2026-06-28 14:16:36 +08:00
134
471eb3f650 fix: retry FAL scale adjust with guide prompts 2026-06-28 14:05:19 +08:00
134
d519ac3dde fix: keep FAL scale adjust on fixed model 2026-06-28 10:51:29 +08:00
134
3b00c37d30 fix: animate claw media responses 2026-06-28 10:50:02 +08:00
134
619cc2bf5b fix: clarify FAL scale adjust guidance 2026-06-28 10:44:54 +08:00
134
1d0af8c2d8 fix: prioritize selected claw shortcut 2026-06-28 10:35:14 +08:00
134
e76c983e64 fix: replace claw skill tags and isolate shortcut sessions 2026-06-28 10:20:53 +08:00
134
98eef59d97 fix: keep FAL scale adjust candidates 2026-06-26 23:20:27 +08:00
134
e3f5b9720e feat: refine canvas shortcuts and image candidate selection 2026-06-26 19:24:53 +08:00
134
d8af4bb48d feat: add Claw skill shortcuts and isolate mode chats 2026-06-26 18:18:43 +08:00
134
80f6827375 feat: improve Claw shortcut mode and toolbar docking 2026-06-26 17:27:59 +08:00
134
045e36b242 fix: allow public font assets 2026-06-25 23:21:58 +08:00
134
4d6e218e98 feat: use JetBrains Mono sitewide 2026-06-25 23:16:03 +08:00
134
95354eb78d fix: allow table links in chat responses 2026-06-25 22:58:35 +08:00
134
b91c40f6df fix: split wide Claw PDF tables 2026-06-25 22:49:12 +08:00
134
934bc56202 fix: improve Claw PDF table print layout 2026-06-25 21:53:19 +08:00
134
e02dff0b06 fix: guide FAL scale adjust with target mockup 2026-06-25 19:45:33 +08:00
134
186e2cefc2 fix: include user department in Claw payload 2026-06-25 14:14:35 +08:00
134
0100f58891 fix: include username in Claw chat payload 2026-06-25 13:05:25 +08:00
134
acaae5fd1b fix: reduce scale adjust guide artifacts 2026-06-25 00:05:52 +08:00
134
270c9fb61d fix: keep Claw composer cleared while submitting 2026-06-24 22:24:59 +08:00
134
948bed1737 fix: harden canvas edit and save flows 2026-06-24 21:52:45 +08:00
134
28c5965c52 fix: handle SnapGen image task failures 2026-06-24 21:51:12 +08:00
134
4de04234d0 fix: persist Claw composer clear guard 2026-06-24 21:44:22 +08:00
134
754209ee53 fix: clear restored Claw composer text 2026-06-24 21:18:39 +08:00
134
6f8c939be2 Tighten multi-image reference handling 2026-06-24 20:12:24 +08:00
134
f0e17dfffe improve current upload target resolution 2026-06-24 20:08:24 +08:00
134
c7e4553c45 fix canvas chat exports and image upload state 2026-06-24 20:03:25 +08:00
134
d10d638119 Refine multi-image target resolution 2026-06-24 19:02:20 +08:00
134
cc80bd5dac Add multi-image reference context resolution 2026-06-24 18:58:38 +08:00
134
cd90c8074c Fix report PDF placement and table scrolling 2026-06-24 18:51:26 +08:00
134
57f4d08c31 Add Claw report PDF export 2026-06-24 18:33:47 +08:00
134
05387d08f3 Update canvas generation UI and downloads 2026-06-24 17:59:10 +08:00
134
41522cc481 Fix canvas toolbar tone and placeholder scaling 2026-06-24 17:59:10 +08:00
134
1aad39b588 fix: keep SnapGen reference order prompt neutral 2026-06-24 17:59:10 +08:00
134
560491aabb fix: preserve SnapGen reference image order 2026-06-24 17:59:10 +08:00
134
91a855c041 fix: improve scale adjust annotation guide 2026-06-24 17:58:41 +08:00
134
07d081f838 chore: log image input counts for failover 2026-06-24 16:39:49 +08:00
134
5973359197 fix: preserve SnapGen resolution tiers 2026-06-24 16:15:50 +08:00
134
dcd005d369 fix: infer SnapGen auto aspect ratio 2026-06-24 11:35:41 +08:00
134
ed2c64d2a6 fix: poll SnapGen history for final images 2026-06-24 11:29:46 +08:00
134
9b94aae898 fix: drive image params from model config 2026-06-24 11:12:11 +08:00
134
7a58cdd12b fix: improve admin dialog dark mode 2026-06-24 11:08:03 +08:00
134
ddfe911812 feat: add SnapGen image provider 2026-06-24 10:58:41 +08:00
134
1af8a29b58 feat: render OpenClaw chart cards 2026-06-24 10:06:12 +08:00
134
fa10115043 fix: rename Claw mode and type responses 2026-06-24 01:37:04 +08:00
134
d5fed4f2da fix: enable markdown table horizontal scroll 2026-06-24 00:59:29 +08:00
134
fb4915e657 fix: show Clawd processing hint 2026-06-24 00:50:45 +08:00
134
47f996d81a fix: soften Clawd typing dots 2026-06-24 00:42:30 +08:00
134
514dcbb21f fix: restore Clawd composer font size 2026-06-24 00:39:08 +08:00
134
cd8f23e4b3 fix: unify Clawd attachment picker 2026-06-24 00:30:55 +08:00
134
16f734e9c5 feat: support Clawd spreadsheet uploads 2026-06-24 00:20:39 +08:00
134
deacac6e8f feat: route canvas agent local edits 2026-06-24 00:17:03 +08:00
134
96827c760c feat: route canvas agent angle variants 2026-06-23 23:59:24 +08:00
134
4588850572 fix: cap markdown heading sizes 2026-06-23 23:45:52 +08:00
134
0c95829b1e feat: batch direct canvas image edits 2026-06-23 23:42:45 +08:00
134
0afa404b19 feat: route canvas agent watermark removal 2026-06-23 23:31:43 +08:00
134
9151058d79 fix: keep background removal out of canvas deletion 2026-06-23 23:20:26 +08:00
134
d4936a8ca4 fix: isolate canvas mode switches 2026-06-23 23:15:41 +08:00
134
3f8219f415 feat: route canvas agent link operations 2026-06-23 23:11:20 +08:00
134
f2ed508cb4 fix: persist canvas chat session selection 2026-06-23 23:07:47 +08:00
134
8a2483ad7d feat: route canvas agent text updates 2026-06-23 23:04:43 +08:00
134
67dbeeab1a fix: improve Clawd pending and markdown tables 2026-06-23 22:59:03 +08:00
134
cd0037dc47 fix: consume canvas agent pending requests 2026-06-23 22:55:55 +08:00
134
d5ac630d01 fix: tighten canvas operation protocol 2026-06-23 22:49:06 +08:00
134
64ba6a6f29 fix: sync canvas save revisions 2026-06-23 22:43:22 +08:00
134
c82223f8f4 feat: add markdown table download 2026-06-23 22:41:17 +08:00
134
3524a4ea16 fix: guide scale adjust with target annotations 2026-06-23 22:36:38 +08:00
134
23b6855b4b fix: align canvas media batch ids 2026-06-23 22:33:00 +08:00
134
eec59f51fc fix: shorten Clawd labels 2026-06-23 22:24:44 +08:00
134
8c6bd2ad03 fix: preserve selected canvas slot context 2026-06-23 22:22:05 +08:00
134
d548e31063 fix: honor canvas agent view rollout on client 2026-06-23 22:08:01 +08:00
134
d434485987 fix: clear Clawd input on send 2026-06-23 22:05:47 +08:00
134
43e01e7613 feat: expose canvas agent rollout controls 2026-06-23 21:56:05 +08:00
134
fae9d32ffb feat: allow canvas agent rollout settings 2026-06-23 21:43:49 +08:00
134
020a74a6a8 fix: support confirmed slot replacement fills 2026-06-23 21:33:30 +08:00
134
652bc75ed0 fix: enforce canvas agent slot media types 2026-06-23 21:26:02 +08:00
134
5d5e10c83e test: cover canvas save revision fallback 2026-06-23 21:14:00 +08:00
134
7c2e31cbe5 fix: reject mismatched canvas agent project ids 2026-06-23 21:06:55 +08:00
134
ac0456eb97 fix: bypass image billing for canvas view intents 2026-06-23 20:52:42 +08:00
134
1669f4e2a4 fix: verify scale adjust after compositing 2026-06-23 20:51:18 +08:00
134
f23fae7676 fix: preserve canvas context for slot controls 2026-06-23 20:33:58 +08:00
134
d9bb014742 feat: add canvas Clawd mode 2026-06-23 20:23:26 +08:00
134
a752737327 fix: suppress image loading for canvas control prompts 2026-06-23 20:13:35 +08:00
134
d93a0d06d5 fix: widen scale adjust workspace guard 2026-06-23 20:10:45 +08:00
134
89b4a82e62 fix: support canvas view prompts without context payload 2026-06-23 20:07:53 +08:00
134
8913daa2b0 fix: route canvas control prompts before image billing 2026-06-23 20:01:32 +08:00
134
76229dac7b fix: let canvas control prompts bypass image point gate 2026-06-23 19:53:06 +08:00
134
3760f7ff44 fix: honor target boxes in scale adjust guard 2026-06-23 19:47:11 +08:00
134
4d65ba0b79 fix: include canvas revisions in service saves 2026-06-23 19:34:43 +08:00
134
733d6338c2 test: cover confirmed canvas agent moves 2026-06-23 19:28:51 +08:00
134
6d66d126c8 test: cover confirmed canvas agent deletes 2026-06-23 19:24:28 +08:00
134
6d5604a84b fix: filter handled canvas media batches 2026-06-23 19:17:13 +08:00
134
cb44b2237f fix: gate canvas agent view operations on client 2026-06-23 19:09:07 +08:00
134
2e386cb931 fix: defer canvas agent view during interactions 2026-06-23 19:03:47 +08:00
134
a1a095fe0e fix: animate canvas agent viewport changes 2026-06-23 18:56:49 +08:00
134
0f74cbd6ff fix: infer canvas agent image counts 2026-06-23 18:51:21 +08:00
134
c15a68385a fix: reset canvas add image dialog state 2026-06-23 18:41:23 +08:00
134
97563450a6 fix: frame canvas agent source and result 2026-06-23 18:40:07 +08:00
134
d3c921a489 fix: distinguish annotation edits from annotation notes 2026-06-23 18:33:35 +08:00
134
e0dc5a3d77 fix: route explicit canvas annotations 2026-06-23 18:28:53 +08:00
134
cf879c63b6 fix: route explicit canvas slot creation 2026-06-23 18:24:38 +08:00
134
06834699be fix: route explicit canvas agent operations 2026-06-23 18:15:08 +08:00
134
6e1d512021 fix: focus latest canvas agent result by prompt 2026-06-23 18:00:18 +08:00
134
b7ff4e68eb fix: centralize canvas agent pending requests 2026-06-23 17:52:00 +08:00
134
914e09af73 fix: show scale regions in detail dialog sidebar 2026-06-23 17:38:29 +08:00
134
c033e75ba1 fix: open chat for annotation edits 2026-06-23 17:34:35 +08:00
134
5648ad12be fix: open chat when filling agent slots 2026-06-23 17:27:35 +08:00
134
1d7c4816b0 fix: persist canvas agent media provenance 2026-06-23 17:17:13 +08:00
134
9e6cf69b45 fix: treat canvas view centering as focus intent 2026-06-23 17:10:44 +08:00
134
75cfa4c00f fix: drive scale adjust from target box guides 2026-06-23 17:02:48 +08:00
134
b78c77a790 feat: add canvas material image search 2026-06-23 16:55:22 +08:00
134
3a2a7f2490 feat: let canvas agent create text nodes 2026-06-23 16:54:18 +08:00
134
1e41335ebb fix: center canvas agent writebacks without anchors 2026-06-23 16:44:34 +08:00
134
38dba2b1bc feat: let canvas agent update image notes 2026-06-23 16:34:52 +08:00
134
e319b16f48 feat: let canvas agent rename nodes 2026-06-23 16:27:06 +08:00
134
763696e8d0 fix: trace annotation edits in canvas writeback metadata 2026-06-23 16:14:27 +08:00
134
ef0f9bc93d fix: anchor canvas writeback to resolved targets 2026-06-23 16:02:41 +08:00
134
e73c2da84e fix: add canvas agent pan view intent 2026-06-23 15:55:10 +08:00
134
38435f9401 fix: reduce canvas agent media duplicate window 2026-06-23 15:37:26 +08:00
134
50aa93d5f3 fix: route clear canvas requests through confirmation 2026-06-23 15:25:02 +08:00
134
0bb134d3b8 fix: include all visible canvas nodes in agent fit-all view 2026-06-23 15:18:54 +08:00
134
8ee7eb1f0a fix: keep annotation edit target in canvas agent context 2026-06-23 15:09:50 +08:00
134
c0550988e0 fix: keep canvas annotation operations idempotent 2026-06-23 14:49:37 +08:00
134
909f1c506c refactor: extract canvas agent writeback planning 2026-06-23 14:43:14 +08:00
134
16ebbeb6ac fix: allow scoped canvas chat history empty state 2026-06-23 14:36:08 +08:00
134
de457b5dc3 feat: pass canvas slot context to agent planner 2026-06-23 14:03:02 +08:00
134
ac16dc5e64 fix: reject rapid stale canvas autosaves 2026-06-23 13:35:54 +08:00
134
7f3ce72468 test: cover stale canvas autosave conflicts 2026-06-23 13:21:31 +08:00
134
084a6225e7 test: cover canvas agent slot creation mutation 2026-06-22 22:49:25 +08:00
134
183f07be25 fix: scope pending canvas view retry to project 2026-06-22 22:45:03 +08:00
134
dfb8971bcb fix: retry canvas agent view after refresh 2026-06-22 22:42:13 +08:00
134
41acb006cf test: cover canvas agent operation route handling 2026-06-22 22:38:00 +08:00
134
2945528f44 feat: support canvas comparison view intent 2026-06-22 22:33:05 +08:00
134
ad6b09d1b2 fix: focus canvas after agent writeback 2026-06-22 22:24:59 +08:00
134
5e82adb7eb fix: preserve canvas agent media metadata 2026-06-22 22:19:54 +08:00
134
3c7c122b2f feat: label image notes as canvas annotations 2026-06-22 22:17:25 +08:00
134
bcea56c0d9 fix: bypass clarification for selected canvas slots 2026-06-22 22:14:14 +08:00
134
7d99330eb9 refactor: resolve canvas agent view operations 2026-06-22 22:09:47 +08:00
134
640e1b6aa6 fix: show scale target corner arrows 2026-06-22 22:09:26 +08:00
134
6041442662 test: cover canvas agent mutation contracts 2026-06-22 22:04:43 +08:00
134
fa9f09e84e test: cover canvas agent operation requests 2026-06-22 21:58:45 +08:00
134
6d8aa557bf fix: allow deleting detail scale selections 2026-06-22 21:56:39 +08:00
134
e1685b517a feat: add canvas annotation edit prompt entry 2026-06-22 21:52:33 +08:00
134
9bc887eee1 fix: merge detail edit scale modes 2026-06-22 21:47:53 +08:00
134
df0daf4d27 feat: add canvas agent operation protocol 2026-06-22 21:45:23 +08:00
134
12b21d6f3b fix: unify canvas scale edit entry 2026-06-22 21:38:29 +08:00
134
d64d3c9deb fix: widen detail replacement preview 2026-06-22 21:10:10 +08:00
134
088d2c1b74 fix: sync detail scale target flow 2026-06-22 20:08:14 +08:00
134
bf8410a6d4 fix: use target box flow for canvas scale adjust 2026-06-22 19:52:55 +08:00
134
aaf3e08dd8 fix: improve canvas derived generation flows 2026-06-22 19:36:35 +08:00
134
47c7da3a1f fix: align detail scale adjust buttons 2026-06-22 18:30:03 +08:00
134
d34e6c8344 fix: keep scale adjust source image visible 2026-06-22 18:21:24 +08:00
134
c2ef33753a fix: improve detail replacement generation controls 2026-06-22 17:56:58 +08:00
134
c528e22680 docs: update canvas agent upgrade plan 2026-06-22 16:38:19 +08:00
134
038730a24b fix: preserve auto image ratio for agnes edits 2026-06-22 16:29:13 +08:00
134
2b96fdb3e7 fix: improve canvas local edit scale adjustment 2026-06-22 14:47:45 +08:00
134
f7d18f20db fix: keep latest canvas image highlighted 2026-06-22 14:46:25 +08:00
134
b032774e8e fix: improve image generation timeout failover 2026-06-22 14:38:59 +08:00
134
25df2d248b fix: honor custom branding favicon sources 2026-06-22 13:26:54 +08:00
134
7fddbe68bc fix: invert branding in dark mode 2026-06-22 12:00:52 +08:00
134
8519aa70e7 fix: guide scale adjust as localized edit 2026-06-22 11:08:51 +08:00
134
072fac14cc fix: sync canvas placeholder with chat loading 2026-06-22 11:02:41 +08:00
134
ef89ab1aeb fix: restore canvas generation placeholders 2026-06-22 10:36:08 +08:00
134
39b4bd5983 fix: skip scale adjust verification rejection 2026-06-22 02:51:22 +08:00
134
9e8817f89e fix: preserve Agnes image edit aspect ratio 2026-06-22 02:28:47 +08:00
134
eb2153818d fix: prefer detail model for scale adjustments 2026-06-22 01:22:25 +08:00
134
dd65ef46a7 fix: support Agnes image-to-image generations API 2026-06-22 01:04:46 +08:00
134
5b2418701f fix: preserve canvas image generation aspect ratio 2026-06-21 23:27:56 +08:00
134
e5bcf6f403 fix: reduce scale adjust false failures 2026-06-21 23:06:26 +08:00
134
7959dd6b3a fix: reduce scale adjust background distortion 2026-06-21 21:31:04 +08:00
134
2fbcd45035 fix: soften scale adjust composite edges 2026-06-21 19:34:23 +08:00
134
317593a72d fix: protect scale adjust output regions 2026-06-21 18:24:56 +08:00
134
e6d3b20308 fix: extend image task waits and scale adjust health 2026-06-21 15:47:10 +08:00
134
1ad52fd3c3 fix: reduce 3d world movement step 2026-06-21 11:57:06 +08:00
134
618bc2bd97 fix: pass scale adjust crop references 2026-06-20 21:47:00 +08:00
134
eac5bb9296 fix: disable scale adjust synthetic fallback 2026-06-20 18:39:49 +08:00
134
4f06ff7151 fix: reject scale adjust whole-frame changes 2026-06-20 10:30:17 +08:00
134
52cd365925 fix: fail over fixed detail image model first 2026-06-20 01:06:09 +08:00
134
0b1a3a715b fix: fallback scale adjust on provider failure 2026-06-20 00:09:42 +08:00
134
c0e16a354f fix: normalize scale adjust verification samples 2026-06-19 23:15:46 +08:00
134
b6296d4022 fix: use detail model for scale adjust 2026-06-19 22:38:31 +08:00
134
1ddb7928c6 fix: preserve subject aspect in scale adjust 2026-06-19 21:35:31 +08:00
134
e22b6f11b9 fix: hide scale prompts and add compare exit 2026-06-19 02:18:00 +08:00
134
baa924c8d4 fix: persist detail scale adjustments 2026-06-19 01:18:34 +08:00
134
43769c5b58 fix: guide scale adjustment target sizing 2026-06-18 22:07:35 +08:00
134
72e4b926d9 fix: soften local edit composite boundaries 2026-06-18 19:49:26 +08:00
134
f29a26b43f feat: improve canvas local edit scale adjustment 2026-06-18 18:41:17 +08:00
134
07854d7c6a fix: unify detail processing placeholders 2026-06-18 14:45:37 +08:00
134
74ae88edbb fix: improve scale adjust guidance and scan feedback 2026-06-17 21:44:57 +08:00
134
af63ebba9e feat: support target detail uploads and improve scale adjust 2026-06-17 17:37:51 +08:00
134
25af7e5526 feat: add detail image scale adjust and processing scan 2026-06-17 15:35:04 +08:00
134
9fae6c6a62 fix: extend local edit task wait time 2026-06-17 13:27:00 +08:00
134
1f1f3fd276 fix: unify canvas processing placeholders 2026-06-17 11:43:40 +08:00
134
8ec27aa225 feat: support canvas text color editing 2026-06-17 10:26:40 +08:00
134
c88cf6146b fix: persist agent workflow selections immediately 2026-06-16 22:30:05 +08:00
134
2807dcf9e3 fix: complete detail local edit workflow 2026-06-16 21:55:01 +08:00
134
553e5b5373 fix: add edit actions to detail replacement results 2026-06-16 21:19:37 +08:00
134
2f6d0498e2 fix: failover grsai maintenance image models 2026-06-16 14:48:42 +08:00
134
c631f906b1 fix: download canvas images from original source 2026-06-15 20:57:12 +08:00
134
71dcd5026b fix: validate external api image resolution 2026-06-15 18:17:50 +08:00
134
65aac6c14d fix: return original images from external image api 2026-06-15 17:19:54 +08:00
134
95d4bccceb fix: reject empty image results across chat providers 2026-06-15 16:52:23 +08:00
134
ffed16d802 fix: hide internal image failure messages 2026-06-15 15:33:58 +08:00
134
fdff0c2865 fix: reject empty gpt image results 2026-06-15 15:05:14 +08:00
134
010d7d1be5 fix: poll grsai image tasks and return originals 2026-06-15 14:33:31 +08:00
134
801d2c4644 feat: enhance detail result image actions 2026-06-15 13:28:58 +08:00
134
fe18aa0000 fix: run selected ai wash preset only 2026-06-15 00:34:46 +08:00
134
5e0464c522 fix: align canvas action credit costs 2026-06-14 20:22:29 +08:00
134
85930dc1a6 feat: show canvas action credit tooltips 2026-06-14 14:30:19 +08:00
134
898ed98f1a fix: simplify 3d world asset actions 2026-06-14 12:32:00 +08:00
134
85b28885a4 fix: refine 3d world viewer controls 2026-06-14 10:53:04 +08:00
134
e5838f480a fix canvas generation chat and failover behavior 2026-06-14 09:54:47 +08:00
134
cf21c80d24 fix: polish canvas generation workflows 2026-06-14 09:39:49 +08:00
134
a420e4cf33 Cap Comfly GPT image size to stable edit resolution 2026-06-13 22:36:29 +08:00
134
a939e000cf Normalize Comfly GPT image model id 2026-06-13 17:15:39 +08:00
134
382faf6c6d Use Comfly chat image edit endpoint 2026-06-13 14:56:59 +08:00
134
4eac5cf0c0 Normalize image output dimensions 2026-06-13 13:33:02 +08:00
134
a265a77095 Fix Comfly GPT image sizing 2026-06-13 13:11:11 +08:00
134
748c468013 Fix Comfly model size override 2026-06-12 23:38:54 +08:00
134
9e52e1844f Fix Comfly 4K image failover timing 2026-06-12 22:20:50 +08:00
134
686111628e Fix model dropdown name clipping 2026-06-12 21:44:10 +08:00
134
1b79f788b5 Adjust image model failover timing 2026-06-12 17:24:13 +08:00
134
d24eedc578 allow emergency server build without typecheck 2026-06-12 16:00:45 +08:00
134
7963a916cb fix comfly image size resolution 2026-06-12 15:52:25 +08:00
134
88afa0551c improve generation placeholder placement 2026-06-12 14:49:51 +08:00
134
03aee55cb6 improve canvas generation placement 2026-06-12 14:28:56 +08:00
134
016d588559 fix grsai image request and generation preview 2026-06-12 14:06:00 +08:00
134
edae2f83c2 fix image failover eligibility 2026-06-12 11:26:38 +08:00
134
1947aae685 improve canvas generation placeholders 2026-06-12 11:14:18 +08:00
134
36654a6fa3 tune grsai gpt image health handling 2026-06-12 10:43:13 +08:00
134
1aaf904fd3 fix grsai gpt image edit inputs 2026-06-12 10:29:01 +08:00
134
04972684c3 fix image providers and model deletion 2026-06-11 19:35:09 +08:00
134
f80b3e8117 Tune Lovart project text spacing 2026-06-11 18:07:20 +08:00
134
86c09ae375 Refine Lovart home visual spacing 2026-06-11 17:51:18 +08:00
134
4fe91d442e Improve canvas image download fallback 2026-06-11 17:06:21 +08:00
134
6523ea740c Polish Lovart home layout 2026-06-11 16:58:28 +08:00
Your Name
89b0eaaa3d fix: toggle home agent composer mode 2026-06-10 23:50:26 +08:00
Your Name
29033b5757 fix: align canvas loading and composer controls 2026-06-10 23:28:10 +08:00
Your Name
f02d18702a fix: refine Lovart-style canvas and home UI 2026-06-10 22:38:58 +08:00
Your Name
6d7f869e56 fix(external-image): correct request filter log template syntax 2026-06-09 19:29:27 +08:00
Your Name
482e1ef291 fix(external-image): improve grsai failover compatibility 2026-06-09 19:25:42 +08:00
Your Name
8db1afdbc0 fix: allow ruxa image in external api failover filtering 2026-06-09 19:00:25 +08:00
Your Name
79751743f2 fix(external-image): hide model details in API-facing generation errors 2026-06-09 18:44:03 +08:00
Your Name
aebf5e86cc fix: block model override fields in external image generate API 2026-06-09 18:36:16 +08:00
Your Name
23d284b994 Fix external image API failover image validation 2026-06-09 18:12:22 +08:00
Your Name
eb15c7a33b fix(ruxa): use configured task input mode for image models 2026-06-09 17:11:10 +08:00
Your Name
aa6c1f72a6 fix: replace existing world3d node before importing 2026-06-09 15:14:38 +08:00
Your Name
4a3342c795 style: refresh canvas composer UI to mirror Lovart dialog 2026-06-09 13:56:20 +08:00
Your Name
6b90b25d5c fix(worldlabs): increase world 3d api request timeouts 2026-06-09 13:32:24 +08:00
Your Name
74adb68d16 fix(chat): normalize image part fields for ts safety 2026-06-09 13:22:42 +08:00
Your Name
09efe47f17 fix(chat): tolerate file part optional data field in image-media conversion 2026-06-09 13:19:56 +08:00
Your Name
7c398cb4b1 fix(ruxa-image): keep preset sync unhidden and enabled 2026-06-09 13:11:29 +08:00
Your Name
cd817a6336 fix(ruxa-image): normalize runtime model id and enforce nano-banana-2 endpoint 2026-06-09 13:06:05 +08:00
Your Name
609700ab54 Fix RUXA nano-banana model input mode and runtime failover IDs 2026-06-09 11:47:42 +08:00
Your Name
56e68ff5d0 Fix RUXA nano-banana-2 runtime model id sync 2026-06-09 11:30:09 +08:00
Your Name
37e48b7878 Fix Gemini image reference forwarding for grsai 2026-06-09 10:46:36 +08:00
Your Name
0ba1ea521f 更新 2026-06-09 10:36:19 +08:00
Your Name
da9c8671fe fix: keep ruxa image modelId from configuration without remap 2026-06-08 21:22:30 +08:00
Your Name
3d7147d242 fix(ruxa): resolve runtime model ids by configured model 2026-06-08 21:06:33 +08:00
Your Name
6a9beb418a fix(ruxa): resolve runtime model ids from config for image flows 2026-06-08 20:13:12 +08:00
Your Name
5a04304ea9 feat(admin): add ruxa image preset model bootstrap 2026-06-08 19:04:13 +08:00
Your Name
663ff7b41c fix ruxa image resolution mapping 2026-06-06 16:39:08 +08:00
Your Name
e2111add87 perf: smooth canvas panning 2026-06-05 20:46:16 +08:00
Your Name
8943e2900b feat: add watermark removal to detail results 2026-06-05 18:01:45 +08:00
Your Name
c795e00d63 feat: configure selected image toolbar density 2026-06-05 18:01:45 +08:00
Your Name
f34344d53b fix: compact canvas image tools 2026-06-05 18:01:45 +08:00
Your Name
1a194b5527 fix: clarify batch pending state 2026-06-05 16:25:16 +08:00
Your Name
14dbe3efeb fix: poll grsai nano banana tasks asynchronously 2026-06-05 11:52:57 +08:00
Your Name
4a36630b57 fix: use original batch result images for actions 2026-06-04 18:42:17 +08:00
Your Name
8ca072d902 fix: refresh site settings after provider changes 2026-06-04 15:12:42 +08:00
Your Name
e358e0241c fix: persist worldlabs base settings without keys 2026-06-04 14:57:32 +08:00
Your Name
1598722ad0 fix: keep worldlabs provider enabled on runtime errors 2026-06-04 14:46:05 +08:00
Your Name
6674c22cdd fix: preserve psd clip mask layer stacking 2026-06-04 13:52:01 +08:00
Your Name
74f2d052ac fix: keep psb upload with compatibility errors 2026-06-04 13:37:05 +08:00
Your Name
c8073c6c46 fix: fail unsupported psb templates fast 2026-06-04 13:26:14 +08:00
2669 changed files with 564391 additions and 16641 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
@ -98,12 +105,25 @@ WORLDLABS_API_KEY=
WORLDLABS_BASE_URL=
WORLDLABS_IMPORT_TOKEN=
WORLDLABS_KEY_POOL_IMPORT_TOKEN=
WORLDLABS_API_TIMEOUT_MS=180000
WORLDLABS_TASK_CREATE_TIMEOUT_MS=180000
WORLDLABS_TASK_STATUS_TIMEOUT_MS=180000
KITTYCAD_API_TOKEN=
KITTYCAD_API_KEY=
KITTYCAD_BASE_URL=https://api.zoo.dev
ZOO_API_TOKEN=
ZOO_BASE_URL=https://api.zoo.dev
# Pascal Editor self-hosted workspace. In local development, leaving this blank
# makes /pascal-editor auto-detect http://localhost:3002. For production or a
# fixed deployment, point this to your self-hosted Pascal service or static
# /vendor/pascal-editor/index.html bundle.
NEXT_PUBLIC_PASCAL_EDITOR_URL=
PASCAL_EDITOR_URL=
# Shared only by the main app, Nginx, and the private Pascal service. Generate a
# long random value; never expose it through NEXT_PUBLIC_* variables.
PASCAL_PROXY_SECRET=
# RUXA / video callback.
RUXA_CALLBACK_URL=
RUXA_VIDEO_CALLBACK_URL=

11
.gitignore vendored
View File

@ -11,6 +11,8 @@ coverage
# next.js
.next/
.next-build/
.next-previous/
out/
build
@ -87,5 +89,14 @@ checkpoints/
# 第三方参考源码(不提交到git)
reference-sources/
# Legacy external Pascal Editor checkout (the managed source is under services/).
.pascal-editor/
# Local self-hosted Zoo Design Studio checkout.
.zoo-modeling-app/
# Generated official Zoo Design Studio web bundle.
public/vendor/zoo-modeling-app/
# Drizzle migrations must be versioned (override the global *.sql ignore above)
!lib/db/migrations/*.sql

View File

@ -144,18 +144,10 @@ log.error("Operation failed", error, { userId: "123" });
#### 7. 修复 service 层错误吞没
**风险等级**: 🟡 中等风险
**改动**:
- `services/ai-cad/index.ts`: 完全重写,所有 catch 块现在记录错误
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
**潜在问题**:
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
- 如果有其他代码依赖 ai-cad service 的错误格式,可能受影响
**测试建议**:
```bash
# 触发一个 AI-CAD 错误(如无效的 workspace ID)
# 检查日志是否包含完整错误堆栈
```
---

View File

@ -0,0 +1,706 @@
"use client";
import {
Activity,
AlertTriangle,
Ban,
Clock3,
Globe2,
LogIn,
Plus,
RefreshCw,
Search,
ShieldAlert,
Unlock,
UserCheck,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn, fetcher } from "@/lib/utils";
type Severity = "info" | "watch" | "high" | "critical";
type AccessLogEvent = {
id: string;
eventType: string;
ip: string;
userEmail: string | null;
userRole: string | null;
method: string | null;
path: string | null;
routeGroup: string | null;
statusCode: number | null;
outcome: string;
severity: Severity;
reasons: string[] | null;
userAgent: string | null;
createdAt: string;
};
type AccessLogTopIp = {
ip: string;
total: number;
uniqueUsers: number;
failureCount: number;
rateLimitedCount: number;
authFailureCount: number;
loginSuccessCount: number;
suspiciousCount: number;
firstSeen: string;
lastSeen: string;
severity: Severity;
failureRate: number;
reasons: string[];
};
type AccessLogResponse = {
overview: {
totalEvents: number;
uniqueIps: number;
abnormalEvents: number;
abnormalIps: number;
rateLimitedEvents: number;
manualBlockedEvents: number;
loginSuccessEvents: number;
unauthorizedEvents: number;
};
topIps: AccessLogTopIp[];
hourlyStats: Array<{
hour: string;
count: number;
abnormalCount: number;
}>;
pathStats: Array<{
path: string | null;
routeGroup: string | null;
count: number;
abnormalCount: number;
}>;
recentEvents: AccessLogEvent[];
rules: string[];
};
type AccessBlocklistEntry = {
id: string;
ip: string;
reason: string | null;
isActive: boolean;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
createdByEmail: string | null;
};
const severityLabels: Record<Severity, string> = {
info: "正常",
watch: "关注",
high: "异常",
critical: "高危",
};
const severityClassNames: Record<Severity, string> = {
info:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-300",
watch:
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/45 dark:text-amber-300",
high:
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-300",
critical:
"border-red-200 bg-red-50 text-red-700 dark:border-red-800/70 dark:bg-red-950/45 dark:text-red-300",
};
function formatNumber(value: number | null | undefined) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}
function formatPercent(value: number | null | undefined) {
return `${Math.round(Number(value || 0) * 100)}%`;
}
function formatTime(value: string | null | undefined) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function getStatusClassName(statusCode: number | null) {
if (!statusCode) return "text-muted-foreground";
if (statusCode >= 500) return "text-red-700 dark:text-red-300";
if (statusCode >= 400) return "text-orange-700 dark:text-orange-300";
if (statusCode >= 300) return "text-sky-700 dark:text-sky-300";
return "text-emerald-700 dark:text-emerald-300";
}
function AccessSeverityBadge({ severity }: { severity: Severity }) {
return (
<Badge
className={cn("shrink-0 border px-2 py-0.5", severityClassNames[severity])}
variant="outline"
>
{severityLabels[severity]}
</Badge>
);
}
export function AccessSecurityDashboard() {
const [hours, setHours] = useState("24");
const [severity, setSeverity] = useState("all");
const [query, setQuery] = useState("");
const [blockIp, setBlockIp] = useState("");
const [blockReason, setBlockReason] = useState("");
const [isSavingBlock, setIsSavingBlock] = useState(false);
const accessLogUrl = useMemo(() => {
const params = new URLSearchParams({
hours,
severity,
limit: "160",
});
if (query.trim()) params.set("q", query.trim());
return `/api/admin/access-logs?${params.toString()}`;
}, [hours, query, severity]);
const { data, isLoading, mutate } = useSWR<AccessLogResponse>(
accessLogUrl,
fetcher,
{ refreshInterval: 30_000 }
);
const { data: blocklistData, mutate: mutateBlocklist } = useSWR<{
entries: AccessBlocklistEntry[];
}>("/api/admin/access-blocklist", fetcher);
const activeBlocklistEntries = useMemo(
() => (blocklistData?.entries || []).filter((entry) => entry.isActive),
[blocklistData?.entries]
);
const blockedRules = useMemo(
() => new Set(activeBlocklistEntries.map((entry) => entry.ip.toLowerCase())),
[activeBlocklistEntries]
);
const saveBlockEntry = async (targetIp?: string, targetReason?: string) => {
const ip = String(targetIp || blockIp).trim();
const reason = String(targetReason ?? blockReason).trim();
if (!ip) {
toast.error("请输入要拉黑的 IP");
return;
}
setIsSavingBlock(true);
try {
const response = await fetch("/api/admin/access-blocklist", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ip, reason }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "拉黑失败");
}
toast.success(`${ip} 已加入手动黑名单`);
setBlockIp("");
setBlockReason("");
await Promise.all([mutateBlocklist(), mutate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "拉黑失败");
} finally {
setIsSavingBlock(false);
}
};
const updateBlockEntry = async (entry: AccessBlocklistEntry, isActive: boolean) => {
try {
const response = await fetch("/api/admin/access-blocklist", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: entry.id,
isActive,
reason: entry.reason || "",
expiresAt: entry.expiresAt || null,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "更新失败");
}
toast.success(isActive ? `${entry.ip} 已重新拉黑` : `${entry.ip} 已解除拉黑`);
await Promise.all([mutateBlocklist(), mutate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新失败");
}
};
const overviewCards = [
{
key: "events",
label: "访问记录",
value: formatNumber(data?.overview.totalEvents),
note: `${hours} 小时窗口`,
icon: Activity,
className:
"border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-700/70 dark:bg-slate-900/70 dark:text-slate-100",
},
{
key: "ips",
label: "独立 IP",
value: formatNumber(data?.overview.uniqueIps),
note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`,
icon: Globe2,
className:
"border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-800/70 dark:bg-sky-950/45 dark:text-sky-100",
},
{
key: "abnormal",
label: "异常事件",
value: formatNumber(data?.overview.abnormalEvents),
note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`,
icon: ShieldAlert,
className:
"border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-100",
},
{
key: "login",
label: "成功登录",
value: formatNumber(data?.overview.loginSuccessEvents),
note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`,
icon: LogIn,
className:
"border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-100",
},
];
const maxHourlyCount = Math.max(
1,
...(data?.hourlyStats || []).map((item) => item.count)
);
const maxPathCount = Math.max(
1,
...(data?.pathStats || []).map((item) => item.count)
);
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<CardTitle className="text-xl">访问安全分析</CardTitle>
<CardDescription>
IP 访问记录、登录来源、异常标注与请求趋势
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative sm:w-[260px]">
<Search className="-translate-y-1/2 absolute left-3 top-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="h-9 pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索 IP、路径、用户"
value={query}
/>
</div>
<Select onValueChange={setHours} value={hours}>
<SelectTrigger className="h-9 sm:w-[132px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">近 1 小时</SelectItem>
<SelectItem value="6">近 6 小时</SelectItem>
<SelectItem value="24">近 24 小时</SelectItem>
<SelectItem value="72">近 3 天</SelectItem>
<SelectItem value="168">近 7 天</SelectItem>
</SelectContent>
</Select>
<Select onValueChange={setSeverity} value={severity}>
<SelectTrigger className="h-9 sm:w-[132px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部级别</SelectItem>
<SelectItem value="abnormal">仅异常</SelectItem>
<SelectItem value="watch">关注</SelectItem>
<SelectItem value="high">异常</SelectItem>
<SelectItem value="critical">高危</SelectItem>
</SelectContent>
</Select>
<Button
className="h-9 gap-2"
onClick={() => mutate()}
size="sm"
variant="outline"
>
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
刷新
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{overviewCards.map((item) => {
const Icon = item.icon;
return (
<div
className={cn("rounded-lg border p-4", item.className)}
key={item.key}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-current/70">{item.label}</div>
<Icon className="h-4 w-4" />
</div>
<div className="mt-3 font-semibold text-3xl tracking-tight">
{item.value}
</div>
<div className="mt-1 text-current/65 text-xs">{item.note}</div>
</div>
);
})}
</div>
<div className="rounded-lg border bg-background p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-sm">
<Clock3 className="h-4 w-4" />
小时趋势
</div>
<div className="text-muted-foreground text-xs">
橙色表示异常事件占比
</div>
</div>
<div className="flex h-[180px] items-end gap-1">
{(data?.hourlyStats || []).map((item) => {
const height = Math.max(4, (item.count / maxHourlyCount) * 100);
const abnormalHeight =
item.count > 0
? Math.max(0, (item.abnormalCount / item.count) * height)
: 0;
return (
<div
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200 dark:bg-slate-800"
key={item.hour}
style={{ height: `${height}%` }}
>
<div
className="w-full bg-orange-400"
style={{ height: `${abnormalHeight}%` }}
/>
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-white text-xs shadow-lg group-hover:block">
{item.hour} · {formatNumber(item.count)} 次 · 异常{" "}
{formatNumber(item.abnormalCount)}
</div>
</div>
);
})}
{(!data?.hourlyStats || data.hourlyStats.length === 0) && (
<div className="flex h-full w-full items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm">
暂无访问记录
</div>
)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">手动 IP 黑名单</CardTitle>
<CardDescription>
管理员手动加入后,命中 IP 会被阻断并写入访问日志
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[220px_minmax(240px,1fr)_auto]">
<Input
className="h-9"
onChange={(event) => setBlockIp(event.target.value)}
placeholder="IP 或 IPv4 CIDR"
value={blockIp}
/>
<Input
className="h-9"
onChange={(event) => setBlockReason(event.target.value)}
placeholder="拉黑原因"
value={blockReason}
/>
<Button
className="h-9 gap-2"
disabled={isSavingBlock}
onClick={() => saveBlockEntry()}
size="sm"
>
<Plus className="h-4 w-4" />
加入黑名单
</Button>
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{activeBlocklistEntries.map((entry) => (
<div
className="rounded-lg border border-red-100 bg-red-50/60 px-3 py-3 dark:border-red-900/60 dark:bg-red-950/30"
key={entry.id}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ban className="h-4 w-4 text-red-600 dark:text-red-400" />
<span className="truncate font-mono font-semibold text-sm text-red-900 dark:text-red-200">
{entry.ip}
</span>
</div>
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs dark:text-red-300/75">
{entry.reason || "未填写原因"}
</div>
<div className="mt-2 text-red-700/70 text-xs dark:text-red-400/70">
{formatTime(entry.updatedAt)}
</div>
</div>
<Button
className="h-8 shrink-0 gap-1 border-red-200 bg-white text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-950/60"
onClick={() => updateBlockEntry(entry, false)}
size="sm"
variant="outline"
>
<Unlock className="h-3.5 w-3.5" />
解除
</Button>
</div>
</div>
))}
{activeBlocklistEntries.length === 0 ? (
<div className="rounded-lg border border-dashed py-8 text-center text-muted-foreground text-sm md:col-span-2 xl:col-span-3">
暂无手动拉黑 IP
</div>
) : null}
</div>
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">异常 IP 标注</CardTitle>
<CardDescription>
按限流、失败率、认证异常、敏感路径和请求量综合排序
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{(data?.topIps || []).map((item) => (
<div
className="rounded-lg border bg-background px-4 py-3"
key={item.ip}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-semibold text-sm">
{item.ip}
</span>
<AccessSeverityBadge severity={item.severity} />
</div>
<div className="mt-2 flex flex-wrap gap-2 text-muted-foreground text-xs">
<span>{formatNumber(item.total)} 次访问</span>
<span>{formatNumber(item.uniqueUsers)} 个用户</span>
<span>失败率 {formatPercent(item.failureRate)}</span>
<span>最近 {formatTime(item.lastSeen)}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-1 md:justify-end">
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
(reason) => (
<Badge
className="border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
key={reason}
variant="outline"
>
{reason}
</Badge>
)
)}
<Button
className="h-7 gap-1 px-2 text-xs"
disabled={blockedRules.has(item.ip.toLowerCase()) || isSavingBlock}
onClick={() =>
saveBlockEntry(
item.ip,
item.reasons.join("、") || "管理员从异常 IP 排行手动拉黑"
)
}
size="sm"
variant="outline"
>
<Ban className="h-3.5 w-3.5" />
{blockedRules.has(item.ip.toLowerCase()) ? "已拉黑" : "拉黑"}
</Button>
</div>
</div>
</div>
))}
{(!data?.topIps || data.topIps.length === 0) && (
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
暂无 IP 聚合数据
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">访问路径</CardTitle>
<CardDescription>按请求数量排序</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(data?.pathStats || []).map((item) => {
const abnormalRate =
item.count > 0 ? item.abnormalCount / item.count : 0;
return (
<div key={`${item.routeGroup || "page"}-${item.path || "/"}`}>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="min-w-0 truncate font-medium">
{item.path || "/"}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{formatNumber(item.count)}
</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<div
className="h-full rounded-full bg-zinc-900 dark:bg-zinc-200"
style={{
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
}}
/>
<div
className="-mt-2 h-full rounded-full bg-orange-400"
style={{
width: `${Math.round(abnormalRate * 100)}%`,
}}
/>
</div>
</div>
);
})}
{(!data?.pathStats || data.pathStats.length === 0) && (
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
暂无路径统计
</div>
)}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-lg">访问日志列表</CardTitle>
<CardDescription>最近 {formatNumber(data?.recentEvents.length)} 条</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
{(data?.rules || []).map((rule) => (
<Badge
className="border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
key={rule}
variant="outline"
>
{rule}
</Badge>
))}
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-lg border">
<div className="min-w-[980px]">
<div className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] border-b bg-zinc-50 px-4 py-2 font-medium text-muted-foreground text-xs dark:bg-zinc-900/70">
<div>时间</div>
<div>IP</div>
<div>状态</div>
<div>路径</div>
<div>用户</div>
<div>标注</div>
</div>
{(data?.recentEvents || []).map((event) => (
<div
className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] items-center border-b px-4 py-3 text-sm last:border-b-0"
key={event.id}
>
<div className="text-muted-foreground text-xs">
{formatTime(event.createdAt)}
</div>
<div className="font-mono text-xs">{event.ip}</div>
<div
className={cn(
"font-semibold text-xs",
getStatusClassName(event.statusCode)
)}
>
{event.statusCode || "--"}
</div>
<div className="min-w-0">
<div className="truncate font-medium">
{event.method || "GET"} {event.path || "/"}
</div>
<div className="mt-1 truncate text-muted-foreground text-xs">
{event.reasons?.join("、") || event.outcome}
</div>
</div>
<div className="min-w-0">
<div className="truncate text-xs">
{event.userEmail || "匿名"}
</div>
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
<UserCheck className="h-3 w-3" />
{event.userRole || "unknown"}
</div>
</div>
<div className="flex items-center gap-2">
{event.severity === "high" || event.severity === "critical" ? (
<AlertTriangle className="h-4 w-4 text-orange-500" />
) : null}
<AccessSeverityBadge severity={event.severity} />
</div>
</div>
))}
{(!data?.recentEvents || data.recentEvents.length === 0) && (
<div className="py-10 text-center text-muted-foreground text-sm">
暂无访问日志
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -11,11 +11,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ADMIN_SETTINGS_NAV_GROUPS } from "../_lib/settings-navigation";
import { ADMIN_SETTINGS_NAV_ITEMS } from "../_lib/settings-navigation";
import type { AdminSettingsNavItem } from "../_lib/types";
const ALL_SETTINGS_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items);
function SettingsQuickAction({
activeTab,
item,
@ -33,8 +31,8 @@ function SettingsQuickAction({
? "border-foreground/10 bg-foreground text-background shadow-sm hover:!bg-foreground/90 hover:!text-background dark:border-white/20 dark:bg-white dark:text-black dark:hover:!bg-white/90 dark:hover:!text-black"
: "border-transparent bg-transparent text-muted-foreground hover:!border-border/80 hover:!bg-zinc-100 hover:!text-foreground dark:hover:!bg-zinc-900/50";
const layoutClassName = stretched
? "h-10 w-full min-w-0 justify-center gap-2 rounded-2xl border px-4"
: "h-9 shrink-0 gap-2 rounded-full border px-3.5";
? "h-8 w-full min-w-0 justify-center gap-1.5 rounded-md border px-2.5 text-xs"
: "h-8 shrink-0 gap-1.5 rounded-md border px-2.5 text-xs";
if (item.href) {
return (
@ -45,7 +43,7 @@ function SettingsQuickAction({
variant="ghost"
>
<Link href={item.href}>
<Icon className="h-4 w-4" />
<Icon className="h-3.5 w-3.5" />
<span className={stretched ? "truncate" : ""}>{item.label}</span>
</Link>
</Button>
@ -59,7 +57,7 @@ function SettingsQuickAction({
size="sm"
variant="ghost"
>
<Icon className="h-4 w-4" />
<Icon className="h-3.5 w-3.5" />
<span className={stretched ? "truncate" : ""}>{item.label}</span>
</Button>
);
@ -73,9 +71,13 @@ export function AdminSettingsNavigation({
onTabChange: (value: string) => void;
}) {
const router = useRouter();
const activeItem = ALL_SETTINGS_ITEMS.find((item) => item.value === activeTab);
const activeItem = ADMIN_SETTINGS_NAV_ITEMS.find(
(item) => item.value === activeTab
);
const handleSelectChange = (value: string) => {
const nextItem = ALL_SETTINGS_ITEMS.find((item) => item.value === value);
const nextItem = ADMIN_SETTINGS_NAV_ITEMS.find(
(item) => item.value === value
);
if (nextItem?.href) {
router.push(nextItem.href);
return;
@ -84,15 +86,18 @@ export function AdminSettingsNavigation({
};
return (
<div className="rounded-2xl border bg-background px-4 py-3 shadow-sm">
<div className="flex flex-col gap-2 md:hidden">
<nav
aria-label="系统设置导航"
className="sticky top-2 z-30 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85"
>
<div className="md:hidden">
<div className="flex items-center gap-2">
<Select onValueChange={handleSelectChange} value={activeTab}>
<SelectTrigger className="h-9 flex-1 border-border/70 bg-background shadow-none">
<SelectTrigger className="h-8 flex-1 border-border/70 bg-background shadow-none">
<SelectValue placeholder={activeItem?.label || "选择设置项"} />
</SelectTrigger>
<SelectContent>
{ALL_SETTINGS_ITEMS.map((item) => (
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@ -102,7 +107,7 @@ export function AdminSettingsNavigation({
<Button
asChild
className="h-9 rounded-full border-border/70 px-3 text-[13px]"
className="h-8 rounded-md border-border/70 px-2.5 text-xs"
size="sm"
variant="outline"
>
@ -113,35 +118,22 @@ export function AdminSettingsNavigation({
</Button>
</div>
<div className="flex flex-wrap gap-1">
{ALL_SETTINGS_ITEMS.map((item) => (
<SettingsQuickAction
activeTab={activeTab}
item={item}
key={item.value}
onTabChange={onTabChange}
/>
))}
</div>
</div>
<div className="hidden min-w-0 items-center gap-3 md:flex">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
{ALL_SETTINGS_ITEMS.map((item) => (
<div className="min-w-[118px] flex-1" key={item.value}>
<SettingsQuickAction
activeTab={activeTab}
item={item}
onTabChange={onTabChange}
stretched
/>
</div>
))}
</div>
<div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]">
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
<SettingsQuickAction
activeTab={activeTab}
item={item}
key={item.value}
onTabChange={onTabChange}
stretched
/>
))}
<Button
asChild
className="h-9 shrink-0 rounded-full border-border/70 px-3.5 text-[13px]"
className="h-8 min-w-0 rounded-md border-border/70 px-2.5 text-xs"
size="sm"
variant="outline"
>
@ -151,6 +143,6 @@ export function AdminSettingsNavigation({
</Link>
</Button>
</div>
</div>
</nav>
);
}

View File

@ -26,22 +26,22 @@ export function LocalAiCommandCenter({
onSetAllExpanded: (expanded: boolean) => void;
}) {
return (
<div className="overflow-hidden rounded-xl border bg-background">
<div className="border-b bg-muted/20 px-5 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1.5">
<div className="font-semibold text-muted-foreground text-xs">
<div className="overflow-hidden rounded-lg border bg-background">
<div className="border-b bg-muted/20 px-4 py-3">
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<div className="font-semibold text-[10px] text-muted-foreground">
SYSTEM INTEGRATIONS
</div>
<h2 className="font-semibold text-2xl tracking-normal">
<h2 className="font-semibold text-lg">
高级能力与外部服务
</h2>
<p className="max-w-3xl text-muted-foreground text-sm leading-6">
<p className="max-w-3xl text-muted-foreground text-xs leading-5">
把商品策划、图像处理、3D/CAD、实时画布和开放 API
按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。
</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
<SettingStatusBadge tone="neutral">
已展开 {openSectionCount} 个模块
</SettingStatusBadge>
@ -52,7 +52,7 @@ export function LocalAiCommandCenter({
模型 {modelCount}
</SettingStatusBadge>
<Button
className="h-7 rounded-md px-2 text-xs"
className="h-6 rounded-md px-2 text-[11px]"
onClick={() => onSetAllExpanded(true)}
size="sm"
type="button"
@ -61,7 +61,7 @@ export function LocalAiCommandCenter({
全部展开
</Button>
<Button
className="h-7 rounded-md px-2 text-xs"
className="h-6 rounded-md px-2 text-[11px]"
onClick={() => onSetAllExpanded(false)}
size="sm"
type="button"
@ -77,21 +77,21 @@ export function LocalAiCommandCenter({
const Icon = item.icon;
return (
<button
className="group bg-background p-4 text-left transition-colors hover:bg-muted/30"
className="group bg-background p-3 text-left transition-colors hover:bg-muted/30"
key={item.key}
onClick={() => onFocusSection(item.key)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-muted/30">
<Icon className="h-4 w-4 text-muted-foreground" />
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-muted/30">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0">
<div className="truncate font-semibold text-sm">
<div className="truncate font-semibold text-xs">
{item.title}
</div>
<div className="mt-0.5 truncate text-muted-foreground text-xs">
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{item.description}
</div>
</div>
@ -104,20 +104,20 @@ export function LocalAiCommandCenter({
);
})}
</div>
<div className="border-t bg-muted/10 px-5 py-4">
<div className="grid gap-4 lg:grid-cols-4">
<div className="border-t bg-muted/10 px-4 py-3">
<div className="grid gap-3 lg:grid-cols-4">
{LOCAL_AI_QUICK_LINK_GROUPS.map((group) => (
<div className="space-y-2" key={group}>
<div className="font-semibold text-muted-foreground text-xs">
<div className="space-y-1.5" key={group}>
<div className="font-semibold text-[11px] text-muted-foreground">
{group}
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{quickLinks
.filter((item) => item.group === group)
.map((item) => (
<button
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs transition-colors hover:bg-background",
"inline-flex h-6 items-center gap-1 rounded-md border px-2 text-[11px] transition-colors hover:bg-background",
isSectionExpanded(item.key)
? "border-foreground/20 bg-background text-foreground"
: "border-border bg-transparent text-muted-foreground"

View File

@ -24,13 +24,13 @@ export function LocalAiGroupHeading({
description: string;
}) {
return (
<div className="rounded-lg border bg-muted/20 px-4 py-3">
<div className="font-semibold text-[11px] text-muted-foreground uppercase tracking-[0.16em]">
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<div className="font-semibold text-[10px] text-muted-foreground uppercase">
{eyebrow}
</div>
<div className="mt-1 flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
<h3 className="font-semibold text-base text-foreground">{title}</h3>
<p className="max-w-3xl text-muted-foreground text-sm">{description}</p>
<h3 className="font-semibold text-sm text-foreground">{title}</h3>
<p className="max-w-3xl text-muted-foreground text-xs">{description}</p>
</div>
</div>
);
@ -45,7 +45,7 @@ export function LocalAiSectionSummary({
}>;
}) {
return (
<div className="flex flex-wrap gap-2 pt-1">
<div className="flex flex-wrap gap-1.5 pt-1">
{items.map((item) => (
<SettingStatusBadge key={item.label} tone={item.tone || "neutral"}>
{item.label}
@ -65,16 +65,16 @@ export function LocalAiSectionToggleButton({
return (
<Button
aria-expanded={expanded}
className="h-8 shrink-0 gap-1.5 rounded-md px-2.5 text-xs"
className="h-7 shrink-0 gap-1 rounded-md px-2 text-[11px]"
onClick={onClick}
size="sm"
type="button"
variant="outline"
>
{expanded ? (
<ArrowUp className="h-3.5 w-3.5" />
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
<ArrowDown className="h-3 w-3" />
)}
{expanded ? "收起配置" : "展开配置"}
</Button>
@ -100,9 +100,9 @@ export function LocalAiSectionShell({
}) {
return (
<Card id={id}>
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="text-xl">{title}</CardTitle>
<CardHeader className="flex flex-col gap-3 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
{summary}
</div>
@ -110,7 +110,7 @@ export function LocalAiSectionShell({
</CardHeader>
<CardContent
className={cn(
"local-ai-config-card-content space-y-6 pt-6",
"local-ai-config-card-content space-y-4 pt-4",
!expanded && "hidden"
)}
>

View File

@ -75,12 +75,16 @@ export function LocalAiImagePipelineSections({
aiWashConfig,
setAiWashConfig,
aiWashModeIconUploading,
canvasLocalEditConfig,
setCanvasLocalEditConfig,
extendConfig,
setExtendConfig,
watermarkRemoveConfig,
setWatermarkRemoveConfig,
angleEditConfig,
setAngleEditConfig,
pointsSystemConfig,
updatePointsCostField,
layerSplitConfig,
setLayerSplitConfig,
isLocalAiSectionExpanded,
@ -101,14 +105,17 @@ export function LocalAiImagePipelineSections({
handleMoveEnhanceProPreset,
handleAiWashModeIconUpload,
handleSaveAiWashConfig,
handleSaveCanvasLocalEditConfig,
handleSaveExtendConfig,
handleSaveWatermarkRemoveConfig,
handleSaveAngleEditConfig,
handleSaveLayerSplitConfig,
}: LocalAiImagePipelineSectionsProps) {
const availableImageModels = (modelsData?.models || []).filter(
(model) => model.supportsImageGen && model.isEnabled && !model.isHidden
(model) => model.supportsImageGen && model.isEnabled
);
const formatAiWashModelOptionLabel = (model: (typeof availableImageModels)[number]) =>
`${model.displayName || model.modelId}${model.isHidden ? " · 前台隐藏" : ""}`;
const getAiWashControlToneCopy = (controlId: AiWashControlId) => {
if (controlId === "creativity") {
return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。";
@ -152,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
@ -172,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"
@ -259,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" && (
<>
@ -464,7 +500,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell
description={
<>
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
配置图片变清晰服务,用于画图标注功能中的图片增强处理。
</>
}
expanded={isLocalAiSectionExpanded("enhance")}
@ -474,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",
@ -494,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">
@ -742,6 +789,8 @@ export function LocalAiImagePipelineSections({
</div>
</>
)}
</>
)}
<Button onClick={handleSaveEnhanceConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
@ -1417,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={
<>
@ -1447,6 +1602,12 @@ export function LocalAiImagePipelineSections({
label: aiWashConfig.modeLabel || "AI洗图",
tone: "neutral",
},
{
label: aiWashConfig.hideModelSelector
? "前台隐藏模型"
: "前台可选模型",
tone: aiWashConfig.hideModelSelector ? "enabled" : "neutral",
},
]}
/>
}
@ -1633,6 +1794,23 @@ export function LocalAiImagePipelineSections({
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className="font-medium text-sm">隐藏前台生图模型选择</div>
<div className="text-muted-foreground text-xs">
开启后,AI洗图页面不再显示生图模型下拉框,直接使用后台预设或默认模型。
</div>
</div>
<Switch
checked={aiWashConfig.hideModelSelector}
onCheckedChange={(checked) =>
setAiWashConfig((prev) => ({
...prev,
hideModelSelector: checked,
}))
}
/>
</div>
</div>
<div className="space-y-4 rounded-xl border p-4">
@ -1743,7 +1921,7 @@ export function LocalAiImagePipelineSections({
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{model.displayName || model.modelId}
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
@ -2105,7 +2283,7 @@ export function LocalAiImagePipelineSections({
key={model.id}
value={model.modelId}
>
{model.displayName || model.modelId}
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
@ -2188,7 +2366,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell
description={
<>
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
</>
}
expanded={isLocalAiSectionExpanded("extend")}
@ -2198,8 +2376,14 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary
items={[
{
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
tone: extendConfig.apiKey ? "enabled" : "warning",
label:
extendConfig.provider === "vip98k" || extendConfig.apiKey
? "Key 已配置"
: "Key 待配置",
tone:
extendConfig.provider === "vip98k" || extendConfig.apiKey
? "enabled"
: "warning",
},
{
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
@ -2210,6 +2394,12 @@ export function LocalAiImagePipelineSections({
}
title="图像拓展API配置"
>
{extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。
</div>
) : (
<div className="space-y-2">
<Label htmlFor="extendApiKey">API密钥 *</Label>
<Input
@ -2226,6 +2416,7 @@ export function LocalAiImagePipelineSections({
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
@ -2383,6 +2574,13 @@ export function LocalAiImagePipelineSections({
label: `${angleEditConfig.inferenceSteps} steps`,
tone: "neutral",
},
{
label: `${pointsSystemConfig.creditCostPerImageAngleEdit || 0} 积分/次`,
tone:
pointsSystemConfig.creditCostPerImageAngleEdit > 0
? "neutral"
: "disabled",
},
]}
/>
}
@ -2453,6 +2651,28 @@ export function LocalAiImagePipelineSections({
<Separator />
{/* 积分消耗 */}
<div className="space-y-2">
<Label htmlFor="angleEditCreditCost">积分消耗</Label>
<Input
id="angleEditCreditCost"
min={0}
onChange={(e) =>
updatePointsCostField(
"creditCostPerImageAngleEdit",
e.target.value
)
}
type="number"
value={pointsSystemConfig.creditCostPerImageAngleEdit}
/>
<div className="text-muted-foreground text-xs">
每次成功生成多角度图片后扣除的积分;填写 0 表示免费。保存本配置时会同步保存到全站积分设置。
</div>
</div>
<Separator />
{/* LoRA 强度 */}
<div className="space-y-3">
<div className="flex justify-between">

View File

@ -372,9 +372,10 @@ export function LocalAiModel3dSections({
<Input
id="worldlabsKeyPoolImportToken"
onChange={(e) =>
setWorldlabsKeyPoolConfig({
setWorldlabsKeyPoolConfig((prev) => ({
...prev,
importToken: e.target.value,
})
}))
}
placeholder="供 /api/worldlabs/key-pool/import 调用的 Bearer Token"
type="password"
@ -915,8 +916,8 @@ export function LocalAiModel3dSections({
<LocalAiSectionShell
description={
<>
配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD
服务,保存后写入数据库 AI 提供商配置。
配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI
提供商配置。
</>
}
expanded={isLocalAiSectionExpanded("kittycad-cad")}
@ -1002,7 +1003,7 @@ export function LocalAiModel3dSections({
<div className="space-y-0.5">
<Label className="text-sm">启用 KittyCAD CAD</Label>
<div className="text-muted-foreground text-xs">
关闭后 AI-CAD 页面将无法切换到外部 CAD 服务
关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务
</div>
</div>
<div className="flex items-center gap-2">

View File

@ -590,7 +590,7 @@ export function LocalAiPlanningSections({
<LocalAiSectionShell
description={
<>
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.6
为 AI详情生成 单独配置 Kimi Code 编程套餐
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
</>
}
@ -615,7 +615,7 @@ export function LocalAiPlanningSections({
]}
/>
}
title={<>Moonshot 官方 Kimi K2.6 配置</>}
title={<>Kimi Code 编程套餐配置</>}
>
<div className="space-y-2">
<Label htmlFor="moonshotProviderName">提供商名称</Label>
@ -627,13 +627,13 @@ export function LocalAiPlanningSections({
providerName: e.target.value,
}))
}
placeholder="Moonshot AI (Official)"
placeholder="Kimi Code 编程套餐"
value={moonshotDetailAgentConfig.providerName}
/>
</div>
<div className="space-y-2">
<Label htmlFor="moonshotBaseUrl">官方 API 地址</Label>
<Label htmlFor="moonshotBaseUrl">编程套餐专用 API 地址</Label>
<Input
id="moonshotBaseUrl"
onChange={(e) =>
@ -646,7 +646,7 @@ export function LocalAiPlanningSections({
value={moonshotDetailAgentConfig.baseUrl}
/>
<div className="text-muted-foreground text-xs">
根据 Moonshot 官方文档,OpenAI 兼容接口地址为 `
根据 Kimi Code 文档,编程套餐 OpenAI 兼容接口地址为 `
{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
</div>
</div>
@ -667,7 +667,7 @@ export function LocalAiPlanningSections({
placeholder={
moonshotDetailAgentConfig.providerId
? "留空则不修改当前 Key"
: "请输入 Moonshot 官方 API Key"
: "请输入 Kimi Code 控制台 API Key"
}
type="password"
value={moonshotDetailAgentConfig.apiKey}
@ -675,7 +675,7 @@ export function LocalAiPlanningSections({
</div>
<div className="space-y-2">
<Label htmlFor="moonshotModelId">Kimi K2.6 API 模型 ID *</Label>
<Label htmlFor="moonshotModelId">Kimi Code API 模型 ID *</Label>
<Input
id="moonshotModelId"
onChange={(e) =>
@ -688,8 +688,8 @@ export function LocalAiPlanningSections({
value={moonshotDetailAgentConfig.modelId}
/>
<div className="text-muted-foreground text-xs">
已按你提供的 Moonshot 官方文档默认填写
`kimi-k2.6`;保存后系统会自动创建/更新对应模型记录。
Kimi Code 编程套餐专用接口固定使用
`kimi-for-coding`;保存后系统会自动创建/更新对应模型记录。
</div>
</div>
@ -745,7 +745,7 @@ export function LocalAiPlanningSections({
<div className="flex justify-end">
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
<Save className="mr-2 h-4 w-4" />
保存 Moonshot Kimi K2.6 配置
保存 Kimi Code 编程套餐配置
</Button>
</div>
</LocalAiSectionShell>

View File

@ -15,7 +15,7 @@ export function SettingStatusBadge({
return (
<Badge
className={cn(
"h-6 rounded-md border px-2 font-medium text-[11px]",
"h-5 rounded-md border px-1.5 font-medium text-[10px]",
tone === "enabled" &&
"border-emerald-200 bg-emerald-50 text-emerald-700",
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500",

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

@ -9,6 +9,7 @@ import {
MessageSquare,
Scissors,
Settings,
ShieldAlert,
Sparkles,
User,
} from "lucide-react";
@ -37,6 +38,12 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
description: "用量、趋势与运营指标",
icon: BarChart3,
},
{
value: "access-security",
label: "访问安全",
description: "访问 IP、异常标注与登录来源",
icon: ShieldAlert,
},
],
},
{
@ -107,3 +114,7 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
],
},
];
export const ADMIN_SETTINGS_NAV_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap(
(group) => group.items
);

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,
@ -8,6 +9,7 @@ import type {
EnhanceProPreset,
} from "@/lib/enhance-pro/config";
import type { ThreeDModelAliasConfig } from "@/lib/model3d/model-alias-config";
import type { PointsSystemConfig } from "@/lib/points/config";
import type { ProductPlanningApiConfig } from "@/lib/product-planning/config";
import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config";
import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections";
@ -85,7 +87,12 @@ export type AdminAiProvider = {
| "bfl-image"
| "ruxa-image"
| "fal-image"
| "snapgen-image"
| "openai-image"
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image"
| "midjourney"
| "fal-video"
@ -167,6 +174,9 @@ export type AiProviderSettingsConfig = {
export type WorldLabsKeyPoolConfig = {
importToken: string;
providerName: string;
baseUrl: string;
isEnabled: boolean;
};
export type LocalAiModel3dSectionsProps = LocalAiSectionControlProps & {
@ -239,7 +249,7 @@ export type EnhanceProKeyRow = {
};
export type RmbgConfig = {
provider: "rmbg" | "photoroom" | "aoscdn";
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
apiUrl: string;
apiKey: string;
photoroomApiKey: string;
@ -251,6 +261,7 @@ export type RmbgConfig = {
};
export type EnhanceConfig = {
provider: "vip98k" | "legacy";
apiUrl: string;
apiKey: string;
type: string;
@ -263,6 +274,7 @@ export type EnhanceConfig = {
};
export type ExtendConfig = {
provider: "vip98k" | "dashscope";
apiKey: string;
xScale: string;
yScale: string;
@ -322,12 +334,19 @@ 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;
setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>;
angleEditConfig: AngleEditConfig;
setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>;
pointsSystemConfig: PointsSystemConfig;
updatePointsCostField: (
key: keyof PointsSystemConfig,
value: unknown
) => void;
layerSplitConfig: LayerSplitConfig;
setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>;
renderEnhanceProPresetEditor: (
@ -359,6 +378,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;

File diff suppressed because it is too large Load Diff

View File

@ -1926,7 +1926,7 @@ const AGENT_WORKFLOW_SETTINGS_FIELD_LABELS: Record<AgentWorkflowSettingsField, s
agentWorkflowDefaultModelId: "风格化专用默认模型",
agentPresetLibraryConfig: "预设库",
agentWorkflowUiConfig: "工作流步骤",
agentWorkflowMaterialApiConfig: "素材搜索接口",
agentWorkflowMaterialApiConfig: "第三方图片素材",
agentWorkflowGoodsInfoApiConfig: "商品信息接口",
agentWorkflowBrandModelConfig: "品牌模特",
agentWorkflowShowcaseConfig: "页面展示",
@ -5933,7 +5933,7 @@ export function AgentWorkflowSettingsClient({
if (isLoading) {
return (
<div className="container mx-auto p-6">
<div className="settings-workspace container mx-auto p-4">
<div className="flex h-[60vh] items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
@ -5945,18 +5945,18 @@ export function AgentWorkflowSettingsClient({
}
return (
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
<div className="settings-workspace min-h-screen bg-muted/10">
{/* Header */}
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-violet-500/20">
<Sparkles className="h-5 w-5 text-white" />
<div className="container mx-auto px-4 py-3 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
<Sparkles className="h-4 w-4 text-background" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">风格化工作流配置</h1>
<p className="text-sm text-muted-foreground">
<h1 className="text-base font-semibold">风格化工作流配置</h1>
<p className="text-xs text-muted-foreground">
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
</p>
</div>
@ -6006,7 +6006,6 @@ export function AgentWorkflowSettingsClient({
onClick={handleSave}
disabled={isSaving}
size="sm"
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500"
>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -6142,8 +6141,8 @@ export function AgentWorkflowSettingsClient({
{/* Stats Bar */}
<div className="border-b bg-muted/30">
<div className="container mx-auto px-6 py-3">
<div className="flex flex-wrap items-center gap-6">
<div className="container mx-auto px-4 py-2.5 sm:px-5">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono">
{enabledStepsCount}
@ -6197,8 +6196,8 @@ export function AgentWorkflowSettingsClient({
</div>
{/* Main Content */}
<main className="container mx-auto p-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<main className="container mx-auto p-4 sm:px-5">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="grid w-full grid-cols-5 lg:w-fit">
<TabsTrigger value="showcase" className="gap-2">
<MonitorPlay className="h-4 w-4" />
@ -6859,17 +6858,19 @@ export function AgentWorkflowSettingsClient({
<Search className="h-4 w-4 text-emerald-600" />
</div>
<div>
<CardTitle className="text-base">素材搜索接口</CardTitle>
<CardDescription>配置白底图、详情参考图、SKU 参考图共用接口</CardDescription>
<CardTitle className="text-base">第三方图片素材配置</CardTitle>
<CardDescription>
配置白底图、任务-详情整套预览、详情参考图和 SKU 参考图共用接口
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">启用素材搜索</div>
<div className="text-sm font-medium">启用第三方图片素材</div>
<div className="text-xs text-muted-foreground">
开启后支持搜索白底素材图、详情参考图和 SKU 参考图
开启后前台和预留接口都可以查询标准模型白底图与任务-详情整套素材
</div>
</div>
<Switch
@ -6890,6 +6891,22 @@ export function AgentWorkflowSettingsClient({
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label className="text-xs">统一接口路径</Label>
<Input
value={materialApiConfig.endpointPath || "/standard-model-white-background"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
endpointPath: e.target.value,
}))
}
placeholder="/standard-model-white-background"
/>
<p className="text-xs text-muted-foreground">
按文档默认调用 /api/external/standard-model-white-background;也支持填写完整 URL。
</p>
</div>
<div className="space-y-2">
<Label className="text-xs">API Key</Label>
<Input
@ -6902,6 +6919,104 @@ export function AgentWorkflowSettingsClient({
/>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">标准模型白底图</div>
<div className="text-xs text-muted-foreground">
对应 resourceType=white-background
</div>
</div>
<Switch
checked={materialApiConfig.whiteBackgroundEnabled !== false}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
whiteBackgroundEnabled: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">任务-详情整套预览</div>
<div className="text-xs text-muted-foreground">
对应 resourceType=task-detail
</div>
</div>
<Switch
checked={materialApiConfig.taskDetailEnabled !== false}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
taskDetailEnabled: checked,
}))
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label className="text-xs">任务详情资源主域名</Label>
<Input
value={materialApiConfig.resourceBaseUrl || "https://s3.jiansun.vip"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
resourceBaseUrl: e.target.value,
}))
}
placeholder="https://s3.jiansun.vip"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">备用资源域名</Label>
<Input
value={materialApiConfig.fallbackResourceBaseUrl || "https://s3.yydd.ai"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
fallbackResourceBaseUrl: e.target.value,
}))
}
placeholder="https://s3.yydd.ai"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">请求超时 ms</Label>
<Input
type="number"
min={3000}
max={120000}
step={1000}
value={materialApiConfig.requestTimeoutMs || 30000}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
requestTimeoutMs: Number(e.target.value) || 30000,
}))
}
/>
</div>
</div>
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
已预留前端接口:
<code className="mx-1 rounded bg-muted px-1 py-0.5">
/api/third-party-image-materials/search
</code>
,支持
<code className="mx-1 rounded bg-muted px-1 py-0.5">
resourceType=white-background|task-detail
</code>
与
<code className="mx-1 rounded bg-muted px-1 py-0.5">
sku/spu
</code>
查询。
</div>
<Separator />
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">

View File

@ -2430,17 +2430,17 @@ export function PsdTemplatesSettingsClient({
);
return (
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
<div className="settings-workspace min-h-screen bg-muted/10">
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-rose-600 shadow-lg shadow-orange-500/20">
<Sparkles className="h-5 w-5 text-white" />
<div className="container mx-auto px-4 py-3 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
<Sparkles className="h-4 w-4 text-background" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">PSD 模板配置</h1>
<p className="text-sm text-muted-foreground">
<h1 className="text-base font-semibold">PSD 模板配置</h1>
<p className="text-xs text-muted-foreground">
上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出
</p>
</div>
@ -2457,7 +2457,6 @@ export function PsdTemplatesSettingsClient({
onClick={handleSaveTemplate}
disabled={!draftTemplate || isSaving}
size="sm"
className="bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -2472,8 +2471,8 @@ export function PsdTemplatesSettingsClient({
</header>
<div className="border-b bg-muted/30">
<div className="container mx-auto px-6 py-3">
<div className="flex flex-wrap items-center gap-4">
<div className="container mx-auto px-4 py-2.5 sm:px-5">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary" className="font-mono">
{templates.length}
</Badge>
@ -2509,8 +2508,8 @@ export function PsdTemplatesSettingsClient({
</div>
</div>
<div className="container mx-auto px-6 py-6">
<div className="space-y-6">
<div className="container mx-auto px-4 py-4 sm:px-5">
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">品类标签</CardTitle>
@ -2581,7 +2580,10 @@ export function PsdTemplatesSettingsClient({
<Card>
<CardHeader>
<CardTitle className="text-base">上传新模板</CardTitle>
<CardDescription>支持 PSD / PSB,建议图层命名清晰,便于后台配置图片位和文字位。</CardDescription>
<CardDescription>
支持 PSD / PSB,建议图层命名清晰,便于后台配置图片位和文字位。复杂 PSB
如提示不兼容,请先在 Photoshop 中另存为 PSD 后上传。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">

View File

@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Plus, Save, Trash2, Wand2 } from "lucide-react";
import { Plus, Save, Trash2, Wand2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
@ -25,6 +25,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { fetcher } from "@/lib/utils";
import { AdminSettingsNavigation } from "../_components/admin-settings-navigation";
import {
STYLE_PRO_CONFIG_KEY,
defaultStyleProConfig,
@ -322,6 +323,7 @@ function CustomFieldEditor({
}
export function StyleProSettingsClient() {
const router = useRouter();
const { data, mutate } = useSWR<SettingsResponse>(
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
fetcher
@ -561,19 +563,17 @@ export function StyleProSettingsClient() {
};
return (
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="settings-workspace w-full px-3 py-3 sm:px-4 xl:px-5">
<AdminSettingsNavigation
activeTab="style-pro"
onTabChange={(value) => router.push(`/settings?tab=${value}`)}
/>
<main className="mt-6 flex w-full flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="mb-3">
<Button asChild variant="outline">
<Link href="/settings">
<ArrowLeft className="mr-2 h-4 w-4" />
返回设置
</Link>
</Button>
</div>
<h1 className="font-semibold text-3xl tracking-tight">风格化 PRO 设置</h1>
<p className="mt-2 max-w-2xl text-muted-foreground">
<h1 className="font-semibold text-xl">风格化 PRO 设置</h1>
<p className="mt-1 max-w-2xl text-muted-foreground text-xs leading-5">
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
</p>
</div>
@ -592,7 +592,7 @@ export function StyleProSettingsClient() {
{saving ? "保存中" : "保存配置"}
</Button>
</div>
</div>
</div>
<Card>
<CardHeader>
@ -1193,6 +1193,7 @@ export function StyleProSettingsClient() {
))}
</CardContent>
</Card>
</main>
</main>
</div>
);
}

View File

@ -1,5 +1,7 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
@ -12,6 +14,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@ -30,6 +33,9 @@ import {
getVideoCanvasFunctionBillingCopy,
type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition,
type VideoCanvasEffectPreset,
type VideoCanvasMotionPreset,
type VideoCanvasRolePreset,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
@ -199,8 +205,9 @@ export function VideoCanvasSettingsClient() {
const functions = useMemo(
() =>
Object.values(config.functions).sort((a, b) => {
const kindCompare = a.kind.localeCompare(b.kind);
return kindCompare || a.label.localeCompare(b.label);
const compare = (left: string, right: string) =>
left === right ? 0 : left < right ? -1 : 1;
return compare(a.kind, b.kind) || compare(a.label, b.label);
}),
[config.functions],
);
@ -225,6 +232,135 @@ export function VideoCanvasSettingsClient() {
}));
};
const updateEffectPreset = (
index: number,
updates: Partial<VideoCanvasEffectPreset>,
) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const updateMotionPreset = (
index: number,
updates: Partial<VideoCanvasMotionPreset>,
) => {
setConfig((current) => ({
...current,
motionPresets: current.motionPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addMotionPreset = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
motionPresets: [
...current.motionPresets,
{
id: `motion-${createdAt}`,
name: "新运镜预设",
previewUrl: "/video-canvas/effects/multi-angle.webp",
prompt: "镜头平稳移动,保持主体和空间关系清晰。",
},
],
}));
};
const removeMotionPreset = (index: number) => {
setConfig((current) => ({
...current,
motionPresets: current.motionPresets.filter(
(_, presetIndex) => presetIndex !== index,
),
}));
};
const addEffectPreset = () => {
setConfig((current) => ({
...current,
effectPresets: [
...current.effectPresets,
{
id: `effect-${Date.now()}`,
title: "新视频特效",
author: "官方预设",
usageCount: 0,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/product-light-sweep.webp",
prompt: "保持参考主体稳定,应用自然、连续的视频特效和镜头运动。",
commercial: true,
},
],
}));
};
const removeEffectPreset = (index: number) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const updateRolePreset = (
index: number,
updates: Partial<VideoCanvasRolePreset>,
) => {
setConfig((current) => ({
...current,
rolePresets: current.rolePresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addRolePreset = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
rolePresets: [
...current.rolePresets,
{
id: `role-${createdAt}`,
assetId: `public-role:role-${createdAt}`,
name: "新角色预设",
accent: "#525252",
description: "管理员配置的公共角色素材。",
prompt: "保持角色身份、五官、发型、服装和体型在所有视频镜头中一致。",
tags: "角色",
compliant: true,
images: {
expression: "",
face: "",
full: "/video-canvas/effects/product-light-sweep.webp",
threeView: "",
},
filters: {
age: "青年",
build: "匀称",
culture: "东亚",
era: "现代",
gender: "中性",
hairColor: "黑发",
species: "人类",
},
},
],
}));
};
const removeRolePreset = (index: number) => {
setConfig((current) => ({
...current,
rolePresets: current.rolePresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const save = async () => {
setSaving(true);
try {
@ -255,11 +391,17 @@ export function VideoCanvasSettingsClient() {
};
return (
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">视频画布功能设置</h1>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
<Button asChild className="mb-2" size="sm" variant="outline">
<Link href="/settings">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
返回设置
</Link>
</Button>
<h1 className="text-xl font-semibold">视频画布功能设置</h1>
<p className="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">
这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。
</p>
</div>
@ -321,6 +463,354 @@ export function VideoCanvasSettingsClient() {
</CardContent>
</Card>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频运镜素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“运镜广场”的名称、预览图和插入提示词都从这里读取。点击运镜卡会按竞品规则把提示词追加到用户输入框,允许组合和重复使用。
</p>
</div>
<Button onClick={addMotionPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加运镜
</Button>
</div>
<div className="grid gap-3">
{config.motionPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-14 shrink-0 rounded-md object-cover"
src={preset.previewUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.prompt || "未设置插入提示词"}
</div>
</div>
<Button
aria-label={`删除运镜 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeMotionPreset(index);
}}
size="icon"
title={`删除运镜 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`motion-name-${index}`}>运镜名称</Label>
<Input
id={`motion-name-${index}`}
onChange={(event) =>
updateMotionPreset(index, { name: event.target.value })
}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`motion-preview-${index}`}>预览图地址</Label>
<Input
id={`motion-preview-${index}`}
onChange={(event) =>
updateMotionPreset(index, { previewUrl: event.target.value })
}
value={preset.previewUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`motion-prompt-${index}`}>插入提示词</Label>
<Textarea
id={`motion-prompt-${index}`}
onChange={(event) =>
updateMotionPreset(index, { prompt: event.target.value })
}
value={preset.prompt}
/>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频特效素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“特效”面板、素材节点封面、模型约束和生成提示词都从这里读取。选择特效时会创建结构化素材节点,不会把描述硬写进用户提示词。
</p>
</div>
<Button onClick={addEffectPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加特效
</Button>
</div>
<div className="grid gap-3">
{config.effectPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-12 w-12 shrink-0 rounded-md object-cover"
src={preset.thumbnailUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.title}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.author} · {preset.model} · {preset.usageCount.toLocaleString()} 次使用
</div>
</div>
<Button
aria-label={`删除特效 ${preset.title}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeEffectPreset(index);
}}
size="icon"
title={`删除特效 ${preset.title}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`effect-title-${index}`}>特效名称</Label>
<Input
id={`effect-title-${index}`}
onChange={(event) => updateEffectPreset(index, { title: event.target.value })}
value={preset.title}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-author-${index}`}>作者</Label>
<Input
id={`effect-author-${index}`}
onChange={(event) => updateEffectPreset(index, { author: event.target.value })}
value={preset.author}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-model-${index}`}>适用模型</Label>
<Input
id={`effect-model-${index}`}
onChange={(event) => updateEffectPreset(index, { model: event.target.value })}
value={preset.model}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-count-${index}`}>展示使用次数</Label>
<Input
id={`effect-count-${index}`}
min={0}
onChange={(event) =>
updateEffectPreset(index, {
usageCount: Math.max(0, Number(event.target.value) || 0),
})
}
type="number"
value={preset.usageCount}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-thumbnail-${index}`}>封面地址</Label>
<Input
id={`effect-thumbnail-${index}`}
onChange={(event) =>
updateEffectPreset(index, { thumbnailUrl: event.target.value })
}
value={preset.thumbnailUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-prompt-${index}`}>生成提示词 / 后端规则</Label>
<Textarea
id={`effect-prompt-${index}`}
onChange={(event) => updateEffectPreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.commercial}
id={`effect-commercial-${index}`}
onCheckedChange={(checked) => updateEffectPreset(index, { commercial: checked })}
/>
<Label htmlFor={`effect-commercial-${index}`}>标记为可商用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频角色素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“角色库”的公共角色、Seedance 合规资产 ID、四类角色图和隐藏生成提示词都从这里读取。角色引用通过画布连线保存,不会改写用户输入框。
</p>
</div>
<Button onClick={addRolePreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加角色
</Button>
</div>
<div className="grid gap-3">
{config.rolePresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-11 shrink-0 rounded-md object-cover object-top"
src={preset.images.full}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.assetId} · {preset.tags || "未设置标签"}
</div>
</div>
<Button
aria-label={`删除角色 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeRolePreset(index);
}}
size="icon"
title={`删除角色 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`role-name-${index}`}>角色名称</Label>
<Input
id={`role-name-${index}`}
onChange={(event) => updateRolePreset(index, { name: event.target.value })}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`role-asset-${index}`}>合规资产 ID</Label>
<Input
id={`role-asset-${index}`}
onChange={(event) => updateRolePreset(index, { assetId: event.target.value })}
value={preset.assetId}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-tags-${index}`}>展示标签</Label>
<Input
id={`role-tags-${index}`}
onChange={(event) => updateRolePreset(index, { tags: event.target.value })}
value={preset.tags}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-description-${index}`}>角色说明</Label>
<Textarea
id={`role-description-${index}`}
onChange={(event) => updateRolePreset(index, { description: event.target.value })}
value={preset.description}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-prompt-${index}`}>生成提示词 / 身份一致性规则</Label>
<Textarea
id={`role-prompt-${index}`}
onChange={(event) => updateRolePreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
{([
["full", "角色立绘"],
["face", "脸部近景"],
["expression", "表情参考"],
["threeView", "三视图"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-${key}-${index}`}>{label}地址</Label>
<Input
id={`role-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
images: { ...preset.images, [key]: event.target.value },
})
}
value={preset.images[key]}
/>
</div>
))}
{([
["gender", "性别"],
["age", "年龄段"],
["species", "种族"],
["era", "时代"],
["culture", "文化区域"],
["build", "体型"],
["hairColor", "发色"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-filter-${key}-${index}`}>{label}</Label>
<Input
id={`role-filter-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
filters: { ...preset.filters, [key]: event.target.value },
})
}
value={preset.filters[key]}
/>
</div>
))}
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.compliant}
id={`role-compliant-${index}`}
onCheckedChange={(checked) => updateRolePreset(index, { compliant: checked })}
/>
<Label htmlFor={`role-compliant-${index}`}>可在 Seedance2.0 合规素材库中使用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>

View File

@ -17,6 +17,7 @@ import {
requiresSingleActiveSession,
revokeSingleActiveSession,
} from "@/lib/auth/session-control";
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
export type UserType = "guest" | "regular" | "admin" | "agent_admin";
@ -200,7 +201,10 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
}
const nextAuth = NextAuth(async () => {
const runtimeAuthSettings = await getAuthSettings().catch(() => null as any);
const runtimeAuthSettings =
process.env.CANVAS_E2E_LOCAL === "true"
? null
: await getAuthSettings().catch(() => null as any);
const resolvedAuthUrl =
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
const googleClientId =
@ -283,6 +287,32 @@ const nextAuth = NextAuth(async () => {
delete token.sessionKey;
}
if (account && resolvedUserId) {
await recordAccessIpEventFromHeaders({
eventType: "login_success",
userId: resolvedUserId,
userEmail:
typeof token.email === "string"
? token.email
: typeof user?.email === "string"
? user.email
: null,
userRole: resolvedUserType || "regular",
method: "POST",
path: `/api/auth/${account.provider}`,
routeGroup: "auth",
statusCode: 200,
outcome: "login_success",
severity: "info",
reasons: ["登录成功"],
metadata: {
provider: account.provider,
},
}).catch((error) => {
console.warn("[Auth] Failed to record login access event:", error);
});
}
return token;
},
session({ session, token }) {

View File

@ -501,11 +501,6 @@ export default function Page() {
const displayLogo = mounted
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
: siteLogo;
const shouldInvertLogo =
mounted &&
resolvedTheme === "dark" &&
!siteLogoDark?.trim() &&
Boolean(siteLogo?.trim());
const hasThirdPartyLogin =
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
(isGoogleEnabled && isGoogleConfigured);
@ -545,7 +540,7 @@ export default function Page() {
<img
src={displayLogo}
alt={siteName}
className={`mb-2 h-12 w-auto object-contain ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
className="mb-2 h-12 w-auto object-contain"
/>
) : null}
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>

View File

@ -1,7 +1,44 @@
import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
import { existsSync } from "node:fs";
import path from "node:path";
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?v=f2b345db-eng1";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "HiCAD",
};
export const runtime = "nodejs";
export default function AiCadPage() {
return <AiCadWorkbench />;
export default async function AiCadPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login?callbackUrl=%2Fai-cad");
}
const isReady = existsSync(
path.join(process.cwd(), "public/vendor/zoo-modeling-app/index.html")
);
if (isReady) {
redirect(ZOO_MODELING_APP_SRC);
}
return (
<main className="flex h-svh w-full items-center justify-center bg-[#10100e] p-6 text-[#f8f6ef]">
<section className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
<h1 className="font-semibold text-lg">HiCAD 官方前端尚未构建</h1>
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
请运行
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
pnpm zoo-modeling-app:vendor
</code>
生成官方前端,然后刷新本页。
</p>
</section>
</main>
);
}

View File

@ -52,6 +52,8 @@ export function normalizeAgentReportHeadingLine(line: string): string {
.trim();
const knownTitles = [
"意图分析",
"任务规划",
"需求理解",
"图片分析",
"执行计划",

View File

@ -1286,6 +1286,28 @@ function applyDetailReplacementScenePlanGuards(
next.routeReason = appendUniqueInstruction(next.routeReason, params.visualProfile.reason, 220);
}
if (
params.hasModelImage &&
!isProductOnly &&
!next.useModelImage &&
(next.peoplePolicy !== "none" ||
next.generationRoute === "people_relation_locked" ||
next.pageKind === "people_scene" ||
next.pageKind === "action_poster")
) {
next = {
...next,
useModelImage: true,
modelImageReason:
next.modelImageReason ||
"本屏已判定为人物/动作详情页,必须使用用户提供的模特图锁定人物身份。",
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏出现人物时必须使用用户提供的同一模特:脸型五官、发型发色、体型肤色和穿搭风格保持一致;不得换成其他人物。",
),
};
}
return next;
}
@ -1723,19 +1745,31 @@ export function normalizeAgentModeReferences(
): AgentReplacementModePayload["references"] {
if (!payload?.references?.length) return [];
return payload.references
.map((item) => ({
id: String(item.id || "").trim(),
label: String(item.label || "").trim() || "参考图",
imageUrl: String(item.imageUrl || "").trim(),
candidateId: String(item.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(item.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(item.sortOrder))))
: 0,
passthrough: item.passthrough === true,
assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex))
? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex))))
: null,
}))
.map((item) => {
const rerunReferenceImages = Array.isArray(item.rerunReferenceImages)
? item.rerunReferenceImages
.map((image) => normalizeAgentModeSourceImage(image))
.filter(
(image): image is NonNullable<ReturnType<typeof normalizeAgentModeSourceImage>> =>
Boolean(image)
)
.slice(0, 6)
: [];
return {
id: String(item.id || "").trim(),
label: String(item.label || "").trim() || "参考图",
imageUrl: String(item.imageUrl || "").trim(),
candidateId: String(item.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(item.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(item.sortOrder))))
: 0,
passthrough: item.passthrough === true,
assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex))
? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex))))
: null,
...(rerunReferenceImages.length > 0 ? { rerunReferenceImages } : {}),
};
})
.filter((item) => item.id && /^https?:\/\//i.test(item.imageUrl))
.sort((left, right) => left.sortOrder - right.sortOrder);
}
@ -2210,7 +2244,7 @@ export async function analyzeReplacementExecutionPlan(params: {
isDetailReplacement
? "用户上传图可能是产品、材质、板材、原材料或细节参考,不要因为它不是完整商品主体就排除;后续应根据上传图外观与参考图里的材质描述共同判断处理方向。"
: "",
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级。",
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级;如果画面里有产品主体/展示区,它也决定产品的拍摄角度、朝向、透视、可见面数量、占比和标注指向。",
isProductOnlyDetailReplacement
? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。"
: isDetailReplacement
@ -2248,12 +2282,12 @@ export async function analyzeReplacementExecutionPlan(params: {
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
: "",
isDetailReplacement
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。"
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
: "",
isProductOnlyDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
: isDetailReplacement
? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若是动作海报,必须写清人物数量、围坐/试坐/承重等动作、产品形状和版式必须照参考图,同时写清“前置词包必须完整重建原照片内容区里的背景/空间/软装/灯光/产品场景,不能原图复刻”;若是产品尺寸页,必须写清白底抠图、尺寸线、单位和数字标注,不允许室内场景;若 promptPackPolicy=allow/soft 且 peoplePolicy=preserve_relation,明确“保留人物数量/动作/关系与文字层级,同时使用前置词包重建背景/产品场景”。"
? "8. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令。若 promptPackPolicy=forbid,必须明确“不使用前置词包,参考图语义优先”;若是动作海报,必须写清人物数量、围坐/试坐/承重等动作、产品形状和版式必须照参考图,同时写清“前置词包必须完整重建原照片内容区里的背景/空间/软装/灯光/产品场景,不能原图复刻”;若是产品尺寸页,必须写清白底抠图、尺寸线、单位和数字标注,不允许室内场景;若 promptPackPolicy=allow/soft 且 peoplePolicy=preserve_relation,明确“保留人物数量/动作/关系与文字层级,同时使用前置词包重建背景/产品场景”。只要参考图有产品主体,generationPrompt 必须明确产品拍摄角度、朝向、透视和标注指向以参考图为准。"
: "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述这一屏需要的场景、机位、主体位置、周边环境重建方向以及必须保留的内容。",
isProductOnlyDetailReplacement
? "5. sceneSummary、styleSummary 和 generationPrompt 必须强调‘只替换产品,不动其他内容’,不要引导重建场景。"
@ -2421,6 +2455,7 @@ export function buildAgentReplacementPrompt(params: {
hasModelImage: boolean;
productImageCount: number;
originalProductImageCount: number;
rerunReferenceCount?: number;
templateReferenceCount: number;
pairedProductIndex?: number;
scenePlan?: ReplacementScenePlan | null;
@ -2455,14 +2490,14 @@ export function buildAgentReplacementPrompt(params: {
? `图片${nextImageIndex}`
: `图片${nextImageIndex}-${nextImageIndex + params.productImageCount - 1}`;
imageOrderLines.push(
`${productRangeLabel}:用户上传的产品/材质/板材/原材料或细节参考图,必须综合理解外观、材质、颜色、纹理和结构,不可只看第一张,也不要因为它不是完整商品主体就忽略。`
`${productRangeLabel}:用户上传的产品/材质/板材/原材料或细节参考图,必须综合理解外观、材质、颜色、纹理和结构,不可只看第一张,也不要因为它不是完整商品主体就忽略;如结果中需要生成商品主体,商品拍摄角度、朝向、透视和画面占比必须以最后一张参考图为准,不得直接沿用这些上传图的拍摄角度。`
);
nextImageIndex += params.productImageCount;
}
if (params.hasModelImage) {
imageOrderLines.push(
`图片${nextImageIndex}:模特图,仅在当前参考图本身包含人物展示时参考;如果参考图是多人,只把这张模特图作为主人物身份/气质参考,其余人物按参考图的人数、关系和风格补齐。`
`图片${nextImageIndex}:用户提供的固定模特图,是人物身份锁定图;当前屏需要人物时,必须复用这张图里的同一人物(脸型五官、发型发色、体型肤色、穿搭风格保持一致)。如果参考图是多人,只把这张模特图作为主人物身份参考,其余人物按参考图的人数、关系和风格补齐。`
);
nextImageIndex += 1;
}
@ -2478,8 +2513,20 @@ export function buildAgentReplacementPrompt(params: {
nextImageIndex += params.templateReferenceCount;
}
const rerunReferenceCount = Math.max(0, Math.round(Number(params.rerunReferenceCount || 0)));
if (rerunReferenceCount > 0) {
const rerunRangeLabel =
rerunReferenceCount === 1
? `图片${nextImageIndex}`
: `图片${nextImageIndex}-${nextImageIndex + rerunReferenceCount - 1}`;
imageOrderLines.push(
`${rerunRangeLabel}:本次重新生图的补充参考图,只用于理解用户新增的风格、构图、细节或局部调整方向;不得覆盖最终参考图底板的版式、文字层级和页面结构。`
);
nextImageIndex += rerunReferenceCount;
}
imageOrderLines.push(
`图片${nextImageIndex}:当前参考图底板(唯一版式基准图),必须保留它的文字排版、信息区、分割线、留白、机位和整体结构。`
`图片${nextImageIndex}:当前参考图底板(唯一版式基准图),必须保留它的文字排版、信息区、分割线、留白、机位和整体结构;如果它包含产品主体/展示区,它同时决定产品姿态、朝向、透视、可见面数量、占比和标注指向。`
);
const marketplaceBrandRemovalLines =
params.modeType === "detail_replacement" && params.marketplaceProductDetailReference
@ -2489,6 +2536,12 @@ export function buildAgentReplacementPrompt(params: {
"如果参考图标题、标签或画面中出现可识别的外部品牌字样,不要保留该品牌名,不要换成相似字,不要生成伪 Logo;需要文字时只保留非品牌卖点语义或改成无品牌通用描述。",
]
: [];
const productAngleLockLines = [
"【产品角度锁定规则】最终商品/主体的拍摄角度以最后一张参考图为准,而不是以前面的产品输入图为准;前面的产品图只提供款式、材质、颜色、结构和细节。",
"必须逐项对齐最后一张参考图中的产品姿态:朝向、正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向、可见侧面数量、支架/腿部/把手/转轴位置、主体外轮廓、占比、重心和透视消失方向。",
"如果最后一张参考图的产品是侧视或低视角,不允许改成俯视三分之四;如果是俯视三分之四,不允许改成正侧视;不允许镜像、旋转、翻转或把产品重画成另一个角度。",
"若参考图含功能标签、箭头、尺寸线、编号或引导线,它们指向的实际结构部位必须保持一致,不得因产品转角改变而让标签指向错误位置。",
];
const normalizedBasePrompt = String(params.basePrompt || "").trim();
const promptPackInstructionLines =
params.modeType === "detail_replacement" &&
@ -2555,7 +2608,7 @@ export function buildAgentReplacementPrompt(params: {
params.scenePlan.useModelImage
? params.scenePlan.modelImageReason
? `本屏允许使用模特图:${params.scenePlan.modelImageReason}`
: "本屏允许使用模特图,但仅在参考图当前这一屏确实存在人物展示需求时使用。"
: "本屏允许使用模特图;只要最终画面出现人物,就必须使用用户提供的同一模特身份。"
: params.hasModelImage
? params.scenePlan.modelImageReason
? `本屏禁止使用模特图:${params.scenePlan.modelImageReason}`
@ -2588,6 +2641,7 @@ export function buildAgentReplacementPrompt(params: {
...promptPackInstructionLines,
...scenePlanLines,
...marketplaceBrandRemovalLines,
...productAngleLockLines,
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
"最后一张参考图是唯一底板,除产品主体/产品展示区外,其余所有内容都必须保持不变。",
@ -2616,6 +2670,7 @@ export function buildAgentReplacementPrompt(params: {
...promptPackInstructionLines,
...scenePlanLines,
...marketplaceBrandRemovalLines,
...productAngleLockLines,
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
isReferencePosterActionPlan
@ -2730,6 +2785,7 @@ export function buildAgentReplacementPrompt(params: {
"【图片顺序规则】",
...imageOrderLines,
...scenePlanLines,
...productAngleLockLines,
params.pairedProductIndex !== undefined
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
: "请使用输入商品图作为本张参考图的一对一替换主体。",
@ -2754,7 +2810,7 @@ export function buildAgentReplacementPrompt(params: {
if (params.hasModelImage) {
instructionLines.push(
"如有模特图,请在需要人物展示时保持模特体态、穿搭和出镜关系一致;多人参考图要保留参考图人数和互动关系,只把上传模特作为主人物参考,其余人物按参考图风格补齐。"
"固定模特身份硬约束:如果本屏最终画面出现人物,必须使用上传模特图中的同一人物;脸型五官、发型发色、体型肤色和穿搭风格必须保持一致,不得换脸、换人、换发型或改成无关人物。多人参考图要保留参考图人数和互动关系,只把上传模特作为主人物参考,其余人物按参考图风格补齐。"
);
}

View File

@ -1,6 +1,7 @@
import { createLogger } from "@/lib/observability/logger";
import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image";
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";
@ -12,7 +13,12 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"bfl-image",
"ruxa-image",
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"runway",
"krea",
@ -25,6 +31,29 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000;
const imageFailoverModelCooldown = new Map<string, { until: number; reason: string }>();
export function resolveImageModelRuntimeId(
model: { defaultParams?: unknown; modelId?: unknown; id?: unknown } | null | undefined,
) {
const defaultParams = model?.defaultParams;
const defaultEndpoint =
typeof defaultParams === "object" &&
defaultParams !== null &&
"endpoint" in defaultParams
? String((defaultParams as Record<string, unknown>).endpoint || "").trim()
: "";
if (defaultEndpoint) {
return defaultEndpoint;
}
const modelId = String(model?.modelId || "").trim();
if (modelId) {
return modelId;
}
return String(model?.id || "").trim();
}
export function normalizeFailoverErrorMessage(error: unknown): string {
if (!error) return "";
if (typeof error === "string") return error;
@ -47,7 +76,16 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean
msg.includes("api key is missing") ||
msg.includes("invalid api key") ||
msg.includes("unauthorized") ||
msg.includes("cannot convert argument to a bytestring")
msg.includes("cannot convert argument to a bytestring") ||
msg.includes("model maintenance") ||
msg.includes("maintenance") ||
msg.includes("维护中") ||
msg.includes("维护") ||
msg.includes("service unavailable") ||
msg.includes("temporarily unavailable") ||
msg.includes("系统繁忙") ||
msg.includes("无可用渠道") ||
/(?:^|\D)(?:429|500|502|503|504)(?:\D|$)/.test(msg)
);
}
@ -77,14 +115,142 @@ export type ImageFallbackCandidate = {
provider: any;
configured: boolean;
sameProvider: boolean;
nameSimilarity: number;
sameName: boolean;
};
const IMAGE_FAILOVER_SAME_NAME_THRESHOLD = 0.5;
function getModelRefKeys(item: { model?: any } | null | undefined) {
return [
String(item?.model?.id || "").trim(),
String(item?.model?.modelId || "").trim(),
].filter(Boolean);
}
function normalizeModelNameForSimilarity(value: unknown) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[「」『』【】[\]()(){}]/g, " ")
.replace(/[_/|::,,.。+]+/g, "-")
.replace(/\s+/g, " ");
}
function tokenizeModelNameForSimilarity(value: unknown) {
const normalized = normalizeModelNameForSimilarity(value);
const compact = normalized.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "");
const tokens = new Set(
normalized
.split(/[^a-z0-9\u4e00-\u9fa5]+/g)
.map((item) => item.trim())
.filter((item) => item.length >= 2)
);
if (compact.length >= 2) {
tokens.add(compact);
for (let index = 0; index < compact.length - 1; index += 1) {
tokens.add(compact.slice(index, index + 2));
}
}
return tokens;
}
function scoreModelNameSimilarity(
source: { model?: any } | null | undefined,
candidate: { model?: any } | null | undefined
) {
const sourceNames = [
source?.model?.displayName,
source?.model?.modelId,
].filter(Boolean);
const candidateNames = [
candidate?.model?.displayName,
candidate?.model?.modelId,
].filter(Boolean);
let bestScore = 0;
for (const sourceName of sourceNames) {
const sourceTokens = tokenizeModelNameForSimilarity(sourceName);
if (sourceTokens.size === 0) continue;
for (const candidateName of candidateNames) {
const candidateTokens = tokenizeModelNameForSimilarity(candidateName);
if (candidateTokens.size === 0) continue;
let overlap = 0;
sourceTokens.forEach((token) => {
if (candidateTokens.has(token)) overlap += 1;
});
const score = overlap / Math.max(sourceTokens.size, candidateTokens.size);
bestScore = Math.max(bestScore, score);
}
}
return bestScore;
}
function resolveCurrentImageModelEntry(
allModels: Array<{ model: any; provider: any }>,
refs: string[]
) {
const normalizedRefs = new Set(refs.map((item) => String(item || "").trim()).filter(Boolean));
if (normalizedRefs.size === 0) return null;
return (
allModels.find((item) =>
getModelRefKeys(item).some((key) => normalizedRefs.has(key))
) || null
);
}
function messagePartHasImageInput(part: any) {
if (!part || typeof part !== "object") return false;
if (part.type === "file" || part.type === "image" || part.type === "image_url") {
return true;
}
if (typeof part.url === "string" && /^https?:\/\//i.test(part.url)) {
return true;
}
if (typeof part.image === "string" && part.image.trim()) {
return true;
}
if (typeof part.image_url?.url === "string" && part.image_url.url.trim()) {
return true;
}
return false;
}
function countImageInputPartsInMessage(message: any) {
const countParts = (parts: any[] | undefined) =>
Array.isArray(parts) ? parts.filter(messagePartHasImageInput).length : 0;
const contentCount = countParts(Array.isArray(message?.content) ? message.content : undefined);
if (contentCount > 0) return contentCount;
return countParts(Array.isArray(message?.parts) ? message.parts : undefined);
}
export function countChatMessageImageInputs(messages: ChatMessage[]) {
return messages.reduce(
(count, message) => count + countImageInputPartsInMessage(message),
0
);
}
export function chatMessagesHaveImageInput(messages: ChatMessage[]) {
return countChatMessageImageInputs(messages) > 0;
}
export function getImageFailoverConvertProvider(providerType?: string | null) {
switch (providerType) {
case "bfl-image":
case "ruxa-image":
case "fal-image":
case "snapgen-image":
case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "stepfun-image":
case "midjourney":
case "runway":
@ -111,6 +277,10 @@ export function buildImageFallbackCandidates(
}
) {
const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options;
const currentEntry = resolveCurrentImageModelEntry(allModels, [
currentModelId || "",
currentModelKey || "",
]);
const configuredRefs = configuredFallbackIds
.map((item) => String(item || "").trim())
.filter(Boolean);
@ -150,6 +320,7 @@ export function buildImageFallbackCandidates(
const candidates: ImageFallbackCandidate[] = [];
const seenModelIds = new Set<string>();
const candidateOrder = new Map<string, number>();
const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => {
if (!item?.model) return;
const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId);
@ -157,11 +328,15 @@ export function buildImageFallbackCandidates(
const modelId = String(item.model.id || "");
if (!modelId || seenModelIds.has(modelId)) return;
seenModelIds.add(modelId);
candidateOrder.set(modelId, candidateOrder.size);
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
candidates.push({
model: item.model,
provider: item.provider,
configured,
sameProvider,
nameSimilarity,
sameName: nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
});
};
@ -175,7 +350,24 @@ export function buildImageFallbackCandidates(
.forEach((item) => pushCandidate(item, false));
}
return candidates;
if (currentEntry) {
allModels
.filter((item) => scoreModelNameSimilarity(currentEntry, item) >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD)
.forEach((item) => pushCandidate(item, false));
}
return candidates.sort((left, right) => {
if (left.sameName !== right.sameName) return left.sameName ? -1 : 1;
if (left.configured !== right.configured) return left.configured ? -1 : 1;
if (left.sameProvider !== right.sameProvider) return left.sameProvider ? -1 : 1;
if (right.nameSimilarity !== left.nameSimilarity) {
return right.nameSimilarity - left.nameSimilarity;
}
return (
(candidateOrder.get(String(left.model.id || "")) ?? 0) -
(candidateOrder.get(String(right.model.id || "")) ?? 0)
);
});
}
export async function generateImageWithFailoverCandidate(params: {
@ -186,14 +378,25 @@ export async function generateImageWithFailoverCandidate(params: {
}) {
const { candidate, messages, imageParams, fallbackImageParams } = params;
const providerType = String(candidate.provider?.type || "");
const candidateRuntimeModelId = String(candidate.model?.modelId || candidate.model?.id || "");
const candidateRuntimeModelId = resolveImageModelRuntimeId(candidate.model);
if (
chatMessagesHaveImageInput(messages) &&
modelLacksReferenceImageInputSupport(candidate.model, candidate.provider)
) {
throw new Error(
`备用模型不支持参考图/图生图输入:${candidate.model.displayName || candidate.model.id}`
);
}
if (
providerType === "fal-image" &&
isFalGptImage2EditModelId(candidateRuntimeModelId)
) {
throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移");
}
const candidateLanguageModel = await getLanguageModelById(candidate.model.id);
const candidateLanguageModel = await getLanguageModelById(
candidate.model.id,
candidateRuntimeModelId
);
if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`);
}
@ -202,11 +405,24 @@ export async function generateImageWithFailoverCandidate(params: {
messages,
getImageFailoverConvertProvider(providerType)
);
const effectiveImageParams = imageParams || fallbackImageParams;
const imageInputCount = countChatMessageImageInputs(messages);
chatApiLogger.info("[Chat API] Image failover candidate params:", {
model: candidate.model?.modelId || candidate.model?.id,
providerType,
aspectRatio: effectiveImageParams?.aspectRatio,
imageSize: effectiveImageParams?.imageSize,
imageCount: effectiveImageParams?.imageCount,
hasImageInput: imageInputCount > 0,
imageInputCount,
multiImageInput: imageInputCount > 1,
});
const candidateResult = await (candidateLanguageModel as any).doGenerate({
messages: candidateMessages,
imageParams: imageParams || fallbackImageParams,
imageParams: effectiveImageParams,
imageGenConfig: candidate.model.imageGenConfig || null,
modelDisplayName: candidate.model.displayName,
overrideModelId: candidate.model.modelId,
overrideModelId: candidateRuntimeModelId || candidate.model.modelId,
});
if (candidateResult?.finishReason === "error") {
@ -214,7 +430,7 @@ export async function generateImageWithFailoverCandidate(params: {
}
return {
text: candidateResult.text || "图片生成失败",
text: candidateResult.text || "",
usedModelId: String(candidate.model.modelId || candidate.model.id || ""),
displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""),
};
@ -224,10 +440,15 @@ export function getImageCrossProviderCandidates(
allModels: any[],
options: {
currentModelId: string;
currentModelKey?: string;
currentProviderId?: string;
providerPriority: Record<string, number>;
}
) {
const currentEntry = resolveCurrentImageModelEntry(allModels, [
options.currentModelId,
options.currentModelKey || "",
]);
return allModels
.filter((m) => {
if (
@ -250,10 +471,37 @@ export function getImageCrossProviderCandidates(
}
return true;
})
.map((item, index) => {
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
return {
...item,
__imageFailoverIndex: index,
__imageFailoverNameSimilarity: nameSimilarity,
__imageFailoverSameName:
nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
};
})
.sort((a, b) => {
if (a.__imageFailoverSameName !== b.__imageFailoverSameName) {
return a.__imageFailoverSameName ? -1 : 1;
}
if (b.__imageFailoverNameSimilarity !== a.__imageFailoverNameSimilarity) {
return b.__imageFailoverNameSimilarity - a.__imageFailoverNameSimilarity;
}
const left = options.providerPriority[a.provider.type as string] ?? 99;
const right = options.providerPriority[b.provider.type as string] ?? 99;
if (left !== right) return left - right;
return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
const sortOrderDiff = (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
if (sortOrderDiff !== 0) return sortOrderDiff;
return (a.__imageFailoverIndex ?? 0) - (b.__imageFailoverIndex ?? 0);
})
.map((item) => {
const {
__imageFailoverIndex,
__imageFailoverNameSimilarity,
__imageFailoverSameName,
...candidate
} = item;
return candidate;
});
}

View File

@ -138,14 +138,14 @@ export function buildAutoModeFallbackMessage(params: {
: "基于当前上传图片处理。";
return [
"### 需求理解",
"### 意图分析",
`- ${demand}`,
"### 图片分析",
params.imageSummary
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
: `- ${sourceLine}`,
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
"### 执行计划",
"### 任务规划",
"- 先完成本轮核心改动,再检查边缘与光影一致性。",
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
"### 输出设置",
@ -154,9 +154,9 @@ export function buildAutoModeFallbackMessage(params: {
}
return [
"### 需求理解",
"### 意图分析",
`- ${demand}`,
"### 执行计划",
"### 任务规划",
"- 先锁定主体与场景,再细化风格、材质与光影。",
"### 输出设置",
"- 默认单图输出,优先稳定命中。",
@ -222,7 +222,7 @@ export function buildPromptOnlyReply(params: {
export function hasStructuredAgentSections(message?: string): boolean {
if (!message) return false;
return /(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
return /(?:###\s*意图分析|【\s*意图分析\s*】|###\s*任务规划|【\s*任务规划\s*】|###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
message,
);
}
@ -253,6 +253,13 @@ export function ensureStructuredAgentFriendlyMessage(params: {
return fallback;
}
if (fallback.includes("### 任务规划\n")) {
return fallback.replace(
"### 任务规划\n",
`### 任务规划\n- 补充说明:${normalizedSupplement}\n`,
);
}
return fallback.replace(
"### 执行计划\n",
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
@ -515,7 +522,12 @@ export async function convertMessagesWithImages(
normalizedProviderType === "bfl-image" ||
normalizedProviderType === "ruxa-image" ||
normalizedProviderType === "fal-image" ||
normalizedProviderType === "snapgen-image" ||
normalizedProviderType === "openai-image" ||
normalizedProviderType === "bananaimage" ||
normalizedProviderType === "visionary" ||
normalizedProviderType === "atlascloud" ||
normalizedProviderType === "vip98k" ||
normalizedProviderType === "stepfun-image" ||
normalizedProviderType === "midjourney" ||
normalizedProviderType === "runway" ||
@ -581,45 +593,71 @@ export async function convertMessagesWithImages(
if (hasAnnotation) {
chatApiLogger.info("[Message Convert] 🎨 检测到标注图片,将自动添加系统提示");
}
// 对于 Gemini,需要特殊处理图片
const convertedMessages = [];
for (const message of preparedMessages) {
const content: any[] = [];
// 🆕 如果是用户消息且有标注图片,在文本前添加系统提示
let hasAddedAnnotationPrompt = false;
// 处理消息的各个部分
for (const part of message.parts) {
if (part.type === "text" && part.text) {
let textContent = part.text;
// 🆕 如果有标注图片且是用户消息,在用户文本前添加系统提示
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt) {
textContent = ANNOTATION_SYSTEM_PROMPT + "\n\n用户请求:" + textContent;
hasAddedAnnotationPrompt = true;
chatApiLogger.info("[Message Convert] 🎨 已注入标注提示词");
}
content.push({
type: "text",
text: textContent,
});
} else if (part.type === "file" && part.url) {
// 直接使用 URL 格式(不再转换为 Base64)
// gemini-image 和 huanke-gemini provider 内部会处理图片上传
continue;
}
const typedPart = part as {
type: string;
url?: string;
image?: string;
image_url?: { url?: string };
data?: string;
};
const candidateImageUrl =
typedPart.type === "file"
? typedPart.url || typedPart.data
: typedPart.type === "image"
? typedPart.image || typedPart.url
: typedPart.type === "image_url"
? typedPart.image_url?.url || typedPart.url
: undefined;
if (typeof candidateImageUrl !== "string" || !candidateImageUrl.trim()) {
continue;
}
const normalizedImageUrl = candidateImageUrl.trim();
// 兼容 file/image/image_url 三类输入;Gemini 内部会将图片交给 S3 后再请求生图网关。
if (typedPart.type === "file" || typedPart.type === "image" || typedPart.type === "image_url") {
content.push({
type: "image_url",
image_url: {
url: part.url,
url: normalizedImageUrl,
},
});
chatApiLogger.info("[Message Convert] Image kept as URL format:", part.url.substring(0, 80) + "...");
chatApiLogger.info(
"[Message Convert] Image kept as URL format:",
normalizedImageUrl.substring(0, 80) + "..."
);
}
}
// 🆕 如果有标注图片但消息中没有文本部分,添加一个纯提示文本
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt && content.length > 0) {
content.unshift({
@ -628,12 +666,10 @@ export async function convertMessagesWithImages(
});
chatApiLogger.info("[Message Convert] 🎨 已在消息开头注入标注提示词");
}
convertedMessages.push({
role: message.role,
content: content.length === 1 && content[0].type === "text"
? content[0].text
: content,
content: content.length === 1 && content[0].type === "text" ? content[0].text : content,
});
}

View File

@ -6,6 +6,8 @@ import type {
ResolvedImageRef,
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.stream");
@ -66,6 +68,104 @@ export function writeStructuredDataChunk(
);
}
function normalizeStreamIdList(value: unknown) {
return Array.isArray(value)
? value.map((id) => String(id || "").trim()).filter(Boolean)
: [];
}
function normalizeStreamMediaUrls(value: unknown) {
return Array.isArray(value)
? value.map((url) => String(url || "").trim())
: [];
}
function isValidStreamMediaUrl(url: string) {
return /^https?:\/\//i.test(url);
}
export function normalizeCanvasMediaStreamPayload(params: {
imageUrls?: unknown;
videoUrls?: unknown;
mediaItemIds?: unknown;
handledCanvasMediaItemIds?: unknown;
}) {
const rawImageUrls = normalizeStreamMediaUrls(params.imageUrls);
const rawVideoUrls = normalizeStreamMediaUrls(params.videoUrls);
const rawEntries = [
...rawImageUrls.map((url, index) => ({
type: "image" as const,
url,
originalIndex: index,
})),
...rawVideoUrls.map((url, index) => ({
type: "video" as const,
url,
originalIndex: rawImageUrls.length + index,
})),
];
const rawMediaItemIds = normalizeStreamIdList(params.mediaItemIds);
const validEntries = rawEntries.filter((entry) =>
isValidStreamMediaUrl(entry.url)
);
const idByValidIndex = new Map<number, string>();
if (rawMediaItemIds.length === rawEntries.length) {
validEntries.forEach((entry, validIndex) => {
const id = rawMediaItemIds[entry.originalIndex];
if (id) idByValidIndex.set(validIndex, id);
});
} else if (rawMediaItemIds.length === validEntries.length) {
rawMediaItemIds.forEach((id, index) => {
if (id) idByValidIndex.set(index, id);
});
}
const dedupedEntries: Array<{
type: "image" | "video";
url: string;
mediaItemId?: string;
}> = [];
const seenMediaKeys = new Set<string>();
validEntries.forEach((entry, validIndex) => {
const key = `${entry.type}:${entry.url}`;
if (seenMediaKeys.has(key)) return;
seenMediaKeys.add(key);
dedupedEntries.push({
type: entry.type,
url: entry.url,
mediaItemId: idByValidIndex.get(validIndex),
});
});
if (
dedupedEntries.length > 0 &&
dedupedEntries.some((entry) => !entry.mediaItemId) &&
rawMediaItemIds.length === dedupedEntries.length
) {
dedupedEntries.forEach((entry, index) => {
entry.mediaItemId = rawMediaItemIds[index];
});
}
const mediaItemIds = dedupedEntries.every((entry) => entry.mediaItemId)
? dedupedEntries.map((entry) => entry.mediaItemId!)
: [];
return {
imageUrls: dedupedEntries
.filter((entry) => entry.type === "image")
.map((entry) => entry.url),
videoUrls: dedupedEntries
.filter((entry) => entry.type === "video")
.map((entry) => entry.url),
mediaItemIds,
handledCanvasMediaItemIds: normalizeStreamIdList(
params.handledCanvasMediaItemIds
),
};
}
export function writeProtocolDebugChunk(
writer: StreamWriter,
enabled: boolean,
@ -86,7 +186,12 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
normalized === "bfl-image" ||
normalized === "ruxa-image" ||
normalized === "fal-image" ||
normalized === "snapgen-image" ||
normalized === "openai-image" ||
normalized === "bananaimage" ||
normalized === "visionary" ||
normalized === "atlascloud" ||
normalized === "vip98k" ||
normalized === "stepfun-image" ||
normalized === "midjourney" ||
normalized === "runway" ||
@ -197,29 +302,25 @@ export function writeCanvasMediaChunk(
agentCategoryId?: string | null;
agentCategoryName?: string | null;
agentExecutionSnapshotId?: string | null;
mediaItemIds?: string[] | null;
handledCanvasMediaItemIds?: string[] | null;
},
label: string
) {
const imageUrls = Array.isArray(params.imageUrls)
const inputImageUrls = Array.isArray(params.imageUrls)
? params.imageUrls
: extractImageUrlsFromText(String(params.text || ""));
const videoUrls = Array.isArray(params.videoUrls) ? params.videoUrls : [];
const normalizedImageUrls = Array.from(
new Set(
imageUrls
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
);
const normalizedVideoUrls = Array.from(
new Set(
videoUrls
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
);
const normalizedPayload = normalizeCanvasMediaStreamPayload({
imageUrls: inputImageUrls,
videoUrls: params.videoUrls,
mediaItemIds: params.mediaItemIds,
handledCanvasMediaItemIds: params.handledCanvasMediaItemIds,
});
if (normalizedImageUrls.length === 0 && normalizedVideoUrls.length === 0) {
if (
normalizedPayload.imageUrls.length === 0 &&
normalizedPayload.videoUrls.length === 0
) {
return;
}
@ -228,8 +329,8 @@ export function writeCanvasMediaChunk(
"data-canvasMedia",
{
messageId: params.messageId,
imageUrls: normalizedImageUrls,
videoUrls: normalizedVideoUrls,
imageUrls: normalizedPayload.imageUrls,
videoUrls: normalizedPayload.videoUrls,
prompt: String(params.prompt || "").trim() || undefined,
sourceImageUrls: Array.isArray(params.sourceImageUrls)
? params.sourceImageUrls
@ -242,11 +343,102 @@ export function writeCanvasMediaChunk(
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
agentExecutionSnapshotId:
String(params.agentExecutionSnapshotId || "").trim() || undefined,
mediaItemIds: normalizedPayload.mediaItemIds,
handledCanvasMediaItemIds: normalizedPayload.handledCanvasMediaItemIds,
},
label
);
}
export function writeCanvasOperationChunk(
writer: StreamWriter,
params: {
projectId: string;
operationId: string;
operation: string;
status?: "applied" | "failed" | "shadowed";
createdNodeIds: string[];
createdNodeIdsByMediaItemId?: Record<string, string>;
updatedNodeIds?: string[];
createdEdgeIds?: string[];
canvasRevision?: string;
anchorNodeId?: string | null;
handledCanvasMediaItemIds?: string[];
mediaItemIds?: string[];
errorCode?: string;
errorMessage?: string;
fallbackToCanvasMedia?: boolean;
message?: string;
},
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasOperation",
{
projectId: params.projectId,
operationId: params.operationId,
operation: params.operation,
status: params.status || "applied",
createdNodeIds: params.createdNodeIds || [],
createdNodeIdsByMediaItemId: params.createdNodeIdsByMediaItemId || {},
updatedNodeIds: params.updatedNodeIds || [],
createdEdgeIds: params.createdEdgeIds || [],
canvasRevision: params.canvasRevision,
anchorNodeId: params.anchorNodeId || undefined,
handledCanvasMediaItemIds:
params.status && params.status !== "applied"
? []
: normalizeStreamIdList(params.handledCanvasMediaItemIds),
mediaItemIds: normalizeStreamIdList(params.mediaItemIds),
errorCode: params.errorCode,
errorMessage: params.errorMessage,
fallbackToCanvasMedia: params.fallbackToCanvasMedia,
message: params.message,
},
label
);
}
export function writeCanvasViewOperationChunk(
writer: StreamWriter,
params: CanvasViewOperationPlan,
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasViewOperation",
{
version: 1,
viewOperationId: params.viewOperationId,
operation: params.operation,
projectId: params.projectId,
nodeIds: params.nodeIds || [],
anchorNodeId: params.anchorNodeId,
zoom: params.zoom,
padding: params.padding,
durationMs: params.durationMs,
persist: false,
center: params.center,
reason: params.reason,
},
label
);
}
export function writeCanvasCapabilityCommandChunk(
writer: StreamWriter,
command: CanvasCapabilityClientCommand,
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasCapabilityCommand",
command,
label
);
}
type AgentTargetLockRefPreview = {
url: string;
title?: string;

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
const textPartSchema = z.object({
type: z.enum(["text"]),
@ -11,6 +12,8 @@ const filePartSchema = z.object({
name: z.string().min(1).max(100),
url: z.string().url(),
assetId: z.string().uuid().optional(),
width: z.number().positive().optional(),
height: z.number().positive().optional(),
});
const partSchema = z.union([textPartSchema, filePartSchema]);
@ -30,11 +33,22 @@ const videoParamsSchema = z.object({
returnLastFrame: z.boolean().optional(),
}).optional();
const imageSizeValues = ["0.5K", "1K", "2K", "4K", "8K", "10K", "12K"] as const;
function normalizeImageSizeInput(value: unknown) {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toUpperCase();
return imageSizeValues.includes(normalized as (typeof imageSizeValues)[number])
? normalized
: undefined;
}
// 🆕 图片生成参数 schema
const imageParamsSchema = z.object({
aspectRatio: z.string().optional(),
imageSize: z.string().optional(),
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
imageCount: z.number().min(1).max(10).optional(),
quality: z.enum(["auto", "low", "medium", "high"]).optional(),
// 阿里生图专用参数
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
negativePrompt: z.string().optional(), // 反向提示词
@ -50,7 +64,8 @@ const contextPayloadSchema = z
projectId: z.string().uuid().optional(),
selectedNodeIds: z.array(z.string().min(1).max(128)).max(20).optional(),
selectedAssetIds: z.array(z.string().uuid()).max(20).optional(),
focusPolicy: z.enum(["auto", "selected", "latest_result", "original"]).optional(),
focusPolicy: z.enum(["auto", "selected", "latest_result", "original", "slot"]).optional(),
canvasAgentContext: z.custom<CanvasAgentContext>().optional(),
canvasImageNodes: z
.array(
z.object({
@ -71,6 +86,13 @@ const contextPayloadSchema = z
})
.optional();
const agentModeSourceImageSchema = z.object({
url: z.string().url(),
label: z.string().max(255).optional(),
name: z.string().max(255).optional(),
assetId: z.string().min(1).max(128).nullable().optional(),
});
const agentModeReferenceSchema = z.object({
id: z.string().min(1).max(128),
label: z.string().min(1).max(255),
@ -79,13 +101,7 @@ const agentModeReferenceSchema = z.object({
sortOrder: z.number().min(0).max(999).optional(),
assignedProductIndex: z.number().min(0).max(9).nullable().optional(),
passthrough: z.boolean().nullable().optional(),
});
const agentModeSourceImageSchema = z.object({
url: z.string().url(),
label: z.string().max(255).optional(),
name: z.string().max(255).optional(),
assetId: z.string().min(1).max(128).nullable().optional(),
rerunReferenceImages: z.array(agentModeSourceImageSchema).max(6).optional(),
});
const agentModePayloadSchema = z

View File

@ -3,6 +3,7 @@ import {
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
HeadObjectCommand,
ListPartsCommand,
PutObjectCommand,
UploadPartCommand,
} from "@aws-sdk/client-s3";
@ -21,20 +22,41 @@ 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) {
return Date.now() - startedAt;
}
const allowedDirectUploadTypes = new Set([
"image/jpeg",
"image/jpg",
"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"),
@ -64,6 +86,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),
@ -73,6 +107,7 @@ const AbortSchema = z.object({
const RequestSchema = z.discriminatedUnion("action", [
InitiateSchema,
ResumeSchema,
CompleteSchema,
AbortSchema,
]);
@ -100,19 +135,49 @@ 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) {
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: {
@ -120,27 +185,28 @@ 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) {
const requestStartedAt = Date.now();
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@ -159,6 +225,16 @@ export async function POST(request: Request) {
);
}
logger.info("direct_upload_request_start", {
action: payload.action,
size: "size" in payload ? payload.size : null,
contentType: "contentType" in payload ? payload.contentType : null,
purpose: "purpose" in payload ? payload.purpose || null : null,
clientUploadId:
"clientUploadId" in payload ? payload.clientUploadId || null : null,
});
const resolveStartedAt = Date.now();
const target = await resolveS3UploadTarget().catch((error) => {
logger.error("resolve_storage_target_failed", error);
return null;
@ -170,9 +246,17 @@ export async function POST(request: Request) {
{ status: 409 }
);
}
logger.info("direct_upload_storage_target_resolved", {
action: payload.action,
elapsedMs: elapsedMs(resolveStartedAt),
provider: target.config.provider || "s3",
bucket: target.config.bucket,
hasPublicUrl: Boolean(target.config.publicUrl),
});
const client = createS3Client(target.config);
const bucket = target.config.bucket;
const usePublicReadAcl = target.config.provider !== "qiniu-kodo";
try {
if (payload.action === "initiate") {
@ -181,7 +265,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,
@ -190,10 +274,19 @@ 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 }
);
}
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
return NextResponse.json(
{ error: `文件不能超过 ${getPurposeMaxBytes(payload.purpose) / 1024 / 1024}MB` },
{ status: 413 }
);
}
const displayName = sanitizeDisplayName(payload.filename);
const key = buildStorageObjectKey(
@ -201,39 +294,57 @@ 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,
Key: key,
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
ACL: "public-read",
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
});
const url = await getSignedUrl(client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
});
logger.info("direct_upload_single_presigned", {
elapsedMs: elapsedMs(presignStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
size: payload.size,
contentType,
clientUploadId: payload.clientUploadId || null,
});
const headers: Record<string, string> = {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
};
if (usePublicReadAcl) {
headers["x-amz-acl"] = "public-read";
}
return NextResponse.json({
mode: "single",
key,
url,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"x-amz-acl": "public-read",
},
headers,
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
clientUploadId: payload.clientUploadId || null,
});
}
const multipartStartedAt = Date.now();
const createResult = await client.send(
new CreateMultipartUploadCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
ACL: "public-read",
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
})
);
@ -249,7 +360,16 @@ export async function POST(request: Request) {
bucket,
key,
uploadId,
partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
});
logger.info("direct_upload_multipart_prepared", {
elapsedMs: elapsedMs(multipartStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
size: payload.size,
partCount,
partSize,
contentType,
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json({
@ -264,8 +384,101 @@ 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)) {
return NextResponse.json({ error: "文件超过允许的上传大小" }, { 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();
await client.send(
new AbortMultipartUploadCommand({
Bucket: bucket,
@ -273,19 +486,31 @@ export async function POST(request: Request) {
UploadId: payload.uploadId,
})
);
logger.info("direct_upload_multipart_aborted", {
elapsedMs: elapsedMs(abortStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json({ ok: true });
}
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)) {
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
}
if (payload.uploadId) {
const completeMultipartStartedAt = Date.now();
const parts = [...(payload.parts || [])].sort(
(a, b) => a.PartNumber - b.PartNumber
);
@ -306,35 +531,77 @@ export async function POST(request: Request) {
},
})
);
logger.info("direct_upload_multipart_completed", {
elapsedMs: elapsedMs(completeMultipartStartedAt),
partCount: parts.length,
clientUploadId: payload.clientUploadId || null,
});
}
const headStartedAt = Date.now();
const head = await client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: key,
})
);
logger.info("direct_upload_head_object_completed", {
elapsedMs: elapsedMs(headStartedAt),
uploadMode: payload.uploadId ? "multipart" : "single",
clientUploadId: payload.clientUploadId || null,
});
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);
const resolvedContentType =
head.ContentType || contentType || getContentType(payload.filename);
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,
@ -344,6 +611,13 @@ export async function POST(request: Request) {
clientUploadId: payload.clientUploadId || null,
},
});
logger.info("direct_upload_asset_registered", {
elapsedMs: elapsedMs(assetStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
uploadMode: payload.uploadId ? "multipart" : "single",
assetId: asset?.id ?? null,
clientUploadId: payload.clientUploadId || null,
});
await maybeEnqueueStorageMigrationTask({
uploaded: { url, pathname: key },
@ -374,7 +648,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

@ -7,6 +7,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,12 +15,32 @@ 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",
]);
const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
"video/mp4",
"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();
@ -35,8 +56,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) {
@ -48,10 +77,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");
@ -71,6 +110,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智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化
@ -197,6 +251,12 @@ export async function POST(request: Request) {
let filename = originalFilename;
let contentType = originalContentType;
const uploadPurpose = String(formData.get("purpose") || "");
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";
@ -227,7 +287,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
if (
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
!isAtlasCloudReferenceVideo &&
!isOpenClawChatVideoUpload
) {
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
logger.warn("[Upload API] Unsupported upload content type", {
contentType,
@ -277,6 +341,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") {
@ -361,18 +457,32 @@ export async function POST(request: Request) {
try {
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
sourceType: "upload",
mediaType:
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? "video"
: "image",
sourceType: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: "upload",
status: "ready",
originalUrl: data.url,
previewUrl: data.url,
thumbnailUrl: 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,
purpose: uploadPurpose || null,
clientUploadId,
convertedFrom,
},

View File

@ -72,6 +72,73 @@ function isWorkflowImageGenerationModel(model: {
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
export async function GET(request: Request) {
try {
const isLocalCanvasE2E = process.env.CANVAS_E2E_LOCAL === "true";
if (isLocalCanvasE2E) {
const models = [
{
id: "local-e2e-image-model",
modelId: "nano-banana-pro-local",
displayName: "Nano Banana Pro",
avatarUrl: null,
description: "本地视频画布测试图片模型",
supportsVision: true,
supportsImageGen: true,
isMultimodal: true,
isDefault: true,
providerName: "Local E2E",
providerType: "local",
isSubModel: false,
parentModelId: null,
capabilities: { image: true },
videoToolCapability: null,
imageGenConfig: {
creditCostPerImage: 1,
creditCostPerVideo: 20,
videoDurationOptions: [5, 10],
},
sortOrder: 0,
selectable: true,
},
{
id: "local-e2e-video-model",
modelId: "kling-3-local",
displayName: "可灵 3.0",
avatarUrl: null,
description: "本地视频画布测试视频模型",
supportsVision: true,
supportsImageGen: false,
isMultimodal: true,
isDefault: false,
providerName: "Local E2E",
providerType: "local",
isSubModel: false,
parentModelId: null,
capabilities: { video: true },
videoToolCapability: {
supported: true,
durations: [5, 10],
resolutions: ["720p", "1080p"],
},
imageGenConfig: {
creditCostPerVideo: 20,
videoDurationOptions: [5, 10],
},
sortOrder: 1,
selectable: true,
},
];
return Response.json({
models,
defaultModelId: models[0].id,
agentWorkflowConfiguredDefaultModelId: null,
agentWorkflowDefaultModelId: null,
agentWorkflowConfiguredModels: [],
agentModel: null,
});
}
const session = await auth();
if (!session?.user) {

View File

@ -0,0 +1,73 @@
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";
export const dynamic = "force-dynamic";
type Model3dRedirectPageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
function getSearchParam(
params: Record<string, string | string[] | undefined>,
key: string
) {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
}
function buildModel3dUrl(
canvasId: string,
params: Record<string, string | string[] | undefined>
) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (key === "new" || value === undefined) {
continue;
}
if (Array.isArray(value)) {
value.forEach((item) => query.append(key, item));
continue;
}
query.set(key, value);
}
const queryString = query.toString();
return `/canvas/${canvasId}/model3d${queryString ? `?${queryString}` : ""}`;
}
async function getLatestCanvasId(userId: string) {
const [latestProject] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, userId),
eq(canvasProject.workspaceType, "canvas")
)
)
.orderBy(desc(canvasProject.updatedAt))
.limit(1);
return latestProject?.id || null;
}
export default async function Model3dRedirectPage({
searchParams,
}: Model3dRedirectPageProps) {
const params = (await searchParams) || {};
const session = await auth();
if (!session?.user?.id) {
redirect("/login?callbackUrl=%2Fmodel3d");
}
const shouldCreateNew = getSearchParam(params, "new") === "1";
if (shouldCreateNew) {
redirect(buildModel3dUrl(randomUUID(), params));
}
const latestCanvasId = await getLatestCanvasId(session.user.id).catch(() => null);
redirect(buildModel3dUrl(latestCanvasId || randomUUID(), params));
}

View File

@ -0,0 +1,95 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
import { PascalEditorFrame } from "@/components/pascal-editor/pascal-editor-frame";
import { getSiteSettings } from "@/lib/db/queries";
const LOCAL_PASCAL_EDITOR_SRC = "/vendor/pascal-editor/index.html";
const DEV_SELF_HOSTED_PASCAL_EDITOR_SRC = "http://localhost:3002";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "建筑编辑",
};
export const runtime = "nodejs";
export default async function PascalEditorPage() {
const [session, siteSettings] = await Promise.all([auth(), getSiteSettings()]);
if (!session?.user?.id) {
redirect("/login?callbackUrl=%2Fpascal-editor");
}
const configuredSrc = normalizePascalEditorUrl(
process.env.NEXT_PUBLIC_PASCAL_EDITOR_URL ||
process.env.PASCAL_EDITOR_URL ||
""
);
const hasLocalEditor = existsSync(
path.join(process.cwd(), "public/vendor/pascal-editor/index.html")
);
const shouldUseDevSelfHosted =
process.env.NODE_ENV === "development" && !configuredSrc && !hasLocalEditor;
const hasDevSelfHostedEditor =
shouldUseDevSelfHosted &&
(await isHttpUrlReachable(DEV_SELF_HOSTED_PASCAL_EDITOR_SRC));
const src =
configuredSrc ||
(hasLocalEditor ? LOCAL_PASCAL_EDITOR_SRC : "") ||
(hasDevSelfHostedEditor ? DEV_SELF_HOSTED_PASCAL_EDITOR_SRC : "") ||
LOCAL_PASCAL_EDITOR_SRC;
const isReady = Boolean(configuredSrc || hasLocalEditor || hasDevSelfHostedEditor);
return (
<PascalEditorFrame
fallbackSrc={LOCAL_PASCAL_EDITOR_SRC}
isReady={isReady}
sourceLabel={
hasDevSelfHostedEditor
? "本机 Pascal Editor"
: configuredSrc
? "Pascal Editor"
: "本地 Pascal Editor"
}
selfHostedSrc={DEV_SELF_HOSTED_PASCAL_EDITOR_SRC}
siteLogo={siteSettings.siteLogo}
siteLogoDark={siteSettings.siteLogoDark}
siteName={siteSettings.siteName}
src={src}
user={session.user}
/>
);
}
function normalizePascalEditorUrl(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (trimmed.startsWith("/")) {
return trimmed;
}
try {
const url = new URL(trimmed);
return url.protocol === "http:" || url.protocol === "https:"
? url.toString()
: "";
} catch {
return "";
}
}
async function isHttpUrlReachable(value: string) {
try {
const response = await fetch(value, {
cache: "no-store",
signal: AbortSignal.timeout(3000),
});
return response.ok;
} catch {
return false;
}
}

View File

@ -0,0 +1,207 @@
import { desc, eq, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { accessIpBlocklist, user } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import { requireAdmin } from "@/lib/middleware/admin";
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
import { normalizeManualBlockIp } from "@/lib/security/manual-ip-blocklist";
export const runtime = "nodejs";
export const maxDuration = 10;
const createSchema = z.object({
ip: z.string().trim().min(1).max(128),
reason: z.string().trim().max(500).optional().nullable(),
expiresAt: z.string().trim().optional().nullable(),
});
const patchSchema = z.object({
id: z.string().uuid(),
isActive: z.boolean(),
reason: z.string().trim().max(500).optional().nullable(),
expiresAt: z.string().trim().optional().nullable(),
});
function normalizeOptionalDate(value?: string | null) {
const normalized = String(value || "").trim();
if (!normalized) return null;
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
export async function GET() {
try {
await requireAdmin();
const entries = await db
.select({
id: accessIpBlocklist.id,
ip: accessIpBlocklist.ip,
reason: accessIpBlocklist.reason,
isActive: accessIpBlocklist.isActive,
expiresAt: accessIpBlocklist.expiresAt,
createdAt: accessIpBlocklist.createdAt,
updatedAt: accessIpBlocklist.updatedAt,
createdBy: accessIpBlocklist.createdBy,
createdByEmail: user.email,
})
.from(accessIpBlocklist)
.leftJoin(user, eq(accessIpBlocklist.createdBy, user.id))
.orderBy(desc(accessIpBlocklist.isActive), desc(accessIpBlocklist.updatedAt))
.limit(300);
return NextResponse.json({ entries });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
console.error("[AccessIpBlocklist] failed to list entries", error);
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function POST(request: Request) {
try {
const session = await requireAdmin();
const body = createSchema.parse(await request.json());
const normalizedIp = normalizeManualBlockIp(body.ip);
if (!normalizedIp) {
return new ChatSDKError(
"bad_request:api",
"请输入有效的 IP 地址或 IPv4 CIDR,例如 1.2.3.4 或 1.2.3.0/24"
).toResponse();
}
const now = new Date();
const expiresAt = normalizeOptionalDate(body.expiresAt);
const reason = body.reason?.trim() || null;
const [existing] = await db
.select({ id: accessIpBlocklist.id })
.from(accessIpBlocklist)
.where(sql`lower(${accessIpBlocklist.ip}) = lower(${normalizedIp})`)
.limit(1);
const [entry] = existing
? await db
.update(accessIpBlocklist)
.set({
reason,
isActive: true,
expiresAt,
updatedBy: session.user.id,
updatedAt: now,
})
.where(eq(accessIpBlocklist.id, existing.id))
.returning()
: await db
.insert(accessIpBlocklist)
.values({
ip: normalizedIp,
reason,
isActive: true,
expiresAt,
createdBy: session.user.id,
updatedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
.returning();
await recordAccessIpEventFromHeaders({
eventType: "manual_block_added",
userId: session.user.id,
userEmail: session.user.email || null,
userRole: session.user.type,
method: "POST",
path: "/api/admin/access-blocklist",
routeGroup: "admin-api",
statusCode: 200,
outcome: "manual_block_added",
severity: "high",
reasons: ["管理员手动拉黑 IP"],
metadata: {
targetIp: normalizedIp,
reason,
},
}).catch((error) => {
console.warn("[AccessIpBlocklist] failed to record block action", error);
});
return NextResponse.json({ entry });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return new ChatSDKError("bad_request:api", error.message).toResponse();
}
console.error("[AccessIpBlocklist] failed to create entry", error);
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function PATCH(request: Request) {
try {
const session = await requireAdmin();
const body = patchSchema.parse(await request.json());
const now = new Date();
const expiresAt = normalizeOptionalDate(body.expiresAt);
const [existing] = await db
.select({ ip: accessIpBlocklist.ip })
.from(accessIpBlocklist)
.where(eq(accessIpBlocklist.id, body.id))
.limit(1);
if (!existing) {
return new ChatSDKError("bad_request:api", "IP 黑名单记录不存在").toResponse();
}
const [entry] = await db
.update(accessIpBlocklist)
.set({
isActive: body.isActive,
reason: body.reason?.trim() || undefined,
expiresAt,
updatedBy: session.user.id,
updatedAt: now,
})
.where(eq(accessIpBlocklist.id, body.id))
.returning();
await recordAccessIpEventFromHeaders({
eventType: body.isActive ? "manual_block_enabled" : "manual_block_released",
userId: session.user.id,
userEmail: session.user.email || null,
userRole: session.user.type,
method: "PATCH",
path: "/api/admin/access-blocklist",
routeGroup: "admin-api",
statusCode: 200,
outcome: body.isActive ? "manual_block_enabled" : "manual_block_released",
severity: body.isActive ? "high" : "watch",
reasons: [body.isActive ? "管理员启用 IP 拉黑" : "管理员解除 IP 拉黑"],
metadata: {
targetIp: existing.ip,
blocklistId: body.id,
},
}).catch((error) => {
console.warn("[AccessIpBlocklist] failed to record update action", error);
});
return NextResponse.json({ entry });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return new ChatSDKError("bad_request:api", error.message).toResponse();
}
console.error("[AccessIpBlocklist] failed to update entry", error);
return new ChatSDKError("offline:admin").toResponse();
}
}

View File

@ -0,0 +1,271 @@
import { and, desc, gte, sql, type SQL } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { accessIpEvent } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import { requireAdmin } from "@/lib/middleware/admin";
export const runtime = "nodejs";
export const maxDuration = 10;
type Severity = "info" | "watch" | "high" | "critical";
const severityByRank: Record<number, Severity> = {
0: "info",
1: "watch",
2: "high",
3: "critical",
};
function clampInt(value: string | null, fallback: number, min: number, max: number) {
const parsed = Number.parseInt(value || "", 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function normalizeSearch(value: string | null) {
return String(value || "").trim().slice(0, 120);
}
function buildSeverityFromAggregate(params: {
eventSeverityRank: number;
total: number;
failureCount: number;
rateLimitedCount: number;
authFailureCount: number;
suspiciousCount: number;
}) {
let severity = severityByRank[params.eventSeverityRank] || "info";
const failureRate =
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
if (params.rateLimitedCount > 0 || params.suspiciousCount > 0) {
severity = "high";
} else if (params.authFailureCount >= 5 || params.total >= 250) {
severity = severity === "critical" ? severity : "high";
} else if (
params.failureCount >= 20 ||
(params.total >= 10 && failureRate >= 0.45)
) {
severity = severity === "critical" ? severity : "high";
} else if (params.authFailureCount > 0 || params.failureCount > 0) {
severity = severity === "info" ? "watch" : severity;
}
return severity;
}
function buildAggregateReasons(params: {
total: number;
failureCount: number;
rateLimitedCount: number;
authFailureCount: number;
suspiciousCount: number;
loginSuccessCount: number;
}) {
const reasons: string[] = [];
const failureRate =
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
if (params.rateLimitedCount > 0) reasons.push("触发限流");
if (params.authFailureCount >= 5) reasons.push("认证失败偏多");
if (params.suspiciousCount > 0) reasons.push("命中异常规则");
if (params.total >= 250) reasons.push("请求量偏高");
if (params.failureCount >= 20 || (params.total >= 10 && failureRate >= 0.45)) {
reasons.push("失败率偏高");
}
if (params.loginSuccessCount > 0 && reasons.length === 0) {
reasons.push("有成功登录记录");
}
return reasons;
}
export async function GET(request: Request) {
try {
await requireAdmin();
const { searchParams } = new URL(request.url);
const hours = clampInt(searchParams.get("hours"), 24, 1, 168);
const limit = clampInt(searchParams.get("limit"), 120, 25, 500);
const severityFilter = searchParams.get("severity") || "all";
const search = normalizeSearch(searchParams.get("q"));
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
const whereParts: SQL[] = [gte(accessIpEvent.createdAt, since)];
if (severityFilter === "abnormal") {
whereParts.push(sql`${accessIpEvent.severity} <> 'info'`);
} else if (["info", "watch", "high", "critical"].includes(severityFilter)) {
whereParts.push(sql`${accessIpEvent.severity} = ${severityFilter}`);
}
if (search) {
const pattern = `%${search}%`;
whereParts.push(sql`(
${accessIpEvent.ip} ILIKE ${pattern}
OR ${accessIpEvent.path} ILIKE ${pattern}
OR ${accessIpEvent.userEmail} ILIKE ${pattern}
OR ${accessIpEvent.userAgent} ILIKE ${pattern}
)`);
}
const whereClause = and(...whereParts);
const [overview] = await db
.select({
totalEvents: sql<number>`count(*)::int`,
uniqueIps: sql<number>`count(distinct ${accessIpEvent.ip})::int`,
abnormalEvents: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
abnormalIps: sql<number>`count(distinct ${accessIpEvent.ip}) filter (where ${accessIpEvent.severity} <> 'info')::int`,
rateLimitedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
manualBlockedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'manual_blocked')::int`,
loginSuccessEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
unauthorizedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'unauthorized' or ${accessIpEvent.statusCode} in (401, 403))::int`,
})
.from(accessIpEvent)
.where(whereClause);
const recentEvents = await db
.select({
id: accessIpEvent.id,
eventType: accessIpEvent.eventType,
ip: accessIpEvent.ip,
userId: accessIpEvent.userId,
userEmail: accessIpEvent.userEmail,
userRole: accessIpEvent.userRole,
method: accessIpEvent.method,
path: accessIpEvent.path,
routeGroup: accessIpEvent.routeGroup,
statusCode: accessIpEvent.statusCode,
outcome: accessIpEvent.outcome,
severity: accessIpEvent.severity,
reasons: accessIpEvent.reasons,
userAgent: accessIpEvent.userAgent,
referer: accessIpEvent.referer,
occurredAt: accessIpEvent.occurredAt,
createdAt: accessIpEvent.createdAt,
})
.from(accessIpEvent)
.where(whereClause)
.orderBy(desc(accessIpEvent.createdAt))
.limit(limit);
const topIpRows = await db
.select({
ip: accessIpEvent.ip,
total: sql<number>`count(*)::int`,
uniqueUsers: sql<number>`count(distinct ${accessIpEvent.userId}) filter (where ${accessIpEvent.userId} is not null)::int`,
failureCount: sql<number>`count(*) filter (where ${accessIpEvent.statusCode} >= 400)::int`,
rateLimitedCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
authFailureCount: sql<number>`count(*) filter (where ${accessIpEvent.path} like '/api/auth%' and ${accessIpEvent.statusCode} >= 400)::int`,
loginSuccessCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
suspiciousCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical'))::int`,
firstSeen: sql<string>`min(${accessIpEvent.createdAt})::text`,
lastSeen: sql<string>`max(${accessIpEvent.createdAt})::text`,
eventSeverityRank: sql<number>`max(case ${accessIpEvent.severity}
when 'critical' then 3
when 'high' then 2
when 'watch' then 1
else 0
end)::int`,
})
.from(accessIpEvent)
.where(whereClause)
.groupBy(accessIpEvent.ip)
.orderBy(
desc(sql`(
count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical')) * 8
+ count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429) * 12
+ count(*) filter (where ${accessIpEvent.statusCode} >= 400) * 3
+ count(*)
)`)
)
.limit(24);
const topIps = topIpRows.map((row) => {
const severity = buildSeverityFromAggregate({
eventSeverityRank: Number(row.eventSeverityRank || 0),
total: Number(row.total || 0),
failureCount: Number(row.failureCount || 0),
rateLimitedCount: Number(row.rateLimitedCount || 0),
authFailureCount: Number(row.authFailureCount || 0),
suspiciousCount: Number(row.suspiciousCount || 0),
});
return {
...row,
severity,
failureRate:
Number(row.total || 0) > 0
? Number(row.failureCount || 0) / Number(row.total || 1)
: 0,
reasons: buildAggregateReasons({
total: Number(row.total || 0),
failureCount: Number(row.failureCount || 0),
rateLimitedCount: Number(row.rateLimitedCount || 0),
authFailureCount: Number(row.authFailureCount || 0),
suspiciousCount: Number(row.suspiciousCount || 0),
loginSuccessCount: Number(row.loginSuccessCount || 0),
}),
};
});
const hourlyStats = await db
.select({
hour: sql<string>`to_char(date_trunc('hour', ${accessIpEvent.createdAt}), 'MM-DD HH24:00')`,
count: sql<number>`count(*)::int`,
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
})
.from(accessIpEvent)
.where(whereClause)
.groupBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`)
.orderBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`);
const pathStats = await db
.select({
path: accessIpEvent.path,
routeGroup: accessIpEvent.routeGroup,
count: sql<number>`count(*)::int`,
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
})
.from(accessIpEvent)
.where(whereClause)
.groupBy(accessIpEvent.path, accessIpEvent.routeGroup)
.orderBy(desc(sql`count(*)`))
.limit(12);
return NextResponse.json({
window: {
hours,
since: since.toISOString(),
},
overview: {
totalEvents: overview?.totalEvents || 0,
uniqueIps: overview?.uniqueIps || 0,
abnormalEvents: overview?.abnormalEvents || 0,
abnormalIps: overview?.abnormalIps || 0,
rateLimitedEvents: overview?.rateLimitedEvents || 0,
manualBlockedEvents: overview?.manualBlockedEvents || 0,
loginSuccessEvents: overview?.loginSuccessEvents || 0,
unauthorizedEvents: overview?.unauthorizedEvents || 0,
},
topIps,
hourlyStats,
pathStats,
recentEvents,
rules: [
"短时间请求量偏高",
"4xx/5xx 或失败率偏高",
"认证失败偏多",
"触发限流",
"命中敏感路径或异常 User-Agent",
],
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
console.error("[AccessIpEvent] failed to load admin analytics", error);
return new ChatSDKError("offline:admin").toResponse();
}
}

View File

@ -33,6 +33,14 @@ 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(),
creditCostPerImage: z.number().min(0).max(100000).optional(),
creditCostMultiplierBySize: z.record(z.string(), z.number().min(0.01).max(100)).optional(),
creditDiscountRate: z.number().min(0.1).max(10).optional(),
@ -42,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,8 @@ 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 = [
"alibaba",
@ -29,7 +31,12 @@ const providerTypes = [
"bfl-image",
"ruxa-image",
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"midjourney",
"fal-video",
@ -69,12 +76,17 @@ const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
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";
@ -230,8 +242,18 @@ export async function POST(request: Request) {
? data.baseUrl || BFL_IMAGE_BASE_URL
: data.type === "ruxa-image"
? data.baseUrl || RUXA_IMAGE_BASE_URL
: data.type === "snapgen-image"
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
: data.type === "openai-image"
? data.baseUrl || OPENAI_IMAGE_BASE_URL
: data.type === "bananaimage"
? data.baseUrl || BANANAIMAGE_BASE_URL
: data.type === "visionary"
? 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"
@ -255,6 +277,7 @@ export async function POST(request: Request) {
});
console.log("[Admin API] Provider created successfully:", provider[0].id);
clearScopedSiteSettingsCache();
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
} catch (error) {
console.error("[Admin API] Create provider error:", error);
@ -293,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,
});
@ -300,6 +329,7 @@ export async function PUT(request: Request) {
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
}
clearScopedSiteSettingsCache();
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
} catch (error) {
if (error instanceof ChatSDKError) {
@ -325,6 +355,7 @@ export async function DELETE(request: Request) {
await deleteAiProvider({ id });
clearScopedSiteSettingsCache();
return Response.json({ success: true });
} catch (error) {
if (error instanceof ChatSDKError) {

View File

@ -0,0 +1,182 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
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";
import { requireAdmin } from "@/lib/middleware/admin";
export const runtime = "nodejs";
export const maxDuration = 300;
export async function GET() {
try {
await requireAdmin();
const result = await fetchAtlasCloudModelPresets();
return NextResponse.json({
presets: result.presets,
errors: result.errors.length > 0 ? result.errors : undefined,
counts: {
total: result.catalogCount,
available: result.presets.length,
failed: result.errors.length,
},
});
} catch (error) {
console.error("[Atlas Cloud Models API] GET error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to fetch Atlas Cloud 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;
if (!providerId) {
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "atlascloud") {
return NextResponse.json(
{ error: "Provider must be an Atlas Cloud provider" },
{ status: 400 }
);
}
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 = 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: resolveAtlasCloudDisplayName(
existing?.displayName,
preset.displayName,
preset.modelId
),
avatarUrl: preset.avatarUrl || null,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
sortOrder: existing?.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,
isEnabled: true,
isHidden: false,
isDefault: false,
isSubModel: false,
parentModelId: null,
})
.returning();
if (created) addedModels.push(created);
}
} catch (error) {
console.error(
`[Atlas Cloud 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,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presets.length,
total: catalog.catalogCount,
available: catalog.presets.length,
skippedUnsupported: catalog.skipped.length,
preservedDeleted: preservedDeletedCount,
},
});
} catch (error) {
console.error("[Atlas Cloud Models API] POST error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to sync Atlas Cloud models",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,128 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: BANANAIMAGE_MODEL_PRESETS,
counts: { total: BANANAIMAGE_MODEL_PRESETS.length },
});
} catch (error: any) {
console.error("[BananaImage Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch BananaImage model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? body.modelIds
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: null;
if (!providerId) {
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "bananaimage") {
return NextResponse.json(
{ error: "Provider must be a BananaImage provider" },
{ status: 400 }
);
}
const presets = modelIds
? BANANAIMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId))
: BANANAIMAGE_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
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,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
};
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);
continue;
}
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: any) {
console.error(`[BananaImage Models API] Failed to sync ${preset.modelId}:`, error);
errors.push({
modelId: preset.modelId,
error: error.message || "Sync failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: { requested: presets.length, total: BANANAIMAGE_MODEL_PRESETS.length },
});
} catch (error: any) {
console.error("[BananaImage Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to sync BananaImage models" },
{ status: 500 }
);
}
}

View File

@ -12,6 +12,12 @@ import {
} from "@/lib/agents/psd-template-parser";
import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection";
import { schedulePsdTemplateAssetBackfill } from "@/lib/agents/psd-template-asset-backfill";
import {
AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE,
AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE,
assertSupportedAgentPsdTemplateFile,
type AgentPsdTemplateFileInspection,
} from "@/lib/agents/psd-template-format";
import {
createAgentPsdTemplateParseHeartbeat,
updateAgentPsdTemplateById,
@ -51,6 +57,22 @@ function getErrorMessage(error: unknown) {
return "PSD 解析失败";
}
function getPsdTemplateParseErrorMessage(params: {
error: unknown;
fileInspection?: AgentPsdTemplateFileInspection;
}) {
const message = getErrorMessage(params.error);
if (
params.fileInspection?.format === "psb" &&
/invalid signature/i.test(message)
) {
return AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE;
}
return message;
}
function pickPrimaryImageLayerId(params: {
parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"];
canvasWidth: number;
@ -181,6 +203,12 @@ export async function POST(
}
const { id } = await context.params;
let fileInspection: AgentPsdTemplateFileInspection | undefined;
let latestProgress = {
progress: 0,
stage: "准备解析 PSD",
error: undefined as string | undefined,
};
try {
const { templates, timedOutTemplates } = await failTimedOutAgentPsdTemplates({
@ -204,10 +232,10 @@ export async function POST(
return jsonError(409, "该模板正在解析中,请稍后刷新");
}
let latestProgress = {
latestProgress = {
progress: 5,
stage: "准备解析 PSD",
error: undefined as string | undefined,
error: undefined,
};
const setParseProgress = async (progress: number, stage: string, error?: string) => {
latestProgress = {
@ -279,6 +307,9 @@ export async function POST(
});
}
await setParseProgress(22, "正在校验 PSD 文件格式");
fileInspection = assertSupportedAgentPsdTemplateFile(fileBuffer);
await setParseProgress(24, "正在解析 PSD 图层结构与预览");
const parsedTemplate = await parsePsdTemplateFile(fileBuffer, {
onProgress: async (event) => {
@ -349,14 +380,19 @@ export async function POST(
throw error;
}
} catch (error) {
const message = getErrorMessage(error);
const message = getPsdTemplateParseErrorMessage({
error,
fileInspection,
});
const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id);
if (existingTemplate) {
const fallbackStatus =
existingTemplate.parseStatus === "completed" && existingTemplate.layers.length > 0
? "completed"
: "failed";
const hasUsablePreviousParse =
existingTemplate.layers.length > 0 &&
existingTemplate.width > 1 &&
existingTemplate.height > 1 &&
Boolean(existingTemplate.previewUrl);
const fallbackStatus = hasUsablePreviousParse ? "completed" : "failed";
await updateAgentPsdTemplateById({
templateId: id,
@ -364,9 +400,14 @@ export async function POST(
updater: (template) => ({
...template,
parseStatus: fallbackStatus,
parseProgress: fallbackStatus === "completed" ? 100 : 0,
parseProgress:
fallbackStatus === "completed"
? 100
: Math.max(0, Math.round(Number(latestProgress.progress || 0))),
parseStage:
fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined,
fallbackStatus === "completed"
? "沿用上次成功解析结果"
: latestProgress.stage || "PSD 解析失败",
parseError:
fallbackStatus === "completed"
? `重新解析失败:${message}`
@ -376,7 +417,20 @@ export async function POST(
}).catch(() => undefined);
}
console.error("[PSD Templates][Admin] parse failed:", error);
return jsonError(502, message);
console.error("[PSD Templates][Admin] parse failed:", {
templateId: id,
fileName: existingTemplate?.fileName,
progress: latestProgress.progress,
stage: latestProgress.stage,
fileFormat: fileInspection?.format,
error,
});
return jsonError(
message === AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE ||
message === AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE
? 400
: 502,
message
);
}
}

View File

@ -15,6 +15,10 @@ import {
type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
import {
AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE,
assertSupportedAgentPsdTemplateFile,
} from "@/lib/agents/psd-template-format";
import { cacheAgentPsdSourceLocally } from "@/lib/agents/psd-local-cache";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout";
@ -222,7 +226,7 @@ export async function POST(request: NextRequest) {
const extension = getFileExtension(file.name);
if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) {
return jsonMutationError(400, "模板文件仅支持 PSD / PSB");
return jsonMutationError(400, "模板文件仅支持 Photoshop PSD / PSB");
}
if (file.size <= 0) {
@ -235,11 +239,15 @@ export async function POST(request: NextRequest) {
try {
const fileBuffer = await file.arrayBuffer();
const fileInspection = assertSupportedAgentPsdTemplateFile(fileBuffer);
const templateId = randomUUID();
console.log("[PSD Templates][Admin] received PSD template upload:", {
templateId,
fileName: file.name,
size: file.size,
fileFormat: fileInspection.format,
width: fileInspection.width,
height: fileInspection.height,
});
await cacheAgentPsdSourceLocally({
templateId,
@ -311,6 +319,13 @@ export async function POST(request: NextRequest) {
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
console.error("[PSD Templates][Admin] create failed:", error);
if (error instanceof Error && error.message.trim()) {
if (
error.message === AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE
) {
return jsonMutationError(400, error.message);
}
}
return jsonMutationError(502, "PSD 模板上传或解析失败");
}
}

View File

@ -0,0 +1,157 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
import { RUXA_IMAGE_MODEL_PRESETS } from "@/lib/ai/ruxa-image-models";
import { requireAdmin } from "@/lib/middleware/admin";
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: RUXA_IMAGE_MODEL_PRESETS,
counts: {
total: RUXA_IMAGE_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[RUXA Image Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch RUXA image model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? body.modelIds
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: null;
if (!providerId) {
return NextResponse.json(
{ error: "Provider ID is required" },
{ status: 400 }
);
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "ruxa-image") {
return NextResponse.json(
{ error: "Provider must be a RUXA image provider" },
{ status: 400 }
);
}
const presetsToAdd = modelIds
? RUXA_IMAGE_MODEL_PRESETS.filter((preset) =>
modelIds.includes(preset.modelId)
)
: RUXA_IMAGE_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
const updatedModels: AiModel[] = [];
for (const preset of presetsToAdd) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
),
});
if (existing) {
const [updated] = await db
.update(aiModel)
.set({
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
})
.where(
and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
)
)
.returning();
if (updated) {
updatedModels.push(updated);
} else {
errors.push({ modelId: preset.modelId, error: "Model exists but failed to sync" });
}
continue;
}
const [newModel] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
isDefault: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
isSubModel: false,
parentModelId: null,
})
.returning();
addedModels.push(newModel);
} catch (error: any) {
console.error(
`[RUXA Image Models API] Failed to add ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
error: error.message || "Insert failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presetsToAdd.length,
total: RUXA_IMAGE_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[RUXA Image Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to add RUXA image models" },
{ status: 500 }
);
}
}

View File

@ -18,6 +18,8 @@ import {
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
isDetailAgentLocalMoonshotBaseUrl,
normalizeDetailAgentLocalMoonshotBaseUrl,
} from "@/lib/ai/detail-agent-model-config";
import {
normalizeProductPlanningApiConfig,
@ -51,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,
@ -67,6 +73,15 @@ import {
EXTERNAL_IMAGE_API_CONFIG_KEY,
normalizeExternalImageApiConfig,
} from "@/lib/external-image-api/config";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
toAdminOpenClawChatConfig,
} from "@/lib/openclaw-chat/config";
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
const settingSchema = z.object({
@ -86,7 +101,7 @@ const moonshotDetailAgentConfigSchema = z.object({
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
function isMoonshotOfficialBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "") === MOONSHOT_OFFICIAL_BASE_URL;
return isDetailAgentLocalMoonshotBaseUrl(value);
}
function isObjectLike(value: unknown): value is Record<string, unknown> {
@ -115,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({
@ -139,6 +160,18 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY) {
const value = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === OPENCLAW_CHAT_CONFIG_KEY) {
const value = toAdminOpenClawChatConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
return Response.json({ setting });
}
@ -170,19 +203,27 @@ export async function POST(request: Request) {
? (existingSetting.value as Record<string, unknown>)
: {};
const config = moonshotDetailAgentConfigSchema.parse(value || {});
const normalizedBaseUrl = String(config.baseUrl || "").trim() || MOONSHOT_OFFICIAL_BASE_URL;
const normalizedBaseUrl = normalizeDetailAgentLocalMoonshotBaseUrl(config.baseUrl);
const rawModelId = String(config.modelId || "").trim();
const rawDisplayName = String(config.displayName || "").trim();
const normalizedModelId =
!rawModelId || rawModelId === "kimi-k2.5"
!rawModelId ||
rawModelId === "kimi-k2.5" ||
rawModelId === "kimi-k2.6" ||
rawModelId === "kimi-k2.7-code" ||
rawModelId === "kimi-k2.7-code-highspeed"
? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID
: rawModelId;
const normalizedDisplayName =
!rawDisplayName || rawDisplayName === "官方 Kimi K2.5"
!rawDisplayName ||
rawDisplayName === "官方 Kimi K2.5" ||
rawDisplayName === "官方 Kimi K2.6" ||
rawDisplayName === "官方 Kimi K2.7 Code Highspeed" ||
rawDisplayName === "Kimi Code 编程套餐 K2.7 Highspeed"
? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME
: rawDisplayName;
const normalizedProviderName =
String(config.providerName || "").trim() || "Moonshot AI (Official)";
String(config.providerName || "").trim() || "Kimi Code 编程套餐";
const normalizedApiKey =
String(config.apiKey || "").trim() ||
String(existingValue.apiKey || "").trim();
@ -191,12 +232,12 @@ export async function POST(request: Request) {
if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) {
return new ChatSDKError(
"bad_request:api",
`Moonshot 官方地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
`Kimi Code 编程套餐专用地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
).toResponse();
}
if (!normalizedApiKey) {
return new ChatSDKError("bad_request:api", "缺少 Moonshot 官方 API Key").toResponse();
return new ChatSDKError("bad_request:api", "缺少 Kimi Code 控制台 API Key").toResponse();
}
const saved = await upsertSystemSetting({
@ -278,6 +319,58 @@ export async function POST(request: Request) {
});
}
if (key === AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY) {
const saved = await upsertSystemSetting({
key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
value: normalizeAgentWorkflowMaterialApiConfig(value),
updatedBy: session.user.id,
});
clearScopedSiteSettingsCache();
return Response.json({
setting: saved[0],
});
}
if (key === OPENCLAW_CHAT_CONFIG_KEY) {
const existingSetting = await getSystemSetting({
key: OPENCLAW_CHAT_CONFIG_KEY,
}).catch(() => null);
const existingValue =
existingSetting?.value && typeof existingSetting.value === "object"
? (existingSetting.value as Record<string, unknown>)
: {};
const normalizedValue = normalizeOpenClawChatConfig(
value,
String(existingValue.token || "").trim()
);
if (normalizedValue.enabled) {
if (!normalizedValue.baseUrl) {
return new ChatSDKError("bad_request:api", "请填写大龙虾聊天接口地址").toResponse();
}
if (!normalizedValue.token) {
return new ChatSDKError("bad_request:api", "请填写大龙虾聊天接口 Token").toResponse();
}
}
const saved = await upsertSystemSetting({
key: OPENCLAW_CHAT_CONFIG_KEY,
value: normalizedValue,
updatedBy: session.user.id,
});
clearScopedSiteSettingsCache();
return Response.json({
setting: saved[0]
? {
...saved[0],
value: toAdminOpenClawChatConfig(saved[0].value),
}
: undefined,
});
}
if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) {
nextValue = normalizeVideoCanvasFunctionConfig(value);
}
@ -286,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

@ -0,0 +1,155 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { SNAPGEN_IMAGE_MODEL_PRESETS } from "@/lib/ai/snapgen-image-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: SNAPGEN_IMAGE_MODEL_PRESETS,
counts: {
total: SNAPGEN_IMAGE_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[SnapGen Image Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch SnapGen image model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? body.modelIds
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: null;
if (!providerId) {
return NextResponse.json(
{ error: "Provider ID is required" },
{ status: 400 }
);
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "snapgen-image") {
return NextResponse.json(
{ error: "Provider must be a SnapGen image provider" },
{ status: 400 }
);
}
const presetsToAdd = modelIds
? SNAPGEN_IMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId))
: SNAPGEN_IMAGE_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
for (const preset of presetsToAdd) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
),
});
if (existing) {
const [updated] = await db
.update(aiModel)
.set({
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
})
.where(
and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
)
)
.returning();
if (updated) {
updatedModels.push(updated);
} else {
errors.push({ modelId: preset.modelId, error: "Model exists but failed to sync" });
}
continue;
}
const [newModel] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
isDefault: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
isSubModel: false,
parentModelId: null,
})
.returning();
addedModels.push(newModel);
} catch (error: any) {
console.error(
`[SnapGen Image Models API] Failed to sync ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
error: error.message || "Insert failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presetsToAdd.length,
total: SNAPGEN_IMAGE_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[SnapGen Image Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to add SnapGen image models" },
{ status: 500 }
);
}
}

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,139 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { VISIONARY_MODEL_PRESETS } from "@/lib/ai/visionary-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: VISIONARY_MODEL_PRESETS,
counts: { total: VISIONARY_MODEL_PRESETS.length },
});
} catch (error: any) {
console.error("[Visionary Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch Visionary model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? body.modelIds
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: null;
if (!providerId) {
return NextResponse.json(
{ error: "Provider ID is required" },
{ status: 400 }
);
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "visionary") {
return NextResponse.json(
{ error: "Provider must be a Visionary provider" },
{ status: 400 }
);
}
const presets = modelIds
? VISIONARY_MODEL_PRESETS.filter((preset) =>
modelIds.includes(preset.modelId)
)
: VISIONARY_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
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,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
};
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);
continue;
}
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: any) {
console.error(
`[Visionary Models API] Failed to sync ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
error: error.message || "Sync failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presets.length,
total: VISIONARY_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[Visionary Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to sync Visionary models" },
{ status: 500 }
);
}
}

View File

@ -8,6 +8,8 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
buildAgentWorkflowMaterialApiUrl,
isAgentWorkflowMaterialResourceEnabled,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import {
@ -677,6 +679,12 @@ export async function GET(request: NextRequest) {
{ status: 400 }
);
}
if (!isAgentWorkflowMaterialResourceEnabled(config, "white-background")) {
return NextResponse.json(
{ error: "白底图素材接口未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) {
return NextResponse.json(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
@ -696,15 +704,18 @@ export async function GET(request: NextRequest) {
return null;
})
: Promise.resolve(null);
const upstreamUrl = `${normalizeBaseUrl(
config.baseUrl
)}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`;
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, {
resourceType: "white-background",
queryType,
keyword: normalizedKeyword,
});
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
headers: {
"X-API-Key": config.apiKey,
},
cache: "no-store",
signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000),
});
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse;

View File

@ -6,6 +6,8 @@ import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
buildAgentWorkflowMaterialApiUrl,
isAgentWorkflowMaterialResourceEnabled,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import {
@ -469,13 +471,11 @@ async function requestTaskDetailAttempt(params: {
verifiedGoodsBrandName?: string | null;
config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>;
}) {
const upstreamParams = new URLSearchParams({
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(params.config, {
resourceType: "task-detail",
[params.queryType]: params.keyword,
queryType: params.queryType,
keyword: params.keyword,
});
const upstreamUrl = `${normalizeBaseUrl(
params.config.baseUrl
)}/standard-model-white-background?${upstreamParams.toString()}`;
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
@ -483,6 +483,7 @@ async function requestTaskDetailAttempt(params: {
"X-API-Key": params.config.apiKey,
},
cache: "no-store",
signal: AbortSignal.timeout(params.config.requestTimeoutMs || 30_000),
});
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
const upstreamBrandName = getExternalBrandName(upstreamResult.data || {});
@ -936,6 +937,12 @@ export async function GET(request: NextRequest) {
{ status: 400 }
);
}
if (!isAgentWorkflowMaterialResourceEnabled(config, "task-detail")) {
return jsonNoStore(
{ error: "任务-详情整套素材接口未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) {
return jsonNoStore(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },

View File

@ -1,122 +0,0 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
getKittyCadModelingCommandError,
isKittyCadModelingCommandSuccessful,
sendKittyCadModelingCommand,
takeKittyCadModelingSnapshot,
} from "@/lib/kittycad/modeling-session";
export const runtime = "nodejs";
export const maxDuration = 60;
type ModelingCommandRequest = {
captureSnapshot?: boolean;
cmd?: Record<string, unknown>;
cmdId?: string;
sessionId?: string;
snapshotFormat?: "jpeg" | "png";
};
function readOptionalRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
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()) as ModelingCommandRequest;
const sessionId = String(body.sessionId || "").trim();
if (!sessionId) {
return NextResponse.json(
{ success: false, error: "缺少官方建模会话 ID" },
{ status: 400 }
);
}
const cmd = readOptionalRecord(body.cmd);
const cmdType = String(cmd?.type || "").trim();
if (!cmd || !cmdType) {
return NextResponse.json(
{ success: false, error: "缺少有效的官方建模命令" },
{ status: 400 }
);
}
const cmdId = String(body.cmdId || "").trim() || randomUUID();
const response = await sendKittyCadModelingCommand({
cmd: {
...cmd,
type: cmdType,
},
cmdId,
sessionId,
});
if (!isKittyCadModelingCommandSuccessful(response)) {
return NextResponse.json(
{
success: false,
error: getKittyCadModelingCommandError(response),
payload: response.errors || null,
},
{ status: 400 }
);
}
let snapshotUrl: string | null = null;
let snapshotError: string | null = null;
const shouldCaptureSnapshot =
body.captureSnapshot === true && cmdType !== "take_snapshot";
if (cmdType === "take_snapshot") {
const contents = String(response.resp?.data?.contents || "").trim();
const snapshotFormat = body.snapshotFormat || "png";
snapshotUrl = contents
? `data:${snapshotFormat === "jpeg" ? "image/jpeg" : "image/png"};base64,${contents}`
: null;
} else if (shouldCaptureSnapshot) {
try {
const snapshot = await takeKittyCadModelingSnapshot({
format: body.snapshotFormat || "png",
sessionId,
});
snapshotUrl = snapshot.snapshotUrl;
} catch (error) {
snapshotError =
error instanceof Error ? error.message : "获取官方建模快照失败";
}
}
return NextResponse.json({
success: true,
data: {
raw: response,
requestId: response.request_id || cmdId,
responseData: response.resp?.data || null,
responseType: response.resp?.type || cmdType,
snapshotError,
snapshotUrl,
},
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "发送官方建模命令失败",
},
{ status: 500 }
);
}
}

View File

@ -1,48 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { closeKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
export const runtime = "nodejs";
export const maxDuration = 60;
type RouteParams = {
params: Promise<{
sessionId: string;
}>;
};
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { sessionId } = await params;
const normalizedSessionId = String(sessionId || "").trim();
if (!normalizedSessionId) {
return NextResponse.json(
{ success: false, error: "缺少 sessionId" },
{ status: 400 }
);
}
const result = await closeKittyCadModelingSession(normalizedSessionId);
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "关闭官方建模会话失败",
},
{ status: 500 }
);
}
}

View File

@ -1,33 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { createKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
export const runtime = "nodejs";
export const maxDuration = 60;
export async function POST() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const modelingSession = await createKittyCadModelingSession();
return NextResponse.json({
success: true,
data: modelingSession,
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "创建官方建模会话失败",
},
{ status: 500 }
);
}
}

View File

@ -1,27 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { findKittyCadProviderConfig } from "@/lib/kittycad/server";
export const runtime = "nodejs";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const config = await findKittyCadProviderConfig();
return NextResponse.json({
success: true,
data: {
configured: Boolean(config),
source: config?.source || "none",
providerName: config?.providerName || null,
baseUrl: config?.baseUrl || null,
},
});
}

View File

@ -1,391 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 60;
type KittyCadTaskResponse = {
id?: string;
status?: string;
outputs?: Record<string, string> | null;
error?: string | null;
code?: string | null;
prompt?: string | null;
conversation_id?: string | null;
model_version?: string | null;
output_format?: string | null;
};
type ArchivedFile = {
sourceHash: string;
archivedUrl: string;
};
type RouteParams = {
params: Promise<{ taskId: string }>;
};
function getArchiveCache() {
const globalState = globalThis as Record<string, unknown>;
if (!globalState.__kittyCadAssetArchiveCache) {
globalState.__kittyCadAssetArchiveCache = new Map<string, ArchivedFile>();
}
return globalState.__kittyCadAssetArchiveCache as Map<string, ArchivedFile>;
}
function getArchiveInflight() {
const globalState = globalThis as Record<string, unknown>;
if (!globalState.__kittyCadAssetArchiveInflight) {
globalState.__kittyCadAssetArchiveInflight = new Map<
string,
Promise<string | null>
>();
}
return globalState.__kittyCadAssetArchiveInflight as Map<
string,
Promise<string | null>
>;
}
function mapKittyCadStatus(status: unknown) {
const normalized = String(status || "")
.trim()
.toLowerCase();
if (!normalized) {
return "queued" as const;
}
if (
normalized === "queued" ||
normalized === "pending" ||
normalized === "created" ||
normalized === "submitted"
) {
return "queued" as const;
}
if (
normalized === "running" ||
normalized === "processing" ||
normalized === "in_progress" ||
normalized === "working" ||
normalized === "generating"
) {
return "running" as const;
}
if (
normalized === "completed" ||
normalized === "success" ||
normalized === "succeeded" ||
normalized === "done" ||
normalized === "finished"
) {
return "success" as const;
}
if (normalized === "cancelled" || normalized === "canceled") {
return "cancelled" as const;
}
if (normalized === "expired" || normalized === "timed_out") {
return "expired" as const;
}
if (normalized === "banned" || normalized === "blocked") {
return "banned" as const;
}
if (
normalized === "failed" ||
normalized === "error" ||
normalized === "errored"
) {
return "failed" as const;
}
return "unknown" as const;
}
function getFileExtension(filename: string) {
const normalized = String(filename || "")
.trim()
.toLowerCase();
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
const segments = cleanName.split(".");
return segments.length > 1 ? segments.at(-1) || "" : "";
}
function isImageExtension(extension: string) {
return ["png", "jpg", "jpeg", "webp", "gif"].includes(extension);
}
function pickPreferredModelFile(files: Array<{ name: string; url: string }>) {
const preferredOrder = ["glb", "gltf", "obj", "stl", "step", "stp"];
for (const extension of preferredOrder) {
const matched = files.find(
(item) => getFileExtension(item.name) === extension
);
if (matched) {
return matched;
}
}
return null;
}
function pickPreviewImageFile(files: Array<{ name: string; url: string }>) {
return (
files.find((item) => isImageExtension(getFileExtension(item.name))) || null
);
}
async function archiveOutputIfNeeded(params: {
taskId: string;
filename: string;
base64: string;
}) {
const normalizedFilename = String(params.filename || "").trim();
const normalizedBase64 = String(params.base64 || "").trim();
if (!normalizedFilename || !normalizedBase64) {
return null;
}
const cacheKey = `${params.taskId}:${normalizedFilename}`;
const cache = getArchiveCache();
const cached = cache.get(cacheKey);
if (cached && cached.sourceHash === normalizedBase64) {
return cached.archivedUrl;
}
const inflight = getArchiveInflight();
const existingTask = inflight.get(cacheKey);
if (existingTask) {
return (await existingTask) || null;
}
const archiveTask = (async () => {
try {
const binary = Buffer.from(normalizedBase64, "base64");
if (binary.byteLength <= 0) {
throw new Error("KittyCAD 输出文件为空");
}
const arrayBuffer = Uint8Array.from(binary).buffer;
const safeFilename =
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.bin";
const uploaded = await uploadFile(
`kittycad-${params.taskId}-${safeFilename}`,
arrayBuffer
);
cache.set(cacheKey, {
sourceHash: normalizedBase64,
archivedUrl: uploaded.url,
});
return uploaded.url;
} catch (error) {
console.error("[KittyCADArchive] failed", {
taskId: params.taskId,
filename: normalizedFilename,
error: error instanceof Error ? error.message : String(error),
});
return null;
} finally {
inflight.delete(cacheKey);
}
})();
inflight.set(cacheKey, archiveTask);
return await archiveTask;
}
async function archiveTextOutputIfNeeded(params: {
taskId: string;
filename: string;
content: string;
}) {
const normalizedFilename = String(params.filename || "").trim();
const normalizedContent = String(params.content || "");
if (!normalizedFilename || !normalizedContent.trim()) {
return null;
}
const cacheKey = `${params.taskId}:${normalizedFilename}`;
const cache = getArchiveCache();
const cached = cache.get(cacheKey);
if (cached && cached.sourceHash === normalizedContent) {
return cached.archivedUrl;
}
const inflight = getArchiveInflight();
const existingTask = inflight.get(cacheKey);
if (existingTask) {
return (await existingTask) || null;
}
const archiveTask = (async () => {
try {
const arrayBuffer = Uint8Array.from(
new TextEncoder().encode(normalizedContent)
).buffer;
const safeFilename =
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.kcl";
const uploaded = await uploadFile(
`kittycad-${params.taskId}-${safeFilename}`,
arrayBuffer
);
cache.set(cacheKey, {
sourceHash: normalizedContent,
archivedUrl: uploaded.url,
});
return uploaded.url;
} catch (error) {
console.error("[KittyCADArchive:text] failed", {
taskId: params.taskId,
filename: normalizedFilename,
error: error instanceof Error ? error.message : String(error),
});
return null;
} finally {
inflight.delete(cacheKey);
}
})();
inflight.set(cacheKey, archiveTask);
return await archiveTask;
}
export async function GET(_request: Request, { params }: RouteParams) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { taskId } = await params;
const normalizedTaskId = String(taskId || "").trim();
if (!normalizedTaskId) {
return NextResponse.json(
{ success: false, error: "缺少 taskId" },
{ status: 400 }
);
}
const config = await getKittyCadProviderConfig();
const result = await kittyCadRequest<KittyCadTaskResponse>(config, {
method: "GET",
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
});
const status = mapKittyCadStatus(result.status);
if (status !== "success") {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "cad",
status,
progress: status === "running" ? 65 : null,
errorMessage: result.error || null,
conversationId: result.conversation_id || null,
modelVersion: result.model_version || null,
outputFormat: result.output_format || null,
output: result,
},
});
}
const outputEntries = Object.entries(result.outputs || {}).filter(
(entry): entry is [string, string] =>
typeof entry[0] === "string" &&
entry[0].trim().length > 0 &&
typeof entry[1] === "string" &&
entry[1].trim().length > 0
);
const archivedEntries = await Promise.all(
outputEntries.map(async ([name, base64]) => {
const archivedUrl = await archiveOutputIfNeeded({
taskId: normalizedTaskId,
filename: name,
base64,
});
return archivedUrl ? { name, url: archivedUrl } : null;
})
);
const archivedFiles = archivedEntries.filter(
(item): item is { name: string; url: string } =>
Boolean(item?.name && item?.url)
);
const hasArchivedKclFile = archivedFiles.some(
(item) => getFileExtension(item.name) === "kcl"
);
if (
!hasArchivedKclFile &&
typeof result.code === "string" &&
result.code.trim()
) {
const archivedCodeUrl = await archiveTextOutputIfNeeded({
taskId: normalizedTaskId,
filename: "main.kcl",
content: result.code,
});
if (archivedCodeUrl) {
archivedFiles.push({
name: "main.kcl",
url: archivedCodeUrl,
});
}
}
const modelFile = pickPreferredModelFile(archivedFiles);
const previewImageFile = pickPreviewImageFile(archivedFiles);
const downloadUrls = Object.fromEntries(
archivedFiles.map((item) => [item.name, item.url])
);
const codeFile =
archivedFiles.find((item) => getFileExtension(item.name) === "kcl") ||
null;
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "cad",
status: "success",
progress: 100,
modelUrl:
modelFile &&
["glb", "gltf"].includes(getFileExtension(modelFile.name))
? modelFile.url
: null,
previewImageUrl: previewImageFile?.url || null,
errorMessage: result.error || null,
conversationId: result.conversation_id || null,
modelVersion: result.model_version || null,
outputFormat: result.output_format || null,
codeUrl: codeFile?.url || null,
downloadUrls,
output: result,
},
});
} catch (error) {
if (error instanceof KittyCadApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询 AI-CAD 任务失败",
},
{ status: 500 }
);
}
}

View File

@ -1,331 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
consumeUserPoints,
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
export const runtime = "nodejs";
export const maxDuration = 60;
type CreateAiCadTaskRequest = {
prompt?: string;
outputFormat?: string;
projectName?: string;
iterationSourceTaskId?: string;
sourceCode?: string;
sourceRanges?: Record<string, unknown>[];
};
type KittyCadCreateResponse = {
id?: string;
status?: string;
conversation_id?: string | null;
model_version?: string | null;
output_format?: string | null;
};
type KittyCadSourceTaskResponse = {
code?: string | null;
outputs?: Record<string, string> | null;
};
type KittyCadIterationResponse = {
id?: string;
status?: string;
conversation_id?: string | null;
model_version?: string | null;
output_format?: string | null;
};
function getFileExtension(filename: string) {
const normalized = String(filename || "")
.trim()
.toLowerCase();
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
const segments = cleanName.split(".");
return segments.length > 1 ? segments.at(-1) || "" : "";
}
function decodeBase64Text(value: unknown) {
const normalized = String(value || "").trim();
if (!normalized) {
return null;
}
try {
const decoded = Buffer.from(normalized, "base64").toString("utf8").trim();
return decoded || null;
} catch (_error) {
return null;
}
}
function extractSourceCodeFromTask(task: KittyCadSourceTaskResponse) {
const directCode = String(task.code || "").trim();
if (directCode) {
return directCode;
}
const kclOutputEntry = Object.entries(task.outputs || {}).find(
(entry): entry is [string, string] =>
typeof entry[0] === "string" &&
entry[0].trim().length > 0 &&
getFileExtension(entry[0]) === "kcl" &&
typeof entry[1] === "string" &&
entry[1].trim().length > 0
);
if (!kclOutputEntry) {
return null;
}
return decodeBase64Text(kclOutputEntry[1]);
}
function mapKittyCadStatus(status: unknown) {
const normalized = String(status || "")
.trim()
.toLowerCase();
if (!normalized) {
return "queued" as const;
}
if (
normalized === "queued" ||
normalized === "pending" ||
normalized === "created" ||
normalized === "submitted"
) {
return "queued" as const;
}
if (
normalized === "running" ||
normalized === "processing" ||
normalized === "in_progress" ||
normalized === "working" ||
normalized === "generating"
) {
return "running" as const;
}
if (
normalized === "completed" ||
normalized === "success" ||
normalized === "succeeded" ||
normalized === "done" ||
normalized === "finished"
) {
return "success" as const;
}
if (normalized === "cancelled" || normalized === "canceled") {
return "cancelled" as const;
}
if (normalized === "expired" || normalized === "timed_out") {
return "expired" as const;
}
if (normalized === "banned" || normalized === "blocked") {
return "banned" as const;
}
if (
normalized === "failed" ||
normalized === "error" ||
normalized === "errored"
) {
return "failed" as const;
}
return "unknown" as const;
}
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()) as CreateAiCadTaskRequest;
const prompt = String(body.prompt || "").trim();
const pointsConfig = await getPointsSystemConfig();
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel))
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel)))
: 0;
if (
pointsConfig.enabled &&
unitCost > 0 &&
pointsConfig.blockOnInsufficient !== false
) {
const userData = await getUserById({ id: session.user.id });
if (!userData?.isHighVip) {
const currentBalance = Math.max(
0,
Number(userData?.pointsBalance || 0)
);
if (currentBalance < unitCost) {
return NextResponse.json(
{
success: false,
error: "积分不足",
code: "insufficient_points",
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
balance: currentBalance,
expectedCost: unitCost,
unitCost,
},
{ status: 402 }
);
}
}
}
const outputFormat =
String(body.outputFormat || "glb")
.trim()
.toLowerCase() || "glb";
const normalizedProjectName = String(body.projectName || "").trim();
const iterationSourceTaskId = String(
body.iterationSourceTaskId || ""
).trim();
const sourceCode = String(body.sourceCode || "");
const normalizedSourceCode = sourceCode.trim();
const sourceRanges = Array.isArray(body.sourceRanges)
? body.sourceRanges.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === "object" && !Array.isArray(item)
)
: [];
const config = await getKittyCadProviderConfig();
const generationMode =
iterationSourceTaskId || normalizedSourceCode ? "iterate" : "create";
if (generationMode === "create" && !prompt) {
return NextResponse.json(
{ success: false, error: "请输入 CAD 描述" },
{ status: 400 }
);
}
const iterationPrompt =
prompt || "Apply the edited KCL source and rebuild the CAD model.";
const result =
generationMode === "iterate"
? await (async () => {
const originalSourceCode =
normalizedSourceCode ||
(await (async () => {
if (!iterationSourceTaskId) {
return null;
}
const sourceTask =
await kittyCadRequest<KittyCadSourceTaskResponse>(config, {
method: "GET",
path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`,
});
return extractSourceCodeFromTask(sourceTask);
})());
if (!originalSourceCode) {
throw new KittyCadApiError({
message:
"当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。",
httpStatus: 409,
});
}
return kittyCadRequest<KittyCadIterationResponse>(config, {
method: "POST",
path: "/ml/text-to-cad/iteration",
json: {
original_source_code: originalSourceCode,
prompt: iterationPrompt,
source_ranges: sourceRanges,
...(normalizedProjectName
? { project_name: normalizedProjectName }
: {}),
},
});
})()
: await kittyCadRequest<KittyCadCreateResponse>(config, {
method: "POST",
path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`,
json: {
prompt,
...(normalizedProjectName
? { project_name: normalizedProjectName }
: {}),
},
});
const taskId = String(result.id || "").trim();
if (!taskId) {
return NextResponse.json(
{
success: false,
error: "KittyCAD 返回缺少任务 ID",
raw: result,
},
{ status: 502 }
);
}
if (pointsConfig.enabled && unitCost > 0) {
try {
await consumeUserPoints({
userId: session.user.id,
amount: unitCost,
reason: "model3d_generation",
modelId: "kittycad-cad",
note:
generationMode === "iterate"
? "AI-CAD 连续迭代消耗积分"
: "AI-CAD 生成消耗积分",
});
} catch (error) {
console.error("[Points] Failed to consume AI-CAD points:", error);
}
}
return NextResponse.json({
success: true,
data: {
taskId,
status: mapKittyCadStatus(result.status),
conversationId: result.conversation_id || null,
modelVersion: result.model_version || null,
outputFormat:
generationMode === "iterate"
? result.output_format || null
: result.output_format || outputFormat,
generationMode,
sourceTaskId: iterationSourceTaskId || null,
raw: result,
},
});
} catch (error) {
if (error instanceof KittyCadApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "创建 AI-CAD 任务失败",
},
{ status: 500 }
);
}
}

View File

@ -1,195 +0,0 @@
import type { AiCadWorkspace } from "@/lib/db/schema";
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
const FINAL_TASK_STATUS = new Set([
"success",
"failed",
"banned",
"expired",
"cancelled",
"unknown",
]);
const DEFAULT_VIEWER_SETTINGS = {
cameraPreset: "iso",
envPreset: "studio",
lightIntensity: 1.1,
autoRotate: true,
showGrid: true,
wireframe: false,
} as const;
type WorkspaceVersionLike = {
status?: unknown;
prompt?: unknown;
};
export type UpdateAiCadWorkspaceRequest = {
name?: string;
prompt?: string;
generationProvider?: string;
modelVersion?: string;
viewerSettings?: unknown;
messages?: unknown;
versions?: unknown;
activeVersionId?: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getWorkspaceVersions(value: unknown): WorkspaceVersionLike[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is WorkspaceVersionLike => isRecord(item));
}
export function normalizeViewerSettings(
value: unknown
): Record<string, unknown> {
if (!isRecord(value)) {
return { ...DEFAULT_VIEWER_SETTINGS };
}
return {
cameraPreset:
value.cameraPreset === "top" ||
value.cameraPreset === "front" ||
value.cameraPreset === "right" ||
value.cameraPreset === "iso"
? value.cameraPreset
: DEFAULT_VIEWER_SETTINGS.cameraPreset,
envPreset:
typeof value.envPreset === "string" && value.envPreset.trim()
? value.envPreset
: DEFAULT_VIEWER_SETTINGS.envPreset,
lightIntensity:
typeof value.lightIntensity === "number" &&
Number.isFinite(value.lightIntensity)
? value.lightIntensity
: DEFAULT_VIEWER_SETTINGS.lightIntensity,
autoRotate:
typeof value.autoRotate === "boolean"
? value.autoRotate
: DEFAULT_VIEWER_SETTINGS.autoRotate,
showGrid:
typeof value.showGrid === "boolean"
? value.showGrid
: DEFAULT_VIEWER_SETTINGS.showGrid,
wireframe:
typeof value.wireframe === "boolean"
? value.wireframe
: DEFAULT_VIEWER_SETTINGS.wireframe,
};
}
export function normalizeJsonRecordArray(
value: unknown
): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is Record<string, unknown> =>
isRecord(item)
);
}
export function normalizeWorkspaceName(
value: unknown,
fallbackName = "AI-CAD 工作台"
) {
return typeof value === "string" && value.trim()
? value.trim()
: fallbackName;
}
export function normalizeWorkspacePayload(body: UpdateAiCadWorkspaceRequest): {
name?: string;
prompt: string;
generationProvider: CadGenerationProvider;
modelVersion: string;
viewerSettings: Record<string, unknown>;
messages: Record<string, unknown>[];
versions: Record<string, unknown>[];
activeVersionId: string | null;
} {
return {
...(typeof body.name === "string" && body.name.trim()
? { name: body.name.trim() }
: {}),
prompt: String(body.prompt || ""),
generationProvider:
body.generationProvider === "tripo-3d" ? "tripo-3d" : "kittycad-cad",
modelVersion:
typeof body.modelVersion === "string" && body.modelVersion.trim()
? body.modelVersion
: "Turbo-v1.0-20250506",
viewerSettings: normalizeViewerSettings(body.viewerSettings),
messages: normalizeJsonRecordArray(body.messages),
versions: normalizeJsonRecordArray(body.versions),
activeVersionId:
typeof body.activeVersionId === "string" && body.activeVersionId.trim()
? body.activeVersionId
: null,
};
}
export function buildDefaultWorkspaceName(index: number) {
return index > 1 ? `AI-CAD 工作台 ${index}` : "AI-CAD 工作台";
}
export function createWorkspaceSummary(workspace: AiCadWorkspace) {
const versions = getWorkspaceVersions(workspace.versions);
const versionCount = versions.length;
const completedCount = versions.filter(
(version) => version.status === "success"
).length;
const hasPendingTasks = versions.some(
(version) =>
typeof version.status !== "string" ||
!FINAL_TASK_STATUS.has(version.status)
);
const latestPrompt =
typeof workspace.prompt === "string" && workspace.prompt.trim()
? workspace.prompt.trim()
: versions.find(
(version) =>
typeof version.prompt === "string" && version.prompt.trim()
)?.prompt || null;
return {
id: workspace.id,
name: workspace.name,
generationProvider: workspace.generationProvider,
versionCount,
completedCount,
hasPendingTasks,
latestPrompt:
typeof latestPrompt === "string" && latestPrompt.trim()
? latestPrompt.trim()
: null,
createdAt: workspace.createdAt?.toISOString() || null,
updatedAt: workspace.updatedAt?.toISOString() || null,
};
}
export function createWorkspaceDocument(workspace: AiCadWorkspace) {
return {
id: workspace.id,
name: workspace.name,
prompt: workspace.prompt,
generationProvider: workspace.generationProvider,
modelVersion: workspace.modelVersion,
viewerSettings: workspace.viewerSettings,
messages: workspace.messages,
versions: workspace.versions,
activeVersionId: workspace.activeVersionId,
createdAt: workspace.createdAt?.toISOString() || null,
updatedAt: workspace.updatedAt?.toISOString() || null,
};
}

View File

@ -1,89 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
createWorkspaceDocument,
normalizeWorkspacePayload,
type UpdateAiCadWorkspaceRequest,
} from "@/app/api/ai-cad/workspace-utils";
import {
createAiCadWorkspace,
listAiCadWorkspacesByUserId,
updateAiCadWorkspace,
} from "@/lib/db/queries";
export const runtime = "nodejs";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const workspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
const workspace = workspaces[0] ?? null;
if (!workspace) {
return NextResponse.json({
success: true,
data: null,
});
}
return NextResponse.json({
success: true,
data: createWorkspaceDocument(workspace),
});
}
export async function PUT(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
const existingWorkspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
const targetWorkspace =
existingWorkspaces[0] ??
(await createAiCadWorkspace({
userId: session.user.id,
name: "AI-CAD 工作台",
}));
if (!targetWorkspace) {
return NextResponse.json(
{ success: false, error: "初始化 AI-CAD 工作台失败" },
{ status: 500 }
);
}
const savedWorkspace = await updateAiCadWorkspace({
workspaceId: targetWorkspace.id,
userId: session.user.id,
...normalizeWorkspacePayload(body),
});
if (!savedWorkspace) {
return NextResponse.json(
{ success: false, error: "保存 AI-CAD 工作台失败" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
data: {
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
},
});
}

View File

@ -1,114 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
createWorkspaceDocument,
createWorkspaceSummary,
normalizeWorkspacePayload,
type UpdateAiCadWorkspaceRequest,
} from "@/app/api/ai-cad/workspace-utils";
import {
deleteAiCadWorkspaceById,
getAiCadWorkspaceById,
updateAiCadWorkspace,
} from "@/lib/db/queries";
export const runtime = "nodejs";
type RouteContext = {
params: Promise<{
workspaceId: string;
}>;
};
export async function GET(_request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { workspaceId } = await context.params;
const workspace = await getAiCadWorkspaceById({
userId: session.user.id,
workspaceId,
});
if (!workspace) {
return NextResponse.json(
{ success: false, error: "AI-CAD 工作台不存在" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: createWorkspaceDocument(workspace),
});
}
export async function PUT(request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { workspaceId } = await context.params;
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
const savedWorkspace = await updateAiCadWorkspace({
workspaceId,
userId: session.user.id,
...normalizeWorkspacePayload(body),
});
if (!savedWorkspace) {
return NextResponse.json(
{ success: false, error: "AI-CAD 工作台不存在或保存失败" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: {
id: savedWorkspace.id,
name: savedWorkspace.name,
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
summary: createWorkspaceSummary(savedWorkspace),
},
});
}
export async function DELETE(_request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { workspaceId } = await context.params;
const deletedWorkspace = await deleteAiCadWorkspaceById({
workspaceId,
userId: session.user.id,
});
if (!deletedWorkspace) {
return NextResponse.json(
{ success: false, error: "AI-CAD 工作台不存在" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: {
id: deletedWorkspace.id,
},
});
}

View File

@ -1,69 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
buildDefaultWorkspaceName,
createWorkspaceSummary,
normalizeWorkspaceName,
} from "@/app/api/ai-cad/workspace-utils";
import {
createAiCadWorkspace,
listAiCadWorkspacesByUserId,
} from "@/lib/db/queries";
export const runtime = "nodejs";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const workspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
return NextResponse.json({
success: true,
data: workspaces.map(createWorkspaceSummary),
});
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = (await request.json().catch(() => ({}))) as { name?: unknown };
const existingWorkspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
const workspace = await createAiCadWorkspace({
userId: session.user.id,
name: normalizeWorkspaceName(
body.name,
buildDefaultWorkspaceName(existingWorkspaces.length + 1)
),
});
if (!workspace) {
return NextResponse.json(
{ success: false, error: "创建 AI-CAD 工作台失败" },
{ status: 500 }
);
}
return NextResponse.json(
{
success: true,
data: createWorkspaceSummary(workspace),
},
{ status: 201 }
);
}

View File

@ -0,0 +1,66 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const ALLOWED_WEBSOCKET_PATHS = new Set([
"/api/ai-cad/zoo-ws/ml/copilot",
"/api/ai-cad/zoo-ws/modeling/commands",
]);
function hasValidInternalSecret(request: Request) {
const expected = String(process.env.AI_CAD_WS_PROXY_SECRET || "").trim();
const received = String(
request.headers.get("x-hicad-ws-proxy-secret") || ""
).trim();
if (!expected || !received) {
return false;
}
const expectedBuffer = Buffer.from(expected);
const receivedBuffer = Buffer.from(received);
return (
expectedBuffer.length === receivedBuffer.length &&
timingSafeEqual(expectedBuffer, receivedBuffer)
);
}
export async function GET(request: Request) {
if (!hasValidInternalSecret(request)) {
return NextResponse.json({ error: "禁止访问" }, { status: 403 });
}
const originalUri = String(
request.headers.get("x-hicad-original-uri") || ""
).split("?", 1)[0];
if (!ALLOWED_WEBSOCKET_PATHS.has(originalUri)) {
return NextResponse.json({ error: "不支持的 Zoo WebSocket" }, { status: 403 });
}
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
try {
const config = await getKittyCadProviderConfig();
return new Response(null, {
headers: {
"Cache-Control": "private, no-store",
"X-Zoo-Authorization": `Bearer ${config.apiKey}`,
},
status: 204,
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Zoo WebSocket 鉴权失败",
},
{ status: 503 }
);
}
}

View File

@ -0,0 +1,140 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export const runtime = "nodejs";
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"content-length",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
const BLOCKED_PATH_PREFIXES = [
"/admin",
"/internal",
"/logout",
"/oauth2",
"/org/api-tokens",
"/user/api-tokens",
"/users",
];
type RouteContext = {
params: Promise<{ path: string[] }>;
};
function isBlockedPath(pathname: string) {
return BLOCKED_PATH_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
);
}
function copyRequestHeaders(request: Request, apiKey: string) {
const headers = new Headers();
for (const [name, value] of request.headers.entries()) {
const normalizedName = name.toLowerCase();
if (
HOP_BY_HOP_HEADERS.has(normalizedName) ||
normalizedName === "authorization" ||
normalizedName === "cookie" ||
normalizedName === "origin" ||
normalizedName === "referer" ||
normalizedName.startsWith("x-forwarded-") ||
normalizedName === "x-hicad-ws-proxy-secret"
) {
continue;
}
headers.set(name, value);
}
headers.set("Authorization", `Bearer ${apiKey}`);
headers.set("Accept", request.headers.get("accept") || "application/json");
return headers;
}
function copyResponseHeaders(upstream: Response) {
const headers = new Headers();
for (const [name, value] of upstream.headers.entries()) {
const normalizedName = name.toLowerCase();
if (
HOP_BY_HOP_HEADERS.has(normalizedName) ||
normalizedName === "content-encoding" ||
normalizedName === "set-cookie"
) {
continue;
}
headers.set(name, value);
}
headers.set("Cache-Control", "private, no-store");
return headers;
}
async function proxyZooRequest(request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { path } = await context.params;
const pathname = `/${path.map(encodeURIComponent).join("/")}`;
if (isBlockedPath(pathname)) {
return NextResponse.json(
{ error: "该 Zoo API 不允许通过 HiCAD 代理访问" },
{ status: 403 }
);
}
try {
const config = await getKittyCadProviderConfig();
const incomingUrl = new URL(request.url);
const baseUrl = config.baseUrl.replace(/\/+$/, "");
const upstreamUrl = `${baseUrl}${pathname}${incomingUrl.search}`;
const method = request.method.toUpperCase();
const hasBody = method !== "GET" && method !== "HEAD";
const init: RequestInit & { duplex?: "half" } = {
body: hasBody ? request.body : undefined,
cache: "no-store",
duplex: hasBody ? "half" : undefined,
headers: copyRequestHeaders(request, config.apiKey),
method,
redirect: "manual",
signal: request.signal,
};
const upstream = await fetch(upstreamUrl, init);
return new Response(upstream.body, {
headers: copyResponseHeaders(upstream),
status: upstream.status,
statusText: upstream.statusText,
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Zoo API 代理请求失败",
},
{ status: 502 }
);
}
}
export const DELETE = proxyZooRequest;
export const GET = proxyZooRequest;
export const HEAD = proxyZooRequest;
export const OPTIONS = proxyZooRequest;
export const PATCH = proxyZooRequest;
export const POST = proxyZooRequest;
export const PUT = proxyZooRequest;

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

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
getPointsSystemConfig,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
@ -10,6 +11,8 @@ 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");
@ -171,6 +174,19 @@ export async function POST(request: NextRequest) {
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) {
@ -256,6 +272,19 @@ export async function POST(request: NextRequest) {
})
.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()

View File

@ -4,23 +4,128 @@ import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { systemSettings } from "@/lib/db/schema";
import {
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
CANVAS_SKILL_TAGS_CONFIG_VERSION,
CANVAS_SKILL_TAGS_SETTING_KEY,
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}`;
}
function normalizeSkillTagOrder(value: unknown) {
const source =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>).tagIds
: value;
if (!Array.isArray(source)) return [];
const seen = new Set<string>();
return source
.map((item) => String(item || "").trim())
.filter((item) => {
if (!item || seen.has(item)) return false;
seen.add(item);
return true;
});
}
export async function GET() {
try {
const session = await auth().catch(() => null);
const userId = session?.user?.id || null;
const [setting, userOrderSetting] = await Promise.all([
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
.limit(1)
.then((rows) => rows[0]),
userId
? db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, getUserSkillTagOrderKey(userId)))
.limit(1)
.then((rows) => rows[0])
: Promise.resolve(null),
]);
const settings = normalizeCanvasSkillTagSettings(setting?.value, {
requireCurrentVersion: true,
});
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
return NextResponse.json({
...settings,
tags: applyCanvasSkillTagOrder(settings.tags, userOrder),
userOrder,
});
} catch (error) {
console.error("[CanvasSkillTags API] Get error:", error);
return NextResponse.json(normalizeCanvasSkillTagSettings(null));
}
}
export async function PATCH(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => null);
const requestedOrder = normalizeSkillTagOrder(
body && typeof body === "object" ? (body as Record<string, unknown>).tagIds : body
);
const [setting] = await db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
.limit(1);
const settings = normalizeCanvasSkillTagSettings(setting?.value, {
requireCurrentVersion: true,
});
const knownIds = new Set(settings.tags.map((tag) => tag.id));
const orderedKnownIds = requestedOrder.filter((id) => knownIds.has(id));
const orderedSet = new Set(orderedKnownIds);
const tagIds = [
...orderedKnownIds,
...settings.tags.map((tag) => tag.id).filter((id) => !orderedSet.has(id)),
];
const value = {
version: 1,
tagIds,
};
const key = getUserSkillTagOrderKey(session.user.id);
const settings = normalizeCanvasSkillTagSettings(setting?.value);
return NextResponse.json(settings);
await db
.insert(systemSettings)
.values({
key,
value,
updatedBy: session.user.id,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value,
updatedBy: session.user.id,
updatedAt: new Date(),
},
});
return NextResponse.json({
success: true,
userOrder: tagIds,
tags: applyCanvasSkillTagOrder(settings.tags, tagIds),
});
} catch (error) {
console.error("[CanvasSkillTags API] Get error:", error);
return NextResponse.json(normalizeCanvasSkillTagSettings(null));
console.error("[CanvasSkillTags API] Save order error:", error);
return NextResponse.json({ error: "Failed to save order" }, { status: 500 });
}
}
@ -35,8 +140,15 @@ 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, tags };
const nextValue = {
enabled,
title,
categories,
tags,
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
};
const [existing] = await db
.select()
@ -61,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

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
const { id } = await params;
const response = await handleCanvasAgentOperationRouteRequest({
userId: session?.user?.id,
projectId: id,
readBody: () => request.json(),
features: await getCanvasAgentFeatureFlagsFromSystemSettings(),
applyOperation: applyCanvasOperationPlan,
logger: console,
});
return NextResponse.json(response.body, { status: response.status });
}

View File

@ -0,0 +1,91 @@
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { auth } from "@/app/(auth)/auth";
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
export const dynamic = "force-dynamic";
export const maxDuration = 600;
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id } = await params;
const requestId = new URL(request.url).searchParams.get("requestId");
if (!requestId) {
return NextResponse.json({
success: true,
capabilities: getCanvasCapabilityManifest(),
});
}
const task = await getCanvasCapabilityTask({
requestId,
userId: session.user.id,
projectId: id,
});
if (!task) {
return NextResponse.json(
{ success: false, error: "能力任务不存在" },
{ status: 404 }
);
}
return NextResponse.json({ success: true, task });
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
try {
const { id } = await params;
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
const result = await executeCanvasCapabilityRuntime({
command,
projectId: id,
requestHeaders: request.headers,
requestUrl: request.url,
userId: session.user.id,
});
return NextResponse.json({ success: true, ...result });
} catch (error) {
if (
error instanceof CanvasCapabilityRuntimeError ||
error instanceof CanvasAgentMutationError
) {
return NextResponse.json(
{ success: false, error: error.message, code: error.code },
{ status: error.status }
);
}
if (error instanceof ZodError) {
return NextResponse.json(
{
success: false,
error: "画布能力参数无效",
issues: error.issues,
},
{ status: 400 }
);
}
const message = error instanceof Error ? error.message : "执行画布能力失败";
return NextResponse.json(
{ success: false, error: message },
{ status: 500 }
);
}
}

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

@ -4,6 +4,10 @@ import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { eq, and } from 'drizzle-orm';
import {
buildStaleCanvasSaveConflictPayload,
isStaleCanvasSaveBaseRevision,
} from '@/lib/canvas/canvas-save-conflict';
function resolveWorkspaceType(input: unknown) {
return input === "agent" || input === "video" ? input : "canvas";
@ -13,19 +17,67 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorksp
return workspaceType === "video" ? "未命名视频画布" : "未命名画布";
}
function shouldUseLocalCanvasE2E(request: NextRequest) {
const hostname = request.nextUrl.hostname;
return (
process.env.CANVAS_E2E_LOCAL === "true" &&
(hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1")
);
}
function createLocalCanvasProject(
id: string,
input: {
canvasData?: unknown;
description?: unknown;
thumbnail?: unknown;
title?: unknown;
workspaceType?: unknown;
} = {}
) {
const now = new Date().toISOString();
const workspaceType = resolveWorkspaceType(input.workspaceType || "video");
return {
id,
userId: "local-video-canvas-e2e-user",
title: String(input.title || getDefaultWorkspaceTitle(workspaceType)),
description: typeof input.description === "string" ? input.description : null,
thumbnail: typeof input.thumbnail === "string" ? input.thumbnail : null,
workspaceType,
canvasData:
input.canvasData && typeof input.canvasData === "object"
? input.canvasData
: {
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select",
},
createdAt: now,
updatedAt: now,
};
}
// 获取单个画布
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
if (shouldUseLocalCanvasE2E(request)) {
return NextResponse.json({
success: true,
data: createLocalCanvasProject(id),
});
}
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
}
const { id } = await params;
const [project] = await db
.select()
.from(canvasProject)
@ -53,26 +105,26 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
if (shouldUseLocalCanvasE2E(request)) {
return NextResponse.json({
success: true,
data: createLocalCanvasProject(id, body),
});
}
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const { title, description, thumbnail, canvasData } = body;
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
? resolveWorkspaceType(body.workspaceType)
: undefined;
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
// 先检查画布是否存在
const [existingProject] = await db
@ -86,6 +138,30 @@ export async function PUT(
);
if (existingProject) {
if (
canvasData !== undefined &&
baseCanvasRevision &&
isStaleCanvasSaveBaseRevision({
baseCanvasRevision: body?.baseCanvasRevision,
baseCanvasUpdatedAt: body?.baseCanvasUpdatedAt,
currentUpdatedAt: existingProject.updatedAt,
})
) {
return NextResponse.json(
buildStaleCanvasSaveConflictPayload(existingProject.updatedAt),
{ status: 409 }
);
}
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
// 更新现有画布
const updateData: any = { updatedAt: new Date() };
if (title !== undefined) updateData.title = title;
@ -107,23 +183,62 @@ export async function PUT(
return NextResponse.json({ success: true, data: updatedProject });
} else {
// 创建新画布(使用指定 ID)
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
const nextWorkspaceType = workspaceType || "canvas";
const nextCanvasData = syncedCanvasData || {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
};
const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
const insertData: any = {
id, // 使用指定的 ID
userId: session.user.id,
title: nextTitle,
workspaceType: nextWorkspaceType,
canvasData: nextCanvasData,
};
const conflictUpdateData: any = {
title: nextTitle,
workspaceType: nextWorkspaceType,
canvasData: nextCanvasData,
updatedAt: new Date(),
};
if (description !== undefined) {
insertData.description = description;
conflictUpdateData.description = description;
}
if (thumbnail !== undefined) {
insertData.thumbnail = thumbnail;
conflictUpdateData.thumbnail = thumbnail;
}
// 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
const [newProject] = await db
.insert(canvasProject)
.values({
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 },
},
.values(insertData)
.onConflictDoUpdate({
target: canvasProject.id,
set: conflictUpdateData,
where: eq(canvasProject.userId, session.user.id),
})
.returning();
if (!newProject) {
return NextResponse.json(
{ success: false, error: '画布ID已被占用' },
{ status: 409 }
);
}
return NextResponse.json({ success: true, data: newProject });
}
} catch (error) {

View File

@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
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(),
userMessageId: z.string().uuid(),
});
export async function POST(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 parsed = cancelSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
const existingChat = await getChatById({ id: parsed.data.id }).catch(() => null);
if (existingChat && existingChat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
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,
});
});
}
}
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

@ -0,0 +1,52 @@
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;
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 limit = Math.max(
1,
Math.min(100, Math.round(Number(url.searchParams.get("limit") || 50)))
);
const chats = await getClawdChatsByUserId({
userId: session.user.id,
limit,
});
return NextResponse.json(
{
chats: chats.map((chat) => ({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt?.toISOString?.() || chat.createdAt,
updatedAt: chat.updatedAt?.toISOString?.() || chat.updatedAt,
messageCount: Number(chat.messageCount || 0),
})),
},
{
headers: {
"Cache-Control": "no-store, max-age=0",
},
}
);
}

View File

@ -0,0 +1,643 @@
import { after, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
getChatById,
getSystemSetting,
getUserById,
saveChat,
} from "@/lib/db/queries";
import {
buildClawdCancelKey,
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 {
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 DEFAULT_PROCESSING_HINT =
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
export const maxDuration = 1800;
const requestSchema = z.object({
id: z.string().uuid(),
message: z.string().trim().max(8000).optional().default(""),
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 = 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 对话";
return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized;
}
function createConversationId(userId: string, chatId: string) {
return `site-user-${userId}-canvas-${chatId}`;
}
function getOpenClawUserName(user: {
id: string;
name?: string | null;
email?: string | null;
}) {
return String(user.name || user.email || user.id).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 {
return (
typeof value === "object" &&
value !== null &&
"arrayBuffer" in value &&
"name" in value &&
"size" in value
);
}
async function parseClawdChatRequest(
request: Request
): Promise<ParsedClawdChatRequest | null> {
const contentType = request.headers.get("content-type") || "";
if (!contentType.includes("multipart/form-data")) {
const parsed = requestSchema.safeParse(await request.json().catch(() => 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;
let metadata: Record<string, unknown> | undefined;
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") 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({
id: formData.get("id"),
message: formData.get("message") || "",
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;
}
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);
return normalizeOpenClawChatConfig(setting?.value);
}
function getAnswer(payload: unknown) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return "";
}
return String((payload as Record<string, unknown>).answer || "").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;
}
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: false,
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),
});
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,
});
} 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: 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 &&
parsed.attachments.length === 0
) {
return NextResponse.json(
{ error: "请输入问题或上传附件" },
{ status: 400 }
);
}
const config = await readOpenClawConfig();
if (!config.enabled || !config.baseUrl || !config.token) {
return NextResponse.json(
{ error: OPENCLAW_UPGRADE_RETRY_MESSAGE },
{ status: 503 }
);
}
const existingChat = await getChatById({ id: parsed.id }).catch(() => null);
if (existingChat && existingChat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const userMessageId = parsed.userMessageId || generateUUID();
const existingTask = await getPersistedClawdTask({
chatId: parsed.id,
userMessageId,
}).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<
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
> | null = null;
if (parsed.tableFiles.length > 0) {
try {
spreadsheetMarkdownResult = await buildOpenClawSpreadsheetMarkdown(
parsed.tableFiles
);
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "表格解析失败,请检查文件格式",
},
{ status: 400 }
);
}
}
const displayMessage = buildOpenClawDisplayMessage({
message: parsed.displayMessage || parsed.message,
files: spreadsheetMarkdownResult?.files || [],
});
const upstreamMessage = buildOpenClawMessageWithSpreadsheetMarkdown({
message: parsed.message,
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
});
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({
id: parsed.id,
userId: session.user.id,
title: buildChatTitle(displayMessage),
visibility: "private",
});
}
const assistantMessageId = generateUUID();
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"),
}));
await persistPendingClawdTask({
chatId: parsed.id,
userMessageId,
assistantMessageId,
displayMessage,
attachments: messageAttachments,
spreadsheetAttachments,
skillTagId,
skillTagName,
processingHint,
});
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 cancelKey = buildClawdCancelKey({
userId: session.user.id,
chatId: parsed.id,
userMessageId,
});
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,
});
});
const responseBody = buildTaskResponse({
chatId: parsed.id,
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",
},
});
}

View File

@ -0,0 +1,190 @@
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,
});
} 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

@ -49,6 +49,10 @@ const DETAIL_PLAN_INLINE_RETRIES = 1;
const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000;
const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000;
const logger = createLogger("canvas-detail-plan");
const DETAIL_MODEL_USAGE_HINT_PATTERN =
/模特|人物|人像|真人|人体|露脸|人脸|面部|发型|体型|肤色|穿搭|服装|试坐|试用|手持|拿着|自拍|合照|真人演示|人物场景|场景演示|使用者|顾客.{0,8}使用|model|person|people|human|face|hair|body|outfit|wearing|holding|selfie|lifestyle\s+demo/i;
const DETAIL_NO_MODEL_HINT_PATTERN =
/不要出现人物|不出现人物|不要有人物|无人|无人物|只展示产品|产品信息展示|不使用人物|不使用模特|禁止出现人物|no\s+(human|person|people)|without\s+(human|person|people)|product[-\s]?only/i;
function isAbortError(error: unknown): boolean {
const target = error as {
@ -271,6 +275,77 @@ type DetailPlanResponse = {
};
};
function buildFixedModelIdentityInstruction(language: "zh" | "en") {
return language === "en"
? "Fixed model identity lock: if this frame includes a person, use only the same person from the uploaded model image. Keep face, hairstyle, body type, skin tone, and outfit style consistent; do not replace with another person. Pose and camera angle may adapt to the composition."
: "固定模特身份硬约束:如果本屏出现人物,只允许使用上传模特图中的同一人物;脸型五官、发型发色、体型肤色和穿搭风格必须保持一致,不得换成其他人。姿势、视线和机位可按构图调整。";
}
function screenSuggestsModelUsage(screen: DetailPlanScreen) {
const text = [
screen.objective,
screen.title,
screen.subtitle,
screen.copy,
screen.layoutNote,
screen.modelReason,
screen.visualPrompt,
screen.fabe?.b,
screen.fabe?.f,
screen.fabe?.a,
screen.fabe?.e,
]
.filter(Boolean)
.join(" ");
return (
DETAIL_MODEL_USAGE_HINT_PATTERN.test(text) &&
!DETAIL_NO_MODEL_HINT_PATTERN.test(text)
);
}
function normalizeDetailModelUsage(params: {
screens: DetailPlanScreen[];
hasModelImage: boolean;
language: "zh" | "en";
}) {
const { screens, hasModelImage, language } = params;
const identityInstruction = buildFixedModelIdentityInstruction(language);
return screens.map((screen) => {
if (!hasModelImage) {
return {
...screen,
useModel: false,
modelReason:
language === "en"
? "No model image was provided, so this frame does not use a fixed model."
: "未提供模特图,当前分镜不使用人物。",
};
}
const shouldUseModel = screen.useModel || screenSuggestsModelUsage(screen);
if (!shouldUseModel) {
return screen;
}
const modelReason =
screen.modelReason && !DETAIL_NO_MODEL_HINT_PATTERN.test(screen.modelReason)
? screen.modelReason
: language === "en"
? "This frame contains a human/model scenario, so the uploaded fixed model must be used."
: "本屏包含人物/模特展示语义,必须使用上传的固定模特图。";
const visualPrompt = screen.visualPrompt.includes(identityInstruction)
? screen.visualPrompt
: `${screen.visualPrompt} ${identityInstruction}`;
return {
...screen,
useModel: true,
modelReason,
visualPrompt,
};
});
}
type DetailPlanPreflight = {
passed: boolean;
designDirectorScore: number;
@ -1230,9 +1305,13 @@ function buildFallbackPlan(params: {
...params,
globalStyle,
});
const screens = enforceCoreFocusOnScreens({
screens: fallbackScreens,
coreFocus: params.coreFocus,
const screens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: fallbackScreens,
coreFocus: params.coreFocus,
language,
}),
hasModelImage: params.hasModelImage,
language,
});
const qualityChecks = evaluatePlanQuality({
@ -1877,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 &&
@ -2181,27 +2260,28 @@ export async function POST(request: Request) {
dslV2Screens?: unknown;
}>(text || "");
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) =>
hasModelImage
? item
: {
...item,
useModel: false,
modelReason: "未提供模特图,当前分镜不使用人物。",
}
);
const normalizedScreens = rawScreens.map((item) =>
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language);
const normalizedScreensWithoutBundles = rawScreens.map((item) =>
hasBundleImages
? item
: {
...item,
bundleShowcase: false,
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
}
);
const coreFocusedScreens = enforceCoreFocusOnScreens({
screens: normalizedScreens,
coreFocus,
const normalizedScreens = normalizeDetailModelUsage({
screens: normalizedScreensWithoutBundles,
hasModelImage,
language,
});
const coreFocusedScreens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: normalizedScreens,
coreFocus,
language,
}),
hasModelImage,
language,
});
const qualityChecksInitial = evaluatePlanQuality({
@ -2225,7 +2305,7 @@ export async function POST(request: Request) {
hasBundleImages,
});
const preflightScreensRaw = preflight.candidateScreens.map((item) =>
const preflightScreensWithoutBundles = preflight.candidateScreens.map((item) =>
hasBundleImages
? item
: {
@ -2234,9 +2314,18 @@ export async function POST(request: Request) {
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
}
);
const preflightScreens = enforceCoreFocusOnScreens({
screens: preflightScreensRaw,
coreFocus,
const preflightScreensRaw = normalizeDetailModelUsage({
screens: preflightScreensWithoutBundles,
hasModelImage,
language,
});
const preflightScreens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: preflightScreensRaw,
coreFocus,
language,
}),
hasModelImage,
language,
});
const qualityChecksAfterPreflight = evaluatePlanQuality({

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

@ -30,6 +30,21 @@ function resolveDownloadUrl(rawUrl: unknown, origin: string): string {
}
}
function resolveDownloadUrls(body: Record<string, unknown>, origin: string): string[] {
const rawCandidates = Array.isArray(body.urls) ? body.urls : [body.url];
const urls: string[] = [];
const seen = new Set<string>();
for (const rawUrl of rawCandidates) {
const url = resolveDownloadUrl(rawUrl, origin);
if (!url || seen.has(url)) continue;
seen.add(url);
urls.push(url);
}
return urls.slice(0, 6);
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
@ -39,23 +54,34 @@ export async function POST(request: NextRequest) {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request);
const url = resolveDownloadUrl(body.url, requestOrigin);
if (!url) {
const urls = resolveDownloadUrls(body, requestOrigin);
if (urls.length === 0) {
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
}
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
const result = await fetchCanvasDownloadAsset({
file: { url, name },
index: 0,
requestOrigin,
cookie: request.headers.get("cookie") || "",
includeSequencePrefix: false,
});
const cookie = request.headers.get("cookie") || "";
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
const failures: string[] = [];
if (!result.ok) {
for (const url of urls) {
result = await fetchCanvasDownloadAsset({
file: { url, name },
index: 0,
requestOrigin,
cookie,
includeSequencePrefix: false,
});
if (result.ok) break;
failures.push(`${url}: ${result.error}`);
}
if (!result?.ok) {
if (failures.length > 0) {
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
}
return NextResponse.json(
{ success: false, error: result.error || "图片下载失败" },
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
{ status: 502 }
);
}

View File

@ -16,7 +16,7 @@ import {
type ImageToolCreditFeature,
} from "@/lib/points/config";
export const maxDuration = 120;
export const maxDuration = 1200;
interface GenerateImageRequest extends CanvasGenerateImageRequest {
async?: boolean;

View File

@ -9,7 +9,25 @@ import {
getCanvasImageTaskForUser,
} from "@/lib/canvas/image-task-queue";
import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { hasScaleAdjustPositionMoved } from "@/lib/canvas/scale-adjust-geometry";
import {
buildScaleAdjustProductLockAssets,
type CanvasScaleAdjustIntentRegion,
} from "@/lib/canvas/scale-adjust-product-lock";
import {
buildSemanticScaleAdjustCleanupPrompt,
buildSemanticScaleAdjustCompositeRegions,
buildSemanticScaleAdjustExtractionPrompt,
} 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";
@ -30,6 +48,7 @@ type LocalEditRegionInput = {
color?: string;
prompt?: string;
rect?: LocalEditRect;
targetRect?: LocalEditRect;
referenceImages?: Array<{
id?: string;
url?: string;
@ -45,6 +64,8 @@ type LocalEditRequest = {
model?: string;
imageSize?: string;
aspectRatio?: string;
operation?: string;
scalePercent?: number;
};
type NormalizedLocalEditRegion = {
@ -65,17 +86,37 @@ type NormalizedLocalEditRegion = {
width: number;
height: number;
};
targetRect?: {
x: number;
y: number;
width: number;
height: number;
} | null;
pixelRect: {
left: number;
top: number;
width: number;
height: number;
};
targetPixelRect?: {
left: number;
top: number;
width: number;
height: number;
} | null;
};
const MAX_LOCAL_EDIT_REGIONS = 8;
const MAX_REFERENCE_IMAGES_PER_REGION = 3;
const MIN_REGION_EDGE_PX = 24;
const SCALE_ADJUST_OPERATION = "scale-adjust";
const SCALE_ADJUST_REPAIR_MODEL_REF = "e03d3341-cecd-425e-b88b-9350cbbce18f";
const SCALE_ADJUST_SEMANTIC_CLEANUP_MODEL_REF =
"5198c20f-b4cc-402c-bc73-eeabacd086b4";
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
const MIN_SCALE_ADJUST_PERCENT = -80;
const MAX_SCALE_ADJUST_PERCENT = 200;
type ScaleAdjustPercent = number;
const DEFAULT_REGION_COLORS = [
"#ff3b30",
"#1677ff",
@ -201,6 +242,35 @@ function normalizeRegionReferenceImages(
.slice(0, MAX_REFERENCE_IMAGES_PER_REGION);
}
function resolveLocalEditOperation(input: unknown) {
return String(input || "").trim() === SCALE_ADJUST_OPERATION
? SCALE_ADJUST_OPERATION
: "local-edit";
}
function normalizeScaleAdjustPercent(input: unknown): ScaleAdjustPercent {
const value = Math.round(Number(input));
if (
Number.isFinite(value) &&
value !== 0 &&
value >= MIN_SCALE_ADJUST_PERCENT &&
value <= MAX_SCALE_ADJUST_PERCENT
) {
return value;
}
throw new Error("请选择有效的比例调整幅度");
}
function buildLocalEditDisplayPrompt(params: {
operation: LocalEditOperation;
scalePercent: ScaleAdjustPercent | null;
}) {
if (params.operation === SCALE_ADJUST_OPERATION) {
return "比例调整:按目标框调整";
}
return "局部编辑处理";
}
function getColorName(hex: string) {
const normalized = hex.toLowerCase();
const names: Record<string, string> = {
@ -250,6 +320,45 @@ function rectToPixels(
return { left, top, width, height };
}
function scaleRectAroundCenter(
rect: NormalizedLocalEditRegion["rect"],
scalePercent: ScaleAdjustPercent
) {
const requestedScale = Math.max(0.1, 1 + scalePercent / 100);
const maxUniformScale = Math.max(
0.1,
Math.min(
rect.width > 0 ? 1 / rect.width : requestedScale,
rect.height > 0 ? 1 / rect.height : requestedScale
)
);
const scale = Math.min(requestedScale, maxUniformScale);
const targetWidth = Math.min(1, rect.width * scale);
const targetHeight = Math.min(1, rect.height * scale);
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
const x = clamp(centerX - targetWidth / 2, 0, Math.max(0, 1 - targetWidth));
const y = clamp(centerY - targetHeight / 2, 0, Math.max(0, 1 - targetHeight));
return {
x,
y,
width: targetWidth,
height: targetHeight,
};
}
function getScaleAdjustTargetRect(
region: NormalizedLocalEditRegion,
scalePercent?: ScaleAdjustPercent | null
) {
if (region.targetRect) return region.targetRect;
if (typeof scalePercent === "number" && Number.isFinite(scalePercent)) {
return scaleRectAroundCenter(region.rect, scalePercent);
}
return region.rect;
}
function wrapText(text: string, maxChars: number, maxLines = 3) {
const compact = text.replace(/\s+/g, " ").trim();
if (!compact) return [];
@ -272,7 +381,8 @@ function wrapText(text: string, maxChars: number, maxLines = 3) {
function normalizeRegions(
rawRegions: LocalEditRegionInput[] | undefined,
sourceWidth: number,
sourceHeight: number
sourceHeight: number,
options: { requirePrompt?: boolean; fallbackPrompt?: string } = {}
): NormalizedLocalEditRegion[] {
if (!Array.isArray(rawRegions) || rawRegions.length === 0) {
throw new Error("请先框选至少一个局部编辑区域");
@ -286,11 +396,16 @@ function normalizeRegions(
if (!rect) {
throw new Error(`区域 ${itemIndex + 1} 缺少有效框选范围`);
}
const targetRect = normalizeRect(region.targetRect);
const prompt = String(region.prompt || "").trim();
if (!prompt) {
const resolvedPrompt = prompt || String(options.fallbackPrompt || "").trim();
if (options.requirePrompt !== false && !resolvedPrompt) {
throw new Error(`区域 ${itemIndex + 1} 请填写局部编辑提示词`);
}
const pixelRect = rectToPixels(rect, sourceWidth, sourceHeight);
const targetPixelRect = targetRect
? rectToPixels(targetRect, sourceWidth, sourceHeight)
: null;
if (pixelRect.width < MIN_REGION_EDGE_PX || pixelRect.height < MIN_REGION_EDGE_PX) {
throw new Error(`区域 ${itemIndex + 1} 过小,请重新框选更大的范围`);
}
@ -303,10 +418,12 @@ function normalizeRegions(
index: itemIndex + 1,
color,
colorName: getColorName(color),
prompt: prompt.slice(0, 300),
prompt: resolvedPrompt.slice(0, 300),
referenceImages: normalizeRegionReferenceImages(region, itemIndex),
rect,
targetRect,
pixelRect,
targetPixelRect,
};
});
}
@ -315,6 +432,7 @@ function buildAnnotationSvg(params: {
width: number;
height: number;
regions: NormalizedLocalEditRegion[];
hidePromptText?: boolean;
}) {
const { width, height, regions } = params;
const minSide = Math.max(1, Math.min(width, height));
@ -329,7 +447,9 @@ function buildAnnotationSvg(params: {
const regionSvg = regions
.map((region) => {
const { left, top, width: rectWidth, height: rectHeight } = region.pixelRect;
const labelText = `区域${region.index} ${region.prompt}`;
const labelText = params.hidePromptText
? `区域${region.index} 目标主体`
: `区域${region.index} ${region.prompt}`;
const maxChars = Math.max(8, Math.min(24, Math.floor((width * 0.42) / Math.max(1, fontSize * 0.58))));
const lines = wrapText(labelText, maxChars, 3);
const labelWidth = Math.min(
@ -407,6 +527,8 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
regionLines,
referenceLines ? `\n区域专属参考图说明:\n${referenceLines}` : "",
"",
"新增主体完整性要求:如果区域提示词要求新增动物、人物、商品或其它完整主体,必须把该主体完整放在框选区域的安全内边距内,四周至少预留 8%-12% 缓冲空间;不要让头、身体、四肢、尾巴、产品边缘或阴影贴到选区边界,更不能被边界截断。空间不足时请主动缩小新增主体,而不是裁切主体。",
"边缘融合要求:编辑区域与未编辑区域的地面、墙面、桌面、椅脚、阴影、纹理和透视必须连续自然,不得出现错位、断层、硬边、矩形补丁、局部贴片或不同清晰度的边界。",
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
"未被列为编辑区域的画面必须逐像素保持原图不变,不允许重绘、改色、补光、换材质、移动物体、改变清晰度或改变背景细节。",
"新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。",
@ -440,9 +562,38 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: false, error: "无法识别原图尺寸" }, { status: 400 });
}
const operation = resolveLocalEditOperation(body.operation);
let scalePercent: ScaleAdjustPercent | null = null;
if (operation === SCALE_ADJUST_OPERATION) {
const hasRequestedScalePercent =
body.scalePercent !== undefined && body.scalePercent !== null;
if (hasRequestedScalePercent) {
try {
scalePercent = normalizeScaleAdjustPercent(body.scalePercent);
} catch (validationError) {
return NextResponse.json(
{
success: false,
error:
validationError instanceof Error
? validationError.message
: "比例调整参数无效",
},
{ status: 400 }
);
}
}
}
let regions: NormalizedLocalEditRegion[];
try {
regions = normalizeRegions(body.regions, sourceWidth, sourceHeight);
regions = normalizeRegions(body.regions, sourceWidth, sourceHeight, {
fallbackPrompt:
operation === SCALE_ADJUST_OPERATION
? "比例调整:按目标框调整"
: undefined,
requirePrompt: operation !== SCALE_ADJUST_OPERATION,
});
} catch (validationError) {
return NextResponse.json(
{
@ -455,27 +606,371 @@ export async function POST(request: NextRequest) {
{ status: 400 }
);
}
const hasScaleAdjustTargetRects =
operation === SCALE_ADJUST_OPERATION &&
regions.length > 0 &&
regions.every((region) => Boolean(region.targetRect));
if (
operation === SCALE_ADJUST_OPERATION &&
!hasScaleAdjustTargetRects &&
scalePercent === null
) {
return NextResponse.json(
{
success: false,
error: "请为每个区域提供有效的目标框 targetRect,或提供比例调整幅度 scalePercent",
},
{ status: 400 }
);
}
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"),
reason: "image_repaint",
modelId: "canvas-local-edit",
note: "局部编辑消耗积分",
reason: operation === SCALE_ADJUST_OPERATION ? "image_scale_adjust" : "image_repaint",
modelId:
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
note: operation === SCALE_ADJUST_OPERATION ? "比例调整消耗积分" : "局部编辑消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const annotationSvg = buildAnnotationSvg({
height: sourceHeight,
regions,
width: sourceWidth,
});
if (
operation === SCALE_ADJUST_OPERATION &&
(hasScaleAdjustTargetRects || scalePercent !== null)
) {
const displayPrompt = buildLocalEditDisplayPrompt({ operation, scalePercent });
const resolvedAspectRatio = resolveLocalEditAspectRatio(
body.aspectRatio,
sourceWidth,
sourceHeight
);
const requestedModel =
typeof body.model === "string" && body.model.trim()
? body.model.trim()
: undefined;
const requestedImageSize =
typeof body.imageSize === "string" && body.imageSize.trim()
? body.imageSize.trim()
: "2K";
const scaleAdjustIntentRegions: CanvasScaleAdjustIntentRegion[] = regions.map(
(region) => ({
index: region.index,
rect: region.rect,
targetRect: getScaleAdjustTargetRect(region, scalePercent),
})
);
const hasPositionMove = scaleAdjustIntentRegions.some((region) =>
hasScaleAdjustPositionMoved(region.rect, region.targetRect)
);
const productLockAssets = await buildScaleAdjustProductLockAssets({
protectPeople: hasPositionMove,
regions: scaleAdjustIntentRegions,
sourceBuffer,
sourceHeight,
sourceImageUrl,
sourceWidth,
userId: session.user.id,
});
const useSemanticScaleAdjust =
!hasPositionMove && productLockAssets.semanticFallbackSuggested;
const semanticCompositeRegions = useSemanticScaleAdjust
? buildSemanticScaleAdjustCompositeRegions(scaleAdjustIntentRegions)
: [];
const assetSuffix = `${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const [
modelMaskUpload,
overlayUpload,
repairMaskUpload,
semanticSourceCropUploads,
sourceSubjectMaskUpload,
] = await Promise.all([
uploadFile(
`canvas-scale-adjust-mask-${assetSuffix}.png`,
toArrayBuffer(productLockAssets.modelMaskBuffer)
),
uploadFile(
`canvas-scale-adjust-overlay-${assetSuffix}.png`,
toArrayBuffer(productLockAssets.overlayBuffer)
),
uploadFile(
`canvas-scale-adjust-repair-mask-${assetSuffix}.png`,
toArrayBuffer(productLockAssets.repairMaskBuffer)
),
useSemanticScaleAdjust
? Promise.all(
productLockAssets.semanticSourceCropBuffers.map(
(buffer, index) =>
uploadFile(
`canvas-scale-adjust-semantic-source-${assetSuffix}-${index + 1}.png`,
toArrayBuffer(buffer)
)
)
)
: Promise.resolve([]),
hasPositionMove || useSemanticScaleAdjust
? uploadFile(
`canvas-scale-adjust-subject-mask-${assetSuffix}.png`,
toArrayBuffer(productLockAssets.sourceSubjectMaskBuffer)
)
: Promise.resolve(null),
]);
const backgroundPlateUpload = productLockAssets.backgroundPlateBuffer
? await uploadFile(
`canvas-scale-adjust-background-${assetSuffix}.png`,
toArrayBuffer(productLockAssets.backgroundPlateBuffer)
)
: null;
const prompt = useSemanticScaleAdjust
? buildSemanticScaleAdjustExtractionPrompt()
: hasPositionMove
? "只编辑白色掩膜区域,完整移除旧位置的产品及其原有阴影,并根据周围墙面、地面、家具和光照连续自然地重建被遮挡背景。黑色区域保持不变,不要保留产品的任何部件或残影。"
: "移除掩膜中的完整产品并自然还原背景。";
const providerImageParams = useSemanticScaleAdjust
? {
quality: "high",
}
: hasPositionMove
? {
maskImageUrl: modelMaskUpload.url,
quality: "medium",
}
: {
objectRemovalMaskImageUrl: modelMaskUpload.url,
objectRemovalMode: "premium",
};
const productLockComposite = {
...(backgroundPlateUpload
? { backgroundPlateImageUrl: backgroundPlateUpload.url }
: {}),
overlayImageUrl: overlayUpload.url,
...(hasPositionMove
? {
repairMaskImageUrl: repairMaskUpload.url,
sourceImageUrl,
}
: {}),
...(hasPositionMove && sourceSubjectMaskUpload
? {
regions: scaleAdjustIntentRegions.map((region) => ({
source: region.rect,
target: region.targetRect,
})),
shadowSourceImageUrl: sourceImageUrl,
sourceSubjectMaskImageUrl: sourceSubjectMaskUpload.url,
}
: {}),
sourceHeight,
sourceWidth,
};
const scaleAdjustVerification = {
allowOutsideChangedWhenChangedInside: false,
deterministicProductOverlayApplied: true,
oldFootprintMaskImageUrl: useSemanticScaleAdjust
? sourceSubjectMaskUpload?.url
: hasPositionMove
? sourceSubjectMaskUpload?.url
: repairMaskUpload.url,
regions: scaleAdjustIntentRegions.map((region) => region.rect),
rejectFailedCandidates: true,
rejectNoEffectiveChangeOnly: false,
rejectOldFootprintOnly: true,
sourceImageUrl,
targetRegions: scaleAdjustIntentRegions.map((region) => region.targetRect),
};
const imageRequest: CanvasGenerateImageRequest = {
allowFailover: false,
aspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
billingFeature: "repaint",
debugLabel: "canvas-scale-adjust-product-lock",
debugMeta: {
displayPrompt,
operation,
requestedModel: requestedModel || null,
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
resolvedModel: configuredScaleAdjustModelId,
scaleAdjustProductLockMaskEditApplied: true,
scaleAdjustPersonExclusionApplied:
productLockAssets.personExclusionApplied,
scaleAdjustPromptStrategy: useSemanticScaleAdjust
? "gpt-extract-product-lock-v2"
: hasPositionMove
? "gpt-mask-background-rebuild-product-lock-v1"
: "finegrain-erase-product-lock-v1",
scaleAdjustProviderMaskImageUrl: useSemanticScaleAdjust
? null
: modelMaskUpload.url,
scaleAdjustSemanticFallbackRegionIndexes:
productLockAssets.semanticFallbackRegionIndexes,
scaleAdjustSemanticFallbackSuggested:
productLockAssets.semanticFallbackSuggested,
scaleAdjustSequentialCleanupRetry: !useSemanticScaleAdjust,
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
.length,
scalePercent,
sourceHeight,
sourceNodeId: body.sourceNodeId || null,
sourceWidth,
},
displayPrompt,
imageCount: 1,
imageSize: requestedImageSize,
model: configuredScaleAdjustModelId,
negativePrompt:
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
prompt,
promptExtend: false,
providerImageParams,
referenceImages: [sourceImageUrl],
...(useSemanticScaleAdjust
? {
localEditComposite: {
expandRegions: false,
featherMaxPx: 28,
featherMinPx: 12,
featherRatio: 0.012,
maskMode: "region" as const,
regions: semanticCompositeRegions,
sourceImageUrl,
},
}
: {}),
...(useSemanticScaleAdjust &&
semanticSourceCropUploads.length === scaleAdjustIntentRegions.length
? {
semanticScaleAdjustPreparation: {
cleanupImageSize: "2K",
cleanupModel: SCALE_ADJUST_SEMANTIC_CLEANUP_MODEL_REF,
cleanupPrompt: buildSemanticScaleAdjustCleanupPrompt(),
extractionPrompt: buildSemanticScaleAdjustExtractionPrompt(),
regions: scaleAdjustIntentRegions.map((region, index) => ({
source: region.rect,
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
target: region.targetRect,
})),
relightModel: configuredScaleAdjustModelId,
sourceImageUrl,
},
}
: {}),
retryVariants: useSemanticScaleAdjust ? [] : [
{
debugMeta: {
scaleAdjustRetryReason: "old-footprint-remnant-detected",
scaleAdjustRetryStrategy: "sequential-finegrain-cleanup",
},
label: "finegrain-cleanup-pass",
prompt,
providerImageParams,
referenceImages: [sourceImageUrl],
scaleAdjustProductLockComposite: productLockComposite,
scaleAdjustVerification: {
...scaleAdjustVerification,
rejectNoEffectiveChangeOnly: true,
rejectOldFootprintOnly: false,
},
},
],
...(useSemanticScaleAdjust
? {}
: { scaleAdjustProductLockComposite: productLockComposite }),
...(hasPositionMove
? {
scaleAdjustModelRelight: {
model: configuredScaleAdjustModelId,
overlayImageUrl: overlayUpload.url,
quality: "medium" as const,
regions: scaleAdjustIntentRegions.map((region) => ({
target: region.targetRect,
})),
sourceHeight,
sourceWidth,
},
}
: {}),
scaleAdjustVerification,
ownerKey: session.user.id,
};
const task = await createCanvasImageTask({
request: imageRequest as unknown as Record<string, unknown>,
userId: session.user.id,
});
await enqueueCanvasImageTask(task.id);
const currentTask = await getCanvasImageTaskForUser({
taskId: task.id,
userId: session.user.id,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `比例调整消耗积分(${task.id})`,
});
} catch (pointsError) {
console.error("[Points] Failed to consume scale adjust points:", pointsError);
}
}
return NextResponse.json({
success: true,
annotatedReferenceUrl: overlayUpload.url,
aspectRatio: resolvedAspectRatio,
displayPrompt,
prompt: displayPrompt,
regions,
sourceHeight,
sourceWidth,
taskId: task.id,
status: currentTask?.status || task.status,
queueStatus: currentTask?.queueStatus,
});
}
const displayPrompt = buildLocalEditDisplayPrompt({ operation, scalePercent });
const resolvedAspectRatio = resolveLocalEditAspectRatio(
body.aspectRatio,
sourceWidth,
sourceHeight
);
const requestedModel =
typeof body.model === "string" && body.model.trim()
? body.model.trim()
: undefined;
const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" })
.composite([{ input: annotationSvg, left: 0, top: 0 }])
.composite([
{
input: buildAnnotationSvg({
height: sourceHeight,
hidePromptText: false,
regions,
width: sourceWidth,
}),
left: 0,
top: 0,
},
])
.png({ compressionLevel: 6 })
.toBuffer();
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
@ -484,46 +979,42 @@ export async function POST(request: NextRequest) {
toArrayBuffer(annotatedBuffer)
);
const prompt = buildLocalEditPrompt(regions);
const regionReferenceImageUrls = regions.flatMap((region) =>
const manualRegionReferenceImageUrls = regions.flatMap((region) =>
region.referenceImages.map((reference) => reference.url)
);
const resolvedAspectRatio = resolveLocalEditAspectRatio(
body.aspectRatio,
sourceWidth,
sourceHeight
);
const referenceImages = [sourceImageUrl, ...manualRegionReferenceImageUrls];
const requestedImageSize =
typeof body.imageSize === "string" && body.imageSize.trim()
? body.imageSize.trim()
: "2K";
const imageRequest: CanvasGenerateImageRequest = {
aspectRatio: resolvedAspectRatio,
billingFeature: "repaint",
debugLabel: "canvas-local-edit",
debugMeta: {
annotatedReferenceUrl: annotatedUpload.url,
annotatedReferenceSentToModel: false,
annotatedReferenceType: "source-overlay",
displayPrompt,
operation,
requestedModel: requestedModel || null,
resolvedModel: requestedModel || configuredLocalEditModelId || null,
resolvedAspectRatio,
regionReferenceImageCount: regionReferenceImageUrls.length,
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
regionReferenceImageCount: referenceImages.length,
regionCount: regions.length,
sourceHeight,
sourceNodeId: body.sourceNodeId || null,
sourceWidth,
},
displayPrompt,
imageCount: 1,
imageSize:
typeof body.imageSize === "string" && body.imageSize.trim()
? body.imageSize.trim()
: "2K",
model:
typeof body.model === "string" && body.model.trim()
? body.model.trim()
: undefined,
imageSize: requestedImageSize,
model: requestedModel || configuredLocalEditModelId || undefined,
negativePrompt:
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
prompt,
promptExtend: false,
referenceImages: [
sourceImageUrl,
...regionReferenceImageUrls,
],
referenceImages,
localEditComposite: {
sourceImageUrl,
regions: regions.map((region) => region.rect),
@ -544,7 +1035,10 @@ export async function POST(request: NextRequest) {
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `局部编辑消耗积分(${task.id})`,
note:
operation === SCALE_ADJUST_OPERATION
? `比例调整消耗积分(${task.id})`
: `局部编辑消耗积分(${task.id})`,
});
} catch (pointsError) {
console.error("[Points] Failed to consume local edit points:", pointsError);
@ -555,7 +1049,8 @@ export async function POST(request: NextRequest) {
success: true,
annotatedReferenceUrl: annotatedUpload.url,
aspectRatio: resolvedAspectRatio,
prompt,
displayPrompt,
prompt: displayPrompt,
regions,
sourceHeight,
sourceWidth,

View File

@ -7,19 +7,26 @@ import { createHuankeGemini } from "@/lib/ai/providers/huanke-gemini";
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
import { createBflImage } from "@/lib/ai/providers/bfl-image";
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
import { createFalImage } from "@/lib/ai/providers/fal-image";
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
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";
import { createKrea } from "@/lib/ai/providers/krea";
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 120;
export const maxDuration = 1200;
const logger = createLogger("canvas-text-edit");
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000;
@ -29,6 +36,7 @@ const textItemSchema = z.object({
id: z.string().min(1),
originalText: z.string().min(1),
editedText: z.string().optional(),
textColor: z.string().optional(),
action: z.enum(["replace", "delete"]).optional(),
});
@ -45,7 +53,12 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
"bfl-image",
"ruxa-image",
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"midjourney",
"runway",
@ -106,8 +119,18 @@ function createTextEditProvider(
return createRuxaImage({ baseURL, apiKey });
case "fal-image":
return createFalImage({ baseURL, apiKey });
case "snapgen-image":
return createSnapgenImage({ baseURL, apiKey });
case "openai-image":
return createOpenAIImage({ baseURL, apiKey });
case "bananaimage":
return createBananaImage({ baseURL, apiKey });
case "visionary":
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":
@ -370,22 +393,53 @@ function parseImageFromGenerationText(
}
function buildTextEditPrompt(
replacements: Array<{ originalText: string; editedText: string }>,
replacements: Array<{
originalText: string;
editedText: string;
textColor?: string;
}>,
deletions: Array<{ originalText: string }>,
) {
const sections: string[] = [
"请基于输入图片执行精确文字替换。",
"硬性要求:",
"1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。",
"2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。",
"1. 仅编辑下方列出的目标文字区域,不要修改其他主体、产品、背景、材质、配色、光影、图标、装饰元素与构图。",
"2. 必须保持原图排版、字重、笔画风格、字体形状、字号关系与文字所在位置尽可能一致,仅替换指定文字内容或指定文字颜色。",
"3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。",
"4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。",
"5. 必须输出一张新生成的图片,不要直接返回输入原图或源图链接;如果无法完成替换,请明确失败,不要用原图冒充结果。",
"5. 如果指定了文字颜色,只允许修改该段文字本身的颜色,绝对禁止把产品、包装、背景、阴影、高光、边框或其他非文字区域一起染色。",
"6. 如未指定文字颜色,则尽量保持原图该文字本来的颜色与质感。",
"7. 删除文字时请仅移除对应文字并自然修复背景,不要改动周围产品外观。",
"8. 必须输出一张新生成的图片,不要直接返回输入原图或源图链接;如果无法完成替换,请明确失败,不要用原图冒充结果。",
];
if (replacements.length > 0) {
const replacementLines = replacements
.map((item, index) => `${index + 1}. 原文: ${item.originalText}\n 替换为: ${item.editedText}`)
.map((item, index) => {
if (item.textColor && item.originalText === item.editedText) {
return [
`${index + 1}. 原文: ${item.originalText}`,
" 文字内容保持不变",
` 仅将这段文字颜色改为: ${item.textColor.toUpperCase()}`,
" 注意: 只能改文字颜色,不要让产品、背景或其他元素变色。",
].join("\n");
}
if (item.textColor) {
return [
`${index + 1}. 原文: ${item.originalText}`,
` 替换为: ${item.editedText}`,
` 新文字颜色: ${item.textColor.toUpperCase()}`,
" 注意: 只对这段文字应用该颜色,其它区域颜色保持原样。",
].join("\n");
}
return [
`${index + 1}. 原文: ${item.originalText}`,
` 替换为: ${item.editedText}`,
" 文字颜色要求: 保持原图该文字原有颜色不变",
].join("\n");
})
.join("\n");
sections.push("替换清单:", replacementLines);
}
@ -397,7 +451,7 @@ function buildTextEditPrompt(
sections.push(
"删除清单:",
deletionLines,
"删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹。",
"删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹,也不要影响产品主体颜色。",
);
}
@ -426,7 +480,10 @@ async function generateTextEditImageWithFailover(params: {
});
const provider = createTextEditProvider(selected.provider);
const result = await provider.chat(selected.model.modelId).doGenerate({
const runtimeModelId = resolveModelRuntimeId(selected.model, {
providerType: selected.provider.type,
});
const generationOptions: any = {
messages: [
{
role: "user",
@ -439,7 +496,18 @@ async function generateTextEditImageWithFailover(params: {
imageParams: {
imageCount: 1,
},
});
};
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
generationOptions.imageGenConfig = selected.model.imageGenConfig || null;
}
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();
@ -493,6 +561,7 @@ export async function POST(request: Request) {
id: item.id,
originalText: item.originalText.trim(),
editedText: (item.editedText || "").trim(),
textColor: normalizeOptionalHexColor(item.textColor),
action: item.action,
}));
@ -500,14 +569,15 @@ export async function POST(request: Request) {
.filter(
(item) =>
item.originalText &&
item.editedText &&
item.action !== "delete" &&
item.originalText !== item.editedText,
item.editedText &&
(item.originalText !== item.editedText || Boolean(item.textColor)),
)
.map((item) => ({
id: item.id,
originalText: item.originalText,
editedText: item.editedText,
textColor: item.textColor,
}));
const deletions = normalizedItems

View File

@ -25,7 +25,7 @@ const VIDEO_ANALYSIS_MAX_SIDE = 960;
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
const requestSchema = z.object({
mode: z.enum(["video-analysis", "character-scene-extraction", "agent-plan"]),
mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]),
prompt: z.string().trim().optional(),
imageUrls: z.array(z.string().trim()).optional(),
videoUrls: z.array(z.string().trim()).optional(),
@ -70,6 +70,14 @@ function normalizeText(value: unknown) {
return String(value || "").trim();
}
function shouldUseLocalWorkflowE2E(request: Request) {
const hostname = new URL(request.url).hostname;
return (
process.env.CANVAS_E2E_LOCAL === "true" &&
(hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1")
);
}
function normalizeStringList(value: unknown, limit = 8) {
if (!Array.isArray(value)) return [];
return Array.from(
@ -118,6 +126,22 @@ function normalizeVideoAnalysisRows(
const sceneDescription = normalizeText(
raw.sceneDescription || raw.scene || raw.title || raw.description,
);
const narrative = normalizeText(
raw.narrative || raw.story || raw.storyContent || raw.narration || raw.summary,
);
const shot = normalizeText(raw.shot || raw.shotNumber || raw.index || index + 1);
const startTime = normalizeText(raw.startTime || raw.start || raw.start_time);
const endTime = normalizeText(raw.endTime || raw.end || raw.end_time);
const duration = normalizeText(raw.duration || raw.durationSeconds || raw.duration_seconds);
const shotSize = normalizeText(raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale);
const cameraAngle = normalizeText(raw.cameraAngle || raw.angle || raw.camera_angle);
const cameraMovement = normalizeText(
raw.cameraMovement || raw.cameraMove || raw.camera_motion || raw.motionType,
);
const focusDepth = normalizeText(
raw.focusDepth || raw.depthOfField || raw.focalDepth || raw.focus_depth,
);
const lighting = normalizeText(raw.lighting || raw.light || raw.lightDesign);
const backgroundMusic = normalizeText(
raw.backgroundMusic || raw.music || raw.bgm,
);
@ -135,10 +159,16 @@ function normalizeVideoAnalysisRows(
);
if (
!sceneDescription &&
!narrative &&
!backgroundMusic &&
!voiceAndSfx &&
!imagePrompt &&
!motionPrompt
!motionPrompt &&
!shotSize &&
!cameraAngle &&
!cameraMovement &&
!focusDepth &&
!lighting
) {
return null;
}
@ -147,7 +177,17 @@ function normalizeVideoAnalysisRows(
? Math.max(0, Math.min(framePreviews.length - 1, Math.round(keyframeIndex) - 1))
: index % Math.max(framePreviews.length, 1);
return {
shot: shot || String(index + 1),
startTime: startTime || (index === 0 ? "0" : ""),
endTime,
duration,
sceneDescription: sceneDescription || `镜头 ${index + 1}`,
narrative: narrative || sceneDescription || `镜头 ${index + 1}`,
shotSize: shotSize || "中景至近景",
cameraAngle: cameraAngle || "平视",
cameraMovement: cameraMovement || "缓慢推镜头 (Slow Zoom In)",
focusDepth: focusDepth || "浅景深",
lighting: lighting || "冷调低沉照明,主体光线柔和。",
backgroundMusic: backgroundMusic || "无",
voiceAndSfx: voiceAndSfx || "无",
imagePrompt: imagePrompt || sceneDescription || "延续当前视频气质生成关键帧",
@ -159,7 +199,17 @@ function normalizeVideoAnalysisRows(
(
item,
): item is {
shot: string;
startTime: string;
endTime: string;
duration: string;
sceneDescription: string;
narrative: string;
shotSize: string;
cameraAngle: string;
cameraMovement: string;
focusDepth: string;
lighting: string;
backgroundMusic: string;
voiceAndSfx: string;
imagePrompt: string;
@ -170,6 +220,226 @@ function normalizeVideoAnalysisRows(
.slice(0, 8);
}
function normalizeStoryShot(value: unknown, index: number) {
const text = normalizeText(value);
if (/^\d+$/.test(text)) return text.padStart(2, "0");
return text || String(index + 1).padStart(2, "0");
}
type NormalizedStoryScriptRow = {
shot: string;
duration: string;
scene: string;
sceneDescription: string;
shotSize: string;
lighting: string;
dialogue: string;
backgroundMusic: string;
voiceAndSfx: string;
cameraMovement: string;
imagePrompt: string;
motionPrompt: string;
keyframeUrl: string;
};
function normalizeStoryScriptRows(value: unknown): NormalizedStoryScriptRow[] {
if (!Array.isArray(value)) return [];
return value
.map((item, index) => {
if (Array.isArray(item)) {
const cells = item.map((cell) => normalizeText(cell));
const shot = normalizeStoryShot(cells[0], index);
const secondCellLooksLikeDuration = /^\d+(\.\d+)?\s*(s|秒)?$/i.test(cells[1] || "");
const duration = secondCellLooksLikeDuration ? cells[1] : "5s";
const scene = secondCellLooksLikeDuration ? cells[2] || `镜头 ${shot}` : cells[1] || `镜头 ${shot}`;
const sceneDescription = secondCellLooksLikeDuration ? cells[2] || scene : cells[2] || cells[3] || scene;
return {
shot,
duration,
scene,
sceneDescription,
shotSize: (secondCellLooksLikeDuration ? cells[3] : "") || "中景",
lighting: (secondCellLooksLikeDuration ? cells[4] : cells[4]) || "电影感光影",
dialogue: (secondCellLooksLikeDuration ? cells[5] : "") || "+",
backgroundMusic: "无",
voiceAndSfx: (secondCellLooksLikeDuration ? cells[6] : cells[3]) || "无",
cameraMovement: (secondCellLooksLikeDuration ? cells[7] : "") || "缓慢推镜头",
imagePrompt: (secondCellLooksLikeDuration ? cells[8] : cells[5]) || sceneDescription,
motionPrompt:
(secondCellLooksLikeDuration ? cells[9] || cells[7] : cells[6]) ||
"保持镜头运动自然、连贯,围绕主体动作推进。",
keyframeUrl: "",
};
}
const raw =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const shot = normalizeStoryShot(raw.shot || raw.shotNumber || raw.index || raw.id, index);
const duration = normalizeText(raw.duration || raw.length || raw.time || raw.seconds);
const scene = normalizeText(raw.scene || raw.location || raw.title || `镜头 ${shot}`);
const sceneDescription = normalizeText(
raw.sceneDescription ||
raw.description ||
raw.visual ||
raw.picture ||
raw.screen ||
raw.summary,
);
const shotSize = normalizeText(
raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale || raw.cameraShot,
);
const lighting = normalizeText(
raw.lighting || raw.light || raw.atmosphere || raw.mood || raw.lightMood,
);
const dialogue = normalizeText(
raw.dialogue || raw.dialog || raw.narration || raw.voiceover || raw.monologue || raw.line,
);
const cameraMovement = normalizeText(
raw.cameraMovement || raw.cameraMove || raw.movement || raw.lensMovement || raw.motion,
);
const imagePrompt = normalizeText(
raw.imagePrompt ||
raw.image_generation_prompt ||
raw.image ||
raw.keyframePrompt ||
raw.keyFramePrompt,
);
const motionPrompt = normalizeText(
raw.motionPrompt ||
raw.videoPrompt ||
raw.video_motion_prompt ||
raw.motion ||
raw.cameraMovement,
);
const backgroundMusic = normalizeText(
raw.backgroundMusic || raw.music || raw.bgm || raw.soundtrack,
);
const voiceAndSfx = normalizeText(
raw.voiceAndSfx || raw.voice || raw.sound || raw.audio || raw.sfx,
);
if (
!scene &&
!sceneDescription &&
!imagePrompt &&
!motionPrompt &&
!backgroundMusic &&
!voiceAndSfx
) {
return null;
}
return {
shot,
duration: duration || "5s",
scene,
sceneDescription: sceneDescription || imagePrompt || scene || `镜头 ${shot}`,
shotSize: shotSize || "中景",
lighting: lighting || "电影感光影",
dialogue: dialogue || "+",
backgroundMusic: backgroundMusic || "无",
voiceAndSfx: voiceAndSfx || "无",
cameraMovement: cameraMovement || "缓慢推镜头",
imagePrompt: imagePrompt || sceneDescription || scene || `镜头 ${shot}关键帧`,
motionPrompt: motionPrompt || cameraMovement || "保持镜头运动自然、连贯,围绕主体动作推进。",
keyframeUrl: "",
};
})
.filter(
(
item,
): item is NormalizedStoryScriptRow => Boolean(item),
)
.slice(0, 18);
}
const storyExpansionPhases = [
{
description: "建立镜头,交代环境、人物位置和当前压力",
cameraMovement: "缓慢推镜头",
imagePrompt: "广角或中景建立画面,环境信息清晰,主体位置明确",
lighting: "环境主光明确,氛围基调清晰",
motionPrompt: "镜头缓慢推进或轻微横移,建立空间关系",
shotSize: "中景",
},
{
description: "动作推进,突出人物的关键行为或剧情转折",
cameraMovement: "跟随主体动作推进",
imagePrompt: "中景表现人物动作,服装、道具和光影保持连续",
lighting: "主体光影连续,动作区域清晰",
motionPrompt: "跟随主体动作推进,节奏自然连贯",
shotSize: "中景",
},
{
description: "情绪反应,用近景或特写强化人物心理和冲突",
cameraMovement: "轻微推近或稳定特写",
imagePrompt: "近景或特写,突出表情、眼神、手部或关键道具细节",
lighting: "面部光线突出,情绪反差增强",
motionPrompt: "轻微推近或稳定特写,强调情绪变化",
shotSize: "特写",
},
{
description: "关系与氛围,表现周围人物、环境反馈或声画张力",
cameraMovement: "横移扫过环境或切换视角",
imagePrompt: "加入环境反馈、群像反应或空间层次,电影感光影",
lighting: "场景氛围增强,空间层次明显",
motionPrompt: "镜头扫过环境或切换视角,保持节奏递进",
shotSize: "全景",
},
{
description: "转场收束,为下一镜头保留动作、视线或情绪钩子",
cameraMovement: "顺势后移、上摇或转场",
imagePrompt: "构图留出转场方向,动作或视线指向下一段剧情",
lighting: "光影引导视线并保留转场方向",
motionPrompt: "镜头后移、上摇或顺势转场,衔接下一镜头",
shotSize: "大远景",
},
];
function expandStoryScriptRowsToTarget(
rows: NormalizedStoryScriptRow[],
targetCount = 18,
): NormalizedStoryScriptRow[] {
if (rows.length === 0) return [];
if (rows.length >= targetCount) {
return rows.slice(0, targetCount).map((row, index) => ({
...row,
shot: String(index + 1).padStart(2, "0"),
}));
}
const baseCount = Math.floor(targetCount / rows.length);
const extraCount = targetCount % rows.length;
const expanded: NormalizedStoryScriptRow[] = [];
rows.forEach((row, rowIndex) => {
const segmentCount = baseCount + (rowIndex < extraCount ? 1 : 0);
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
const phase =
storyExpansionPhases[
Math.min(
storyExpansionPhases.length - 1,
Math.floor((segmentIndex / Math.max(segmentCount, 1)) * storyExpansionPhases.length),
)
];
expanded.push({
...row,
shot: String(expanded.length + 1).padStart(2, "0"),
sceneDescription: `${phase.description}:${row.sceneDescription}`,
shotSize: row.shotSize || phase.shotSize,
lighting: row.lighting || phase.lighting,
cameraMovement: row.cameraMovement || phase.cameraMovement,
imagePrompt: `${row.imagePrompt};${phase.imagePrompt}`,
motionPrompt: `${phase.motionPrompt};${row.motionPrompt}`,
});
}
});
return expanded.slice(0, targetCount);
}
function normalizeCharacters(value: unknown) {
if (!Array.isArray(value)) return [];
return value
@ -243,11 +513,21 @@ function buildVideoAnalysisPrompt(params: {
"highlights": ["3-5条镜头分析要点"],
"analysisRows": [
{
"sceneDescription": "镜头内容与景别描述",
"shot": "1",
"startTime": "0",
"endTime": "5",
"duration": "5",
"sceneDescription": "画面描述,说明主体、环境、动作、构图和色调",
"narrative": "叙事内容,说明这一镜头承担的剧情/情绪/信息作用",
"shotSize": "景别,例如中景至近景",
"cameraAngle": "摄影机角度,例如平视",
"cameraMovement": "摄影机运动,例如缓慢推镜头 (Slow Zoom In)",
"focusDepth": "焦距与景深,例如浅景深",
"lighting": "光线,例如冷调低沉照明,屏幕光作为侧逆光",
"backgroundMusic": "这一段适合的背景音乐氛围",
"voiceAndSfx": "人声/环境声/音效建议,没有就写无",
"imagePrompt": "后续生成关键帧时可直接使用的图像提示词",
"motionPrompt": "后续生成视频时可直接使用的运动提示词",
"imagePrompt": "后续生成关键帧时可直接使用的英文图像提示词",
"motionPrompt": "后续生成视频时可直接使用的英文运动提示词",
"keyframeIndex": 1
}
],
@ -257,7 +537,9 @@ function buildVideoAnalysisPrompt(params: {
"visualMotifs": ["2-4个可复用的视觉母题"],
"recommendedNextSteps": ["2-4条建议的下游动作"]
}`,
"analysisRows 请尽量输出 2-6 行;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
"analysisRows 请按镜头时间顺序输出;短视频可以输出 1 行,复杂视频输出 2-6 行。",
"每行必须覆盖镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词和关键帧。",
"imagePrompt 和 motionPrompt 优先使用英文;其他字段使用中文;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
]
.filter(Boolean)
@ -437,13 +719,63 @@ function buildAgentPlanPrompt(params: {
.join("\n");
}
function buildStoryScriptPrompt(params: {
nodeTitle?: string;
prompt: string;
imageCount: number;
videoCount: number;
}) {
return [
"你是视频画布里的故事脚本生成节点,需要把用户输入、参考图和参考视频整理成可直接用于后续分镜/生图/生视频的结构化脚本表。",
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
`参考图数量:${params.imageCount}`,
`视频数量:${params.videoCount}`,
params.prompt ? `输入内容:\n${params.prompt}` : "没有额外脚本,请生成一个节奏清晰的短视频脚本。",
"",
"请只输出 JSON,不要输出 markdown,不要输出解释。",
"JSON 结构如下:",
`{
"summary": "一句话概括故事主题、情绪和视频方向,不超过120字",
"analysisRows": [
{
"shot": "01",
"duration": "5s",
"scene": "场景名或地点,如 盛唐·金銮殿·日",
"sceneDescription": "画面内容、人物动作和剧情推进,不要重复 scene 字段",
"shotSize": "景别,如中景、特写、全景、大远景",
"lighting": "光影氛围,如暖金主光带阴影,气氛紧张压抑",
"dialogue": "对白或旁白,没有就写 +",
"backgroundMusic": "背景音乐或情绪氛围,没有就写无",
"voiceAndSfx": "环境声、动作声或音效,没有就写无",
"cameraMovement": "运镜,如缓慢推镜头、固定、横移、上摇、环绕轨道跟拍",
"imagePrompt": "后续生成该镜头关键帧时可直接使用的中文图像提示词",
"motionPrompt": "后续生成视频时可直接使用的中文视频运动提示词"
}
],
"assetCandidates": {
"style": "全局视觉风格,如 盛唐·国风二次元",
"characters": ["需要生成的角色资产名"],
"scenes": ["需要生成的场景资产名"],
"props": ["需要生成的道具资产名"]
},
"shotIdeas": [
{ "title": "镜头 1", "description": "一句话描述镜头目标、景别或动作" }
],
"recommendedNextSteps": ["2-4条建议的下游动作"]
}`,
"analysisRows 必须直接输出 18 行,按镜头时间顺序编号 01-18;每行默认 5s 或 6s,剧情高潮可用 6s。",
"每行要覆盖竞品脚本表同等信息:镜号、时长、画面描述、景别、光影氛围、对白·旁白、音效、运镜、最终图像提示词和视频运动提示词。",
"画面描述中用 @角色名 或 @场景/道具名 标出可复用资产,例如 @沈昭昭、@金銮殿、@侍卫长刀。",
"imagePrompt 要像可直接生成分镜图的中文提示词,包含主体、环境、构图、光影、风格与一致性要求;motionPrompt 要按起始状态、动作过程、结束状态、音效组织。",
"assetCandidates 根据 18 镜头抽取 3-6 个角色、2-5 个场景和 1-4 个道具,方便后续准备资产。",
"每行要像专业视频画布脚本表:场景具体、画面可生成、声音可执行、镜头节奏明确,不要只写剧情摘要。",
]
.filter(Boolean)
.join("\n");
}
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 parsed = requestSchema.safeParse(body);
if (!parsed.success) {
@ -468,6 +800,11 @@ export async function POST(request: Request) {
);
}
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const [allModels, agentModel] = await Promise.all([
getEnabledAiModelsWithProvider(false),
getAgentAiModelWithProvider(),
@ -534,7 +871,14 @@ export async function POST(request: Request) {
prompt,
imageCount: imageUrls.length,
})
: buildAgentPlanPrompt({
: parsed.data.mode === "story-script"
? buildStoryScriptPrompt({
nodeTitle: normalizeText(parsed.data.nodeTitle),
prompt,
imageCount: imageUrls.length,
videoCount: videoUrls.length,
})
: buildAgentPlanPrompt({
nodeTitle: normalizeText(parsed.data.nodeTitle),
prompt,
imageCount: imageUrls.length,
@ -568,7 +912,7 @@ export async function POST(request: Request) {
},
],
temperature: 0.2,
maxTokens: 1600,
maxTokens: parsed.data.mode === "story-script" ? 4200 : parsed.data.mode === "video-analysis" ? 2200 : 1600,
} as any);
const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {};
@ -619,6 +963,29 @@ export async function POST(request: Request) {
});
}
if (parsed.data.mode === "story-script") {
const analysisRows = expandStoryScriptRowsToTarget(normalizeStoryScriptRows(
parsedJson.analysisRows || parsedJson.storyRows || parsedJson.rows || parsedJson.shots,
));
return NextResponse.json({
success: true,
data: {
summary,
analysisRows,
shotIdeas:
normalizeShotIdeas(parsedJson.shotIdeas).length > 0
? normalizeShotIdeas(parsedJson.shotIdeas)
: analysisRows.map((row, index) => ({
title: `镜头 ${index + 1}`,
description: row.sceneDescription,
})),
recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6),
model: selectedModel.model.displayName,
raw: text,
},
});
}
return NextResponse.json({
success: true,
data: {

View File

@ -7,18 +7,34 @@ 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,
} from "@/lib/worldlabs/types";
export const runtime = "nodejs";
export const maxDuration = 120;
export const maxDuration = 180;
const logger = createLogger("world-labs-task-status");
const WORLDLABS_TASK_STATUS_TIMEOUT_MS =
Number.parseInt(String(process.env.WORLDLABS_TASK_STATUS_TIMEOUT_MS || "").trim(), 10) ||
180_000;
function resolveWorldLabsStatusTimeoutMs() {
if (!Number.isFinite(WORLDLABS_TASK_STATUS_TIMEOUT_MS)) return 180_000;
if (WORLDLABS_TASK_STATUS_TIMEOUT_MS <= 0) return 180_000;
return WORLDLABS_TASK_STATUS_TIMEOUT_MS;
}
type ArchiveCacheItem = {
sourceUrl: string;
@ -410,6 +426,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 }>;
}
@ -443,9 +667,191 @@ export async function GET(request: Request, { params }: RouteParams) {
});
providerForLog = config.providerId || null;
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
const persistedAssetPayload =
getObjectValue(persistedWorldAsset?.assetPayload) || {};
const shouldUseExternalTaskResultApi =
isWorldLabsExternalTaskApi(config.baseUrl) &&
persistedAssetPayload.externalTaskApi !== false &&
persistedAssetPayload.marbleApi !== true;
if (shouldUseExternalTaskResultApi) {
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 assetPayload = {
...externalAssetPayloadBase,
worldId: extractedAssetPayload.worldId,
worldUrl: stableWorldUrl,
displayName: extractedAssetPayload.displayName,
model: resolveThreeDModelAliasId(
extractedAssetPayload.model,
modelAliasConfig.worldModels
),
caption: extractedAssetPayload.caption,
thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
storageArchivedAt: new Date().toISOString(),
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
? rawMeshUrl
: null,
sourceThumbnailUrl:
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
? rawThumbnailUrl
: null,
sourcePanoUrl:
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
? rawPanoUrl
: null,
sourceSpzUrls:
extractedAssetPayload.spzUrls && stableSpzUrls !== extractedAssetPayload.spzUrls
? extractedAssetPayload.spzUrls
: null,
};
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "success",
progress: 100,
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
error: null,
assetPayload,
});
logger.info("external task success status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
hasMesh: Boolean(stableModelUrl),
hasPano: Boolean(stablePanoUrl),
hasThumbnail: Boolean(stableThumbnailUrl),
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "success",
progress: 100,
output: externalData,
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
worldId: assetPayload.worldId,
worldUrl: stableWorldUrl,
panoUrl: stablePanoUrl,
caption: assetPayload.caption,
assetPayload,
},
});
}
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
method: "GET",
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
});
if (!operation.done) {
@ -505,6 +911,7 @@ export async function GET(request: Request, { params }: RouteParams) {
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
method: "GET",
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
});
world = world
? {

View File

@ -12,18 +12,37 @@ 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 {
disableWorldLabsProvider,
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 = 60;
export const maxDuration = 180;
const logger = createLogger("world-labs-task-create");
const WORLDLABS_TASK_CREATE_TIMEOUT_MS =
Number.parseInt(String(process.env.WORLDLABS_TASK_CREATE_TIMEOUT_MS || "").trim(), 10) ||
180_000;
function resolveWorldLabsCreateTimeoutMs() {
if (!Number.isFinite(WORLDLABS_TASK_CREATE_TIMEOUT_MS)) return 180_000;
if (WORLDLABS_TASK_CREATE_TIMEOUT_MS <= 0) return 180_000;
return WORLDLABS_TASK_CREATE_TIMEOUT_MS;
}
type WorldTaskMode = "text" | "image" | "multi-image" | "video";
@ -38,8 +57,10 @@ type CreateWorldTaskRequest = {
makePublic?: boolean;
reconstructImages?: boolean;
mediaAssetId?: string;
sourceUrl?: string;
mediaAssets?: Array<{
mediaAssetId?: string;
sourceUrl?: string;
azimuth?: number | null;
}>;
};
@ -49,10 +70,89 @@ 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 === "text";
}
function resolveOfficialMarbleModel(model?: string | null) {
const normalized = readOptionalTrimmedText(model);
if (!normalized || normalized === WORLDLABS_EXTERNAL_TASK_MODEL) {
return "marble-1.1";
}
return normalized;
}
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
const textPrompt = readOptionalTrimmedText(body.prompt);
@ -71,15 +171,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 }
@ -93,7 +199,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
@ -102,10 +209,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);
@ -128,21 +240,27 @@ 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 } : {}),
};
}
function isPermanentProviderError(error: unknown) {
function isCredentialOrBillingProviderError(error: unknown) {
return (
error instanceof WorldLabsApiError &&
[401, 402, 403].includes(error.httpStatus)
@ -178,12 +296,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 &&
@ -211,47 +346,57 @@ 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",
json: {
display_name: displayName,
model: resolvedWorldModel || "marble-1.1",
permission: {
public: body.makePublic === true,
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
if (useExternalTaskApi) {
const prompt = readOptionalTrimmedText(body.prompt);
if (!prompt) {
throw new Error("请输入世界描述");
}
const externalResult = await worldLabsRequest<
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
>(candidate, {
method: "POST",
path: "/api/external/task/generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: {
model: WORLDLABS_EXTERNAL_TASK_MODEL,
displayName,
prompt,
},
world_prompt: worldPrompt,
},
});
});
result = normalizeExternalTaskCreateOperation(externalResult);
} else {
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
method: "POST",
path: "/marble/v1/worlds:generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: {
display_name: displayName,
model: officialMarbleModel,
permission: {
public: body.makePublic === true,
},
world_prompt: worldPrompt,
},
});
}
usedProvider = candidate;
usedExternalTaskApi = useExternalTaskApi;
providerForLog = candidate.providerId || null;
break;
} catch (error) {
lastError = error;
if (isPermanentProviderError(error)) {
await disableWorldLabsProvider(
if (isCredentialOrBillingProviderError(error)) {
await reportWorldLabsProviderRuntimeFailure(
candidate.providerId,
error instanceof Error ? error.message : String(error)
);
@ -282,12 +427,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, {
@ -303,10 +454,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({
@ -317,6 +471,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,11 +1,15 @@
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 {
disableWorldLabsProvider,
getWorldLabsProviderConfig,
getWorldLabsProviderConfigs,
reportWorldLabsProviderRuntimeFailure,
} from "@/lib/worldlabs/server";
import type { WorldLabsPrepareUploadResponse } from "@/lib/worldlabs/types";
@ -112,7 +116,82 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
}
function isPermanentProviderError(error: unknown) {
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 &&
[401, 402, 403].includes(error.httpStatus)
@ -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,8 +331,33 @@ export async function POST(request: Request) {
});
} catch (error) {
lastError = error;
if (isPermanentProviderError(error)) {
await disableWorldLabsProvider(config.providerId, (error as Error).message);
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,
error instanceof Error ? error.message : String(error)
);
}
if (requestedProviderId) {
break;

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",

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import {
createMediaAssetRelation,
getAiProvidersByType,
getMediaAssetsByUserAndUrls,
getSystemSetting,
upsertMediaAsset,
@ -9,9 +10,12 @@ import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { createFalClient } from "@fal-ai/client";
// 变清晰API配置类型
type EnhanceApiConfig = {
provider?: "vip98k" | "aoscdn";
apiUrl?: string;
apiKey?: string;
type?: string; // "clean" | "face"
@ -317,6 +321,155 @@ async function processWithRealESRGANBase64(
};
}
async function processWithFalEsrgan(
imageBase64: string,
requestedScaleFactor: number,
): Promise<{ imageBase64: string; processingTime: number }> {
const providers = await getAiProvidersByType({
type: "fal-image",
enabledOnly: true,
});
const provider = providers.find((item) => item.apiKey?.trim());
if (!provider?.apiKey) {
throw new Error("未配置可用的 FAL 专用高清服务");
}
const scale = Math.max(2, Math.min(8, requestedScaleFactor || 4));
const model = scale <= 2 ? "RealESRGAN_x2plus" : "RealESRGAN_x4plus";
const credentials = provider.apiKey
.trim()
.replace(/^(?:Key|Bearer)\s+/i, "");
const fal = createFalClient({ credentials });
const startedAt = Date.now();
const result: any = await fal.subscribe("fal-ai/esrgan", {
input: {
image_url: `data:image/jpeg;base64,${imageBase64}`,
scale,
model,
output_format: "png",
},
logs: true,
timeout: 180_000,
});
const outputUrl = result?.data?.image?.url || result?.image?.url;
if (!outputUrl) {
throw new Error("FAL ESRGAN 专用高清服务未返回图片");
}
const outputResponse = await fetch(outputUrl, {
cache: "no-store",
signal: AbortSignal.timeout(120_000),
});
if (!outputResponse.ok) {
throw new Error(
`FAL ESRGAN 高清结果下载失败(HTTP ${outputResponse.status})`,
);
}
return {
imageBase64: Buffer.from(await outputResponse.arrayBuffer()).toString(
"base64",
),
processingTime: (Date.now() - startedAt) / 1000,
};
}
async function processWithVip98kEnhance(params: {
userId: string;
imageBase64: string;
prompt: string;
chatId: string | null;
messageId: string | null;
sourceImageUrl: string | null;
scaleFactor: number;
}) {
const startedAt = Date.now();
const result = await executeConfiguredVip98kTask({
modelId: "enhance",
kind: "image-tool",
prompt: `${params.prompt || "Enhance image clarity"}. Upscale ${params.scaleFactor}x while preserving composition, color and texture.`,
imageUrls: [`data:image/jpeg;base64,${params.imageBase64}`],
imageParams: { scaleFactor: params.scaleFactor },
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) throw new Error("VIP98K 变清晰未返回图片");
const response = await fetch(outputUrl, {
cache: "no-store",
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) {
throw new Error(`VIP98K 变清晰结果下载失败(HTTP ${response.status})`);
}
const outputBuffer = Buffer.from(await response.arrayBuffer());
const outputBase64 = outputBuffer.toString("base64");
const uploaded = await uploadBase64ToS3(outputBase64, "image/png");
const [savedImage] = await db
.insert(generatedImage)
.values({
userId: params.userId,
chatId: params.chatId,
messageId: params.messageId,
thumbnailUrl: uploaded.thumbnail,
originalUrl: uploaded.original,
imageSize: `${params.scaleFactor}x放大`,
prompt: params.prompt,
modelId: "enhance",
})
.returning();
try {
const sourceAssets = params.sourceImageUrl
? await getMediaAssetsByUserAndUrls({
userId: params.userId,
urls: [params.sourceImageUrl],
})
: [];
const media = await upsertMediaAsset({
userId: params.userId,
chatId: params.chatId,
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: uploaded.original,
previewUrl: uploaded.original,
thumbnailUrl: uploaded.thumbnail,
displayName: params.prompt.slice(0, 80),
prompt: params.prompt,
modelId: "enhance",
sourceMessageId: params.messageId,
sourceTable: "GeneratedImage",
sourceRowId: savedImage?.id || null,
primaryParentAssetId: sourceAssets[0]?.id ?? null,
metadata: { provider: "vip98k", scaleFactor: params.scaleFactor },
});
if (media) {
for (const sourceAsset of sourceAssets) {
await createMediaAssetRelation({
userId: params.userId,
chatId: params.chatId,
fromAssetId: sourceAsset.id,
toAssetId: media.id,
relationType: "edited_from",
sourceMessageId: params.messageId,
metadata: { prompt: params.prompt, provider: "vip98k" },
});
}
}
} catch (error) {
console.warn("[Enhance-VIP98K] MediaAsset registration failed", error);
}
return {
success: true,
image_base64: outputBase64,
thumbnail_url: uploaded.thumbnail,
original_url: uploaded.original,
processing_time: (Date.now() - startedAt) / 1000,
api_type: "vip98k",
};
}
/**
* 轮询获取任务结果(根据官方示例)
*/
@ -536,6 +689,42 @@ async function finalizeEnhanceTaskResult(params: {
};
}
async function executeFalEsrganFallback(params: {
userId: string;
imageBase64: string;
prompt: string;
chatId: string | null;
messageId: string | null;
sourceImageUrl: string | null;
requestedScaleFactor?: number;
reason: string;
}) {
const scaleFactor = params.requestedScaleFactor || 4;
console.warn("[Enhance-FAL-ESRGAN] 启用专用高清备用链路", {
reason: params.reason.slice(0, 500),
scaleFactor,
});
const result = await processWithFalEsrgan(params.imageBase64, scaleFactor);
const payload = await finalizeEnhanceTaskResult({
userId: params.userId,
taskResult: {
status: 200,
data: { image_base64: result.imageBase64 },
},
prompt: params.prompt || "图片变清晰处理",
chatId: params.chatId,
messageId: params.messageId,
sourceImageUrls: params.sourceImageUrl
? [params.sourceImageUrl]
: undefined,
});
return {
...payload,
processing_time: result.processingTime,
api_type: "fal-esrgan",
};
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
@ -563,6 +752,12 @@ export async function GET(request: NextRequest) {
}
const config = enhanceConfig.value as EnhanceApiConfig;
if (config.provider === "vip98k") {
return NextResponse.json(
{ error: "VIP98K 变清晰任务在 POST 请求内完成,无需 taskId 轮询" },
{ status: 400 }
);
}
if (config.useBackupApi) {
return NextResponse.json(
{ error: "当前启用了Real-ESRGAN备用模式,不支持 taskId 轮询" },
@ -651,12 +846,15 @@ export async function POST(request: NextRequest) {
}
const config = enhanceConfig.value as EnhanceApiConfig;
const useVip98k = config.provider === "vip98k";
// 检查是否使用备用API
const useBackupApi = config.useBackupApi === true;
const useBackupApi = !useVip98k && config.useBackupApi === true;
console.log(`[Enhance API] 使用${useBackupApi ? "备用API (Real-ESRGAN)" : "主API"}`);
if (useBackupApi) {
if (useVip98k) {
// VIP98K 密钥只从 AiProvider 读取。
} else if (useBackupApi) {
// 验证备用API配置
if (!config.backupApiUrl || !config.backupApiKey) {
return NextResponse.json(
@ -757,6 +955,31 @@ export async function POST(request: NextRequest) {
);
}
if (useVip98k) {
try {
const scaleFactor = requestedScaleFactor || 4;
const payload = await processWithVip98kEnhance({
userId,
imageBase64,
prompt: prompt || "图片变清晰处理",
chatId,
messageId,
sourceImageUrl,
scaleFactor,
});
return NextResponse.json(payload);
} catch (error) {
console.error("[Enhance-VIP98K] Processing failed", error);
return NextResponse.json(
{
error: "VIP98K 变清晰处理失败",
detail: error instanceof Error ? error.message : "未知错误",
},
{ status: 500 }
);
}
}
// ========== 备用API处理路径 (Real-ESRGAN) ==========
if (useBackupApi) {
console.log("[Enhance API] 使用Real-ESRGAN备用API处理(URL方式)...");
@ -894,6 +1117,21 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error("[Enhance-RealESRGAN] ❌ 处理失败:", error);
try {
const fallbackPayload = await executeFalEsrganFallback({
userId,
imageBase64,
prompt,
chatId,
messageId,
sourceImageUrl,
requestedScaleFactor,
reason: error instanceof Error ? error.message : "RealESRGAN 备用服务失败",
});
return NextResponse.json(fallbackPayload);
} catch (falError) {
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
}
return NextResponse.json(
{
error: "Real-ESRGAN变清晰处理失败",
@ -1048,6 +1286,22 @@ export async function POST(request: NextRequest) {
enhanceUrl,
processingTime,
});
try {
const fallbackPayload = await executeFalEsrganFallback({
userId,
imageBase64,
prompt,
chatId,
messageId,
sourceImageUrl,
requestedScaleFactor,
reason: fetchError instanceof Error ? fetchError.message : "AOS 高清任务创建失败",
});
return NextResponse.json(fallbackPayload);
} catch (falError) {
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
}
// 检查是否是网络连接错误
if (fetchError instanceof Error) {
@ -1114,6 +1368,21 @@ export async function POST(request: NextRequest) {
return buildEnhanceTaskPendingResponse(taskId);
}
const detail = pollError instanceof Error ? pollError.message : "轮询失败";
try {
const fallbackPayload = await executeFalEsrganFallback({
userId,
imageBase64,
prompt,
chatId,
messageId,
sourceImageUrl,
requestedScaleFactor,
reason: detail,
});
return NextResponse.json(fallbackPayload);
} catch (falError) {
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
}
const friendlyMessage = getAosFriendlyErrorMessage(detail);
return NextResponse.json(
{ error: "变清晰处理失败", detail: friendlyMessage || detail },

View File

@ -8,115 +8,18 @@ import {
authenticateExternalImageApiRequest,
buildExternalImageApiModelCandidates,
buildExternalImageApiKeyFingerprint,
ensureHostedImageMarkdown,
ExternalImageApiGenerationError,
ensureHostedReferenceImageUrls,
extractGeneratedImagesFromMarkdown,
generateImagesWithFailover,
getRequesterIp,
normalizeExternalImageApiImageSize,
resolveExternalImageApiOwnerUserId,
serializeExternalImageApiModel,
uploadExternalImageApiFile,
} from "@/lib/external-image-api/service";
export const runtime = "nodejs";
export const maxDuration = 120;
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);
});
}
export const maxDuration = 1200;
const requestSchema = z.object({
prompt: z.string().trim().min(1, "prompt 不能为空"),
@ -124,8 +27,19 @@ const requestSchema = z.object({
imageCount: z.coerce.number().int().min(1).max(8).optional(),
aspectRatio: z.string().trim().max(32).optional(),
imageSize: z.string().trim().max(32).optional(),
model: z.string().trim().optional(),
modelId: z.string().trim().optional(),
modelRef: z.string().trim().optional(),
metadata: z.record(z.string(), z.any()).optional(),
referenceImages: z.array(z.string().trim().min(1)).max(14).optional(),
}).superRefine((payload, ctx) => {
if (payload.model || payload.modelId || payload.modelRef) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["model"],
message: "第三方生图 API 不允许传入模型参数,系统会按后台配置自动选模",
});
}
});
function normalizeString(value: FormDataEntryValue | string | null | undefined) {
@ -153,6 +67,10 @@ function parseOptionalJsonRecord(value: FormDataEntryValue | null) {
return undefined;
}
function hasDisallowedModelOverride(formValue: string) {
return formValue.trim().length > 0;
}
function normalizeReferenceImageInputs(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) {
@ -173,6 +91,23 @@ async function parseRequestPayload(request: Request) {
const contentType = request.headers.get("content-type") || "";
if (contentType.toLowerCase().includes("multipart/form-data")) {
const formData = await request.formData();
const forcedModel = normalizeString(formData.get("model"));
const forcedModelId = normalizeString(formData.get("modelId"));
const forcedModelRef = normalizeString(formData.get("modelRef"));
if (
hasDisallowedModelOverride(forcedModel) ||
hasDisallowedModelOverride(forcedModelId) ||
hasDisallowedModelOverride(forcedModelRef)
) {
throw new z.ZodError([
{
code: "custom",
path: ["model"],
message: "第三方生图 API 不允许传入模型参数,系统会按后台配置自动选模",
},
]);
}
const fileEntries = [
...formData.getAll("images"),
...formData.getAll("image"),
@ -205,6 +140,9 @@ async function parseRequestPayload(request: Request) {
imageCount: parseOptionalInteger(formData.get("imageCount")),
aspectRatio: normalizeString(formData.get("aspectRatio")) || undefined,
imageSize: normalizeString(formData.get("imageSize")) || undefined,
model: forcedModel,
modelId: forcedModelId,
modelRef: forcedModelRef,
metadata: parseOptionalJsonRecord(formData.get("metadata")),
referenceImages: [...uploadedReferenceImages, ...hostedInlineImages],
});
@ -223,6 +161,9 @@ async function parseRequestPayload(request: Request) {
const json = await request.json();
const parsed = requestSchema.parse({
...json,
model: normalizeString((json as any)?.model),
modelId: normalizeString((json as any)?.modelId),
modelRef: normalizeString((json as any)?.modelRef),
referenceImages: await ensureHostedReferenceImageUrls([
...normalizeReferenceImageInputs((json as any)?.referenceImages),
...normalizeReferenceImageInputs((json as any)?.imageUrls),
@ -256,13 +197,14 @@ 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({
referenceImageCount: payload.referenceImages?.length || 0,
aspectRatio: payload.aspectRatio,
imageSize,
});
const selectedModel = candidateModels[0];
const ownerUserId = await resolveExternalImageApiOwnerUserId(
@ -284,12 +226,13 @@ export async function POST(request: Request) {
negativePrompt: payload.negativePrompt || null,
imageCount: payload.imageCount || 1,
aspectRatio: payload.aspectRatio || null,
imageSize: payload.imageSize || null,
imageSize,
status: "pending",
requesterIp,
requesterUserAgent,
requestPayload: {
...payload,
imageSize,
candidateModels: candidateModels.map(serializeExternalImageApiModel),
apiKeyFingerprint: buildExternalImageApiKeyFingerprint(
authResult.accessKey,
@ -302,24 +245,19 @@ 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,
aspectRatio: payload.aspectRatio || null,
imageSize: payload.imageSize || null,
imageSize,
referenceImageUrls: payload.referenceImages || [],
});
const actualModel = generationResult.usedModel;
const rawMarkdown = generationResult.markdown;
const hostedMarkdown = await ensureHostedImageMarkdown(
rawMarkdown,
payload.imageSize || "2K",
);
const images = extractGeneratedImagesFromMarkdown(hostedMarkdown);
if (!images.length) {
throw new Error("模型返回结果里未解析到图片");
}
const hostedMarkdown = generationResult.markdown;
const images = generationResult.images;
const originalImageUrls = images.map((image) => image.originalUrl || image.url);
const thumbnailImageUrls = images.map((image) => image.thumbnailUrl);
const persistResult = await saveGeneratedImages(
hostedMarkdown,
@ -334,7 +272,7 @@ export async function POST(request: Request) {
optimizedPrompt: payload.prompt,
requestedImageCount: payload.imageCount || images.length,
aspectRatio: payload.aspectRatio,
imageSize: payload.imageSize,
imageSize,
sourceImageUrls: payload.referenceImages || [],
relationType: "edited_from",
skipBilling: true,
@ -353,6 +291,8 @@ export async function POST(request: Request) {
model: serializeExternalImageApiModel(actualModel),
markdown: hostedMarkdown,
images,
imageUrls: originalImageUrls,
thumbnailUrls: thumbnailImageUrls,
attempts: generationResult.attempts,
},
generatedImageIds: persistResult.savedImageIds,
@ -369,6 +309,14 @@ export async function POST(request: Request) {
data: {
requestId: logId,
model: serializeExternalImageApiModel(actualModel),
imageUrl: originalImageUrls[0] || null,
image_url: originalImageUrls[0] || null,
imageUrls: originalImageUrls,
image_urls: originalImageUrls,
originalUrls: originalImageUrls,
original_urls: originalImageUrls,
thumbnailUrls: thumbnailImageUrls,
thumbnail_urls: thumbnailImageUrls,
images,
generatedImageIds: persistResult.savedImageIds,
generatedAssetIds: persistResult.savedAssetIds,
@ -377,6 +325,17 @@ export async function POST(request: Request) {
});
} catch (error) {
console.error("[External Image API] Generate failed:", error);
const shouldHideModelInfo = error instanceof ExternalImageApiGenerationError;
const internalError = shouldHideModelInfo
? error.internalMessage
: error instanceof Error
? error.message
: "图片生成失败";
const publicError = shouldHideModelInfo
? error.userMessage
: error instanceof Error
? error.message
: "图片生成失败";
if (error instanceof z.ZodError) {
return NextResponse.json(
@ -393,9 +352,16 @@ export async function POST(request: Request) {
.update(externalImageApiRequestLog)
.set({
status: "failed",
errorMessage: error instanceof Error ? error.message : "图片生成失败",
errorMessage: internalError,
responsePayload: {
error: error instanceof Error ? error.message : "图片生成失败",
error: shouldHideModelInfo
? {
message: error.userMessage,
attempts: error.attempts,
internalError,
}
: internalError,
attempts: shouldHideModelInfo ? error.attempts : undefined,
},
durationMs: Date.now() - startedAt,
completedAt: new Date(),
@ -403,31 +369,12 @@ 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,
error: error instanceof Error ? error.message : "图片生成失败",
error: publicError,
},
{ status: 500 },
);
} finally {
if (releaseExternalImageApiSlot) {
releaseExternalImageApiSlot();
}
}
}

View File

@ -56,8 +56,24 @@ function resolveFaviconUrl(rawUrl: string, requestUrl: string) {
}
}
function uniqueNonEmptyValues(values: string[]) {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const trimmed = value.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
result.push(trimmed);
}
return result;
}
async function readLocalFallbackFavicon() {
const file = await readFile(join(process.cwd(), "public", "default-favicon.ico"));
const file = await readFile(
join(process.cwd(), "public", "default-favicon.ico")
);
return new NextResponse(file, {
status: 200,
headers: noStoreHeaders(DEFAULT_CONTENT_TYPE),
@ -66,55 +82,78 @@ async function readLocalFallbackFavicon() {
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
if (process.env.CANVAS_E2E_LOCAL === "true") {
return await readLocalFallbackFavicon();
}
const preferDark = requestUrl.searchParams.get("theme") === "dark";
const settings = await getSiteSettings();
const rawFaviconUrl = String(settings?.favicon || "").trim();
const configuredFavicon = String(settings?.favicon || "").trim();
const configuredLightLogo = String(settings?.siteLogo || "").trim();
const configuredDarkLogo = String(settings?.siteLogoDark || "").trim();
const rawFaviconCandidates = configuredFavicon
? [configuredFavicon]
: preferDark
? uniqueNonEmptyValues([configuredDarkLogo, configuredLightLogo])
: uniqueNonEmptyValues([configuredLightLogo, configuredDarkLogo]);
if (!rawFaviconUrl) {
if (rawFaviconCandidates.length === 0) {
return await readLocalFallbackFavicon();
}
const faviconUrl = resolveFaviconUrl(rawFaviconUrl, request.url);
if (!faviconUrl) {
console.error("[favicon] Invalid favicon URL:", rawFaviconUrl);
return await readLocalFallbackFavicon();
}
for (const rawFaviconUrl of rawFaviconCandidates) {
const faviconUrl = resolveFaviconUrl(rawFaviconUrl, request.url);
if (!faviconUrl) {
console.error("[favicon] Invalid favicon URL:", rawFaviconUrl);
continue;
}
const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let upstream: Response;
try {
upstream = await fetch(targetUrl, {
cache: "no-store",
signal: controller.signal,
redirect: "follow",
headers: {
Accept: "image/*,*/*;q=0.8",
"User-Agent": "Mozilla/5.0 (compatible; BuyiAIFavicon/1.0)",
},
let upstream: Response;
try {
upstream = await fetch(targetUrl, {
cache: "no-store",
signal: controller.signal,
redirect: "follow",
headers: {
Accept: "image/*,*/*;q=0.8",
"User-Agent": "Mozilla/5.0 (compatible; BuyiAIFavicon/1.0)",
},
});
} catch (error) {
console.error("[favicon] Candidate request failed:", {
targetUrl,
error,
});
continue;
} finally {
clearTimeout(timeout);
}
if (!upstream.ok) {
console.error("[favicon] Upstream request failed:", {
targetUrl,
status: upstream.status,
statusText: upstream.statusText,
});
continue;
}
const buffer = await upstream.arrayBuffer();
const contentType =
upstream.headers.get("content-type") || inferContentType(faviconUrl);
return new NextResponse(buffer, {
status: 200,
headers: noStoreHeaders(contentType),
});
} finally {
clearTimeout(timeout);
}
if (!upstream.ok) {
console.error("[favicon] Upstream request failed:", {
targetUrl,
status: upstream.status,
statusText: upstream.statusText,
});
return await readLocalFallbackFavicon();
}
const buffer = await upstream.arrayBuffer();
const contentType =
upstream.headers.get("content-type") || inferContentType(faviconUrl);
return new NextResponse(buffer, {
status: 200,
headers: noStoreHeaders(contentType),
});
return await readLocalFallbackFavicon();
} catch (error) {
console.error("[favicon] Failed to proxy favicon:", error);
return await readLocalFallbackFavicon();

View File

@ -0,0 +1,147 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
type AccessLogSeverity,
recordAccessIpEvent,
} from "@/lib/security/access-log";
export const runtime = "nodejs";
export const maxDuration = 10;
const payloadSchema = z.object({
eventType: z.string().trim().max(32).optional(),
ip: z.string().trim().max(128).optional().nullable(),
userId: z.string().trim().uuid().optional().nullable(),
userEmail: z.string().trim().max(128).optional().nullable(),
userRole: z.string().trim().max(32).optional().nullable(),
method: z.string().trim().max(12).optional().nullable(),
path: z.string().trim().max(256).optional().nullable(),
routeGroup: z.string().trim().max(64).optional().nullable(),
statusCode: z.coerce.number().int().min(100).max(599).optional().nullable(),
outcome: z.string().trim().max(32).optional().nullable(),
severity: z.enum(["info", "watch", "high", "critical"]).optional(),
reasons: z.array(z.string().trim().max(80)).max(12).optional(),
userAgent: z.string().trim().max(512).optional().nullable(),
referer: z.string().trim().max(512).optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(),
occurredAt: z.union([z.string(), z.number(), z.date()]).optional(),
});
const suspiciousPathPatterns = [
/\.env/i,
/\/\.git/i,
/\/wp-admin/i,
/\/phpmyadmin/i,
/\/actuator/i,
/\/vendor\/phpunit/i,
/\/eval-stdin\.php/i,
/\/containers\/json/i,
/\/index\.php/i,
];
const suspiciousUserAgentPatterns = [
/libredtail|masscan|zgrab|sqlmap|nikto|acunetix|nessus/i,
/wget|curl|python-requests|go-http-client/i,
/nuclei|crawlergo|httpx/i,
];
function pushReason(reasons: string[], reason: string) {
if (!reasons.includes(reason)) reasons.push(reason);
}
function rankSeverity(value: AccessLogSeverity) {
return { info: 0, watch: 1, high: 2, critical: 3 }[value];
}
function maxSeverity(
current: AccessLogSeverity,
next: AccessLogSeverity
): AccessLogSeverity {
return rankSeverity(next) > rankSeverity(current) ? next : current;
}
function classifyPayload(
payload: z.infer<typeof payloadSchema>
): { severity: AccessLogSeverity; reasons: string[] } {
let severity: AccessLogSeverity = payload.severity || "info";
const reasons = [...(payload.reasons || [])];
const path = payload.path || "";
const userAgent = payload.userAgent || "";
const statusCode = payload.statusCode || 0;
const outcome = payload.outcome || "allowed";
if (outcome === "rate_limited") {
severity = maxSeverity(severity, "high");
pushReason(reasons, "触发访问频率限制");
}
if (outcome === "unauthorized" || statusCode === 401 || statusCode === 403) {
severity = maxSeverity(severity, "watch");
pushReason(reasons, "出现未授权访问");
}
if (statusCode === 429) {
severity = maxSeverity(severity, "high");
pushReason(reasons, "返回 429 限流");
} else if (statusCode >= 500) {
severity = maxSeverity(severity, "high");
pushReason(reasons, "触发服务端错误");
} else if (statusCode >= 400) {
severity = maxSeverity(severity, "watch");
pushReason(reasons, "返回 4xx 异常状态");
}
if (path.startsWith("/api/auth") && statusCode >= 400) {
severity = maxSeverity(severity, "high");
pushReason(reasons, "认证接口失败");
}
if (suspiciousPathPatterns.some((pattern) => pattern.test(path))) {
severity = maxSeverity(severity, "high");
pushReason(reasons, "命中敏感探测路径");
}
if (suspiciousUserAgentPatterns.some((pattern) => pattern.test(userAgent))) {
severity = maxSeverity(severity, "watch");
pushReason(reasons, "User-Agent 异常");
}
if (reasons.includes("静态黑名单命中但未封禁")) {
severity = maxSeverity(severity, "high");
}
return {
severity,
reasons: Array.from(new Set(reasons)).slice(0, 8),
};
}
function getExpectedSecret() {
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
}
export async function POST(request: Request) {
const expectedSecret = getExpectedSecret();
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
if (!expectedSecret || providedSecret !== expectedSecret) {
return new NextResponse("Not found", { status: 404 });
}
const raw = await request.json().catch(() => null);
const parsed = payloadSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json({ ok: true });
}
const classification = classifyPayload(parsed.data);
await recordAccessIpEvent({
...parsed.data,
severity: classification.severity,
reasons: classification.reasons,
}).catch((error) => {
console.warn("[AccessIpEvent] failed to record access event", error);
});
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,140 @@
import { and, eq, gt, isNull, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { accessIpBlocklist } from "@/lib/db/schema";
import { matchManualIpBlock } from "@/lib/security/manual-ip-blocklist";
export const runtime = "nodejs";
export const maxDuration = 5;
const payloadSchema = z.object({
ip: z.string().trim().max(128).optional().nullable(),
});
const BLOCKLIST_UNAVAILABLE_CACHE_MS = 5 * 60 * 1000;
let blocklistUnavailableUntil = 0;
let blocklistMissingTableWarned = false;
function getExpectedSecret() {
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
}
function getErrorCode(error: unknown) {
const directCode =
error && typeof error === "object"
? (error as { code?: unknown }).code
: null;
if (typeof directCode === "string") return directCode;
const cause =
error && typeof error === "object"
? (error as { cause?: unknown }).cause
: null;
const causeCode =
cause && typeof cause === "object"
? (cause as { code?: unknown }).code
: null;
return typeof causeCode === "string" ? causeCode : null;
}
function getErrorMessage(error: unknown) {
const messages: string[] = [];
if (error instanceof Error) messages.push(error.message);
const cause =
error && typeof error === "object"
? (error as { cause?: unknown }).cause
: null;
if (cause instanceof Error) messages.push(cause.message);
if (!messages.length) messages.push(String(error));
return messages.join("\n");
}
function isMissingAccessIpBlocklistTableError(error: unknown) {
if (getErrorCode(error) === "42P01") return true;
const message = getErrorMessage(error);
return (
message.includes('relation "AccessIpBlocklist" does not exist') ||
(message.includes("AccessIpBlocklist") && message.includes("does not exist"))
);
}
function markBlocklistUnavailable(error: unknown) {
blocklistUnavailableUntil = Date.now() + BLOCKLIST_UNAVAILABLE_CACHE_MS;
if (blocklistMissingTableWarned) return;
blocklistMissingTableWarned = true;
console.warn(
"[AccessIpBlocklist] table is unavailable; manual IP block checks will be skipped temporarily",
getErrorMessage(error)
);
}
export async function POST(request: Request) {
const expectedSecret = getExpectedSecret();
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
if (!expectedSecret || providedSecret !== expectedSecret) {
return new NextResponse("Not found", { status: 404 });
}
const parsed = payloadSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success || !parsed.data.ip) {
return NextResponse.json({ blocked: false });
}
if (
process.env.MANUAL_IP_BLOCKLIST_ENABLED === "false" ||
Date.now() < blocklistUnavailableUntil
) {
return NextResponse.json({ blocked: false });
}
try {
const now = new Date();
const entries = await db
.select({
id: accessIpBlocklist.id,
ip: accessIpBlocklist.ip,
reason: accessIpBlocklist.reason,
expiresAt: accessIpBlocklist.expiresAt,
})
.from(accessIpBlocklist)
.where(
and(
eq(accessIpBlocklist.isActive, true),
or(
isNull(accessIpBlocklist.expiresAt),
gt(accessIpBlocklist.expiresAt, now)
)
)
)
.limit(1000);
const activeEntries = entries.filter((entry) => entry.ip && entry.id);
const matched = matchManualIpBlock(parsed.data.ip, activeEntries);
return NextResponse.json({
blocked: Boolean(matched),
entry: matched
? {
id: matched.id,
ip: matched.ip,
reason: matched.reason || null,
expiresAt: matched.expiresAt || null,
}
: null,
});
} catch (error) {
if (isMissingAccessIpBlocklistTableError(error)) {
markBlocklistUnavailable(error);
return NextResponse.json({ blocked: false });
}
console.warn("[AccessIpBlocklist] internal check failed", error);
return NextResponse.json({ blocked: false });
}
}

View File

@ -0,0 +1,138 @@
import { NextResponse } from "next/server";
import { getProviderDefaultBaseUrl } from "@/lib/ai/provider-defaults";
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
import { getMultimodalAiModelsWithProvider } from "@/lib/db/queries";
import { createLogger } from "@/lib/observability/logger";
import { isPascalInternalRequest } from "@/lib/pascal/internal-auth";
export const dynamic = "force-dynamic";
export const maxDuration = 600;
export const runtime = "nodejs";
const logger = createLogger("pascal-model-gateway");
const OPENAI_COMPATIBLE_PROVIDER_TYPES = new Set([
"alibaba",
"custom",
"openai",
"zhipu",
]);
function upstreamChatUrl(baseUrl: string) {
const normalized = baseUrl.replace(/\/+$/, "");
return normalized.endsWith("/chat/completions")
? normalized
: `${normalized}/chat/completions`;
}
function openAiError(message: string, status: number) {
return NextResponse.json(
{ error: { message, type: "pascal_gateway_error" } },
{ status }
);
}
async function getConfiguredMultimodalModel() {
const candidates = await getMultimodalAiModelsWithProvider();
return candidates.find(({ provider }) =>
OPENAI_COMPATIBLE_PROVIDER_TYPES.has(provider.type)
);
}
export async function GET(request: Request) {
if (!isPascalInternalRequest(request)) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
const configured = await getConfiguredMultimodalModel();
if (!configured) {
return NextResponse.json({ configured: false, selection: "multimodal" });
}
return NextResponse.json({
configured: true,
compatible: OPENAI_COMPATIBLE_PROVIDER_TYPES.has(configured.provider.type),
model: configured.model.displayName || configured.model.modelId,
provider: configured.provider.name,
providerType: configured.provider.type,
selection: "multimodal",
});
}
export async function POST(request: Request) {
if (!isPascalInternalRequest(request)) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
let body: Record<string, unknown>;
try {
const parsed = await request.json();
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return openAiError("Invalid request body.", 400);
}
body = parsed as Record<string, unknown>;
} catch {
return openAiError("Invalid JSON body.", 400);
}
const configured = await getConfiguredMultimodalModel();
if (!configured) {
return openAiError("No enabled multimodal model is configured.", 503);
}
const { model, provider } = configured;
if (!OPENAI_COMPATIBLE_PROVIDER_TYPES.has(provider.type)) {
return openAiError(
`The configured multimodal provider type '${provider.type}' is not OpenAI-compatible.`,
503
);
}
const baseUrl =
provider.baseUrl?.trim() || getProviderDefaultBaseUrl(provider.type) || "";
const runtimeModelId = resolveModelRuntimeId(model, {
providerType: provider.type,
});
if (!baseUrl || !runtimeModelId || !provider.apiKey) {
return openAiError("The configured multimodal model is incomplete.", 503);
}
const startedAt = Date.now();
try {
const upstream = await fetch(upstreamChatUrl(baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, model: runtimeModelId }),
cache: "no-store",
signal: request.signal,
});
logger.info("upstream completed", {
elapsedMs: Date.now() - startedAt,
modelId: model.id,
providerId: provider.id,
status: upstream.status,
});
const headers = new Headers({
"Cache-Control": "no-cache, no-transform",
"Content-Type":
upstream.headers.get("content-type") || "application/json; charset=utf-8",
"X-Accel-Buffering": "no",
});
return new Response(upstream.body, { status: upstream.status, headers });
} catch (error) {
logger.error("upstream failed", {
elapsedMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
modelId: model.id,
providerId: provider.id,
});
return openAiError(
"The configured multimodal model is temporarily unavailable.",
502
);
}
}

View File

@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { isPascalInternalRequest } from "@/lib/pascal/internal-auth";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: Request) {
if (!isPascalInternalRequest(request)) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
const session = await auth();
const userId = session?.user?.id?.trim();
if (!userId) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
return new NextResponse(null, {
status: 204,
headers: {
"Cache-Control": "no-store",
"X-Pascal-User-Id": userId,
},
});
}

View File

@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { runImageArchiveJob } from "@/lib/jobs/image-archive-job";
import { getImageArchiveTaskStats } from "@/lib/storage/image-archive-db";
function verifyAuthToken(request: Request): boolean {
const authHeader = request.headers.get("authorization");
const token = authHeader?.replace("Bearer ", "");
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret) {
console.error("[ImageArchiveAPI] CRON_SECRET is not configured");
return false;
}
return token === cronSecret;
}
export async function POST(request: Request) {
try {
if (!verifyAuthToken(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const result = await runImageArchiveJob();
return NextResponse.json({
...result,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error("[ImageArchiveAPI] Job execution failed:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
},
{ status: 500 }
);
}
}
export async function GET(request: Request) {
try {
if (!verifyAuthToken(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const stats = await getImageArchiveTaskStats();
return NextResponse.json({
success: true,
stats,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error("[ImageArchiveAPI] Stats query failed:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,38 @@
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { createCanvasCapabilityMcpServer } from "@/lib/canvas/capabilities/mcp-server";
export const dynamic = "force-dynamic";
export const maxDuration = 600;
async function handleMcpRequest(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "未登录,无法访问画布 MCP 服务" },
{ status: 401 }
);
}
const { id: projectId } = await params;
const transport = new WebStandardStreamableHTTPServerTransport({
enableJsonResponse: true,
});
const server = await createCanvasCapabilityMcpServer({
projectId,
requestHeaders: request.headers,
requestUrl: request.url,
userId: session.user.id,
});
await server.connect(transport);
return transport.handleRequest(request);
}
export const POST = handleMcpRequest;
export const GET = handleMcpRequest;
export const DELETE = handleMcpRequest;

View File

@ -18,6 +18,12 @@ import {
getFalImageTaskStatus,
resolveFalImageTaskToMarkdown,
} from "@/lib/ai/providers/fal-image";
import {
isAgentModeScopedChatId,
isScopedEmptyHistoryRequest,
isValidMessagesChatId,
normalizeMessagesChatId,
} from "@/lib/chat/messages-route-access";
export const dynamic = "force-dynamic";
export const revalidate = 0;
@ -25,18 +31,14 @@ export const revalidate = 0;
const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
const falTaskLastPolledAt = new Map<string, number>();
const falTaskSyncInFlight = new Set<string>();
const AGENT_MODE_SCOPED_CHAT_SUFFIX = "a93e17a60001";
function isAgentModeScopedChatId(chatId: string) {
const normalized = String(chatId || "")
.trim()
.toLowerCase()
.replace(/-/g, "");
return (
/^[0-9a-f]{32}$/.test(normalized) &&
normalized.endsWith(AGENT_MODE_SCOPED_CHAT_SUFFIX)
);
function toPublicVideoMetadata(value: DBMessage["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;
return publicMetadata;
}
function createEmptyMessagesResponse() {
@ -226,18 +228,27 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const chatId = request.nextUrl.searchParams.get("chatId");
const requestedChatId = request.nextUrl.searchParams.get("chatId");
if (!chatId) {
if (!requestedChatId) {
return NextResponse.json(
{ error: "Missing required parameter: chatId" },
{ status: 400 }
);
}
const chatId = normalizeMessagesChatId(requestedChatId);
if (!isValidMessagesChatId(chatId)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const scope = request.nextUrl.searchParams.get("scope");
const selectedChat = await getChatById({ id: chatId });
if (!selectedChat) {
if (isAgentModeScopedChatId(chatId)) {
if (
isAgentModeScopedChatId(chatId) ||
isScopedEmptyHistoryRequest({ chatId, scope })
) {
return createEmptyMessagesResponse();
}
@ -275,7 +286,7 @@ export async function GET(request: NextRequest) {
createdAt: msg.createdAt,
parts: parts,
attachments: msg.attachments || [],
videoMetadata: msg.videoMetadata || null,
videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || 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,8 +1,14 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import { getSystemSetting } from "@/lib/db/queries";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import sharp from "sharp";
export const runtime = "nodejs";
@ -21,10 +27,12 @@ interface PickObjectRequest {
imageData: string; // 带红色标记的图片 base64
clickX: number; // 点击位置 X (0-1)
clickY: number; // 点击位置 Y (0-1)
mode?: "object" | "video-canvas-mark";
}
interface PickObjectResult {
name: string;
labels?: string[];
position: string;
confidence: number;
boundingBox: {
@ -119,7 +127,51 @@ async function uploadVisionAnalysisImage(buffer: Buffer, filenamePrefix: string)
return uploaded.url;
}
async function normalizeImageInput(imageData: string): Promise<string> {
async function prepareMarkedVisionAnalysisImage(
buffer: Buffer,
clickX: number,
clickY: number
) {
const prepared = await downscaleImageForVision(buffer);
const metadata = await sharp(prepared.buffer, { failOn: "none" }).metadata();
const width = Math.max(1, metadata.width || prepared.width || MAX_VISION_SIDE);
const height = Math.max(1, metadata.height || prepared.height || MAX_VISION_SIDE);
const markerX = Math.max(0, Math.min(width, clickX * width));
const markerY = Math.max(0, Math.min(height, clickY * height));
const radius = Math.max(10, Math.min(width, height) * 0.025);
const overlay = Buffer.from(`
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${markerX}" cy="${markerY}" r="${radius + 3}" fill="#fff" />
<circle cx="${markerX}" cy="${markerY}" r="${radius}" fill="#f00" />
<g stroke="#f00" stroke-width="2">
<line x1="${markerX - radius * 2}" y1="${markerY}" x2="${markerX - radius - 2}" y2="${markerY}" />
<line x1="${markerX + radius + 2}" y1="${markerY}" x2="${markerX + radius * 2}" y2="${markerY}" />
<line x1="${markerX}" y1="${markerY - radius * 2}" x2="${markerX}" y2="${markerY - radius - 2}" />
<line x1="${markerX}" y1="${markerY + radius + 2}" x2="${markerX}" y2="${markerY + radius * 2}" />
</g>
</svg>
`);
const composited = sharp(prepared.buffer, { failOn: "none" }).composite([
{ input: overlay, left: 0, top: 0 },
]);
const markedBuffer =
prepared.ext === "png"
? await composited.png({ compressionLevel: 9 }).toBuffer()
: await composited.jpeg({ quality: 84, mozjpeg: true }).toBuffer();
logger.info("marked vision image prepared", {
clickX,
clickY,
outputBytes: markedBuffer.length,
outputExt: prepared.ext,
});
const mimeType = prepared.ext === "png" ? "image/png" : "image/jpeg";
return `data:${mimeType};base64,${markedBuffer.toString("base64")}`;
}
async function normalizeImageInput(
imageData: string,
options?: { clickX: number; clickY: number; markPoint: boolean }
): Promise<string> {
const raw = String(imageData || "").trim();
if (!raw) throw new Error("缺少图片数据");
@ -145,7 +197,10 @@ async function normalizeImageInput(imageData: string): Promise<string> {
}
const arrayBuffer = await response.arrayBuffer();
ensureImageSizeAllowed(arrayBuffer.byteLength);
return uploadVisionAnalysisImage(Buffer.from(arrayBuffer), "pick-object-url");
const buffer = Buffer.from(arrayBuffer);
return options?.markPoint
? prepareMarkedVisionAnalysisImage(buffer, options.clickX, options.clickY)
: uploadVisionAnalysisImage(buffer, "pick-object-url");
} finally {
clearTimeout(timeout);
}
@ -160,7 +215,9 @@ async function normalizeImageInput(imageData: string): Promise<string> {
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
const buffer = Buffer.from(base64Body, "base64");
ensureImageSizeAllowed(buffer.byteLength);
return uploadVisionAnalysisImage(buffer, "pick-object-base64");
return options?.markPoint
? prepareMarkedVisionAnalysisImage(buffer, options.clickX, options.clickY)
: uploadVisionAnalysisImage(buffer, "pick-object-base64");
}
export async function POST(request: Request) {
@ -174,7 +231,7 @@ export async function POST(request: Request) {
}
const body: PickObjectRequest = await request.json();
const { imageData, clickX, clickY } = body;
const { imageData, clickX, clickY, mode = "object" } = body;
logger.info("request received", {
imageDataLength: imageData?.length,
@ -204,12 +261,34 @@ export async function POST(request: Request) {
}, { status: 400 });
}
const normalizedImage = await normalizeImageInput(imageData);
const normalizedImage = await normalizeImageInput(imageData, {
clickX,
clickY,
markPoint: mode === "video-canvas-mark",
});
logger.info("vision model selected", { model: "qwen3-vl" });
// 构建 prompt - AI 通过红色标记来识别用户选择的物品
const prompt = `
const videoCanvasConfig =
mode === "video-canvas-mark"
? normalizeVideoCanvasFunctionConfig(
(
await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
() => null
)
)?.value || defaultVideoCanvasFunctionConfig
)
: null;
const elementMarkDefinition = videoCanvasConfig?.functions["element-mark"];
if (mode === "video-canvas-mark" && elementMarkDefinition?.enabled === false) {
return NextResponse.json(
{ success: false, error: "视频元素标记功能当前未启用" },
{ status: 403 }
);
}
// AI 通过红色标记识别用户点中的主体;视频画布规则来自系统设置。
const defaultObjectPrompt = `
图片中有一个红色圆形标记(带十字线),请识别这个红色标记所指向的具体物品。
【重要】
@ -232,12 +311,24 @@ export async function POST(request: Request) {
【position 格式】固定4字,从以下选择:
${POSITION_OPTIONS.join('、')}
`.trim();
const prompt =
mode === "video-canvas-mark"
? String(
elementMarkDefinition?.prompt ||
defaultVideoCanvasFunctionConfig.functions["element-mark"]?.prompt ||
defaultObjectPrompt
).replaceAll("{{positionOptions}}", POSITION_OPTIONS.join("、"))
: defaultObjectPrompt;
logger.info("vision model call started");
// 调用视觉模型,发送带红色标记的图片
const { text: responseText, model } = await callQwen3Vl({
imageUrl: normalizedImage,
model:
mode === "video-canvas-mark"
? elementMarkDefinition?.model || videoCanvasConfig?.defaultMultimodalModel
: undefined,
prompt,
temperature: 0.1,
maxTokens: 300,
@ -266,7 +357,17 @@ ${POSITION_OPTIONS.join('、')}
const parsed = JSON.parse(jsonMatch[0]);
// 验证和修正名称
let name = parsed.name || '物品';
const labels =
mode === "video-canvas-mark" && Array.isArray(parsed.labels)
? Array.from(
new Set(
parsed.labels
.map((item: unknown) => String(item || "").trim().slice(0, 8))
.filter(Boolean)
)
).slice(0, 4)
: [];
let name = labels[0] || parsed.name || '物品';
if (name.length > 8) {
name = name.substring(0, 6);
}
@ -292,6 +393,7 @@ ${POSITION_OPTIONS.join('、')}
result = {
name,
labels: mode === "video-canvas-mark" ? (labels.length ? labels : [name]) : undefined,
position,
confidence,
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容
@ -312,6 +414,7 @@ ${POSITION_OPTIONS.join('、')}
result = {
name: '物品',
labels: mode === "video-canvas-mark" ? ['物品'] : undefined,
position,
confidence: 0.5,
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 },

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