Compare commits

...

1025 Commits

Author SHA1 Message Date
ae4605d479 Merge pull request 'fix(deploy): 切换时重建单个 PM2 应用定义并保留回滚日志' (#4) from codex/production-48-pm2-definition into main 2026-10-30 19:16:50 +08:00
Codex
36efdfae53 fix(deploy): replace only the target PM2 process definition 2026-10-30 19:15:56 +08:00
10efb4c1e7 Merge pull request 'build: 独立封装发布依赖并用写入时复制控制空间' (#3) from codex/production-48-runtime-snapshot into main 2026-10-30 18:43:39 +08:00
Codex
9da598967a build: snapshot complete release runtime with copy-on-write files 2026-10-30 18:42:59 +08:00
91edc4019e Merge pull request 'build: 建立 main 到 48 / PM2 的可回滚发布流程' (#2) from codex/production-48-release-pipeline into main 2026-10-30 18:23:21 +08:00
Codex
7439792d40 build: add verified PM2 release workflow for production 48 2026-10-30 18:22:11 +08:00
be642948ec chore(repo): restore main from the verified production source baseline (#1)
Preserve production history and recovered source changes, with portable repository validation.

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

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

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

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

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

Bundle canvas styles for preview and production, show marquee borders and selection fills, and align editor density with the project UI. Include interaction regression coverage and client comparison notes.
2026-10-04 18:57:02 +08:00
134
a492d082ed feat: recreate XMind editing and file workflows 2026-10-04 18:56:53 +08:00
134
4ada7eaf38 fix: polish canvas icons, context menu and panel layout 2026-10-04 12:13:47 +08:00
134
63e2f48192 fix: restore canvas skills and local uploads, simplify home and canvas headers 2026-10-04 10:31:53 +08:00
134
d376f5959f feat: refresh home and canvas UI with customizable image toolbar 2026-10-04 03:41:03 +08:00
134
2c8f306e74 docs(tvstar): record live production evidence and remaining gaps 2026-10-03 17:58:58 +08:00
134
c4a2753a6e fix(tvstar): enforce structured video analysis and reject truncation 2026-10-03 17:48:41 +08:00
134
0d752d6e38 fix(character-studio): separate identity from view composition 2026-10-03 17:34:01 +08:00
134
96d67955cb fix(canvas): preserve uncertain image submissions without paid failover 2026-10-03 17:29:31 +08:00
134
c45a6a77a0 fix(tvstar): isolate shot inputs and reject observed visual defects 2026-10-03 16:56:57 +08:00
134
88cf96b998 fix(tvstar): validate live video contracts and avoid duplicate planning 2026-10-03 16:18:56 +08:00
134
570375b5f1 fix(video-canvas): enforce production delivery requirements and analyze real footage 2026-10-03 15:33:10 +08:00
134
e7be5af3f2 feat(video-canvas): add TVstar creation and session workflows 2026-10-01 22:58:20 +08:00
134
365bad8539 fix(canvas): distinguish provider balance from image rate limits 2026-10-01 07:45:39 +08:00
134
08eecb0190 docs: record the UUID validation regression that broke detail replacement 2026-09-27 19:19:20 +08:00
134
f75d99baf6 fix(agent): repair the UUID check that made every detail replacement request 400
新增的批次接口把一个 UUID 正则写成了 8-4-4-12(少一组 -{4}),标准 UUID 是
8-4-4-4-12。结果是任何真实项目编号和请求编号都过不了校验:查询接口恒返 400
「项目编号无效」,提交接口恒返 400「任务编号或项目编号无效」,详情替换完全无法使用。

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

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

- 新增 /api/agents/detail-replacement/batches(提交/查询、幂等去重、重试上限)
- 前端只在服务端确认批次后显示处理中,卡片显示排队/生成/完成/失败真实状态
- 去掉假进度动画;没有产品输入图时提交前直接拦截
- 服务重启后自动恢复未完成批次,长批次不再被通用 30 分钟清理误判
- 单张失败只落到该卡片并继续后续图片,已完成的图片不重复生成和扣费
- 详情替换默认模型由持续 404 的 gpt-image-2.5-flare 改回 gpt-image-2.5
2026-09-26 02:39:11 +08:00
134
1c2acee547 Preserve uploaded media aspect ratios in video canvas 2026-09-24 14:36:22 +08:00
134
439af60c2a Update video canvas home and Aliyun analysis flow 2026-09-24 13:36:31 +08:00
134
e97296a9e8 Add share entry to the canvas top bar
The share popover (publish to site, copy link) and publish modal had
no reachable entry; add a share button next to the save status,
matching the reference product's top bar.
2026-09-19 17:09:39 +08:00
134
94b49b7fad Document shortcut system rollout and server-side verification 2026-09-19 14:57:29 +08:00
134
ea4f34bf89 Re-enable the video toolbar HD entry
The P40 upscale flow (popover, HD node creation, task submission) is
fully wired; the toolbar entry had been left disabled and mislabeled
as not connected.
2026-09-19 14:42:52 +08:00
134
1141ecc43a Bound separation input WAV duration to the probed length
The unbounded apad filter made ffmpeg pad the 3-second test clip
endlessly (a 22.9GB WAV) until the disk filled, which was the real
cause of the voice separation failures. Trim to the probed duration
and drop apad.
2026-09-19 14:33:06 +08:00
134
8b2955eb0b Add canvas undo/redo and full shortcut system
Snapshot-based undo/redo (max 50 steps) with Cmd+Z / Cmd+Shift+Z,
ignoring volatile generation progress fields. Bind the remaining
reference shortcuts: Cmd+D duplicate, Cmd+Enter generate selection,
Tab new-node palette, Cmd+F canvas node search panel, Cmd+0 fit view,
Cmd+=/- zoom, V pan-tool toggle, H hold-to-pan, plain G to group.
Expand the shortcut help panel to the full four-group layout.
2026-09-19 12:53:07 +08:00
134
94b3ac9cb3 Restyle story script nodes to dark theme
Align the story script generator table and script text node with the
reference product: dark cards with subtle white borders, muted header
row, dark status pills, white primary button and dark textarea card,
replacing the previous light table chrome.
2026-09-18 22:57:07 +08:00
134
8a1876e016 Render canvas image nodes with object-contain
Show the full picture inside image resource nodes instead of cropping
with object-cover; matches the reference product where node frames
adapt to the image and no content is clipped, and fixes existing
nodes created before aspect fitting was added.
2026-09-18 18:45:39 +08:00
134
b4b6e1a738 Size dropped image nodes to intrinsic aspect ratio
Dropped images landed in fixed 420x280 nodes and rendered with
object-cover, cropping portraits and wide photos. Read the image's
natural size during ingestion and fit the node frame to the aspect
ratio (landscape caps height, portrait caps width) so the full
picture stays visible like the reference product.
2026-09-18 18:34:23 +08:00
134
885cab9fca Document voice separation rollout 2026-09-18 18:21:47 +08:00
134
6ed8879b28 Wire canvas voice/background separation to remote HTDemucs service
The private FunASR/HTDemucs audio service was already live and used by
the smart edit pipeline, but the canvas video toolbar still claimed
voice/background separation was unavailable. Add a dedicated
/api/canvas/audio-separate route (download, ffmpeg stem extraction,
remote separation, storage upload) and route the toolbar's voice and
background modes through it, replacing the stale not-connected notice.
2026-09-18 18:16:44 +08:00
134
c1975942ac Document video canvas shell cleanup and media upload fix rollout 2026-09-18 18:04:51 +08:00
134
a4354dc535 Remove video canvas demo shells and fix media upload timeouts
- Starter/toolbox presets no longer seed fake generated results; demo
  fallback media URLs in tool actions now fail with honest guidance
- Delete dead demo scene components, fake director console, demo
  history constants and unreachable submit toast fallbacks
- Smart edit dialog accepts desktop drag-and-drop video files
- Raise media upload client timeout to 10 minutes and surface
  friendly timeout errors instead of raw 'signal timed out'
2026-09-18 17:40:49 +08:00
134
3002704d77 Document video canvas production rollout 2026-09-18 15:24:10 +08:00
134
fe1c9b60f3 Fix video canvas media imports and node layout 2026-09-18 15:09:34 +08:00
134
4f9453b8b2 Add durable remote audio inference and verified offline speech models 2026-09-18 14:12:28 +08:00
134
229f4d72cb Record verified production rollout of canvas groups and video upscaling 2026-09-18 13:37:54 +08:00
134
12576c565f Complete durable canvas groups and add isolated P40 video upscaling 2026-09-18 13:18:48 +08:00
134
b17b8b7689 Make canvas connections resolve live inputs and respect disconnects 2026-09-18 13:18:48 +08:00
134
0d5558c50c Fix handwritten storyboard entry and generic grid presets; record GPU allocation audit 2026-09-18 13:18:48 +08:00
134
c6533e92f3 Complete smart edit audio processing and font catalogue with main app validation 2026-09-18 13:18:48 +08:00
134
17e17b9ad5 Fix smart edit captions, audio gain and timeline parity 2026-09-18 13:18:48 +08:00
134
689fa615e8 Audit smart edit parity and fix caption timing and outline previews 2026-09-18 13:18:47 +08:00
134
725c1ec3d2 Match smart edit transition previews and centered effects to reference 2026-09-18 13:18:47 +08:00
134
84bfd6b9f4 Support local preview transforms and detached smart edit audio tracks 2026-09-18 13:18:47 +08:00
134
6869bd8c18 Align smart edit canvas and timeline UI with reference workflow 2026-09-17 18:05:29 +08:00
134
a5f4ad0385 Bound per-input decoder and scene detection threads 2026-09-17 16:42:06 +08:00
134
6eff0a7332 Use static import in remote rendering helper test 2026-09-17 16:39:51 +08:00
134
a00a910227 Verify remote render recovery identity and endpoint binding 2026-09-17 16:39:51 +08:00
134
721de36692 Remove duplicate canvas definitions introduced by release merge 2026-09-17 16:34:47 +08:00
134
be8bdb4dab Merge verified video canvas updates with production hotfix history 2026-09-17 16:33:36 +08:00
134
bbac6289b3 Add smart editing workflow and isolated durable video rendering service 2026-09-17 16:32:04 +08:00
134
cb42f27ee9 Add official Alibaba audio generation and editable director object tracks 2026-09-17 15:43:32 +08:00
134
292eddc5a6 fix(video-canvas): resume provider jobs and recover editing and 3d state 2026-09-17 15:01:02 +08:00
134
69dcf9b745 fix(video-canvas): persist story analysis and resume accepted enhance tasks 2026-09-17 13:51:15 +08:00
134
86b66172b1 fix(video-canvas): persist image operations and story subtasks 2026-09-17 13:30:32 +08:00
134
0d17638a8d fix(video-canvas): persist generation tasks and repair workflow controls 2026-09-17 12:32:47 +08:00
134
23f86dfce1 fix(video-canvas): repair audited controls and document remaining validation gaps 2026-09-17 11:22:49 +08:00
134
f10e6b7c8d fix(video-canvas): connect generation controls and repair script media director workflows 2026-09-17 10:28:18 +08:00
134
e5b2de3c1b docs(video-canvas): audit 231 nested LibTV feature checkpoints 2026-09-17 09:48:11 +08:00
134
d5593ac3f0 feat(video-canvas): implement camera motion timeline and real MP4 export 2026-09-17 00:31:36 +08:00
134
38be09de9c feat(video-canvas): connect real 3d director and validate media chain 2026-09-17 00:02:03 +08:00
134
598f5f617a feat(video-canvas): add tested Aliyun Wan first-frame adapter 2026-09-16 23:03:37 +08:00
134
f7550251ec fix(video-canvas): preserve story length and archive generated videos 2026-09-16 22:12:35 +08:00
134
2ba093b714 feat(video-canvas): connect prompt tools and durable media processing 2026-09-16 21:37:07 +08:00
134
5b0d75a738 fix(video-canvas): persist edits and record live provider regression results 2026-09-16 18:56:03 +08:00
134
027f0a4201 docs: record official Qwen3.8 Max canvas configuration 2026-09-16 18:10:25 +08:00
134
96b8e1662c docs: record official Qwen3.8 Max canvas configuration 2026-09-16 18:10:05 +08:00
134
290f6d2c99 docs: record video canvas production rollout and verification 2026-09-16 16:35:48 +08:00
134
7c276fe43a docs: record video canvas production rollout and verification 2026-09-16 16:35:27 +08:00
134
741fe3663e fix: exclude build artifacts from canvas stylesheet scanning 2026-09-16 16:29:31 +08:00
134
924c8065b6 fix: match video canvas models to real capabilities and remove simulated processing 2026-09-16 16:29:31 +08:00
134
50d782850f fix: exclude build artifacts from canvas stylesheet scanning 2026-09-16 15:11:50 +08:00
134
60b7900bf9 fix: match video canvas models to real capabilities and remove simulated processing 2026-09-16 15:06:05 +08:00
134
0c1a2e6fee docs: record Claw 3D loading rollout verification 2026-09-15 16:24:43 +08:00
134
73593051fe fix: render Claw models without waiting for external HDR lighting 2026-09-15 16:21:06 +08:00
134
3df44f1f8c fix: render Claw models without waiting for external HDR lighting 2026-09-15 16:20:23 +08:00
134
54c857d714 docs: record Claw history recovery rollout checks 2026-09-15 14:39:08 +08:00
134
3bf0fec097 fix: restore Claw history before waiting for active tasks 2026-09-15 14:37:26 +08:00
134
d31079e26d fix: restore Claw history before waiting for active tasks 2026-09-15 14:34:58 +08:00
134
2bf5f6232d fix: separate Claw markdown table columns 2026-09-15 13:36:52 +08:00
134
c024ae31b9 fix: separate Claw markdown table columns 2026-09-15 13:34:41 +08:00
134
8f0dff35ed docs: record Claw production rollout verification 2026-09-14 09:16:01 +08:00
134
f3cded51fd fix: stop Claw artifact request loops and restore CAD actions 2026-09-14 09:10:16 +08:00
134
b45d9ee314 fix: stop Claw artifact request loops and restore CAD actions 2026-09-14 09:09:31 +08:00
134
0aff83f61a docs: record VIP98K live matrix and deployment evidence 2026-09-11 15:10:20 +08:00
134
b26e265083 fix: verify VIP98K sizes and recover replacement results 2026-09-11 14:56:00 +08:00
134
aaa650265c fix: verify VIP98K sizes and recover replacement results 2026-09-11 14:52:38 +08:00
134
a2a9e7d8b2 fix: stabilize detail replacement generation 2026-09-11 11:36:53 +08:00
134
dcff190c62 fix(claw): prevent new chat history rebound 2026-09-11 11:19:04 +08:00
134
a7d994d93b fix: advertise verified vip98k image sizes 2026-09-10 16:32:25 +08:00
134
a1973ae202 fix: honor vip98k image contracts 2026-09-10 16:19:49 +08:00
134
f9d1b46d81 fix: bind image extension to Alibaba provider key 2026-09-10 12:48:27 +08:00
134
821b8658a8 feat: add verified VIP98K multi-image models 2026-09-10 10:08:13 +08:00
134
6f5a444756 fix: preserve detail replacement product geometry 2026-09-09 19:50:03 +08:00
134
2e340c004a fix: restore bananaimage models 2026-09-08 10:14:29 +08:00
134
2a9b05de80 fix: hide unsupported vip98k image editors 2026-09-08 10:00:19 +08:00
134
08ed9ac3ef perf: cache vip98k reference staging 2026-09-07 20:50:20 +08:00
134
2fb53b4279 test: accept synchronous vip98k image results 2026-09-07 20:28:21 +08:00
134
42359fbf0f fix: support vip98k image array contracts 2026-09-07 20:20:07 +08:00
134
6ce7c31a1e fix: poll qwen image generation asynchronously 2026-09-07 19:46:18 +08:00
134
e49b0707e9 fix: enforce vip98k multi-image contracts 2026-09-07 18:17:01 +08:00
134
77f8774a55 fix: restore vip98k text image models 2026-09-07 15:06:40 +08:00
134
3a1c445c0b fix: stabilize vip98k image generation uploads 2026-09-07 14:45:06 +08:00
134
cfd3418bb7 fix: accept large canvas references for vip98k 2026-09-07 13:17:39 +08:00
134
faf3a2dbb2 fix: retry vip98k reconciliation responses 2026-09-07 13:14:50 +08:00
134
002ab8b98d fix: bypass vip98k media fetch cache on retry 2026-09-07 12:41:41 +08:00
134
c69ddb1bb3 fix: retry vip98k while staged media propagates 2026-09-07 12:32:38 +08:00
134
01b3f6eec1 fix: use public upload path for vip98k staging 2026-09-07 12:24:25 +08:00
134
6a30795bb8 fix: stage vip98k media on reachable storage 2026-09-07 12:18:26 +08:00
134
0a364f537f fix: normalize vip98k references for provider limits 2026-09-07 12:04:46 +08:00
134
d133a48866 fix: stabilize vip98k reference image delivery 2026-09-07 11:48:40 +08:00
134
434d0a63cd fix: keep verified vip98k image models available 2026-09-07 11:16:52 +08:00
134
608bd25679 fix: trust vip98k published image contracts 2026-09-07 00:32:33 +08:00
134
47909e39a9 fix: update vip98k image task integration 2026-09-07 00:22:45 +08:00
134
740fc90dfd feat: support updated OpenClaw 3D task protocol 2026-09-06 12:45:39 +08:00
134
272592488b fix(claw): persist 3d preview and restore chat history 2026-09-06 09:19:09 +08:00
134
6269aefee5 fix(ai): preserve relational image references 2026-09-05 18:46:25 +08:00
134
9717196264 fix(claw): assemble legacy 3d models by default 2026-09-03 17:43:01 +08:00
134
2817aab197 Merge Claw interactive 3D preview support 2026-09-02 20:22:41 +08:00
134
bbfece5720 feat(claw): support interactive 3D previews 2026-09-02 20:21:40 +08:00
134
e0501bbbd1 Merge remote-tracking branch 'origin/codex/bbyy-production-156-20260820' into codex/deploy-48-bloub-20260902 2026-09-02 19:14:09 +08:00
134
6ab68e8ac9 fix(canvas): show Bloub in chat and image placeholders 2026-09-02 19:12:58 +08:00
134
0ed21b1b76 Merge remote-tracking branch 'origin/codex/bbyy-production-156-20260820' into codex/deploy-48-bloub-20260902 2026-09-02 18:08:52 +08:00
134
02017a2531 feat(ui): add official Bloub assistant states 2026-09-02 18:06:46 +08:00
134
0a375de55c feat(claw): preview 3D artifacts in chat 2026-09-02 12:46:05 +08:00
134
4d8c984d08 feat(claw): preview 3D artifacts in chat 2026-09-02 12:42:02 +08:00
134
c39d7260fb fix(model3d): restore archived world assets and previews 2026-08-28 22:17:20 +08:00
134
cb9a90d9ac fix(ai): restore models after successful circuit probe 2026-08-27 17:10:09 +08:00
134
412fa85a81 fix(ai): restore models after successful circuit probe 2026-08-27 17:09:33 +08:00
134
ac1e586156 fix(ai): fail over forbidden image requests 2026-08-27 16:32:47 +08:00
134
e1c155be9e fix(ai): fail over forbidden image requests 2026-08-27 16:31:27 +08:00
134
2a4f8f631b feat(ai): support Qwen Image 3.0 2026-08-26 16:10:47 +08:00
134
ba232f3385 feat(ai): support Qwen Image 3.0 2026-08-26 16:10:00 +08:00
134
5f6341a318 fix: preserve manually disabled image models 2026-08-26 13:44:42 +08:00
134
d005b3477a fix: preserve manually disabled image models 2026-08-26 13:43:58 +08:00
134
07a9a61743 fix(ai): fail over failed VIP98K image tasks 2026-08-25 17:04:50 +08:00
root
86c8e4995d Merge remote-tracking branch 'origin/codex/48-ai-template-integration-20260813' into codex/48-ai-template-integration-20260813 2026-08-25 17:04:04 +08:00
134
e5dc650443 fix(ai): fail over failed VIP98K image tasks 2026-08-25 17:00:38 +08:00
134
9596b0a667 fix(canvas): honor text edit failover exclusions 2026-08-25 15:44:25 +08:00
134
4a30cad91f fix(canvas): honor text edit failover exclusions 2026-08-25 15:43:50 +08:00
134
7752ec42ee fix(canvas): restore reliable text editing 2026-08-25 14:46:01 +08:00
134
3a88fb0df0 fix(canvas): restore reliable text editing 2026-08-25 14:44:50 +08:00
134
f95c6b2b2b fix(canvas): decouple text detection from image provider 2026-08-25 14:03:51 +08:00
134
a0999bfcde fix(canvas): decouple text detection from image provider 2026-08-25 14:03:25 +08:00
134
4b5540f3c2 fix(ai): route vip98k image tools correctly 2026-08-25 13:30:33 +08:00
134
d120e0ddb6 fix(ai): route vip98k image tools correctly 2026-08-25 13:27:31 +08:00
134
b73afcdfdc fix(ai): mark vip98k images as async tasks 2026-08-25 13:11:40 +08:00
134
c21a86beea fix(ai): mark vip98k images as async tasks 2026-08-25 13:07:58 +08:00
134
bcb21344ba fix(ai): update vip98k image task contract 2026-08-25 13:02:39 +08:00
134
e87724499b fix(ai): update vip98k image task contract 2026-08-25 12:59:53 +08:00
134
42e1175830 fix: persist generated images before gallery save 2026-08-22 17:25:34 +08:00
134
afac47add2 fix(home): harden Qiniu featured previews 2026-08-20 22:41:01 +08:00
134
d1582f33e8 fix(bbyy): isolate production branding from E2E defaults 2026-08-20 22:12:33 +08:00
134
1d84b1b1c0 fix(ops): wait for production container startup 2026-08-20 19:52:05 +08:00
134
f0a24a1a99 fix(canvas): optimize Qiniu image previews 2026-08-20 19:47:02 +08:00
134
3d12c508d3 fix: stop retrying expired image archives forever 2026-08-20 19:34:56 +08:00
134
88dec71a41 ops: make production builds atomic 2026-08-20 19:32:49 +08:00
134
6180c5fc49 fix: restore production model availability helpers 2026-08-20 19:28:51 +08:00
root
dfb50c83fa fix: make chat layout dynamic for stable homepage bundles 2026-08-19 16:01:52 +08:00
134
3ad353b97f fix: make chat layout dynamic to avoid broken home chunk artifacts 2026-08-19 16:00:38 +08:00
134
e05b42942d fix(world): fallback status polling from failed external task result 2026-08-19 13:48:07 +08:00
134
e601f8079f fix(world): harden world task create external-marble fallback 2026-08-19 13:45:10 +08:00
134
fd49843c5a fix(world): align vip98k world task mode support 2026-08-19 13:17:37 +08:00
134
a9a9f8e138 fix(world): guard unsupported modes for external world task provider 2026-08-19 12:34:13 +08:00
134
15c3b519a7 Fix image proxy public access and model avatar fallback 2026-08-19 09:27:10 +08:00
134
c27a707776 fix world labs task polling fallback for dynamic response payloads 2026-08-18 18:43:17 +08:00
134
7602365bb8 fix(canvas3d): retry transient poll failures before fail state 2026-08-18 16:53:28 +08:00
134
1fa126cecf fix(canvas): allow repeated layout source picking 2026-08-14 12:10:23 +08:00
134
398a4bbefd feat(canvas): add direct canvas image picking 2026-08-14 11:47:42 +08:00
134
3010e51f72 feat(canvas): add durable batch template replacement 2026-08-14 10:20:01 +08:00
134
71b3ce56cf fix(canvas): restore AI layout replacement 2026-08-14 00:18:15 +08:00
134
bf6bd01fcb feat(canvas): add batch template replacement 2026-08-13 23:29:08 +08:00
134
3009727ec1 chore(git): ignore local runtime data 2026-08-13 18:38:28 +08:00
134
e2806578c0 fix(release): align template flow with production baseline 2026-08-13 18:34:46 +08:00
134
231650dc00 chore(git): enforce deployment branch and feature guards 2026-08-13 18:31:44 +08:00
134
675966a783 feat(agent): add AI template reuse to main results 2026-08-13 18:31:19 +08:00
134
9ff3dc68ce test(canvas): guard PSD template toolbar entry 2026-08-13 18:28:53 +08:00
134
01722af2fb fix(canvas): restore PSD template replacement entry 2026-08-13 18:28:53 +08:00
134
46a28f77f0 fix(claw): restore spreadsheet attachment badges 2026-08-11 21:24:44 +08:00
134
aafc970ae8 fix(claw): preserve per-task credentials on reconnect 2026-08-11 18:22:32 +08:00
134
e2a8f2dda3 merge: preserve server-only production updates 2026-08-11 15:47:02 +08:00
134
731aa9725a fix: preserve main behavior after Claw merge 2026-08-11 15:28:11 +08:00
134
1dd489100d feat(claw): support resumable 2GB file uploads 2026-08-11 15:13:36 +08:00
134
3f2b18d18d merge: sync Claw production updates into main
# Conflicts:
#	app/(admin)/settings/admin-settings-client.tsx
#	app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
#	playwright.helpers.config.ts
2026-08-11 15:11:59 +08:00
134
398b2ec83c feat(batch): sync dedicated image tools 2026-08-11 14:05:38 +08:00
134
bbd0795fdc fix(image-tools): restore dedicated processing fallbacks 2026-08-11 13:48:33 +08:00
134
7d67bd1059 fix(claw): resume durable long-running tasks 2026-08-11 11:58:49 +08:00
134
aebc7edecb fix(claw): preserve archive preview metadata 2026-08-11 10:21:48 +08:00
134
bcff82aeca feat(claw): add secure forms and folder uploads 2026-08-11 10:16:02 +08:00
134
2fee4a4a8c fix: reconcile VIP98K media catalog 2026-08-10 23:20:54 +08:00
134
e225e06f0b fix: stabilize 3d workspace and canvas model controls 2026-08-10 20:54:23 +08:00
134
74ea0ca9fc fix(ai): remove VIP98K text-to-image models only 2026-08-10 16:31:23 +08:00
134
ab306e298b Revert "fix(ai): block costly and text-to-image VIP98K models"
This reverts commit 6b2c2c7b42d8a893b170736c06448f99aa059881.
2026-08-10 15:45:28 +08:00
root
65e9b26ad2 Merge branch 'codex/48-main-sync-20260714-150806' of http://git.1080p.hk/lunanting/ai-chatbot into codex/48-main-sync-20260714-150806 2026-08-10 14:22:27 +08:00
134
6b2c2c7b42 fix(ai): block costly and text-to-image VIP98K models 2026-08-10 14:21:18 +08:00
134
0a17a2842c fix(ai): block costly and text-to-image VIP98K models 2026-08-10 14:18:45 +08:00
134
0a1026c237 fix(ai): keep dedicated 3d provider frontend route 2026-08-09 18:46:56 +08:00
134
9438cf2f36 fix(ai): keep dedicated 3d provider frontend route 2026-08-09 18:45:09 +08:00
134
e1e7e0ccd4 fix(ai): store provider model metadata as jsonb objects 2026-08-09 18:39:07 +08:00
134
8fb4873eac fix(ai): store provider model metadata as jsonb objects 2026-08-09 18:37:05 +08:00
134
5decd202a1 fix(ai): preserve realtime canvas and normalize model types 2026-08-09 18:20:46 +08:00
134
2ad794d6e1 fix(ai): preserve realtime canvas and normalize model types 2026-08-09 18:07:01 +08:00
134
27736f0a5d fix(ai): preserve HiCAD provider integration 2026-08-09 17:30:33 +08:00
134
96c4b76250 fix(ai): preserve HiCAD provider integration 2026-08-09 17:29:01 +08:00
134
578f97b248 fix(model3d): preserve Tripo provider integration 2026-08-09 15:28:57 +08:00
134
c962fff811 fix(model3d): preserve Tripo provider integration 2026-08-09 15:26:30 +08:00
134
4c6ce023eb fix(ai): add VIP98K expansion fallback 2026-08-08 17:57:30 +08:00
134
658c3345f3 fix(ai): add VIP98K expansion fallback 2026-08-08 17:56:18 +08:00
134
4f43c7a8c7 fix(ai): honor VIP98K invocation contracts 2026-08-08 17:35:47 +08:00
134
93158e0b7b fix(ai): honor VIP98K invocation contracts 2026-08-08 17:33:44 +08:00
134
a326cf6935 fix(ai): route VIP98K reference edits through task API 2026-08-08 17:21:03 +08:00
134
2676d9a96f fix(ai): route VIP98K reference edits through task API 2026-08-08 17:18:55 +08:00
134
82088274be fix(ai): allow VIP98K catalog in server scripts 2026-08-08 17:02:15 +08:00
134
f9ded76a38 fix(ai): allow VIP98K catalog in server scripts 2026-08-08 16:59:25 +08:00
134
4a1536515e feat(ai): consolidate providers around VIP98K and FAL 2026-08-08 16:52:14 +08:00
134
725a93331a feat(ai): add VIP98K provider foundation on 48 baseline 2026-08-08 16:52:14 +08:00
134
f414150e73 feat(ai): consolidate providers around VIP98K and FAL 2026-08-08 16:40:18 +08:00
134
311d4a0ec3 feat(ai): add VIP98K provider foundation on 48 baseline 2026-08-08 16:40:01 +08:00
134
30b7ae168f fix(claw): use static thirty-minute route duration 2026-08-07 15:24:17 +08:00
134
54cd02d09d feat: improve Claw workflows and reliability 2026-08-07 14:52:17 +08:00
134
f1b769490a fix(claw): allow ten-minute chat tasks 2026-08-07 12:12:11 +08:00
root
7708b66e8d backup: preserve server changes before OpenClaw deploy 2026-08-07 11:43:46 +08:00
134
2f00645cd4 feat: add user-scoped OpenClaw MCP access 2026-08-07 11:31:00 +08:00
134
a68f9832ed fix(claw): persist and recover background chat tasks 2026-08-06 12:58:01 +08:00
134
c2fcf11280 fix(canvas): raise Claw skill tag limit 2026-08-05 21:36:11 +08:00
134
ee5b5d146d fix(canvas): dedupe Claw table images 2026-08-05 19:18:51 +08:00
134
c6c7c703b5 fix(canvas): handle expired sessions for Claw skills 2026-08-05 00:14:06 +08:00
134
233c7b3308 feat: add Claw skill search 2026-08-05 00:04:36 +08:00
134
d467694aa7 style: lighten homepage promo banner text 2026-08-04 23:38:09 +08:00
134
98c8dd20ee feat: add configurable homepage promo banner 2026-08-04 21:41:38 +08:00
134
31567af44e feat(canvas): allow resizing model selector 2026-08-04 21:41:00 +08:00
root
d3a6497672 fix: normalize WorldLabs VIP98K endpoint 2026-08-04 21:39:59 +08:00
134
e3180c23ac fix: persist Claw spreadsheet attachments 2026-07-28 21:12:18 +08:00
134
fb8d8232dc feat: support video attachments in Claw chat 2026-07-28 15:50:47 +08:00
134
21ba23c4ab fix: reconcile HiCAD manufacturing geometry 2026-07-28 14:26:05 +08:00
134
07aef767b9 fix: preserve HiCAD hole annotations 2026-07-28 13:53:07 +08:00
134
a2c9404a3d fix: stabilize HiCAD manufacturing dimensions 2026-07-28 13:53:07 +08:00
134
0397bf3a88 fix: correct HiCAD BOM geometry 2026-07-28 10:42:30 +08:00
134
8e89bb179e feat: add HiCAD editor project shortcuts 2026-07-27 23:55:51 +08:00
134
016076015e feat: add Claw product lifecycle shortcuts 2026-07-27 23:17:48 +08:00
134
43fd3d258a feat: simplify HiCAD project management 2026-07-27 22:58:10 +08:00
134
1a9888d825 fix: harden canvas image generation flows
(cherry picked from commit 78b0da908c4a0c22bd7eb66b5a661c07fcecb4fc)
2026-07-27 14:41:08 +00:00
134
2c81486862 feat: generate complete front and side part drawings 2026-07-27 22:36:42 +08:00
134
b5b5398b30 fix: render HiCAD exploded fallback and reposition trigger 2026-07-27 22:31:57 +08:00
134
cc113155dd fix: compact HiCAD engineering sheet header 2026-07-27 22:25:51 +08:00
134
5565829c65 test: validate HiCAD hardware production drawings 2026-07-27 22:20:56 +08:00
134
d84f3dc7d8 feat: add HiCAD hole dimension production drawings 2026-07-27 22:04:42 +08:00
134
e40c351f80 fix: render HiCAD orthographic views and Chinese BOM names 2026-07-27 21:48:02 +08:00
134
dc7f58d940 fix: correct HiCAD exploded previews and BOM templates 2026-07-27 21:37:19 +08:00
134
46f1d97593 fix: make HiCAD engineering analysis resilient 2026-07-27 21:06:25 +08:00
134
3b3f9e71ed fix: normalize HiCAD engine responses 2026-07-27 20:54:59 +08:00
134
9c9e462682 feat: restore isolated HiCAD engineering tools 2026-07-27 20:46:18 +08:00
134
4c21fd4844 fix: exclude HiCAD templates from app typecheck 2026-07-27 20:34:20 +08:00
134
623ab0f8b5 fix: isolate latest HiCAD startup 2026-07-27 20:24:53 +08:00
134
bbe27bd555 fix: isolate HiCAD manufacturing extension 2026-07-27 20:09:39 +08:00
134
d93b2e8ea7 feat: brand HiCAD production sheets 2026-07-27 19:58:35 +08:00
134
7435e7b0f0 feat: add HiCAD manufacturing drawings 2026-07-27 19:58:35 +08:00
134
08d2503e11 fix: use site favicon in HiCAD 2026-07-27 19:42:35 +08:00
134
240352362a feat: upgrade embedded HiCAD to latest official source 2026-07-27 19:33:59 +08:00
134
f65b9e0472 fix: frame complete HiCAD models after generation 2026-07-27 19:20:57 +08:00
134
11deb3bba6 perf: streamline HiCAD startup 2026-07-27 18:24:43 +08:00
134
de8b7670b2 fix: support Claw AVIF and TIFF uploads 2026-07-27 17:30:42 +08:00
134
80d05e177a fix: keep sent Claw messages out of composer 2026-07-27 10:22:49 +08:00
134
c889bfc381 feat: add product planning v2 claw skill 2026-07-24 14:10:53 +08:00
134
62c686a26b fix: fail over on upstream image provider errors 2026-07-24 13:51:35 +08:00
134
3096eb6752 feat: add AtlasCloud video model support 2026-07-23 10:52:29 +08:00
134
974a4b1b59 chore: rename hidden model control 2026-07-22 11:42:39 +08:00
134
6ccf04a786 fix: preserve AtlasCloud model aliases 2026-07-22 10:53:19 +08:00
134
1a91b8ada9 fix: hide stale AtlasCloud catalog entries 2026-07-22 10:35:05 +08:00
134
75cecdfcad fix: validate AtlasCloud frontend model requests 2026-07-22 10:32:28 +08:00
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
Your Name
faa70a315c fix: require prompt pack for detail scene replacement 2026-06-04 13:16:01 +08:00
Your Name
9da001e3ee feat: add canvas ai wash quick panel 2026-06-04 13:16:01 +08:00
Your Name
b12d61b371 fix: align psd template layer ordering 2026-06-04 12:04:14 +08:00
Your Name
800eb10ba4 fix: stabilize psd template parsing progress 2026-06-04 11:39:53 +08:00
Your Name
37e2b66725 fix: force scene prompt rebuild for detail references 2026-06-04 11:29:40 +08:00
Your Name
0dcc759c80 fix: preserve psd clipped image layer order 2026-06-04 11:22:12 +08:00
Your Name
0808f34021 fix: make detail scene prompt pack explicit 2026-06-04 10:07:00 +08:00
Your Name
2ea6f2e800 fix: apply scene prompts to action detail posters 2026-06-03 22:01:49 +08:00
Your Name
7ac5bdf44f fix: harden detail replacement page routing 2026-06-03 16:47:50 +08:00
Your Name
87f23a0ab6 fix: add progress dialog for batch result exports 2026-06-03 16:02:53 +08:00
Your Name
fb240616e0 fix: route detail replacements by scene intent 2026-06-03 15:41:13 +08:00
Your Name
ce7c2bff35 fix: apply prompt packs to white scene detail replacements 2026-06-03 14:06:52 +08:00
Your Name
a154a28a4a fix: harden detail replacement layout rules 2026-06-03 11:13:19 +08:00
Your Name
1eddc7e296 style: refine ai wash workspace 2026-06-03 10:19:34 +08:00
Your Name
4e6f47d35f fix: remove marketplace brand marks from detail replacement 2026-06-02 23:03:42 +08:00
Your Name
91b1d868d4 fix: lock detail replacement layout slots 2026-06-02 22:29:58 +08:00
Your Name
a17d2da133 fix: apply prompt packs to action detail cards 2026-06-02 21:55:22 +08:00
Your Name
e126355b26 fix: avoid blank detail replacement outputs 2026-06-02 21:27:37 +08:00
Your Name
1a97f56169 fix: stabilize detail replacement page layout 2026-06-02 20:42:59 +08:00
Your Name
29930f3c14 fix: prevent detail prompt pack guard self-trigger 2026-06-02 19:40:02 +08:00
Your Name
5aab184a97 fix: route detail replacement scene prompts correctly 2026-06-02 19:12:58 +08:00
Your Name
5e896b8e56 fix: show psd image loading placeholder 2026-06-02 16:47:47 +08:00
Your Name
6f4a881238 fix: speed up psd editor image preview 2026-06-02 16:35:32 +08:00
Your Name
19dc43049d fix: guard detail replacement prompt routing 2026-06-02 16:34:05 +08:00
Your Name
0b9d41e7e2 fix: force detail replacement generation 2026-06-02 15:39:56 +08:00
Your Name
3e2261b1c7 fix: restore detail market id lookup input 2026-06-02 15:21:24 +08:00
Your Name
fa4b4183cf fix: stabilize detail replacement result images 2026-06-02 14:36:37 +08:00
Your Name
537a9d10c2 fix: update detail replacement and UI behavior 2026-06-02 14:26:01 +08:00
Your Name
37f6ef7e8e fix: polish ai wash layout and sidebar overflow 2026-06-02 14:22:49 +08:00
Your Name
ea970888f8 refactor: move ai wash toolbar right 2026-06-02 14:22:49 +08:00
Your Name
70cf96afaf fix: use spu for detail reference search 2026-06-02 13:50:52 +08:00
Your Name
51e8393f95 fix: resolve detail reference sku to spu 2026-06-02 13:19:15 +08:00
Your Name
5a7f8bc1f1 fix: streamline detail reference template lookup 2026-06-02 13:00:27 +08:00
Your Name
f69e2d07a2 fix: show regenerating state for style batch results 2026-06-02 11:44:04 +08:00
Your Name
caf9eb7290 fix: avoid premature psd parse timeout 2026-06-02 11:27:27 +08:00
Your Name
ea1bc4c78b fix: export canvas images as ps-safe png 2026-06-02 10:53:26 +08:00
Your Name
61bb42a266 fix: reject unchanged text edit results 2026-06-01 21:47:50 +08:00
Your Name
08323571aa fix: align rerun replacement pending cards 2026-06-01 21:40:03 +08:00
Your Name
a78fd8a0d4 fix: keep PSD clipped replacements in target layer order 2026-06-01 20:57:50 +08:00
Your Name
87be5499d5 fix: anchor PSD replacements to clip targets 2026-06-01 20:39:25 +08:00
Your Name
c247093ec9 fix: avoid empty PSD replacement masks 2026-06-01 20:28:11 +08:00
Your Name
cffed9ab50 fix: support PSD clipping mask target layers 2026-06-01 20:03:38 +08:00
Your Name
c00b9e333d fix: enable text edit after ai copy suggestion 2026-06-01 19:45:04 +08:00
Your Name
5461cff1db fix: improve PSD template management and clipping masks 2026-06-01 19:40:57 +08:00
Your Name
db1fc8c3df feat: add PSD force clipping mask option 2026-06-01 18:45:54 +08:00
Your Name
4d2093ef44 Revert "fix: preserve PSD super lock mask editing"
This reverts commit 6d60d53910cf8cb3d65aade75496cf514d3df617.
2026-06-01 18:45:54 +08:00
Your Name
0baa13f7de Revert "fix: clip PSD super lock replacements"
This reverts commit 6e647021cdc52a083cace27801044838f11a5864.
2026-06-01 18:45:54 +08:00
Your Name
5d7e8ce773 fix: allow system setting writes from jobs 2026-06-01 18:39:30 +08:00
Your Name
d82a47c035 fix: clip PSD super lock replacements 2026-06-01 18:23:40 +08:00
Your Name
e2cf152662 fix: preserve PSD super lock mask editing 2026-06-01 18:02:16 +08:00
Your Name
0a001fc5f4 fix: precompile PSD template render segments 2026-06-01 17:53:31 +08:00
Your Name
720652b68a fix: proxy material card previews 2026-06-01 16:49:28 +08:00
Your Name
00ae2d4e59 fix: preserve manual detail cutlines 2026-06-01 15:07:05 +08:00
Your Name
a3c291a1e0 fix: stabilize material grid hook order 2026-06-01 14:52:24 +08:00
Your Name
efc77160c2 fix: stabilize detail text edits and cutline deletion 2026-06-01 14:44:13 +08:00
Your Name
e8796c74a6 fix: guard material search client crashes 2026-06-01 14:25:13 +08:00
Your Name
531a23d15f fix: load material previews from originals 2026-06-01 14:09:18 +08:00
Your Name
291eb9b11c fix: improve material preview loading 2026-06-01 13:57:02 +08:00
Your Name
1c8a709670 fix: avoid canvas redirect shell flash 2026-06-01 13:22:11 +08:00
root
25a1a005a4 merge: sync settings updates into main 2026-06-01 12:05:39 +08:00
root
57261dad71 perf: use external material image urls 2026-06-01 11:46:25 +08:00
Your Name
f31db01b8e feat: add agent workflow snapshot center 2026-05-29 17:22:32 +08:00
Your Name
e160d7f217 fix: guard agent workflow config rollback 2026-05-29 16:53:19 +08:00
3616 changed files with 712802 additions and 26592 deletions

View File

@ -9,6 +9,13 @@
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
AUTH_SECRET=replace-with-random-secret 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. # Public application URL. Required for production and self-hosted Auth.js.
AUTH_URL=https://your-domain.com AUTH_URL=https://your-domain.com
NEXTAUTH_URL=https://your-domain.com NEXTAUTH_URL=https://your-domain.com
@ -98,12 +105,25 @@ WORLDLABS_API_KEY=
WORLDLABS_BASE_URL= WORLDLABS_BASE_URL=
WORLDLABS_IMPORT_TOKEN= WORLDLABS_IMPORT_TOKEN=
WORLDLABS_KEY_POOL_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_TOKEN=
KITTYCAD_API_KEY= KITTYCAD_API_KEY=
KITTYCAD_BASE_URL=https://api.zoo.dev KITTYCAD_BASE_URL=https://api.zoo.dev
ZOO_API_TOKEN= ZOO_API_TOKEN=
ZOO_BASE_URL=https://api.zoo.dev 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 / video callback.
RUXA_CALLBACK_URL= RUXA_CALLBACK_URL=
RUXA_VIDEO_CALLBACK_URL= RUXA_VIDEO_CALLBACK_URL=
@ -133,8 +153,9 @@ PRODUCT_PLANNING_DB_SSL=false
# Home Performance / Queues # Home Performance / Queues
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
CANVAS_IMAGE_TASK_CONCURRENCY=3 # Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000 CANVAS_IMAGE_TASK_CONCURRENCY=0
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3 EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20 EXTERNAL_IMAGE_API_MAX_QUEUE=20
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000 EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000

20
.gitattributes vendored Normal file
View File

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

49
.gitignore vendored
View File

@ -11,6 +11,9 @@ coverage
# next.js # next.js
.next/ .next/
.next-build/
.next-previous/
/.next-*/
out/ out/
build build
@ -48,6 +51,7 @@ yarn-error.log*
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/* /playwright/*
/output/
deploy.tar.gz deploy.tar.gz
libvips-8.14.5-linux-x64.tar.br libvips-8.14.5-linux-x64.tar.br
.temp-packages/ .temp-packages/
@ -64,10 +68,19 @@ migration-backup-complete/
migration-*.log migration-*.log
migration-*.txt migration-*.txt
# Local release backups, model credentials and migration scratch data.
/.model-config-backups/
/backups/
/tmp/
# 本地抓包/竞品参考产物,不能提交 # 本地抓包/竞品参考产物,不能提交
artifacts/liblib-*/ artifacts/liblib-*/
analysis/qiaodou-extract/ analysis/qiaodou-extract/
# 本地运行时数据库与上传文件
.local/
public/uploads/local/
# 模型文件 # 模型文件
*.model *.model
*.pth *.pth
@ -87,5 +100,41 @@ checkpoints/
# 第三方参考源码(不提交到git) # 第三方参考源码(不提交到git)
reference-sources/ 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) # Drizzle migrations must be versioned (override the global *.sql ignore above)
!lib/db/migrations/*.sql !lib/db/migrations/*.sql
# Private smart edit runtime and media/model caches
.venv-smart-edit/
.smart-edit-models/
.cache/smart-edit/
services/video-render/dist/
# Downloaded and normalized subtitle fonts are runtime assets.
.cache/smart-edit-fonts/
services/smart-edit/__pycache__/
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
.qa/
.next-video-*/
.next-tvstar-*/
services/video-upscale/dist/
services/video-upscale/__pycache__/
services/video-upscale/vendor/__pycache__/
/services/audio-processing/dist/
/services/audio-processing/__pycache__/
# Python release-tool and test bytecode.
__pycache__/
*.pyc

View File

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

View File

@ -144,18 +144,10 @@ log.error("Operation failed", error, { userId: "123" });
#### 7. 修复 service 层错误吞没 #### 7. 修复 service 层错误吞没
**风险等级**: 🟡 中等风险 **风险等级**: 🟡 中等风险
**改动**: **改动**:
- `services/ai-cad/index.ts`: 完全重写,所有 catch 块现在记录错误
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移) - `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
**潜在问题**: **潜在问题**:
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同 - `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, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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"; import type { AdminSettingsNavItem } from "../_lib/types";
const ALL_SETTINGS_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items);
function SettingsQuickAction({ function SettingsQuickAction({
activeTab, activeTab,
item, 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-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"; : "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 const layoutClassName = stretched
? "h-10 w-full min-w-0 justify-center gap-2 rounded-2xl border px-4" ? "h-8 w-full min-w-0 justify-center gap-1.5 rounded-md border px-2.5 text-xs"
: "h-9 shrink-0 gap-2 rounded-full border px-3.5"; : "h-8 shrink-0 gap-1.5 rounded-md border px-2.5 text-xs";
if (item.href) { if (item.href) {
return ( return (
@ -45,7 +43,7 @@ function SettingsQuickAction({
variant="ghost" variant="ghost"
> >
<Link href={item.href}> <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> <span className={stretched ? "truncate" : ""}>{item.label}</span>
</Link> </Link>
</Button> </Button>
@ -59,7 +57,7 @@ function SettingsQuickAction({
size="sm" size="sm"
variant="ghost" variant="ghost"
> >
<Icon className="h-4 w-4" /> <Icon className="h-3.5 w-3.5" />
<span className={stretched ? "truncate" : ""}>{item.label}</span> <span className={stretched ? "truncate" : ""}>{item.label}</span>
</Button> </Button>
); );
@ -73,9 +71,13 @@ export function AdminSettingsNavigation({
onTabChange: (value: string) => void; onTabChange: (value: string) => void;
}) { }) {
const router = useRouter(); 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 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) { if (nextItem?.href) {
router.push(nextItem.href); router.push(nextItem.href);
return; return;
@ -84,15 +86,18 @@ export function AdminSettingsNavigation({
}; };
return ( return (
<div className="rounded-2xl border bg-background px-4 py-3 shadow-sm"> <nav
<div className="flex flex-col gap-2 md:hidden"> 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"> <div className="flex items-center gap-2">
<Select onValueChange={handleSelectChange} value={activeTab}> <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 || "选择设置项"} /> <SelectValue placeholder={activeItem?.label || "选择设置项"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{ALL_SETTINGS_ITEMS.map((item) => ( {ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}> <SelectItem key={item.value} value={item.value}>
{item.label} {item.label}
</SelectItem> </SelectItem>
@ -102,7 +107,7 @@ export function AdminSettingsNavigation({
<Button <Button
asChild 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" size="sm"
variant="outline" variant="outline"
> >
@ -113,35 +118,22 @@ export function AdminSettingsNavigation({
</Button> </Button>
</div> </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>
<div className="hidden min-w-0 items-center gap-3 md:flex"> <div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto"> {ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
{ALL_SETTINGS_ITEMS.map((item) => ( <SettingsQuickAction
<div className="min-w-[118px] flex-1" key={item.value}> activeTab={activeTab}
<SettingsQuickAction item={item}
activeTab={activeTab} key={item.value}
item={item} onTabChange={onTabChange}
onTabChange={onTabChange} stretched
stretched />
/> ))}
</div>
))}
</div>
<Button <Button
asChild 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" size="sm"
variant="outline" variant="outline"
> >
@ -151,6 +143,6 @@ export function AdminSettingsNavigation({
</Link> </Link>
</Button> </Button>
</div> </div>
</div> </nav>
); );
} }

View File

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

View File

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

View File

@ -75,12 +75,16 @@ export function LocalAiImagePipelineSections({
aiWashConfig, aiWashConfig,
setAiWashConfig, setAiWashConfig,
aiWashModeIconUploading, aiWashModeIconUploading,
canvasLocalEditConfig,
setCanvasLocalEditConfig,
extendConfig, extendConfig,
setExtendConfig, setExtendConfig,
watermarkRemoveConfig, watermarkRemoveConfig,
setWatermarkRemoveConfig, setWatermarkRemoveConfig,
angleEditConfig, angleEditConfig,
setAngleEditConfig, setAngleEditConfig,
pointsSystemConfig,
updatePointsCostField,
layerSplitConfig, layerSplitConfig,
setLayerSplitConfig, setLayerSplitConfig,
isLocalAiSectionExpanded, isLocalAiSectionExpanded,
@ -101,14 +105,17 @@ export function LocalAiImagePipelineSections({
handleMoveEnhanceProPreset, handleMoveEnhanceProPreset,
handleAiWashModeIconUpload, handleAiWashModeIconUpload,
handleSaveAiWashConfig, handleSaveAiWashConfig,
handleSaveCanvasLocalEditConfig,
handleSaveExtendConfig, handleSaveExtendConfig,
handleSaveWatermarkRemoveConfig, handleSaveWatermarkRemoveConfig,
handleSaveAngleEditConfig, handleSaveAngleEditConfig,
handleSaveLayerSplitConfig, handleSaveLayerSplitConfig,
}: LocalAiImagePipelineSectionsProps) { }: LocalAiImagePipelineSectionsProps) {
const availableImageModels = (modelsData?.models || []).filter( 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) => { const getAiWashControlToneCopy = (controlId: AiWashControlId) => {
if (controlId === "creativity") { if (controlId === "creativity") {
return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。"; return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。";
@ -152,12 +159,15 @@ export function LocalAiImagePipelineSections({
}, },
{ {
label: label:
rmbgConfig.apiKey || rmbgConfig.provider === "vip98k"
? "服务商 Key 已配置"
: rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey || rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey rmbgConfig.aosApiKey
? "Key 已配置" ? "Key 已配置"
: "Key 待配置", : "Key 待配置",
tone: tone:
rmbgConfig.provider === "vip98k" ||
rmbgConfig.apiKey || rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey || rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey rmbgConfig.aosApiKey
@ -172,7 +182,26 @@ export function LocalAiImagePipelineSections({
{/* 服务提供商选择 */} {/* 服务提供商选择 */}
<div className="space-y-3"> <div className="space-y-3">
<Label>抠图服务提供商</Label> <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 <div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${ className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "rmbg" rmbgConfig.provider === "rmbg"
@ -259,6 +288,13 @@ export function LocalAiImagePipelineSections({
<Separator /> <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 配置 */} {/* RMBG 配置 */}
{rmbgConfig.provider === "rmbg" && ( {rmbgConfig.provider === "rmbg" && (
<> <>
@ -464,7 +500,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。 配置图片变清晰服务,用于画图标注功能中的图片增强处理。
</> </>
} }
expanded={isLocalAiSectionExpanded("enhance")} expanded={isLocalAiSectionExpanded("enhance")}
@ -474,17 +510,21 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary <LocalAiSectionSummary
items={[ items={[
{ {
label: enhanceConfig.useBackupApi label: enhanceConfig.provider === "vip98k"
? "VIP98K"
: enhanceConfig.useBackupApi
? "备用 Real-ESRGAN" ? "备用 Real-ESRGAN"
: "主 API", : "主 API",
tone: "neutral", tone: "neutral",
}, },
{ {
label: label:
enhanceConfig.provider === "vip98k" ||
enhanceConfig.apiKey || enhanceConfig.backupApiKey enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "Key 已配置" ? "Key 已配置"
: "Key 待配置", : "Key 待配置",
tone: tone:
enhanceConfig.provider === "vip98k" ||
enhanceConfig.apiKey || enhanceConfig.backupApiKey enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "enabled" ? "enabled"
: "warning", : "warning",
@ -494,6 +534,13 @@ export function LocalAiImagePipelineSections({
} }
title="变清晰API配置" 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切换开关 */} {/* API切换开关 */}
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
@ -618,7 +665,7 @@ export function LocalAiImagePipelineSections({
</p> </p>
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p> <p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
<p> <p>
• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸) • 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸)
</p> </p>
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p> <p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
</div> </div>
@ -737,11 +784,13 @@ export function LocalAiImagePipelineSections({
<p>• 支持2x、4x、8x放大倍数</p> <p>• 支持2x、4x、8x放大倍数</p>
<p>• 处理时间:通常1-10秒,取决于图片大小</p> <p>• 处理时间:通常1-10秒,取决于图片大小</p>
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p> <p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
<p>• 文件限制:最大50MB</p> <p>• 文件限制:最大20MB</p>
</div> </div>
</div> </div>
</> </>
)} )}
</>
)}
<Button onClick={handleSaveEnhanceConfig}> <Button onClick={handleSaveEnhanceConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置 <Save className="mr-2 h-4 w-4" /> 保存配置
@ -1417,6 +1466,112 @@ export function LocalAiImagePipelineSections({
</Button> </Button>
</LocalAiSectionShell> </LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。
</>
}
expanded={isLocalAiSectionExpanded("canvas-local-edit")}
id="local-ai-section-canvas-local-edit"
onToggle={() => toggleLocalAiSection("canvas-local-edit")}
summary={
<LocalAiSectionSummary
items={[
{
label: canvasLocalEditConfig.modelId
? "普通局部编辑已指定"
: "普通局部编辑继承默认",
tone: canvasLocalEditConfig.modelId ? "enabled" : "neutral",
},
{
label: canvasLocalEditConfig.scaleAdjustModelId
? "比例调整已指定"
: "比例调整兼容旧默认",
tone: canvasLocalEditConfig.scaleAdjustModelId
? "enabled"
: "neutral",
},
]}
/>
}
title="局部编辑模型配置"
>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="canvasLocalEditModel">普通局部编辑默认模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
modelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.modelId || "__none__"}
>
<SelectTrigger id="canvasLocalEditModel">
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于手动画框并填写提示词的局部编辑。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasScaleAdjustModel">比例调整/目标框模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
scaleAdjustModelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
>
<SelectTrigger id="canvasScaleAdjustModel">
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。
</div>
</div>
</div>
<Button onClick={handleSaveCanvasLocalEditConfig}>
<Save className="mr-2 h-4 w-4" /> 保存局部编辑模型配置
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
@ -1447,6 +1602,12 @@ export function LocalAiImagePipelineSections({
label: aiWashConfig.modeLabel || "AI洗图", label: aiWashConfig.modeLabel || "AI洗图",
tone: "neutral", tone: "neutral",
}, },
{
label: aiWashConfig.hideModelSelector
? "前台隐藏模型"
: "前台可选模型",
tone: aiWashConfig.hideModelSelector ? "enabled" : "neutral",
},
]} ]}
/> />
} }
@ -1633,6 +1794,23 @@ export function LocalAiImagePipelineSections({
} }
/> />
</div> </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>
<div className="space-y-4 rounded-xl border p-4"> <div className="space-y-4 rounded-xl border p-4">
@ -1743,7 +1921,7 @@ export function LocalAiImagePipelineSections({
{availableImageModels.length > 0 ? ( {availableImageModels.length > 0 ? (
availableImageModels.map((model) => ( availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}> <SelectItem key={model.id} value={model.modelId}>
{model.displayName || model.modelId} {formatAiWashModelOptionLabel(model)}
</SelectItem> </SelectItem>
)) ))
) : ( ) : (
@ -2105,7 +2283,7 @@ export function LocalAiImagePipelineSections({
key={model.id} key={model.id}
value={model.modelId} value={model.modelId}
> >
{model.displayName || model.modelId} {formatAiWashModelOptionLabel(model)}
</SelectItem> </SelectItem>
)) ))
) : ( ) : (
@ -2188,7 +2366,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理 配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
</> </>
} }
expanded={isLocalAiSectionExpanded("extend")} expanded={isLocalAiSectionExpanded("extend")}
@ -2198,8 +2376,11 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary <LocalAiSectionSummary
items={[ items={[
{ {
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置", label:
tone: extendConfig.apiKey ? "enabled" : "warning", extendConfig.provider === "vip98k"
? "VIP98K 服务商"
: "阿里云服务商",
tone: "enabled",
}, },
{ {
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`, label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
@ -2210,22 +2391,18 @@ export function LocalAiImagePipelineSections({
} }
title="图像拓展API配置" title="图像拓展API配置"
> >
<div className="space-y-2"> {extendConfig.provider === "vip98k" ? (
<Label htmlFor="extendApiKey">API密钥 *</Label> <div className="rounded-lg border bg-muted/40 p-4 text-sm">
<Input 使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
id="extendApiKey" VIP98K 服务商读取,仅在这里维护扩展比例。
onChange={(e) =>
setExtendConfig({ ...extendConfig, apiKey: e.target.value })
}
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
type="password"
value={extendConfig.apiKey}
/>
<div className="text-muted-foreground text-xs">
请输入阿里云Dashscope API密钥,例如:
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</div> </div>
</div> ) : (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用阿里云 DashScope 的 image-out-painting 模型。API Key
从“AI 模型设置”中已启用的阿里云官方生图服务商读取,
仅在这里维护拓展比例。
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="extendXScale">X轴放大倍数 *</Label> <Label htmlFor="extendXScale">X轴放大倍数 *</Label>
@ -2279,7 +2456,7 @@ export function LocalAiImagePipelineSections({
https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
</p> </p>
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p> <p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
<p>• 最大图片大小:4MB</p> <p>• 最大图片大小:10MB</p>
<p>• 处理时间:约5-15秒/张</p> <p>• 处理时间:约5-15秒/张</p>
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p> <p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
</div> </div>
@ -2383,6 +2560,13 @@ export function LocalAiImagePipelineSections({
label: `${angleEditConfig.inferenceSteps} steps`, label: `${angleEditConfig.inferenceSteps} steps`,
tone: "neutral", tone: "neutral",
}, },
{
label: `${pointsSystemConfig.creditCostPerImageAngleEdit || 0} 积分/次`,
tone:
pointsSystemConfig.creditCostPerImageAngleEdit > 0
? "neutral"
: "disabled",
},
]} ]}
/> />
} }
@ -2453,6 +2637,28 @@ export function LocalAiImagePipelineSections({
<Separator /> <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 强度 */} {/* LoRA 强度 */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between"> <div className="flex justify-between">

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ export function SettingStatusBadge({
return ( return (
<Badge <Badge
className={cn( 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" && tone === "enabled" &&
"border-emerald-200 bg-emerald-50 text-emerald-700", "border-emerald-200 bg-emerald-50 text-emerald-700",
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500", 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, rmbg: false,
enhance: false, enhance: false,
"enhance-pro": false, "enhance-pro": false,
"canvas-local-edit": false,
"ai-wash": false, "ai-wash": false,
extend: false, extend: false,
"watermark-remove": false, "watermark-remove": false,

View File

@ -9,6 +9,7 @@ import {
MessageSquare, MessageSquare,
Scissors, Scissors,
Settings, Settings,
ShieldAlert,
Sparkles, Sparkles,
User, User,
} from "lucide-react"; } from "lucide-react";
@ -37,6 +38,12 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
description: "用量、趋势与运营指标", description: "用量、趋势与运营指标",
icon: BarChart3, 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 { LucideIcon } from "lucide-react";
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react"; import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config"; import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
import type { AiWashConfig } from "@/lib/ai-wash/config"; import type { AiWashConfig } from "@/lib/ai-wash/config";
import type { import type {
EnhanceProKreaConfig, EnhanceProKreaConfig,
@ -8,6 +9,7 @@ import type {
EnhanceProPreset, EnhanceProPreset,
} from "@/lib/enhance-pro/config"; } from "@/lib/enhance-pro/config";
import type { ThreeDModelAliasConfig } from "@/lib/model3d/model-alias-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 { ProductPlanningApiConfig } from "@/lib/product-planning/config";
import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config"; import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config";
import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections"; import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections";
@ -85,7 +87,12 @@ export type AdminAiProvider = {
| "bfl-image" | "bfl-image"
| "ruxa-image" | "ruxa-image"
| "fal-image" | "fal-image"
| "snapgen-image"
| "openai-image" | "openai-image"
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image" | "stepfun-image"
| "midjourney" | "midjourney"
| "fal-video" | "fal-video"
@ -167,6 +174,9 @@ export type AiProviderSettingsConfig = {
export type WorldLabsKeyPoolConfig = { export type WorldLabsKeyPoolConfig = {
importToken: string; importToken: string;
providerName: string;
baseUrl: string;
isEnabled: boolean;
}; };
export type LocalAiModel3dSectionsProps = LocalAiSectionControlProps & { export type LocalAiModel3dSectionsProps = LocalAiSectionControlProps & {
@ -239,7 +249,7 @@ export type EnhanceProKeyRow = {
}; };
export type RmbgConfig = { export type RmbgConfig = {
provider: "rmbg" | "photoroom" | "aoscdn"; provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
apiUrl: string; apiUrl: string;
apiKey: string; apiKey: string;
photoroomApiKey: string; photoroomApiKey: string;
@ -251,6 +261,7 @@ export type RmbgConfig = {
}; };
export type EnhanceConfig = { export type EnhanceConfig = {
provider: "vip98k" | "legacy";
apiUrl: string; apiUrl: string;
apiKey: string; apiKey: string;
type: string; type: string;
@ -263,7 +274,7 @@ export type EnhanceConfig = {
}; };
export type ExtendConfig = { export type ExtendConfig = {
apiKey: string; provider: "vip98k" | "dashscope";
xScale: string; xScale: string;
yScale: string; yScale: string;
}; };
@ -322,12 +333,19 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
aiWashConfig: AiWashConfig; aiWashConfig: AiWashConfig;
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>; setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
aiWashModeIconUploading: boolean; aiWashModeIconUploading: boolean;
canvasLocalEditConfig: CanvasLocalEditConfig;
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
extendConfig: ExtendConfig; extendConfig: ExtendConfig;
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>; setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
watermarkRemoveConfig: WatermarkRemoveConfig; watermarkRemoveConfig: WatermarkRemoveConfig;
setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>; setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>;
angleEditConfig: AngleEditConfig; angleEditConfig: AngleEditConfig;
setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>; setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>;
pointsSystemConfig: PointsSystemConfig;
updatePointsCostField: (
key: keyof PointsSystemConfig,
value: unknown
) => void;
layerSplitConfig: LayerSplitConfig; layerSplitConfig: LayerSplitConfig;
setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>; setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>;
renderEnhanceProPresetEditor: ( renderEnhanceProPresetEditor: (
@ -359,6 +377,7 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void; handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
handleAiWashModeIconUpload: (file: File) => Promise<void>; handleAiWashModeIconUpload: (file: File) => Promise<void>;
handleSaveAiWashConfig: AsyncAction; handleSaveAiWashConfig: AsyncAction;
handleSaveCanvasLocalEditConfig: AsyncAction;
handleSaveExtendConfig: AsyncAction; handleSaveExtendConfig: AsyncAction;
handleSaveWatermarkRemoveConfig: AsyncAction; handleSaveWatermarkRemoveConfig: AsyncAction;
handleSaveAngleEditConfig: AsyncAction; handleSaveAngleEditConfig: AsyncAction;

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: "风格化专用默认模型", agentWorkflowDefaultModelId: "风格化专用默认模型",
agentPresetLibraryConfig: "预设库", agentPresetLibraryConfig: "预设库",
agentWorkflowUiConfig: "工作流步骤", agentWorkflowUiConfig: "工作流步骤",
agentWorkflowMaterialApiConfig: "素材搜索接口", agentWorkflowMaterialApiConfig: "第三方图片素材",
agentWorkflowGoodsInfoApiConfig: "商品信息接口", agentWorkflowGoodsInfoApiConfig: "商品信息接口",
agentWorkflowBrandModelConfig: "品牌模特", agentWorkflowBrandModelConfig: "品牌模特",
agentWorkflowShowcaseConfig: "页面展示", agentWorkflowShowcaseConfig: "页面展示",
@ -5933,7 +5933,7 @@ export function AgentWorkflowSettingsClient({
if (isLoading) { if (isLoading) {
return ( 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 h-[60vh] items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground"> <div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
@ -5945,18 +5945,18 @@ export function AgentWorkflowSettingsClient({
} }
return ( 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 */}
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <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="container mx-auto px-4 py-3 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<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"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
<Sparkles className="h-5 w-5 text-white" /> <Sparkles className="h-4 w-4 text-background" />
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold tracking-tight">风格化工作流配置</h1> <h1 className="text-base font-semibold">风格化工作流配置</h1>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤 配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
</p> </p>
</div> </div>
@ -6006,7 +6006,6 @@ export function AgentWorkflowSettingsClient({
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
size="sm" size="sm"
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500"
> >
{isSaving ? ( {isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -6142,8 +6141,8 @@ export function AgentWorkflowSettingsClient({
{/* Stats Bar */} {/* Stats Bar */}
<div className="border-b bg-muted/30"> <div className="border-b bg-muted/30">
<div className="container mx-auto px-6 py-3"> <div className="container mx-auto px-4 py-2.5 sm:px-5">
<div className="flex flex-wrap items-center gap-6"> <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono"> <Badge variant="secondary" className="font-mono">
{enabledStepsCount} {enabledStepsCount}
@ -6197,8 +6196,8 @@ export function AgentWorkflowSettingsClient({
</div> </div>
{/* Main Content */} {/* Main Content */}
<main className="container mx-auto p-6"> <main className="container mx-auto p-4 sm:px-5">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="grid w-full grid-cols-5 lg:w-fit"> <TabsList className="grid w-full grid-cols-5 lg:w-fit">
<TabsTrigger value="showcase" className="gap-2"> <TabsTrigger value="showcase" className="gap-2">
<MonitorPlay className="h-4 w-4" /> <MonitorPlay className="h-4 w-4" />
@ -6859,17 +6858,19 @@ export function AgentWorkflowSettingsClient({
<Search className="h-4 w-4 text-emerald-600" /> <Search className="h-4 w-4 text-emerald-600" />
</div> </div>
<div> <div>
<CardTitle className="text-base">素材搜索接口</CardTitle> <CardTitle className="text-base">第三方图片素材配置</CardTitle>
<CardDescription>配置白底图、详情参考图、SKU 参考图共用接口</CardDescription> <CardDescription>
配置白底图、任务-详情整套预览、详情参考图和 SKU 参考图共用接口
</CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3"> <div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div> <div>
<div className="text-sm font-medium">启用素材搜索</div> <div className="text-sm font-medium">启用第三方图片素材</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
开启后支持搜索白底素材图、详情参考图和 SKU 参考图 开启后前台和预留接口都可以查询标准模型白底图与任务-详情整套素材
</div> </div>
</div> </div>
<Switch <Switch
@ -6890,6 +6891,22 @@ export function AgentWorkflowSettingsClient({
placeholder="https://..." placeholder="https://..."
/> />
</div> </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"> <div className="space-y-2">
<Label className="text-xs">API Key</Label> <Label className="text-xs">API Key</Label>
<Input <Input
@ -6902,6 +6919,104 @@ export function AgentWorkflowSettingsClient({
/> />
</div> </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 /> <Separator />
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3"> <div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">

View File

@ -95,6 +95,8 @@ const SYSTEM_FONT_OPTIONS = [
const EMPTY_CATEGORY_SELECT_VALUE = "__none__"; const EMPTY_CATEGORY_SELECT_VALUE = "__none__";
const EMPTY_BRAND_SELECT_VALUE = "__unassigned__"; const EMPTY_BRAND_SELECT_VALUE = "__unassigned__";
const ALL_TEMPLATE_BRANDS_SELECT_VALUE = "__all_template_brands__";
const SELF_CLIP_MASK_SELECT_VALUE = "__self_clip_mask__";
const UNASSIGNED_BRAND_LABEL = "未分类"; const UNASSIGNED_BRAND_LABEL = "未分类";
const MAX_PSD_TEMPLATE_FILE_SIZE = 80 * 1024 * 1024; const MAX_PSD_TEMPLATE_FILE_SIZE = 80 * 1024 * 1024;
const PSD_TEMPLATE_MODE_OPTIONS: Array<{ const PSD_TEMPLATE_MODE_OPTIONS: Array<{
@ -366,6 +368,7 @@ export function PsdTemplatesSettingsClient({
const [templateBrandDrafts, setTemplateBrandDrafts] = useState<Record<string, string>>({}); const [templateBrandDrafts, setTemplateBrandDrafts] = useState<Record<string, string>>({});
const [fontBrandDrafts, setFontBrandDrafts] = useState<Record<string, string>>({}); const [fontBrandDrafts, setFontBrandDrafts] = useState<Record<string, string>>({});
const [parsingTemplateIds, setParsingTemplateIds] = useState<string[]>([]); const [parsingTemplateIds, setParsingTemplateIds] = useState<string[]>([]);
const [templateBrandFilter, setTemplateBrandFilter] = useState(ALL_TEMPLATE_BRANDS_SELECT_VALUE);
const selectedTemplate = useMemo( const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) || null, () => templates.find((template) => template.id === selectedTemplateId) || null,
@ -444,9 +447,42 @@ export function PsdTemplatesSettingsClient({
const currentBrand = normalizeManagedBrandName(draftTemplate?.brandName); const currentBrand = normalizeManagedBrandName(draftTemplate?.brandName);
return Array.from(new Set([...managedBrandOptions, ...(currentBrand ? [currentBrand] : [])])); return Array.from(new Set([...managedBrandOptions, ...(currentBrand ? [currentBrand] : [])]));
}, [draftTemplate?.brandName, managedBrandOptions]); }, [draftTemplate?.brandName, managedBrandOptions]);
const templateBrandFilterOptions = useMemo(() => {
const brands = new Set<string>();
let hasUnassigned = false;
templates.forEach((template) => {
const brand = normalizeManagedBrandName(template.brandName);
if (brand) {
brands.add(brand);
} else {
hasUnassigned = true;
}
});
return {
brands: Array.from(brands).sort((left, right) => left.localeCompare(right, "zh-CN")),
hasUnassigned,
};
}, [templates]);
const filteredTemplates = useMemo(() => {
if (templateBrandFilter === ALL_TEMPLATE_BRANDS_SELECT_VALUE) {
return templates;
}
if (templateBrandFilter === EMPTY_BRAND_SELECT_VALUE) {
return templates.filter((template) => !normalizeManagedBrandName(template.brandName));
}
return templates.filter(
(template) =>
normalizeManagedBrandName(template.brandName).toLowerCase() ===
templateBrandFilter.toLowerCase()
);
}, [templateBrandFilter, templates]);
const groupedTemplates = useMemo(() => { const groupedTemplates = useMemo(() => {
const grouped = new Map<string, AgentPsdTemplate[]>(); const grouped = new Map<string, AgentPsdTemplate[]>();
templates.forEach((template) => { filteredTemplates.forEach((template) => {
const label = normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL; const label = normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL;
const current = grouped.get(label) || []; const current = grouped.get(label) || [];
current.push(template); current.push(template);
@ -463,7 +499,7 @@ export function PsdTemplatesSettingsClient({
label, label,
items, items,
})); }));
}, [templates]); }, [filteredTemplates]);
const groupedFonts = useMemo(() => { const groupedFonts = useMemo(() => {
const grouped = new Map<string, AgentPsdFont[]>(); const grouped = new Map<string, AgentPsdFont[]>();
fonts.forEach((font) => { fonts.forEach((font) => {
@ -1644,7 +1680,22 @@ export function PsdTemplatesSettingsClient({
: "当前筛选条件下没有图层,请换个关键词试试。"} : "当前筛选条件下没有图层,请换个关键词试试。"}
</div> </div>
) : ( ) : (
filteredLayers.map((layer, index) => ( filteredLayers.map((layer, index) => {
const clipMaskLayerOptions =
layer.slotType === "image"
? draftTemplate.layers.filter(
(candidate) =>
candidate.id !== layer.id &&
(candidate.previewUrl || candidate.sourceUrl) &&
!(
candidate.slotType === "text" &&
!candidate.previewUrl &&
!candidate.sourceUrl
)
)
: [];
return (
<div key={layer.id} className="rounded-2xl border p-4"> <div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
@ -1798,6 +1849,12 @@ export function PsdTemplatesSettingsClient({
allowUserUploadOverride: checked allowUserUploadOverride: checked
? false ? false
: current.allowUserUploadOverride, : current.allowUserUploadOverride,
forceClipMask: checked
? false
: current.forceClipMask,
clipMaskLayerId: checked
? undefined
: current.clipMaskLayerId,
}; };
} }
@ -1834,6 +1891,12 @@ export function PsdTemplatesSettingsClient({
allowUserUploadOverride: checked allowUserUploadOverride: checked
? current.allowUserUploadOverride ? current.allowUserUploadOverride
: false, : false,
forceClipMask: checked
? current.forceClipMask
: false,
clipMaskLayerId: checked
? current.clipMaskLayerId
: undefined,
} }
: { : {
...current, ...current,
@ -1949,6 +2012,84 @@ export function PsdTemplatesSettingsClient({
/> />
</div> </div>
<div className="flex items-center justify-between rounded-xl border p-3">
<div className="space-y-0.5">
<div className="text-sm font-medium">强制剪切蒙版</div>
<div className="text-xs text-muted-foreground">
开启后替换图会按指定目标图层的透明区域剪切,适合替换图需要贴合超级锁或固定形状的图片槽位。
</div>
</div>
<Switch
checked={layer.isEditable && layer.forceClipMask === true}
disabled={!layer.isEditable || layer.isSuperLocked === true}
onCheckedChange={(checked) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
forceClipMask: current.isEditable
? checked
: false,
clipMaskLayerId:
current.isEditable && checked
? current.clipMaskLayerId
: undefined,
}
: current
)
}
/>
</div>
{layer.forceClipMask ? (
<div className="space-y-2 rounded-xl border p-3 md:col-span-2 xl:col-span-3">
<div className="text-sm font-medium">剪切蒙版目标图层</div>
<Select
value={
layer.clipMaskLayerId ||
SELF_CLIP_MASK_SELECT_VALUE
}
disabled={
!layer.isEditable ||
layer.isSuperLocked === true
}
onValueChange={(value) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
clipMaskLayerId:
value === SELF_CLIP_MASK_SELECT_VALUE
? undefined
: value,
}
: current
)
}
>
<SelectTrigger>
<SelectValue placeholder="选择用于裁切的目标图层" />
</SelectTrigger>
<SelectContent>
<SelectItem value={SELF_CLIP_MASK_SELECT_VALUE}>
使用当前替换图层自身透明区域
</SelectItem>
{clipMaskLayerOptions.map((candidate) => (
<SelectItem
key={`${layer.id}-clip-mask-${candidate.id}`}
value={candidate.id}
>
{candidate.layerName} · {candidate.width} x {candidate.height}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
如果 PSD 里目标形状是超级锁图层,请在这里选择那个目标图层;前台只能移动替换图,目标图仍保持锁定不可选中。
</div>
</div>
) : null}
<div className="space-y-2 rounded-xl border p-3"> <div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">填充模式</div> <div className="text-sm font-medium">填充模式</div>
<Select <Select
@ -2261,7 +2402,8 @@ export function PsdTemplatesSettingsClient({
</div> </div>
)} )}
</div> </div>
)) );
})
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
@ -2288,17 +2430,17 @@ export function PsdTemplatesSettingsClient({
); );
return ( 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"> <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="container mx-auto px-4 py-3 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<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"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
<Sparkles className="h-5 w-5 text-white" /> <Sparkles className="h-4 w-4 text-background" />
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold tracking-tight">PSD 模板配置</h1> <h1 className="text-base font-semibold">PSD 模板配置</h1>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出 上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出
</p> </p>
</div> </div>
@ -2315,7 +2457,6 @@ export function PsdTemplatesSettingsClient({
onClick={handleSaveTemplate} onClick={handleSaveTemplate}
disabled={!draftTemplate || isSaving} disabled={!draftTemplate || isSaving}
size="sm" size="sm"
className="bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
> >
{isSaving ? ( {isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -2330,8 +2471,8 @@ export function PsdTemplatesSettingsClient({
</header> </header>
<div className="border-b bg-muted/30"> <div className="border-b bg-muted/30">
<div className="container mx-auto px-6 py-3"> <div className="container mx-auto px-4 py-2.5 sm:px-5">
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary" className="font-mono"> <Badge variant="secondary" className="font-mono">
{templates.length} {templates.length}
</Badge> </Badge>
@ -2367,8 +2508,8 @@ export function PsdTemplatesSettingsClient({
</div> </div>
</div> </div>
<div className="container mx-auto px-6 py-6"> <div className="container mx-auto px-4 py-4 sm:px-5">
<div className="space-y-6"> <div className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">品类标签</CardTitle> <CardTitle className="text-base">品类标签</CardTitle>
@ -2439,7 +2580,10 @@ export function PsdTemplatesSettingsClient({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">上传新模板</CardTitle> <CardTitle className="text-base">上传新模板</CardTitle>
<CardDescription>支持 PSD / PSB,建议图层命名清晰,便于后台配置图片位和文字位。</CardDescription> <CardDescription>
支持 PSD / PSB,建议图层命名清晰,便于后台配置图片位和文字位。复杂 PSB
如提示不兼容,请先在 Photoshop 中另存为 PSD 后上传。
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@ -2929,9 +3073,44 @@ export function PsdTemplatesSettingsClient({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">模板列表</CardTitle> <CardTitle className="text-base">模板列表</CardTitle>
<CardDescription>按品牌查看模板,选择一个模板继续配置槽位。</CardDescription> <CardDescription>按品牌筛选模板,选择一个模板继续配置槽位。</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex flex-col gap-3 rounded-2xl border bg-muted/20 p-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">品牌筛选</div>
<div className="text-xs text-muted-foreground">
当前显示 {filteredTemplates.length} / {templates.length} 个模板
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Select value={templateBrandFilter} onValueChange={setTemplateBrandFilter}>
<SelectTrigger className="w-full sm:w-[260px]">
<SelectValue placeholder="选择品牌" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_TEMPLATE_BRANDS_SELECT_VALUE}>全部品牌</SelectItem>
{templateBrandFilterOptions.hasUnassigned ? (
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>未分类</SelectItem>
) : null}
{templateBrandFilterOptions.brands.map((brand) => (
<SelectItem key={`template-brand-filter-${brand}`} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
{templateBrandFilter !== ALL_TEMPLATE_BRANDS_SELECT_VALUE ? (
<Button
type="button"
variant="outline"
onClick={() => setTemplateBrandFilter(ALL_TEMPLATE_BRANDS_SELECT_VALUE)}
>
清除筛选
</Button>
) : null}
</div>
</div>
{templateListErrorMessage && templates.length > 0 ? ( {templateListErrorMessage && templates.length > 0 ? (
<div className="flex items-start gap-3 rounded-xl border border-amber-300/30 bg-amber-500/5 p-4 text-sm text-amber-100"> <div className="flex items-start gap-3 rounded-xl border border-amber-300/30 bg-amber-500/5 p-4 text-sm text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
@ -2977,6 +3156,10 @@ export function PsdTemplatesSettingsClient({
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground"> <div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
还没有 PSD 模板,先上传一个模板开始配置。 还没有 PSD 模板,先上传一个模板开始配置。
</div> </div>
) : filteredTemplates.length === 0 ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
当前品牌下没有 PSD 模板,可以切换品牌或清除筛选。
</div>
) : ( ) : (
groupedTemplates.map((group) => ( groupedTemplates.map((group) => (
<div key={group.label} className="space-y-3 rounded-2xl border bg-background/50 p-3"> <div key={group.label} className="space-y-3 rounded-2xl border bg-background/50 p-3">

View File

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

View File

@ -1,5 +1,8 @@
"use client"; "use client";
import { filterCanvasModels } from "@/lib/video-canvas/model-matching";
import Link from "next/link";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { toast } from "sonner"; import { toast } from "sonner";
@ -12,6 +15,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -21,15 +25,14 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { fetcher } from "@/lib/utils"; import { fetcher } from "@/lib/utils";
import { import {
getVideoCanvasFunctionBillingCopy, getVideoCanvasFunctionBillingCopy,
type VideoCanvasFunctionConfig, type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition, type VideoCanvasFunctionDefinition,
type VideoCanvasEffectPreset,
type VideoCanvasMotionPreset,
type VideoCanvasRolePreset,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY, VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig, defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig, normalizeVideoCanvasFunctionConfig,
@ -81,56 +84,23 @@ function getModelOptionLabel(model: AiModelOption) {
return `${name}${id}`; return `${name}${id}`;
} }
function isImageModelOption(model: AiModelOption) {
return isImageGenerationModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function isVideoModelOption(model: AiModelOption) {
return isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function filterModelsForFunctionKind(
models: AiModelOption[],
kind: VideoCanvasFunctionDefinition["kind"],
) {
const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model));
let filtered = enabled;
if (kind === "image" || kind === "image-edit") {
filtered = enabled.filter(isImageModelOption);
} else if (kind === "video") {
filtered = enabled.filter(isVideoModelOption);
} else if (kind === "multimodal") {
filtered = enabled.filter(
(model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)),
);
}
return filtered.length ? filtered : enabled;
}
function ModelSelect({ function ModelSelect({
allowInherit = false, allowInherit = false,
kind, kind,
functionKey,
models, models,
onChange, onChange,
value, value,
}: { }: {
allowInherit?: boolean; allowInherit?: boolean;
kind: VideoCanvasFunctionDefinition["kind"]; kind: VideoCanvasFunctionDefinition["kind"];
functionKey?: string;
models: AiModelOption[]; models: AiModelOption[];
onChange: (value: string) => void; onChange: (value: string) => void;
value: string; value: string;
}) { }) {
const filteredModels = filterModelsForFunctionKind(models, kind); const modelKind = functionKey === "audio-to-video" ? "audio-video" : kind === "image" || kind === "image-edit" || kind === "video" || kind === "multimodal" ? kind : "text";
const filteredModels = filterCanvasModels(models, modelKind);
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto"); const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
const hasCurrent = const hasCurrent =
normalizedValue === "auto" || normalizedValue === "auto" ||
@ -199,8 +169,9 @@ export function VideoCanvasSettingsClient() {
const functions = useMemo( const functions = useMemo(
() => () =>
Object.values(config.functions).sort((a, b) => { Object.values(config.functions).sort((a, b) => {
const kindCompare = a.kind.localeCompare(b.kind); const compare = (left: string, right: string) =>
return kindCompare || a.label.localeCompare(b.label); left === right ? 0 : left < right ? -1 : 1;
return compare(a.kind, b.kind) || compare(a.label, b.label);
}), }),
[config.functions], [config.functions],
); );
@ -225,6 +196,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 () => { const save = async () => {
setSaving(true); setSaving(true);
try { try {
@ -255,11 +355,17 @@ export function VideoCanvasSettingsClient() {
}; };
return ( return (
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8"> <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-4 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">视频画布功能设置</h1> <Button asChild className="mb-2" size="sm" variant="outline">
<p className="mt-2 max-w-3xl text-sm text-muted-foreground"> <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> </p>
</div> </div>
@ -321,6 +427,354 @@ export function VideoCanvasSettingsClient() {
</CardContent> </CardContent>
</Card> </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"> <section className="space-y-4">
<div> <div>
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2> <h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
@ -361,13 +815,14 @@ export function VideoCanvasSettingsClient() {
<div className="grid gap-4 md:grid-cols-1"> <div className="grid gap-4 md:grid-cols-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label> <Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
<ModelSelect {["canvas-generate-image", "canvas-generate-video", "canvas-workflow-analyze", "pick-object"].includes(item.route) ? <ModelSelect
allowInherit allowInherit
functionKey={item.key}
kind={item.kind} kind={item.kind}
models={modelOptions} models={modelOptions}
onChange={(value) => updateFunction(item.key, { model: value })} onChange={(value) => updateFunction(item.key, { model: value })}
value={item.model || ""} value={item.model || ""}
/> /> : <p className="text-sm text-muted-foreground">{item.route === "local" ? "本地处理,无需生成模型" : "使用此功能的专用服务接口"}</p>}
</div> </div>
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2"> <div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
<div className="text-xs font-medium text-foreground"> <div className="text-xs font-medium text-foreground">

View File

@ -17,6 +17,7 @@ import {
requiresSingleActiveSession, requiresSingleActiveSession,
revokeSingleActiveSession, revokeSingleActiveSession,
} from "@/lib/auth/session-control"; } from "@/lib/auth/session-control";
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
export type UserType = "guest" | "regular" | "admin" | "agent_admin"; export type UserType = "guest" | "regular" | "admin" | "agent_admin";
@ -200,7 +201,10 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
} }
const nextAuth = NextAuth(async () => { 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 = const resolvedAuthUrl =
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined; (await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
const googleClientId = const googleClientId =
@ -283,6 +287,32 @@ const nextAuth = NextAuth(async () => {
delete token.sessionKey; 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; return token;
}, },
session({ session, token }) { session({ session, token }) {

View File

@ -501,11 +501,6 @@ export default function Page() {
const displayLogo = mounted const displayLogo = mounted
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo) ? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
: siteLogo; : siteLogo;
const shouldInvertLogo =
mounted &&
resolvedTheme === "dark" &&
!siteLogoDark?.trim() &&
Boolean(siteLogo?.trim());
const hasThirdPartyLogin = const hasThirdPartyLogin =
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) || (isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
(isGoogleEnabled && isGoogleConfigured); (isGoogleEnabled && isGoogleConfigured);
@ -545,7 +540,7 @@ export default function Page() {
<img <img
src={displayLogo} src={displayLogo}
alt={siteName} 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} ) : null}
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3> <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 dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "HiCAD",
};
export const runtime = "nodejs";
export default function AiCadPage() { export default async function AiCadPage() {
return <AiCadWorkbench />; 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(); .trim();
const knownTitles = [ const knownTitles = [
"意图分析",
"任务规划",
"需求理解", "需求理解",
"图片分析", "图片分析",
"执行计划", "执行计划",

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,8 +1,11 @@
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image"; import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media"; import { convertMessagesWithImages } from "./image-media";
import { isProviderBalanceError } from "@/lib/request-error";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
const chatApiLogger = createLogger("chat.imageFailover"); const chatApiLogger = createLogger("chat.imageFailover");
@ -12,7 +15,12 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"bfl-image", "bfl-image",
"ruxa-image", "ruxa-image",
"fal-image", "fal-image",
"snapgen-image",
"openai-image", "openai-image",
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image", "stepfun-image",
"runway", "runway",
"krea", "krea",
@ -25,10 +33,47 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000; const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000;
const imageFailoverModelCooldown = new Map<string, { until: number; reason: string }>(); 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 { export function normalizeFailoverErrorMessage(error: unknown): string {
if (!error) return ""; if (!error) return "";
if (typeof error === "string") return error; if (typeof error === "string") return error;
if (error instanceof Error) return error.message || String(error); if (error instanceof Error) {
const record = error as Error & {
status?: unknown;
statusCode?: unknown;
httpStatus?: unknown;
response?: { status?: unknown };
};
const status =
record.status ??
record.statusCode ??
record.httpStatus ??
record.response?.status;
const message = error.message || String(error);
return status ? `[HTTP ${String(status)}] ${message}` : message;
}
try { try {
return JSON.stringify(error); return JSON.stringify(error);
} catch { } catch {
@ -39,6 +84,7 @@ export function normalizeFailoverErrorMessage(error: unknown): string {
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean { export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
const msg = errorMessage.toLowerCase(); const msg = errorMessage.toLowerCase();
return ( return (
isProviderBalanceError(errorMessage) ||
msg.includes("model not found") || msg.includes("model not found") ||
msg.includes("model_not_found") || msg.includes("model_not_found") ||
msg.includes("no available channel for model") || msg.includes("no available channel for model") ||
@ -47,7 +93,23 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean
msg.includes("api key is missing") || msg.includes("api key is missing") ||
msg.includes("invalid api key") || msg.includes("invalid api key") ||
msg.includes("unauthorized") || msg.includes("unauthorized") ||
msg.includes("cannot convert argument to a bytestring") /(?:^|\D)401(?:\D|$)/.test(msg) ||
msg === "forbidden" ||
msg.includes("access forbidden") ||
msg.includes("access denied") ||
msg.includes("permission denied") ||
/(?:^|\D)403(?:\D|$)/.test(msg) ||
msg.includes("vip98k task failed") ||
msg.includes("cannot convert argument to a bytestring") ||
msg.includes("model maintenance") ||
msg.includes("maintenance") ||
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 +139,142 @@ export type ImageFallbackCandidate = {
provider: any; provider: any;
configured: boolean; configured: boolean;
sameProvider: 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) { export function getImageFailoverConvertProvider(providerType?: string | null) {
switch (providerType) { switch (providerType) {
case "bfl-image": case "bfl-image":
case "ruxa-image": case "ruxa-image":
case "fal-image": case "fal-image":
case "snapgen-image":
case "openai-image": case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "stepfun-image": case "stepfun-image":
case "midjourney": case "midjourney":
case "runway": case "runway":
@ -111,6 +301,10 @@ export function buildImageFallbackCandidates(
} }
) { ) {
const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options; const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options;
const currentEntry = resolveCurrentImageModelEntry(allModels, [
currentModelId || "",
currentModelKey || "",
]);
const configuredRefs = configuredFallbackIds const configuredRefs = configuredFallbackIds
.map((item) => String(item || "").trim()) .map((item) => String(item || "").trim())
.filter(Boolean); .filter(Boolean);
@ -132,9 +326,9 @@ export function buildImageFallbackCandidates(
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) { if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false; return false;
} }
if (isImageFallbackModelCoolingDown(item.model.id)) { if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
const cooldown = imageFailoverModelCooldown.get(item.model.id); const cooldown = imageFailoverModelCooldown.get(item.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; return false;
} }
return true; return true;
@ -150,6 +344,7 @@ export function buildImageFallbackCandidates(
const candidates: ImageFallbackCandidate[] = []; const candidates: ImageFallbackCandidate[] = [];
const seenModelIds = new Set<string>(); const seenModelIds = new Set<string>();
const candidateOrder = new Map<string, number>();
const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => { const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => {
if (!item?.model) return; if (!item?.model) return;
const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId); const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId);
@ -157,11 +352,15 @@ export function buildImageFallbackCandidates(
const modelId = String(item.model.id || ""); const modelId = String(item.model.id || "");
if (!modelId || seenModelIds.has(modelId)) return; if (!modelId || seenModelIds.has(modelId)) return;
seenModelIds.add(modelId); seenModelIds.add(modelId);
candidateOrder.set(modelId, candidateOrder.size);
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
candidates.push({ candidates.push({
model: item.model, model: item.model,
provider: item.provider, provider: item.provider,
configured, configured,
sameProvider, sameProvider,
nameSimilarity,
sameName: nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
}); });
}; };
@ -175,7 +374,24 @@ export function buildImageFallbackCandidates(
.forEach((item) => pushCandidate(item, false)); .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: { export async function generateImageWithFailoverCandidate(params: {
@ -186,14 +402,25 @@ export async function generateImageWithFailoverCandidate(params: {
}) { }) {
const { candidate, messages, imageParams, fallbackImageParams } = params; const { candidate, messages, imageParams, fallbackImageParams } = params;
const providerType = String(candidate.provider?.type || ""); 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 ( if (
providerType === "fal-image" && providerType === "fal-image" &&
isFalGptImage2EditModelId(candidateRuntimeModelId) isFalGptImage2EditModelId(candidateRuntimeModelId)
) { ) {
throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移"); 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") { if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`); throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`);
} }
@ -202,11 +429,24 @@ export async function generateImageWithFailoverCandidate(params: {
messages, messages,
getImageFailoverConvertProvider(providerType) 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({ const candidateResult = await (candidateLanguageModel as any).doGenerate({
messages: candidateMessages, messages: candidateMessages,
imageParams: imageParams || fallbackImageParams, imageParams: effectiveImageParams,
imageGenConfig: candidate.model.imageGenConfig || null,
modelDisplayName: candidate.model.displayName, modelDisplayName: candidate.model.displayName,
overrideModelId: candidate.model.modelId, overrideModelId: candidateRuntimeModelId || candidate.model.modelId,
}); });
if (candidateResult?.finishReason === "error") { if (candidateResult?.finishReason === "error") {
@ -214,7 +454,7 @@ export async function generateImageWithFailoverCandidate(params: {
} }
return { return {
text: candidateResult.text || "图片生成失败", text: candidateResult.text || "",
usedModelId: String(candidate.model.modelId || candidate.model.id || ""), usedModelId: String(candidate.model.modelId || candidate.model.id || ""),
displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""), displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""),
}; };
@ -224,10 +464,15 @@ export function getImageCrossProviderCandidates(
allModels: any[], allModels: any[],
options: { options: {
currentModelId: string; currentModelId: string;
currentModelKey?: string;
currentProviderId?: string; currentProviderId?: string;
providerPriority: Record<string, number>; providerPriority: Record<string, number>;
} }
) { ) {
const currentEntry = resolveCurrentImageModelEntry(allModels, [
options.currentModelId,
options.currentModelKey || "",
]);
return allModels return allModels
.filter((m) => { .filter((m) => {
if ( if (
@ -243,17 +488,44 @@ export function getImageCrossProviderCandidates(
} }
if (m.model.id === options.currentModelId) return false; if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false; if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id)) { if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id); const cooldown = imageFailoverModelCooldown.get(m.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; return false;
} }
return true; return true;
}) })
.map((item, index) => {
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
return {
...item,
__imageFailoverIndex: index,
__imageFailoverNameSimilarity: nameSimilarity,
__imageFailoverSameName:
nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
};
})
.sort((a, b) => { .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 left = options.providerPriority[a.provider.type as string] ?? 99;
const right = options.providerPriority[b.provider.type as string] ?? 99; const right = options.providerPriority[b.provider.type as string] ?? 99;
if (left !== right) return left - right; 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 [ return [
"### 需求理解", "### 意图分析",
`- ${demand}`, `- ${demand}`,
"### 图片分析", "### 图片分析",
params.imageSummary params.imageSummary
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}` ? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
: `- ${sourceLine}`, : `- ${sourceLine}`,
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。", "- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
"### 执行计划", "### 任务规划",
"- 先完成本轮核心改动,再检查边缘与光影一致性。", "- 先完成本轮核心改动,再检查边缘与光影一致性。",
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。", "- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
"### 输出设置", "### 输出设置",
@ -154,9 +154,9 @@ export function buildAutoModeFallbackMessage(params: {
} }
return [ return [
"### 需求理解", "### 意图分析",
`- ${demand}`, `- ${demand}`,
"### 执行计划", "### 任务规划",
"- 先锁定主体与场景,再细化风格、材质与光影。", "- 先锁定主体与场景,再细化风格、材质与光影。",
"### 输出设置", "### 输出设置",
"- 默认单图输出,优先稳定命中。", "- 默认单图输出,优先稳定命中。",
@ -222,7 +222,7 @@ export function buildPromptOnlyReply(params: {
export function hasStructuredAgentSections(message?: string): boolean { export function hasStructuredAgentSections(message?: string): boolean {
if (!message) return false; 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, message,
); );
} }
@ -253,6 +253,13 @@ export function ensureStructuredAgentFriendlyMessage(params: {
return fallback; return fallback;
} }
if (fallback.includes("### 任务规划\n")) {
return fallback.replace(
"### 任务规划\n",
`### 任务规划\n- 补充说明:${normalizedSupplement}\n`,
);
}
return fallback.replace( return fallback.replace(
"### 执行计划\n", "### 执行计划\n",
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`, `### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
@ -515,7 +522,12 @@ export async function convertMessagesWithImages(
normalizedProviderType === "bfl-image" || normalizedProviderType === "bfl-image" ||
normalizedProviderType === "ruxa-image" || normalizedProviderType === "ruxa-image" ||
normalizedProviderType === "fal-image" || normalizedProviderType === "fal-image" ||
normalizedProviderType === "snapgen-image" ||
normalizedProviderType === "openai-image" || normalizedProviderType === "openai-image" ||
normalizedProviderType === "bananaimage" ||
normalizedProviderType === "visionary" ||
normalizedProviderType === "atlascloud" ||
normalizedProviderType === "vip98k" ||
normalizedProviderType === "stepfun-image" || normalizedProviderType === "stepfun-image" ||
normalizedProviderType === "midjourney" || normalizedProviderType === "midjourney" ||
normalizedProviderType === "runway" || normalizedProviderType === "runway" ||
@ -581,45 +593,71 @@ export async function convertMessagesWithImages(
if (hasAnnotation) { if (hasAnnotation) {
chatApiLogger.info("[Message Convert] 🎨 检测到标注图片,将自动添加系统提示"); chatApiLogger.info("[Message Convert] 🎨 检测到标注图片,将自动添加系统提示");
} }
// 对于 Gemini,需要特殊处理图片 // 对于 Gemini,需要特殊处理图片
const convertedMessages = []; const convertedMessages = [];
for (const message of preparedMessages) { for (const message of preparedMessages) {
const content: any[] = []; const content: any[] = [];
// 🆕 如果是用户消息且有标注图片,在文本前添加系统提示 // 🆕 如果是用户消息且有标注图片,在文本前添加系统提示
let hasAddedAnnotationPrompt = false; let hasAddedAnnotationPrompt = false;
// 处理消息的各个部分 // 处理消息的各个部分
for (const part of message.parts) { for (const part of message.parts) {
if (part.type === "text" && part.text) { if (part.type === "text" && part.text) {
let textContent = part.text; let textContent = part.text;
// 🆕 如果有标注图片且是用户消息,在用户文本前添加系统提示 // 🆕 如果有标注图片且是用户消息,在用户文本前添加系统提示
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt) { if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt) {
textContent = ANNOTATION_SYSTEM_PROMPT + "\n\n用户请求:" + textContent; textContent = ANNOTATION_SYSTEM_PROMPT + "\n\n用户请求:" + textContent;
hasAddedAnnotationPrompt = true; hasAddedAnnotationPrompt = true;
chatApiLogger.info("[Message Convert] 🎨 已注入标注提示词"); chatApiLogger.info("[Message Convert] 🎨 已注入标注提示词");
} }
content.push({ content.push({
type: "text", type: "text",
text: textContent, text: textContent,
}); });
} else if (part.type === "file" && part.url) { continue;
// 直接使用 URL 格式(不再转换为 Base64) }
// gemini-image 和 huanke-gemini provider 内部会处理图片上传
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({ content.push({
type: "image_url", type: "image_url",
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) { if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt && content.length > 0) {
content.unshift({ content.unshift({
@ -628,12 +666,10 @@ export async function convertMessagesWithImages(
}); });
chatApiLogger.info("[Message Convert] 🎨 已在消息开头注入标注提示词"); chatApiLogger.info("[Message Convert] 🎨 已在消息开头注入标注提示词");
} }
convertedMessages.push({ convertedMessages.push({
role: message.role, role: message.role,
content: content.length === 1 && content[0].type === "text" content: content.length === 1 && content[0].type === "text" ? content[0].text : content,
? content[0].text
: content,
}); });
} }

View File

@ -6,6 +6,8 @@ import type {
ResolvedImageRef, ResolvedImageRef,
ResolvedTargetAssets, ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types"; } 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"; import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.stream"); 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( export function writeProtocolDebugChunk(
writer: StreamWriter, writer: StreamWriter,
enabled: boolean, enabled: boolean,
@ -86,7 +186,12 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
normalized === "bfl-image" || normalized === "bfl-image" ||
normalized === "ruxa-image" || normalized === "ruxa-image" ||
normalized === "fal-image" || normalized === "fal-image" ||
normalized === "snapgen-image" ||
normalized === "openai-image" || normalized === "openai-image" ||
normalized === "bananaimage" ||
normalized === "visionary" ||
normalized === "atlascloud" ||
normalized === "vip98k" ||
normalized === "stepfun-image" || normalized === "stepfun-image" ||
normalized === "midjourney" || normalized === "midjourney" ||
normalized === "runway" || normalized === "runway" ||
@ -197,29 +302,25 @@ export function writeCanvasMediaChunk(
agentCategoryId?: string | null; agentCategoryId?: string | null;
agentCategoryName?: string | null; agentCategoryName?: string | null;
agentExecutionSnapshotId?: string | null; agentExecutionSnapshotId?: string | null;
mediaItemIds?: string[] | null;
handledCanvasMediaItemIds?: string[] | null;
}, },
label: string label: string
) { ) {
const imageUrls = Array.isArray(params.imageUrls) const inputImageUrls = Array.isArray(params.imageUrls)
? params.imageUrls ? params.imageUrls
: extractImageUrlsFromText(String(params.text || "")); : extractImageUrlsFromText(String(params.text || ""));
const videoUrls = Array.isArray(params.videoUrls) ? params.videoUrls : []; const normalizedPayload = normalizeCanvasMediaStreamPayload({
const normalizedImageUrls = Array.from( imageUrls: inputImageUrls,
new Set( videoUrls: params.videoUrls,
imageUrls mediaItemIds: params.mediaItemIds,
.map((url) => String(url || "").trim()) handledCanvasMediaItemIds: params.handledCanvasMediaItemIds,
.filter((url) => /^https?:\/\//i.test(url)) });
)
);
const normalizedVideoUrls = Array.from(
new Set(
videoUrls
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
);
if (normalizedImageUrls.length === 0 && normalizedVideoUrls.length === 0) { if (
normalizedPayload.imageUrls.length === 0 &&
normalizedPayload.videoUrls.length === 0
) {
return; return;
} }
@ -228,8 +329,8 @@ export function writeCanvasMediaChunk(
"data-canvasMedia", "data-canvasMedia",
{ {
messageId: params.messageId, messageId: params.messageId,
imageUrls: normalizedImageUrls, imageUrls: normalizedPayload.imageUrls,
videoUrls: normalizedVideoUrls, videoUrls: normalizedPayload.videoUrls,
prompt: String(params.prompt || "").trim() || undefined, prompt: String(params.prompt || "").trim() || undefined,
sourceImageUrls: Array.isArray(params.sourceImageUrls) sourceImageUrls: Array.isArray(params.sourceImageUrls)
? params.sourceImageUrls ? params.sourceImageUrls
@ -242,11 +343,102 @@ export function writeCanvasMediaChunk(
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined, agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
agentExecutionSnapshotId: agentExecutionSnapshotId:
String(params.agentExecutionSnapshotId || "").trim() || undefined, String(params.agentExecutionSnapshotId || "").trim() || undefined,
mediaItemIds: normalizedPayload.mediaItemIds,
handledCanvasMediaItemIds: normalizedPayload.handledCanvasMediaItemIds,
}, },
label 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 = { type AgentTargetLockRefPreview = {
url: string; url: string;
title?: string; title?: string;

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -3,6 +3,7 @@ import {
CompleteMultipartUploadCommand, CompleteMultipartUploadCommand,
CreateMultipartUploadCommand, CreateMultipartUploadCommand,
HeadObjectCommand, HeadObjectCommand,
ListPartsCommand,
PutObjectCommand, PutObjectCommand,
UploadPartCommand, UploadPartCommand,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
@ -12,6 +13,10 @@ import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { upsertMediaAsset } from "@/lib/db/queries"; import { upsertMediaAsset } from "@/lib/db/queries";
import {
IMAGE_UPLOAD_MAX_BYTES,
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
} from "@/lib/image-upload-limits";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { import {
buildS3PublicUrl, buildS3PublicUrl,
@ -21,20 +26,41 @@ import {
maybeEnqueueStorageMigrationTask, maybeEnqueueStorageMigrationTask,
resolveS3UploadTarget, resolveS3UploadTarget,
} from "@/lib/storage/service"; } 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 logger = createLogger("direct-upload-api");
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024; const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
const DIRECT_UPLOAD_MEDIA_MAX_BYTES = 500 * 1024 * 1024;
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024; const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024; const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60; const PRESIGNED_URL_EXPIRES_SECONDS = 6 * 60 * 60;
const DIRECT_UPLOAD_PREFIX = "uploads/direct"; const DIRECT_UPLOAD_PREFIX = "uploads/direct";
function elapsedMs(startedAt: number) {
return Date.now() - startedAt;
}
const allowedDirectUploadTypes = new Set([ const allowedDirectUploadTypes = new Set([
"image/jpeg", "image/jpeg",
"image/jpg", "image/jpg",
"image/png", "image/png",
"image/webp", "image/webp",
]); ]);
const allowedOpenClawVideoUploadTypes = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const InitiateSchema = z.object({ const InitiateSchema = z.object({
action: z.literal("initiate"), action: z.literal("initiate"),
@ -64,6 +90,18 @@ const CompleteSchema = z.object({
clientUploadId: z.string().max(128).optional(), clientUploadId: z.string().max(128).optional(),
}); });
const ResumeSchema = z.object({
action: z.literal("resume"),
key: z.string().min(1).max(512),
uploadId: z.string().min(1).max(512),
filename: z.string().min(1).max(256),
contentType: z.string().min(1).max(128),
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
partSize: z.number().int().min(5 * 1024 * 1024).max(128 * 1024 * 1024),
purpose: z.string().max(64).optional(),
clientUploadId: z.string().max(128).optional(),
});
const AbortSchema = z.object({ const AbortSchema = z.object({
action: z.literal("abort"), action: z.literal("abort"),
key: z.string().min(1).max(512), key: z.string().min(1).max(512),
@ -73,6 +111,7 @@ const AbortSchema = z.object({
const RequestSchema = z.discriminatedUnion("action", [ const RequestSchema = z.discriminatedUnion("action", [
InitiateSchema, InitiateSchema,
ResumeSchema,
CompleteSchema, CompleteSchema,
AbortSchema, AbortSchema,
]); ]);
@ -100,19 +139,52 @@ function assertUserOwnedKey(key: string, userId: string) {
const normalized = String(key || "").replace(/^\/+/, ""); const normalized = String(key || "").replace(/^\/+/, "");
const prefix = `${userUploadPrefix(userId)}/`; const prefix = `${userUploadPrefix(userId)}/`;
if (!normalized.startsWith(prefix)) { if (!normalized.startsWith(prefix)) {
throw new Error("Invalid upload object key"); const error = new Error("Invalid upload object key");
error.name = "InvalidUploadObjectKeyError";
throw error;
} }
return normalized; return normalized;
} }
function isAllowedDirectUpload(contentType: string) { function isAllowedDirectUpload(
return allowedDirectUploadTypes.has(contentType); contentType: string,
purpose: string | undefined,
filename: string
) {
return (
allowedDirectUploadTypes.has(contentType) ||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
allowedOpenClawVideoUploadTypes.has(contentType)) ||
(purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
Boolean(
getOpenClawChatFileContentType({ name: filename, type: contentType })
)) ||
(purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
isOpenClawChatFolderFileAllowed({ name: filename, type: contentType }))
);
}
function getPurposeMaxBytes(purpose?: string, contentType?: string) {
if (String(contentType || "").toLowerCase().startsWith("image/")) {
return IMAGE_UPLOAD_MAX_BYTES;
}
if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) {
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
}
if (
purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
) {
return DIRECT_UPLOAD_MAX_BYTES;
}
return DIRECT_UPLOAD_MEDIA_MAX_BYTES;
} }
function computePartSize(size: number) { function computePartSize(size: number) {
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES; if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024; if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
return 16 * 1024 * 1024; if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
return 64 * 1024 * 1024;
} }
async function createPartUrls(params: { async function createPartUrls(params: {
@ -120,27 +192,28 @@ async function createPartUrls(params: {
bucket: string; bucket: string;
key: string; key: string;
uploadId: string; uploadId: string;
partCount: number; partNumbers: number[];
}) { }) {
const urls = []; return Promise.all(
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) { params.partNumbers.map(async (partNumber) => {
const command = new UploadPartCommand({ const command = new UploadPartCommand({
Bucket: params.bucket, Bucket: params.bucket,
Key: params.key, Key: params.key,
UploadId: params.uploadId, UploadId: params.uploadId,
PartNumber: partNumber, PartNumber: partNumber,
}); });
urls.push({ return {
partNumber, partNumber,
url: await getSignedUrl(params.client, command, { url: await getSignedUrl(params.client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
}), }),
}); };
} })
return urls; );
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const requestStartedAt = Date.now();
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@ -159,6 +232,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) => { const target = await resolveS3UploadTarget().catch((error) => {
logger.error("resolve_storage_target_failed", error); logger.error("resolve_storage_target_failed", error);
return null; return null;
@ -170,9 +253,17 @@ export async function POST(request: Request) {
{ status: 409 } { 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 client = createS3Client(target.config);
const bucket = target.config.bucket; const bucket = target.config.bucket;
const usePublicReadAcl = target.config.provider !== "qiniu-kodo";
try { try {
if (payload.action === "initiate") { if (payload.action === "initiate") {
@ -181,7 +272,7 @@ export async function POST(request: Request) {
payload.filename payload.filename
); );
if (!isAllowedDirectUpload(contentType)) { if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
logger.warn("unsupported_direct_upload_type", { logger.warn("unsupported_direct_upload_type", {
contentType, contentType,
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null, filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
@ -190,10 +281,24 @@ export async function POST(request: Request) {
clientUploadId: payload.clientUploadId || null, clientUploadId: payload.clientUploadId || null,
}); });
return NextResponse.json( return NextResponse.json(
{ error: "Direct upload only supports JPG, PNG and WebP images" }, {
error:
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
},
{ status: 400 } { status: 400 }
); );
} }
const maxBytes = getPurposeMaxBytes(payload.purpose, contentType);
if (payload.size > maxBytes) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: `文件不能超过 ${maxBytes / 1024 / 1024}MB`,
},
{ status: 413 }
);
}
const displayName = sanitizeDisplayName(payload.filename); const displayName = sanitizeDisplayName(payload.filename);
const key = buildStorageObjectKey( const key = buildStorageObjectKey(
@ -201,39 +306,57 @@ export async function POST(request: Request) {
userUploadPrefix(session.user.id) userUploadPrefix(session.user.id)
); );
if (payload.size < MULTIPART_THRESHOLD_BYTES) { const multipartThreshold =
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? DEFAULT_PART_SIZE_BYTES
: MULTIPART_THRESHOLD_BYTES;
if (payload.size < multipartThreshold) {
const presignStartedAt = Date.now();
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: bucket, Bucket: bucket,
Key: key, Key: key,
ContentType: contentType, ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable", CacheControl: "public, max-age=31536000, immutable",
ACL: "public-read", ...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
}); });
const url = await getSignedUrl(client, command, { const url = await getSignedUrl(client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, 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({ return NextResponse.json({
mode: "single", mode: "single",
key, key,
url, url,
headers: { headers,
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"x-amz-acl": "public-read",
},
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
clientUploadId: payload.clientUploadId || null, clientUploadId: payload.clientUploadId || null,
}); });
} }
const multipartStartedAt = Date.now();
const createResult = await client.send( const createResult = await client.send(
new CreateMultipartUploadCommand({ new CreateMultipartUploadCommand({
Bucket: bucket, Bucket: bucket,
Key: key, Key: key,
ContentType: contentType, ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable", CacheControl: "public, max-age=31536000, immutable",
ACL: "public-read", ...(usePublicReadAcl ? { ACL: "public-read" as const } : {}),
}) })
); );
@ -249,7 +372,16 @@ export async function POST(request: Request) {
bucket, bucket,
key, key,
uploadId, 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, partCount,
partSize,
contentType,
clientUploadId: payload.clientUploadId || null,
}); });
return NextResponse.json({ return NextResponse.json({
@ -264,8 +396,108 @@ export async function POST(request: Request) {
}); });
} }
if (payload.action === "resume") {
const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 });
}
if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: "文件超过允许的上传大小",
},
{ status: 413 }
);
}
const expectedPartSize = computePartSize(payload.size);
if (payload.partSize !== expectedPartSize) {
return NextResponse.json(
{ error: "续传分片参数已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
{ status: 409 }
);
}
try {
const listed = await client.send(
new ListPartsCommand({
Bucket: bucket,
Key: key,
UploadId: payload.uploadId,
MaxParts: 1000,
})
);
const uploadedParts = (listed.Parts || []).flatMap((part) => {
if (!part.ETag || typeof part.PartNumber !== "number") return [];
return [
{
ETag: part.ETag,
PartNumber: part.PartNumber,
size: Number(part.Size || 0),
},
];
});
const uploadedPartNumbers = new Set(
uploadedParts.map((part) => part.PartNumber)
);
const partCount = Math.ceil(payload.size / expectedPartSize);
const missingPartNumbers = Array.from(
{ length: partCount },
(_, index) => index + 1
).filter((partNumber) => !uploadedPartNumbers.has(partNumber));
const parts = await createPartUrls({
client,
bucket,
key,
uploadId: payload.uploadId,
partNumbers: missingPartNumbers,
});
logger.info("direct_upload_multipart_resumed", {
totalElapsedMs: elapsedMs(requestStartedAt),
size: payload.size,
uploadedPartCount: uploadedParts.length,
missingPartCount: missingPartNumbers.length,
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json({
mode: "multipart",
key,
uploadId: payload.uploadId,
partSize: expectedPartSize,
partCount,
parts,
uploadedParts,
resumed: true,
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
clientUploadId: payload.clientUploadId || null,
});
} catch (error) {
const head = await client
.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
.catch(() => null);
if (head?.ContentLength === payload.size) {
return NextResponse.json({
mode: "completed",
key,
clientUploadId: payload.clientUploadId || null,
});
}
logger.warn("direct_upload_resume_session_missing", error, {
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json(
{ error: "续传会话已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
{ status: 409 }
);
}
}
if (payload.action === "abort") { if (payload.action === "abort") {
const key = assertUserOwnedKey(payload.key, session.user.id); const key = assertUserOwnedKey(payload.key, session.user.id);
const abortStartedAt = Date.now();
await client.send( await client.send(
new AbortMultipartUploadCommand({ new AbortMultipartUploadCommand({
Bucket: bucket, Bucket: bucket,
@ -273,19 +505,38 @@ export async function POST(request: Request) {
UploadId: payload.uploadId, UploadId: payload.uploadId,
}) })
); );
logger.info("direct_upload_multipart_aborted", {
elapsedMs: elapsedMs(abortStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
const key = assertUserOwnedKey(payload.key, session.user.id); const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename); const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType)) { if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
return NextResponse.json( return NextResponse.json(
{ error: "Direct upload only supports JPG, PNG and WebP images" }, {
error:
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
},
{ status: 400 } { status: 400 }
); );
} }
if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: "文件超过允许的上传大小",
},
{ status: 413 }
);
}
if (payload.uploadId) { if (payload.uploadId) {
const completeMultipartStartedAt = Date.now();
const parts = [...(payload.parts || [])].sort( const parts = [...(payload.parts || [])].sort(
(a, b) => a.PartNumber - b.PartNumber (a, b) => a.PartNumber - b.PartNumber
); );
@ -306,35 +557,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( const head = await client.send(
new HeadObjectCommand({ new HeadObjectCommand({
Bucket: bucket, Bucket: bucket,
Key: key, Key: key,
}) })
); );
logger.info("direct_upload_head_object_completed", {
elapsedMs: elapsedMs(headStartedAt),
uploadMode: payload.uploadId ? "multipart" : "single",
clientUploadId: payload.clientUploadId || null,
});
const actualSize = const actualSize =
typeof head.ContentLength === "number" ? head.ContentLength : null; typeof head.ContentLength === "number" ? head.ContentLength : null;
if (actualSize !== null && actualSize <= 0) { if (actualSize !== null && actualSize !== payload.size) {
throw new Error("Uploaded object is empty"); throw new Error(
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
);
} }
const url = buildS3PublicUrl(target.config, key); const url = buildS3PublicUrl(target.config, key);
const resolvedContentType = const resolvedContentType =
head.ContentType || contentType || getContentType(payload.filename); head.ContentType || contentType || getContentType(payload.filename);
const assetStartedAt = Date.now();
const asset = await upsertMediaAsset({ const asset = await upsertMediaAsset({
userId: session.user.id, userId: session.user.id,
mediaType: "image", mediaType:
sourceType: "upload", 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", status: "ready",
originalUrl: url, originalUrl: url,
previewUrl: url, previewUrl:
thumbnailUrl: url, payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? null
: url,
thumbnailUrl:
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? null
: url,
mimeType: resolvedContentType, mimeType: resolvedContentType,
displayName: payload.filename, displayName: payload.filename,
sourceTable: "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: { metadata: {
pathname: key, pathname: key,
size: actualSize ?? payload.size, size: actualSize ?? payload.size,
@ -344,6 +637,13 @@ export async function POST(request: Request) {
clientUploadId: payload.clientUploadId || null, 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({ await maybeEnqueueStorageMigrationTask({
uploaded: { url, pathname: key }, uploaded: { url, pathname: key },
@ -374,7 +674,13 @@ export async function POST(request: Request) {
error: "Direct upload failed", error: "Direct upload failed",
details: error instanceof Error ? error.message : String(error), details: error instanceof Error ? error.message : String(error),
}, },
{ status: 500 } {
status:
error instanceof Error &&
error.name === "InvalidUploadObjectKeyError"
? 403
: 500,
}
); );
} }
} }

View File

@ -1,5 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import {
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
isImageUploadTooLarge,
} from "@/lib/image-upload-limits";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images"; import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
@ -7,6 +11,7 @@ import { upsertMediaAsset } from "@/lib/db/queries";
import { isSiteAdminType } from "@/lib/middleware/admin"; import { isSiteAdminType } from "@/lib/middleware/admin";
import { getContentType, uploadFile } from "@/lib/storage/service"; import { getContentType, uploadFile } from "@/lib/storage/service";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("upload-api"); const logger = createLogger("upload-api");
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
@ -14,12 +19,32 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/jpg", "image/jpg",
"image/png", "image/png",
"image/webp", "image/webp",
"image/avif",
"image/tiff",
"image/x-tiff",
"image/svg+xml", "image/svg+xml",
"image/heic", "image/heic",
"image/heif", "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 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) { function normalizeUploadContentType(contentType: string, filename: string) {
const normalized = contentType.trim().toLowerCase(); const normalized = contentType.trim().toLowerCase();
@ -35,8 +60,16 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") { if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。"; 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) { function isHeicUpload(contentType: string, filename: string) {
@ -48,10 +81,20 @@ function isHeicUpload(contentType: string, filename: string) {
); );
} }
function isSharpCompatibilityUpload(contentType: string, filename: string) {
const extension = filename.toLowerCase().split(".").pop() || "";
return (
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
extension === "avif" ||
extension === "tif" ||
extension === "tiff"
);
}
function toJpegFilename(filename: string) { function toJpegFilename(filename: string) {
const trimmed = filename.trim() || `upload-${Date.now()}`; const trimmed = filename.trim() || `upload-${Date.now()}`;
if (/\.(heic|heif)$/i.test(trimmed)) { if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
return trimmed.replace(/\.(heic|heif)$/i, ".jpg"); return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
} }
if (/\.[a-z0-9]+$/i.test(trimmed)) { if (/\.[a-z0-9]+$/i.test(trimmed)) {
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg"); return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
@ -71,6 +114,21 @@ async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
} }
return Buffer.from(output); 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智能优化图片(保持原格式) * 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化 * 策略:避免双重压缩,只在需要时优化
@ -197,9 +255,21 @@ export async function POST(request: Request) {
let filename = originalFilename; let filename = originalFilename;
let contentType = originalContentType; let contentType = originalContentType;
const uploadPurpose = String(formData.get("purpose") || ""); 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 = const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null; String(formData.get("clientUploadId") || "").trim() || null;
const isSvgUpload = contentType === "image/svg+xml"; const isSvgUpload = contentType === "image/svg+xml";
if (contentType.startsWith("image/") && isImageUploadTooLarge(file.size)) {
return NextResponse.json(
{ error: IMAGE_UPLOAD_TOO_LARGE_MESSAGE },
{ status: 413 }
);
}
if ( if (
isSvgUpload && isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type)) (uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
@ -227,7 +297,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 }); 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); const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
logger.warn("[Upload API] Unsupported upload content type", { logger.warn("[Upload API] Unsupported upload content type", {
contentType, contentType,
@ -277,6 +351,38 @@ export async function POST(request: Request) {
{ status: 400 } { 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") { if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
@ -361,18 +467,32 @@ export async function POST(request: Request) {
try { try {
const asset = await upsertMediaAsset({ const asset = await upsertMediaAsset({
userId: session.user.id, userId: session.user.id,
mediaType: "image", mediaType:
sourceType: "upload", isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? "video"
: "image",
sourceType: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: "upload",
status: "ready", status: "ready",
originalUrl: data.url, originalUrl: data.url,
previewUrl: data.url, previewUrl:
thumbnailUrl: data.url, isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? null
: data.url,
thumbnailUrl:
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? null
: data.url,
mimeType: data.contentType || contentType || null, mimeType: data.contentType || contentType || null,
displayName: filename, displayName: filename,
sourceTable: "upload", sourceTable: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: "upload",
metadata: { metadata: {
pathname: data.pathname, pathname: data.pathname,
size: optimizedBuffer.length, size: optimizedBuffer.length,
purpose: uploadPurpose || null,
clientUploadId, clientUploadId,
convertedFrom, convertedFrom,
}, },

View File

@ -72,6 +72,73 @@ function isWorkflowImageGenerationModel(model: {
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限) // 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
export async function GET(request: Request) { export async function GET(request: Request) {
try { 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: "veo-3.1-fast",
displayName: "Veo 3.1 Fast(本地布局测试)",
avatarUrl: null,
description: "本地视频画布测试视频模型",
supportsVision: true,
supportsImageGen: false,
isMultimodal: true,
isDefault: false,
providerName: "Local E2E",
providerType: "vip98k",
isSubModel: false,
parentModelId: null,
capabilities: { kind: "video", supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"], supportedRatios:["16:9"], supportedResolutions:["720p","1080p"], durationOptions:[8], maxReferenceImages:3 },
videoToolCapability: {
supported: true,
durations: [8],
resolutions: ["720p", "1080p"],
},
imageGenConfig: {
creditCostPerVideo: 20,
videoDurationOptions: [8],
},
sortOrder: 1,
selectable: true,
},
];
return Response.json({
models,
defaultModelId: models[0].id,
agentWorkflowConfiguredDefaultModelId: null,
agentWorkflowDefaultModelId: null,
agentWorkflowConfiguredModels: [],
agentModel: null,
});
}
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {

View File

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

View File

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

View File

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

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

View File

@ -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(), supportsNegativePrompt: z.boolean().optional(),
supportsWatermark: z.boolean().optional(), supportsWatermark: z.boolean().optional(),
supportsSeed: 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(), creditCostPerImage: z.number().min(0).max(100000).optional(),
creditCostMultiplierBySize: z.record(z.string(), z.number().min(0.01).max(100)).optional(), creditCostMultiplierBySize: z.record(z.string(), z.number().min(0.01).max(100)).optional(),
creditDiscountRate: z.number().min(0.1).max(10).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(), creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
enableLoadingPlaceholder: z.boolean().optional(), enableLoadingPlaceholder: z.boolean().optional(),
loadingColor: z.string().optional(), loadingColor: z.string().optional(),
vip98k: z.record(z.string(), z.unknown()).optional(),
}).optional(); }).optional();
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional(); const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();

View File

@ -9,6 +9,8 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models"; 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 = [ const providerTypes = [
"alibaba", "alibaba",
@ -29,7 +31,12 @@ const providerTypes = [
"bfl-image", "bfl-image",
"ruxa-image", "ruxa-image",
"fal-image", "fal-image",
"snapgen-image",
"openai-image", "openai-image",
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image", "stepfun-image",
"midjourney", "midjourney",
"fal-video", "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 DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai"; const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
const RUXA_IMAGE_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 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 STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai"; const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com"; const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi"; 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 KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com"; const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
const KREA_BASE_URL = "https://api.krea.ai"; 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.baseUrl || BFL_IMAGE_BASE_URL
: data.type === "ruxa-image" : data.type === "ruxa-image"
? data.baseUrl || RUXA_IMAGE_BASE_URL ? data.baseUrl || RUXA_IMAGE_BASE_URL
: data.type === "snapgen-image"
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
: data.type === "openai-image" : data.type === "openai-image"
? data.baseUrl || OPENAI_IMAGE_BASE_URL ? 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.type === "stepfun-image"
? data.baseUrl || STEPFUN_IMAGE_BASE_URL ? data.baseUrl || STEPFUN_IMAGE_BASE_URL
: data.type === "midjourney" : data.type === "midjourney"
@ -255,6 +277,7 @@ export async function POST(request: Request) {
}); });
console.log("[Admin API] Provider created successfully:", provider[0].id); console.log("[Admin API] Provider created successfully:", provider[0].id);
clearScopedSiteSettingsCache();
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) }); return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
} catch (error) { } catch (error) {
console.error("[Admin API] Create provider error:", error); console.error("[Admin API] Create provider error:", error);
@ -293,6 +316,12 @@ export async function PUT(request: Request) {
...data, ...data,
apiKey: normalizedApiKey, apiKey: normalizedApiKey,
type: normalizeProviderTypeForStorage(data.type), type: normalizeProviderTypeForStorage(data.type),
baseUrl:
data.baseUrl !== undefined
? data.baseUrl
: data.type === "vip98k"
? VIP98K_PROVIDER_BASE_URL
: undefined,
updatedBy: session.user.id, updatedBy: session.user.id,
}); });
@ -300,6 +329,7 @@ export async function PUT(request: Request) {
return new ChatSDKError("bad_request:api", "Provider not found").toResponse(); return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
} }
clearScopedSiteSettingsCache();
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) }); return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
@ -325,6 +355,7 @@ export async function DELETE(request: Request) {
await deleteAiProvider({ id }); await deleteAiProvider({ id });
clearScopedSiteSettingsCache();
return Response.json({ success: true }); return Response.json({ success: true });
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) { 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,130 @@
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,
isEnabled: true,
isHidden: false,
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

@ -1,11 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin"; import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching"; import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { import {
AGENT_PSD_TEMPLATES_KEY,
type AgentPsdTemplate,
type AgentPsdTemplateLayerSlot, type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateMutationResponse, type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config"; } from "@/lib/agents/psd-template-config";
@ -15,6 +12,17 @@ import {
} from "@/lib/agents/psd-template-parser"; } from "@/lib/agents/psd-template-parser";
import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection"; import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection";
import { schedulePsdTemplateAssetBackfill } from "@/lib/agents/psd-template-asset-backfill"; 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,
updateAgentPsdTemplateParseProgress,
} from "@/lib/agents/psd-template-progress";
import { import {
cacheAgentPsdSourceLocally, cacheAgentPsdSourceLocally,
hasAgentPsdLocalCache, hasAgentPsdLocalCache,
@ -25,6 +33,7 @@ import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 1800;
function jsonError(status: number, error: string) { function jsonError(status: number, error: string) {
const payload: AgentPsdTemplateMutationResponse = { const payload: AgentPsdTemplateMutationResponse = {
@ -48,6 +57,22 @@ function getErrorMessage(error: unknown) {
return "PSD 解析失败"; 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: { function pickPrimaryImageLayerId(params: {
parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"]; parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"];
canvasWidth: number; canvasWidth: number;
@ -157,44 +182,15 @@ function buildInitialTemplateLayers(params: {
}); });
} }
async function updateTemplate(
templateId: string,
updatedBy: string,
updater: (template: AgentPsdTemplate) => AgentPsdTemplate
) {
const templates = await readAgentPsdTemplates();
const currentTemplate = templates.find((entry) => entry.id === templateId);
if (!currentTemplate) {
return null;
}
const nextTemplate = updater(currentTemplate);
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: templates.map((entry) => (entry.id === templateId ? nextTemplate : entry)),
updatedBy,
});
return nextTemplate;
}
async function updateParseProgress(params: { async function updateParseProgress(params: {
templateId: string; templateId: string;
updatedBy: string; updatedBy: string;
progress: number; progress: number;
stage: string; stage: string;
error?: string; error?: string;
restart?: boolean;
}) { }) {
const { templateId, updatedBy, progress, stage, error } = params; return updateAgentPsdTemplateParseProgress(params);
return updateTemplate(templateId, updatedBy, (template) => ({
...template,
parseStatus: "processing",
parseProgress: progress,
parseStage: stage,
parseError: error,
updatedAt: new Date().toISOString(),
}));
} }
export async function POST( export async function POST(
@ -207,6 +203,12 @@ export async function POST(
} }
const { id } = await context.params; const { id } = await context.params;
let fileInspection: AgentPsdTemplateFileInspection | undefined;
let latestProgress = {
progress: 0,
stage: "准备解析 PSD",
error: undefined as string | undefined,
};
try { try {
const { templates, timedOutTemplates } = await failTimedOutAgentPsdTemplates({ const { templates, timedOutTemplates } = await failTimedOutAgentPsdTemplates({
@ -230,155 +232,205 @@ export async function POST(
return jsonError(409, "该模板正在解析中,请稍后刷新"); return jsonError(409, "该模板正在解析中,请稍后刷新");
} }
await updateParseProgress({ latestProgress = {
templateId: id,
updatedBy: session.user.id,
progress: 5, progress: 5,
stage: "准备解析 PSD", stage: "准备解析 PSD",
error: undefined, error: undefined,
}); };
const setParseProgress = async (progress: number, stage: string, error?: string) => {
let fileBuffer: ArrayBuffer; latestProgress = {
if ( progress,
await hasAgentPsdLocalCache({ stage,
templateId: id, error,
fileName: currentTemplate.fileName, };
}) return updateParseProgress({
) {
await updateParseProgress({
templateId: id, templateId: id,
updatedBy: session.user.id, updatedBy: session.user.id,
progress: 18, progress,
stage: "正在读取服务器本地 PSD 缓存", stage,
error: undefined, error,
});
const localBuffer = await readAgentPsdSourceFromLocalCache({
templateId: id,
fileName: currentTemplate.fileName,
});
fileBuffer = localBuffer.buffer.slice(
localBuffer.byteOffset,
localBuffer.byteOffset + localBuffer.byteLength
) as ArrayBuffer;
} else {
const response = await fetch(currentTemplate.fileUrl, {
cache: "no-store",
signal: AbortSignal.timeout(60_000),
}); });
};
if (!response.ok) { await updateParseProgress({
throw new Error(`PSD 文件下载失败 (${response.status})`); templateId: id,
updatedBy: session.user.id,
...latestProgress,
error: undefined,
restart: true,
});
const heartbeat = createAgentPsdTemplateParseHeartbeat({
templateId: id,
updatedBy: session.user.id,
getSnapshot: () => latestProgress,
});
try {
let fileBuffer: ArrayBuffer;
if (
await hasAgentPsdLocalCache({
templateId: id,
fileName: currentTemplate.fileName,
})
) {
await setParseProgress(18, "正在读取服务器本地 PSD 缓存");
const localBuffer = await readAgentPsdSourceFromLocalCache({
templateId: id,
fileName: currentTemplate.fileName,
});
fileBuffer = localBuffer.buffer.slice(
localBuffer.byteOffset,
localBuffer.byteOffset + localBuffer.byteLength
) as ArrayBuffer;
} else {
const response = await fetch(currentTemplate.fileUrl, {
cache: "no-store",
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
throw new Error(`PSD 文件下载失败 (${response.status})`);
}
await setParseProgress(18, "正在下载并写入服务器本地 PSD 缓存");
fileBuffer = await response.arrayBuffer();
await cacheAgentPsdSourceLocally({
templateId: id,
fileName: currentTemplate.fileName,
buffer: fileBuffer,
}).catch((error) => {
console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", {
templateId: id,
error,
});
});
} }
await updateParseProgress({ await setParseProgress(22, "正在校验 PSD 文件格式");
fileInspection = assertSupportedAgentPsdTemplateFile(fileBuffer);
await setParseProgress(24, "正在解析 PSD 图层结构与预览");
const parsedTemplate = await parsePsdTemplateFile(fileBuffer, {
onProgress: async (event) => {
await setParseProgress(event.progress, event.stage);
},
});
if (parsedTemplate.layers.length === 0) {
throw new Error("PSD 中未识别到可配置图层");
}
await setParseProgress(42, "正在上传模板预览");
const uploadedPreview = await uploadFile(
`agent-psd-preview-${id}-${Date.now()}.png`,
toArrayBuffer(parsedTemplate.previewBuffer)
);
await setParseProgress(58, "正在匹配字体与初始化图层");
const fonts = await readAgentPsdFonts().catch(() => []);
const initialLayers = buildInitialTemplateLayers({
parsedLayers: parsedTemplate.layers,
fonts,
canvasWidth: parsedTemplate.width,
canvasHeight: parsedTemplate.height,
});
heartbeat.stop();
const updatedTemplate = await updateAgentPsdTemplateById({
templateId: id, templateId: id,
updatedBy: session.user.id, updatedBy: session.user.id,
progress: 18, updater: (template) => {
stage: "正在下载并写入服务器本地 PSD 缓存", const now = new Date().toISOString();
error: undefined, return {
...template,
previewUrl: uploadedPreview.url,
previewPath: uploadedPreview.pathname,
width: parsedTemplate.width,
height: parsedTemplate.height,
layerCount: parsedTemplate.layers.length,
layers: initialLayers,
parseStatus: "processing",
parseProgress: 48,
parseStage: "快速解析完成,正在后台补全图层资源和模板底图",
parseError: undefined,
parseHeartbeatAt: now,
updatedAt: now,
};
},
}); });
fileBuffer = await response.arrayBuffer(); if (!updatedTemplate) {
await cacheAgentPsdSourceLocally({ return jsonError(404, "未找到 PSD 模板");
}
schedulePsdTemplateAssetBackfill({
templateId: id, templateId: id,
fileName: currentTemplate.fileName, updatedBy: session.user.id,
buffer: fileBuffer,
}).catch((error) => {
console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", {
templateId: id,
error,
});
}); });
return NextResponse.json({
success: true,
template: updatedTemplate,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
heartbeat.stop();
throw error;
} }
const parsedTemplate = await parsePsdTemplateFile(fileBuffer);
if (parsedTemplate.layers.length === 0) {
throw new Error("PSD 中未识别到可配置图层");
}
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 42,
stage: "正在生成模板预览",
error: undefined,
});
const uploadedPreview = await uploadFile(
`agent-psd-preview-${id}-${Date.now()}.png`,
toArrayBuffer(parsedTemplate.previewBuffer)
);
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 58,
stage: "正在匹配字体与初始化图层",
error: undefined,
});
const fonts = await readAgentPsdFonts().catch(() => []);
const initialLayers = buildInitialTemplateLayers({
parsedLayers: parsedTemplate.layers,
fonts,
canvasWidth: parsedTemplate.width,
canvasHeight: parsedTemplate.height,
});
const updatedTemplate = await updateTemplate(id, session.user.id, (template) => ({
...template,
previewUrl: uploadedPreview.url,
previewPath: uploadedPreview.pathname,
width: parsedTemplate.width,
height: parsedTemplate.height,
layerCount: parsedTemplate.layers.length,
layers: initialLayers,
parseStatus: "processing",
parseProgress: 48,
parseStage: "快速解析完成,正在后台补全图层资源和模板底图",
parseError: undefined,
updatedAt: new Date().toISOString(),
}));
if (!updatedTemplate) {
return jsonError(404, "未找到 PSD 模板");
}
schedulePsdTemplateAssetBackfill({
templateId: id,
updatedBy: session.user.id,
});
return NextResponse.json({
success: true,
template: updatedTemplate,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getPsdTemplateParseErrorMessage({
error,
fileInspection,
});
const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id); const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id);
if (existingTemplate) { if (existingTemplate) {
const fallbackStatus = const hasUsablePreviousParse =
existingTemplate.parseStatus === "completed" && existingTemplate.layers.length > 0 existingTemplate.layers.length > 0 &&
? "completed" existingTemplate.width > 1 &&
: "failed"; existingTemplate.height > 1 &&
Boolean(existingTemplate.previewUrl);
const fallbackStatus = hasUsablePreviousParse ? "completed" : "failed";
await updateTemplate(id, session.user.id, (template) => ({ await updateAgentPsdTemplateById({
...template, templateId: id,
parseStatus: fallbackStatus, updatedBy: session.user.id,
parseProgress: fallbackStatus === "completed" ? 100 : 0, updater: (template) => ({
parseStage: ...template,
fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined, parseStatus: fallbackStatus,
parseError: parseProgress:
fallbackStatus === "completed" fallbackStatus === "completed"
? `重新解析失败:${message}` ? 100
: message, : Math.max(0, Math.round(Number(latestProgress.progress || 0))),
updatedAt: new Date().toISOString(), parseStage:
})).catch(() => undefined); fallbackStatus === "completed"
? "沿用上次成功解析结果"
: latestProgress.stage || "PSD 解析失败",
parseError:
fallbackStatus === "completed"
? `重新解析失败:${message}`
: message,
updatedAt: new Date().toISOString(),
}),
}).catch(() => undefined);
} }
console.error("[PSD Templates][Admin] parse failed:", error); console.error("[PSD Templates][Admin] parse failed:", {
return jsonError(502, message); 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

@ -18,6 +18,7 @@ import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache"; import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
import { import {
preparePsdTemplateRenderAssets, preparePsdTemplateRenderAssets,
uploadPreparedPsdTemplateRenderSegments,
} from "@/lib/agents/psd-template-parser"; } from "@/lib/agents/psd-template-parser";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair"; import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair";
@ -222,6 +223,11 @@ export async function PUT(
preparedAssets.renderBaseBuffer.byteOffset + preparedAssets.renderBaseBuffer.byteLength preparedAssets.renderBaseBuffer.byteOffset + preparedAssets.renderBaseBuffer.byteLength
) as ArrayBuffer ) as ArrayBuffer
); );
const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({
templateId: currentTemplate.id,
segments: preparedAssets.renderSegments,
uploadAsset,
});
const updatedTemplate = { const updatedTemplate = {
...currentTemplate, ...currentTemplate,
@ -234,6 +240,7 @@ export async function PUT(
status: nextStatus, status: nextStatus,
renderBaseUrl: uploadedRenderBase.url, renderBaseUrl: uploadedRenderBase.url,
renderBasePath: uploadedRenderBase.pathname, renderBasePath: uploadedRenderBase.pathname,
renderSegments: uploadedRenderSegments,
layers: nextLayers, layers: nextLayers,
layerCount: nextLayers.length, layerCount: nextLayers.length,
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION, assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,

View File

@ -15,6 +15,10 @@ import {
type AgentPsdTemplateMutationResponse, type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config"; } from "@/lib/agents/psd-template-config";
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-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 { cacheAgentPsdSourceLocally } from "@/lib/agents/psd-local-cache";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout"; import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout";
@ -222,7 +226,7 @@ export async function POST(request: NextRequest) {
const extension = getFileExtension(file.name); const extension = getFileExtension(file.name);
if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) { if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) {
return jsonMutationError(400, "模板文件仅支持 PSD / PSB"); return jsonMutationError(400, "模板文件仅支持 Photoshop PSD / PSB");
} }
if (file.size <= 0) { if (file.size <= 0) {
@ -235,11 +239,15 @@ export async function POST(request: NextRequest) {
try { try {
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
const fileInspection = assertSupportedAgentPsdTemplateFile(fileBuffer);
const templateId = randomUUID(); const templateId = randomUUID();
console.log("[PSD Templates][Admin] received PSD template upload:", { console.log("[PSD Templates][Admin] received PSD template upload:", {
templateId, templateId,
fileName: file.name, fileName: file.name,
size: file.size, size: file.size,
fileFormat: fileInspection.format,
width: fileInspection.width,
height: fileInspection.height,
}); });
await cacheAgentPsdSourceLocally({ await cacheAgentPsdSourceLocally({
templateId, templateId,
@ -311,6 +319,13 @@ export async function POST(request: NextRequest) {
} satisfies AgentPsdTemplateMutationResponse); } satisfies AgentPsdTemplateMutationResponse);
} catch (error) { } catch (error) {
console.error("[PSD Templates][Admin] create failed:", 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 模板上传或解析失败"); 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_DISPLAY_NAME,
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID, DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY, DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
isDetailAgentLocalMoonshotBaseUrl,
normalizeDetailAgentLocalMoonshotBaseUrl,
} from "@/lib/ai/detail-agent-model-config"; } from "@/lib/ai/detail-agent-model-config";
import { import {
normalizeProductPlanningApiConfig, normalizeProductPlanningApiConfig,
@ -51,6 +53,10 @@ import {
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY, CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
normalizeCanvasImageTaskQueueConfig, normalizeCanvasImageTaskQueueConfig,
} from "@/lib/canvas/image-task-config"; } from "@/lib/canvas/image-task-config";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import { import {
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
normalizeDingTalkRobotNotificationConfig, normalizeDingTalkRobotNotificationConfig,
@ -67,6 +73,15 @@ import {
EXTERNAL_IMAGE_API_CONFIG_KEY, EXTERNAL_IMAGE_API_CONFIG_KEY,
normalizeExternalImageApiConfig, normalizeExternalImageApiConfig,
} from "@/lib/external-image-api/config"; } 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"; import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
const settingSchema = z.object({ const settingSchema = z.object({
@ -86,7 +101,7 @@ const moonshotDetailAgentConfigSchema = z.object({
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL; const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
function isMoonshotOfficialBaseUrl(value: string) { 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> { 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 }, setting: setting ? { ...setting, value } : { key, value },
}); });
} }
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
const value = normalizeCanvasLocalEditConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
const value = normalizeDingTalkRobotNotificationConfig(setting?.value); const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
return Response.json({ return Response.json({
@ -139,6 +160,18 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value }, 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 }); return Response.json({ setting });
} }
@ -170,19 +203,27 @@ export async function POST(request: Request) {
? (existingSetting.value as Record<string, unknown>) ? (existingSetting.value as Record<string, unknown>)
: {}; : {};
const config = moonshotDetailAgentConfigSchema.parse(value || {}); 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 rawModelId = String(config.modelId || "").trim();
const rawDisplayName = String(config.displayName || "").trim(); const rawDisplayName = String(config.displayName || "").trim();
const normalizedModelId = 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 ? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID
: rawModelId; : rawModelId;
const normalizedDisplayName = 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 ? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME
: rawDisplayName; : rawDisplayName;
const normalizedProviderName = const normalizedProviderName =
String(config.providerName || "").trim() || "Moonshot AI (Official)"; String(config.providerName || "").trim() || "Kimi Code 编程套餐";
const normalizedApiKey = const normalizedApiKey =
String(config.apiKey || "").trim() || String(config.apiKey || "").trim() ||
String(existingValue.apiKey || "").trim(); String(existingValue.apiKey || "").trim();
@ -191,12 +232,12 @@ export async function POST(request: Request) {
if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) { if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) {
return new ChatSDKError( return new ChatSDKError(
"bad_request:api", "bad_request:api",
`Moonshot 官方地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`, `Kimi Code 编程套餐专用地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
).toResponse(); ).toResponse();
} }
if (!normalizedApiKey) { 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({ 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) { if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) {
nextValue = normalizeVideoCanvasFunctionConfig(value); nextValue = normalizeVideoCanvasFunctionConfig(value);
} }
@ -286,6 +379,10 @@ export async function POST(request: Request) {
nextValue = normalizeCanvasImageTaskQueueConfig(value); nextValue = normalizeCanvasImageTaskQueueConfig(value);
} }
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
nextValue = normalizeCanvasLocalEditConfig(value);
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
nextValue = normalizeDingTalkRobotNotificationConfig(value); nextValue = normalizeDingTalkRobotNotificationConfig(value);
} }

View File

@ -0,0 +1,153 @@
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,
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

@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 120;
const DEFAULT_WIDTH = 790;
const DEFAULT_HEIGHT = 1300;
const MAX_DIMENSION = 4096;
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
}
function normalizeDimension(value: unknown, fallback: number) {
const next = Math.round(Number(value));
if (!Number.isFinite(next) || next <= 0) return fallback;
return Math.min(MAX_DIMENSION, Math.max(64, next));
}
function normalizeFit(value: unknown) {
const fit = String(value || "").trim();
return fit === "contain" ? "contain" : "cover-top";
}
async function createThumbnailBuffer(buffer: Buffer) {
const sharp = (await import("sharp")).default;
return sharp(buffer, { failOn: "none" })
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 82, mozjpeg: true })
.toBuffer();
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const imageUrl = String(body.imageUrl || "").trim();
if (!/^https?:\/\//i.test(imageUrl)) {
return NextResponse.json({ success: false, error: "缺少有效图片地址" }, { status: 400 });
}
const width = normalizeDimension(body.width, DEFAULT_WIDTH);
const height = normalizeDimension(body.height, DEFAULT_HEIGHT);
const fit = normalizeFit(body.fit);
const sourceBuffer = await fetchImageBufferFromUrl(imageUrl);
const sharp = (await import("sharp")).default;
const pipeline = sharp(sourceBuffer, { failOn: "none" }).rotate();
const normalizedBuffer =
fit === "contain"
? await pipeline
.resize(width, height, {
fit: "contain",
position: "centre",
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false,
})
.png()
.toBuffer()
: await pipeline
.resize(width, height, {
fit: "cover",
position: "top",
withoutEnlargement: false,
})
.png()
.toBuffer();
const thumbnailBuffer = await createThumbnailBuffer(normalizedBuffer);
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(`agent-detail-normalized-${suffix}.png`, bufferToArrayBuffer(normalizedBuffer)),
uploadFile(`agent-detail-normalized-thumb-${suffix}.jpg`, bufferToArrayBuffer(thumbnailBuffer)),
]);
return NextResponse.json({
success: true,
data: {
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
imageSize: `${width}x${height}`,
},
});
} catch (error) {
console.error("[Agent Detail Image Normalize] Failed:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "详情图尺寸归一化失败",
},
{ status: 500 },
);
}
}

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -28,14 +29,27 @@ type StitchSourceSegment = {
}; };
const MAX_REFERENCE_COUNT = 120; const MAX_REFERENCE_COUNT = 120;
const FETCH_TIMEOUT_MS = 10000; const FETCH_TIMEOUT_MS = 20_000;
const FETCH_CONCURRENCY = 32; const FETCH_MAX_ATTEMPTS = 3;
const SLICE_UPLOAD_CONCURRENCY = 10; const FETCH_RETRY_BASE_DELAY_MS = 350;
const FETCH_CONCURRENCY = 12;
const SLICE_UPLOAD_CONCURRENCY = 4;
const PREVIEW_MAX_WIDTH = 720; const PREVIEW_MAX_WIDTH = 720;
const PREVIEW_MIN_WIDTH = 96; const PREVIEW_MIN_WIDTH = 96;
const PREVIEW_MAX_HEIGHT = 32000; const PREVIEW_MAX_HEIGHT = 32000;
const PREVIEW_MAX_PIXELS = 36_000_000; const PREVIEW_MAX_PIXELS = 36_000_000;
const SEGMENT_MATCH_TOLERANCE = 2; const SEGMENT_MATCH_TOLERANCE = 2;
const logger = createLogger("detail-stitch");
class ImageFetchError extends Error {
retryable: boolean;
constructor(message: string, retryable = false) {
super(message);
this.name = "ImageFetchError";
this.retryable = retryable;
}
}
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice( return buffer.buffer.slice(
@ -44,6 +58,38 @@ function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
) as ArrayBuffer; ) as ArrayBuffer;
} }
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error || "");
}
function isTimeoutLikeError(error: unknown) {
if (!(error instanceof Error)) return false;
return (
error.name === "TimeoutError" ||
error.name === "AbortError" ||
/timeout|aborted|operation was aborted/i.test(error.message)
);
}
function isRetryableFetchError(error: unknown) {
if (error instanceof ImageFetchError) {
return error.retryable;
}
return isTimeoutLikeError(error);
}
function toPublicErrorMessage(error: unknown) {
const message = getErrorMessage(error);
if (isTimeoutLikeError(error) || /timeout|aborted|operation was aborted/i.test(message)) {
return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。";
}
return message || "拼合切割失败";
}
async function runBoundedParallel<T, R>( async function runBoundedParallel<T, R>(
items: T[], items: T[],
workerCount: number, workerCount: number,
@ -148,31 +194,54 @@ function buildReferenceFetchUrl(url: string): string {
} }
async function fetchImageBuffer(url: string, requestOrigin: string, cookie: string): Promise<Buffer> { async function fetchImageBuffer(url: string, requestOrigin: string, cookie: string): Promise<Buffer> {
const response = await fetch(url, { let lastError: unknown = null;
cache: "no-store",
redirect: "follow", for (let attempt = 1; attempt <= FETCH_MAX_ATTEMPTS; attempt += 1) {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), try {
headers: buildFetchHeaders(url, requestOrigin, cookie), const response = await fetch(url, {
}); cache: "no-store",
if (!response.ok) { redirect: "follow",
throw new Error(`HTTP ${response.status}`); signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: buildFetchHeaders(url, requestOrigin, cookie),
});
if (!response.ok) {
throw new ImageFetchError(
`图片下载失败:HTTP ${response.status}`,
response.status === 408 || response.status === 429 || response.status >= 500,
);
}
const contentType = response.headers.get("content-type") || "";
const normalizedContentType = contentType.toLowerCase();
if (
normalizedContentType &&
!normalizedContentType.startsWith("image/") &&
(normalizedContentType.startsWith("text/") ||
normalizedContentType.includes("json") ||
normalizedContentType.includes("xml"))
) {
throw new ImageFetchError("返回内容不是图片");
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new ImageFetchError("图片内容为空");
}
return buffer;
} catch (error) {
lastError = error;
if (attempt >= FETCH_MAX_ATTEMPTS || !isRetryableFetchError(error)) {
break;
}
logger.warn("image_fetch_retry", {
attempt,
maxAttempts: FETCH_MAX_ATTEMPTS,
timeoutMs: FETCH_TIMEOUT_MS,
reason: getErrorMessage(error),
});
await sleep(FETCH_RETRY_BASE_DELAY_MS * attempt);
}
} }
const contentType = response.headers.get("content-type") || "";
const normalizedContentType = contentType.toLowerCase(); throw new ImageFetchError(toPublicErrorMessage(lastError), false);
if (
normalizedContentType &&
!normalizedContentType.startsWith("image/") &&
(normalizedContentType.startsWith("text/") ||
normalizedContentType.includes("json") ||
normalizedContentType.includes("xml"))
) {
throw new Error("返回内容不是图片");
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error("图片内容为空");
}
return buffer;
} }
function resolvePreviewWidth(items: Array<{ width: number; height: number }>) { function resolvePreviewWidth(items: Array<{ width: number; height: number }>) {
@ -356,7 +425,9 @@ async function applyStitch(params: {
return sharp(buffer, { failOn: "none" }) return sharp(buffer, { failOn: "none" })
.rotate() .rotate()
.resize(params.width, source.height, { .resize(params.width, source.height, {
fit: "fill", fit: "contain",
position: "centre",
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false, withoutEnlargement: false,
}) })
.jpeg({ .jpeg({
@ -475,6 +546,8 @@ async function applyStitch(params: {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const startedAt = Date.now();
let mode = "unknown";
try { try {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
@ -482,7 +555,7 @@ export async function POST(request: NextRequest) {
} }
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const mode = String(body.mode || "").trim(); mode = String(body.mode || "").trim();
const requestOrigin = getRequestOrigin(request); const requestOrigin = getRequestOrigin(request);
const cookie = request.headers.get("cookie") || ""; const cookie = request.headers.get("cookie") || "";
@ -493,6 +566,12 @@ export async function POST(request: NextRequest) {
} }
const data = await prepareStitch({ references, requestOrigin, cookie }); const data = await prepareStitch({ references, requestOrigin, cookie });
logger.info("prepare_completed", {
referenceCount: references.length,
sourceSegmentCount: data.sourceSegments.length,
failedCount: data.failed.length,
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ success: true, data }); return NextResponse.json({ success: true, data });
} }
@ -558,16 +637,25 @@ export async function POST(request: NextRequest) {
requestOrigin, requestOrigin,
cookie, cookie,
}); });
logger.info("apply_completed", {
sourceSegmentCount: sourceSegments.length,
cutLineCount: normalizeCutLines(body.cutLines, height).length,
outputCount: data.cards.length,
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ success: true, data }); return NextResponse.json({ success: true, data });
} }
return NextResponse.json({ success: false, error: "未知操作" }, { status: 400 }); return NextResponse.json({ success: false, error: "未知操作" }, { status: 400 });
} catch (error) { } catch (error) {
console.error("[DetailStitch API] Failed:", error); logger.error("request_failed", error, {
mode,
durationMs: Date.now() - startedAt,
});
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error instanceof Error ? error.message : "拼合切割失败", error: toPublicErrorMessage(error),
}, },
{ status: 500 }, { status: 500 },
); );

View File

@ -8,6 +8,8 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries"; import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
import { import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
buildAgentWorkflowMaterialApiUrl,
isAgentWorkflowMaterialResourceEnabled,
normalizeAgentWorkflowMaterialApiConfig, normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config"; } from "@/lib/agents/workflow-material-config";
import { import {
@ -34,6 +36,7 @@ type ExternalImageRecord = {
label?: string; label?: string;
fileName?: string; fileName?: string;
fileSize?: number; fileSize?: number;
previewUrl?: string;
downloadUrl?: string; downloadUrl?: string;
}; };
@ -81,6 +84,7 @@ type MaterialSearchImage = {
label: string; label: string;
fileName: string; fileName: string;
imageUrl: string; imageUrl: string;
previewUrl: string | null;
assetId: string | null; assetId: string | null;
mimeType: string; mimeType: string;
group: "fixed" | "other"; group: "fixed" | "other";
@ -95,6 +99,14 @@ function sanitizeGoodsInfoText(value: unknown, maxLength = 255) {
return String(value || "").trim().slice(0, maxLength); return String(value || "").trim().slice(0, maxLength);
} }
function deriveSpuFromSku(value: unknown) {
const normalized = String(value || "").trim().toUpperCase();
if (!/[0-9]{2}$/.test(normalized) || normalized.length <= 2) {
return "";
}
return normalized.slice(0, -2);
}
function createGoodsInfoSignature(timespan: string, secret: string) { function createGoodsInfoSignature(timespan: string, secret: string) {
const secretBuffer = Buffer.from(String(secret || "").trim(), "utf8"); const secretBuffer = Buffer.from(String(secret || "").trim(), "utf8");
if (secretBuffer.length !== 8) { if (secretBuffer.length !== 8) {
@ -611,6 +623,7 @@ function mapExternalImage(params: {
label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`, label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`,
fileName: filename, fileName: filename,
imageUrl: downloadUrl, imageUrl: downloadUrl,
previewUrl: String(params.image.previewUrl || "").trim() || null,
assetId: null, assetId: null,
mimeType: inferMimeTypeFromFilename(filename), mimeType: inferMimeTypeFromFilename(filename),
group: params.group, group: params.group,
@ -666,6 +679,12 @@ export async function GET(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
if (!isAgentWorkflowMaterialResourceEnabled(config, "white-background")) {
return NextResponse.json(
{ error: "白底图素材接口未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) { if (!config.apiKey) {
return NextResponse.json( return NextResponse.json(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" }, { error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
@ -685,15 +704,18 @@ export async function GET(request: NextRequest) {
return null; return null;
}) })
: Promise.resolve(null); : Promise.resolve(null);
const upstreamUrl = `${normalizeBaseUrl( const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, {
config.baseUrl resourceType: "white-background",
)}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`; queryType,
keyword: normalizedKeyword,
});
const upstreamResponse = await fetch(upstreamUrl, { const upstreamResponse = await fetch(upstreamUrl, {
method: "GET", method: "GET",
headers: { headers: {
"X-API-Key": config.apiKey, "X-API-Key": config.apiKey,
}, },
cache: "no-store", cache: "no-store",
signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000),
}); });
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse; const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse;
@ -814,7 +836,7 @@ export async function GET(request: NextRequest) {
query: normalizedKeyword, query: normalizedKeyword,
queryType: "sku", queryType: "sku",
matchedSku, matchedSku,
matchedSpu: goodsInfo?.matchedSpu || productModel, matchedSpu: goodsInfo?.matchedSpu || productModel || deriveSpuFromSku(matchedSku),
materialId: String(upstreamResult.data.materialId || "").trim() || null, materialId: String(upstreamResult.data.materialId || "").trim() || null,
productModel, productModel,
zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null, zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null,

View File

@ -6,6 +6,8 @@ import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries"; import { getSystemSetting } from "@/lib/db/queries";
import { import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
buildAgentWorkflowMaterialApiUrl,
isAgentWorkflowMaterialResourceEnabled,
normalizeAgentWorkflowMaterialApiConfig, normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config"; } from "@/lib/agents/workflow-material-config";
import { import {
@ -33,6 +35,7 @@ const querySchema = z.object({
target: z.enum(["main", "detail", "sku"]), target: z.enum(["main", "detail", "sku"]),
brandName: z.string().trim().max(64).optional(), brandName: z.string().trim().max(64).optional(),
channel: productDetailChannelSchema, channel: productDetailChannelSchema,
strictQueryType: z.enum(["true", "false", "1", "0"]).optional(),
}); });
function jsonNoStore( function jsonNoStore(
@ -235,7 +238,35 @@ function getGoodsInfoBrandName(data: Record<string, unknown>) {
); );
} }
async function fetchGoodsBrandByKeyword(params: { function getGoodsInfoMatchedSpu(data: Record<string, unknown>) {
return (
sanitizeText(data["spu"], 64) ||
sanitizeText(data["SPU"], 64) ||
sanitizeText(data["productSpu"], 64) ||
sanitizeText(data["ProductSpu"], 64) ||
sanitizeText(data["商品SPU"], 64) ||
sanitizeText(data["商品spu"], 64) ||
sanitizeText(data["款号"], 64) ||
sanitizeText(data["商品款号"], 64) ||
null
);
}
function getGoodsInfoMatchedSku(data: Record<string, unknown>) {
return (
sanitizeText(data["sku"], 64) ||
sanitizeText(data["SKU"], 64) ||
sanitizeText(data["skuId"], 64) ||
sanitizeText(data["SkuId"], 64) ||
sanitizeText(data["skuoid"], 64) ||
sanitizeText(data["SKUOID"], 64) ||
sanitizeText(data["商品SKU"], 64) ||
sanitizeText(data["商品sku"], 64) ||
null
);
}
async function fetchGoodsLookupByKeyword(params: {
keyword: string; keyword: string;
config: ReturnType<typeof normalizeAgentWorkflowGoodsInfoApiConfig>; config: ReturnType<typeof normalizeAgentWorkflowGoodsInfoApiConfig>;
}) { }) {
@ -269,13 +300,20 @@ async function fetchGoodsBrandByKeyword(params: {
}); });
const result = (await response.json().catch(() => ({}))) as ExternalGoodsInfoResponse; const result = (await response.json().catch(() => ({}))) as ExternalGoodsInfoResponse;
if (response.ok && result.success === true && result.data) { if (response.ok && result.success === true && result.data) {
const brandName = getGoodsInfoBrandName(result.data); const data = result.data;
if (brandName) { const brandName = getGoodsInfoBrandName(data);
return brandName; const matchedSpu = getGoodsInfoMatchedSpu(data);
const matchedSku = getGoodsInfoMatchedSku(data);
if (brandName || matchedSpu || matchedSku) {
return {
brandName,
matchedSpu,
matchedSku,
};
} }
} }
} catch (error) { } catch (error) {
console.warn("[Agent Workflow Reference Search] Goods brand query failed:", { console.warn("[Agent Workflow Reference Search] Goods info lookup failed:", {
requestPath, requestPath,
error, error,
}); });
@ -433,13 +471,11 @@ async function requestTaskDetailAttempt(params: {
verifiedGoodsBrandName?: string | null; verifiedGoodsBrandName?: string | null;
config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>; config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>;
}) { }) {
const upstreamParams = new URLSearchParams({ const upstreamUrl = buildAgentWorkflowMaterialApiUrl(params.config, {
resourceType: "task-detail", 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, { const upstreamResponse = await fetch(upstreamUrl, {
method: "GET", method: "GET",
@ -447,6 +483,7 @@ async function requestTaskDetailAttempt(params: {
"X-API-Key": params.config.apiKey, "X-API-Key": params.config.apiKey,
}, },
cache: "no-store", cache: "no-store",
signal: AbortSignal.timeout(params.config.requestTimeoutMs || 30_000),
}); });
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse; const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
const upstreamBrandName = getExternalBrandName(upstreamResult.data || {}); const upstreamBrandName = getExternalBrandName(upstreamResult.data || {});
@ -854,6 +891,8 @@ export async function GET(request: NextRequest) {
target: request.nextUrl.searchParams.get("target"), target: request.nextUrl.searchParams.get("target"),
brandName: request.nextUrl.searchParams.get("brandName") || undefined, brandName: request.nextUrl.searchParams.get("brandName") || undefined,
channel: request.nextUrl.searchParams.get("channel") || undefined, channel: request.nextUrl.searchParams.get("channel") || undefined,
strictQueryType:
request.nextUrl.searchParams.get("strictQueryType") || undefined,
}); });
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY }); const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
@ -898,6 +937,12 @@ export async function GET(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
if (!isAgentWorkflowMaterialResourceEnabled(config, "task-detail")) {
return jsonNoStore(
{ error: "任务-详情整套素材接口未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) { if (!config.apiKey) {
return jsonNoStore( return jsonNoStore(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" }, { error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
@ -907,15 +952,25 @@ export async function GET(request: NextRequest) {
const materialQueryType = queryType as AgentWorkflowMaterialQueryType; const materialQueryType = queryType as AgentWorkflowMaterialQueryType;
const requestedBrandName = String(parsed.brandName || "").trim(); const requestedBrandName = String(parsed.brandName || "").trim();
const verifiedGoodsBrandName = requestedBrandName const goodsLookup = await fetchGoodsLookupByKeyword({
? await fetchGoodsBrandByKeyword({ keyword: normalizedKeyword,
keyword: normalizedKeyword, config: goodsInfoConfig,
config: goodsInfoConfig, }).catch((error) => {
}).catch((error) => { console.warn("[Agent Workflow Reference Search] Goods info lookup failed:", error);
console.warn("[Agent Workflow Reference Search] Goods brand lookup failed:", error); return null;
return null; });
}) const verifiedGoodsBrandName = goodsLookup?.brandName || null;
: null; const resolvedMaterialKeyword =
materialQueryType === "spu" && goodsLookup?.matchedSpu
? goodsLookup.matchedSpu.trim().toUpperCase()
: normalizedKeyword;
if (materialQueryType === "spu" && resolvedMaterialKeyword !== normalizedKeyword) {
console.info("[Agent Workflow Reference Search] Resolved SPU query keyword:", {
keyword: normalizedKeyword,
resolvedSpu: resolvedMaterialKeyword,
matchedSku: goodsLookup?.matchedSku || null,
});
}
if ( if (
requestedBrandName && requestedBrandName &&
verifiedGoodsBrandName && verifiedGoodsBrandName &&
@ -928,12 +983,15 @@ export async function GET(request: NextRequest) {
}); });
} }
const queryTypesToTry = Array.from( const strictQueryType = parsed.strictQueryType === "true" || parsed.strictQueryType === "1";
new Set<AgentWorkflowMaterialQueryType>([ const queryTypesToTry = strictQueryType
materialQueryType, ? [materialQueryType]
materialQueryType === "spu" ? "sku" : "spu", : Array.from(
]) new Set<AgentWorkflowMaterialQueryType>([
); materialQueryType,
materialQueryType === "spu" ? "sku" : "spu",
])
);
let selectedAttempt: let selectedAttempt:
| Awaited<ReturnType<typeof requestTaskDetailAttempt>> | Awaited<ReturnType<typeof requestTaskDetailAttempt>>
@ -947,7 +1005,7 @@ export async function GET(request: NextRequest) {
for (const currentQueryType of queryTypesToTry) { for (const currentQueryType of queryTypesToTry) {
const attempt = await requestTaskDetailAttempt({ const attempt = await requestTaskDetailAttempt({
keyword: normalizedKeyword, keyword: resolvedMaterialKeyword,
queryType: currentQueryType, queryType: currentQueryType,
target: parsed.target, target: parsed.target,
requestedBrandName, requestedBrandName,
@ -958,6 +1016,7 @@ export async function GET(request: NextRequest) {
if (requestedBrandName) { if (requestedBrandName) {
console.info("[Agent Workflow Reference Search] Brand gate:", { console.info("[Agent Workflow Reference Search] Brand gate:", {
keyword: normalizedKeyword, keyword: normalizedKeyword,
resolvedKeyword: resolvedMaterialKeyword,
queryType: currentQueryType, queryType: currentQueryType,
target: parsed.target, target: parsed.target,
requestedBrandName, requestedBrandName,
@ -1042,6 +1101,7 @@ export async function GET(request: NextRequest) {
if (selectedAttempt.queryType !== materialQueryType) { if (selectedAttempt.queryType !== materialQueryType) {
console.info("[Agent Workflow Reference Search] Query type fallback succeeded:", { console.info("[Agent Workflow Reference Search] Query type fallback succeeded:", {
keyword: normalizedKeyword, keyword: normalizedKeyword,
resolvedKeyword: resolvedMaterialKeyword,
requestedQueryType: materialQueryType, requestedQueryType: materialQueryType,
resolvedQueryType: selectedAttempt.queryType, resolvedQueryType: selectedAttempt.queryType,
target: parsed.target, target: parsed.target,
@ -1059,6 +1119,7 @@ export async function GET(request: NextRequest) {
success: true, success: true,
data: { data: {
query: normalizedKeyword, query: normalizedKeyword,
resolvedQuery: resolvedMaterialKeyword,
queryType: selectedAttempt.queryType, queryType: selectedAttempt.queryType,
target: parsed.target, target: parsed.target,
matchedSpu: String(selectedAttempt.upstreamResult.data?.spu || "").trim() || null, matchedSpu: String(selectedAttempt.upstreamResult.data?.spu || "").trim() || null,

View File

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

View File

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

View File

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

View File

@ -4,23 +4,128 @@ import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { systemSettings } from "@/lib/db/schema"; import { systemSettings } from "@/lib/db/schema";
import { import {
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
CANVAS_SKILL_TAGS_CONFIG_VERSION,
CANVAS_SKILL_TAGS_SETTING_KEY, CANVAS_SKILL_TAGS_SETTING_KEY,
normalizeCanvasSkillTagSettings, normalizeCanvasSkillTagSettings,
} from "@/lib/canvas-skill-tags"; } 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() { export async function GET() {
try { 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 const [setting] = await db
.select() .select()
.from(systemSettings) .from(systemSettings)
.where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY)) .where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
.limit(1); .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); await db
return NextResponse.json(settings); .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) { } catch (error) {
console.error("[CanvasSkillTags API] Get error:", error); console.error("[CanvasSkillTags API] Save order error:", error);
return NextResponse.json(normalizeCanvasSkillTagSettings(null)); 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 normalized = normalizeCanvasSkillTagSettings(body);
const enabled = normalized.enabled; const enabled = normalized.enabled;
const title = normalized.title; const title = normalized.title;
const categories = normalized.categories;
const tags = normalized.tags; 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 const [existing] = await db
.select() .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) { } catch (error) {
console.error("[CanvasSkillTags API] Update error:", error); console.error("[CanvasSkillTags API] Update error:", error);
return NextResponse.json({ error: "Failed to update" }, { status: 500 }); return NextResponse.json({ error: "Failed to update" }, { status: 500 });

View File

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

View File

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

View File

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

View File

@ -3,7 +3,12 @@ import { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema'; import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries'; import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import {
buildStaleCanvasSaveConflictPayload,
isStaleCanvasSaveBaseRevision,
} from '@/lib/canvas/canvas-save-conflict';
function resolveWorkspaceType(input: unknown) { function resolveWorkspaceType(input: unknown) {
return input === "agent" || input === "video" ? input : "canvas"; return input === "agent" || input === "video" ? input : "canvas";
@ -13,19 +18,67 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorksp
return workspaceType === "video" ? "未命名视频画布" : "未命名画布"; 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( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
if (shouldUseLocalCanvasE2E(request)) {
return NextResponse.json({
success: true,
data: createLocalCanvasProject(id),
});
}
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
} }
const { id } = await params;
const [project] = await db const [project] = await db
.select() .select()
.from(canvasProject) .from(canvasProject)
@ -50,85 +103,118 @@ export async function GET(
// 更新画布(支持 upsert:如果不存在则创建) // 更新画布(支持 upsert:如果不存在则创建)
export async function PUT( export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
}
const { id } = await params; const { id } = await params;
const body = await request.json(); const body = await request.json();
const { title, description, thumbnail, canvasData } = body; 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 { title, description, canvasData } = body;
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
const baseCanvasRevision =
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType = const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType") body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
? resolveWorkspaceType(body.workspaceType) ? resolveWorkspaceType(body.workspaceType)
: undefined; : undefined;
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
// 先检查画布是否存在 return await db.transaction(async (tx) => {
const [existingProject] = await db const [existingProject] = await tx
.select() .select()
.from(canvasProject) .from(canvasProject)
.where( .where(
and( and(
eq(canvasProject.id, id), eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id) eq(canvasProject.userId, session.user.id),
),
) )
); .for("update");
if (
if (existingProject) { existingProject &&
// 更新现有画布 canvasData !== undefined &&
const updateData: any = { updatedAt: new Date() }; baseCanvasRevision &&
isStaleCanvasSaveBaseRevision({
baseCanvasRevision: body?.baseCanvasRevision,
baseCanvasUpdatedAt: body?.baseCanvasUpdatedAt,
currentUpdatedAt: existingProject.updatedAt,
})
) {
return NextResponse.json(
buildStaleCanvasSaveConflictPayload(existingProject.updatedAt),
{ status: 409 },
);
}
if (!existingProject) {
// The parent must exist before node/asset foreign keys are synchronized.
const [created] = await tx
.insert(canvasProject)
.values({
id,
userId: session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
workspaceType: workspaceType || "canvas",
canvasData: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
.onConflictDoNothing({ target: canvasProject.id })
.returning();
if (!created)
return NextResponse.json(
{ success: false, error: "画布已被创建,请重新加载后保存" },
{ status: 409 },
);
}
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject(
{ projectId: id, userId: session.user.id, canvasData },
tx,
)
: undefined;
const updateData: any = { updatedAt: new Date(Math.max(Date.now(), (existingProject?.updatedAt.getTime() || 0) + 1)) };
if (title !== undefined) updateData.title = title; if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description; if (description !== undefined) updateData.description = description;
if (thumbnail !== undefined) updateData.thumbnail = thumbnail; if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
if (workspaceType !== undefined) updateData.workspaceType = workspaceType; if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData; if (syncedCanvasData !== undefined)
updateData.canvasData = syncedCanvasData;
const [updatedProject] = await db const [saved] = await tx
.update(canvasProject) .update(canvasProject)
.set(updateData) .set(updateData)
.where( .where(
and( and(
eq(canvasProject.id, id), eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id) eq(canvasProject.userId, session.user.id),
) ),
) )
.returning(); .returning();
return NextResponse.json({ success: true, data: saved });
return NextResponse.json({ success: true, data: updatedProject }); });
} else {
// 创建新画布(使用指定 ID)
const [newProject] = await db
.insert(canvasProject)
.values({
id, // 使用指定的 ID
userId: session.user.id,
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
description,
workspaceType: workspaceType || "canvas",
canvasData: syncedCanvasData || {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
.returning();
return NextResponse.json({ success: true, data: newProject });
}
} catch (error) { } catch (error) {
console.error('更新/创建画布失败:', error); console.error("更新/创建画布失败:", error);
return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 }); return NextResponse.json(
{ success: false, error: "更新画布失败" },
{ status: 500 },
);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(500, Math.round(Number(url.searchParams.get("limit") || 100)))
);
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,664 @@
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 { extractOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts";
import {
buildOpenClawDisplayMessage,
buildOpenClawMessageWithSpreadsheetMarkdown,
buildOpenClawSpreadsheetMarkdown,
} from "@/lib/openclaw-chat/spreadsheet-markdown";
import {
appendPersistedClawdTaskProgress,
getPersistedClawdTask,
markClawdTaskCanceled,
markClawdTaskCompleted,
markClawdTaskFailed,
markClawdTaskRunning,
persistPendingClawdTask,
} from "@/lib/openclaw-chat/task-store";
import {
OpenClawTaskDetachedError,
submitOpenClawDurableTask,
waitForOpenClawDurableTask,
} from "@/lib/openclaw-chat/upstream-task-client";
import { generateUUID } from "@/lib/utils";
const 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: !params.sensitiveInput,
sensitive_input: params.sensitiveInput,
};
if (params.sensitiveInput) {
const timeout = setTimeout(
() =>
controller.abort(
new DOMException("OpenClaw request timed out", "TimeoutError")
),
params.config.timeoutMs
);
try {
const response = await fetch(`${params.config.baseUrl}/api/chat`, {
method: "POST",
headers: {
Authorization: `Bearer ${params.config.token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(upstreamBody),
signal: controller.signal,
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!response.ok) {
throw new Error(`OpenClaw request failed: ${response.status}`);
}
if (!payload) throw new Error("OpenClaw returned an invalid response");
const answer = getAnswer(payload);
if (!answer) throw new Error("OpenClaw returned an empty answer");
await markClawdTaskCompleted({
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
answer,
requestId: getRequestId(payload),
artifacts: extractOpenClawArtifacts(payload, {
baseUrl: params.config.baseUrl,
}),
});
clearClawdRequestCancellation(params.cancelKey);
return;
} finally {
clearTimeout(timeout);
}
}
const target = {
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
};
const upstreamTask = await submitOpenClawDurableTask({
config: params.config,
body: upstreamBody,
idempotencyKey: `${params.userId}:${params.chatId}:${params.userMessageId}`,
target,
signal: controller.signal,
});
const outcome = await waitForOpenClawDurableTask({
config: params.config,
upstreamTask,
target,
deadlineAt: Date.now() + params.config.timeoutMs,
signal: controller.signal,
});
if (isClawdRequestCanceled(params.cancelKey)) return;
if (outcome.status === "completed") {
await markClawdTaskCompleted({
...target,
answer: outcome.answer,
requestId: outcome.requestId,
artifacts: outcome.artifacts,
});
} else if (outcome.status === "canceled") {
await markClawdTaskCanceled({
...target,
message: outcome.error,
});
} else if (outcome.status === "failed") {
await markClawdTaskFailed({
...target,
error: outcome.error,
});
}
clearClawdRequestCancellation(params.cancelKey);
} catch (error) {
if (isClawdRequestCanceled(params.cancelKey)) return;
if (error instanceof OpenClawTaskDetachedError) {
await appendPersistedClawdTaskProgress({
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
progress: {
step_code: "task.detached",
step_state: "waiting",
message: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
display: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
},
}).catch(() => null);
return;
}
console.error("[ClawdChat API] Durable task failed:", error);
await markClawdTaskFailed({
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
error: getOpenClawTaskFailureMessage(error),
}).catch(() => null);
} finally {
if (params.sensitiveInput?.type === "taobao_credentials") {
params.sensitiveInput.username = "";
params.sensitiveInput.password = "";
} else if (params.sensitiveInput?.type === "taobao_verification") {
params.sensitiveInput.verification_code = "";
}
params.sensitiveInput = undefined;
unregisterActiveClawdRequest(params.cancelKey);
}
}
function buildTaskResponse(params: {
chatId: string;
userMessageId: string;
assistantMessageId: string;
status: string;
processingHint: string;
}) {
const statusUrl = `/api/canvas/clawd-chat/status?id=${encodeURIComponent(
params.chatId
)}&userMessageId=${encodeURIComponent(params.userMessageId)}`;
return {
accepted: true,
task: {
chatId: params.chatId,
userMessageId: params.userMessageId,
assistantMessageId: params.assistantMessageId,
status: params.status,
processingHint: params.processingHint,
statusUrl,
},
};
}
export async function POST(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json(
{
error: 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"),
}));
const persistedTask = await persistPendingClawdTask({
chatId: parsed.id,
userMessageId,
assistantMessageId,
displayMessage,
attachments: messageAttachments,
spreadsheetAttachments,
skillTagId,
skillTagName,
processingHint,
});
if (
persistedTask &&
persistedTask.assistantMessageId !== assistantMessageId
) {
return NextResponse.json(
buildTaskResponse({
chatId: parsed.id,
userMessageId,
assistantMessageId: persistedTask.assistantMessageId,
status: persistedTask.status,
processingHint:
persistedTask.processingHint || DEFAULT_PROCESSING_HINT,
}),
{ status: 202, headers: { "Retry-After": "2" } }
);
}
const userProfile = await getUserById({ id: session.user.id }).catch(
() => null
);
const userName = getOpenClawUserName({
id: session.user.id,
name: userProfile?.name ?? session.user.name,
email: userProfile?.email ?? session.user.email,
});
const 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,191 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById, getSystemSetting } from "@/lib/db/queries";
import {
buildClawdCancelKey,
hasActiveClawdRequest,
registerActiveClawdRequest,
unregisterActiveClawdRequest,
} from "@/lib/openclaw-chat/cancel-registry";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import {
OPENCLAW_AUTH_REQUIRED_CODE,
OPENCLAW_AUTH_REQUIRED_MESSAGE,
} from "@/lib/openclaw-chat/errors";
import {
getPersistedClawdTask,
markClawdTaskCanceled,
markClawdTaskCompleted,
markClawdTaskFailed,
} from "@/lib/openclaw-chat/task-store";
import { isPendingClawdTaskStatus } from "@/lib/openclaw-chat/task-state";
import {
getPersistedUpstreamTaskState,
refreshOpenClawDurableTask,
} from "@/lib/openclaw-chat/upstream-task-client";
const statusSchema = z.object({
id: z.string().uuid(),
userMessageId: z.string().uuid().optional(),
});
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const maxDuration = 60;
async function reconcileDetachedOpenClawTask(params: {
chatId: string;
userId: string;
task: NonNullable<Awaited<ReturnType<typeof getPersistedClawdTask>>>;
}) {
if (!isPendingClawdTaskStatus(params.task.metadata.status)) return;
const upstreamTask = getPersistedUpstreamTaskState(params.task.metadata);
if (!upstreamTask) return;
const cancelKey = buildClawdCancelKey({
userId: params.userId,
chatId: params.chatId,
userMessageId: params.task.metadata.userMessageId,
});
if (hasActiveClawdRequest(cancelKey)) return;
const controller = new AbortController();
registerActiveClawdRequest(cancelKey, controller, 30_000);
const target = {
chatId: params.chatId,
assistantMessageId: params.task.metadata.assistantMessageId,
};
try {
const setting = await getSystemSetting({
key: OPENCLAW_CHAT_CONFIG_KEY,
}).catch(() => null);
const config = normalizeOpenClawChatConfig(setting?.value);
if (!config.enabled || !config.baseUrl || !config.token) return;
const outcome = await refreshOpenClawDurableTask({
config,
upstreamTask,
target,
signal: controller.signal,
});
if (outcome.status === "completed") {
await markClawdTaskCompleted({
...target,
answer: outcome.answer,
requestId: outcome.requestId,
artifacts: outcome.artifacts,
});
} else if (outcome.status === "failed") {
await markClawdTaskFailed({ ...target, error: outcome.error });
} else if (outcome.status === "canceled") {
await markClawdTaskCanceled({ ...target, message: outcome.error });
}
} catch (error) {
if (!controller.signal.aborted) {
console.warn("[ClawdChat Status] Upstream reconciliation deferred:", {
chatId: params.chatId,
taskId: upstreamTask.taskId,
error,
});
}
} finally {
unregisterActiveClawdRequest(cancelKey);
}
}
export async function GET(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json(
{
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
code: OPENCLAW_AUTH_REQUIRED_CODE,
},
{ status: 401 }
);
}
const url = new URL(request.url);
const parsed = statusSchema.safeParse({
id: url.searchParams.get("id"),
userMessageId: url.searchParams.get("userMessageId") || undefined,
});
if (!parsed.success) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
const chat = await getChatById({ id: parsed.data.id }).catch(() => null);
if (!chat) {
return NextResponse.json({ task: null }, { status: 404 });
}
if (chat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let task = await getPersistedClawdTask({
chatId: parsed.data.id,
userMessageId: parsed.data.userMessageId,
});
if (!task) {
return NextResponse.json(
{ task: null },
{ headers: { "Cache-Control": "no-store, max-age=0" } }
);
}
await reconcileDetachedOpenClawTask({
chatId: parsed.data.id,
userId: session.user.id,
task,
});
task =
(await getPersistedClawdTask({
chatId: parsed.data.id,
userMessageId: parsed.data.userMessageId,
})) || task;
const userTextPart = Array.isArray(task.userMessage?.parts)
? task.userMessage.parts.find(
(part) =>
part &&
typeof part === "object" &&
!Array.isArray(part) &&
(part as { type?: unknown }).type === "text"
)
: null;
const userText =
userTextPart && typeof userTextPart === "object"
? String((userTextPart as { text?: unknown }).text || "")
: "";
return NextResponse.json(
{
task: {
chatId: parsed.data.id,
userMessageId: task.metadata.userMessageId,
assistantMessageId: task.metadata.assistantMessageId,
status: task.metadata.status,
processingHint: task.metadata.processingHint,
progressEvents: task.metadata.progressEvents,
skillTagId: task.metadata.skillTagId || null,
skillTagName: task.metadata.skillTagName || null,
requestId: task.metadata.requestId || null,
error: task.metadata.error || null,
createdAt: task.metadata.createdAt,
updatedAt: task.metadata.updatedAt,
userMessage: {
text: userText,
attachments: task.userMessage?.attachments || [],
},
},
},
{
headers: {
"Cache-Control": "no-store, max-age=0",
"Retry-After": "2",
},
}
);
}

View File

@ -49,6 +49,10 @@ const DETAIL_PLAN_INLINE_RETRIES = 1;
const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000; const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000;
const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000; const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000;
const logger = createLogger("canvas-detail-plan"); 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 { function isAbortError(error: unknown): boolean {
const target = error as { 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 = { type DetailPlanPreflight = {
passed: boolean; passed: boolean;
designDirectorScore: number; designDirectorScore: number;
@ -1230,9 +1305,13 @@ function buildFallbackPlan(params: {
...params, ...params,
globalStyle, globalStyle,
}); });
const screens = enforceCoreFocusOnScreens({ const screens = normalizeDetailModelUsage({
screens: fallbackScreens, screens: enforceCoreFocusOnScreens({
coreFocus: params.coreFocus, screens: fallbackScreens,
coreFocus: params.coreFocus,
language,
}),
hasModelImage: params.hasModelImage,
language, language,
}); });
const qualityChecks = evaluatePlanQuality({ const qualityChecks = evaluatePlanQuality({
@ -1877,7 +1956,7 @@ export async function POST(request: Request) {
(item) => (item) =>
!item.model.isSubModel && !item.model.isSubModel &&
item.model.isEnabled && item.model.isEnabled &&
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type), ["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
); );
const configuredDetailAgentConfig = const configuredDetailAgentConfig =
currentSiteSettings?.canvasDetailAgentModelId && currentSiteSettings?.canvasDetailAgentModelId &&
@ -2181,27 +2260,28 @@ export async function POST(request: Request) {
dslV2Screens?: unknown; dslV2Screens?: unknown;
}>(text || ""); }>(text || "");
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle); const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) => const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language);
hasModelImage const normalizedScreensWithoutBundles = rawScreens.map((item) =>
? item
: {
...item,
useModel: false,
modelReason: "未提供模特图,当前分镜不使用人物。",
}
);
const normalizedScreens = rawScreens.map((item) =>
hasBundleImages hasBundleImages
? item ? item
: { : {
...item, ...item,
bundleShowcase: false, bundleShowcase: false,
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
} }
); );
const coreFocusedScreens = enforceCoreFocusOnScreens({ const normalizedScreens = normalizeDetailModelUsage({
screens: normalizedScreens, screens: normalizedScreensWithoutBundles,
coreFocus, hasModelImage,
language,
});
const coreFocusedScreens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: normalizedScreens,
coreFocus,
language,
}),
hasModelImage,
language, language,
}); });
const qualityChecksInitial = evaluatePlanQuality({ const qualityChecksInitial = evaluatePlanQuality({
@ -2225,7 +2305,7 @@ export async function POST(request: Request) {
hasBundleImages, hasBundleImages,
}); });
const preflightScreensRaw = preflight.candidateScreens.map((item) => const preflightScreensWithoutBundles = preflight.candidateScreens.map((item) =>
hasBundleImages hasBundleImages
? item ? item
: { : {
@ -2234,9 +2314,18 @@ export async function POST(request: Request) {
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
} }
); );
const preflightScreens = enforceCoreFocusOnScreens({ const preflightScreensRaw = normalizeDetailModelUsage({
screens: preflightScreensRaw, screens: preflightScreensWithoutBundles,
coreFocus, hasModelImage,
language,
});
const preflightScreens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: preflightScreensRaw,
coreFocus,
language,
}),
hasModelImage,
language, language,
}); });
const qualityChecksAfterPreflight = evaluatePlanQuality({ const qualityChecksAfterPreflight = evaluatePlanQuality({

View File

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

View File

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

View File

@ -0,0 +1,160 @@
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
import { after, NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
import {
buildAttachmentContentDisposition,
fetchCanvasDownloadAsset,
sanitizeCanvasDownloadFileName,
} from "@/lib/canvas/photoshop-safe-download";
export const runtime = "nodejs";
export const maxDuration = 120;
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
// A proxy candidate for the same original must not repeat the same slow
// upstream request through our own server (and create another HTTP timeout).
const sources = [...new Set(input.urls.map(url => {
const source = extractImageProxySourceUrl(url) || url;
return inferOriginalImageUrl(source) || source;
}))];
for (let attempt = 0; attempt < 3; attempt++) {
signal.throwIfAborted();
for (const url of sources) {
signal.throwIfAborted();
const result = await fetchCanvasDownloadAsset({
file: { url, name: "original" }, index: 0,
requestOrigin: input.requestOrigin, cookie: input.cookie,
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
});
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
}
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
// No AI/model request is involved and a completed source fetch is shared.
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
}
throw new Error("Original image sources unavailable");
}
function getRequestOrigin(request: NextRequest): string {
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
if (forwardedHost && forwardedProto) {
return `${forwardedProto}://${forwardedHost}`;
}
return new URL(request.url).origin;
}
function resolveDownloadUrl(rawUrl: unknown, origin: string): string {
const value = String(rawUrl || "").trim();
if (!value) return "";
try {
const parsed = new URL(value, origin);
if (!/^https?:$/i.test(parsed.protocol)) return "";
return parsed.toString();
} catch (_error) {
return "";
}
}
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();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request);
const urls = resolveDownloadUrls(body, requestOrigin);
const requestedUrls = [...urls];
if (urls.length === 0) {
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
}
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
if (body.preferDirect === true) {
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
if (task?.targetUrl) {
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
after(() => prepareImageArchiveDownload(task.id));
}
}
const cookie = request.headers.get("cookie") || "";
if (body.preparation === "async") {
const result = await getDownloadPreparationQueue(prepareDownload, after).request({
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
});
if (result.status === "preparing") {
return NextResponse.json({ delivery: "preparing", retryAfter: 3,
message: "正在准备原图,完成后将自动下载" }, {
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
});
}
if (result.status === "failed") {
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
}
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
"Content-Type": result.asset.contentType,
"Content-Length": String(result.asset.buffer.length),
"Content-Disposition": buildAttachmentContentDisposition(`${name}.png`),
"Cache-Control": "no-store",
} });
}
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
const failures: string[] = [];
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 || "图片源文件暂时不可访问" },
{ status: 502 }
);
}
return new NextResponse(result.bytes, {
status: 200,
headers: {
"Content-Type": result.contentType,
"Content-Length": String(result.bytes.length),
"Content-Disposition": buildAttachmentContentDisposition(result.filename),
"Cache-Control": "no-store",
},
});
} catch (error) {
console.error("[CanvasDownloadImage] Failed:", error);
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
}
}

View File

@ -0,0 +1,238 @@
import { writeDownloadZip, readDownloadZip } from "@/lib/canvas/download-zip-stream";
import { claimZipTask, loadZipTask, saveZipTask, zipTaskPath, assertZipDiskSpace, cleanupZipTasks, assertZipTaskCapacity, type ZipTask } from "@/lib/canvas/download-zip-task-store";
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
import { createReadStream } from "node:fs";
import { mkdir, stat, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { after, NextRequest, NextResponse } from "next/server";
import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
export const runtime = "nodejs";
export const maxDuration = 300;
type DownloadFileItem = { url: string; name?: string };
const MAX_DOWNLOAD_FILES = 200;
const TASK_TTL_MS = 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const active = running.__canvasZipRunning ??= new Set<string>();
function sanitizeFolderName(value: string): string {
const cleaned = String(value || "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.trim();
return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `canvas-images-${Date.now()}`;
}
function buildContentDisposition(folderName: string): string {
const encodedName = encodeURIComponent(`${folderName}.zip`);
const asciiName =
folderName
.replace(/[^\x20-\x7E]+/g, "")
.replace(/"/g, "")
.trim() || "canvas-images";
return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`;
}
function getRequestOrigin(request: NextRequest): string {
const forwardedHost = (request.headers.get("x-forwarded-host") || "")
.split(",")[0]
?.trim();
const forwardedProto = (request.headers.get("x-forwarded-proto") || "")
.split(",")[0]
?.trim();
if (forwardedHost && forwardedProto) {
return `${forwardedProto}://${forwardedHost}`;
}
return new URL(request.url).origin;
}
function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
if (!Array.isArray(input)) return [];
return input
.map((item) => {
const raw = (item || {}) as Record<string, unknown>;
const rawUrl = String(raw.url || "").trim();
if (!rawUrl) return null;
let resolvedUrl = "";
try {
const parsed = new URL(rawUrl, origin);
if (!/^https?:$/i.test(parsed.protocol)) return null;
resolvedUrl = parsed.toString();
} catch (_error) {
return null;
}
const name = String(raw.name || "").trim();
const nextItem: DownloadFileItem = { url: resolvedUrl };
if (name) {
nextItem.name = name;
}
return nextItem;
})
.filter((item): item is DownloadFileItem => item !== null)
.slice(0, MAX_DOWNLOAD_FILES);
}
function serializeTask(task: ZipTask) {
return { id: task.id, folderName: task.folderName, status: task.status, stage: task.stage,
totalCount: task.totalCount, processedCount: task.processedCount, successCount: task.successCount,
failedCount: task.failedCount, progress: task.status === "completed" ? 100 : Math.min(99,
Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)),
error: task.error || "", expiresAt: task.expiresAt,
downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" };
}
async function processTask(id: string, userId: string, requestOrigin: string, cookie: string) {
if (active.has(id) || active.size >= 2) return;
active.add(id);
let release: (() => Promise<void>) | null = null;
let task: ZipTask | null = null;
try {
release = await claimZipTask(id);
if (!release) return;
task = await loadZipTask(id, userId);
if (!task || task.status === "completed" || task.status === "failed") return;
await assertZipDiskSpace();
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
await saveZipTask(task);
// Stage each image on disk, not all PNG buffers in the web process heap.
// A restart resumes from these files using the current owner's session.
let cursor = 0;
let writes = Promise.resolve();
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
while (cursor < task!.files.length) {
const index = cursor++; const file = task!.files[index];
const path = join(zipTaskPath(id), `${index}.image`);
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
delete file.prepared; delete file.filename;
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
if (result.ok) {
await assertZipDiskSpace(result.bytes.length);
await writeFile(path, result.bytes, { mode: 0o600 });
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
} else file.error = result.error;
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
task!.successCount = task!.files.filter(f => f.prepared).length;
task!.failedCount = task!.files.filter(f => f.error).length;
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
task!.updatedAt = Date.now(); await persist();
}
}));
const rejected = results.find(result => result.status === "rejected");
if (rejected?.status === "rejected") throw rejected.reason;
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
for (const file of task.files) if (file.prepared && file.filename)
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName);
task.stage = "正在准备下载链接"; await saveZipTask(task);
try {
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (cdn.url) task.cdnUrl = cdn.url;
}
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes.
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
} catch (error) {
if (task) {
task.status = "failed"; task.stage = "打包未完成";
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
}
console.error("[CanvasDownloadZipTask] Preparation failed", { taskId: id, message: task?.error });
} finally { await release?.().catch(() => undefined); active.delete(id); }
}
function schedule(task: ZipTask, request: NextRequest) {
const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "");
after(run);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = await request.json();
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
let task = await loadZipTask(id, session.user.id);
if (!task) {
await assertZipTaskCapacity(session.user.id);
if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID();
const files = toDownloadItems(body.files, getRequestOrigin(request));
if (!files.length || !Array.isArray(body.files) || body.files.length > MAX_DOWNLOAD_FILES || files.length !== body.files.length)
return NextResponse.json({ error: "请选择 1 到 200 张可下载图片" }, { status: 400 });
// Exclusive creation prevents an arbitrary taskId from overwriting another owner's manifest.
await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => {
if (error.code !== "ENOENT") throw error;
await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 });
await mkdir(zipTaskPath(id), { mode: 0o700 });
});
const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS };
await saveZipTask(task);
} else if (task.status === "failed" && body.retry === true) {
task.status = "pending"; task.error = undefined;
await assertZipTaskCapacity(session.user.id);
for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
await saveZipTask(task);
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); }));
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
} catch (error) {
console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message });
return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 });
}
}
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const id = request.nextUrl.searchParams.get("taskId") || "";
const task = await loadZipTask(id, session.user.id);
if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 });
if (request.nextUrl.searchParams.get("download") === "1") {
if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 });
if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1")
return NextResponse.redirect(task.cdnUrl, 307);
const info = await stat(task.zipFile.path).catch(() => null);
if (!info || info.size !== task.zipFile.bytes) return NextResponse.json({ error: "下载文件已过期,请重新打包" }, { status: 410 });
const etag = `"${task.id}-${info.size}"`;
const headers: Record<string, string> = { "Content-Type": "application/zip", "Content-Disposition": task.contentDisposition!,
"Accept-Ranges": "bytes", "ETag": etag, "Cache-Control": "private, no-store",
"X-Download-Success-Count": String(task.successCount), "X-Download-Failed-Count": String(task.failedCount) };
const ifRange = request.headers.get("if-range");
let range;
try { range = parseDownloadByteRange(!ifRange || ifRange === etag ? request.headers.get("range") : null, info.size); }
catch { return new NextResponse(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${info.size}` } }); }
headers["Content-Length"] = String(range ? range.end - range.start + 1 : info.size);
if (range) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${info.size}`;
return new NextResponse(readDownloadZip(task.zipFile.path, range || undefined), { status: range ? 206 : 200, headers });
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
} catch {
return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 });
}
}

View File

@ -1,6 +1,8 @@
import { streamDownloadZip } from "@/lib/canvas/download-zip-stream";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import JSZip from "jszip"; import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 300; export const maxDuration = 300;
@ -10,40 +12,8 @@ type DownloadFileItem = {
name?: string; name?: string;
}; };
const FETCH_TIMEOUT_MS = 30000;
const FETCH_RETRY_COUNT = 3;
const MAX_CONCURRENCY = 8; const MAX_CONCURRENCY = 8;
const MAX_DOWNLOAD_FILES = 200; const MAX_DOWNLOAD_FILES = 200;
const RETRYABLE_STATUS_CODES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
function sanitizeFileName(value: string, fallback: string): string {
const cleaned = String(value || "")
.replace(/\.[a-zA-Z0-9]{2,6}$/g, "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.trim();
return cleaned || fallback;
}
function inferExt(url: string, contentType: string): string {
const type = String(contentType || "").toLowerCase();
if (type.includes("jpeg")) return "jpg";
if (type.includes("png")) return "png";
if (type.includes("webp")) return "webp";
if (type.includes("gif")) return "gif";
if (type.includes("bmp")) return "bmp";
if (type.includes("tiff")) return "tiff";
if (type.includes("mp4")) return "mp4";
if (type.includes("webm")) return "webm";
if (type.includes("quicktime")) return "mov";
const cleanUrl = String(url || "").split("?")[0] || "";
const matched = cleanUrl.match(/\.([a-zA-Z0-9]{2,6})$/);
if (matched?.[1]) return matched[1].toLowerCase();
return "png";
}
function sanitizeFolderName(value: string): string { function sanitizeFolderName(value: string): string {
const cleaned = String(value || "") const cleaned = String(value || "")
@ -65,23 +35,6 @@ function buildContentDisposition(folderName: string): string {
return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`; return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`;
} }
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
function getRequestOrigin(request: NextRequest): string { function getRequestOrigin(request: NextRequest): string {
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim(); const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim(); const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
@ -116,94 +69,6 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
.slice(0, MAX_DOWNLOAD_FILES); .slice(0, MAX_DOWNLOAD_FILES);
} }
function buildRemoteFetchHeaders(fileUrl: string, requestOrigin: string, cookie: string): HeadersInit {
const headers: Record<string, string> = {
Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (compatible; BuyiCanvasDownloader/1.0; +https://buyi.ai)",
Referer: requestOrigin,
};
try {
const targetOrigin = new URL(fileUrl).origin;
if (targetOrigin === requestOrigin && cookie) {
headers.cookie = cookie;
}
} catch (_error) {
// URL has already been normalized before this point; keep this defensive.
}
return headers;
}
function normalizeDownloadError(error: unknown): string {
if (error instanceof Error) return error.message || "download failed";
if (typeof error === "string") return error;
return "download failed";
}
async function fetchDownloadFile(params: {
file: DownloadFileItem;
index: number;
requestOrigin: string;
cookie: string;
}): Promise<
| { ok: true; filename: string; bytes: Buffer }
| { ok: false; url: string; name?: string; error: string }
> {
let lastError = "";
for (let attempt = 1; attempt <= FETCH_RETRY_COUNT; attempt += 1) {
try {
const response = await fetchWithTimeout(
params.file.url,
{
method: "GET",
cache: "no-store",
redirect: "follow",
headers: buildRemoteFetchHeaders(params.file.url, params.requestOrigin, params.cookie),
},
FETCH_TIMEOUT_MS
);
if (!response.ok) {
lastError = `HTTP ${response.status}`;
if (attempt < FETCH_RETRY_COUNT && RETRYABLE_STATUS_CODES.has(response.status)) {
await sleep(350 * attempt);
continue;
}
break;
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length === 0) {
throw new Error("empty response");
}
const ext = inferExt(params.file.url, response.headers.get("content-type") || "");
const baseName = sanitizeFileName(
params.file.name || `canvas-image-${params.index + 1}`,
`canvas-image-${params.index + 1}`
);
const sequencePrefix = String(params.index + 1).padStart(3, "0");
const filename = `${sequencePrefix}-${baseName}.${ext}`;
return { ok: true, filename, bytes };
} catch (error) {
lastError = normalizeDownloadError(error);
if (attempt < FETCH_RETRY_COUNT) {
await sleep(350 * attempt);
}
}
}
return {
ok: false,
url: params.file.url,
name: params.file.name,
error: lastError || "图片下载失败",
};
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const session = await auth(); const session = await auth();
@ -238,7 +103,7 @@ export async function POST(request: NextRequest) {
const folder = zip.folder(folderName) || zip; const folder = zip.folder(folderName) || zip;
const cookie = request.headers.get("cookie") || ""; const cookie = request.headers.get("cookie") || "";
const results: Array< const results: Array<
| { ok: true; filename: string; bytes: Buffer } | { ok: true; filename: string; bytes: Buffer; contentType: string }
| { ok: false; url: string; name?: string; error: string } | { ok: false; url: string; name?: string; error: string }
| null | null
> = new Array(files.length).fill(null); > = new Array(files.length).fill(null);
@ -252,7 +117,7 @@ export async function POST(request: NextRequest) {
cursor += 1; cursor += 1;
if (index >= files.length) break; if (index >= files.length) break;
const file = files[index]!; const file = files[index]!;
results[index] = await fetchDownloadFile({ results[index] = await fetchCanvasDownloadAsset({
file, file,
index, index,
requestOrigin, requestOrigin,
@ -295,13 +160,7 @@ export async function POST(request: NextRequest) {
console.warn("[CanvasDownloadZip] Partial download failures:", failures); console.warn("[CanvasDownloadZip] Partial download failures:", failures);
} }
const zipBuffer = await zip.generateAsync({ return new NextResponse(streamDownloadZip(zip), {
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
return new NextResponse(zipBuffer, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/zip", "Content-Type": "application/zip",

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