Compare commits

..

519 Commits

Author SHA1 Message Date
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
77b25662b4 fix: send layout drafts to fal scale edits 2026-07-04 13:40:02 +08:00
3493 changed files with 652443 additions and 28596 deletions

View File

@ -9,6 +9,13 @@
# Generate with: openssl rand -base64 32
AUTH_SECRET=replace-with-random-secret
# Stable pepper for user Access Key hashes. Generate independently from AUTH_SECRET.
ACCESS_KEY_PEPPER=replace-with-random-secret
# Authorizes trusted third-party services to provision user-scoped MCP keys.
# Must contain at least 32 characters. Never expose it to end users or browsers.
MCP_KEY_PROVISIONING_SECRET=replace-with-random-secret
# Public application URL. Required for production and self-hosted Auth.js.
AUTH_URL=https://your-domain.com
NEXTAUTH_URL=https://your-domain.com
@ -113,6 +120,9 @@ ZOO_BASE_URL=https://api.zoo.dev
# /vendor/pascal-editor/index.html bundle.
NEXT_PUBLIC_PASCAL_EDITOR_URL=
PASCAL_EDITOR_URL=
# Shared only by the main app, Nginx, and the private Pascal service. Generate a
# long random value; never expose it through NEXT_PUBLIC_* variables.
PASCAL_PROXY_SECRET=
# RUXA / video callback.
RUXA_CALLBACK_URL=
@ -143,8 +153,9 @@ PRODUCT_PLANNING_DB_SSL=false
# Home Performance / Queues
# -----------------------------------------------------------------------------
CANVAS_IMAGE_TASK_CONCURRENCY=3
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
CANVAS_IMAGE_TASK_CONCURRENCY=0
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000

19
.gitattributes vendored Normal file
View File

@ -0,0 +1,19 @@
# 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
*.bat text eol=crlf
*.cmd text eol=crlf

44
.gitignore vendored
View File

@ -11,6 +11,9 @@ coverage
# next.js
.next/
.next-build/
.next-previous/
/.next-*/
out/
build
@ -48,6 +51,7 @@ yarn-error.log*
/playwright-report/
/blob-report/
/playwright/*
/output/
deploy.tar.gz
libvips-8.14.5-linux-x64.tar.br
.temp-packages/
@ -64,10 +68,19 @@ migration-backup-complete/
migration-*.log
migration-*.txt
# Local release backups, model credentials and migration scratch data.
/.model-config-backups/
/backups/
/tmp/
# 本地抓包/竞品参考产物,不能提交
artifacts/liblib-*/
analysis/qiaodou-extract/
# 本地运行时数据库与上传文件
.local/
public/uploads/local/
# 模型文件
*.model
*.pth
@ -87,8 +100,37 @@ checkpoints/
# 第三方参考源码(不提交到git)
reference-sources/
# Local self-hosted Pascal Editor checkout.
# Legacy external Pascal Editor checkout (the managed source is under services/).
.pascal-editor/
# Local self-hosted Zoo Design Studio checkout.
.zoo-modeling-app/
# Generated official Zoo Design Studio web bundle.
public/vendor/zoo-modeling-app/
# Drizzle migrations must be versioned (override the global *.sql ignore above)
!lib/db/migrations/*.sql
# 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__/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -75,6 +75,8 @@ export function LocalAiImagePipelineSections({
aiWashConfig,
setAiWashConfig,
aiWashModeIconUploading,
canvasLocalEditConfig,
setCanvasLocalEditConfig,
extendConfig,
setExtendConfig,
watermarkRemoveConfig,
@ -103,6 +105,7 @@ export function LocalAiImagePipelineSections({
handleMoveEnhanceProPreset,
handleAiWashModeIconUpload,
handleSaveAiWashConfig,
handleSaveCanvasLocalEditConfig,
handleSaveExtendConfig,
handleSaveWatermarkRemoveConfig,
handleSaveAngleEditConfig,
@ -156,12 +159,15 @@ export function LocalAiImagePipelineSections({
},
{
label:
rmbgConfig.apiKey ||
rmbgConfig.provider === "vip98k"
? "服务商 Key 已配置"
: rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey
? "Key 已配置"
: "Key 待配置",
tone:
rmbgConfig.provider === "vip98k" ||
rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey
@ -176,7 +182,26 @@ export function LocalAiImagePipelineSections({
{/* 服务提供商选择 */}
<div className="space-y-3">
<Label>抠图服务提供商</Label>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "vip98k"
? "border-primary bg-accent ring-1 ring-primary"
: ""
}`}
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "vip98k" })}
>
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
rmbgConfig.provider === "vip98k"
? "bg-primary text-primary-foreground"
: "bg-transparent"
}`}
>
{rmbgConfig.provider === "vip98k" && <Check className="h-3 w-3" />}
</div>
<p className="font-medium text-sm leading-none">VIP98K 统一服务</p>
</div>
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "rmbg"
@ -263,6 +288,13 @@ export function LocalAiImagePipelineSections({
<Separator />
{rmbgConfig.provider === "vip98k" && (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 remove-background 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,不会在此重复保存。
</div>
)}
{/* RMBG 配置 */}
{rmbgConfig.provider === "rmbg" && (
<>
@ -468,7 +500,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell
description={
<>
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
配置图片变清晰服务,用于画图标注功能中的图片增强处理。
</>
}
expanded={isLocalAiSectionExpanded("enhance")}
@ -478,17 +510,21 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary
items={[
{
label: enhanceConfig.useBackupApi
label: enhanceConfig.provider === "vip98k"
? "VIP98K"
: enhanceConfig.useBackupApi
? "备用 Real-ESRGAN"
: "主 API",
tone: "neutral",
},
{
label:
enhanceConfig.provider === "vip98k" ||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "Key 已配置"
: "Key 待配置",
tone:
enhanceConfig.provider === "vip98k" ||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "enabled"
: "warning",
@ -498,6 +534,13 @@ export function LocalAiImagePipelineSections({
}
title="变清晰API配置"
>
{enhanceConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 enhance 模型,默认 4 倍增强。API Key 从“AI 模型设置”中的
VIP98K 服务商读取。
</div>
) : (
<>
{/* API切换开关 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
@ -622,7 +665,7 @@ export function LocalAiImagePipelineSections({
</p>
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
<p>
• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸)
• 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸)
</p>
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
</div>
@ -741,11 +784,13 @@ export function LocalAiImagePipelineSections({
<p>• 支持2x、4x、8x放大倍数</p>
<p>• 处理时间:通常1-10秒,取决于图片大小</p>
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
<p>• 文件限制:最大50MB</p>
<p>• 文件限制:最大20MB</p>
</div>
</div>
</>
)}
</>
)}
<Button onClick={handleSaveEnhanceConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
@ -1421,6 +1466,112 @@ export function LocalAiImagePipelineSections({
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。
</>
}
expanded={isLocalAiSectionExpanded("canvas-local-edit")}
id="local-ai-section-canvas-local-edit"
onToggle={() => toggleLocalAiSection("canvas-local-edit")}
summary={
<LocalAiSectionSummary
items={[
{
label: canvasLocalEditConfig.modelId
? "普通局部编辑已指定"
: "普通局部编辑继承默认",
tone: canvasLocalEditConfig.modelId ? "enabled" : "neutral",
},
{
label: canvasLocalEditConfig.scaleAdjustModelId
? "比例调整已指定"
: "比例调整兼容旧默认",
tone: canvasLocalEditConfig.scaleAdjustModelId
? "enabled"
: "neutral",
},
]}
/>
}
title="局部编辑模型配置"
>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="canvasLocalEditModel">普通局部编辑默认模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
modelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.modelId || "__none__"}
>
<SelectTrigger id="canvasLocalEditModel">
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于手动画框并填写提示词的局部编辑。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasScaleAdjustModel">比例调整/目标框模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
scaleAdjustModelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
>
<SelectTrigger id="canvasScaleAdjustModel">
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。
</div>
</div>
</div>
<Button onClick={handleSaveCanvasLocalEditConfig}>
<Save className="mr-2 h-4 w-4" /> 保存局部编辑模型配置
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
@ -2215,7 +2366,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell
description={
<>
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
</>
}
expanded={isLocalAiSectionExpanded("extend")}
@ -2225,8 +2376,11 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary
items={[
{
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
tone: extendConfig.apiKey ? "enabled" : "warning",
label:
extendConfig.provider === "vip98k"
? "VIP98K 服务商"
: "阿里云服务商",
tone: "enabled",
},
{
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
@ -2237,22 +2391,18 @@ export function LocalAiImagePipelineSections({
}
title="图像拓展API配置"
>
<div className="space-y-2">
<Label htmlFor="extendApiKey">API密钥 *</Label>
<Input
id="extendApiKey"
onChange={(e) =>
setExtendConfig({ ...extendConfig, apiKey: e.target.value })
}
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
type="password"
value={extendConfig.apiKey}
/>
<div className="text-muted-foreground text-xs">
请输入阿里云Dashscope API密钥,例如:
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
{extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。
</div>
</div>
) : (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用阿里云 DashScope 的 image-out-painting 模型。API Key
从“AI 模型设置”中已启用的阿里云官方生图服务商读取,
仅在这里维护拓展比例。
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
@ -2306,7 +2456,7 @@ export function LocalAiImagePipelineSections({
https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
</p>
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
<p>• 最大图片大小:4MB</p>
<p>• 最大图片大小:10MB</p>
<p>• 处理时间:约5-15秒/张</p>
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
</div>

View File

@ -916,8 +916,8 @@ export function LocalAiModel3dSections({
<LocalAiSectionShell
description={
<>
配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD
服务,保存后写入数据库 AI 提供商配置。
配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI
提供商配置。
</>
}
expanded={isLocalAiSectionExpanded("kittycad-cad")}
@ -1003,7 +1003,7 @@ export function LocalAiModel3dSections({
<div className="space-y-0.5">
<Label className="text-sm">启用 KittyCAD CAD</Label>
<div className="text-muted-foreground text-xs">
关闭后 AI-CAD 页面将无法切换到外部 CAD 服务
关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务
</div>
</div>
<div className="flex items-center gap-2">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
import type { AiWashConfig } from "@/lib/ai-wash/config";
import type {
EnhanceProKreaConfig,
@ -88,6 +89,10 @@ export type AdminAiProvider = {
| "fal-image"
| "snapgen-image"
| "openai-image"
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image"
| "midjourney"
| "fal-video"
@ -244,7 +249,7 @@ export type EnhanceProKeyRow = {
};
export type RmbgConfig = {
provider: "rmbg" | "photoroom" | "aoscdn";
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
apiUrl: string;
apiKey: string;
photoroomApiKey: string;
@ -256,6 +261,7 @@ export type RmbgConfig = {
};
export type EnhanceConfig = {
provider: "vip98k" | "legacy";
apiUrl: string;
apiKey: string;
type: string;
@ -268,7 +274,7 @@ export type EnhanceConfig = {
};
export type ExtendConfig = {
apiKey: string;
provider: "vip98k" | "dashscope";
xScale: string;
yScale: string;
};
@ -327,6 +333,8 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
aiWashConfig: AiWashConfig;
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
aiWashModeIconUploading: boolean;
canvasLocalEditConfig: CanvasLocalEditConfig;
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
extendConfig: ExtendConfig;
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
watermarkRemoveConfig: WatermarkRemoveConfig;
@ -369,6 +377,7 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
handleAiWashModeIconUpload: (file: File) => Promise<void>;
handleSaveAiWashConfig: AsyncAction;
handleSaveCanvasLocalEditConfig: AsyncAction;
handleSaveExtendConfig: AsyncAction;
handleSaveWatermarkRemoveConfig: AsyncAction;
handleSaveAngleEditConfig: AsyncAction;

File diff suppressed because it is too large Load Diff

View File

@ -5933,7 +5933,7 @@ export function AgentWorkflowSettingsClient({
if (isLoading) {
return (
<div className="container mx-auto p-6">
<div className="settings-workspace container mx-auto p-4">
<div className="flex h-[60vh] items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
@ -5945,18 +5945,18 @@ export function AgentWorkflowSettingsClient({
}
return (
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
<div className="settings-workspace min-h-screen bg-muted/10">
{/* Header */}
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-violet-500/20">
<Sparkles className="h-5 w-5 text-white" />
<div className="container mx-auto px-4 py-3 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
<Sparkles className="h-4 w-4 text-background" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">风格化工作流配置</h1>
<p className="text-sm text-muted-foreground">
<h1 className="text-base font-semibold">风格化工作流配置</h1>
<p className="text-xs text-muted-foreground">
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
</p>
</div>
@ -6006,7 +6006,6 @@ export function AgentWorkflowSettingsClient({
onClick={handleSave}
disabled={isSaving}
size="sm"
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500"
>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -6142,8 +6141,8 @@ export function AgentWorkflowSettingsClient({
{/* Stats Bar */}
<div className="border-b bg-muted/30">
<div className="container mx-auto px-6 py-3">
<div className="flex flex-wrap items-center gap-6">
<div className="container mx-auto px-4 py-2.5 sm:px-5">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono">
{enabledStepsCount}
@ -6197,8 +6196,8 @@ export function AgentWorkflowSettingsClient({
</div>
{/* Main Content */}
<main className="container mx-auto p-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<main className="container mx-auto p-4 sm:px-5">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="grid w-full grid-cols-5 lg:w-fit">
<TabsTrigger value="showcase" className="gap-2">
<MonitorPlay className="h-4 w-4" />

View File

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

View File

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

View File

@ -1,5 +1,8 @@
"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 useSWR from "swr";
import { toast } from "sonner";
@ -12,6 +15,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@ -21,15 +25,14 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { fetcher } from "@/lib/utils";
import {
getVideoCanvasFunctionBillingCopy,
type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition,
type VideoCanvasEffectPreset,
type VideoCanvasMotionPreset,
type VideoCanvasRolePreset,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
@ -81,56 +84,23 @@ function getModelOptionLabel(model: AiModelOption) {
return `${name}${id}`;
}
function isImageModelOption(model: AiModelOption) {
return isImageGenerationModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function isVideoModelOption(model: AiModelOption) {
return isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function filterModelsForFunctionKind(
models: AiModelOption[],
kind: VideoCanvasFunctionDefinition["kind"],
) {
const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model));
let filtered = enabled;
if (kind === "image" || kind === "image-edit") {
filtered = enabled.filter(isImageModelOption);
} else if (kind === "video") {
filtered = enabled.filter(isVideoModelOption);
} else if (kind === "multimodal") {
filtered = enabled.filter(
(model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)),
);
}
return filtered.length ? filtered : enabled;
}
function ModelSelect({
allowInherit = false,
kind,
functionKey,
models,
onChange,
value,
}: {
allowInherit?: boolean;
kind: VideoCanvasFunctionDefinition["kind"];
functionKey?: string;
models: AiModelOption[];
onChange: (value: string) => void;
value: string;
}) {
const filteredModels = filterModelsForFunctionKind(models, kind);
const modelKind = functionKey === "audio-to-video" ? "audio-video" : kind === "image" || kind === "image-edit" || kind === "video" || kind === "multimodal" ? kind : "text";
const filteredModels = filterCanvasModels(models, modelKind);
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
const hasCurrent =
normalizedValue === "auto" ||
@ -199,8 +169,9 @@ export function VideoCanvasSettingsClient() {
const functions = useMemo(
() =>
Object.values(config.functions).sort((a, b) => {
const kindCompare = a.kind.localeCompare(b.kind);
return kindCompare || a.label.localeCompare(b.label);
const compare = (left: string, right: string) =>
left === right ? 0 : left < right ? -1 : 1;
return compare(a.kind, b.kind) || compare(a.label, b.label);
}),
[config.functions],
);
@ -225,6 +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 () => {
setSaving(true);
try {
@ -255,11 +355,17 @@ export function VideoCanvasSettingsClient() {
};
return (
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">视频画布功能设置</h1>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
<Button asChild className="mb-2" size="sm" variant="outline">
<Link href="/settings">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
返回设置
</Link>
</Button>
<h1 className="text-xl font-semibold">视频画布功能设置</h1>
<p className="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">
这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。
</p>
</div>
@ -321,6 +427,354 @@ export function VideoCanvasSettingsClient() {
</CardContent>
</Card>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频运镜素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“运镜广场”的名称、预览图和插入提示词都从这里读取。点击运镜卡会按竞品规则把提示词追加到用户输入框,允许组合和重复使用。
</p>
</div>
<Button onClick={addMotionPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加运镜
</Button>
</div>
<div className="grid gap-3">
{config.motionPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-14 shrink-0 rounded-md object-cover"
src={preset.previewUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.prompt || "未设置插入提示词"}
</div>
</div>
<Button
aria-label={`删除运镜 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeMotionPreset(index);
}}
size="icon"
title={`删除运镜 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`motion-name-${index}`}>运镜名称</Label>
<Input
id={`motion-name-${index}`}
onChange={(event) =>
updateMotionPreset(index, { name: event.target.value })
}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`motion-preview-${index}`}>预览图地址</Label>
<Input
id={`motion-preview-${index}`}
onChange={(event) =>
updateMotionPreset(index, { previewUrl: event.target.value })
}
value={preset.previewUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`motion-prompt-${index}`}>插入提示词</Label>
<Textarea
id={`motion-prompt-${index}`}
onChange={(event) =>
updateMotionPreset(index, { prompt: event.target.value })
}
value={preset.prompt}
/>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频特效素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“特效”面板、素材节点封面、模型约束和生成提示词都从这里读取。选择特效时会创建结构化素材节点,不会把描述硬写进用户提示词。
</p>
</div>
<Button onClick={addEffectPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加特效
</Button>
</div>
<div className="grid gap-3">
{config.effectPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-12 w-12 shrink-0 rounded-md object-cover"
src={preset.thumbnailUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.title}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.author} · {preset.model} · {preset.usageCount.toLocaleString()} 次使用
</div>
</div>
<Button
aria-label={`删除特效 ${preset.title}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeEffectPreset(index);
}}
size="icon"
title={`删除特效 ${preset.title}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`effect-title-${index}`}>特效名称</Label>
<Input
id={`effect-title-${index}`}
onChange={(event) => updateEffectPreset(index, { title: event.target.value })}
value={preset.title}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-author-${index}`}>作者</Label>
<Input
id={`effect-author-${index}`}
onChange={(event) => updateEffectPreset(index, { author: event.target.value })}
value={preset.author}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-model-${index}`}>适用模型</Label>
<Input
id={`effect-model-${index}`}
onChange={(event) => updateEffectPreset(index, { model: event.target.value })}
value={preset.model}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-count-${index}`}>展示使用次数</Label>
<Input
id={`effect-count-${index}`}
min={0}
onChange={(event) =>
updateEffectPreset(index, {
usageCount: Math.max(0, Number(event.target.value) || 0),
})
}
type="number"
value={preset.usageCount}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-thumbnail-${index}`}>封面地址</Label>
<Input
id={`effect-thumbnail-${index}`}
onChange={(event) =>
updateEffectPreset(index, { thumbnailUrl: event.target.value })
}
value={preset.thumbnailUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-prompt-${index}`}>生成提示词 / 后端规则</Label>
<Textarea
id={`effect-prompt-${index}`}
onChange={(event) => updateEffectPreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.commercial}
id={`effect-commercial-${index}`}
onCheckedChange={(checked) => updateEffectPreset(index, { commercial: checked })}
/>
<Label htmlFor={`effect-commercial-${index}`}>标记为可商用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频角色素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“角色库”的公共角色、Seedance 合规资产 ID、四类角色图和隐藏生成提示词都从这里读取。角色引用通过画布连线保存,不会改写用户输入框。
</p>
</div>
<Button onClick={addRolePreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加角色
</Button>
</div>
<div className="grid gap-3">
{config.rolePresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-11 shrink-0 rounded-md object-cover object-top"
src={preset.images.full}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.assetId} · {preset.tags || "未设置标签"}
</div>
</div>
<Button
aria-label={`删除角色 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeRolePreset(index);
}}
size="icon"
title={`删除角色 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`role-name-${index}`}>角色名称</Label>
<Input
id={`role-name-${index}`}
onChange={(event) => updateRolePreset(index, { name: event.target.value })}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`role-asset-${index}`}>合规资产 ID</Label>
<Input
id={`role-asset-${index}`}
onChange={(event) => updateRolePreset(index, { assetId: event.target.value })}
value={preset.assetId}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-tags-${index}`}>展示标签</Label>
<Input
id={`role-tags-${index}`}
onChange={(event) => updateRolePreset(index, { tags: event.target.value })}
value={preset.tags}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-description-${index}`}>角色说明</Label>
<Textarea
id={`role-description-${index}`}
onChange={(event) => updateRolePreset(index, { description: event.target.value })}
value={preset.description}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-prompt-${index}`}>生成提示词 / 身份一致性规则</Label>
<Textarea
id={`role-prompt-${index}`}
onChange={(event) => updateRolePreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
{([
["full", "角色立绘"],
["face", "脸部近景"],
["expression", "表情参考"],
["threeView", "三视图"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-${key}-${index}`}>{label}地址</Label>
<Input
id={`role-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
images: { ...preset.images, [key]: event.target.value },
})
}
value={preset.images[key]}
/>
</div>
))}
{([
["gender", "性别"],
["age", "年龄段"],
["species", "种族"],
["era", "时代"],
["culture", "文化区域"],
["build", "体型"],
["hairColor", "发色"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-filter-${key}-${index}`}>{label}</Label>
<Input
id={`role-filter-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
filters: { ...preset.filters, [key]: event.target.value },
})
}
value={preset.filters[key]}
/>
</div>
))}
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.compliant}
id={`role-compliant-${index}`}
onCheckedChange={(checked) => updateRolePreset(index, { compliant: checked })}
/>
<Label htmlFor={`role-compliant-${index}`}>可在 Seedance2.0 合规素材库中使用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
@ -361,13 +815,14 @@ export function VideoCanvasSettingsClient() {
<div className="grid gap-4 md:grid-cols-1">
<div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
<ModelSelect
{["canvas-generate-image", "canvas-generate-video", "canvas-workflow-analyze", "pick-object"].includes(item.route) ? <ModelSelect
allowInherit
functionKey={item.key}
kind={item.kind}
models={modelOptions}
onChange={(value) => updateFunction(item.key, { model: value })}
value={item.model || ""}
/>
/> : <p className="text-sm text-muted-foreground">{item.route === "local" ? "本地处理,无需生成模型" : "使用此功能的专用服务接口"}</p>}
</div>
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
<div className="text-xs font-medium text-foreground">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,8 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media";
import { isProviderBalanceError } from "@/lib/request-error";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
const chatApiLogger = createLogger("chat.imageFailover");
@ -15,6 +17,10 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"runway",
"krea",
@ -53,7 +59,21 @@ export function resolveImageModelRuntimeId(
export function normalizeFailoverErrorMessage(error: unknown): string {
if (!error) return "";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message || String(error);
if (error instanceof Error) {
const record = error as Error & {
status?: unknown;
statusCode?: unknown;
httpStatus?: unknown;
response?: { status?: unknown };
};
const status =
record.status ??
record.statusCode ??
record.httpStatus ??
record.response?.status;
const message = error.message || String(error);
return status ? `[HTTP ${String(status)}] ${message}` : message;
}
try {
return JSON.stringify(error);
} catch {
@ -64,6 +84,7 @@ export function normalizeFailoverErrorMessage(error: unknown): string {
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
const msg = errorMessage.toLowerCase();
return (
isProviderBalanceError(errorMessage) ||
msg.includes("model not found") ||
msg.includes("model_not_found") ||
msg.includes("no available channel for model") ||
@ -72,6 +93,13 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean
msg.includes("api key is missing") ||
msg.includes("invalid api key") ||
msg.includes("unauthorized") ||
/(?:^|\D)401(?:\D|$)/.test(msg) ||
msg === "forbidden" ||
msg.includes("access forbidden") ||
msg.includes("access denied") ||
msg.includes("permission denied") ||
/(?:^|\D)403(?:\D|$)/.test(msg) ||
msg.includes("vip98k task failed") ||
msg.includes("cannot convert argument to a bytestring") ||
msg.includes("model maintenance") ||
msg.includes("maintenance") ||
@ -243,6 +271,10 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
case "fal-image":
case "snapgen-image":
case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "stepfun-image":
case "midjourney":
case "runway":
@ -294,9 +326,9 @@ export function buildImageFallbackCandidates(
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false;
}
if (isImageFallbackModelCoolingDown(item.model.id)) {
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
const cooldown = imageFailoverModelCooldown.get(item.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false;
}
return true;
@ -456,9 +488,9 @@ export function getImageCrossProviderCandidates(
}
if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id)) {
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "cooldown"}`);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false;
}
return true;

View File

@ -138,14 +138,14 @@ export function buildAutoModeFallbackMessage(params: {
: "基于当前上传图片处理。";
return [
"### 需求理解",
"### 意图分析",
`- ${demand}`,
"### 图片分析",
params.imageSummary
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
: `- ${sourceLine}`,
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
"### 执行计划",
"### 任务规划",
"- 先完成本轮核心改动,再检查边缘与光影一致性。",
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
"### 输出设置",
@ -154,9 +154,9 @@ export function buildAutoModeFallbackMessage(params: {
}
return [
"### 需求理解",
"### 意图分析",
`- ${demand}`,
"### 执行计划",
"### 任务规划",
"- 先锁定主体与场景,再细化风格、材质与光影。",
"### 输出设置",
"- 默认单图输出,优先稳定命中。",
@ -222,7 +222,7 @@ export function buildPromptOnlyReply(params: {
export function hasStructuredAgentSections(message?: string): boolean {
if (!message) return false;
return /(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
return /(?:###\s*意图分析|【\s*意图分析\s*】|###\s*任务规划|【\s*任务规划\s*】|###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
message,
);
}
@ -253,6 +253,13 @@ export function ensureStructuredAgentFriendlyMessage(params: {
return fallback;
}
if (fallback.includes("### 任务规划\n")) {
return fallback.replace(
"### 任务规划\n",
`### 任务规划\n- 补充说明:${normalizedSupplement}\n`,
);
}
return fallback.replace(
"### 执行计划\n",
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
@ -517,6 +524,10 @@ export async function convertMessagesWithImages(
normalizedProviderType === "fal-image" ||
normalizedProviderType === "snapgen-image" ||
normalizedProviderType === "openai-image" ||
normalizedProviderType === "bananaimage" ||
normalizedProviderType === "visionary" ||
normalizedProviderType === "atlascloud" ||
normalizedProviderType === "vip98k" ||
normalizedProviderType === "stepfun-image" ||
normalizedProviderType === "midjourney" ||
normalizedProviderType === "runway" ||

View File

@ -7,6 +7,7 @@ import type {
ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types";
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.stream");
@ -187,6 +188,10 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
normalized === "fal-image" ||
normalized === "snapgen-image" ||
normalized === "openai-image" ||
normalized === "bananaimage" ||
normalized === "visionary" ||
normalized === "atlascloud" ||
normalized === "vip98k" ||
normalized === "stepfun-image" ||
normalized === "midjourney" ||
normalized === "runway" ||
@ -421,6 +426,19 @@ export function writeCanvasViewOperationChunk(
);
}
export function writeCanvasCapabilityCommandChunk(
writer: StreamWriter,
command: CanvasCapabilityClientCommand,
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasCapabilityCommand",
command,
label
);
}
type AgentTargetLockRefPreview = {
url: string;
title?: string;

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,7 @@ const imageParamsSchema = z.object({
aspectRatio: z.string().optional(),
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
imageCount: z.number().min(1).max(10).optional(),
quality: z.enum(["auto", "low", "medium", "high"]).optional(),
// 阿里生图专用参数
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
negativePrompt: z.string().optional(), // 反向提示词

View File

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

View File

@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
isImageUploadTooLarge,
} from "@/lib/image-upload-limits";
import { auth } from "@/app/(auth)/auth";
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
@ -7,6 +11,7 @@ import { upsertMediaAsset } from "@/lib/db/queries";
import { isSiteAdminType } from "@/lib/middleware/admin";
import { getContentType, uploadFile } from "@/lib/storage/service";
import { createLogger } from "@/lib/observability/logger";
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("upload-api");
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
@ -14,12 +19,32 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/jpg",
"image/png",
"image/webp",
"image/avif",
"image/tiff",
"image/x-tiff",
"image/svg+xml",
"image/heic",
"image/heif",
]);
const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
]);
const ALLOWED_OPENCLAW_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
"image/avif",
"image/tiff",
"image/x-tiff",
]);
function normalizeUploadContentType(contentType: string, filename: string) {
const normalized = contentType.trim().toLowerCase();
@ -35,8 +60,16 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
}
if (
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
extension === "avif" ||
extension === "tif" ||
extension === "tiff"
) {
return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。";
}
return "File type should be JPEG, PNG, WebP or SVG";
return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。";
}
function isHeicUpload(contentType: string, filename: string) {
@ -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) {
const trimmed = filename.trim() || `upload-${Date.now()}`;
if (/\.(heic|heif)$/i.test(trimmed)) {
return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
}
if (/\.[a-z0-9]+$/i.test(trimmed)) {
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
@ -71,6 +114,21 @@ async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
}
return Buffer.from(output);
}
async function convertSharpCompatibilityImageToJpeg(
buffer: Buffer
): Promise<Buffer> {
const sharp = (await import("sharp")).default;
return sharp(buffer, { animated: false, failOn: "none" })
.rotate()
.flatten({ background: "#ffffff" })
.jpeg({
quality: 92,
mozjpeg: true,
progressive: true,
})
.toBuffer();
}
/**
* 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化
@ -197,9 +255,21 @@ export async function POST(request: Request) {
let filename = originalFilename;
let contentType = originalContentType;
const uploadPurpose = String(formData.get("purpose") || "");
const isAtlasCloudReferenceVideo =
uploadPurpose === "canvas-video-process" &&
ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
const isOpenClawChatVideoUpload =
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
ALLOWED_OPENCLAW_VIDEO_TYPES.has(contentType);
const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null;
const isSvgUpload = contentType === "image/svg+xml";
if (contentType.startsWith("image/") && isImageUploadTooLarge(file.size)) {
return NextResponse.json(
{ error: IMAGE_UPLOAD_TOO_LARGE_MESSAGE },
{ status: 413 }
);
}
if (
isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
@ -227,7 +297,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
if (
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
!isAtlasCloudReferenceVideo &&
!isOpenClawChatVideoUpload
) {
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
logger.warn("[Upload API] Unsupported upload content type", {
contentType,
@ -277,6 +351,38 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
} else if (isSharpCompatibilityUpload(contentType, filename)) {
try {
const convertedBuffer =
await convertSharpCompatibilityImageToJpeg(uploadBuffer);
logger.info("[Upload API] Compatibility image converted to JPEG", {
clientUploadId,
originalContentType: contentType,
originalSize: uploadBuffer.length,
convertedSize: convertedBuffer.length,
});
uploadBuffer = convertedBuffer;
filename = toJpegFilename(filename);
convertedFrom = contentType;
contentType = "image/jpeg";
} catch (conversionError) {
logger.warn("[Upload API] Compatibility image conversion failed", {
clientUploadId,
contentType,
size: uploadBuffer.length,
error:
conversionError instanceof Error
? conversionError.message
: String(conversionError),
});
return NextResponse.json(
{
error: createUnsupportedImageTypeMessage(contentType, filename),
clientUploadId,
},
{ status: 400 }
);
}
}
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
@ -361,18 +467,32 @@ export async function POST(request: Request) {
try {
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
sourceType: "upload",
mediaType:
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? "video"
: "image",
sourceType: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: "upload",
status: "ready",
originalUrl: data.url,
previewUrl: data.url,
thumbnailUrl: data.url,
previewUrl:
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? null
: data.url,
thumbnailUrl:
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? null
: data.url,
mimeType: data.contentType || contentType || null,
displayName: filename,
sourceTable: "upload",
sourceTable: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: "upload",
metadata: {
pathname: data.pathname,
size: optimizedBuffer.length,
purpose: uploadPurpose || null,
clientUploadId,
convertedFrom,
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@ -18,6 +18,8 @@ import {
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
isDetailAgentLocalMoonshotBaseUrl,
normalizeDetailAgentLocalMoonshotBaseUrl,
} from "@/lib/ai/detail-agent-model-config";
import {
normalizeProductPlanningApiConfig,
@ -51,6 +53,10 @@ import {
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
normalizeCanvasImageTaskQueueConfig,
} from "@/lib/canvas/image-task-config";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import {
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
normalizeDingTalkRobotNotificationConfig,
@ -95,7 +101,7 @@ const moonshotDetailAgentConfigSchema = z.object({
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
function isMoonshotOfficialBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "") === MOONSHOT_OFFICIAL_BASE_URL;
return isDetailAgentLocalMoonshotBaseUrl(value);
}
function isObjectLike(value: unknown): value is Record<string, unknown> {
@ -124,6 +130,12 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
const value = normalizeCanvasLocalEditConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
return Response.json({
@ -191,19 +203,27 @@ export async function POST(request: Request) {
? (existingSetting.value as Record<string, unknown>)
: {};
const config = moonshotDetailAgentConfigSchema.parse(value || {});
const normalizedBaseUrl = String(config.baseUrl || "").trim() || MOONSHOT_OFFICIAL_BASE_URL;
const normalizedBaseUrl = normalizeDetailAgentLocalMoonshotBaseUrl(config.baseUrl);
const rawModelId = String(config.modelId || "").trim();
const rawDisplayName = String(config.displayName || "").trim();
const normalizedModelId =
!rawModelId || rawModelId === "kimi-k2.5"
!rawModelId ||
rawModelId === "kimi-k2.5" ||
rawModelId === "kimi-k2.6" ||
rawModelId === "kimi-k2.7-code" ||
rawModelId === "kimi-k2.7-code-highspeed"
? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID
: rawModelId;
const normalizedDisplayName =
!rawDisplayName || rawDisplayName === "官方 Kimi K2.5"
!rawDisplayName ||
rawDisplayName === "官方 Kimi K2.5" ||
rawDisplayName === "官方 Kimi K2.6" ||
rawDisplayName === "官方 Kimi K2.7 Code Highspeed" ||
rawDisplayName === "Kimi Code 编程套餐 K2.7 Highspeed"
? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME
: rawDisplayName;
const normalizedProviderName =
String(config.providerName || "").trim() || "Moonshot AI (Official)";
String(config.providerName || "").trim() || "Kimi Code 编程套餐";
const normalizedApiKey =
String(config.apiKey || "").trim() ||
String(existingValue.apiKey || "").trim();
@ -212,12 +232,12 @@ export async function POST(request: Request) {
if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) {
return new ChatSDKError(
"bad_request:api",
`Moonshot 官方地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
`Kimi Code 编程套餐专用地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
).toResponse();
}
if (!normalizedApiKey) {
return new ChatSDKError("bad_request:api", "缺少 Moonshot 官方 API Key").toResponse();
return new ChatSDKError("bad_request:api", "缺少 Kimi Code 控制台 API Key").toResponse();
}
const saved = await upsertSystemSetting({
@ -359,6 +379,10 @@ export async function POST(request: Request) {
nextValue = normalizeCanvasImageTaskQueueConfig(value);
}
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
nextValue = normalizeCanvasLocalEditConfig(value);
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
nextValue = normalizeDingTalkRobotNotificationConfig(value);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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