Compare commits

...

3 Commits

Author SHA1 Message Date
12be225242 新增cursor初始化skill 2026-04-21 11:13:57 +08:00
1b50511a4f 交互原型 尽量完整版 2026-04-20 23:38:26 +08:00
d6e1797f08 引入3d模型 2026-04-20 21:52:35 +08:00
49 changed files with 8278 additions and 342 deletions

View File

@@ -4,6 +4,134 @@
## 记录
### [CL-20260420-2237] 2026-04-20 22:37 — 前端一次性铺开文档中心 / 3D / 动作三大功能 UI占位实现 + Phase 2 徽章)
- **tags**: 前端, UI, 文档中心, 3D, 动作序列, 命令面板, 工作台, 资源库, 占位实现, Phase 2, react-markdown, model-viewer
- **affected_files**:
- art-agent/frontend/package.json新增 `react-markdown@9` / `remark-gfm@4`
- art-agent/frontend/src/lib/types.ts扩展 DocNode / DocTabState / View3D / Asset3D / AssetMotion / TaskType / Params3D / ParamsMotion
- art-agent/frontend/src/lib/placeholder-store.ts新增 docs/assets3D/assetsMotion 的 load/save/upsert/delete + 样本数据 SAMPLE_DOC_*
- art-agent/frontend/src/lib/app-context.tsx加入 docs/assets3D/assetsMotion/sessionTaskType/commandPaletteOpen 状态与 mutation 回调)
- art-agent/frontend/src/app/layout.tsx挂载 `<CommandPalette />` 全局;`<model-viewer>``next/script` lazyOnload 注入)
- art-agent/frontend/src/components/layout/top-nav.tsx在「工作台」后插入「文档」Tab带 Phase 1 徽章)
- art-agent/frontend/src/app/docs/page.tsx新增文档中心三栏页面DocTree + DocTabs + DocEditor + DocOutline
- art-agent/frontend/src/components/docs/doc-tree.tsx / doc-tabs.tsx / doc-editor.tsx / doc-outline.tsx / markdown-content.tsx / asset-picker.tsx / command-palette.tsx新建
- art-agent/frontend/src/components/chat/task-type-tabs.tsx新增 2D/3D/动作任务类型切换 Tab
- art-agent/frontend/src/components/workbench/params-3d.tsx / params-motion.tsx新增 3D / 动作参数条,占位)
- art-agent/frontend/src/app/page.tsx工作台注入 TaskTypeTabs + 按类型条件渲染 Params3D / ParamsMotionBar
- art-agent/frontend/src/components/chat/task-card.tsx新增 variant 属性image2d/model3d/motion颜色/图标/专有字段全套切换)
- art-agent/frontend/src/components/detail/model-3d-panel.tsx新增多视图/360°占位/<model-viewer> 三模式 + 通道切换 + 视图缩略条360° 旋转用 framer-motion `rotateY` + SVG 替代 styled-jsx 避免多行 className 编译错误)
- art-agent/frontend/src/components/detail/motion-panel.tsx新增视频/帧序列双模式 + 播放 scrubber + MP4/GIF/帧序列占位下载)
- art-agent/frontend/src/app/gallery/page.tsx资源库顶部加模态 Tab「图片/3D 模型/动作序列」+ Assets3DGrid / AssetsMotionGrid 两个子组件3D/MOTION 徽章和视角/帧数徽章叠加显示;接 detail 面板)
- art-agent/frontend/src/components/ui/motion-presets.ts`sendBounce``ease: "easeInOut"``as const`,修预存 framer-motion 类型错误不阻塞 build
- **what**:
按用户一次性铺开「文档管理中心(类似 Cursor/ 3D 模型生成 / 动作生成」全部界面元素与交互的要求,用占位实现把所有入口、组件、面板、参数条、徽章与跳转都先打通:
1. 文档中心:`/docs` 三栏布局(树/Tab+编辑器/大纲),支持 Markdown textarea 编辑 + react-markdown 预览 + 分屏、`[[asset:id]]` 资产引用(图片/角色/风格)在正文内渲染为交互卡片、拖拽/重命名/新建/删除、AssetPicker 浮层。
2. 命令面板Cmd/Ctrl+K 全站 CommandPalette整合导航/动作/文档/图片/角色/风格/会话的统一搜索。
3. 工作台扩展:任务类型 Tab2D/3D/动作),按类型显示不同参数条(`Params3D` 含管道/视图数/分辨率,`ParamsMotionBar` 含模型/帧数/帧率/循环/输出/角色绑定/动作描述);`TaskCard``variant` 区分颜色/图标/描述/专有字段3D 显示管道+视图数+产物,动作显示帧数+循环+产物)。
4. 结果详情:`Model3DPanel` 支持多视图切换color/normal/stylized 三通道 + 视图缩略条)/ 360°占位旋转 / `<model-viewer>` 模型预览三模式;`MotionPanel` 支持视频/帧序列双模式 + 播放 scrubber。
5. 资源库:顶部新增模态 Tab图片/3D 模型/动作序列)+ 计数徽章 + Phase 徽章3D 网格叠 `3D` 徽章+视角数,动作网格叠 `MOTION` 徽章+帧数·fps + hover 自动播放视频。
- **why**:
用户明确要求"所有功能都先用占位把界面元素和交互实现出来,避免后续反复返工"。选择"一次性完整铺开"策略而非渐进式可让用户在真实管线Mesh Pipeline / 动作生成后端)接入前就能验收完整 UX 流与设计语言一致性,减少后端开发时 UI 的认知负担。
- **decisions**:
-**react-markdown + remark-gfm** 轻量 textarea 方案(否决 Monaco/TipTap文档场景还不需要代码折叠/语法高亮/WYSIWYG先保持依赖干净
- **站内 Cmd+K 命令面板**统一搜索(否决每页独立搜索:符合 Cursor 风格且跨域跳转一致)。
- 3D 预览走 **`<model-viewer>` 自定义元素**(否决 three.js/react-three-fiber占位阶段用现成自定义元素最省代码真实接入时只需把 `asset.meshUrl` 换成真实 GLB 路径;用 `next/script` lazyOnload 避免阻塞首屏)。
- 动作预览走 **HTML5 `<video>`**(否决 GIFmp4 文件更小 + 支持帧擦写GIF 只作下载选项保留)。
- 「文档」Tab 放 **TopNav 第 2 位**(「工作台」之后),符合用户对"近 workspace"的访问距离预期。
- Model3D 面板的 360°预览**放弃 styled-jsx CSS 立方体**6 face 的 styled-jsx 触发 webpack 对多行 className 的解析错误),改用 framer-motion `rotateY` 驱动 SVG 六边形,更简洁且不会和 webpack 打架。
- **notes**:
1. 所有新组件均标 `phase-chip`(文档 Phase 1 / 3D·动作 Phase 2真实链路接入时只需替换 placeholder-store 的 loader 和 `upsert*` 的写入方向。
2. `<model-viewer>` 脚本是从 unpkg lazyOnload 加载,生产环境若断网/被墙 3D 模式会静默降级(只显示占位文案),可在 Phase 2 接入时改为本地打包。
3. `chat-input.tsx` 的 framer-motion 类型错误(`ease: string` 不匹配新版 `Easing` 联合类型)顺手在 `motion-presets.ts` 源头用 `as const` 修掉,`npx next build` 已通过全绿。
4. TopNav 的「文档」Tab 是全站入口,新建文档/打开文档走 Ctrl/Cmd+K 也能到,两者使用同一份 `docs` 状态。
5. 资源库的 3D / 动作模态下不显示「收藏/标签/视图切换」等图片专属工具栏(条件分支上移到 `<main>` 内部),避免误导用户这些控件对 3D/动作生效。
- **source_chat**: [前端铺开三大功能占位 UI](4f55b11a-3572-47e2-8292-7d815773e7ad)
### [CL-20260420-1800] 2026-04-20 18:00 — Mesh Pipeline Phase 3 风格还原接入IP-Adapter + ControlNet
- **tags**: 视角变换, Mesh Pipeline, 风格还原, IP-Adapter, ControlNet, Replicate, style_restorer, 后端, services, config, Phase 3
- **affected_files**:
- art-agent/backend/app/config.py
- art-agent/backend/app/services/style_restorer.py (新增)
- art-agent/backend/app/services/view_transform.py
- art-agent/backend/scripts/test_style_restore.py (新增)
- docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **what**: 按 SPEC §4 Phase 3 落地 Mesh Pipeline 的阶段 3 — 对 Stage 2 抽出的 normal 帧做 IP-Adapter + ControlNet 重绘,用用户原图做风格参考,输出保留原画风且视角正确的最终图。
- **why**: Phase 2 已能稳定产出 color/normal 6 视角帧,但 Trellis 的 color 帧是"半写实"纹理风与用户上传的卡通描边原图风格差异明显。Phase 3 用 `chigozienri/ip_adapter-sdxl-controlnet-depth`(版本 `0436c870...`$0.07/张 ~72sL40S`image` 字段传原图承担 IP-Adapter 风格参考,`controlnet_input` 传 normal 帧作为结构约束的 depth 近似(按 SPEC §1.4 一期走"路 A"简化方案,效果不足再升级为 `depth-anything-v2` 真 depth
- **decisions**:
1. 新模型以 `internal=True` 注册进 `IMAGE_MODELS`(而非 `VIEW_TRANSFORM_MODELS`),理由:它是"普通文生/图生图"模型,只是用在内部流水线;同步改 `get_image_models_list()` 过滤 `internal`,避免它出现在前端生图模型下拉
2. 暴露 `get_style_restore_model_id()` helper 让 service 层不硬编码短 ID
3. `restore_batch()``asyncio.Semaphore(3)` 控制并发而非 `asyncio.gather` 全冲 —— SPEC §5 风险表提示过 Replicate rate limit 风险
4. 默认参数选 `scale=0.75` / `controlnet_conditioning_scale=0.8`SPEC §2.3 建议范围 0.7-0.8 / 0.7-0.9 的中位偏上),最终值由 P3-4 调参确认
5. **单帧失败不阻塞整体**Stage 3 任一帧失败时自动回退为 Stage 2 的 color 帧返回,前端至少能看到东西;并在该 image 上挂 `restyle_error` 字段暴露失败原因。这一兜底 + "Stage 2 失败不影响 Stage 1 交付" 形成一致的"阶段独立可交付"设计
- **notes**:
- 成本:一次完整 6 视角请求 ~$0.46Trellis $0.041 + 6 × IP-Adapter $0.07 = $0.461),耗时 ~105s30s 重建 + 并发 3 两轮 × 72s ≈ 75s 风格化)
- `images[i]` 新结构:新增 `color_url`(保留 Stage 2 原图便于前端对比/切换)、`restyled` 布尔、可选 `restyle_error`;返回体 `_stage``stage2` 升级为 `stage3 (N/M restyled)`
- `_note` 字段去掉了,错误通过 `error` 字段聚合(拼接 Stage 2 + Stage 3 错误);`_preserve_style_requested` 保留供调试
- Checkpoint 3 脚本 `scripts/test_style_restore.py` 支持 `--single` 冒烟(~$0.07)和全量(~$0.42),以及 `--scale/--cn-scale` 临时覆盖参数,便于 P3-4 调参;默认自动挑 generated/ 下最新一组 `view_<prefix>_az*_el0_normal.png` 作为结构参考
- **P3-4 未完成**等用户用真实原图跑调参P4 前端联调将在 Checkpoint 3 通过后开启
- **source_chat**: [继续执行 Mesh Pipeline Phase 3](85c3e2f0-ab1f-48c3-9f12-a1b2c3d4e5f6)
### [CL-20260420-1700] 2026-04-20 17:00 — Mesh Pipeline Phase 2 抽帧 + combined_video 左右切分落地
- **tags**: 视角变换, Mesh Pipeline, Trellis, combined_video, ffmpeg, imageio-ffmpeg, PIL, 抽帧, 后端, services, Phase 2
- **affected_files**:
- art-agent/backend/app/services/video_frame_extractor.py (新增)
- art-agent/backend/app/services/view_transform.py
- art-agent/backend/requirements.txt
- art-agent/backend/scripts/test_frame_extract.py (新增)
- docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **what**: 按 SPEC §4 Phase 2 清单落地。(1) 确认宿主机 `ffmpeg` 未安装,改用 `imageio-ffmpeg==0.5.1` 自带的 Windows 静态二进制(`ffmpeg-win64-v4.2.2.exe`,随 wheel 一起打包 22.6MB免系统依赖venv 已装,`requirements.txt` 同步写入。(2) 新增 `services/video_frame_extractor.py` 实现 `extract_by_azimuth(combined_video_path, azimuths, elevations=None, total_rotation_deg=360)`:核心策略采用"一次性把 combined_video 所有帧抽到 `generated/_frames_<id>/` 临时目录 + 按 `frame_idx = round(az / total_rotation_deg * total_frames) % total_frames` 挑帧 + PIL 按中线切左半→color、右半→normal 保存到 `generated/view_<batch>_az{az}_el{el}_{color|normal}.png`",比逐帧用 `-ss` 抽更稳(不受 keyframe 对齐影响5s 视频整体成本可忽略;用完即清理临时目录,只保留切好的最终帧。(3) ffmpeg + PIL 是阻塞操作,用 `asyncio.get_running_loop().run_in_executor(None, _do_extract, ...)` 丢到默认线程池跑,避免堵住 FastAPI 事件循环。(4) `services/view_transform.py``_transform_view_mesh` 在 Stage 1 成功后串接 Stage 2——默认 `azimuths=[0,60,120,180,240,300]`(从模块级常量 `DEFAULT_MESH_AZIMUTHS` 读取),`elevations` 缺省或长度不足补 0、超长截断保证与 azimuths 对齐;调用 `video_frame_extractor.extract_by_azimuth(combined_video_path=stage1["combined_video_path"], ...)` 得到 `frames[{az, el, frame_idx, color_png, normal_png}]`;把 `images` 填为 `[{url: color_png, azimuth, elevation, normal_url: normal_png, frame_idx}]`Stage 2 失败**不**把整体置为失败Stage 1 的 mesh/video 仍有交付价值),单独在返回 dict 的 `error` 字段带回 Stage 2 错误信息,`success` 同时要求 stage1 成功 + Stage 2 无 error。(5) 新增 `scripts/test_frame_extract.py` 供用户 Checkpoint 2 验收:默认取 `generated/` 下最新的 `trellis_combined_*.mp4` 做输入(**不需要再花钱跑 Trellis**),调用 `video_frame_extractor.extract_by_azimuth` 抽 6 视角,打印每帧的 `color_png/normal_png` URL + 本地文件大小,并提示用户目视检查。(6) SPEC 文档 P2-1 ~ P2-4 全部打勾、追加本次会话的进度 note含 Agent 自测结论)。
- **why**: SPEC §4 "每个 Phase 完成后停下来让用户测试/审阅"Phase 2 是用户能看到"可见结果"的第一步——Phase 1 只能交付一个 .glb + 一个 mp4 视频用户很难一眼判断是否正确Phase 2 交付 6 张方位角明确的方形图后,用户可以直接目视环绕教堂一周。抽帧方案故意选"一次抽全 + 按索引挑"而不是"每帧一次 -ss 快进",是因为:(a) combined_video 约 5s/120 帧,总 PIL+ffmpeg 开销 ~1-2s比 6 次 -ss (每次 ffmpeg 启动 ~300-500ms) 更快;(b) `-ss` 在 GOP 长的 mp4 上会对齐到最近 keyframe取帧索引可能不精确(c) 代码简单、debug 时看临时目录文件序号就能对应帧。Stage 2 失败不牵连 Stage 1是考虑到"抽帧失败"通常意味着 ffmpeg 或视频本身异常,但用户可能仍想拿 .glb 做手动检视——与其整体 fail不如降级返回部分产物 + 明确 error。测试脚本用已有 combined_video 是故意为之Checkpoint 2 的重点是验证抽帧+切图的正确性,不是再验证一遍 Trellis没理由让用户多花 $0.041。
- **decisions**: (1) **ffmpeg 方案**:首选 `imageio-ffmpeg` 而不是让用户 `choco install ffmpeg`,因为 SPEC §5 风险栏就列了 "Windows ffmpeg 未装" 是高概率风险imageio-ffmpeg 的 pip wheel 自带 Win64 二进制是零摩擦方案;代价是 wheel 22.6MB + 每次 Python 启动查找二进制(成本可忽略)。(2) **返回 schema**:给 mesh 管道的 `images[i]` 加了 `normal_url``frame_idx` 字段而不是单独的 `frames` 列表——这样前端不用改 schema 就能显示(`url` 仍是最终要展示的彩色图),同时 Phase 3 可以直接读 `normal_url` 作为 `controlnet_input`。(3) **elevation 字段保留但忽略**combined_video 本身是水平平视一圈,`elevation != 0` 在 Phase 2 无意义(所有帧 el 都是 0但保留字段为 Phase 5 自由相机路径预留;当前传 `elevations=[30,30,...]` 也不会报错,只是结果图的 el 字段被原样记录、帧内容仍是水平视角(有心理预期即可,未来 renderer 路径会真正使用)。(4) **临时目录策略**:抽帧到 `generated/_frames_<8位hex>/` 而不是系统临时目录,是因为后者在 Windows 跨盘符时可能慢;前者失败时也留现场方便 debug。完成后的清理用 `try/except OSError` 兜底,即使 PIL 占着句柄也不会报错退出。(5) **DEFAULT_MESH_AZIMUTHS 提到模块级**:下 Phase 4 前端会暴露"快速预设 6/8/12 视角"选项,这里先把默认值拎出来便于配置扩展;目前命名为 6 视角Phase 4 再考虑增加 8/12。
- **notes**: (1) **Stage 2 路径依赖 stage1 的 `combined_video_path` 键名**(非 `color_video_path`)——这是 Trellis 部署版 `e8f6c452...` 的实测事实PF-20260420-1600不要以为 Phase 1 的字段 `color_video_path` 有值;现在该字段仍然返回但恒为 `None``_transform_view_mesh` 里优先读 `combined_video_path`。(2) **前端目前仍不会自动展示 mesh 管道结果** ——`loop.py` 只在 `result.get("images")` 非空时发 `image_result` 事件Phase 2 让 images 有值了,所以前端**应该**能看到 6 张;但 Phase 4 才正式端到端联调Phase 2 的建议验收入口仍是 `scripts/test_frame_extract.py` 而不是 UI。(3) **normal 帧的 url 在前端目前是"隐藏字段"**——`images[i].normal_url` 前端 types.ts 里没这个字段会被丢弃Phase 3 接入时 view_transform 会改为把重绘后的图填到 `url`normal 帧继续作为中间产物留在磁盘上。(4) **预留的 `preserve_style` 参数当前不生效**,返回 dict 的 `_preserve_style_requested` 字段记录了用户意图——Phase 3 会根据这个开关分流True 走 ControlNet 重绘、False 直出 color 帧)。(5) **Agent 自测结果2026-04-20 17:05**`test_frame_extract.py``trellis_combined_290da12de954.mp4` 运行,抽出 120 帧az=[0,60,120,180,240,300] 分别映射到 frame_idx=[0,20,40,60,80,100](每 60° = 20 帧完全均匀12 个 PNG 全部落盘,单张尺寸 512×512 方形(说明原 combined_video 是 1024×512 左右并排)。自测**不能替代用户目视检查** "6 张 color 图是否按 0°→300° 顺序环绕建筑一圈"Checkpoint 2 必须由用户拍板。(6) 用户验收流程:(a) 在 `art-agent/backend` 激活 venv(b) 跑 `python scripts/test_frame_extract.py`(默认取 `generated/` 最新 trellis_combined也可 `python scripts/test_frame_extract.py generated/trellis_combined_<hash>.mp4` 指定);(c) 终端应打印 6 个 color_png + 6 个 normal_png 均 `OK`,每个 size > 0(d) 打开 `generated/view_*_az0_el0_color.png ~ view_*_az300_el0_color.png` 6 张图,验证是同一建筑从 6 个均匀角度绕一圈的视角;(e) normal 图是蓝紫调法线图,可抽 1-2 张抽查。通过后开 Phase 3。
- **source_chat**: 本次会话 "继续执行 Phase 2"
### [CL-20260420-1600] 2026-04-20 16:00 — Mesh Pipeline Phase 1 接入 Trellis视角变换第二条管道落地阶段 1
- **tags**: 视角变换, Mesh Pipeline, Trellis, Replicate, 后端, services, config, tools, Phase 1
- **affected_files**:
- art-agent/backend/app/services/mesh_generator.py (新增)
- art-agent/backend/app/services/view_transform.py
- art-agent/backend/app/config.py
- art-agent/backend/app/agent/tools.py
- art-agent/backend/scripts/test_trellis.py (新增)
- docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **what**: 按 SPEC §4 Phase 1 清单落地。(1) 新增 `services/mesh_generator.py`,封装 `generate_with_trellis(image_path, params=None)` 和统一入口 `generate_mesh(image_path, model_id)`,调用 `firtoz/trellis:e8f6c452...` 并把 `model_file/color_video/normal_video/combined_video` 挨个 httpx 下载到 `GENERATED_DIR`,以 `trellis_*_<uuid>.{glb,mp4}` 命名,返回 `/generated/...` 本地 URL。(2) `config.py``VIEW_TRANSFORM_MODELS` 新增 `trellis`pipeline=mesh, enabled=True`hunyuan3d`pipeline=mesh, enabled=False 占位),给原 `zero123plus` 条目补 `pipeline=grid``get_view_transform_models_list` 改为只返回 enabled 条目 + 带上 pipeline 字段。(3) `services/view_transform.py` 改为按 `pipeline` 字段分流——`grid` 走原 Zero123++ 逻辑(逻辑未动,仅输出字段补齐 `pipeline/mesh_url/color_video/normal_video``mesh` 走新增 `_transform_view_mesh()`:当前阶段只跑 mesh_generator 返回 `.glb + color_video + normal_video` 本地 URL`images` 列表留空,附 `_stage="stage1"``_note` 标记 Phase 2/3 未接入。(4) `tools.py``transform_view` 工具定义扩三个参数 `model_id` (enum: zero123plus/trellis, default zero123plus) / `azimuths` / `preserve_style`,执行侧把这些参数透传给 `transform_view()`。(5) 新增 `backend/scripts/test_trellis.py` 作为 Phase 1 独立验收脚本——默认用 uploads/ 下最新 png、支持命令行指定路径、自动加载 .env、调用 `mesh_generator.generate_with_trellis` 后检查本地文件大小。(6) SPEC 文档 P1-1 ~ P1-5 打勾并追加进度 note。
- **why**: SPEC 明确要求"按 Phase 顺序推进,每个 Phase 完成后停下来让用户测试/审阅"Phase 1 是整条 Mesh Pipeline 的地基——验证 Replicate Trellis 调用通道是否打通、文件下载策略是否正确、与现有 grid 管道并存不互相干扰。把 mesh_generator 独立成模块是为后续 Phase 2/3/5 解耦Phase 2 只需读 color_video_path/normal_video_pathPhase 3 只需消费 images 列表Phase 5 切到 Hunyuan3D-2 时只需替换 generate_mesh 的分支。测试脚本独立走 asyncio 是因为 Phase 1 还没接到工具链,用户需要一个"最小能跑"的入口先确认 API 本身可用SPEC P1-5 也是这么要求的)。
- **decisions**: (1) `view_transform` 返回结构升级为同时带 `grid_image/mesh_url/color_video/normal_video/pipeline` 字段,两种管道缺省位补 `None`——比"两种完全不同的返回 schema"更利于前端和 loop.py 后续统一处理。(2) Phase 1 当前故意让 mesh 管道的 `images` 为空 + 带 `_note` 说明,而不是硬拼一个"只返回视频"的假结果——这样 Phase 2 接入时 frontend 的 "没有图片显示" 现象会被替换为 "6 张图片显示",语义更清晰。(3) hunyuan3d 提前注册但 `enabled=False`,避免用户在前端误选到二期才启用的路径;`get_view_transform_models_list` 同步过滤 enabled。(4) `mesh_generator._to_url` 做了 `FileOutput → str` 的显式转换兜底,因为 Replicate SDK 的字典输出里每个字段可能是 FileOutput 对象而不是裸字符串 URL——这点在 image_gen.py 的 `ReplicateProvider._download` 已经吃过亏,这里主动规避。(5) `_download_asset` 的 httpx 超时放到 300s因为 color_video ~3-5MB 在国内网络可能慢;复用项目既有的 HTTPS_PROXY 注入模式。
- **notes**: 本次改动**完全不触碰 Zero123++ 现有流程**——grid 管道所有行为保持 byte-level 等价,只是返回字典多了几个 None 字段,`loop.py` 和前端已有代码没事。但还**未修改 loop.py 的 SSE 事件处理**:当前 loop.py 只在 `tool_name == "transform_view" and result.get("images")` 时发 `image_result`mesh 管道 Phase 1 的 `images=[]` 会让 loop.py 不发事件,前端看到空结果——这**是预期行为**Phase 1 的测试入口是 `scripts/test_trellis.py` 而不是前端。Phase 2 接入后 images 有值loop.py 无需改动即可工作。用户验收流程:(a) 在 `art-agent/backend` 激活 venv(b) 跑 `python scripts/test_trellis.py`(默认取 uploads/ 最新 png`python scripts/test_trellis.py uploads/<hash>.png` 指定图;(c) 等 ~30-60s 看终端打印 `glb_path/color_video_path/normal_video_path` 均为 `/generated/trellis_*...` + 本地大小 > 0(d) 可选:去 `backend/generated/` 目录用系统播放器看 color_video.mp4 是否为 360° 环绕教堂视频。验收通过后我再开 Phase 2ffmpeg 抽帧)。
- **source_chat**: 本次会话 "按计划实施 Mesh Pipeline"
### [CL-20260420-1530] 2026-04-20 15:30 — 写入 Mesh-Pipeline 视角变换方案 SPEC跨会话实施计划
- **tags**: 视角变换, Mesh Pipeline, Trellis, Hunyuan3D-2, ControlNet, IP-Adapter, SPEC, 方案设计, 建筑, 跨会话
- **affected_files**:
- docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **what**: 新增一份完整的实施计划 SPEC规划 Zero123++ 之外的第二条视角变换路径——Mesh Pipeline。三段式流水线(1) Trellis/Hunyuan3D-2 单图生 mesh + 360° color_video(2) ffmpeg 从 color_video 抽指定 azimuth 的帧(一期方案,免 pyrender(3) `chigozienri/ip_adapter-sdxl-controlnet-depth` 做 SDXL+ControlNet-Depth+IP-Adapter 二次重绘,用原图保持卡通描边风格。文档含:三个 Replicate 模型的精确版本 ID + OpenAPI schema、config.py 扩展设计、伪代码管道、6 个 Phase 的可独立执行任务清单(每个 Phase 有独立验收标准)、风险兜底、成本预估(~$0.46/~105s、跨会话恢复指引。
- **why**: 用户反馈 Zero123++ 只能出 6 个固定视角 + 建筑大角度偏转一致性差。用户选定重流水线P1-P3 全做),明确要求"保留 2D 卡通风格"+"自包含跨会话执行"。调研中发现Trellis ($0.041/30s 自带 color_video) 比 Hunyuan3D-2 ($0.12/127s 仅 mesh) 快 4× 便宜 3×一期走 Trellis 还能省掉自定义渲染模块。把 Hunyuan3D-2 路线拆进 Phase 5二期可选工程量从 ~9h 降到 ~5h。
- **decisions**: 主选 Trellis 而非 Hunyuan3D-2——以便一期完全跳过 pyrender/blender 渲染器(用 ffmpeg 抽帧替代)。阶段 3 的 ControlNet 结构约束用 normal_video 抽帧Trellis 送的),不先走 depth-anything-v2省一次 API 调用不完美但足够。Hunyuan3D-2 + pyrender 自由相机留作 Phase 5 可选。不在一期解决 elevation 仰俯视角Trellis color_video 只给水平环绕),那是 Phase 5 的事。
- **notes**: 文档位置 `docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md`。下一个会话开工时 Agent 需先读 §0-§3 建立背景,再按 §4 Phase 顺序推进,每 Phase 交付后停下来让用户验证。文档底部有"进度日志"章节,每个 Phase 完成后在 checkbox 打勾 + 追加 note。未修改任何业务代码纯 SPEC 文档。
- **source_chat**: 本次会话排查 Zero123++ 切图错位 meta-bug + 后续方案调研
### [CL-20260420-1501] 2026-04-20 15:01 — 后端增加 watchfiles 依赖,修复 Windows uvicorn --reload 失效
- **tags**: uvicorn, watchfiles, 热重载, Windows, FastAPI, requirements, 依赖管理, reload, Zero123++
- **affected_files**:
- art-agent/backend/requirements.txt
- art-agent/backend/requirements-lock.txt
- **what**: 在 `requirements.txt``requirements-lock.txt` 中显式加入 `watchfiles==0.24.0`,并在当前 venv 中安装。这让 uvicorn `--reload` 使用 WatchFiles 作为 reloader 后端,而不是无依赖时的 StatReload 轮询回退。
- **why**: 用户反馈 Zero123++ 切图错位——但 CL-20260420-1212 里做的 `cols=2/rows=3` 修复和 `_split_grid_image` 自动纠错逻辑在磁盘上明明是新版,独立调用也能切出正确的 320×320。进一步排查发现后端 uvicorn 进程是 11:55 启动、从未 reload而 venv 里 `watchfiles`/`watchdog` 均未安装——StatReload 在 Windows + 深层子目录 + 新建文件 (`view_transform.py` 是 untracked 新增) 的叠加场景下几乎完全失效,所以代码改了但运行时还是老版本。这一层 meta-bug 如果不治本,以后每次改后端都要手动重启。
- **decisions**: 选择 `watchfiles`uvicorn 官方推荐、跨平台 Rust 实现、依赖最轻)而不是 `watchdog`(历史包袱更重)。版本锁 0.24.0 与 Python 3.12 兼容良好且 uvicorn 0.44.0 默认识别。未添加 `--reload-dir app` 参数到 project-launcher Skill 的启动命令里——先靠装依赖覆盖主要场景,如果未来仍有监听遗漏再显式限定目录。
- **notes**: **仅装依赖不重启当前 uvicorn 进程不会生效**,因为旧进程已经绑定到 StatReload。需要 kill PID 28172 并重启一次后端,之后所有代码改动才会自动 reload。启动日志里应该能看到 `Started reloader process [xxx] using WatchFiles`,如果仍是 `StatReload` 说明 watchfiles 没正确安装到当前 venv。踩坑已记录 PF-20260420-1501。
- **source_chat**: 本次会话排查 Zero123++ 切图错位 meta-bug
### [CL-20260420-1212] 2026-04-20 12:12 — 修复 Zero123++ 视角切图布局反转导致的图像错位
- **tags**: Zero123++, 视角变换, 切图, bug修复, grid_layout, PIL, Replicate, view_transform
- **affected_files**:
- art-agent/backend/app/config.py
- art-agent/backend/app/services/view_transform.py
- **what**: 修复 `transform_view` 输出的 6 张视角图严重错位的问题。`config.py` 里的 `VIEW_TRANSFORM_MODELS["zero123plus"].grid_layout``{"cols": 3, "rows": 2}` 改为 `{"cols": 2, "rows": 3}`,与 `jd7h/zero123plusplus` Replicate 实际输出(高图布局)一致。同时在 `view_transform._split_grid_image` 加入"按实际图像长宽比自动纠正 cols/rows"的健壮性保护:假定每个单视角为正方形,若配置的 `cols/rows` 与图像实际 `w/h` 差异超过 20%,自动交换 cols/rows 并记录 warning。
- **why**: 用户反馈视角变换后的切图全部错位(窄长条、上下拼两个物体)。根因是 Zero123++ 官方 README 写 "3×2 grid" 但实际 Replicate 部署版输出的是 2 列 × 3 行高图config 里按"3 列 × 2 行"配置cell_w/cell_h 算反了。加上动态纠错是为了未来换其他 view transform 模型时不再重复踩这个坑。
- **decisions**: 选择"修 config + 加运行时自动纠错"而非仅修 config。纯改 config 能解决眼前问题但无法防御未来模型切换;加动态纠错的代价只是一段 if 判断+warning 日志,换取对"布局文档歧义"场景的整体免疫。放弃了更激进的"用 PIL 探测内容块边界"方案,因为对有微量白边/噪声的模型不稳定。
- **notes**: uvicorn 以 `--reload` 方式运行,改完自动重载,无需重启服务。用户需要在前端重新触发一次视角变换来验证切图是否正确。对应踩坑记录已写入 `.cursor/pitfalls/pitfalls.md` (PF-20260420-1212)。
- **source_chat**: 本次会话启动项目并调试 Zero123++ 切图
### [CL-20260417-1510] 2026-04-17 15:10 — Cloudflare Quick Tunnel 脚本修复PS5.1 编码 + 本地 cloudflared
- **tags**: 内网穿透, cloudflared, PowerShell, start-tunnel, UTF-8, gitignore
- **affected_files**:

View File

@@ -2,6 +2,13 @@
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
- [CL-20260420-2237] 前端一次性铺开文档中心(/docs 三栏 + DocTree/Tabs/Editor/Outline + react-markdown + `[[asset:id]]` 资产引用 + AssetPicker+ 全站 Cmd/Ctrl+K CommandPalette + 工作台 TaskTypeTabs2D/3D/动作)按类型显示 Params3D/ParamsMotionBar + TaskCard 加 variant + 3D 结果面板 Model3DPanel多视图/360°/`<model-viewer>` 三模式color/normal/stylized 通道切)+ 动作结果面板 MotionPanel视频/帧序列双模式+scrubber+ 资源库顶部模态 Tab图片/3D/动作)+ 3D/MOTION 徽章叠加;`<model-viewer>` 走 next/script lazyOnloadtypes/placeholder-store/app-context 全量扩展 doc/assets3D/assetsMotion顺手把 `sendBounce``ease``as const` 修预存 build 类型错,`npx next build` 全绿
- [CL-20260420-1800] 按SPEC §4 Phase 3落地Mesh Pipeline风格还原config注册internal=True的ip-adapter-controlnet-depth$0.07/张)+ get_image_models_list过滤internal新增services/style_restorer.py(restore/restore_batch带Semaphore并发3防rate limit)view_transform._transform_view_mesh接入Stage 3preserve_style=True时用原图做IP-Adapter风格参考+normal帧做ControlNet结构约束SPEC §1.4路A单帧失败自动回退Stage 2 color帧不阻塞整体images[i]新增color_url/restyled/restyle_error_stage升级为stage3 (N/M restyled)新增scripts/test_style_restore.py支持--single冒烟/全量/--scale/--cn-scale覆盖参数复用Phase 2 normal帧免重跑Trellis默认scale=0.75 cn_scale=0.8P3-4调参等用户验收一次完整6视角 ~$0.46/~105s
- [CL-20260420-1700] 按SPEC §4 Phase 2落地Mesh Pipeline阶段2新增services/video_frame_extractor.py用imageio-ffmpeg静态二进制(系统ffmpeg未装的兜底)+PIL一次抽全帧到temp dir再按帧索引挑6个方位角+切左右半得color/normalview_transform.py的mesh管道现已走到Stage2返回images[{url,azimuth,elevation,normal_url,frame_idx}]Stage 2失败不牵连Stage1的mesh/video产物新增scripts/test_frame_extract.py复用已有combined_video做验收省钱requirements加imageio-ffmpeg==0.5.1Agent自测120帧视频6视角映射到0/20/40/60/80/100索引+512×512切图均正确等用户Checkpoint 2验收后开Phase 3
- [CL-20260420-1600] 按SPEC §4 Phase 1落地Mesh Pipeline阶段1新增services/mesh_generator.py封装Trellis调用+下载.glb/color_video/normal_video到generated/config注册trellis(enabled)+hunyuan3d(占位)+加pipeline字段view_transform.py按pipeline分流grid/meshtools.py扩model_id/azimuths/preserve_style新增scripts/test_trellis.py独立验收入口Zero123++现有流程完全不变等用户跑脚本验收后开Phase 2
- [CL-20260420-1530] 写入Mesh-Pipeline视角变换方案SPEC跨会话实施计划Trellis生mesh+360°video→ffmpeg抽帧→IP-Adapter+ControlNet重绘保留卡通风含3个Replicate模型版本ID/schema+6 Phase任务清单+风险兜底位于docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- [CL-20260420-1501] 后端装watchfiles并写入requirements修复Windows uvicorn --reload退化为StatReload导致代码改动不热重载view_transform 12:12改完但后端15:00仍跑旧码踩坑PF-20260420-1501需重启后端生效
- [CL-20260420-1212] 修复Zero123++切图错位jd7h部署版实际输出2列×3行而非3×2config改cols=2/rows=3_split_grid_image新增按实际ratio自动纠错防御未来布局歧义踩坑记为PF-20260420-1212
- [CL-20260417-1510] start-tunnel.ps1 修复 PS5.1 中文 here-string 解析失败WriteAllLines 写 .env、脚本 UTF-8 BOM、同目录 cloudflared.exe 优先;根 .gitignore 忽略下载的 cloudflared.exe
- [CL-20260417-1500] 按 INFOLAYER.md 一次性铺开 Phase 1+ 前端 UI 骨架:新增风格库/角色库/训练中心/项目设置四大页面模块+工作台快选条+高级控制+候选评估+Task Card+资源库多维过滤placeholder-store.ts 管理占位数据phase-chip 标注阶段
- [CL-20260416-1600] 视觉风格从暗绿底全面转为白底青绿(绢本山水):背景#FAFAF7+文字#1A1A1A+accent#2E8B7A13个文件颜色替换面板改白色毛玻璃云雾透明度降低VISUAL-STYLE-GUIDE.md重写
@@ -45,10 +52,3 @@
- [CL-20260413-0930] System Prompt 加入"禁止模拟工具调用"约束,防止 DeepSeek 用文字模拟生图而不调用工具
- [CL-20260413-0900] 非 vision + 参考图场景:不再绝对禁止风格词,改为"不自行猜测但保留用户明确指定的风格"
- [CL-20260413-0800] 修复 InstantStyle ReadTimeout + 多次生成丢失参考图wait 改 False 走纯轮询 + 不再清除 ref_image_url
- [CL-20260413-0730] 修复生图失败后 LLM 重试丢失参考图ref_image_url 改为仅在成功产出图片后才清除
- [CL-20260413-0700] 修复 InstantStyle 生图 422 + ReadTimeout`wait=300` 超限改为 `wait=60`SDK 超时自动 fallback 轮询
- [CL-20260413-0630] 修复 Replicate SDK 不走代理 + 超时不够:注入 HTTPS_PROXY 到 transport + read 300s / connect 30s解决 ConnectTimeout 连环错误
- [CL-20260413-0600] 新增 InstantStyle 模型(强风格迁移),与 Kolors 并列为参考图模型选项block_mode=style-only
- [CL-20260413-0540] 非 vision LLM + 参考图时禁止猜测风格prompt 只描述内容不写画风,风格交由 IP-Adapter 处理
- [CL-20260413-0520] 多图生成改为单次 API 调用(利用模型原生批量参数),避免速率限制 + 修复空错误信息显示
- [CL-20260413-0500] 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),便于模型溯源

View File

@@ -1,8 +1,43 @@
# Dev Changelog — Recent
# Dev Changelog — Recent
最近 ~10 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。
### [CL-20260420-2237] 2026-04-20 — 前端一次性铺开文档中心 / 3D / 动作三大功能 UI占位 + Phase 2 徽章)
- **tags**: 前端, UI, 文档中心, 3D, 动作, 命令面板, 工作台, 资源库, 占位, react-markdown, model-viewer
- **affected_files**: types.ts, placeholder-store.ts, app-context.tsx, layout.tsx, top-nav.tsx, app/docs/page.tsx, components/docs/*, task-type-tabs.tsx, workbench/params-3d.tsx, workbench/params-motion.tsx, app/page.tsx, task-card.tsx, detail/model-3d-panel.tsx, detail/motion-panel.tsx, app/gallery/page.tsx, motion-presets.ts
- **summary**: 按用户要求一次性把"文档管理中心(类似 Cursor/ 3D 模型生成 / 动作生成"的界面与交互全部用占位实现落地。文档中心:`/docs` 三栏DocTree+Tabs+Editor+Outlinetextarea+react-markdown+remark-gfm 编辑预览,`[[asset:id]]` 在正文内渲染为资产卡片AssetPicker 浮层 + Ctrl/Cmd+K CommandPalette 全站搜索。工作台TaskTypeTabs 切 2D/3D/动作,按类型显示 Params3D管道/视图/分辨率)或 ParamsMotionBar模型/帧数/fps/循环/输出/角色/动作描述TaskCard 加 variant 区分颜色图标和 3D/动作专有字段。结果详情Model3DPanel多视图+360°占位+`<model-viewer>` 三模式,通道切 color/normal/stylized、MotionPanel视频/帧序列双模式+scrubber+MP4/GIF 下载占位)。资源库:顶部模态 Tab图片/3D/动作)+ 计数与 Phase 徽章3D/动作网格叠徽章+视角/帧数,动作卡片 hover 自动播放视频。`<model-viewer>` 通过 `next/script` lazyOnload 引入,真实管线接入时只换数据源。一次性写完避免后续反复返工;`npx next build` 通过。
### [CL-20260420-1800] 2026-04-20 — Mesh Pipeline Phase 3 风格还原接入IP-Adapter + ControlNet
- **tags**: 视角变换, Mesh Pipeline, 风格还原, IP-Adapter, ControlNet, style_restorer, Phase 3
- **affected_files**: config.py, services/style_restorer.py (新增), services/view_transform.py, scripts/test_style_restore.py (新增), VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **summary**: 按 SPEC §4 Phase 3 落地 Mesh Pipeline 最后一段。在 `IMAGE_MODELS``internal=True` 注册 `chigozienri/ip_adapter-sdxl-controlnet-depth`$0.07/张 ~72sL40S`get_image_models_list()` 改过滤 internal 防污染前端下拉;新增 helper `get_style_restore_model_id()`。新增 `services/style_restorer.py``restore()` 调 Replicate`image`=用户原图做 IP-Adapter 风格参考,`controlnet_input`=Stage 2 的 normal 帧做结构约束,即 SPEC §1.4 的"路 A"`restore_batch()``asyncio.Semaphore(3)` 批量重绘防 Replicate rate limit。`view_transform._transform_view_mesh` 接入 Stage 3`preserve_style=True` 时对每帧调 restore_batch**单帧失败自动回退为 Stage 2 color 帧且不阻塞整体**error 字段挂出原因),与"Stage 2 失败不影响 Stage 1"的独立交付设计一致;`images[i]` 新增 `color_url/restyled/restyle_error` 字段,`_stage` 升级为 `stage3 (N/M restyled)`。新增 `scripts/test_style_restore.py` 供 Checkpoint 3支持 `--single` 冒烟($0.07/全量($0.42)、`--scale/--cn-scale` 覆盖参数、默认自动挑最新一组 normal 帧,复用 Phase 2 产物无需再跑 Trellis。默认参 scale=0.75 / controlnet=0.8P3-4 调参由用户验收阶段确认。一次完整 6 视角请求 ~$0.46 / ~105s。
### [CL-20260420-1700] 2026-04-20 — Mesh Pipeline Phase 2 抽帧 + combined_video 左右切分
- **tags**: 视角变换, Mesh Pipeline, Trellis, ffmpeg, imageio-ffmpeg, PIL, video_frame_extractor, Phase 2
- **affected_files**: video_frame_extractor.py (新增), view_transform.py, requirements.txt, scripts/test_frame_extract.py (新增), VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **summary**: 按 SPEC §4 Phase 2 落地 Mesh Pipeline 的阶段 2 抽帧。系统 ffmpeg 未装,改用 `imageio-ffmpeg==0.5.1` 自带的 Windows 静态二进制(`ffmpeg-win64-v4.2.2.exe`wheel 22.6MB,免系统依赖)。新增 `services/video_frame_extractor.py` 实现 `extract_by_azimuth(combined_video_path, azimuths, elevations, total_rotation_deg=360)`:策略采用"一次全帧抽取到临时目录 + 按帧索引挑 + PIL 按中线切左右半",比 `-ss` 逐帧抽更稳(免 keyframe 对齐5s 视频成本可忽略ffmpeg + PIL 阻塞部分用 `run_in_executor` 丢到线程池。`view_transform._transform_view_mesh` 串接 Stage1→Stage2默认 `azimuths=[0,60,120,180,240,300]`,返回 `images[{url,azimuth,elevation,normal_url,frame_idx}]`Stage 2 失败不会把整体置为失败Stage 1 的 mesh/video 仍有交付价值,单独填 `error` 字段)。新增 `scripts/test_frame_extract.py` 供用户复用已有 `generated/trellis_combined_*.mp4` 做 Checkpoint 2 验收,无需再花钱跑 Trellis。Agent 已自测 120 帧 combined_video6 视角映射到 frame_idx=0/20/40/60/80/100完全均匀+ 所有切图 512×512 方形。等用户 Checkpoint 2 验收后开 Phase 3IP-Adapter+ControlNet 风格还原)。
### [CL-20260420-1600] 2026-04-20 — Mesh Pipeline Phase 1 接入 Trellis阶段 1 地基)
- **tags**: 视角变换, Mesh Pipeline, Trellis, Replicate, services, config, tools, Phase 1
- **affected_files**: mesh_generator.py (新增), view_transform.py, config.py, tools.py, scripts/test_trellis.py (新增), VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **summary**: 按 SPEC §4 Phase 1 落地 Mesh Pipeline 第二条视角变换管道的阶段 1。新增 `services/mesh_generator.py` 封装 Trellis 调用(`firtoz/trellis:e8f6c452...`+ httpx 下载 `.glb/color_video/normal_video``generated/``config.py``VIEW_TRANSFORM_MODELS` 注册 `trellis`enabled`hunyuan3d`enabled=False 占位),给所有条目加 `pipeline` 字段;`view_transform.py``pipeline` 分流 grid/meshmesh 阶段 1 只返回 mesh_url + color_video + normal_videoimages 暂空;`tools.py``transform_view` 工具扩 `model_id/azimuths/preserve_style`。另新增 `scripts/test_trellis.py` 作为独立验收入口。Zero123++ 现有行为完全不变。等用户手跑脚本验收后开 Phase 2ffmpeg 抽帧)。
### [CL-20260420-1530] 2026-04-20 — 写入 Mesh-Pipeline 视角变换方案 SPEC跨会话实施计划
- **tags**: 视角变换, Mesh Pipeline, Trellis, Hunyuan3D-2, SPEC, 方案
- **affected_files**: docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
- **summary**: 针对"Zero123++ 只出 6 固定视角 + 建筑大角度易崩"的痛点,规划第二条视角变换路径 Mesh Pipeline。三段式Trellis 生 .glb + 360° color_video$0.041/30s → ffmpeg 按 azimuth 抽帧 → `chigozienri/ip_adapter-sdxl-controlnet-depth` 用原图做 IP-Adapter 风格还原。文档含三个 Replicate 模型的版本 ID/OpenAPI schema、config 扩展设计、6 个 Phase 可独立执行任务清单、跨会话恢复指引。Hunyuan3D-2 + pyrender 自由相机留作 Phase 5 二期可选。一期工程量 ~5h单次总成本 ~$0.46/~105s。
### [CL-20260420-1501] 2026-04-20 — 后端增加 watchfiles 依赖,修复 Windows uvicorn --reload 失效
- **tags**: uvicorn, watchfiles, 热重载, Windows, reload, requirements
- **affected_files**: requirements.txt, requirements-lock.txt
- **summary**: 排查"Zero123++ 切图还是错位"时发现CL-20260420-1212 的修复在磁盘上完全正确,但后端 uvicorn 进程是 11:55 启动从未 reload。根因是 venv 里 `watchfiles``watchdog` 都没装,`--reload` 退化成 StatReload 轮询式 reloader在 Windows + 深层子目录 + 新建文件 (`view_transform.py`) 场景下基本失效。解决pip 安装 `watchfiles==0.24.0` 并写入 requirements 两个文件。**仅装依赖不会对当前进程生效,必须 kill + 重启后端**。踩坑记为 PF-20260420-1501。
### [CL-20260420-1212] 2026-04-20 — 修复 Zero123++ 视角切图布局反转3×2→2×3
- **tags**: Zero123++, 视角变换, 切图, bug修复, grid_layout
- **affected_files**: config.py, view_transform.py
- **summary**: 用户反馈 `transform_view` 输出的 6 张视角图严重错位(每张呈窄长条、上下拼两个物体)。根因是 `jd7h/zero123plusplus` Replicate 部署版实际输出 **2 列 × 3 行** 的高图,但 `config.py` 里按官方 README "3×2 grid" 字面意思配成了 `cols=3, rows=2`。修复:(1) `grid_layout` 改为 `{"cols": 2, "rows": 3}`(2) `_split_grid_image` 新增健壮性保护——假定单视角正方形,若配置 ratio 与实际图像 ratio 差异 >20%,自动交换 cols/rows 并打 warning让未来换其他视角变换模型时免疫类似布局歧义。踩坑已记录 PF-20260420-1212。
### [CL-20260417-1510] 2026-04-17 — Cloudflare 穿透脚本兼容 PS5.1 + 本地 cloudflared
- **tags**: 内网穿透, cloudflared, PowerShell, start-tunnel, env
- **affected_files**: art-agent/start-tunnel.ps1, .gitignore
@@ -68,253 +103,3 @@
- **affected_files**: image_gen.py, config.py
- **summary**: 新增 GeminiNativeImageProvider通过向量引擎中转调用 Gemini 原生 `/v1beta/models/{model}:generateContent` 接口(非 OpenAI 兼容的 images/edit。支持文字+图片混合输入(最多 14 张参考图),图片以 inline_data base64 内联传入。新增 `_resolve_image_base64` 工具函数统一处理 data URI 和本地路径到 (mime, b64) 的解析。config.py 中 Gemini 模型改用 `gemini_native` provider 并恢复 `supports_ref_image: True`。Provider 注册表新增 `gemini_native` 条目。
### [CL-20260416-0620] 2026-04-16 — 修复拖拽上传重复 + Gemini 改回不支持参考图 + images.edit 参数修正
- **tags**: bug修复, 拖拽, Gemini, 向量引擎, images.edit, config
- **affected_files**: chat-input.tsx, config.py, image_gen.py
- **summary**: 三个问题修复:(1) 拖拽到 ChatInput 区域时事件冒泡导致 page.tsx 和 ChatInput 各触发一次上传 → ChatInput handleDrop/handleDragOver 加 stopPropagation(2) 向量引擎 Gemini 图片编辑走原生 generateContent 接口而非 OpenAI 兼容 images/edits对 Gemini 调用 images.edit 返回 500 → Gemini 改回 supports_ref_image=False(3) images.edit 端点不支持 quality 参数 → 从 _edit_with_refs 中移除。
### [CL-20260416-0600] 2026-04-16 — 多张参考图全链路支持
- **tags**: 前端, 后端, 参考图, 多图, OpenAI, images.edit, Provider, API, Agent Loop
- **affected_files**: chat-input.tsx, page.tsx, chat-messages.tsx, api.ts, types.ts, chat.py, loop.py, tools.py, image_gen.py, config.py
- **summary**: 从单张参考图扩展为多张参考图全链路支持。前端ChatInput 改为多图状态数组+file input multiple+多图预览/删除/拖拽API层 sendChat 改为 ref_image_urls: string[]消息气泡和沿用逻辑适配多图。后端chat.py 新增 ref_image_urls JSON 数组参数(兼容旧 ref_image_urlAgent Loop 多图注入 vision LLM 上下文OpenAIImageProvider 有参考图时改用 images.edit 端点支持最多16张Replicate 取首张兼容GPT Image 1.5 和 Gemini 注册表标记 supports_ref_image=True。
### [CL-20260416-0510] 2026-04-16 — 生图模型新增 Gemini 3.1 Flash Image
- **tags**: 后端, 生图模型, Gemini, 向量引擎, config
- **affected_files**: config.py, .env
- **summary**: IMAGE_MODELS 注册表新增 gemini-3.1-flash-imagemodel_id: gemini-3.1-flash-image-previewprovider 为 openai复用 OpenAIImageProvider 走向量引擎中转。无需新增 Provider 代码。
### [CL-20260416-0500] 2026-04-16 — 全区域拖拽添加参考图
- **tags**: 前端, UX, 拖拽, 参考图, 上传
- **affected_files**: page.tsx, chat-input.tsx
- **summary**: 拖拽上传参考图区域从底部输入栏扩展到整个对话区域。ChatInput 改为 forwardRef 暴露 uploadFile 方法main 区域处理拖放事件并调用。拖入时显示全屏覆盖层提示,用 dragCounter 防止子元素冒泡导致闪烁。
### [CL-20260416-0430] 2026-04-16 — 接入 GPT Image 1.5 生图模型(向量引擎中转)
- **tags**: 后端, 生图模型, GPT-Image, OpenAI, 向量引擎, provider
- **affected_files**: image_gen.py, config.py, .env
- **summary**: 新增 OpenAIImageProvider通过向量引擎 API 中转调用 `/v1/images/generations` 端点。IMAGE_MODELS 注册表新增 gpt-image-1.5provider: openai支持 size/quality 参数和 URL/base64 双格式返回。
### [CL-20260416-0400] 2026-04-16 — Session 级参考图自动沿用 + 缺参考图前置校验
- **tags**: 前端, 后端, 参考图, UX, InstantStyle, 风格迁移
- **affected_files**: page.tsx, chat-input.tsx, image_gen.py
- **summary**: 用户发送带参考图的消息后,前端在 session 级别记住该参考图 URL后续消息自动沿用输入框显示"沿用上次参考图"提示条,可清除)。解决切换 InstantStyle 后无参考图导致 "No input, Save money" 的问题。后端对需要参考图但未收到的模型统一返回友好错误。
### [CL-20260416-0300] 2026-04-16 — System Prompt 禁止 LLM 嵌入图片链接
- **tags**: 后端, agent-loop, system-prompt, LLM行为约束
- **affected_files**: loop.py
- **summary**: LLMGPT 系列)在收到 generate_image 工具返回的本地路径后,自行拼凑 `sandbox:/generated/xxx.png` Markdown 图片链接。前端图片展示由 image_result SSE 事件独立处理,文字中的链接无效且多余。在 System Prompt 注意事项中新增禁止嵌入图片 Markdown 的约束。
### [CL-20260416-0245] 2026-04-16 — 修复三点菜单 z-index 层级问题
- **tags**: 前端, UI, sidebar, 菜单, z-index
- **affected_files**: session-list.tsx
- **summary**: 三点菜单原用 fixed 定位在侧栏右侧弹出,被主内容区遮挡。改为 inline 在会话项正下方展开,模型子菜单改为折叠式内联列表(带 max-h 滚动),全部在侧栏内部完成,不再溢出。移除了 menuPos 状态和 fixed 定位逻辑。
### [CL-20260416-0230] 2026-04-16 — LLM 注册表扩充 + 默认模型改 GPT-5.4
- **tags**: 后端, config, LLM, 模型注册表
- **affected_files**: config.py, .env
- **summary**: LLM_MODELS 从 3→7 个:新增 gpt-5.4、claude-sonnet-4-6、claude-opus-4-6、gemini-3.1-pro-preview、glm-4.7,全部走 vectorengine provider。默认模型从 gpt-4o-mini 改为 gpt-5.4。gpt-4o-mini/deepseek-chat 保留。
### [CL-20260416-0200] 2026-04-16 — 接入向量引擎中转 API + 对话级 LLM 模型切换
- **tags**: 后端, 前端, LLM, 向量引擎, 中转API, 模型切换, config, UI, session
- **affected_files**: config.py, loop.py, chat.py, .env, types.ts, api.ts, app-context.tsx, session-list.tsx, page.tsx
- **summary**: 新增 LLM_MODELS 注册表gpt-4o-mini/gpt-4o/deepseek-chatprovider 分发为 vectorengine中转和 deepseek直连。loop.py 改为按 provider 创建 AsyncOpenAI 客户端vision 检测从注册表读取。chat.py 新增 llm_model 参数和 GET /api/llm-models 端点。前端 Session 新增 llmModel 字段侧栏三点菜单支持模型选择子菜单sendChat 传递 llm_model。
### [CL-20260416-0030] 2026-04-16 — 青绿山水风格改造:千里江山图色调 + 云烟雾气动效
- **tags**: 前端, UI改造, 视觉风格, 青绿山水, 千里江山图, CSS动画, 色彩体系, 云烟
- **affected_files**: globals.css, top-nav.tsx, sidebar.tsx, session-list.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, image-detail-panel.tsx, profile-modal.tsx, page.tsx, gallery/page.tsx, login/page.tsx, VISUAL-STYLE-GUIDE.md
- **summary**: 全站从 Cyberpunk 霓虹改为青绿山水风格。色彩替换为石青绿(#4DB8A4)/石绿蓝(#3A8FB7)/墨绿底色(#0C1210)。背景改为双层漂移云雾(60s+45s)。.glow-border hover 效果柔化为云烟缭绕(6s/宽弧段/blur 12px)。.glass-panel 加绿底渐变+增强模糊。新增 .fog-scroll 雾气滚动边缘。11 个组件硬编码颜色全部替换。VISUAL-STYLE-GUIDE.md 完全重写。
### [CL-20260415-2330] 2026-04-15 — 用户个人信息 + 记忆查看面板:右上角菜单弹窗
- **tags**: 前端, 后端, 用户信息, 记忆系统, Mem0, Modal, ProfileModal
- **affected_files**: api/memory.py, main.py, profile-modal.tsx, top-nav.tsx
- **summary**: 后端新建 GET /api/memory/list 端点Mem0 get_all + run_in_executor前端新建 ProfileModal 组件glass-panel 风格居中弹窗),展示用户信息 + 按时间分组的记忆列表只读。TopNav 的 username 行改为可点击按钮,点击打开弹窗。
### [CL-20260415-2230] 2026-04-15 — 光影流动边框效果:导航活动项 + 活动会话项
- **tags**: 前端, CSS动画, 视觉效果, conic-gradient, 霓虹边框
- **affected_files**: globals.css, top-nav.tsx, session-list.tsx, sidebar.tsx
- **summary**: 用 @property + conic-gradient 实现旋转光影边框效果。双伪元素方案:::before 做渐变边框mask-composite 裁内部),::after 做模糊扩散光晕。提供 .glow-border动画和 .glow-border-statichover 静态发光)两个类,分别应用到导航活动 Tab 和活动会话项。
### [CL-20260415-2200] 2026-04-15 — 全站 UI 风格改造Cyberpunk Dark Neon 主题
- **tags**: 前端, UI改造, 视觉风格, Cyberpunk, Neon, 毛玻璃, 全局样式
- **affected_files**: globals.css, page.tsx, gallery/page.tsx, login/page.tsx, top-nav.tsx, sidebar.tsx, session-list.tsx, tag-filter.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, model-selector.tsx, image-detail-panel.tsx, VISUAL-STYLE-GUIDE.md
- **summary**: 参考 SolCasino Dribbble 设计,全站从朴素暗色改为 Cyberpunk Dark Neon 风格。主色从紫色改为青绿霓虹(#00E5A0),新增环境光晕背景、毛玻璃面板(backdrop-blur)、霓虹发光边框。所有组件统一升级圆角和过渡效果,图片卡片加 hover 缩放+发光。同时编写了完整的视觉风格文档。
### [CL-20260414-2350] 2026-04-14 — 修复登录后 useApp must be used within AppProvider 报错
- **tags**: bug修复, 前端, 认证, AppProvider, 初始化时序
- **affected_files**: app-context.tsx
- **summary**: AppProvider 在 `!initialized` 时不提供 context 但渲染了需要 useApp() 的子组件。修复:已认证但未初始化时显示加载状态,未认证时才渲染裸 children/login
### [CL-20260414-2340] 2026-04-14 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题
- **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级
- **affected_files**: auth.py, requirements.txt, docs/art-agent/USER-SYSTEM.md
- **summary**: passlib 已停止维护且与 bcrypt 5.x 不兼容。替换为 pwdlib[argon2,bcrypt],新密码用 Argon2id旧 bcrypt 哈希仍可验证。不再需要锁定 bcrypt 版本。
### [CL-20260414-2330] 2026-04-14 — 修复 passlib + bcrypt 5.x 兼容性问题,锁定 bcrypt<4.1
- **tags**: bug修复, 依赖, bcrypt, passlib, 用户系统
- **affected_files**: requirements.txt, docs/art-agent/USER-SYSTEM.md
- **summary**: passlib 1.7.4 与 bcrypt 5.0.0 不兼容(`__about__` 移除 + 密码长度 ValueError后端启动报错。降级 bcrypt 到 4.0.1 并在 requirements.txt 中锁定 `>=4.0.1,<4.1`。同时在用户系统文档故障排除中补充此问题。
### [CL-20260414-2300] 2026-04-14 — 用户系统:管理员邀请制 + JWT 认证 + 数据按用户隔离
- **tags**: 用户系统, 认证, JWT, SQLite, 数据隔离, 多用户, 安全
- **affected_files**: db.py, auth.py, api/auth.py, api/admin.py, chat.py, main.py, loop.py, auth-context.tsx, auth-guard.tsx, api.ts, store.ts, app-context.tsx, layout.tsx, login/page.tsx, top-nav.tsx, model-selector.tsx, requirements.txt, .env.example
- **summary**: 后端新增 SQLite 用户表 + JWT 认证access+refresh+ 管理员/普通用户路由,所有 API 加鉴权Mem0 user_id 改为真实用户 ID。前端新增登录页 + AuthProvider/AuthGuard + 401 自动跳转 + localStorage 按用户 ID 隔离。TopNav 新增用户菜单。首次启动自动创建 admin。
### [CL-20260414-2230] 2026-04-14 — 对话区域新增"回到底部"浮动按钮
- **tags**: 前端, UX, 滚动, 浮动按钮
- **affected_files**: art-agent/frontend/src/app/page.tsx
- **summary**: 长对话中不在底部时,右下角显示向下箭头按钮,点击平滑滚到最新消息。复用 scroll 监听器检测距底部距离(>200px 显示),切换会话后也同步按钮状态。
### [CL-20260414-2220] 2026-04-14 — 修复长对话切换回来滚动位置上移scroll 事件实时保存取代 effect 延迟保存
- **tags**: 前端, bug修复, 滚动, 多会话, UX, scroll 事件
- **affected_files**: art-agent/frontend/src/app/page.tsx
- **summary**: effect 中保存 scrollTop 时 DOM 已变为短对话内容,浏览器 clamp scrollTop 导致保存值偏小。改用 scroll 事件实时记录位置,确保在任何 DOM 变更之前数据已正确保存。
### [CL-20260414-2210] 2026-04-14 — 修复会话切换时滚动位置跳动:时序竞争 + 抑制切换期间 smooth scroll
- **tags**: 前端, bug修复, 滚动, 多会话, UX, 时序
- **affected_files**: art-agent/frontend/src/app/page.tsx
- **summary**: CL-20260414-2200 的 rAF 恢复时序不可靠——React 可能还没渲染新会话内容scrollHeight 是旧值。拆为两个 effect保存+标记 / 等 messages 更新后恢复),新增 `isSwitching` 抑制切换期间的 smooth scroll 干扰instant 模式改为同步赋值 `scrollTop`
### [CL-20260414-2200] 2026-04-14 — 修复多会话滚动位置共享 bugper-session 独立记录 + 切换恢复
- **tags**: 前端, bug修复, 滚动, 多会话, UX
- **affected_files**: art-agent/frontend/src/app/page.tsx
- **summary**: 所有会话共用同一 scrollRef切换时不保存/恢复 scrollTop。修复新增 `scrollPositions` Map 按 sessionId 记录滚动位置,`useEffect` 监听 `activeSessionId` 变化时保存旧位置、恢复新位置(有记录则恢复,无记录则 scrollToBottom`scrollToBottom` 改为 useCallback + 支持 instant 模式。
### [CL-20260413-2320] 2026-04-13 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆
- **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口
- **affected_files**: app/memory.py, app/agent/loop.py, app/config.py, app/api/chat.py, requirements.txt, .env, frontend/src/lib/api.ts, frontend/src/app/page.tsx
- **summary**: 集成 Mem0 OSS 作为统一记忆方案。DeepSeek 做事实提取Ollama nomic-embed-text 做本地 embeddingQdrant 文件模式做向量存储。agent loop 新增滑动窗口MAX_RECENT_TURNS=20、记忆检索注入 system prompt、异步事实存储。前端传递 session_id 实现会话级记忆作用域。不做静默降级,所有错误显式报告。
### [CL-20260413-1130] 2026-04-13 — 修复 SDXL 模型 404 错误:补全 Replicate 版本 hash
- **tags**: bug修复, Replicate, SDXL, 模型配置, 404
- **affected_files**: art-agent/backend/app/config.py
- **summary**: SDXL 调用 Replicate 404根因同 KolorsCL-20260413-0440非 Flux 官方模型需要 `owner/model:version_hash` 完整格式。将 `stability-ai/sdxl` 补全为 `stability-ai/sdxl:39ed52f2...e08b`
### [CL-20260413-1100] 2026-04-13 — 修复切换模型后 LLM 仍声称使用旧模型名
- **tags**: Agent Loop, system prompt, 模型选择, LLM 幻觉
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: System Prompt 不含模型信息LLM 从对话历史幻觉旧模型名。修复:在 system prompt 末尾动态注入当前模型名 + 强调忽略历史中的旧记录假生成重试消息也附带模型名。debug 日志验证传递链路正确,问题仅在 LLM 文字层。
### [CL-20260413-1030] 2026-04-13 — Agent Loop 假生成检测 + 自动重试机制
- **tags**: Agent Loop, DeepSeek, function calling, 防幻觉, 自动修复
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: DeepSeek 无视 prompt 约束仍用文字模拟生图。新增代码层面检测LLM 纯文字回复中命中"已生成"等关键词 >= 2 次时,自动注入纠正消息强制重试调用工具。重试消耗一次迭代配额。
### [CL-20260413-1000] 2026-04-13 — 助手消息气泡添加复制文本按钮
- **tags**: 前端, UX, chat-messages, 复制
- **affected_files**: art-agent/frontend/src/components/chat/chat-messages.tsx
- **summary**: 助手回复气泡下方新增复制文本按钮hover 显示),点击通过 clipboard API 复制文本并显示 1.5s 对勾反馈。只对 assistant 消息展示。
### [CL-20260413-0930] 2026-04-13 — System Prompt 加入"禁止模拟工具调用"约束
- **tags**: prompt 工程, system prompt, DeepSeek, function calling, 防幻觉
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: DeepSeek 在多步生图任务中有时不调用工具而是用文字模拟"已生成",导致前端收不到图片。在 System Prompt 注意事项中加入硬性约束:"生成图片必须调用 generate_image 工具,禁止用文字模拟"。
### [CL-20260413-0900] 2026-04-13 — 非 vision + 参考图场景:尊重用户主动指定的风格意图
- **tags**: prompt 工程, IP-Adapter, 风格, system prompt, UX
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: SYSTEM_PROMPT 和非 vision hint 中对风格关键词的禁止从"绝对禁止"改为"不自行猜测,但用户明确指定则保留到 prompt 中"。之前的绝对性措辞导致 LLM 即使用户主动写了"赛博朋克风"也会丢弃。修正后 IP-Adapter 风格迁移与用户文字风格可叠加。
### [CL-20260413-0800] 2026-04-13 — 修复 InstantStyle ReadTimeout + 多次生成丢失参考图
- **tags**: bug修复, Replicate, InstantStyle, timeout, 参考图, Agent Loop, wait
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/loop.py
- **summary**: 运行时日志证实两个问题:(1) `wait=60` 时 SDK 内部 `read=60.5s` timeout 覆盖客户端 300s timeout上传 1.6MB base64 + 等待初始响应超过 60s → ReadTimeout。改为 `wait=False`create 请求立即返回 prediction IDSDK 自动走 `prediction.async_wait()` 轮询,轮询用客户端级 read=300s timeout。(2) 成功生成后清除 ref_image_url 导致后续工具调用丢失参考图InstantStyle 返回 "No input, Save money"。完全移除清除逻辑ref_image_url 在整个对话期间保持有效。
### [CL-20260413-0730] 2026-04-13 — 修复生图失败后 LLM 重试丢失参考图
- **tags**: bug修复, Agent Loop, 参考图, InstantStyle, ref_image_url
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: LLM 第一次生图工具调用失败后重试时参考图丢失。InstantStyle 返回 "No input, Save money"。根因:`loop.py` 在每轮工具调用结束后无条件清除 `ref_image_url`。修复:改为只在本轮至少有一次成功产出图片时才清除,失败时保留参考图供 LLM 重试。
### [CL-20260413-0700] 2026-04-13 — 修复 InstantStyle 生图 422 + ReadTimeoutwait 参数超限
- **tags**: bug修复, Replicate, InstantStyle, API参数, async_run, timeout
- **affected_files**: art-agent/backend/app/services/image_gen.py
- **summary**: InstantStyle 生图 422`Prefer: wait=x must be 1-60`+ ReadTimeout61.4s)。两个问题同根因:`async_run(wait=300)` 超出 API 限制,改为 `wait=True` 后 SDK 内部 60.5s timeout 覆盖了客户端 300s timeout。最终改为 `wait=60`API 最大合法值),初始请求 60s 内未完成则 SDK 自动 fallback 到异步轮询,轮询用客户端级 read=300s timeout。
### [CL-20260413-0630] 2026-04-13 — 修复 Replicate SDK 不走代理 + 超时不够
- **tags**: bug修复, Replicate, InstantStyle, 代理, httpx, 超时
- **affected_files**: art-agent/backend/app/services/image_gen.py
- **summary**: Replicate SDK 内部显式传 `transport` 给 httpx绕过了环境变量代理配置导致 ConnectTimeout且默认 read 30s 不够 InstantStyle 的 ~128s。修复`_make_replicate_client()` 工厂函数从 `HTTPS_PROXY` 读取代理注入 `AsyncHTTPTransport(proxy=...)`read 300s / connect 30s。`_download_image` 同步加了代理。
### [CL-20260413-0600] 2026-04-13 — 新增 InstantStyle 模型:强风格迁移选项
- **tags**: 模型注册, InstantStyle, 风格迁移, IP-Adapter
- **affected_files**: art-agent/backend/app/config.py
- **summary**: 注册 InstantStyle`jyoung105/instant-style`)到模型注册表,`block_mode: "style-only"` + `style_strength: 1.0`,通过 `ref_image_param: "style_image"` 适配现有抽象。与 Kolors 并列为参考图模型,提供强风格迁移能力。
### [CL-20260413-0540] 2026-04-13 — 非 vision LLM + 参考图时禁止猜测风格
- **tags**: prompt 工程, IP-Adapter, 风格一致性, system prompt
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: 修改 system prompt 和非 vision 模型的参考图提示明确禁止猜测画风。LLM 只描述画面内容,风格交给 IP-Adapter 从参考图提取。解决了 DeepSeek 看不到参考图时脑补 "pixel art" 导致生成结果与参考图风格不一致的问题。
### [CL-20260413-0520] 2026-04-13 — 多图生成改为单次 API 调用 + 修复空错误信息
- **tags**: 性能优化, Replicate, 速率限制, 错误处理, 模型配置
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/config.py
- **summary**: 多图生成从 for 循环改为单次 API 调用(新增 `num_images_param` 配置),避免低余额账户的速率限制。同时修复空异常时 `[生成失败: ]` 信息丢失的问题,改为显示异常类型名和 repr。
### [CL-20260413-0500] 2026-04-13 — 生成图片后显示使用的模型名称
- **tags**: 模型溯源, SSE, 图片生成, 前端展示
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/tools.py, art-agent/backend/app/agent/loop.py, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx
- **summary**: `generate_images` 返回 `GenerateResult`(含 model_nameSSE 事件携带模型名称前端在图片下方和错误提示中展示。ChatMessage 新增 `modelName` 字段,归档后仍可溯源。
### [CL-20260413-0440] 2026-04-13 — 修复 Kolors IP-Adapter 模型 404 错误:补全 Replicate 版本 hash
- **tags**: bug修复, Replicate, Kolors, IP-Adapter, 模型配置
- **affected_files**: art-agent/backend/app/config.py
- **summary**: Kolors IP-Adapter 生图 404 的根因是 Replicate SDK 对社区模型需要 `owner/model:version_hash` 格式。在 config.py 中补全 kolors-ipadapter 的版本 hash 后,实测生图成功。
### [CL-20260413-0430] 2026-04-13 — 图片生成失败时将具体错误信息透传到前端
- **tags**: 错误处理, Agent Loop, SSE, 调试体验
- **affected_files**: art-agent/backend/app/agent/loop.py, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/lib/api.ts
- **summary**: 新增 `tool_error` SSE 事件类型。Agent Loop 在工具执行产生错误时,额外 yield 一个 tool_error 事件携带具体报错信息;前端处理该事件并以 ⚠️ 前缀展示在对话流中。解决了之前工具失败时用户只能看到 LLM 模糊翻译、无法获取真实错误详情的问题。
### [CL-20260413-0330] 2026-04-13 — README 补充访问地址汇总 + 穿透管理文档
- **tags**: 文档, README, 穿透, Cloudflare, cloudflared
- **affected_files**: art-agent/README.md
- **summary**: README 新增"访问地址"章节(本机/局域网/外网三种场景表格),重写"远程访问(外网穿透)"章节,补充 cloudflared 安装指引winget + 手动下载)、穿透管理操作表(启动/停止/查看状态/重启)、代理配置排错说明。
### [CL-20260412-2345] 2026-04-12 — 参考图上传独立化:进度条 + 状态反馈 + 失败重试
- **tags**: 前端, 后端, 参考图, 上传, UX, 进度反馈
- **affected_files**: art-agent/backend/app/api/chat.py, art-agent/frontend/src/lib/api.ts, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/app/page.tsx
- **summary**: 参考图从"随消息提交"改为"选中即独立上传"。后端新增 POST /api/upload-ref-image 端点;前端用 XMLHttpRequest 获取上传进度事件ChatInput 显示环形进度条、成功/失败角标、失败可重选。sendChat 参数从 File 改为服务端路径。同时修复 file input 多次上传不重置的问题。
### [CL-20260412-2315] 2026-04-12 — 修复 Cloudflare Tunnel 跨域开发警告
- **tags**: 前端, 配置, Next.js, Cloudflare, 跨域
- **affected_files**: art-agent/frontend/next.config.ts
- **summary**: Next.js 检测到来自 trycloudflare.com 的跨域请求发出警告。在 next.config.ts 中添加 `allowedDevOrigins: ["*.trycloudflare.com"]` 消除警告,通配符覆盖所有 Quick Tunnel 随机域名。
### [CL-20260412-2300] 2026-04-12 — 集成 Kolors IP-Adapter参考图可直接用于风格生成
- **tags**: 后端, 前端, IP-Adapter, 风格迁移, 参考图, 模型注册, Replicate, 架构
- **affected_files**: art-agent/backend/app/config.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/loop.py, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/components/chat/model-selector.tsx
- **summary**: 集成 Kolors IP-Adapter 模型fofr/kolors-with-ipadapter~$0.004/次),参考图通过 base64 data URI 直接传给 Replicate 的 IP-Adapter 参与风格生成。模型注册表新增 supports_ref_image / ref_image_param 通用字段,新增 IP-Adapter 模型只需加配置。to_data_uri 抽为公共函数供 loop.py 和 image_gen.py 共用。前端模型列表显示绿色"参考图"徽章。
### [CL-20260412-2230] 2026-04-12 — 用户消息气泡中显示参考图缩略图
- **tags**: 前端, 参考图, UX, chat-messages
- **affected_files**: art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx
- **summary**: 用户上传参考图后发送消息之前聊天气泡中不显示参考图无法确认是否成功附带。修复ChatMessage 类型新增 refImageUrl 字段page.tsx 构造用户消息时用 URL.createObjectURL 保存预览 URLchat-messages.tsx 在用户消息气泡中渲染缩略图(最大 160×120px。Blob URL 仅当前会话有效,不持久化。
### [CL-20260412-2200] 2026-04-12 — 修复 DeepSeek 不支持 image_url 导致参考图上传报错
- **tags**: bug修复, 后端, Agent Loop, vision, DeepSeek, 多模态
- **affected_files**: art-agent/backend/app/agent/loop.py
- **summary**: 上传参考图时使用 OpenAI Vision 格式(`image_url` content type发送给 DeepSeek API但 DeepSeek 只支持 `text` 类型,导致 400 错误。修复:新增 vision 能力检测(基于模型名称关键词),不支持 vision 的模型改为文字提示方式告知 LLM 有参考图,参考图仍传给图像生成工具处理。
### [CL-20260412-2130] 2026-04-12 — 修复图片生成失败时前端显示破碎图标
- **tags**: bug修复, 前端, 后端, 图像生成, 错误处理
- **affected_files**: art-agent/backend/app/agent/tools.py, art-agent/frontend/src/app/page.tsx
- **summary**: Replicate API 调用失败(如余额不足 402错误字符串 `[生成失败: ...]` 被混入 images 列表传给前端,导致 `<img>` 标签渲染破碎图标。修复:后端 tools.py 分离有效 URL 和错误信息为 images/errors 两个字段;前端 page.tsx 过滤掉以 `[` 开头的无效 URL无有效图片时不渲染图片网格。
### [CL-20260412-2100] 2026-04-12 — 生图模型动态切换Provider 抽象 + 前端模型选择器
- **tags**: 后端, 前端, 图像生成, 模型切换, Provider, 重构, Replicate
- **affected_files**: art-agent/backend/app/config.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/app/api/chat.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/agent/tools.py, art-agent/backend/.env, art-agent/backend/.env.example, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/lib/api.ts, art-agent/frontend/src/components/chat/model-selector.tsx, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/app/page.tsx
- **summary**: 将图像生成从 Replicate 硬绑定重构为 Provider 抽象层。后端config.py 新增 IMAGE_MODELS 注册表flux-schnell / flux-dev / SDXLimage_gen.py 引入 ImageProvider 基类 + ReplicateProvider 实现chat.py 新增 GET /api/models + POST /api/chat 增加 image_model 参数agent loop / tools 全链路透传。前端:新建 ModelSelector 下拉组件集成到 ChatInput每条消息可选不同模型选择持久化到 localStorage。
### [CL-20260412-1800] 2026-04-12 — 移动端适配 + Cloudflare Tunnel 内网穿透
- **tags**: 前端, 响应式, 移动端, 部署, Cloudflare, 穿透, 配置
- **affected_files**: art-agent/frontend/src/app/layout.tsx, art-agent/frontend/src/app/globals.css, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/app/gallery/page.tsx, art-agent/frontend/src/components/sidebar/sidebar.tsx, art-agent/frontend/src/components/layout/top-nav.tsx, art-agent/frontend/src/components/detail/image-detail-panel.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/components/chat/image-grid.tsx, art-agent/frontend/src/lib/app-context.tsx, art-agent/frontend/next.config.ts, art-agent/start-tunnel.ps1, art-agent/README.md
- **summary**: 为实现"手机浏览器远程访问"做了两大块工作。(1) 移动端响应式适配侧边栏改为固定定位抽屉式md 以下),顶栏新增汉堡菜单,图片详情面板移动端全屏 overlay气泡/输入框/Gallery 网格间距微调hover 操作在移动端始终可见,默认小屏折叠侧边栏。(2) 穿透方案:安装 cloudflared编写 start-tunnel.ps1 一键脚本自动创建双隧道 + 更新 .env.local + 退出时恢复。Next.js 图片白名单加 trycloudflare.com。uvicorn 改绑 0.0.0.0。路线 B云服务器部署记为延期方案。
### [CL-20260412-1530] 2026-04-12 — 修复 .env 配置加载时序问题
- **tags**: 后端, bug修复, 配置, dotenv, Python
- **affected_files**: art-agent/backend/app/main.py, art-agent/backend/app/config.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/.env
- **summary**: 切换 DeepSeek 后报 "Model Not Exist"。根因:(1) load_dotenv() 在业务模块 import 之后执行config.py 的模块级变量在 import 时就固化为默认值 gpt-4o-mini(2) load_dotenv 默认 override=False 不覆盖已有环境变量。修复load_dotenv(override=True) 提到所有 import 之前config.py 常量改为函数式懒读取;.env base_url 改为不带 /v1。
### [CL-20260412-1500] 2026-04-12 — 品牌重命名 EPEEKit + API 配置集中化
- **tags**: EPEEKit, 品牌, 配置, 后端, 前端, 重构
- **affected_files**: art-agent/frontend/src/app/layout.tsx, art-agent/frontend/src/components/layout/top-nav.tsx, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/lib/store.ts, art-agent/frontend/src/app/gallery/page.tsx, art-agent/frontend/src/components/detail/image-detail-panel.tsx, art-agent/frontend/src/components/chat/image-grid.tsx, art-agent/frontend/package.json, art-agent/backend/app/main.py, art-agent/backend/app/config.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/.env, art-agent/backend/.env.example, art-agent/README.md
- **summary**: 应用正式命名为 "EPEEKit",全部用户可见文案 + 文件名前缀 + localStorage 键名统一替换。后端新增 config.py 集中管理 LLM_MODEL、IMAGE_MODEL 等原硬编码配置,.env 重组为带注释的分组结构,切换 LLM 只需改 3 个环境变量。同时新增 .env.example 模板和 Logo SVG。

View File

@@ -5,6 +5,27 @@ Agent 进入 Debug mode 或遇到运行时错误时自动检索匹配。
---
### [PF-20260420-1600] firtoz/trellis Replicate 部署版 schema 与 README 及官方 API 文档不一致
- **症状**: (1) 按 Replicate README example 用 `input={"image": uri}` 传单数 image → `422 Input validation failed: input: images is required`。改成 `images: [uri]` 后模型能跑,产出 model_file/combined_video/gaussian_ply/no_background_images。(2) 尽管 `generate_color=True` + `generate_normal=True` 都开,输出字典里 `color_video``normal_video` 字段**恒为 null**,只有 `combined_video` 这个"color 和 normal 左右并排的 mp4"才真正可用。
- **根因**: `firtoz/trellis:e8f6c45206993f297372f5436b90350817bd9b4a0d52d2a76df50c1c8afa2b3c` 这个 Replicate 部署版的实际输入/输出 schema 与 firtoz 在 Replicate README 里贴的 example、以及 microsoft/TRELLIS 官方仓库的 Python API 都不一样——输入字段是 `images`(数组)而不是 `image`(字符串),输出字段虽然声明了 `color_video``normal_video` 但该部署版恒给 null只填 `combined_video`。很可能是部署者在包装层做了合并(省带宽/对象存储开销)但没同步 README。
- **解法**: (1) 输入字段固定传 `images: [uri]` 数组,即使只有 1 张图;(2) Phase 2 抽帧的数据源从 `combined_video` 切——每一帧的左半 = color、右半 = normal用 PIL crop 即可;(3) 不要相信 Replicate README 上的 Python example永远以 **该版本 ID 的 OpenAPI schema** 为准(虽然 firtoz/trellis 这个 README 也看不到精确 schema所以只能实测
- **防御**: 接 Replicate 新模型前先用 1 美元额度做一次最小输入测试,打印 `output` 的原始 dict 结构 + 每个 key 的值类型;不要假设 README 的 example 与实际部署版本一致。`mesh_generator.py` 里已经做了"把 raw_output_keys 和每个字段的 None 警告都打印"的防御日志。
- **关联**: Replicate, firtoz/trellis, Mesh Pipeline, view_transform, Trellis, 3D 重建, schema, OpenAPI, mesh_generator.py
### [PF-20260420-1501] Windows uvicorn --reload 缺 watchfiles 退化为轮询,代码改动不被热重载
- **症状**: 代码已修好git diff / Read 看源文件都是新版但后端运行时行为仍是旧代码。具体表现CL-20260420-1212 修好 Zero123++ 切图后(`cols=2/rows=3` + 自动纠错),前端再触发一次转视角,切出的 view 图仍是 213×480= 640/3 × 960/2旧的 `cols=3,rows=2` 逻辑),而独立用当前代码调用 `_split_grid_image` 能正确切出 320×320。进程启动时间 11:55改动时间 12:12观察时间 15:00期间未 reload 过任何改动。
- **根因**: uvicorn `--reload` 在未安装 `watchfiles``watchdog` 时 fallback 到 `StatReload`基于轮询。Windows 文件系统事件语义本就弱,加上深层子目录(`app/services/`+ 新建文件(`view_transform.py` 在仓库里是新增 untracked 文件),几个因素叠加让 StatReload 漏通知/响应极慢,表现为"启动后改什么代码都不会重载"。venv 里 `watchfiles``watchdog` 双双 MISSING 是核心证据。
- **解法**: (1) `pip install watchfiles==0.24.0`uvicorn 官方首选 reloader(2) 写入 `requirements.txt``requirements-lock.txt`,避免其他设备重装环境时再次踩坑;(3) **kill 当前 uvicorn 进程重启一次**——新进程才会用上 WatchFiles reloader并同步加载最新代码。仅装依赖不重启不会生效因为当前进程已经加载了 StatReload。
- **防御**: Windows 上只要 uvicorn 带 `--reload``watchfiles` 就是硬依赖,不是可选项。启动后首屏日志如果看到 `WatchFiles detected changes``Started reloader process [xxx] using WatchFiles` 才算正常;如果只有 `Started reloader process [xxx] using StatReload` 就是退化状态。可以额外传 `--reload-dir app` 显式指定监控范围更保险。任何"改了代码不生效"的诡异现象,第一时间先确认进程是否已经重载(看日志里的文件变更提醒,或对比进程启动时间 vs 文件 mtime再排查代码逻辑。
- **关联**: uvicorn, --reload, watchfiles, StatReload, FastAPI, Windows, 热重载, requirements.txt, view_transform.py, Zero123++
### [PF-20260420-1212] Zero123++ 输出 grid 布局被文档措辞"3×2"误导
- **症状**: Zero123++ 视角变换后切出的 6 张图严重错位,每张子图呈窄长条(宽:高 约 1:2画面里上下拼着两个教堂边缘大量留白。拼接图原图视觉正常。
- **根因**: Zero123++ 官方 README 写 "output is a 3×2 grid of 6 views",但 `jd7h/zero123plusplus` 这个 Replicate 部署版本实际输出的是 **2 列 × 3 行**(高图,如 640×960而 config.py 里按"3 列 × 2 行"配置了 `grid_layout`。切图时 `cell_w = 640//3 = 213, cell_h = 960//2 = 480`,每个切片横跨了真实 grid 中两行的一部分,于是每张图都拼着两个视角的局部。
- **解法**: (1) `config.py``VIEW_TRANSFORM_MODELS["zero123plus"].grid_layout` 改为 `{"cols": 2, "rows": 3}`(2) `view_transform._split_grid_image` 加入"按实际图像长宽比 vs 配置 ratio 自动纠正"——假定每个单视角为正方形,真实 aspect ratio 应等于 `cols/rows`,若配置 ratio 与实际差异 >20% 就自动交换 cols/rows 并打 warning。
- **防御**: 任何涉及"grid 拼接图切分"的场景,不要完全相信模型文档的"A×B"措辞A 是列还是行常有歧义),必须用**实际图像长宽比**做健壮性检查;最稳妥的做法是让 split 函数本身能根据单视角正方形假设自动推断布局,不信任硬编码。更激进的方案是直接用 PIL 探测内容块边界(但对有微量白边的模型不稳定)。
- **关联**: Zero123++, Replicate, jd7h/zero123plusplus, grid 切图, PIL, view_transform.py, config.py, Novel View Synthesis
### [PF-20260412-1530] Python load_dotenv 默认不覆盖已有环境变量 + 模块级变量在 import 时固化
- **症状**: 修改 .env 切换 API 供应商后,后端仍报旧供应商的错误(如 DeepSeek 报 "Model Not Exist",实际发送的模型名是 OpenAI 的 gpt-4o-mini
- **根因**: 两层叠加:(1) `load_dotenv()` 默认 `override=False`,如果进程/系统中已有同名环境变量,.env 中的新值不会生效;(2) `config.py` 使用模块级常量 `LLM_MODEL = os.getenv(...)` 在 import 时就求值固化,而 `main.py` 的 import 链在 `load_dotenv()` 之前就触发了 config.py 的加载

View File

@@ -73,8 +73,33 @@ Agent 开始处理一个新任务时,判断是否需要读取近期详细记
Agent 在即将输出最终回复前,**必须**逐项检查以下清单。
这是硬性要求,不是建议——**跳过任何一项都视为执行错误**。
### 前置项(每次回复前无条件执行)
**A. 画像信号扫描(短路版)**
目的:以最小 token 成本维持 `profile-memory` Skill 的被动检测通路。
步骤:
1. **快判(词表匹配)**:扫描本次对话中**用户消息**,检查是否**明显**出现以下偏好/决策触发词族之一:
- 个人偏好信号:`我喜欢` / `我倾向` / `我不喜欢` / `我讨厌` / `以后都` / `以后别` /
`别再` / `不要再` / `我的风格` / `我的偏好` / `太花哨` / `minimal` /
`简洁` / `精炼` / `啰嗦`
- 项目决策信号:`这个项目是` / `目标用户是` / `技术栈` / `架构上` /
`产品方向` / `定位是`
2. **分支**
- 无匹配 → **直接跳过**,进入下一 checklist 项thinking 应在 50 token 内结束)
- 有匹配 → 读取 `.cursor/skills/profile-memory/SKILL.md` 并执行操作 A 的确认流程
3. **约束**:本项不做全量语义判断——漏记隐式信号是可接受的权衡。
用户如需记录隐式偏好可显式说"把这条记入画像"。
### 变更检查项(仅当本次有代码/配置文件改动时执行)
1. **本次是否有代码/配置文件改动?**(不含 `.cursor/` 下的基础设施文件)
- 否 → 跳过后续项
- 否 → 跳过本组后续项,直接进入"收尾项"
- 是 → 继续
2. **是否已写入三层开发日志?**
@@ -89,9 +114,12 @@ Agent 在即将输出最终回复前,**必须**逐项检查以下清单。
- 否 → 跳过
- 是 → 执行 `epee-orchestrator.mdc` 中的自迭代确认流程
5. **静默 Hook 重复触发**(每次 checklist 完成后必须执行)
- 用 Write 工具创建空文件 `.cursor/changelog/.changelog-ack`
- 这会更新 ack 文件的 mtime阻止 `check-changelog.ps1` hook 在后续回复中重复提醒
### 收尾项(每次回复前无条件执行)
**Z. 静默 Hook ack**
- 用 Write 工具创建空文件 `.cursor/changelog/.changelog-ack`
- 这会更新 ack 文件的 mtime阻止 `check-changelog.ps1` hook 在后续回复中重复提醒
**执行原则**:先完成 checklist 中的所有写入动作,再输出面向用户的回复文本。
回复末尾如果执行了第 2 项,附 `[已记录到开发日志]`。
回复末尾如果执行了变更检查项第 2 项,附 `[已记录到开发日志]`。

View File

@@ -0,0 +1,31 @@
---
description: 当用户要求初始化/重置 .cursor 目录(新项目首次落地模板)时,引导 Agent 读取 cursor-init Skill 并按其流程执行
globs:
alwaysApply: false
---
## .cursor 初始化触发
当用户表达以下意图之一时,读取并执行 `cursor-init` Skill
**触发关键词**(匹配任意一条即可):
- "初始化cursor" / "初始化 cursor" / "cursor 初始化"
- "重置 cursor" / "reset cursor"
- "cursor init" / "init cursor"
- "把复制过来的 .cursor 清理一下" / "按 baserule 归位一下"
**执行方式**
1. 读取 `.cursor/skills/cursor-init/SKILL.md`
2. 严格按 Skill 中的 0 → 6 阶段流程执行
3. **稳定准确优先于 token 成本**
- 破坏性操作(删除、重置、移动)前必须给用户 dry-run 清单
- 分类不清的文件必须逐条询问用户,不要猜
- 开始前先执行 `git status` 并提醒用户 commit/stash
4. 本 Skill 预期在新项目生命周期中只运行一次,但设计为幂等——重复运行在已干净状态下不应造成破坏
**不触发本 Skill 的情况**
- 用户只是问"cursor 有什么 skill"——这是浏览需求,走 `epee-orchestrator` 的操作 A
- 用户只是想初始化某个具体数据文件(比如"初始化画像")——这是对应 Skill 自己的流程(如 `profile-memory`),不涉及全局结构重置

View File

@@ -34,8 +34,13 @@ alwaysApply: true
3. 用 Read 工具读取该行号 ±15 行范围,获取原始上下文和来源信息
4. 一次上溯通常只涉及 1-2 条记录,不批量上溯
## 被动检测提醒
## 被动检测触发点
Agent 在整个对话过程中应保持对画像信号的被动感知。
当对话结束、用户的主线任务完成后,如果检测到了新的画像信息,
应读取 `profile-memory` Skill 并执行其"操作 A"的确认流程。
被动检测不在本 Rule 执行,而是由 `changelog-recall.mdc` 的
**任务完成 Checklist 前置项 A画像信号扫描** 统一触发。
理由:本 Rule 仅负责上下文注入(低成本、每次必做);被动检测需要挂入
已有强制力的 checklist 中才能避免被 Agent 遗漏,该 checklist 由
`changelog-recall` 维护,两者分工清晰。
具体触发词族和短路逻辑详见 `changelog-recall.mdc` 中的前置项 A 定义。

View File

@@ -0,0 +1,412 @@
---
name: cursor-init
description: >-
初始化新项目的 .cursor 结构。当用户把本项目的 .cursor 模板复制到新项目后说
"初始化cursor"、"cursor 初始化"、"重置 cursor"、"cursor init" 时触发。
按 baserule.mdc 约定识别并迁移目标项目原有的 rules/skills清理模板中残留的
项目专用数据changelog/deferred/pitfalls/profile/distillery/local-env/
项目级 rules 与 skills 等),把 .cursor 重置为可以直接投入新项目的干净起点。
该 Skill 通常每个新项目只运行一次,以稳定准确为首要目标。
---
# Cursor Init Skill
把从其它项目复制过来的 `.cursor/` 目录重置为当前新项目的干净起点。
## 使用场景
典型流程:
```
1. 用户在 "源项目 A" 中使用本 .cursor 模板开发
2. 用户开 "新项目 B",把 A 的 .cursor/ 整体拷贝到 B 的根目录
3. B 原先可能也有自己零散的 rules/SKILL 文件(路径、命名未必标准)
4. 用户在 B 中说"初始化cursor"
5. 本 Skill 接管:
- 把 B 原有的 rules/skills 按 baserule.mdc 归位
- 把 A 遗留的项目专用数据清空或删除
- 重建干净的 registry让 B 从第一天起就有规范的 .cursor
```
## 触发条件
用户直接表达"初始化 cursor"类意图:
- `初始化cursor` / `初始化 cursor` / `cursor 初始化`
- `重置 cursor` / `reset cursor`
- `cursor init` / `init cursor`
- 口头描述"把复制过来的 .cursor 清理一下"、"按 baserule 归位一下"
## 执行原则(不可协商)
1. **稳定准确 > token 成本**:每一步可以多读、多确认、让用户点头,不要图快
2. **破坏性操作必须 dry-run**:删除、重置必须先列清单给用户确认
3. **不可逆操作前提醒 git**:开始前先提示用户确认工作区已 commit/stash便于回滚
4. **模糊就问**:分类不清的文件一律问用户,不要猜
5. **幂等**:反复运行应当无害(第二次不会再改已经干净的 .cursor
## 核心流程
### 阶段 0前置确认
```
1. 检查当前工作目录下存在 .cursor/
- 不存在 → 直接结束,提示用户先复制模板
2. 运行 `git status`,若有未提交改动:
- 提醒用户:"建议先 commit 或 stash便于回滚"
- 等用户明确回复"继续"再往下
3. 向用户声明本次操作的范围(见"分组规则"),请求用户口头确认启动
```
### 阶段 1递归扫描 `.cursor/`
```
1. 用 shell 列出 .cursor 下所有文件(含子目录)
2. 把每个文件对照"规范清单"和"清理清单"分成三组:
- A 组canonical在规范清单中原样保留
- B 组template-data在清理清单中需要删除或重置
- C 组foreign两张清单都没覆盖到的视作目标项目原有文件或未知项
3. 把分组结果用表格汇报给用户:
| 组 | 路径 | 处理动作 |
```
### 阶段 2处理 C 组(目标项目原有文件归位)
对 C 组中的每个文件,按 baserule.mdc 的归属约定分类,逐条询问用户(可一次性列清单批量确认):
| 文件特征 | 建议归属 | 目标路径 |
|----------|----------|----------|
| `.mdc` 且内容是跨项目通用规则 | 通用 Rule | `.cursor/rules/common/<name>.mdc` |
| `.mdc` 且内容是当前新项目专属规则 | 项目 Rule | `.cursor/rules/project/<name>.mdc` |
| 包含 `SKILL.md` 的目录 | Skill无论通用/项目专属) | `.cursor/skills/<skill-name>/` |
| 看不出归属 | 询问用户 | — |
| 明显是临时/垃圾文件(`*.log``*.tmp`、缓存等) | 删除(需用户确认) | — |
执行要点:
- **先列清单一次性确认**,然后批量执行移动/删除,避免交互过于频繁
- **重名冲突**C 组文件若与 A 组规范文件重名(例如 C 组有 `baserule.mdc`**保留 A 组版本**,把 C 组备份到 `.cursor/_init-backup/` 下让用户自行 diff不直接覆盖
- **Skill 归属提示**:通用 Skill 建议用户之后手动同步到 `~/.cursor/skills/`(跨项目便利副本,可选)
- **迁移后核查**:移动完成后重新扫描,确认 C 组已清空
### 阶段 3处理 B 组(源项目项目专用数据清理/重置)
按"清理清单"执行:
**A. 直接删除**
- `.cursor/rules/project/*.mdc`(全部)—— 源项目的项目级 rules新项目不适用
- `.cursor/skills/<非规范 skill 目录>/`—— 源项目的项目级 Skills不在规范 Skill 列表中的所有 Skill 目录)
- `.cursor/plans/` 下所有文件(目录保留,内容清空)
- `.cursor/debug-*.log``.cursor/*.log` —— 调试日志
- `.cursor/local-env.json` —— 设备绑定,需要新设备重新生成(`local-env-check.mdc` 会自动探测)
**B. 重置为空模板**(保留文件,仅清内容)
| 文件 | 模板(见"模板内容"节) |
|------|------------------------|
| `.cursor/changelog/changelog-full.md` | L1 模板 |
| `.cursor/changelog/changelog-recent.md` | L2 模板 |
| `.cursor/changelog/changelog-headlines.md` | L3 模板 |
| `.cursor/changelog/.changelog-ack` | 空文件touch |
| `.cursor/deferred/registry.md` | deferred 模板 |
| `.cursor/pitfalls/pitfalls.md` | pitfalls 模板 |
| `.cursor/distillery/problems.md` | problems 模板 |
| `.cursor/distillery/insights.md` | insights 模板 |
| `.cursor/distillery/golden-rules.md` | golden-rules 模板 |
| `.cursor/profile/project-profile.md` | project-profile 模板 |
| `.cursor/profile/project-profile-log.md` | project-profile-log 模板 |
执行要点:
- 每个重置动作前**先 Read 现有内容预览前 10 行**给用户,避免误删有价值的数据(特别是 deferred/pitfalls 用户可能想保留)
- 如用户对某个数据文件明确说"保留"(例如 golden-rules 想带过去),跳过该文件的重置
### 阶段 4重建 registry
打开 `.cursor/skills/epee-orchestrator/registry.md`
1. 解析现有条目(每条 `### <skill-name>` 块)
2. 删除所有 `类型: 项目级` 的条目
3. 遍历 `.cursor/skills/` 目录,对照 registry
- registry 有条目但 skill 目录不存在 → 删除条目(说明是被第 3 阶段清理掉的项目级 skill
- skill 目录存在但 registry 无条目 → 读取该 skill 的 SKILL.md frontmatter新增条目
4. **确保 `cursor-init` 条目本身存在**(类型: 基础设施)
5. 写回 registry.md
### 阶段 5同步 `hooks.json`(跨平台保守处理)
- 读取 `.cursor/hooks.json`
-`command` 字段硬编码为 `powershell ...`,且目标新项目声明/探测到的 shell 不是 powershell参考 `local-env-check.mdc`**提醒用户**需要按 `hooks/README.md` 的说明手动改成 `bash .cursor/hooks/xxx.sh`
- 本阶段不自动改 hooks.json避免误判
### 阶段 6验证与报告
```
1. 重新递归扫描 .cursor/,再次分组
2. 期望状态:
- A 组:全部保留,文件内容未被动过
- B 组:数据文件仅含模板内容;项目级 rules/skills/日志 已删除
- C 组:为空(或仅剩用户明确要求保留的文件)
3. 给用户一份结构化报告,至少包含:
- 移动的文件from → to
- 删除的文件
- 重置的文件
- 新注册/移除的 registry 条目
- 未处理的文件(如果有,逐条说明原因)
4. 收尾提示:
> 初始化完成。建议现在执行:
> git add .cursor/
> git diff --cached -- .cursor/
> git commit -m "chore(cursor): initialize .cursor from template"
```
---
## 规范清单canonical
下列文件构成本 `.cursor` 模板的"主干",初始化后必须都在、内容不被删改(数据文件除外,见"清理清单"
### 顶层
- `.cursor/hooks.json`
- `.cursor/local-env.example.json`
### Rules通用
- `.cursor/rules/common/baserule.mdc`
- `.cursor/rules/common/changelog-recall.mdc`
- `.cursor/rules/common/deferred-recall.mdc`
- `.cursor/rules/common/distillery-recall.mdc`
- `.cursor/rules/common/epee-orchestrator.mdc`
- `.cursor/rules/common/pitfall-recall.mdc`
- `.cursor/rules/common/profile-recall.mdc`
- `.cursor/rules/common/cursor-init-recall.mdc`
### Rules项目专属目录
- `.cursor/rules/project/`(保留**空目录**;源项目的 `.mdc` 内容一律删除)
### Skills规范 Skill 目录白名单)
- `.cursor/skills/cursor-init/`
- `.cursor/skills/deferred-decisions/`
- `.cursor/skills/dev-changelog/`
- `.cursor/skills/epee-orchestrator/`(含 `registry.md`,见阶段 4
- `.cursor/skills/pitfall-journal/`
- `.cursor/skills/problem-distillery/`
- `.cursor/skills/profile-memory/`
> **任何不在此白名单中的 skill 目录**,在阶段 3 中一律删除(视为源项目的项目级 Skill
### Hooks
- `.cursor/hooks/README.md`
- `.cursor/hooks/run-hook.ps1`
- `.cursor/hooks/session-init.ps1`
- `.cursor/hooks/session-init.sh`
- `.cursor/hooks/check-changelog.ps1`
- `.cursor/hooks/check-changelog.sh`
### 数据占位目录(保留目录,文件重置为模板内容)
- `.cursor/changelog/{changelog-full.md, changelog-recent.md, changelog-headlines.md, .changelog-ack}`
- `.cursor/deferred/registry.md`
- `.cursor/pitfalls/pitfalls.md`
- `.cursor/distillery/{problems.md, insights.md, golden-rules.md}`
- `.cursor/profile/{project-profile.md, project-profile-log.md}`
- `.cursor/plans/`(空目录)
---
## 清理清单cleanup list
### 强制删除
| 路径 | 原因 |
|------|------|
| `.cursor/rules/project/*.mdc` | 源项目的项目级 Rules |
| `.cursor/skills/<非白名单>/` | 源项目的项目级 Skills |
| `.cursor/plans/*`(内容) | 源项目规划文档 |
| `.cursor/*.log``.cursor/debug-*.log` | 调试日志遗留 |
| `.cursor/local-env.json` | 设备绑定配置,需由新设备重新生成 |
| `.cursor/_init-backup/`(如果是上一轮残留) | 仅阶段 2 冲突备份用,运行前应不存在 |
### 重置为模板
见"模板内容"节。
---
## 模板内容
重置数据文件时使用以下内容。带 `{{DATE}}` 的占位符替换为当天日期(`YYYY-MM-DD`)。
### changelog-full.md
```markdown
# Dev Changelog — Full
完整的开发改动记录,按时间倒序排列。作为主动 RAG 的数据源,用户手动唤醒时读取。
## 记录
```
### changelog-recent.md
```markdown
# Dev Changelog — Recent
最近 ~10 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。
```
### changelog-headlines.md
```markdown
# Dev Changelog — Headlines
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
```
### .changelog-ack
空文件0 字节),只用于更新 mtime 阻止 hook 重复提醒。
### deferred/registry.md
```markdown
# Deferred Decisions Registry
## Active Items
(暂无延期方案)
---
## Completed / Cancelled Items
(暂无已完成或已废弃的方案)
```
### pitfalls/pitfalls.md
```markdown
# Pitfall Journal
开发过程中踩过的坑,按时间倒序排列。
Agent 进入 Debug mode 或遇到运行时错误时自动检索匹配。
---
```
### distillery/problems.md
```markdown
# Problem Distillery — Problems
反复出现的顽固问题追踪记录,按时间倒序排列。
<!-- 新条目追加在此行下方 -->
```
### distillery/insights.md
```markdown
# Problem Distillery — Insights
从已解决的顽固问题中蒸馏出的精炼方法论。
<!-- 新条目追加在此行下方 -->
---
last_distill_date: {{DATE}}
```
### distillery/golden-rules.md
```markdown
# Golden Rules
经过实践验证(权重 >= 5的精炼认知每次新会话自动注入。
<!-- 当条目达到权重阈值后由 Agent 自动写入 -->
```
### profile/project-profile.md
```markdown
# Project Profile
## 项目定位
## 技术栈与架构
## 设计约定
## 产品方向
```
### profile/project-profile-log.md
```markdown
# Project Profile Log
详细记录每次项目画像更新的完整上下文,按时间正序追加。
## 记录
```
---
## 冲突与边界情况处理
| 场景 | 处理 |
|------|------|
| C 组某文件与 A 组重名 | 保留 A 组C 组移到 `.cursor/_init-backup/`,告知用户自行 diff |
| C 组有 `SKILL.md` 且 skill 名与 A 组白名单同名 | 保留 A 组 skillC 组整个 skill 目录移到 `_init-backup/` |
| 数据文件(如 `pitfalls.md`)用户明确说"保留" | 跳过该文件重置 |
| `local-env.json` 存在且用户说"我就在这台机器上开 B保留它" | 跳过删除,但提醒用户其已在 `.gitignore` |
| `.cursor/rules/project/` 为空目录 | 保留空目录(便于未来放项目级 rules |
| 扫描到 `.cursor/_init-backup/`(上轮残留) | 警告用户并询问:删除 / 保留 / 重命名 |
| `git status` 不能执行(不是 git 仓库) | 警告但不阻塞,改为建议用户手动备份 |
---
## 与其他系统的协作
| 系统 | 关系 | 说明 |
|------|------|------|
| `baserule.mdc` | 分类权威 | 阶段 2 归位目录严格按 baserule 约定 |
| `epee-orchestrator` Skill | 双向 | 本 Skill 被 Orchestrator 在 registry 里注册;同时本 Skill 负责清理 registry |
| `dev-changelog` / `pitfall-journal` / `deferred-decisions` / `problem-distillery` / `profile-memory` | 下游数据主人 | 本 Skill 只在阶段 3 重置其数据文件,不改动 SKILL.md |
| `local-env-check.mdc` | 后继 | 本 Skill 删除 `local-env.json`,后续新会话由 `local-env-check` 自动重新探测并生成 |
| `cursor-init-recall.mdc` | 触发器 | 负责匹配"初始化cursor"等关键词,引导 Agent 读取本 SKILL.md |
---
## 一次运行的最终状态(验收标准)
运行结束后 `.cursor/` 应当满足:
1. 规范清单中列出的所有文件/目录都存在
2. 所有数据文件changelog/deferred/pitfalls/distillery/profile仅含模板内容
3. `.cursor/rules/project/` 为空目录
4. `.cursor/skills/` 下仅有白名单中的 skill 目录
5. `.cursor/skills/epee-orchestrator/registry.md` 中只有:基础设施 + 个人级 skill 条目,且包含 `cursor-init` 条目
6. 不存在 `*.log``local-env.json``_init-backup/`(或 backup 已被用户确认保留/清理)
7. `git status .cursor/` 能让用户清楚看到所有改动
---
## 自迭代日志
本节记录使用本 Skill 过程中发现的必要检查项。
### 已知必要检查
1. **阶段 2 的 C 组归位前必须先做 dry-run 展示** —— 批量移动 .mdc / SKILL 文件是高破坏性操作,只要有一条分类错误就会污染 canonical 结构,必须让用户在列表上逐条过一遍再执行。
2. **重名冲突不许直接覆盖** —— C 组与 A 组重名时A 组(模板版本)永远是 source of truth冲突文件只能进 `_init-backup/`,让用户自行决定是否把差异合并回 common rule。
3. **registry 重建时先读后写** —— 阶段 4 不要直接用硬编码模板覆盖 registry.md必须先解析现有条目只移除项目级条目、补齐缺失条目避免丢失用户自己添加的个人级 Skill 条目。

View File

@@ -249,3 +249,11 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。
2. **L3/L2 滚动窗口必须落地** — 操作 A 第 4 步不是「插入即结束」写入后必须数清条目L3 为以 `- [` 开头的列表行L2 为 `### [CL-` 标题行。L3 超过 50 条、L2 超过 20 条时,必须从**文件底部(最旧)**整段删除条目直至恰好满足上限;禁止长期只追加不修剪。若发现 `changelog-headlines.md` 列表行多于 50说明上次写入未执行本项本次补修剪并自检。
3. **顶部插入 StrReplace 范式L1/L2** — L1 和 L2 的每个条目都是多行结构(标题 + 多条 `- **字段**:`)。使用 StrReplace 在顶部插入新条目时anchor`old_string`)有且仅有两种合法选择:
(a) 只包含新条目之前的"稳定前缀"(如 L1 的 `## 记录\n\n` 或 L2 的整段文件头说明),**不触及任何已有条目的任何一行**`new_string` = 稳定前缀 + 新条目完整内容 + 空行。
(b) 把旧首条目的**完整多行内容**(标题行 + 全部字段行)都纳入 `old_string``new_string` = 新条目完整内容 + 空行 + 旧首条目完整内容。
**绝对禁止**:只把旧首条目的标题行(或前 1-2 字段)当 anchor 而不带上剩余字段行——StrReplace 会吞掉这些 anchor 行但不动剩余字段,造成"旧标题丢失、字段游离"的半损坏状态2026-04-20 在 `pitfall-journal` 的写入中已实际踩过此坑)。
**写入后自检**Read 文件头部 30 行,确认看到新条目完整 + 旧首条目标题行仍存在且字段完整跟随。L3 的一行条目不受本条约束(它就是单行,天然无此风险)。

View File

@@ -17,6 +17,14 @@
## 已注册 Skill
### cursor-init
- **类型**: 基础设施
- **能力**: 把从其它项目复制过来的 .cursor/ 重置为新项目的干净起点——按 baserule 归位目标项目原有的 rules/skills清理源项目的项目专用数据重建干净的 registry
- **触发场景**: "初始化cursor"、"cursor 初始化"、"重置 cursor"、"cursor init"、"init cursor"、"reset cursor"、"按 baserule 归位"、新项目首次落地 .cursor 模板
- **输出**: 重置/清理后的 `.cursor/` 结构规范清单完整、项目级内容已清、数据文件为模板、registry 仅含通用条目)
- **路径**: .cursor/skills/cursor-init/SKILL.md
- **备注**: 每个新项目一次性运行,破坏性操作必须 dry-run`cursor-init-recall.mdc` 触发;设计为幂等,重复运行无害
### deferred-decisions
- **类型**: 基础设施
- **能力**: 记录和追踪延期的技术方案/决策,在关联任务出现时主动提醒
@@ -28,10 +36,10 @@
### profile-memory
- **类型**: 个人级
- **能力**: 在对话中被动检测用户个人特质和项目信息,经确认后持久化为精简画像和详细日志
- **触发场景**: "画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"、"查看项目画像"、用户主动管理画像
- **触发场景**: "画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"、"查看项目画像"、用户主动管理画像;被动检测由 `changelog-recall` checklist 前置项 A 的短路扫描触发(命中偏好/项目决策触发词族时)
- **输出**: ~/.cursor/profile/user-profile.md、.cursor/profile/project-profile.md 及对应 log 文件
- **路径**: .cursor/skills/profile-memory/SKILL.md
- **备注**: 被动检测由 profile-recall Rule 触发;精简 Profile 每次会话自动注入上下文
- **备注**: 精简 Profile 每次会话由 `profile-recall` Rule 自动注入上下文;被动检测路径由 `changelog-recall` 的强制 checklist 前置项 A 兜底2026-04-20 升级:原 `profile-recall` 的软性"被动检测提醒"失效率高,改为词表短路扫描挂入 checklist
### dev-changelog
- **类型**: 基础设施
@@ -39,7 +47,7 @@
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
- **路径**: .cursor/skills/dev-changelog/SKILL.md
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklistL3 概要每次会话自动注入上下文stop Hook 双保险兜底;写入后须维护 L3≤50 / L2≤20 滚动窗口(见 Skill 自迭代检查项)
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklistL3 概要每次会话自动注入上下文stop Hook 双保险兜底;写入后须维护 L3≤50 / L2≤20 滚动窗口L1/L2 多行条目须遵循"顶部插入 StrReplace 范式"防止旧首条目被截断(见 Skill 自迭代检查项)
### pitfall-journal
- **类型**: 基础设施
@@ -47,7 +55,7 @@
- **触发场景**: debug 完成后、"踩坑"、"之前遇到过"、"坑"、进入 Debug mode、同类错误反复出现
- **输出**: .cursor/pitfalls/pitfalls.md 条目
- **路径**: .cursor/skills/pitfall-journal/SKILL.md
- **备注**: 与 dev-changelog 互补——changelog 记事实pitfall 记经验;由 pitfall-recall Rule 触发自动检索
- **备注**: 与 dev-changelog 互补——changelog 记事实pitfall 记经验;由 pitfall-recall Rule 触发自动检索;写入时须遵循"顶部插入 StrReplace 范式"防止旧首条目标题丢失(见 Skill 自迭代检查项)
### problem-distillery
- **类型**: 基础设施

View File

@@ -113,4 +113,10 @@ description: >-
### 已知必要检查
(暂无)
1. **顶部插入必须保持旧首条目完整性** — 使用 StrReplace 在文件顶部插入新条目时(操作 A 第 4 步anchor`old_string`)有且仅有两种合法选择:
(a) 只包含新条目之前的"稳定前缀"(如 `---\n\n` 或整个文件头部说明段),**不触及任何已有条目的任何一行**;然后 `new_string` = 稳定前缀 + 新条目完整内容 + 空行。
(b) 把旧首条目的**完整多行内容**(标题行 + 全部字段行)都纳入 `old_string`;然后 `new_string` = 新条目完整内容 + 空行 + 旧首条目完整内容。
**绝对禁止**:把旧首条目的"标题行"单独当 anchor 而不带上它紧跟的字段行——StrReplace 是精确替换,这样做会删掉标题、让字段"无主"残留在新条目之后造成半损坏状态2026-04-20 实际踩过此坑)。
**写入后自检**Read 文件头 25 行,确认看到 `### [PF-新ID]` 后紧跟其 5 字段,再往下能看到 `### [PF-旧首条目ID]` 标题行且字段完整跟随。

View File

@@ -1,9 +1,11 @@
---
name: profile-memory
description: >-
渐进式用户/项目画像系统。在对话中被动检测用户个人特质(审美、技术偏好、做事风格等)
和项目信息(定位、技术栈、产品方向等),对话结束前统一总结并经用户确认后记录。
当用户提到"画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"等时触发
渐进式用户/项目画像系统。两条触发路径:
1被动检测主路径——由 `changelog-recall` checklist 前置项 A 触发短路扫描,
命中偏好/项目决策触发词时执行操作 A总结 + 用户确认 + 写入)
2显式管理——当用户提到"画像"、"profile"、"查看画像"、"我的偏好"、"项目信息"等时
执行操作 B浏览、修改、删除已有条目
---
# Profile Memory

View File

@@ -39,17 +39,34 @@ TOOL_DEFINITIONS = [
"function": {
"name": "transform_view",
"description": (
"将一张图片转换为多个不同视角。"
"使用 Zero123++ 模型从单张图片生成 6 个固定视角的图片。"
"适用于建筑、物体等需要从不同角度查看的场景。"
"输入图片必须是正方形(或会被自动裁切为正方形),建议分辨率 >= 320x320。"
"输出 6 个视角:方位角 30°/90°/150°/210°/270°/330°"
"仰角交替为 30°/-20°正俯视/微仰视)。"
"此工具需要参考图作为输入——必须先有用户上传的图片才能使用"
"将一张图片转换为多个不同视角。需要用户先上传参考图。\n"
"可选模型:\n"
"- zero123plus默认~15s/次Zero123++ 直接输出 6 个固定视角"
"(方位角 30/90/150/210/270/330°仰角交替 30/-20°适合快速预览"
"对建筑大角度可能有错位。\n"
"- trellis~105s/次,先 3D 重建再重绘,"
"支持任意方位角 + 原画风保留,适合卡通描边风格建筑"
),
"parameters": {
"type": "object",
"properties": {},
"properties": {
"model_id": {
"type": "string",
"enum": ["zero123plus", "trellis"],
"default": "zero123plus",
"description": "视角变换模型;默认 zero123plustrellis 画风还原更强但慢",
},
"azimuths": {
"type": "array",
"items": {"type": "integer"},
"description": "mesh 管道专用自定义方位角数组0-359默认 [0,60,120,180,240,300]",
},
"preserve_style": {
"type": "boolean",
"default": True,
"description": "mesh 管道专用:是否对抽帧结果走 IP-Adapter+ControlNet 重绘还原原画风",
},
},
"required": [],
},
},
@@ -92,7 +109,13 @@ async def execute_tool(
"success": False,
"error": "视角变换需要一张输入图片,请先上传参考图",
}
result = await transform_view(ref_image_urls[0])
result = await transform_view(
ref_image_urls[0],
model_id=arguments.get("model_id"),
azimuths=arguments.get("azimuths"),
elevations=arguments.get("elevations"),
preserve_style=arguments.get("preserve_style", True),
)
return result
return {"success": False, "error": f"未知工具: {tool_name}"}

View File

@@ -225,9 +225,35 @@ IMAGE_MODELS: dict[str, dict[str, Any]] = {
"output_format": "png",
},
},
# Mesh 管道专用的内部模型internal=True 不展示到前端下拉):
# 输入两张图image=原图做 IP-Adapter 风格参考controlnet_input=normal/depth 结构参考。
# 用途:在 Stage 3 对 Trellis 抽出来的 normal 帧做二次重绘,还原原画风。
"ip-adapter-controlnet-depth": {
"id": "ip-adapter-controlnet-depth",
"name": "IP-Adapter + ControlNet Depth (内部)",
"provider": "replicate",
"model_id": "chigozienri/ip_adapter-sdxl-controlnet-depth:0436c8702ef52616be5c30948551b3af6a86c821cca9b01f11ac297624fff14c",
"description": "Mesh 管道 Stage 3 专用IP-Adapter 保风格 + ControlNet 保几何",
"internal": True,
"supports_ref_image": True,
"ref_image_param": "image", # IP-Adapter 参考图字段
"default_params": {
"scale": 0.75, # IP-Adapter 权重(建议 0.7-0.8
"controlnet_conditioning_scale": 0.8, # 结构约束(建议 0.7-0.9
"prompt": "same building, same art style, consistent with reference",
"negative_prompt": "blurry, distorted, different style, realistic photo, photograph",
"num_outputs": 1,
"num_inference_steps": 30,
},
},
}
def get_style_restore_model_id() -> str:
"""Mesh 管道 Stage 3 使用的内部模型短 ID。"""
return "ip-adapter-controlnet-depth"
# ─── 视角变换模型注册表 ─────────────────────────────────
#
# 视角变换模型不用于生图,而是将已有图片转换为不同视角。
@@ -240,6 +266,7 @@ VIEW_TRANSFORM_MODELS: dict[str, dict[str, Any]] = {
"provider": "replicate",
"model_id": "jd7h/zero123plusplus:c69c6559a29011b576f1ff0371b3bc1add2856480c60520c7e9ce0b40a6e9052",
"description": "单图生成 6 个固定视角,适合建筑/物体的多角度预览",
"pipeline": "grid",
"output_views": [
{"azimuth": 30, "elevation": 30},
{"azimuth": 90, "elevation": -20},
@@ -248,7 +275,46 @@ VIEW_TRANSFORM_MODELS: dict[str, dict[str, Any]] = {
{"azimuth": 270, "elevation": 30},
{"azimuth": 330, "elevation": -20},
],
"grid_layout": {"cols": 3, "rows": 2},
"grid_layout": {"cols": 2, "rows": 3},
"enabled": True,
},
"trellis": {
"id": "trellis",
"name": "Trellis (Mesh Pipeline)",
"provider": "replicate",
"model_id": "firtoz/trellis:e8f6c45206993f297372f5436b90350817bd9b4a0d52d2a76df50c1c8afa2b3c",
"description": "3D 重建 + 任意视角 + 风格还原,建筑友好(~30s 重建 + 72s/张重绘)",
"pipeline": "mesh",
"default_params": {
"texture_size": 1024,
"mesh_simplify": 0.95,
"generate_color": True,
"generate_normal": True,
"generate_model": True,
"save_gaussian_ply": False,
"ss_sampling_steps": 12,
"slat_sampling_steps": 12,
"ss_guidance_strength": 7.5,
"slat_guidance_strength": 3.0,
},
"default_azimuths": [0, 60, 120, 180, 240, 300],
"default_elevations": [0, 0, 0, 0, 0, 0],
"enabled": True,
},
"hunyuan3d": {
"id": "hunyuan3d",
"name": "Hunyuan3D-2 (高质量)",
"provider": "replicate",
"model_id": "tencent/hunyuan3d-2:b1b9449a1277e10402781c5d41eb30c0a0683504fb23fab591ca9dfc2aabe1cb",
"description": "几何质量最佳(~127s需自定义渲染器二期启用",
"pipeline": "mesh",
"default_params": {
"steps": 50,
"guidance_scale": 5.5,
"octree_resolution": 256,
"remove_background": True,
},
"enabled": False,
},
}
@@ -261,14 +327,16 @@ def get_view_transform_model_config(model_id: str | None = None) -> dict[str, An
def get_view_transform_models_list() -> list[dict]:
"""返回前端下拉列表所需的视角变换模型摘要信息。"""
"""返回前端下拉列表所需的视角变换模型摘要信息(只包含 enabled=True 的)"""
return [
{
"id": cfg["id"],
"name": cfg["name"],
"description": cfg["description"],
"pipeline": cfg.get("pipeline", "grid"),
}
for cfg in VIEW_TRANSFORM_MODELS.values()
if cfg.get("enabled", True)
]
@@ -297,7 +365,7 @@ def get_ref_image_model_id() -> str | None:
def get_image_models_list() -> list[dict]:
"""返回前端下拉列表所需的模型摘要信息。"""
"""返回前端下拉列表所需的模型摘要信息(过滤掉 internal=True 的内部模型)"""
return [
{
"id": cfg["id"],
@@ -306,6 +374,7 @@ def get_image_models_list() -> list[dict]:
"supports_ref_image": cfg.get("supports_ref_image", False),
}
for cfg in IMAGE_MODELS.values()
if not cfg.get("internal", False)
]

View File

@@ -0,0 +1,189 @@
"""3D Mesh 生成服务 — 阶段 1单图 → textured .glb + 360° 预渲染视频。
当前支持:
- Trellis (firtoz/trellis)~30s$0.041/次,自带 color_video + normal_video + .glb
- Hunyuan3D-2 (tencent/hunyuan3d-2)~127s$0.12/次,仅 .glb 无预渲染视频(二期启用)
"""
import logging
import os
import uuid
from pathlib import Path
from typing import Any
import httpx
from app.config import get_view_transform_model_config
from app.services.image_gen import _make_replicate_client, GENERATED_DIR, to_data_uri
logger = logging.getLogger(__name__)
_replicate_client = _make_replicate_client()
SUPPORTED_EXTS = {
"model_file": ".glb",
"color_video": ".mp4",
"normal_video": ".mp4",
"combined_video": ".mp4",
"gaussian_ply": ".ply",
"mesh": ".glb",
}
async def _download_asset(url: str, ext: str, prefix: str) -> Path:
"""下载远程资源到 generated/ 目录,返回本地路径。"""
filename = f"{prefix}_{uuid.uuid4().hex[:12]}{ext}"
filepath = GENERATED_DIR / filename
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(300.0)) as client:
resp = await client.get(url, follow_redirects=True)
resp.raise_for_status()
filepath.write_bytes(resp.content)
return filepath
def _to_url(value: Any) -> str | None:
"""把 Replicate FileOutput / str / None 统一转为 URL 字符串。"""
if value is None:
return None
s = str(value)
if s.startswith("http://") or s.startswith("https://"):
return s
return None
async def generate_with_trellis(
image_path: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""调用 Trellis 做单图 3D 重建,下载所有输出资产到本地。
Args:
image_path: 输入图(本地路径 / data URI / URL
params: 覆盖 default_params 的可选参数字典
Returns:
{
"success": bool,
"glb_path": "/generated/trellis_xxx.glb" | None,
"color_video_path": "/generated/trellis_xxx_color.mp4" | None,
"normal_video_path": "/generated/trellis_xxx_normal.mp4" | None,
"combined_video_path": ...,
"raw_output_keys": [...], # 调试用
"error": str | None,
}
"""
config = get_view_transform_model_config("trellis")
replicate_model_id = config["model_id"]
default_params = dict(config.get("default_params", {}))
if params:
default_params.update(params)
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
try:
image_uri = to_data_uri(image_path)
# firtoz/trellis:e8f6c452... 这个部署版的 schema
# - 输入必须用 `images` 数组(传 `image` 单数会 422
# - 输出字段 `color_video` 和 `normal_video` 实测恒为 null
# - 实际可用的视频只有 `combined_video`color+normal 左右并排)
# 因此 Phase 2 抽帧时从 combined_video 切左半得 color、切右半得 normal。
input_params: dict[str, Any] = {
"images": [image_uri],
"randomize_seed": False,
"seed": 0,
**default_params,
}
logger.info(
"调用 Trellis 进行 3D 重建model=%s, params=%s",
replicate_model_id,
{k: v for k, v in input_params.items() if k != "images"},
)
output = await _replicate_client.async_run(
replicate_model_id, input=input_params, wait=False
)
if not isinstance(output, dict):
return {
"success": False,
"error": f"Trellis 返回非字典输出:{type(output).__name__} / {str(output)[:200]}",
"raw_output_keys": [],
}
raw_keys = list(output.keys())
logger.info("Trellis 返回字段:%s", raw_keys)
# 按已知字段挨个下载
result: dict[str, Any] = {
"success": True,
"raw_output_keys": raw_keys,
"error": None,
}
download_map = [
("model_file", "glb_path", ".glb", "trellis"),
("color_video", "color_video_path", ".mp4", "trellis_color"),
("normal_video", "normal_video_path", ".mp4", "trellis_normal"),
("combined_video", "combined_video_path", ".mp4", "trellis_combined"),
]
for src_key, dst_key, ext, prefix in download_map:
if src_key not in output:
result[dst_key] = None
continue
url = _to_url(output[src_key])
if not url:
logger.warning("Trellis 字段 %s 不是有效 URL%s", src_key, output[src_key])
result[dst_key] = None
continue
try:
local_path = await _download_asset(url, ext, prefix)
result[dst_key] = f"/generated/{local_path.name}"
logger.info("已下载 %s%s", src_key, local_path.name)
except Exception as e:
logger.error("下载 %s 失败:%s", src_key, e)
result[dst_key] = None
# 阶段 2 抽帧的优先级color_video > combined_video切左半
# 只要其中一个可用即算成功
has_color_source = bool(result.get("color_video_path")) or bool(
result.get("combined_video_path")
)
if not has_color_source:
result["success"] = False
result["error"] = (
"Trellis 未返回 color_video 或 combined_video无法进入阶段 2 抽帧;"
f"实际返回字段:{raw_keys}"
)
return result
except Exception as e:
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
logger.error("Trellis 调用失败:%s", detail, exc_info=True)
return {
"success": False,
"error": detail,
"raw_output_keys": [],
}
async def generate_mesh(
image_path: str,
model_id: str = "trellis",
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""统一入口:按 model_id 分发到具体实现。"""
if model_id == "trellis":
return await generate_with_trellis(image_path, params)
if model_id == "hunyuan3d":
return {
"success": False,
"error": "Hunyuan3D-2 (一期未启用,待二期接入自定义渲染)",
"raw_output_keys": [],
}
return {
"success": False,
"error": f"未知 mesh 模型:{model_id}",
"raw_output_keys": [],
}

View File

@@ -0,0 +1,180 @@
"""阶段 3对 Trellis 抽帧结果做 IP-Adapter + ControlNet 重绘,还原原画风。
输入:
- original_image用户上传的原图提供风格 / IP-Adapter 参考)
- structure_imageStage 2 抽出的 normal 帧(提供几何 / ControlNet 结构约束)
输出:
- 单张保留原画风且视角正确的 PNG落盘到 GENERATED_DIR/ 下
模型chigozienri/ip_adapter-sdxl-controlnet-depth配置见 config.IMAGE_MODELS
- $0.07/张,~72s硬件 L40S
- `image` 字段 = IP-Adapter 参考图(风格源)
- `controlnet_input` 字段 = 结构参考图(当前一期传 normal 帧作为 depth 近似)
调参建议(可在 IMAGE_MODELS 默认值基础上覆盖):
- scale (IP-Adapter 权重): 0.7-0.8,越高越贴近原图风格
- controlnet_conditioning_scale (结构权重): 0.7-0.9,越高越保几何
"""
from __future__ import annotations
import asyncio
import logging
import os
import uuid
from pathlib import Path
from typing import Any
import httpx
from app.config import get_image_model_config, get_style_restore_model_id
from app.services.image_gen import GENERATED_DIR, _make_replicate_client, to_data_uri
logger = logging.getLogger(__name__)
_replicate_client = _make_replicate_client()
async def _download_image(url: str, prefix: str) -> Path:
"""把 Replicate 返回的图片下载到 generated/。"""
filename = f"{prefix}_{uuid.uuid4().hex[:12]}.png"
filepath = GENERATED_DIR / filename
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(180.0)) as client:
resp = await client.get(url, follow_redirects=True)
resp.raise_for_status()
filepath.write_bytes(resp.content)
return filepath
def _first_url(output: Any) -> str | None:
"""Replicate 输出有时是 list[FileOutput]、有时是单个;统一取第一张。"""
if output is None:
return None
if isinstance(output, list):
if not output:
return None
return str(output[0])
return str(output)
async def restore(
original_image: str,
structure_image: str,
azimuth: int = 0,
elevation: int = 0,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""对单张 normal 帧做 IP-Adapter + ControlNet 重绘。
Args:
original_image: 用户原图,本地路径 / data URI / URL 均可
structure_image: Stage 2 抽出的 normal 帧,同上
azimuth / elevation: 仅用于日志与返回元数据,不参与模型调用
params: 覆盖默认参数的字典scale / controlnet_conditioning_scale / prompt 等)
Returns:
{
"success": bool,
"url": "/generated/restyled_xxx.png" | None,
"azimuth": int,
"elevation": int,
"error": str | None,
}
"""
config = get_image_model_config(get_style_restore_model_id())
replicate_model_id = config["model_id"]
merged_params = dict(config.get("default_params", {}))
if params:
merged_params.update(params)
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
try:
image_uri = to_data_uri(original_image)
structure_uri = to_data_uri(structure_image)
input_params: dict[str, Any] = {
"image": image_uri, # IP-Adapter 参考图 = 用户原图
"controlnet_input": structure_uri, # 结构参考 = normal 帧
**merged_params,
}
logger.info(
"Stage 3 重绘 az=%d el=%d: scale=%s, cn_scale=%s",
azimuth, elevation,
merged_params.get("scale"),
merged_params.get("controlnet_conditioning_scale"),
)
output = await _replicate_client.async_run(
replicate_model_id, input=input_params, wait=False
)
url = _first_url(output)
if not url or not (url.startswith("http://") or url.startswith("https://")):
return {
"success": False,
"url": None,
"azimuth": azimuth,
"elevation": elevation,
"error": f"模型未返回有效图片 URL: {str(output)[:200]}",
}
local = await _download_image(url, prefix=f"restyled_az{azimuth}_el{elevation}")
return {
"success": True,
"url": f"/generated/{local.name}",
"azimuth": azimuth,
"elevation": elevation,
"error": None,
}
except Exception as e:
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
logger.error(
"Stage 3 重绘失败 az=%d el=%d: %s", azimuth, elevation, detail, exc_info=True
)
return {
"success": False,
"url": None,
"azimuth": azimuth,
"elevation": elevation,
"error": detail,
}
async def restore_batch(
original_image: str,
frames: list[dict[str, Any]],
params: dict[str, Any] | None = None,
concurrency: int = 3,
) -> list[dict[str, Any]]:
"""对一批 frames 做 Stage 3 重绘,带并发控制。
Args:
original_image: 用户原图(所有帧共用同一张风格参考)
frames: [{"az", "el", "normal_png", ...}, ...]normal_png 作为结构参考
params: 透传给 restore() 的覆盖参数
concurrency: 最大并发数Replicate 并发打太猛容易撞 rate limit3 比较稳
Returns:
列表,顺序与 frames 对齐,元素与 restore() 的返回一致。
"""
if not frames:
return []
sem = asyncio.Semaphore(max(1, concurrency))
async def _one(f: dict[str, Any]) -> dict[str, Any]:
async with sem:
return await restore(
original_image=original_image,
structure_image=f["normal_png"],
azimuth=int(f.get("az", 0)),
elevation=int(f.get("el", 0)),
params=params,
)
return await asyncio.gather(*[_one(f) for f in frames])

View File

@@ -0,0 +1,191 @@
"""阶段 2从 Trellis combined_video 抽帧 + 切左右半得到 color / normal 帧。
Trellis (firtoz/trellis) 部署版 `e8f6c452...` 实测只返回 `combined_video`
color 和 normal 左右并排 mp4约 5 秒 360° 水平环绕),不再单独返回
`color_video` / `normal_video`(详见 PF-20260420-1600
本模块职责:
1. 对给定 combined_video 做一次全帧抽取imageio-ffmpeg 自带二进制,无需系统 ffmpeg
2. 按 azimuth / total_rotation_deg 映射到帧索引并挑出对应帧
3. 用 PIL 切左半 → color png、右半 → normal png落盘到 GENERATED_DIR
4. 返回 [{az, el, frame_idx, color_png, normal_png}, ...]
一期仅支持 elevation=0Trellis 的 combined_video 就是平视一圈,本身没有仰俯)。
需要自由 elevation 时走 Phase 5 的 mesh_renderer 路径pyrender / Blender headless
"""
from __future__ import annotations
import asyncio
import logging
import subprocess
import uuid
from pathlib import Path
from typing import Any
import imageio_ffmpeg # 提供静态 ffmpeg 二进制,避免依赖系统 ffmpeg
from PIL import Image
from app.services.image_gen import GENERATED_DIR
logger = logging.getLogger(__name__)
FFMPEG_EXE = imageio_ffmpeg.get_ffmpeg_exe()
def _resolve_local_path(path_or_url: str) -> Path:
"""把 mesh_generator 返回的 '/generated/xxx.mp4' 解析为真实磁盘路径。
兼容三种输入:
- '/generated/xxx.mp4' → GENERATED_DIR / xxx.mp4
- 'D:/abs/path/xxx.mp4' → Path 原样
- 'xxx.mp4' → Path 原样(调用方自行保证)
"""
if not path_or_url:
raise ValueError("combined_video_path 为空")
if path_or_url.startswith("/generated/"):
return GENERATED_DIR / Path(path_or_url).name
return Path(path_or_url)
def _extract_all_frames(video_path: Path, temp_dir: Path) -> list[Path]:
"""把 video 的所有帧抽到 temp_dir返回按文件名排序的帧列表。
对 Trellis ~5s 的 combined_video~100-150 帧)这种量级是 OK 的,
比起"用 -ss 逐帧抽取"更省心、角度映射也更稳。
"""
temp_dir.mkdir(parents=True, exist_ok=True)
out_pattern = str(temp_dir / "f%05d.png")
cmd = [
FFMPEG_EXE,
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", str(video_path),
"-vsync", "0",
out_pattern,
]
logger.info("ffmpeg extract-all: %s", " ".join(cmd))
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(
f"ffmpeg 抽帧失败 (code {proc.returncode}): stderr={proc.stderr[-500:]}"
)
frames = sorted(temp_dir.glob("f*.png"))
if not frames:
raise RuntimeError(
f"ffmpeg 执行成功但没产出帧:{video_path}stderr={proc.stderr[-500:]}"
)
return frames
def _split_combined_frame(
frame_path: Path,
out_prefix: str,
az: int,
el: int,
) -> tuple[Path, Path]:
"""把 combined 帧按中线切成 color+ normal两张图。"""
img = Image.open(frame_path)
w, h = img.size
mid = w // 2
color = img.crop((0, 0, mid, h))
normal = img.crop((mid, 0, w, h))
color_name = f"view_{out_prefix}_az{az}_el{el}_color.png"
normal_name = f"view_{out_prefix}_az{az}_el{el}_normal.png"
color_path = GENERATED_DIR / color_name
normal_path = GENERATED_DIR / normal_name
color.save(color_path, "PNG")
normal.save(normal_path, "PNG")
return color_path, normal_path
def _cleanup_dir(dir_path: Path) -> None:
"""静默删除临时目录及其所有文件,失败不抛。"""
if not dir_path.exists():
return
for f in dir_path.glob("*"):
try:
f.unlink()
except OSError:
pass
try:
dir_path.rmdir()
except OSError:
pass
def _do_extract(
video: Path,
azimuths: list[int],
elevations: list[int],
total_rotation_deg: int,
) -> list[dict[str, Any]]:
"""同步版抽帧逻辑,供 async 版本用 run_in_executor 包起来跑。"""
batch_id = uuid.uuid4().hex[:8]
temp_dir = GENERATED_DIR / f"_frames_{batch_id}"
try:
frames = _extract_all_frames(video, temp_dir)
total = len(frames)
logger.info("combined_video %s 抽出 %d 帧,总时长~%.2fs (按 24fps 估)",
video.name, total, total / 24.0)
results: list[dict[str, Any]] = []
for az, el in zip(azimuths, elevations):
# az 归一化到 [0, total_rotation_deg),再映射到 [0, total) 整数索引
az_mod = az % total_rotation_deg if total_rotation_deg > 0 else 0
frame_idx = int(round(az_mod / total_rotation_deg * total)) % total if total > 0 else 0
src_frame = frames[frame_idx]
color_path, normal_path = _split_combined_frame(src_frame, batch_id, az, el)
results.append({
"az": az,
"el": el,
"frame_idx": frame_idx,
"color_png": f"/generated/{color_path.name}",
"normal_png": f"/generated/{normal_path.name}",
})
return results
finally:
_cleanup_dir(temp_dir)
async def extract_by_azimuth(
combined_video_path: str,
azimuths: list[int],
elevations: list[int] | None = None,
total_rotation_deg: int = 360,
) -> list[dict[str, Any]]:
"""按 azimuth 列表从 combined_video 抽帧 + 切左右半。
Args:
combined_video_path: '/generated/trellis_combined_xxx.mp4' 或本地磁盘路径
azimuths: 方位角数组单位度0-360允许大于 360 会自动取模)
elevations: 仰角数组;长度须等于 azimuths一期所有值被忽略保留字段以便后续扩展
total_rotation_deg: combined_video 覆盖的水平旋转范围Trellis 默认 360
Returns:
[{"az": 60, "el": 0, "frame_idx": 20,
"color_png": "/generated/view_xxx_az60_el0_color.png",
"normal_png": "/generated/view_xxx_az60_el0_normal.png"}, ...]
"""
video = _resolve_local_path(combined_video_path)
if not video.exists():
raise FileNotFoundError(f"combined_video 文件不存在:{video}")
if not azimuths:
return []
if elevations is None:
elevations = [0] * len(azimuths)
if len(elevations) != len(azimuths):
raise ValueError(
f"azimuths / elevations 长度不一致:{len(azimuths)} vs {len(elevations)}"
)
# ffmpeg + PIL 是 CPU/IO 阻塞的,丢到线程池里跑避免堵死事件循环
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None,
_do_extract,
video, azimuths, elevations, total_rotation_deg,
)

View File

@@ -1,4 +1,8 @@
"""视角变换服务 — 调用 Zero123++ 等模型,将单张图片转为多视角图片。"""
"""视角变换服务 — 调度单图→多视角生成,支持两种管道:
- grid 管道Zero123++ 直接输出 6 宫格拼接图,切分后得到固定 6 视角。
- mesh 管道Trellis/Hunyuan3D → .glb + 预渲染视频 → 抽帧 → 可选风格还原。
"""
import base64
import logging
@@ -13,6 +17,9 @@ from PIL import Image
from app.config import get_view_transform_model_config
from app.services.image_gen import _make_replicate_client, GENERATED_DIR, to_data_uri
from app.services import mesh_generator, style_restorer, video_frame_extractor
DEFAULT_MESH_AZIMUTHS = [0, 60, 120, 180, 240, 300]
logger = logging.getLogger(__name__)
@@ -29,9 +36,30 @@ def _split_grid_image(
返回格式:[{"url": "/generated/xxx.png", "azimuth": 30, "elevation": 30}, ...]
Grid 布局从左到右、从上到下依次对应 view_labels 中的视角。
由于不同 Replicate 部署版本输出的拼接图布局可能不同(有的是 3列×2行 的
宽图,有的是 2列×3行 的高图),这里会根据实际图像长宽比自动纠正 cols/rows——
假定每个单视角为正方形,按 aspect ratio 推断真实布局,防止切图错位。
"""
img = Image.open(image_path)
w, h = img.size
expected_cells = cols * rows
if expected_cells > 0 and len(view_labels) == expected_cells:
# 假定每个视角为正方形:真实 aspect ratio = cols / rows
configured_ratio = cols / rows
actual_ratio = w / h if h else configured_ratio
# 如果实际 ratio 与配置差异较大(超过 20%),判定为布局反了
if actual_ratio > 0 and (
max(configured_ratio, actual_ratio) / min(configured_ratio, actual_ratio) > 1.2
):
logger.warning(
"Grid 布局与实际图像不匹配:配置 cols=%d rows=%dratio=%.2f"
"实际图像 %dx%dratio=%.2f),自动交换 cols/rows。",
cols, rows, configured_ratio, w, h, actual_ratio,
)
cols, rows = rows, cols
cell_w = w // cols
cell_h = h // rows
@@ -73,30 +101,60 @@ async def _download_to_local(url: str) -> Path:
async def transform_view(
image_path: str,
model_id: str | None = None,
azimuths: list[int] | None = None,
elevations: list[int] | None = None,
preserve_style: bool = True,
) -> dict[str, Any]:
"""调用视角变换模型,返回多视角图片列表。
Args:
image_path: 输入图片路径本地路径、data URI 或 URL
model_id: 视角变换模型 ID,默认 zero123plus
model_id: 视角变换模型 IDzero123plus / trellis / hunyuan3d
azimuths: mesh 管道专用,方位角数组
elevations: mesh 管道专用,仰角数组(当前一期仅 0 有效)
preserve_style: mesh 管道专用,是否走阶段 3 风格还原
Returns:
统一字段(不同管道可能缺省):
{
"success": bool,
"images": [{"url": str, "azimuth": int, "elevation": int}, ...],
"grid_image": str, # 原始拼接图的本地 URL
"grid_image": str | None, # grid 管道:原始拼接图 URL
"mesh_url": str | None, # mesh 管道:.glb URL
"color_video": str | None, # mesh 管道360° color video URL
"normal_video": str | None, # mesh 管道:法线 video URL
"pipeline": "grid" | "mesh",
"model_name": str,
"error": str | None,
}
"""
config = get_view_transform_model_config(model_id)
pipeline = config.get("pipeline", "grid")
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
if pipeline == "mesh":
return await _transform_view_mesh(
image_path=image_path,
config=config,
azimuths=azimuths,
elevations=elevations,
preserve_style=preserve_style,
)
return await _transform_view_grid(image_path=image_path, config=config)
async def _transform_view_grid(
image_path: str,
config: dict[str, Any],
) -> dict[str, Any]:
"""原 Zero123++ 逻辑:单次调用 → grid 图 → 切 6 张。"""
model_name = config["name"]
replicate_model_id = config["model_id"]
grid_layout = config["grid_layout"]
output_views = config["output_views"]
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
try:
image_uri = to_data_uri(image_path)
input_params = {"image": image_uri}
@@ -141,6 +199,10 @@ async def transform_view(
"success": True,
"images": view_images,
"grid_image": grid_local_url,
"mesh_url": None,
"color_video": None,
"normal_video": None,
"pipeline": "grid",
"model_name": model_name,
"error": None,
}
@@ -152,6 +214,144 @@ async def transform_view(
"success": False,
"images": [],
"grid_image": None,
"mesh_url": None,
"color_video": None,
"normal_video": None,
"pipeline": "grid",
"model_name": model_name,
"error": detail,
}
async def _transform_view_mesh(
image_path: str,
config: dict[str, Any],
azimuths: list[int] | None,
elevations: list[int] | None,
preserve_style: bool,
) -> dict[str, Any]:
"""Mesh 管道调度器:阶段 1 生成 mesh + video阶段 2/3 后续 Phase 接入。
Phase 1 验收状态:只跑阶段 1返回 .glb / color_video / normal_video 本地 URL
images 列表为空。Phase 2 完成后会填充 imagesraw 抽帧结果),
Phase 3 完成后 images 会是经 IP-Adapter+ControlNet 重绘的最终图。
"""
model_name = config["name"]
model_id = config["id"]
# === Stage 1生成 mesh + 预渲染视频 ===
stage1 = await mesh_generator.generate_mesh(image_path, model_id=model_id)
if not stage1.get("success"):
return {
"success": False,
"images": [],
"grid_image": None,
"mesh_url": None,
"color_video": None,
"normal_video": None,
"combined_video": None,
"pipeline": "mesh",
"model_name": model_name,
"error": stage1.get("error") or "阶段 1 失败",
}
# === Stage 2抽帧 + 切左右半color + normal===
# azimuths / elevations 缺省时用默认 6 视角;长度对齐
if not azimuths:
azimuths = list(DEFAULT_MESH_AZIMUTHS)
if elevations is None or not elevations:
elevations = [0] * len(azimuths)
elif len(elevations) < len(azimuths):
elevations = list(elevations) + [0] * (len(azimuths) - len(elevations))
elif len(elevations) > len(azimuths):
elevations = list(elevations[: len(azimuths)])
# 优先 color_video当前 Trellis 部署版恒为 None缺则 fallback 到 combined_video
combined_video_path = stage1.get("combined_video_path")
frames: list[dict[str, Any]] = []
stage2_error: str | None = None
if combined_video_path:
try:
frames = await video_frame_extractor.extract_by_azimuth(
combined_video_path=combined_video_path,
azimuths=azimuths,
elevations=elevations,
total_rotation_deg=360,
)
except Exception as e:
stage2_error = f"Stage 2 抽帧失败:{e}"
logger.error(stage2_error, exc_info=True)
else:
stage2_error = "Trellis 未返回 combined_video无法执行 Stage 2 抽帧"
logger.error(stage2_error)
# === Stage 3风格还原可选===
# preserve_style=True 时对每一帧的 normal_png 做 IP-Adapter+ControlNet 重绘,
# 用户原图做风格参考;失败的单帧会回退为 Stage 2 的 color 帧,不阻塞其它帧。
stage3_error: str | None = None
stage3_results: list[dict[str, Any]] = []
did_stage3 = bool(preserve_style) and bool(frames)
if did_stage3:
try:
stage3_results = await style_restorer.restore_batch(
original_image=image_path,
frames=frames,
concurrency=3,
)
except Exception as e:
stage3_error = f"Stage 3 风格还原失败:{e}"
logger.error(stage3_error, exc_info=True)
# 组装最终 images
# - Stage 3 成功的帧 → 用重绘后的 URL
# - Stage 3 失败 / 未跑 → 回退到 Stage 2 的 color 帧(保证前端至少能看到点东西)
images: list[dict[str, Any]] = []
for i, f in enumerate(frames):
restyled = stage3_results[i] if i < len(stage3_results) else None
if restyled and restyled.get("success") and restyled.get("url"):
final_url = restyled["url"]
frame_error = None
else:
final_url = f["color_png"]
frame_error = (restyled or {}).get("error") if did_stage3 else None
images.append({
"url": final_url,
"azimuth": f["az"],
"elevation": f["el"],
"normal_url": f["normal_png"],
"color_url": f["color_png"],
"frame_idx": f["frame_idx"],
"restyled": bool(restyled and restyled.get("success")),
**({"restyle_error": frame_error} if frame_error else {}),
})
# Stage 2 失败不把整体置为失败——Stage 1 的 mesh / video 产物仍有交付价值
# Stage 3 部分失败也不置为整体失败(每帧都有 Stage 2 回退)
success = bool(stage1.get("success")) and (stage2_error is None)
# 合并错误信息Stage 2 和 Stage 3 错误都要暴露给前端)
error_parts = [e for e in (stage2_error, stage3_error) if e]
error_out = "; ".join(error_parts) if error_parts else None
if did_stage3:
restyled_ok = sum(1 for img in images if img.get("restyled"))
stage_label = f"stage3 ({restyled_ok}/{len(images)} restyled)"
elif frames:
stage_label = "stage2"
else:
stage_label = "stage1"
return {
"success": success,
"images": images,
"grid_image": None,
"mesh_url": stage1.get("glb_path"),
"color_video": stage1.get("color_video_path"),
"normal_video": stage1.get("normal_video_path"),
"combined_video": combined_video_path,
"pipeline": "mesh",
"model_name": model_name,
"error": error_out,
"_stage": stage_label,
"_preserve_style_requested": bool(preserve_style),
}

View File

@@ -1,5 +1,6 @@
fastapi==0.135.3
uvicorn==0.44.0
watchfiles==0.24.0
openai==2.32.0
replicate==1.0.7
sse-starlette==3.3.4
@@ -7,6 +8,7 @@ python-multipart==0.0.26
httpx==0.28.1
python-dotenv==1.2.2
Pillow==12.2.0
imageio-ffmpeg==0.5.1
mem0ai==1.0.11
ollama==0.6.1
sqlmodel==0.0.38

View File

@@ -0,0 +1,107 @@
"""Phase 2 验收脚本:独立验证 combined_video 抽帧 + 左右切分逻辑。
不需要再跑一次 Trellis省钱直接用 generated/ 下已有的
`trellis_combined_*.mp4` 做验证。
用法(在 backend 目录激活 venv 之后):
python scripts/test_frame_extract.py
python scripts/test_frame_extract.py generated/trellis_combined_211176d3940b.mp4
成功标准:
- 退出码 0
- 打印的 color_png / normal_png 均为 /generated/view_xxx.png
- 到 backend/generated/ 能看到 6 对 color + normal 文件12 张)且尺寸接近正方形
- 目视检查:每组 color 图按 0/60/120/180/240/300° 依次环绕建筑一圈
"""
import asyncio
import json
import sys
from pathlib import Path
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from dotenv import load_dotenv # noqa: E402
load_dotenv(_BACKEND_ROOT / ".env", override=True)
from app.services import video_frame_extractor # noqa: E402
from app.services.image_gen import GENERATED_DIR # noqa: E402
def _pick_default_video() -> str:
candidates = sorted(
GENERATED_DIR.glob("trellis_combined_*.mp4"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not candidates:
raise FileNotFoundError(
"generated/ 下找不到 trellis_combined_*.mp4请先跑一次 Phase 1 "
"scripts/test_trellis.py或显式传入视频路径"
)
return f"/generated/{candidates[0].name}"
async def main() -> int:
video_arg = sys.argv[1] if len(sys.argv) > 1 else _pick_default_video()
# 允许传相对路径或纯文件名
if not video_arg.startswith("/generated/"):
if video_arg.startswith("generated/"):
video_arg = "/" + video_arg.replace("\\", "/")
elif not video_arg.startswith("/") and not Path(video_arg).is_absolute():
# 如果只给了个文件名,假定在 generated/ 下
if (GENERATED_DIR / video_arg).exists():
video_arg = f"/generated/{video_arg}"
print(f"[test_frame_extract] 输入视频:{video_arg}")
print("[test_frame_extract] 抽取 6 视角az=[0,60,120,180,240,300]el 全 0")
azimuths = [0, 60, 120, 180, 240, 300]
elevations = [0] * len(azimuths)
frames = await video_frame_extractor.extract_by_azimuth(
combined_video_path=video_arg,
azimuths=azimuths,
elevations=elevations,
total_rotation_deg=360,
)
print("[test_frame_extract] 抽帧结果:")
print(json.dumps(frames, ensure_ascii=False, indent=2))
# 简单完整性校验
all_exist = True
for f in frames:
for key in ("color_png", "normal_png"):
rel = f.get(key)
if not rel:
print(f"[test_frame_extract] 警告:帧 az={f['az']} 缺少 {key}")
all_exist = False
continue
local = GENERATED_DIR / Path(rel).name
size = local.stat().st_size if local.exists() else -1
status = "OK" if size > 0 else "MISSING"
print(f" [{status}] az={f['az']:3d} {key}: {rel} ({size} bytes)")
if size <= 0:
all_exist = False
if not all_exist:
print("[test_frame_extract] 部分文件缺失,判定失败")
return 1
print(
f"[test_frame_extract] 成功:共 {len(frames)} 个视角,"
f"color+normal 合计 {len(frames) * 2} 张已落盘到 {GENERATED_DIR}"
)
print(
"[test_frame_extract] 请目视检查:打开 generated/ 下任意 view_*_color.png"
"确认 az=0 正面、az=180 背面、其余按顺序环绕normal 帧应为蓝紫调法线图。"
)
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,167 @@
"""Phase 3 / Checkpoint 3 验收脚本:独立验证 Stage 3 风格还原链路。
复用 Phase 2 已抽出的 normal 帧,无需再跑 Trellis省 $0.041)。
默认会挑最新一组 view_*_el0_normal.png 作为结构参考,并让用户指定原图。
用法(在 backend 目录激活 venv 后):
# 1. 全 6 帧批量重绘(~6 × $0.07 ≈ $0.42
python scripts/test_style_restore.py <原图路径>
# 2. 只跑一帧做快速冒烟(便宜 ~$0.07
python scripts/test_style_restore.py <原图路径> --single
# 3. 手动指定 normal 帧前缀(不指定时用最新一组)
python scripts/test_style_restore.py <原图路径> --prefix view_xxxxxxxx
成功标准:
- 退出码 0
- 打印的每一帧都返回 success=True 且 url 以 /generated/restyled_ 开头
- 打开 generated/restyled_* 图片,目视检查:风格与原图一致、视角符合 az 标注
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from dotenv import load_dotenv # noqa: E402
load_dotenv(_BACKEND_ROOT / ".env", override=True)
from app.services import style_restorer # noqa: E402
from app.services.image_gen import GENERATED_DIR # noqa: E402
def _pick_latest_normal_group() -> tuple[str, list[dict]]:
"""在 generated/ 下挑一组最新的 view_<prefix>_az*_el0_normal.png按 az 排好序。
返回 (prefix, frames)frames = [{az, el, normal_png, color_png}, ...]
"""
all_normals = sorted(
GENERATED_DIR.glob("view_*_el0_normal.png"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not all_normals:
raise FileNotFoundError(
"generated/ 下找不到 view_*_el0_normal.png请先跑 scripts/test_frame_extract.py"
)
# 最新文件的 prefixview_<8hex>_az...
latest_name = all_normals[0].name
# 形如 view_63731b77_az0_el0_normal.png → prefix = view_63731b77
parts = latest_name.split("_")
prefix = "_".join(parts[:2])
return prefix, _load_group_by_prefix(prefix)
def _load_group_by_prefix(prefix: str) -> list[dict]:
normals = sorted(GENERATED_DIR.glob(f"{prefix}_az*_el0_normal.png"))
if not normals:
raise FileNotFoundError(f"找不到前缀为 {prefix} 的 normal 帧")
frames = []
for n in normals:
# 从文件名解析 azview_63731b77_az120_el0_normal.png → az=120
tokens = n.stem.split("_")
az = 0
el = 0
for t in tokens:
if t.startswith("az"):
try:
az = int(t[2:])
except ValueError:
pass
elif t.startswith("el"):
try:
el = int(t[2:])
except ValueError:
pass
color = n.with_name(n.name.replace("_normal.png", "_color.png"))
frames.append({
"az": az,
"el": el,
"normal_png": f"/generated/{n.name}",
"color_png": f"/generated/{color.name}" if color.exists() else None,
"frame_idx": -1,
})
frames.sort(key=lambda f: f["az"])
return frames
async def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("original", help="用户原图路径(本地相对/绝对路径均可)")
parser.add_argument("--prefix", default=None, help="normal 帧的文件名前缀(如 view_63731b77")
parser.add_argument("--single", action="store_true", help="只跑第一帧做快速冒烟")
parser.add_argument("--scale", type=float, default=None, help="覆盖 IP-Adapter 权重0-1")
parser.add_argument("--cn-scale", type=float, default=None, help="覆盖 ControlNet 权重0-1")
args = parser.parse_args()
original = args.original
original_path = Path(original)
if not original_path.is_absolute():
original_path = (_BACKEND_ROOT / original).resolve()
if not original_path.exists():
print(f"[test_style_restore] 原图不存在:{original_path}")
return 2
if args.prefix:
frames = _load_group_by_prefix(args.prefix)
prefix = args.prefix
else:
prefix, frames = _pick_latest_normal_group()
if args.single:
frames = frames[:1]
override_params = {}
if args.scale is not None:
override_params["scale"] = args.scale
if args.cn_scale is not None:
override_params["controlnet_conditioning_scale"] = args.cn_scale
print(f"[test_style_restore] 原图:{original_path}")
print(f"[test_style_restore] normal 帧组 prefix={prefix},共 {len(frames)}")
print(f"[test_style_restore] 角度列表:{[f['az'] for f in frames]}")
if override_params:
print(f"[test_style_restore] 覆盖参数:{override_params}")
print(
f"[test_style_restore] 预计耗时 ~{72}s × {len(frames)}"
f"成本 ~${0.07 * len(frames):.2f}(并发 3 实际会更快)"
)
results = await style_restorer.restore_batch(
original_image=str(original_path),
frames=frames,
params=override_params or None,
concurrency=3,
)
print("[test_style_restore] 重绘结果:")
print(json.dumps(results, ensure_ascii=False, indent=2))
ok = sum(1 for r in results if r.get("success"))
print(f"[test_style_restore] 成功 {ok}/{len(results)}")
if ok == 0:
print("[test_style_restore] 全军覆没,判定失败")
return 1
if ok < len(results):
print("[test_style_restore] 部分帧失败,视为部分通过;请检查上方错误信息")
return 0
print(
"[test_style_restore] 全部成功;请目视检查 generated/restyled_* 图片:"
"风格应贴近原图、视角按 az 顺序环绕。"
)
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,72 @@
"""Phase 1 验收脚本:直接调用 Trellis验证 .glb + color_video + normal_video 能下到本地。
用法(在 backend 目录激活 venv 之后):
python scripts/test_trellis.py uploads/0e6fd7e47a524bb9ad18de0b72495e0e.png
不带参数时会尝试使用 uploads/ 下最新的 png 作为测试图。
成功标准:
- 退出码 0
- 终端打印 glb_path / color_video_path / normal_video_path 均为 /generated/ 开头
- 实际到 backend/generated/ 目录能看到 3 个文件且 size > 0
"""
import asyncio
import json
import sys
from pathlib import Path
# 允许直接 `python scripts/test_trellis.py` 跑
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
# .env 加载(复用项目 main.py 的习惯)
from dotenv import load_dotenv # noqa: E402
load_dotenv(_BACKEND_ROOT / ".env", override=True)
from app.services import mesh_generator # noqa: E402
def _pick_default_image() -> str:
uploads = _BACKEND_ROOT / "uploads"
pngs = sorted(uploads.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True)
if not pngs:
raise FileNotFoundError(f"uploads/ 下没有 png 文件,请显式传入图片路径")
return str(pngs[0].relative_to(_BACKEND_ROOT)).replace("\\", "/")
async def main() -> int:
image_arg = sys.argv[1] if len(sys.argv) > 1 else _pick_default_image()
# 规范化成 / 开头的 "本地相对 URL" 以便 to_data_uri 识别
if not image_arg.startswith("/") and not image_arg.startswith("data:") and not image_arg.startswith("http"):
image_arg = "/" + image_arg.lstrip("./").replace("\\", "/")
print(f"[test_trellis] 输入图:{image_arg}")
print("[test_trellis] 开始调用 Trellis预计 ~30s 起)...")
result = await mesh_generator.generate_with_trellis(image_arg)
print("[test_trellis] Trellis 返回:")
print(json.dumps(result, ensure_ascii=False, indent=2))
if not result.get("success"):
print("[test_trellis] 失败:", result.get("error"))
return 1
generated_dir = _BACKEND_ROOT / "generated"
for key in ("glb_path", "color_video_path", "normal_video_path"):
rel = result.get(key)
if not rel:
print(f"[test_trellis] 警告:缺少 {key}")
continue
local = generated_dir / Path(rel).name
size = local.stat().st_size if local.exists() else -1
print(f"[test_trellis] {key}: {rel} (本地大小: {size} bytes)")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,9 @@
"framer-motion": "^12.38.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",

View File

@@ -0,0 +1,254 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { DocTree } from "@/components/docs/doc-tree";
import { DocTabs } from "@/components/docs/doc-tabs";
import { DocEditor } from "@/components/docs/doc-editor";
import { DocOutline } from "@/components/docs/doc-outline";
import { useApp } from "@/lib/app-context";
import type { DocNode } from "@/lib/types";
/**
* 文档管理中心Phase 1
*
* 三栏布局:
* - 左:文档树(文件夹 + 文档,右键菜单、拖拽移动、搜索)
* - 中:多 Tab 编辑器Markdown 源码 / 分栏 / 预览)
* - 右:大纲 + 关联资产 + AI 助手入口
*/
export default function DocsPage() {
const { docs, updateDocContent } = useApp();
// 打开的 Tab 列表(按顺序)
const [openIds, setOpenIds] = useState<string[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
/** 每个 Tab 的未保存 buffernodeId -> content。无条目表示已同步。 */
const [buffers, setBuffers] = useState<Record<string, string>>({});
const [treeSearch, setTreeSearch] = useState("");
const [showTree, setShowTree] = useState(true);
const [showOutline, setShowOutline] = useState(true);
// 在 docs 列表中递归查找节点
const findNode = (list: DocNode[], id: string): DocNode | null => {
for (const n of list) {
if (n.id === id) return n;
if (n.children) {
const r = findNode(n.children, id);
if (r) return r;
}
}
return null;
};
// 自动打开第一个有内容的文档
useEffect(() => {
if (openIds.length === 0 && docs.length > 0) {
const findFirstDoc = (list: DocNode[]): DocNode | null => {
for (const n of list) {
if (n.type === "doc") return n;
if (n.children) {
const r = findFirstDoc(n.children);
if (r) return r;
}
}
return null;
};
const first = findFirstDoc(docs);
if (first) {
setOpenIds([first.id]);
setActiveId(first.id);
}
}
}, [docs, openIds.length]);
const openTabs = useMemo(
() =>
openIds
.map((id) => {
const node = findNode(docs, id);
if (!node) return null;
const dirty = buffers[id] !== undefined && buffers[id] !== (node.content ?? "");
return { node, dirty };
})
.filter((x): x is { node: DocNode; dirty: boolean } => x !== null),
[openIds, docs, buffers]
);
const openDoc = (node: DocNode) => {
if (node.type !== "doc") return;
setOpenIds((prev) => (prev.includes(node.id) ? prev : [...prev, node.id]));
setActiveId(node.id);
};
const closeTab = (id: string) => {
setOpenIds((prev) => {
const idx = prev.indexOf(id);
if (idx < 0) return prev;
// 若有 dirty 且未保存,简单 confirm
const dirty = buffers[id] !== undefined && buffers[id] !== (findNode(docs, id)?.content ?? "");
if (dirty && !confirm("存在未保存的修改,确认关闭?")) return prev;
const next = prev.filter((x) => x !== id);
if (activeId === id) {
setActiveId(next[Math.min(idx, next.length - 1)] ?? null);
}
return next;
});
setBuffers((prev) => {
const { [id]: _, ...rest } = prev;
return rest;
});
};
const closeOthers = (keepId: string) => {
setOpenIds((prev) => prev.filter((id) => id === keepId));
setActiveId(keepId);
setBuffers((prev) => ({ keepId: prev[keepId], ...(prev[keepId] !== undefined ? { [keepId]: prev[keepId] } : {}) }));
};
const closeAll = () => {
if (Object.keys(buffers).length > 0 && !confirm("存在未保存的修改,确认关闭全部?")) return;
setOpenIds([]);
setActiveId(null);
setBuffers({});
};
const activeDoc = activeId ? findNode(docs, activeId) : null;
const activeBuffer =
activeDoc && activeId
? buffers[activeId] !== undefined
? buffers[activeId]
: activeDoc.content ?? ""
: "";
const handleContentChange = (content: string) => {
if (!activeId) return;
setBuffers((prev) => ({ ...prev, [activeId]: content }));
};
const handleSave = () => {
if (!activeId || !activeDoc) return;
const buf = buffers[activeId];
if (buf === undefined) return;
updateDocContent(activeId, buf);
setBuffers((prev) => {
const { [activeId]: _, ...rest } = prev;
return rest;
});
};
return (
<div className="h-screen flex flex-col relative z-[1]">
<AmbientParticles count={12} />
<TopNav />
<div className="flex-1 flex overflow-hidden">
{/* 左栏:文档树 */}
{showTree && (
<aside className="w-56 md:w-64 flex-shrink-0 border-r border-[var(--border)]
bg-[var(--bg-secondary)]/40 backdrop-blur-sm flex flex-col">
<DocTree
activeId={activeId}
openIds={new Set(openIds)}
onOpen={openDoc}
searchQuery={treeSearch}
onSearch={setTreeSearch}
/>
</aside>
)}
{/* 中栏Tabs + 编辑器 */}
<main className="flex-1 flex flex-col min-w-0 bg-[var(--bg-primary)]">
{/* 折叠/展开按钮 */}
<div className="flex-shrink-0 flex items-center border-b border-[var(--border)] px-2 py-1 gap-1
bg-[var(--bg-secondary)]/50 backdrop-blur-sm">
<button
onClick={() => setShowTree(!showTree)}
className="p-1.5 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5 cursor-pointer transition-colors"
title={showTree ? "隐藏文档树" : "显示文档树"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
</button>
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1">
<span className="phase-chip">P1</span>
</span>
<div className="flex-1" />
<button
onClick={() => setShowOutline(!showOutline)}
className="p-1.5 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5 cursor-pointer transition-colors"
title={showOutline ? "隐藏大纲" : "显示大纲"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="15" y1="3" x2="15" y2="21" />
</svg>
</button>
</div>
<DocTabs
openTabs={openTabs}
activeId={activeId}
onActivate={setActiveId}
onClose={closeTab}
onCloseOthers={closeOthers}
onCloseAll={closeAll}
/>
{activeDoc ? (
<DocEditor
doc={activeDoc}
buffer={activeBuffer}
onChange={handleContentChange}
onSave={handleSave}
/>
) : (
<DocEmptyState />
)}
</main>
{/* 右栏:大纲 + 关联资产 */}
{showOutline && activeDoc && (
<aside className="w-56 md:w-64 flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/40 backdrop-blur-sm">
<DocOutline content={activeBuffer} />
</aside>
)}
</div>
</div>
);
}
function DocEmptyState() {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4 max-w-md px-6">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
bg-[var(--accent)]/8 border border-[var(--accent)]/20">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.7">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="13" y2="17" />
</svg>
</div>
<div>
<p className="typo-display text-lg mb-2"></p>
<p className="typo-caption leading-relaxed">
Prompt <br />
<kbd className="px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] border border-[var(--border)] text-[10px] font-mono">Ctrl+K</kbd>
</p>
</div>
<div className="text-[10px] text-[var(--text-secondary)]">
Phase 2
</div>
</div>
</div>
);
}

View File

@@ -1,14 +1,15 @@
"use client";
import { useState, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
import { useApp } from "@/lib/app-context";
import { getImageUrl } from "@/lib/api";
import { staggerContainer, staggerItem, slideUp } from "@/components/ui/motion-presets";
import type { ImageAsset } from "@/lib/types";
import { Model3DPanel } from "@/components/detail/model-3d-panel";
import { MotionPanel } from "@/components/detail/motion-panel";
import type { ImageAsset, Asset3D, AssetMotion, TaskType } from "@/lib/types";
type ViewMode = "grid" | "list";
type SortBy = "time" | "name";
@@ -23,8 +24,10 @@ const ASSET_TYPES = [
];
export default function GalleryPage() {
const { assets, tags, toggleFavorite, deleteAssetById, stylePacks, characters } = useApp();
const { assets, tags, toggleFavorite, deleteAssetById, stylePacks, characters,
assets3D, assetsMotion, deleteAsset3D, deleteAssetMotion } = useApp();
const [modality, setModality] = useState<TaskType>("image2d");
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [sortBy, setSortBy] = useState<SortBy>("time");
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
@@ -33,6 +36,8 @@ export default function GalleryPage() {
const [showFavOnly, setShowFavOnly] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [detailAsset, setDetailAsset] = useState<ImageAsset | null>(null);
const [detail3D, setDetail3D] = useState<Asset3D | null>(null);
const [detailMotion, setDetailMotion] = useState<AssetMotion | null>(null);
const [selectedAssetType, setSelectedAssetType] = useState<string | null>(null);
const [selectedStyleId, setSelectedStyleId] = useState<string | null>(null);
const [selectedCharacterId, setSelectedCharacterId] = useState<string | null>(null);
@@ -161,8 +166,60 @@ export default function GalleryPage() {
<AmbientParticles count={filtered.length === 0 ? 25 : 15} />
<TopNav />
{/* 资产类型 Tab2D 图片 / 3D 模型 / 动作序列) */}
<div className="flex-shrink-0 border-b border-[var(--border)] surface-1 px-4 md:px-5">
<div className="flex items-center gap-1">
{(
[
{ k: "image2d" as TaskType, label: "图片 (2D)", count: assets.length, phase: "Phase 0", color: "var(--accent)" },
{ k: "model3d" as TaskType, label: "3D 模型", count: assets3D.length, phase: "Phase 2", color: "var(--gold)" },
{ k: "motion" as TaskType, label: "动作序列", count: assetsMotion.length, phase: "Phase 2", color: "var(--accent-secondary)" },
]
).map((t) => {
const active = modality === t.k;
return (
<button
key={t.k}
onClick={() => setModality(t.k)}
className="relative flex items-center gap-2 px-4 py-2.5 text-xs font-medium cursor-pointer transition-all"
style={{
color: active ? t.color : "var(--text-secondary)",
borderBottom: active ? `2px solid ${t.color}` : "2px solid transparent",
}}
>
{t.label}
<span
className="px-1.5 py-0.5 rounded-md text-[10px] font-mono"
style={{
backgroundColor: active ? t.color + "18" : "var(--bg-tertiary)",
color: active ? t.color : "var(--text-secondary)",
}}
>
{t.count}
</span>
{t.k !== "image2d" && <span className="phase-chip">{t.phase}</span>}
</button>
);
})}
</div>
</div>
<div className="flex-1 flex overflow-hidden">
<main className="flex-1 flex flex-col min-w-0">
{modality === "model3d" ? (
<Assets3DGrid
items={assets3D}
onOpen={setDetail3D}
onDelete={deleteAsset3D}
/>
) : modality === "motion" ? (
<AssetsMotionGrid
items={assetsMotion}
onOpen={setDetailMotion}
onDelete={deleteAssetMotion}
/>
) : (
<>
{/* 工具栏 */}
<div className="flex-shrink-0 border-b border-[var(--border)] surface-1
backdrop-blur-xl px-4 md:px-5 py-3
@@ -629,8 +686,15 @@ export default function GalleryPage() {
</div>
)}
</div>
</>
)}
</main>
{/* 3D 详情面板 */}
{detail3D && <Model3DPanel asset={detail3D} onClose={() => setDetail3D(null)} />}
{/* 动作详情面板 */}
{detailMotion && <MotionPanel asset={detailMotion} onClose={() => setDetailMotion(null)} />}
{/* 详情 Modal */}
{detailAsset && (
<GalleryDetailModal
@@ -799,3 +863,222 @@ function GalleryDetailModal({
</div>
);
}
/* ============ 3D 资产网格(占位简化版) ============ */
function Assets3DGrid({
items,
onOpen,
onDelete,
}: {
items: Asset3D[];
onOpen: (a: Asset3D) => void;
onDelete: (id: string) => void;
}) {
if (items.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<motion.div className="text-center space-y-4" variants={staggerContainer} initial="hidden" animate="visible">
<motion.div
variants={staggerItem}
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
bg-[var(--gold)]/10 border border-[var(--gold)]/20"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.5" opacity="0.7">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
</motion.div>
<motion.p variants={staggerItem} className="typo-display text-lg">
3D
</motion.p>
<motion.p variants={staggerItem} className="typo-caption">
3D
</motion.p>
</motion.div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-4 md:p-5">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
{items.map((a) => (
<div
key={a.id}
onClick={() => onOpen(a)}
className="group relative rounded-xl overflow-hidden neon-border cursor-pointer
bg-[var(--bg-card)] backdrop-blur-sm transition-all"
>
<div className="relative aspect-square bg-[var(--bg-primary)]">
{a.thumbnail || a.views[0]?.url ? (
<img src={a.thumbnail || a.views[0].url} alt={a.prompt ?? ""} className="w-full h-full object-cover
transition-transform duration-300 group-hover:scale-[1.03]" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.2" opacity="0.6">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
</div>
)}
{/* 3D 徽章 */}
<div className="absolute top-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded-md
bg-black/55 backdrop-blur-sm border border-[var(--gold)]/40">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
</svg>
<span className="text-[9px] font-bold tracking-wider" style={{ color: "var(--gold)" }}>3D</span>
</div>
{/* 视图数徽章 */}
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-md
bg-black/55 backdrop-blur-sm text-[9px] text-white/85 font-mono">
{a.views.length}
</div>
{/* 删除按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
onDelete(a.id);
}}
className="absolute bottom-2 right-2 w-7 h-7 rounded-lg bg-black/50 backdrop-blur-sm
text-white/70 hover:text-[var(--hot)] flex items-center justify-center
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-all cursor-pointer"
title="删除"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
</button>
</div>
<div className="p-2.5 bg-[var(--bg-secondary)]/80 backdrop-blur-sm">
<div className="text-[11px] text-[var(--text-primary)] truncate font-medium">
{a.prompt ?? "未命名模型"}
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-[9px] text-[var(--text-secondary)] uppercase tracking-wider">
{a.pipeline}
</span>
<span className="text-[9px] text-[var(--text-secondary)]">
{new Date(a.createdAt).toLocaleDateString("zh-CN")}
</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}
/* ============ 动作序列网格(占位简化版) ============ */
function AssetsMotionGrid({
items,
onOpen,
onDelete,
}: {
items: AssetMotion[];
onOpen: (a: AssetMotion) => void;
onDelete: (id: string) => void;
}) {
if (items.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<motion.div className="text-center space-y-4" variants={staggerContainer} initial="hidden" animate="visible">
<motion.div
variants={staggerItem}
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
bg-[var(--accent-secondary)]/10 border border-[var(--accent-secondary)]/20"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent-secondary)" strokeWidth="1.5" opacity="0.7">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</motion.div>
<motion.p variants={staggerItem} className="typo-display text-lg">
</motion.p>
<motion.p variants={staggerItem} className="typo-caption">
</motion.p>
</motion.div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-4 md:p-5">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
{items.map((a) => (
<div
key={a.id}
onClick={() => onOpen(a)}
className="group relative rounded-xl overflow-hidden neon-border cursor-pointer
bg-[var(--bg-card)] backdrop-blur-sm transition-all"
>
<div className="relative aspect-square bg-[var(--bg-primary)]">
{a.videoUrl ? (
<video src={a.videoUrl} className="w-full h-full object-cover" muted loop playsInline
onMouseEnter={(e) => (e.currentTarget as HTMLVideoElement).play()}
onMouseLeave={(e) => {
const v = e.currentTarget as HTMLVideoElement;
v.pause();
v.currentTime = 0;
}} />
) : a.frames[0] ? (
<img src={a.frames[0]} alt={a.prompt ?? ""} className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--accent-secondary)" strokeWidth="1.2" opacity="0.6">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</div>
)}
{/* 动作徽章 */}
<div className="absolute top-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded-md
bg-black/55 backdrop-blur-sm border border-[var(--accent-secondary)]/40">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style={{ color: "var(--accent-secondary)" }}>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
<span className="text-[9px] font-bold tracking-wider" style={{ color: "var(--accent-secondary)" }}>MOTION</span>
</div>
{/* 帧/FPS 徽章 */}
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-md
bg-black/55 backdrop-blur-sm text-[9px] text-white/85 font-mono">
{a.frameCount}f · {a.fps}fps
</div>
{/* 删除按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
onDelete(a.id);
}}
className="absolute bottom-2 right-2 w-7 h-7 rounded-lg bg-black/50 backdrop-blur-sm
text-white/70 hover:text-[var(--hot)] flex items-center justify-center
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-all cursor-pointer"
title="删除"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
</button>
</div>
<div className="p-2.5 bg-[var(--bg-secondary)]/80 backdrop-blur-sm">
<div className="text-[11px] text-[var(--text-primary)] truncate font-medium">
{a.prompt || "未命名动作"}
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-[9px] text-[var(--text-secondary)]">
{(a.frameCount / a.fps).toFixed(1)}s · {a.loop ? "循环" : "一次"}
</span>
<span className="text-[9px] text-[var(--text-secondary)]">
{new Date(a.createdAt).toLocaleDateString("zh-CN")}
</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import Script from "next/script";
import { AuthProvider } from "@/lib/auth-context";
import { AuthGuard } from "@/lib/auth-guard";
import { AppProvider } from "@/lib/app-context";
import { CommandPalette } from "@/components/docs/command-palette";
import "./globals.css";
export const metadata: Metadata = {
@@ -26,9 +28,18 @@ export default function RootLayout({
<body className="antialiased">
<AuthProvider>
<AuthGuard>
<AppProvider>{children}</AppProvider>
<AppProvider>
{children}
<CommandPalette />
</AppProvider>
</AuthGuard>
</AuthProvider>
{/* <model-viewer> 自定义元素,供 3D 结果面板使用(占位) */}
<Script
type="module"
src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"
strategy="lazyOnload"
/>
</body>
</html>
);

View File

@@ -5,6 +5,9 @@ import { motion, AnimatePresence } from "framer-motion";
import { ChatMessages } from "@/components/chat/chat-messages";
import { ChatInput, type ChatInputHandle } from "@/components/chat/chat-input";
import { SessionQuickPicks } from "@/components/chat/session-quick-picks";
import { TaskTypeTabs } from "@/components/chat/task-type-tabs";
import { Params3D } from "@/components/workbench/params-3d";
import { ParamsMotionBar } from "@/components/workbench/params-motion";
import { AdvancedControls } from "@/components/workbench/advanced-controls";
import { CandidatePanel } from "@/components/workbench/candidate-panel";
import { Sidebar } from "@/components/sidebar/sidebar";
@@ -27,6 +30,7 @@ export default function Home() {
detailImage,
sidebarCollapsed,
setSidebarCollapsed,
sessionTaskType,
} = useApp();
const messages = activeSession?.messages ?? [];
@@ -464,8 +468,15 @@ export default function Home() {
)}
</AnimatePresence>
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--bg-secondary)]/60 backdrop-blur-md
flex items-center">
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--bg-secondary)]/60 backdrop-blur-md">
{/* 任务类型切换2D / 3D / 动作) */}
<TaskTypeTabs />
{/* 3D / 动作参数条(按任务类型显示) */}
{sessionTaskType === "model3d" && <Params3D />}
{sessionTaskType === "motion" && <ParamsMotionBar />}
<div className="flex items-center">
<div className="flex-1 min-w-0">
<SessionQuickPicks />
</div>
@@ -490,6 +501,7 @@ export default function Home() {
</button>
</div>
</div>
</div>
<ChatInput
ref={chatInputRef}

View File

@@ -2,14 +2,50 @@
import { useState } from "react";
import { useApp } from "@/lib/app-context";
import type { TaskIR } from "@/lib/types";
import type { TaskIR, TaskType } from "@/lib/types";
interface TaskCardProps {
ir: TaskIR;
/** 任务类型变体2D / 3D / 动作。默认 image2d向后兼容旧数据 */
variant?: TaskType;
onConfirm?: (ir: TaskIR) => void;
onCancel?: () => void;
}
const VARIANT_META: Record<TaskType, { label: string; color: string; icon: React.ReactNode }> = {
image2d: {
label: "2D 图片生成",
color: "var(--accent)",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
),
},
model3d: {
label: "3D 模型生成",
color: "var(--gold)",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
),
},
motion: {
label: "动作序列生成",
color: "var(--accent-secondary)",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
),
},
};
/**
* 任务说明卡Phase 1 占位组件)
*
@@ -17,23 +53,28 @@ interface TaskCardProps {
* 用于在对话中展示 Agent 解析出的任务意图,供用户确认、微调或取消。
* 数据结构见 types.ts 中的 TaskIR。
*/
export function TaskCard({ ir, onConfirm, onCancel }: TaskCardProps) {
export function TaskCard({ ir, variant = "image2d", onConfirm, onCancel }: TaskCardProps) {
const { stylePacks, characters } = useApp();
const [candidateCount, setCandidateCount] = useState(ir.candidateCount);
const styleName = stylePacks.find((s) => s.id === ir.styleId)?.name;
const charName = characters.find((c) => c.id === ir.characterId)?.name;
const meta = VARIANT_META[variant];
return (
<div className="rounded-xl border border-[var(--accent)]/20 bg-[var(--accent)]/5
px-4 py-3 max-w-xl backdrop-blur-sm">
<div
className="rounded-xl border px-4 py-3 max-w-xl backdrop-blur-sm"
style={{
borderColor: meta.color + "30",
backgroundColor: meta.color + "0A",
}}
>
<div className="flex items-center gap-2 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
<span className="typo-strong text-xs text-[var(--accent)]"></span>
<span className="phase-chip">Phase 1</span>
<span style={{ color: meta.color }}>{meta.icon}</span>
<span className="typo-strong text-xs" style={{ color: meta.color }}>
· {meta.label}
</span>
<span className="phase-chip">{variant === "image2d" ? "Phase 1" : "Phase 2"}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
@@ -61,6 +102,42 @@ export function TaskCard({ ir, onConfirm, onCancel }: TaskCardProps) {
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]">{ir.outputSpec}</div>
</div>
{/* 3D 变体专有字段 */}
{variant === "model3d" && (
<>
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]">Trellis / Hunyuan3D</div>
</div>
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]">6 </div>
</div>
<div className="sm:col-span-2">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]"> PNG · 线 · · .glb </div>
</div>
</>
)}
{/* 动作变体专有字段 */}
{variant === "motion" && (
<>
<div>
<span className="typo-micro" style={{ textTransform: "none" }}> · </span>
<div className="text-[var(--text-primary)]">16 · 12 fps 1.3s</div>
</div>
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]"></div>
</div>
<div className="sm:col-span-2">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]">MP4 · GIF</div>
</div>
</>
)}
</div>
<div className="mt-3 flex items-center gap-3 flex-wrap">
@@ -90,10 +167,9 @@ export function TaskCard({ ir, onConfirm, onCancel }: TaskCardProps) {
)}
<button
onClick={() => onConfirm?.({ ...ir, candidateCount })}
className="px-2.5 py-1 text-xs rounded-md font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
cursor-pointer transition-all"
className="px-2.5 py-1 text-xs rounded-md font-medium text-[var(--bg-primary)]
hover:opacity-90 cursor-pointer transition-all"
style={{ backgroundColor: meta.color }}
>
</button>
@@ -101,7 +177,9 @@ export function TaskCard({ ir, onConfirm, onCancel }: TaskCardProps) {
</div>
<div className="text-[10px] text-[var(--text-secondary)] mt-2">
Agent Phase 1
{variant === "image2d" && "确认后 Agent 将按此参数组合生成候选(占位交互,真实链路待 Phase 1。"}
{variant === "model3d" && "3D 管线:单图 → 多视图 → 法线 → 风格化 → GLB占位真实链路待 Phase 2。"}
{variant === "motion" && "动作管线:立绘 / 角色 LoRA → 运动编码 → 视频(占位,真实链路待 Phase 2。"}
</div>
</div>
);

View File

@@ -0,0 +1,85 @@
"use client";
import { useApp } from "@/lib/app-context";
import type { TaskType } from "@/lib/types";
const TASK_TYPES: { id: TaskType; label: string; phase: string; icon: React.ReactNode }[] = [
{
id: "image2d",
label: "2D 图片",
phase: "P0",
icon: (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
),
},
{
id: "model3d",
label: "3D 模型",
phase: "P2",
icon: (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
),
},
{
id: "motion",
label: "动作序列",
phase: "P2",
icon: (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
),
},
];
/**
* 工作台任务类型切换2D / 3D / 动作)。
*
* 切换后会影响:
* - 下方"任务说明卡"的变体
* - 参数条组件Params3D / ParamsMotion是否可见
* - 发送后生成物的归类2D image / 3D asset / motion asset
*/
export function TaskTypeTabs() {
const { sessionTaskType, setSessionTaskType } = useApp();
return (
<div className="flex items-center gap-1 px-3 md:px-5 pt-2 pb-0 flex-wrap">
<span className="typo-micro text-[10px] mr-1" style={{ textTransform: "none" }}>
</span>
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]">
{TASK_TYPES.map((t) => {
const active = sessionTaskType === t.id;
return (
<button
key={t.id}
onClick={() => setSessionTaskType(t.id)}
className={`relative h-6 px-2 rounded-md text-[11px] flex items-center gap-1 cursor-pointer
transition-all whitespace-nowrap ${
active
? "bg-[var(--accent)]/12 text-[var(--accent)] shadow-[0_0_8px_rgba(46,139,122,0.1)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
title={`${t.label}${t.phase}`}
>
{t.icon}
<span className="font-medium">{t.label}</span>
{!active && t.phase !== "P0" && (
<span className="phase-chip ml-0.5">{t.phase}</span>
)}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
"use client";
import { createElement, useState } from "react";
import { motion } from "framer-motion";
import { slideUp } from "@/components/ui/motion-presets";
import type { Asset3D } from "@/lib/types";
interface Model3DPanelProps {
asset: Asset3D;
onClose?: () => void;
}
type ViewChannel = "color" | "normal" | "stylized";
const VIEW_TABS: { key: ViewChannel; label: string }[] = [
{ key: "color", label: "原图" },
{ key: "normal", label: "法线" },
{ key: "stylized", label: "风格化" },
];
/**
* 3D 结果面板占位支持多视图切换、360 旋转、<model-viewer> 预览。
* 真实模型文件加载待 Phase 2 接入 Trellis/Hunyuan3D 管线。
*/
export function Model3DPanel({ asset, onClose }: Model3DPanelProps) {
const [mode, setMode] = useState<"views" | "rotate" | "mesh">("views");
const [viewTab, setViewTab] = useState<ViewChannel>("color");
const [activeViewIdx, setActiveViewIdx] = useState(0);
const activeView = asset.views[activeViewIdx];
const imgSrc =
viewTab === "color"
? activeView?.url
: viewTab === "normal"
? activeView?.normalUrl
: activeView?.restyledUrl;
return (
<motion.aside
variants={slideUp}
initial="hidden"
animate="visible"
className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
md:w-[420px] flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
flex flex-col overflow-hidden"
>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<div className="flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<span className="text-sm font-semibold text-[var(--text-primary)]">3D </span>
<span className="phase-chip">Phase 2</span>
</div>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--accent)]
cursor-pointer transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* 模式切换 */}
<div className="p-3 flex gap-1 border-b border-[var(--border)]">
{(
[
{ k: "views", l: "多视图" },
{ k: "rotate", l: "360°" },
{ k: "mesh", l: "模型" },
] as const
).map((m) => (
<button
key={m.k}
onClick={() => setMode(m.k)}
className={`flex-1 text-xs px-2 py-1.5 rounded-lg font-medium transition-all cursor-pointer ${
mode === m.k
? "bg-[var(--gold)]/15 text-[var(--gold)] border border-[var(--gold)]/40"
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)] hover:text-[var(--text-primary)]"
}`}
>
{m.l}
</button>
))}
</div>
{/* 预览区 */}
<div className="p-4">
<div className="relative rounded-xl overflow-hidden neon-border bg-[var(--bg-primary)] aspect-square">
{mode === "views" && imgSrc && (
<img src={imgSrc} alt="view" className="w-full h-full object-contain" />
)}
{mode === "views" && !imgSrc && (
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
{viewTab}
</div>
)}
{mode === "rotate" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-[var(--text-secondary)] text-xs">
<motion.div
animate={{ rotateY: 360 }}
transition={{ duration: 8, ease: "linear", repeat: Infinity }}
style={{ transformStyle: "preserve-3d" as const, perspective: "400px" }}
className="w-20 h-20 flex items-center justify-center"
>
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" stroke="var(--gold)" strokeWidth="1.5">
<polygon points="40,10 70,25 70,55 40,70 10,55 10,25" fill="color-mix(in srgb, var(--gold) 10%, transparent)" />
<line x1="40" y1="10" x2="40" y2="70" />
<line x1="10" y1="25" x2="70" y2="25" />
<line x1="10" y1="55" x2="70" y2="55" />
</svg>
</motion.div>
<span>360° </span>
</div>
)}
{mode === "mesh" && (
<div className="absolute inset-0 flex items-center justify-center">
{asset.meshUrl ? (
createElement("model-viewer", {
src: asset.meshUrl,
"camera-controls": true,
"auto-rotate": true,
ar: true,
exposure: "1",
style: { width: "100%", height: "100%" },
})
) : (
<div className="text-[var(--text-secondary)] text-xs flex flex-col items-center gap-2">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<span>GLB ...</span>
</div>
)}
</div>
)}
</div>
{/* 多视图:通道 Tab */}
{mode === "views" && (
<div className="mt-3">
<div className="flex gap-1 mb-2">
{VIEW_TABS.map((t) => (
<button
key={t.key}
onClick={() => setViewTab(t.key)}
className={`text-[10px] px-2 py-0.5 rounded-md font-medium transition-all cursor-pointer ${
viewTab === t.key
? "bg-[var(--accent)]/15 text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{t.label}
</button>
))}
</div>
{/* 视图缩略条 */}
<div className="flex gap-1.5 overflow-x-auto pb-1">
{asset.views.map((v, i) => {
const label = `方位 ${Math.round(v.azimuth)}° · 仰角 ${Math.round(v.elevation)}°`;
return (
<button
key={i}
onClick={() => setActiveViewIdx(i)}
className={`flex-shrink-0 w-14 h-14 rounded-lg overflow-hidden border-2 transition-all cursor-pointer ${
i === activeViewIdx ? "border-[var(--gold)]" : "border-transparent opacity-70 hover:opacity-100"
}`}
title={label}
>
<img src={v.url} alt={label} className="w-full h-full object-cover" />
</button>
);
})}
</div>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="px-4 pb-3 flex flex-wrap gap-2">
<button
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--gold)] text-[var(--bg-primary)]
hover:opacity-90 transition-all cursor-pointer btn-hover-lift"
title="下载 GLB占位"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
GLB
</button>
<button
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] border border-[var(--border)]
hover:border-[var(--gold)]/40 transition-all cursor-pointer btn-hover-lift"
title="重新生成(占位)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5" />
</svg>
</button>
<button
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] border border-[var(--border)]
hover:border-[var(--accent)]/40 transition-all cursor-pointer btn-hover-lift"
title="基于此模型生成动作(占位)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</button>
</div>
{/* 元信息 */}
<div className="px-4 pb-4 space-y-3">
<div>
<span className="typo-caption font-medium block mb-1.5">线</span>
<div className="text-xs text-[var(--text-secondary)]">
{asset.pipeline === "trellis"
? "Trellis3D Gaussians → Mesh"
: asset.pipeline === "hunyuan3d"
? "Hunyuan3D腾讯开源"
: "Zero123++(多视图合成)"}
</div>
</div>
{asset.prompt && (
<div>
<span className="typo-caption font-medium block mb-1.5">Prompt</span>
<div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
{asset.prompt}
</div>
</div>
)}
<div>
<span className="typo-caption font-medium block mb-1.5"></span>
<div className="text-xs text-[var(--text-secondary)] space-y-1">
<div>{asset.views.length}</div>
<div>{new Date(asset.createdAt).toLocaleString("zh-CN")}</div>
</div>
</div>
</div>
</div>
</motion.aside>
);
}

View File

@@ -0,0 +1,259 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import { slideUp } from "@/components/ui/motion-presets";
import { useApp } from "@/lib/app-context";
import type { AssetMotion } from "@/lib/types";
interface MotionPanelProps {
asset: AssetMotion;
onClose?: () => void;
}
/**
* 动作序列结果面板(占位):
* - 视频/帧序列预览
* - 播放/暂停/帧擦写scrubber
* - 下载 MP4 / GIF / 帧序列占位按钮
*/
export function MotionPanel({ asset, onClose }: MotionPanelProps) {
const { characters } = useApp();
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [frameIdx, setFrameIdx] = useState(0);
const [mode, setMode] = useState<"video" | "frames">("video");
const character = characters.find((c) => c.id === asset.characterId);
// 帧序列自动播放
useEffect(() => {
if (mode !== "frames" || !playing || asset.frames.length === 0) return;
const iv = setInterval(() => {
setFrameIdx((i) => (i + 1) % asset.frames.length);
}, 1000 / asset.fps);
return () => clearInterval(iv);
}, [mode, playing, asset.frames.length, asset.fps]);
const togglePlay = () => {
if (mode === "video") {
if (!videoRef.current) return;
if (videoRef.current.paused) {
videoRef.current.play();
setPlaying(true);
} else {
videoRef.current.pause();
setPlaying(false);
}
} else {
setPlaying((p) => !p);
}
};
return (
<motion.aside
variants={slideUp}
initial="hidden"
animate="visible"
className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
md:w-[420px] flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
flex flex-col overflow-hidden"
>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<div className="flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent-secondary)" strokeWidth="2">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<span className="phase-chip">Phase 2</span>
</div>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--accent)]
cursor-pointer transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* 模式切换 */}
<div className="p-3 flex gap-1 border-b border-[var(--border)]">
{(
[
{ k: "video", l: "视频" },
{ k: "frames", l: "帧序列" },
] as const
).map((m) => (
<button
key={m.k}
onClick={() => {
setMode(m.k);
setPlaying(false);
}}
className={`flex-1 text-xs px-2 py-1.5 rounded-lg font-medium transition-all cursor-pointer ${
mode === m.k
? "bg-[var(--accent-secondary)]/15 text-[var(--accent-secondary)] border border-[var(--accent-secondary)]/40"
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)] hover:text-[var(--text-primary)]"
}`}
>
{m.l}
</button>
))}
</div>
{/* 预览 */}
<div className="p-4">
<div className="relative rounded-xl overflow-hidden neon-border bg-[var(--bg-primary)] aspect-square">
{mode === "video" ? (
asset.videoUrl ? (
<video
ref={videoRef}
src={asset.videoUrl}
className="w-full h-full object-contain"
loop={asset.loop}
muted
playsInline
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
...
</div>
)
) : asset.frames.length > 0 ? (
<img src={asset.frames[frameIdx]} alt={`frame ${frameIdx}`} className="w-full h-full object-contain" />
) : (
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
</div>
)}
</div>
{/* 播放控件 */}
<div className="mt-3 flex items-center gap-2">
<button
onClick={togglePlay}
className="flex items-center justify-center w-9 h-9 rounded-full
bg-[var(--accent-secondary)] text-[var(--bg-primary)]
hover:opacity-90 cursor-pointer transition-all"
>
{playing ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
)}
</button>
{mode === "frames" && asset.frames.length > 0 && (
<>
<input
type="range"
min={0}
max={asset.frames.length - 1}
value={frameIdx}
onChange={(e) => setFrameIdx(Number(e.target.value))}
className="flex-1 accent-[var(--accent-secondary)]"
/>
<span className="text-[10px] text-[var(--text-secondary)] font-mono min-w-[40px] text-right">
{frameIdx + 1}/{asset.frames.length}
</span>
</>
)}
{mode === "video" && (
<div className="flex-1 text-[10px] text-[var(--text-secondary)]">
{asset.frameCount} · {asset.fps} fps · {(asset.frameCount / asset.fps).toFixed(2)}s
</div>
)}
</div>
</div>
{/* 操作 */}
<div className="px-4 pb-3 flex flex-wrap gap-2">
<button
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent-secondary)] text-[var(--bg-primary)]
hover:opacity-90 transition-all cursor-pointer btn-hover-lift"
title="下载 MP4占位"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
MP4
</button>
<button
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] border border-[var(--border)]
hover:border-[var(--accent-secondary)]/40 transition-all cursor-pointer btn-hover-lift"
title="下载 GIF占位"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
GIF
</button>
<button
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] border border-[var(--border)]
hover:border-[var(--accent-secondary)]/40 transition-all cursor-pointer btn-hover-lift"
title="下载帧序列 zip占位"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
</button>
</div>
{/* 元信息 */}
<div className="px-4 pb-4 space-y-3">
{asset.prompt && (
<div>
<span className="typo-caption font-medium block mb-1.5"></span>
<div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
{asset.prompt}
</div>
</div>
)}
{character && (
<div>
<span className="typo-caption font-medium block mb-1.5"></span>
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
style={{ backgroundColor: "var(--accent)" + "22", color: "var(--accent)" }}>
{character.name.slice(0, 1)}
</div>
<span className="text-xs text-[var(--text-primary)]">{character.name}</span>
</div>
</div>
)}
<div>
<span className="typo-caption font-medium block mb-1.5"></span>
<div className="text-xs text-[var(--text-secondary)] space-y-1">
<div> / {asset.frameCount} · {asset.fps} fps</div>
<div>{asset.loop ? "是" : "否"}</div>
<div>{new Date(asset.createdAt).toLocaleString("zh-CN")}</div>
</div>
</div>
</div>
</div>
</motion.aside>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import { useApp } from "@/lib/app-context";
import { getImageUrl } from "@/lib/api";
interface AssetPickerProps {
onInsert: (assetRef: string) => void;
onClose: () => void;
}
type Kind = "all" | "image" | "character" | "style";
/**
* 资产引用选择器浮层(按 @ 键或工具条按钮唤起)。
*
* 产物:将选中的资产作为 [[asset:<id>]] 插入到文档。
*/
export function AssetPicker({ onInsert, onClose }: AssetPickerProps) {
const { assets, characters, stylePacks } = useApp();
const [kind, setKind] = useState<Kind>("all");
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const results = useMemo(() => {
const q = query.toLowerCase();
const out: Array<{
id: string;
kind: "image" | "character" | "style";
label: string;
sublabel?: string;
thumb?: string;
}> = [];
if (kind === "all" || kind === "image") {
for (const a of assets) {
if (q && !a.prompt.toLowerCase().includes(q) && !a.id.includes(q)) continue;
out.push({
id: a.id,
kind: "image",
label: a.prompt.slice(0, 50) || "(无 prompt",
sublabel: new Date(a.createdAt).toLocaleString("zh-CN"),
thumb: a.url,
});
}
}
if (kind === "all" || kind === "character") {
for (const c of characters) {
if (q && !c.name.toLowerCase().includes(q) && !c.id.includes(q)) continue;
out.push({
id: c.id,
kind: "character",
label: c.name,
sublabel: c.visualHooks.slice(0, 3).join(" · "),
});
}
}
if (kind === "all" || kind === "style") {
for (const s of stylePacks) {
if (q && !s.name.toLowerCase().includes(q) && !s.id.includes(q)) continue;
out.push({
id: s.id,
kind: "style",
label: s.name,
sublabel: s.keywords.slice(0, 3).join(" · "),
});
}
}
return out.slice(0, 40);
}, [assets, characters, stylePacks, kind, query]);
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh] bg-black/30 backdrop-blur-sm"
onClick={onClose}
>
<div
className="surface-3 rounded-xl w-full max-w-xl mx-4 overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* 搜索框 */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--border)]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
<circle cx="12" cy="12" r="4" />
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
</svg>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="引用资产:图片 / 角色 / 风格..."
className="flex-1 bg-transparent border-none outline-none text-sm
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
/>
<span className="text-[10px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded border border-[var(--border)]">
Esc
</span>
</div>
{/* 类型切换 */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
{([
{ k: "all", label: "全部" },
{ k: "image", label: "图片" },
{ k: "character", label: "角色" },
{ k: "style", label: "风格" },
] as const).map((t) => (
<button
key={t.k}
onClick={() => setKind(t.k)}
className={`px-2.5 py-1 text-[11px] rounded-md cursor-pointer transition-all ${
kind === t.k
? "bg-[var(--accent)]/12 text-[var(--accent)] border border-[var(--accent)]/30"
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{t.label}
</button>
))}
</div>
{/* 结果列表 */}
<div className="max-h-[50vh] overflow-y-auto">
{results.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--text-secondary)]">
</div>
) : (
results.map((r) => (
<button
key={`${r.kind}-${r.id}`}
onClick={() => onInsert(`[[asset:${r.id}]]`)}
className="w-full px-3 py-2 flex items-center gap-2.5 text-left cursor-pointer
hover:bg-[var(--accent)]/5 transition-colors border-b border-[var(--border)] last:border-0"
>
{r.kind === "image" && r.thumb ? (
<img
src={getImageUrl(r.thumb)}
alt=""
className="w-9 h-9 rounded-md object-cover border border-[var(--border)] flex-shrink-0"
/>
) : (
<div
className={`w-9 h-9 rounded-md border border-[var(--border)] flex-shrink-0
flex items-center justify-center ${
r.kind === "character"
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "bg-gradient-to-br from-[var(--accent)]/20 to-[var(--accent-secondary)]/20 text-[var(--accent-secondary)]"
}`}
>
{r.kind === "character" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="typo-micro text-[9px]" style={{ textTransform: "none" }}>
{r.kind === "image" ? "图片" : r.kind === "character" ? "角色" : "风格"}
</span>
<span className="text-[10px] text-[var(--text-secondary)] font-mono truncate">
{r.id}
</span>
</div>
<div className="text-xs text-[var(--text-primary)] truncate">{r.label}</div>
{r.sublabel && (
<div className="text-[10px] text-[var(--text-secondary)] truncate">
{r.sublabel}
</div>
)}
</div>
</button>
))
)}
</div>
<div className="px-3 py-1.5 text-[10px] text-[var(--text-secondary)]
bg-[var(--bg-secondary)]/40 border-t border-[var(--border)]">
<code className="font-mono text-[var(--accent)]">[[asset:id]]</code>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,494 @@
"use client";
import { useState, useEffect, useRef, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useApp } from "@/lib/app-context";
import type { DocNode } from "@/lib/types";
import { getImageUrl } from "@/lib/api";
type CommandItem =
| { id: string; kind: "nav"; label: string; hint: string; action: () => void; icon: React.ReactNode }
| { id: string; kind: "action"; label: string; hint: string; action: () => void; icon: React.ReactNode }
| { id: string; kind: "doc"; label: string; hint: string; action: () => void; icon: React.ReactNode }
| { id: string; kind: "asset"; label: string; hint: string; action: () => void; icon: React.ReactNode; thumb?: string }
| { id: string; kind: "char"; label: string; hint: string; action: () => void; icon: React.ReactNode }
| { id: string; kind: "style"; label: string; hint: string; action: () => void; icon: React.ReactNode }
| { id: string; kind: "session"; label: string; hint: string; action: () => void; icon: React.ReactNode };
const KIND_META: Record<CommandItem["kind"], { label: string; color: string }> = {
nav: { label: "导航", color: "var(--accent)" },
action: { label: "操作", color: "var(--accent-secondary)" },
doc: { label: "文档", color: "var(--accent)" },
asset: { label: "图片", color: "var(--gold)" },
char: { label: "角色", color: "var(--accent)" },
style: { label: "风格", color: "var(--accent-secondary)" },
session: { label: "会话", color: "var(--text-secondary)" },
};
/**
* 全站命令面板Ctrl/Cmd+K
*
* 聚合:导航、全局操作、文档、资产、角色、风格、会话。
* 通过 app-context 中的 commandPaletteOpen 控制可见性。
*/
export function CommandPalette() {
const router = useRouter();
const {
commandPaletteOpen: open,
setCommandPaletteOpen: setOpen,
docs,
assets,
characters,
stylePacks,
sessions,
switchSession,
createSession,
setSessionTaskType,
} = useApp();
const [query, setQuery] = useState("");
const [activeIdx, setActiveIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// 全局快捷键Cmd/Ctrl+K
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen(!open);
}
if (e.key === "Escape" && open) {
setOpen(false);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, setOpen]);
// 打开时聚焦输入框、重置状态
useEffect(() => {
if (open) {
setQuery("");
setActiveIdx(0);
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);
// 收集所有文档(扁平化)
const flatDocs = useMemo(() => {
const out: DocNode[] = [];
const walk = (list: DocNode[]) => {
for (const n of list) {
if (n.type === "doc") out.push(n);
if (n.children) walk(n.children);
}
};
walk(docs);
return out;
}, [docs]);
const items = useMemo<CommandItem[]>(() => {
const out: CommandItem[] = [
{
id: "nav-workbench",
kind: "nav",
label: "前往 · 工作台",
hint: "/",
action: () => router.push("/"),
icon: <IconDot />,
},
{
id: "nav-docs",
kind: "nav",
label: "前往 · 文档中心",
hint: "/docs",
action: () => router.push("/docs"),
icon: <IconDot />,
},
{
id: "nav-gallery",
kind: "nav",
label: "前往 · 资源库",
hint: "/gallery",
action: () => router.push("/gallery"),
icon: <IconDot />,
},
{
id: "nav-styles",
kind: "nav",
label: "前往 · 风格库",
hint: "/styles",
action: () => router.push("/styles"),
icon: <IconDot />,
},
{
id: "nav-chars",
kind: "nav",
label: "前往 · 角色库",
hint: "/characters",
action: () => router.push("/characters"),
icon: <IconDot />,
},
{
id: "nav-training",
kind: "nav",
label: "前往 · 训练中心",
hint: "/training",
action: () => router.push("/training"),
icon: <IconDot />,
},
{
id: "nav-settings",
kind: "nav",
label: "前往 · 项目设置",
hint: "/settings",
action: () => router.push("/settings"),
icon: <IconDot />,
},
{
id: "action-new-session",
kind: "action",
label: "新建对话",
hint: "工作台",
action: () => {
createSession();
router.push("/");
},
icon: <IconPlus />,
},
{
id: "action-switch-2d",
kind: "action",
label: "切换任务类型 · 2D 图片",
hint: "工作台",
action: () => {
setSessionTaskType("image2d");
router.push("/");
},
icon: <IconSwitch />,
},
{
id: "action-switch-3d",
kind: "action",
label: "切换任务类型 · 3D 模型",
hint: "工作台",
action: () => {
setSessionTaskType("model3d");
router.push("/");
},
icon: <IconSwitch />,
},
{
id: "action-switch-motion",
kind: "action",
label: "切换任务类型 · 动作序列",
hint: "工作台",
action: () => {
setSessionTaskType("motion");
router.push("/");
},
icon: <IconSwitch />,
},
];
for (const d of flatDocs) {
out.push({
id: `doc-${d.id}`,
kind: "doc",
label: d.name,
hint: (d.content ?? "").replace(/\n/g, " ").slice(0, 80) || "(空文档)",
action: () => router.push("/docs"),
icon: <IconDoc />,
});
}
for (const a of assets.slice(0, 50)) {
out.push({
id: `asset-${a.id}`,
kind: "asset",
label: a.prompt.slice(0, 60) || "(无 prompt",
hint: new Date(a.createdAt).toLocaleString("zh-CN"),
action: () => router.push("/gallery"),
icon: <IconImage />,
thumb: a.url,
});
}
for (const c of characters) {
out.push({
id: `char-${c.id}`,
kind: "char",
label: c.name,
hint: c.visualHooks.slice(0, 3).join(" · "),
action: () => router.push(`/characters/${c.id}`),
icon: <IconChar />,
});
}
for (const s of stylePacks) {
out.push({
id: `style-${s.id}`,
kind: "style",
label: s.name,
hint: s.keywords.slice(0, 3).join(" · "),
action: () => router.push(`/styles/${s.id}`),
icon: <IconStyle />,
});
}
for (const s of sessions.slice(0, 20)) {
out.push({
id: `session-${s.id}`,
kind: "session",
label: s.title || "未命名会话",
hint: new Date(s.updatedAt).toLocaleString("zh-CN"),
action: () => {
switchSession(s.id);
router.push("/");
},
icon: <IconSession />,
});
}
return out;
}, [flatDocs, assets, characters, stylePacks, sessions, router, createSession, switchSession, setSessionTaskType]);
const filtered = useMemo(() => {
if (!query.trim()) {
return items.slice(0, 30);
}
const q = query.toLowerCase();
const tokens = q.split(/\s+/).filter(Boolean);
return items
.map((it) => {
const haystack = `${it.label} ${it.hint} ${KIND_META[it.kind].label}`.toLowerCase();
let score = 0;
for (const t of tokens) {
const pos = haystack.indexOf(t);
if (pos < 0) return null;
score += 1 - pos / 200;
}
return { it, score };
})
.filter((x): x is { it: CommandItem; score: number } => x !== null)
.sort((a, b) => b.score - a.score)
.slice(0, 50)
.map((x) => x.it);
}, [items, query]);
useEffect(() => {
setActiveIdx(0);
}, [query]);
// 滚动选中项到可见
useEffect(() => {
if (!listRef.current) return;
const el = listRef.current.querySelector<HTMLElement>(`[data-idx="${activeIdx}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [activeIdx]);
if (!open) return null;
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => Math.min(filtered.length - 1, i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => Math.max(0, i - 1));
} else if (e.key === "Enter") {
e.preventDefault();
const it = filtered[activeIdx];
if (it) {
it.action();
setOpen(false);
}
}
};
return (
<div
className="fixed inset-0 z-[70] flex items-start justify-center pt-[12vh] bg-black/40 backdrop-blur-sm"
onClick={() => setOpen(false)}
>
<div
className="surface-3 rounded-xl w-full max-w-2xl mx-4 overflow-hidden flex flex-col shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* 搜索框 */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-[var(--border)]">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="搜索页面、操作、文档、资产、角色、风格、会话..."
className="flex-1 bg-transparent border-none outline-none text-sm
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
/>
<div className="flex items-center gap-1 text-[10px] text-[var(--text-secondary)]">
<Kbd></Kbd>
<Kbd>Enter</Kbd>
<Kbd>Esc</Kbd>
</div>
</div>
{/* 结果列表 */}
<div ref={listRef} className="max-h-[55vh] overflow-y-auto">
{filtered.length === 0 ? (
<div className="py-10 text-center text-xs text-[var(--text-secondary)]">
</div>
) : (
filtered.map((it, i) => {
const meta = KIND_META[it.kind];
const active = i === activeIdx;
return (
<button
key={it.id}
data-idx={i}
onMouseMove={() => setActiveIdx(i)}
onClick={() => {
it.action();
setOpen(false);
}}
className={`w-full px-4 py-2 flex items-center gap-3 text-left cursor-pointer transition-colors ${
active
? "bg-[var(--accent)]/10"
: "hover:bg-[var(--accent)]/5"
}`}
>
<div
className="flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center
border border-[var(--border)]"
style={{ color: meta.color, backgroundColor: meta.color + "12" }}
>
{it.kind === "asset" && "thumb" in it && it.thumb ? (
<img
src={getImageUrl(it.thumb)}
alt=""
className="w-full h-full object-cover rounded-md"
/>
) : (
it.icon
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span
className="text-[9px] font-semibold px-1.5 py-0.5 rounded"
style={{ color: meta.color, backgroundColor: meta.color + "12" }}
>
{meta.label}
</span>
<span
className={`text-xs truncate ${
active ? "text-[var(--accent)]" : "text-[var(--text-primary)]"
}`}
>
{it.label}
</span>
</div>
<div className="text-[10px] text-[var(--text-secondary)] truncate">
{it.hint}
</div>
</div>
{active && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5" className="flex-shrink-0">
<path d="M9 18l6-6-6-6" />
</svg>
)}
</button>
);
})
)}
</div>
<div className="px-4 py-2 border-t border-[var(--border)] bg-[var(--bg-secondary)]/40
text-[10px] text-[var(--text-secondary)] flex items-center gap-3">
<span>EPEEKit </span>
<span>·</span>
<span>{filtered.length} </span>
<div className="flex-1" />
<span>
<Kbd>Ctrl</Kbd>+<Kbd>K</Kbd>
</span>
</div>
</div>
</div>
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="px-1 py-0.5 rounded bg-[var(--bg-tertiary)] border border-[var(--border)]
text-[9px] font-mono text-[var(--text-secondary)]">
{children}
</kbd>
);
}
function IconDot() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="5" />
</svg>
);
}
function IconPlus() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
}
function IconSwitch() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
);
}
function IconDoc() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
}
function IconImage() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
);
}
function IconChar() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
);
}
function IconStyle() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" strokeDasharray="2 3" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function IconSession() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
);
}

View File

@@ -0,0 +1,315 @@
"use client";
import { useRef, useState, useEffect } from "react";
import type { DocNode } from "@/lib/types";
import { AssetPicker } from "./asset-picker";
import { MarkdownContent } from "./markdown-content";
type ViewMode = "source" | "preview" | "split";
interface DocEditorProps {
doc: DocNode;
/** 当前 buffer可能包含未保存修改 */
buffer: string;
onChange: (content: string) => void;
onSave: () => void;
}
export function DocEditor({ doc, buffer, onChange, onSave }: DocEditorProps) {
const [viewMode, setViewMode] = useState<ViewMode>("split");
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Ctrl+S 保存
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
onSave();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onSave]);
const insertAtCursor = (before: string, after = "") => {
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = buffer.slice(start, end);
const next = buffer.slice(0, start) + before + selected + after + buffer.slice(end);
onChange(next);
requestAnimationFrame(() => {
ta.focus();
const pos = start + before.length + selected.length;
ta.setSelectionRange(pos, pos);
});
};
const insertLine = (prefix: string) => {
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart;
const lineStart = buffer.lastIndexOf("\n", start - 1) + 1;
const next = buffer.slice(0, lineStart) + prefix + buffer.slice(lineStart);
onChange(next);
requestAnimationFrame(() => {
ta.focus();
ta.setSelectionRange(start + prefix.length, start + prefix.length);
});
};
const handleInsertAsset = (assetRef: string) => {
insertAtCursor(assetRef);
setAssetPickerOpen(false);
};
// 监听 @ 键唤起资产选择器
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "@") {
e.preventDefault();
setAssetPickerOpen(true);
}
if (e.key === "Tab") {
e.preventDefault();
insertAtCursor(" ");
}
};
const stats = {
chars: buffer.length,
lines: buffer.split("\n").length,
words: buffer.trim() ? buffer.trim().split(/\s+/).length : 0,
};
return (
<div className="flex-1 flex flex-col min-h-0">
{/* 工具条 */}
<div className="flex-shrink-0 border-b border-[var(--border)] px-3 py-2
flex items-center gap-1 flex-wrap bg-[var(--bg-secondary)]/40 backdrop-blur-sm">
<ToolBtn onClick={() => insertLine("# ")} title="H1">
<span className="text-[11px] font-bold">H1</span>
</ToolBtn>
<ToolBtn onClick={() => insertLine("## ")} title="H2">
<span className="text-[11px] font-bold">H2</span>
</ToolBtn>
<ToolBtn onClick={() => insertLine("### ")} title="H3">
<span className="text-[11px] font-bold">H3</span>
</ToolBtn>
<Sep />
<ToolBtn onClick={() => insertAtCursor("**", "**")} title="粗体Ctrl+B">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6zM6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
</svg>
</ToolBtn>
<ToolBtn onClick={() => insertAtCursor("*", "*")} title="斜体">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="19" y1="4" x2="10" y2="4" />
<line x1="14" y1="20" x2="5" y2="20" />
<line x1="15" y1="4" x2="9" y2="20" />
</svg>
</ToolBtn>
<ToolBtn onClick={() => insertAtCursor("~~", "~~")} title="删除线">
<span className="text-[11px] line-through">S</span>
</ToolBtn>
<Sep />
<ToolBtn onClick={() => insertLine("- ")} title="无序列表">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3.5" cy="6" r="1" />
<circle cx="3.5" cy="12" r="1" />
<circle cx="3.5" cy="18" r="1" />
</svg>
</ToolBtn>
<ToolBtn onClick={() => insertLine("1. ")} title="有序列表">
<span className="text-[10px] font-mono">1.</span>
</ToolBtn>
<ToolBtn onClick={() => insertLine("> ")} title="引用">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z" />
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z" />
</svg>
</ToolBtn>
<ToolBtn onClick={() => insertAtCursor("`", "`")} title="行内代码">
<span className="text-[11px] font-mono">{"<>"}</span>
</ToolBtn>
<ToolBtn onClick={() => insertAtCursor("\n```\n", "\n```\n")} title="代码块">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
</ToolBtn>
<Sep />
<ToolBtn onClick={() => insertAtCursor("[", "](url)")} title="链接">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71" />
</svg>
</ToolBtn>
<ToolBtn onClick={() => insertAtCursor("![alt](", ")")} title="图片">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</ToolBtn>
<ToolBtn onClick={() => setAssetPickerOpen(true)} title="引用资产(@">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="4" />
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
</svg>
</ToolBtn>
<Sep />
<ToolBtn onClick={() => insertAtCursor("\n---\n")} title="分割线">
<span className="text-[11px] font-mono"></span>
</ToolBtn>
<ToolBtn
onClick={() =>
insertAtCursor("\n| 列 1 | 列 2 |\n|---|---|\n| A | B |\n")
}
title="表格"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="15" x2="21" y2="15" />
<line x1="12" y1="3" x2="12" y2="21" />
</svg>
</ToolBtn>
<div className="flex-1" />
{/* 视图切换 */}
<div className="flex items-center bg-[var(--bg-tertiary)] rounded-md p-0.5 border border-[var(--border)]">
<ViewBtn active={viewMode === "source"} onClick={() => setViewMode("source")} title="源码">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
</ViewBtn>
<ViewBtn active={viewMode === "split"} onClick={() => setViewMode("split")} title="分栏">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="12" y1="3" x2="12" y2="21" />
</svg>
</ViewBtn>
<ViewBtn active={viewMode === "preview"} onClick={() => setViewMode("preview")} title="预览">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</ViewBtn>
</div>
<button
onClick={onSave}
className="ml-1 h-7 px-3 rounded-md text-xs font-medium bg-[var(--accent)] text-white
hover:bg-[var(--accent-hover)] cursor-pointer transition-all"
title="保存Ctrl+S"
>
</button>
</div>
{/* 编辑/预览区 */}
<div className="flex-1 min-h-0 flex overflow-hidden">
{(viewMode === "source" || viewMode === "split") && (
<div className="flex-1 min-w-0 relative">
<textarea
ref={textareaRef}
value={buffer}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
spellCheck={false}
className="w-full h-full px-5 py-4 bg-transparent border-none outline-none resize-none
text-sm leading-relaxed text-[var(--text-primary)]
font-mono placeholder:text-[var(--text-secondary)]"
placeholder={`# ${doc.name.replace(/\.md$/, "")}\n\n开始写吧。按 @ 键可快速引用资产。`}
/>
</div>
)}
{viewMode === "split" && (
<div className="w-px bg-[var(--border)] flex-shrink-0" />
)}
{(viewMode === "preview" || viewMode === "split") && (
<div className="flex-1 min-w-0 overflow-y-auto px-6 py-5 fog-scroll">
<MarkdownContent content={buffer} />
</div>
)}
</div>
{/* 状态栏 */}
<div className="flex-shrink-0 border-t border-[var(--border)] px-4 py-1.5
flex items-center gap-4 text-[10px] text-[var(--text-secondary)]
bg-[var(--bg-secondary)]/40">
<span>{doc.name}</span>
<span>·</span>
<span>{stats.chars} </span>
<span>·</span>
<span>{stats.lines} </span>
<span>·</span>
<span>{stats.words} </span>
<div className="flex-1" />
<span>Markdown</span>
<span>·</span>
<span>UTF-8</span>
</div>
{assetPickerOpen && (
<AssetPicker onInsert={handleInsertAsset} onClose={() => setAssetPickerOpen(false)} />
)}
</div>
);
}
function ToolBtn({
onClick,
children,
title,
}: {
onClick: () => void;
children: React.ReactNode;
title: string;
}) {
return (
<button
onClick={onClick}
title={title}
className="w-7 h-7 rounded-md flex items-center justify-center cursor-pointer transition-all
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/8"
>
{children}
</button>
);
}
function ViewBtn({
active,
onClick,
children,
title,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
title: string;
}) {
return (
<button
onClick={onClick}
title={title}
className={`w-7 h-6 rounded flex items-center justify-center cursor-pointer transition-all
${active ? "bg-[var(--accent)]/12 text-[var(--accent)]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"}`}
>
{children}
</button>
);
}
function Sep() {
return <div className="w-px h-5 bg-[var(--border)] mx-0.5" />;
}

View File

@@ -0,0 +1,216 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { useApp } from "@/lib/app-context";
import { getImageUrl } from "@/lib/api";
interface DocOutlineProps {
content: string;
onJumpToLine?: (lineNo: number) => void;
}
interface Heading {
level: number;
text: string;
lineNo: number;
slug: string;
}
export function DocOutline({ content, onJumpToLine }: DocOutlineProps) {
const { assets, characters, stylePacks } = useApp();
const headings = useMemo<Heading[]>(() => {
const out: Heading[] = [];
const lines = content.split("\n");
let inCodeBlock = false;
lines.forEach((line, i) => {
if (line.trim().startsWith("```")) {
inCodeBlock = !inCodeBlock;
return;
}
if (inCodeBlock) return;
const m = line.match(/^(#{1,6})\s+(.+)$/);
if (m) {
out.push({
level: m[1].length,
text: m[2].trim(),
lineNo: i + 1,
slug: m[2].trim().toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, "-"),
});
}
});
return out;
}, [content]);
const referencedAssets = useMemo(() => {
const ids = new Set<string>();
const re = /\[\[asset:([^\]]+)\]\]/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
ids.add(m[1]);
}
return Array.from(ids)
.map((id) => {
const img = assets.find((a) => a.id === id);
const char = characters.find((c) => c.id === id);
const style = stylePacks.find((s) => s.id === id);
return { id, img, char, style };
});
}, [content, assets, characters, stylePacks]);
return (
<div className="h-full flex flex-col">
{/* 大纲 */}
<div className="flex-shrink-0 px-3 pt-3 pb-2 border-b border-[var(--border)] flex items-center gap-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
<span className="typo-strong text-xs flex-1"></span>
<span className="text-[10px] text-[var(--text-secondary)]">{headings.length}</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1.5">
{headings.length === 0 ? (
<div className="px-3 py-4 text-[11px] text-[var(--text-secondary)] text-center">
</div>
) : (
headings.map((h, i) => (
<button
key={`${h.lineNo}-${i}`}
onClick={() => onJumpToLine?.(h.lineNo)}
className="w-full text-left px-3 py-1 text-xs cursor-pointer
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5 transition-colors truncate"
style={{ paddingLeft: 12 + (h.level - 1) * 12 }}
title={h.text}
>
<span className="text-[9px] text-[var(--text-secondary)]/60 mr-1 font-mono">
H{h.level}
</span>
{h.text}
</button>
))
)}
</div>
{/* 关联资产 */}
<div className="flex-shrink-0 border-t border-[var(--border)]">
<div className="px-3 pt-2.5 pb-1.5 flex items-center gap-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71" />
</svg>
<span className="typo-strong text-xs flex-1"></span>
<span className="text-[10px] text-[var(--text-secondary)]">{referencedAssets.length}</span>
</div>
<div className="max-h-[28vh] overflow-y-auto pb-2">
{referencedAssets.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-[var(--text-secondary)] text-center">
</div>
) : (
referencedAssets.map(({ id, img, char, style }) => {
const key = `${id}`;
if (img) {
return (
<div
key={key}
className="mx-2 my-1 p-1.5 flex items-center gap-2 rounded-md
border border-[var(--border)] hover:border-[var(--accent)]/40
bg-[var(--bg-card)] transition-colors"
>
<img
src={getImageUrl(img.url)}
alt=""
className="w-8 h-8 rounded object-cover border border-[var(--border)] flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-[var(--text-secondary)]"></div>
<div className="text-xs truncate">{img.prompt.slice(0, 30) || "(无 prompt"}</div>
</div>
<Link
href="/gallery"
className="text-[10px] text-[var(--accent)] hover:underline flex-shrink-0"
>
</Link>
</div>
);
}
if (char) {
return (
<Link
key={key}
href={`/characters/${char.id}`}
className="mx-2 my-1 p-1.5 flex items-center gap-2 rounded-md
border border-[var(--border)] hover:border-[var(--accent)]/40
bg-[var(--bg-card)] transition-colors"
>
<div className="w-8 h-8 rounded-full bg-[var(--accent)]/10 border border-[var(--border)]
flex items-center justify-center text-[var(--accent)] text-xs font-semibold flex-shrink-0">
{char.name.charAt(0)}
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-[var(--text-secondary)]"></div>
<div className="text-xs truncate">{char.name}</div>
</div>
<span className="text-[10px] text-[var(--accent)] flex-shrink-0"></span>
</Link>
);
}
if (style) {
return (
<Link
key={key}
href={`/styles/${style.id}`}
className="mx-2 my-1 p-1.5 flex items-center gap-2 rounded-md
border border-[var(--border)] hover:border-[var(--accent)]/40
bg-[var(--bg-card)] transition-colors"
>
<div className="w-8 h-8 rounded-md bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]
opacity-60 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[10px] text-[var(--text-secondary)]"></div>
<div className="text-xs truncate">{style.name}</div>
</div>
<span className="text-[10px] text-[var(--accent-secondary)] flex-shrink-0"></span>
</Link>
);
}
return (
<div key={key} className="mx-2 my-1 px-2 py-1 rounded-md text-[10px] font-mono
text-[var(--hot)]/70 bg-[var(--hot)]/5 border border-[var(--hot)]/20">
[[asset:{id}]]
</div>
);
})
)}
</div>
</div>
{/* AI 助手占位 */}
<div className="flex-shrink-0 border-t border-[var(--border)] p-3">
<button
onClick={() => alert("AI 写作助手将在 Phase 2 接入:\n- 改写 / 扩写 / 概括 / 翻译\n- 基于项目上下文的续写\n- 从选中角色生成立绘 Prompt")}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-md
border border-dashed border-[var(--border)] bg-[var(--accent)]/5
text-xs text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
title="Phase 2 规划"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
AI
<span className="phase-chip">P2</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import type { DocNode } from "@/lib/types";
interface DocTabsProps {
openTabs: Array<{ node: DocNode; dirty: boolean }>;
activeId: string | null;
onActivate: (id: string) => void;
onClose: (id: string) => void;
onCloseOthers: (id: string) => void;
onCloseAll: () => void;
}
export function DocTabs({
openTabs,
activeId,
onActivate,
onClose,
onCloseOthers,
onCloseAll,
}: DocTabsProps) {
if (openTabs.length === 0) return null;
return (
<div className="flex-shrink-0 flex items-center border-b border-[var(--border)]
bg-[var(--bg-secondary)]/60 backdrop-blur-sm overflow-x-auto hide-scrollbar">
{openTabs.map((t) => {
const active = t.node.id === activeId;
return (
<div
key={t.node.id}
className={`flex items-center gap-1.5 pl-3 pr-2 py-1.5 cursor-pointer
border-r border-[var(--border)] min-w-0 max-w-[200px]
transition-all group flex-shrink-0 ${
active
? "bg-[var(--bg-primary)] text-[var(--accent)] border-t-2 border-t-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--accent)]/5 border-t-2 border-t-transparent"
}`}
onClick={() => onActivate(t.node.id)}
onMouseDown={(e) => {
if (e.button === 1) {
e.preventDefault();
onClose(t.node.id);
}
}}
onContextMenu={(e) => {
e.preventDefault();
const action = window.prompt(
`"${t.node.name}"\n1=关闭此 Tab2=关闭其他3=关闭全部`,
"1"
);
if (action === "1") onClose(t.node.id);
else if (action === "2") onCloseOthers(t.node.id);
else if (action === "3") onCloseAll();
}}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-60 flex-shrink-0">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span className="text-xs truncate flex-1">{t.node.name}</span>
{t.dirty && !active && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)]/60 flex-shrink-0" title="未保存" />
)}
{t.dirty && active && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] flex-shrink-0" title="未保存" />
)}
<button
onClick={(e) => {
e.stopPropagation();
onClose(t.node.id);
}}
className="w-4 h-4 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--hot)] flex items-center justify-center flex-shrink-0
opacity-0 group-hover:opacity-100 transition-opacity"
title="关闭 Tab中键"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,464 @@
"use client";
import { useState, useMemo, useRef, useEffect } from "react";
import type { DocNode } from "@/lib/types";
import { useApp } from "@/lib/app-context";
interface DocTreeProps {
/** 当前选中的文档 ID用于高亮 */
activeId: string | null;
/** 当前打开的文档 ID 集合(显示 • 标识) */
openIds: Set<string>;
onOpen: (node: DocNode) => void;
onSearch: (query: string) => void;
searchQuery: string;
}
export function DocTree({ activeId, openIds, onOpen, onSearch, searchQuery }: DocTreeProps) {
const { docs, createDoc, renameDoc, deleteDoc, moveDoc } = useApp();
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(docs.filter((d) => d.type === "folder").map((d) => d.id))
);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; node: DocNode } | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameBuffer, setRenameBuffer] = useState("");
const [newNodeParent, setNewNodeParent] = useState<{ parentId: string | null; type: "folder" | "doc" } | null>(null);
const [newNodeName, setNewNodeName] = useState("");
const [dragOverId, setDragOverId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const newNodeInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renamingId && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [renamingId]);
useEffect(() => {
if (newNodeParent && newNodeInputRef.current) {
newNodeInputRef.current.focus();
}
}, [newNodeParent]);
useEffect(() => {
if (!contextMenu) return;
const handler = () => setContextMenu(null);
window.addEventListener("click", handler);
window.addEventListener("blur", handler);
return () => {
window.removeEventListener("click", handler);
window.removeEventListener("blur", handler);
};
}, [contextMenu]);
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const filtered = useMemo(() => {
if (!searchQuery.trim()) return docs;
const q = searchQuery.toLowerCase();
const matches = (node: DocNode): boolean => {
if (node.name.toLowerCase().includes(q)) return true;
if (node.type === "doc" && (node.content ?? "").toLowerCase().includes(q)) return true;
if (node.children) return node.children.some(matches);
return false;
};
const filter = (list: DocNode[]): DocNode[] => {
return list
.map((n) => {
if (n.type === "folder") {
const kids = filter(n.children ?? []);
if (kids.length > 0 || n.name.toLowerCase().includes(q)) {
return { ...n, children: kids };
}
return null;
}
return matches(n) ? n : null;
})
.filter(Boolean) as DocNode[];
};
return filter(docs);
}, [docs, searchQuery]);
const startRename = (node: DocNode) => {
setContextMenu(null);
setRenamingId(node.id);
setRenameBuffer(node.name);
};
const commitRename = () => {
if (renamingId && renameBuffer.trim()) {
renameDoc(renamingId, renameBuffer.trim());
}
setRenamingId(null);
setRenameBuffer("");
};
const startNewNode = (parentId: string | null, type: "folder" | "doc") => {
setContextMenu(null);
if (parentId) setExpanded((prev) => new Set(prev).add(parentId));
setNewNodeParent({ parentId, type });
setNewNodeName(type === "folder" ? "新文件夹" : "新文档.md");
};
const commitNewNode = () => {
if (newNodeParent && newNodeName.trim()) {
const created = createDoc(newNodeParent.parentId, newNodeParent.type, newNodeName.trim());
if (created.type === "doc") onOpen(created);
}
setNewNodeParent(null);
setNewNodeName("");
};
const renderNode = (node: DocNode, depth: number) => {
const isFolder = node.type === "folder";
const isExpanded = expanded.has(node.id);
const isActive = activeId === node.id;
const isOpen = openIds.has(node.id);
const isRenaming = renamingId === node.id;
const isDragOver = dragOverId === node.id;
return (
<div key={node.id}>
<div
draggable={!isRenaming}
onDragStart={(e) => {
e.dataTransfer.setData("application/x-docnode-id", node.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (isFolder) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverId(node.id);
}
}}
onDragLeave={() => {
if (dragOverId === node.id) setDragOverId(null);
}}
onDrop={(e) => {
e.preventDefault();
setDragOverId(null);
if (!isFolder) return;
const draggedId = e.dataTransfer.getData("application/x-docnode-id");
if (draggedId && draggedId !== node.id) {
moveDoc(draggedId, node.id);
}
}}
className={`group flex items-center gap-1 pr-1 py-1 rounded-md cursor-pointer
${isActive ? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"}
${isDragOver ? "ring-1 ring-[var(--accent)]/50 bg-[var(--accent)]/8" : ""}
transition-colors`}
style={{ paddingLeft: 6 + depth * 12 }}
onClick={() => {
if (isRenaming) return;
if (isFolder) toggleExpand(node.id);
else onOpen(node);
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, node });
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (!isRenaming) startRename(node);
}}
>
{isFolder ? (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className={`flex-shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}`}
>
<polyline points="9 18 15 12 9 6" />
</svg>
) : (
<span className="w-[10px]" />
)}
{isFolder ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0 opacity-70">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0 opacity-60">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)}
{isRenaming ? (
<input
ref={renameInputRef}
value={renameBuffer}
onChange={(e) => setRenameBuffer(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") {
setRenamingId(null);
setRenameBuffer("");
}
}}
onClick={(e) => e.stopPropagation()}
className="flex-1 text-xs px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)]/40
rounded outline-none focus:border-[var(--accent)]"
/>
) : (
<span className="text-xs truncate flex-1">{node.name}</span>
)}
{isOpen && !isActive && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)]/40 flex-shrink-0" title="已打开" />
)}
{isFolder && !isRenaming && (
<button
onClick={(e) => {
e.stopPropagation();
startNewNode(node.id, "doc");
}}
className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)]
hover:text-[var(--accent)] transition-opacity flex-shrink-0"
title="新建文档到此文件夹"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
)}
</div>
{/* 新节点输入 */}
{newNodeParent && newNodeParent.parentId === node.id && isFolder && (
<div
className="flex items-center gap-1 py-1 rounded-md bg-[var(--accent)]/5"
style={{ paddingLeft: 6 + (depth + 1) * 12 }}
>
<span className="w-[10px]" />
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0 opacity-60">
{newNodeParent.type === "folder" ? (
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
) : (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</>
)}
</svg>
<input
ref={newNodeInputRef}
value={newNodeName}
onChange={(e) => setNewNodeName(e.target.value)}
onBlur={commitNewNode}
onKeyDown={(e) => {
if (e.key === "Enter") commitNewNode();
if (e.key === "Escape") {
setNewNodeParent(null);
setNewNodeName("");
}
}}
className="flex-1 text-xs px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)]/40
rounded outline-none focus:border-[var(--accent)] mr-1"
/>
</div>
)}
{/* 子节点 */}
{isFolder && isExpanded && node.children && node.children.length > 0 && (
<div>{node.children.map((c) => renderNode(c, depth + 1))}</div>
)}
</div>
);
};
return (
<div className="h-full flex flex-col">
{/* 头部 + 新建 */}
<div className="flex-shrink-0 px-3 pt-3 pb-2 flex items-center gap-2 border-b border-[var(--border)]">
<span className="typo-strong text-xs flex-1"></span>
<button
onClick={() => startNewNode(null, "doc")}
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5 cursor-pointer transition-colors"
title="新建文档"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="13" x2="12" y2="17" />
<line x1="10" y1="15" x2="14" y2="15" />
</svg>
</button>
<button
onClick={() => startNewNode(null, "folder")}
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5 cursor-pointer transition-colors"
title="新建文件夹"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
</button>
</div>
{/* 搜索 */}
<div className="flex-shrink-0 px-3 py-2 border-b border-[var(--border)]">
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[var(--bg-primary)]
border border-[var(--border)] focus-within:border-[var(--accent)]/40 transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
value={searchQuery}
onChange={(e) => onSearch(e.target.value)}
placeholder="搜索文档(名称/内容)"
className="flex-1 text-xs bg-transparent border-none outline-none
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/60"
/>
{searchQuery && (
<button
onClick={() => onSearch("")}
className="text-[var(--text-secondary)] hover:text-[var(--hot)] cursor-pointer"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{/* 树 */}
<div
className="flex-1 overflow-y-auto py-1"
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}}
onDrop={(e) => {
e.preventDefault();
const draggedId = e.dataTransfer.getData("application/x-docnode-id");
if (draggedId) moveDoc(draggedId, null);
}}
>
{/* 根级新节点输入 */}
{newNodeParent && newNodeParent.parentId === null && (
<div className="flex items-center gap-1 py-1 rounded-md bg-[var(--accent)]/5 mx-1"
style={{ paddingLeft: 6 }}>
<span className="w-[10px]" />
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0 opacity-60">
{newNodeParent.type === "folder" ? (
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
) : (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</>
)}
</svg>
<input
ref={newNodeInputRef}
value={newNodeName}
onChange={(e) => setNewNodeName(e.target.value)}
onBlur={commitNewNode}
onKeyDown={(e) => {
if (e.key === "Enter") commitNewNode();
if (e.key === "Escape") {
setNewNodeParent(null);
setNewNodeName("");
}
}}
className="flex-1 text-xs px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)]/40
rounded outline-none focus:border-[var(--accent)] mr-1"
/>
</div>
)}
{filtered.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-[var(--text-secondary)]">
{searchQuery ? "没有匹配的文档" : "还没有文档"}
</div>
) : (
<div className="px-1">{filtered.map((n) => renderNode(n, 0))}</div>
)}
</div>
{/* 右键菜单 */}
{contextMenu && (
<div
className="fixed z-50 rounded-lg surface-3 py-1 min-w-[160px] shadow-xl"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
{contextMenu.node.type === "folder" && (
<>
<ContextItem onClick={() => startNewNode(contextMenu.node.id, "doc")}>
</ContextItem>
<ContextItem onClick={() => startNewNode(contextMenu.node.id, "folder")}>
</ContextItem>
<Sep />
</>
)}
<ContextItem onClick={() => startRename(contextMenu.node)}></ContextItem>
<ContextItem
danger
onClick={() => {
if (confirm(`确定删除"${contextMenu.node.name}"`)) {
deleteDoc(contextMenu.node.id);
}
setContextMenu(null);
}}
>
</ContextItem>
</div>
)}
</div>
);
}
function ContextItem({
children,
onClick,
danger,
}: {
children: React.ReactNode;
onClick: () => void;
danger?: boolean;
}) {
return (
<button
onClick={onClick}
className={`w-full text-left px-3 py-1.5 text-xs cursor-pointer transition-colors ${
danger
? "text-[var(--hot)]/70 hover:text-[var(--hot)] hover:bg-[var(--hot)]/5"
: "text-[var(--text-primary)] hover:bg-[var(--accent)]/5 hover:text-[var(--accent)]"
}`}
>
{children}
</button>
);
}
function Sep() {
return <div className="border-t border-[var(--border)] my-1 mx-2" />;
}

View File

@@ -0,0 +1,240 @@
"use client";
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useApp } from "@/lib/app-context";
import { getImageUrl } from "@/lib/api";
import type { ImageAsset, CharacterCard, StylePack } from "@/lib/types";
interface MarkdownContentProps {
content: string;
}
/**
* 统一的 Markdown 渲染组件。
*
* 扩展语法:`[[asset:<id>]]` 会被替换为对应的资产卡片(图片/角色/风格)。
* 无法解析的引用会降级为纯文本。
*/
export function MarkdownContent({ content }: MarkdownContentProps) {
const { assets, characters, stylePacks } = useApp();
// 将 [[asset:xxx]] 替换为特殊占位符,渲染后再替换为 React 组件
const { processed, assetBlocks } = useMemo(() => {
const blocks: Array<{ placeholder: string; node: React.ReactNode }> = [];
let idx = 0;
const processed = content.replace(/\[\[asset:([^\]]+)\]\]/g, (_, id) => {
const placeholder = `%%ASSET_BLOCK_${idx}%%`;
idx++;
const img = assets.find((a) => a.id === id);
const char = characters.find((c) => c.id === id);
const style = stylePacks.find((s) => s.id === id);
blocks.push({
placeholder,
node: renderAssetCard({ id, img, char, style }),
});
return `\n\n${placeholder}\n\n`;
});
return { processed, assetBlocks: blocks };
}, [content, assets, characters, stylePacks]);
// 递归替换文本节点中的占位符
const renderWithAssets = (text: string): React.ReactNode => {
if (assetBlocks.length === 0) return text;
const parts: React.ReactNode[] = [];
let rest = text;
let idx = 0;
while (rest.length > 0) {
let found: { block: (typeof assetBlocks)[number]; at: number } | null = null;
for (const block of assetBlocks) {
const at = rest.indexOf(block.placeholder);
if (at >= 0 && (!found || at < found.at)) found = { block, at };
}
if (!found) {
if (rest) parts.push(<span key={idx++}>{rest}</span>);
break;
}
if (found.at > 0) parts.push(<span key={idx++}>{rest.slice(0, found.at)}</span>);
parts.push(<span key={idx++}>{found.block.node}</span>);
rest = rest.slice(found.at + found.block.placeholder.length);
}
return <>{parts}</>;
};
return (
<div className="markdown-body prose prose-sm max-w-none
text-[var(--text-primary)] leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => {
if (typeof children === "string" && children.includes("%%ASSET_BLOCK_")) {
return <div className="my-3">{renderWithAssets(children)}</div>;
}
if (Array.isArray(children) && children.some((c) => typeof c === "string" && c.includes("%%ASSET_BLOCK_"))) {
return (
<div className="my-3">
{children.map((c, i) =>
typeof c === "string" && c.includes("%%ASSET_BLOCK_") ? (
<span key={i}>{renderWithAssets(c)}</span>
) : (
<span key={i}>{c}</span>
)
)}
</div>
);
}
return <p className="my-2.5 text-sm leading-[1.7]">{children}</p>;
},
h1: ({ children }) => (
<h1 className="text-xl font-bold mt-6 mb-3 pb-2 border-b border-[var(--border)]">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="typo-h2 mt-5 mb-2.5">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-sm font-semibold text-[var(--accent)] mt-4 mb-2">
{children}
</h3>
),
ul: ({ children }) => <ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>,
li: ({ children }) => <li className="text-sm leading-[1.7]">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="my-3 pl-3 border-l-2 border-[var(--accent)]/50
text-[var(--text-secondary)] italic">
{children}
</blockquote>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
if (isBlock) {
return (
<code className="block p-3 my-2 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border)]
text-xs font-mono overflow-x-auto whitespace-pre text-[var(--text-primary)]">
{children}
</code>
);
}
return (
<code className="px-1.5 py-0.5 rounded text-xs font-mono
bg-[var(--bg-tertiary)] text-[var(--accent)]
border border-[var(--border)]">
{children}
</code>
);
},
pre: ({ children }) => <>{children}</>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent)] underline decoration-[var(--accent)]/40 hover:decoration-[var(--accent)]"
>
{children}
</a>
),
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table className="w-full border-collapse text-xs">{children}</table>
</div>
),
th: ({ children }) => (
<th className="px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border)]
text-left font-semibold text-[var(--accent)]">
{children}
</th>
),
td: ({ children }) => (
<td className="px-3 py-2 border border-[var(--border)]">{children}</td>
),
hr: () => <hr className="my-4 border-[var(--border)]" />,
strong: ({ children }) => (
<strong className="font-semibold text-[var(--accent)]">{children}</strong>
),
}}
>
{processed}
</ReactMarkdown>
</div>
);
}
function renderAssetCard({
id,
img,
char,
style,
}: {
id: string;
img?: ImageAsset;
char?: CharacterCard;
style?: StylePack;
}): React.ReactNode {
if (img) {
return (
<span className="not-prose inline-flex items-center gap-2 my-2 px-2.5 py-1.5 rounded-lg
border border-[var(--accent)]/20 bg-[var(--accent)]/5
text-xs max-w-full">
<img
src={getImageUrl(img.url)}
alt=""
className="w-10 h-10 rounded-md object-cover border border-[var(--border)] flex-shrink-0"
/>
<span className="flex flex-col min-w-0">
<span className="text-[var(--accent)] font-medium text-[10px]"></span>
<span className="truncate text-[var(--text-primary)]">
{img.prompt.slice(0, 40) || "(无 prompt"}
</span>
</span>
</span>
);
}
if (char) {
return (
<span className="not-prose inline-flex items-center gap-2 my-2 px-2.5 py-1.5 rounded-lg
border border-[var(--accent)]/25 bg-[var(--accent)]/5
text-xs max-w-full">
<span className="w-8 h-8 rounded-full bg-[var(--bg-tertiary)] border border-[var(--border)]
flex-shrink-0 flex items-center justify-center text-[var(--accent)] font-semibold">
{char.name.charAt(0)}
</span>
<span className="flex flex-col min-w-0">
<span className="text-[var(--accent)] font-medium text-[10px]"></span>
<span className="truncate text-[var(--text-primary)]">{char.name}</span>
<span className="truncate text-[10px] text-[var(--text-secondary)]">
{char.visualHooks.slice(0, 3).join(" · ")}
</span>
</span>
</span>
);
}
if (style) {
return (
<span className="not-prose inline-flex items-center gap-2 my-2 px-2.5 py-1.5 rounded-lg
border border-[var(--accent-secondary)]/30 bg-[var(--accent-secondary)]/5
text-xs max-w-full">
<span className="w-8 h-8 rounded-md bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]
opacity-60 flex-shrink-0" />
<span className="flex flex-col min-w-0">
<span className="text-[var(--accent-secondary)] font-medium text-[10px]"></span>
<span className="truncate text-[var(--text-primary)]">{style.name}</span>
<span className="truncate text-[10px] text-[var(--text-secondary)]">
{style.keywords.slice(0, 3).join(" · ")}
</span>
</span>
</span>
);
}
return (
<span className="not-prose inline-block my-1 px-2 py-0.5 rounded
border border-[var(--hot)]/30 bg-[var(--hot)]/5
text-[10px] text-[var(--hot)] font-mono">
[[asset:{id}]]
</span>
);
}

View File

@@ -21,6 +21,19 @@ const NAV_ITEMS = [
</svg>
),
},
{
href: "/docs",
label: "文档",
phase: "Phase 1",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="13" y2="17" />
</svg>
),
},
{
href: "/gallery",
label: "资源库",

View File

@@ -45,7 +45,7 @@ export const assistantEnter: Variants = {
export const buttonTap = { scale: 0.85 };
export const sendBounce = {
scale: [1, 0.85, 1.08, 1],
transition: { duration: 0.35, ease: "easeInOut" },
transition: { duration: 0.35, ease: "easeInOut" as const },
};
/* ===== 涟漪 ===== */

View File

@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import type { Params3D } from "@/lib/types";
const PIPELINE_OPTIONS: { id: Params3D["pipeline"]; label: string; desc: string }[] = [
{ id: "trellis", label: "Trellis", desc: "稳定细节,中速" },
{ id: "hunyuan3d", label: "Hunyuan 3D", desc: "适合复杂几何" },
{ id: "zero123plus", label: "Zero123++", desc: "快速低保真" },
];
const RESOLUTION_OPTIONS = ["512×512", "768×768", "1024×1024"];
/**
* 3D 模型生成参数条Phase 2 占位)
*
* 对应 Mesh Pipeline 的 Stage 1多视图渲染参数
* - 管道选择trellis / hunyuan3d / zero123plus
* - 视图数4 / 6 / 8 / 12
* - 方位角预设(等距 / 自定义)
* - 是否保持原图风格IP-Adapter 色彩还原)
* - 分辨率
*/
export function Params3D() {
const [pipeline, setPipeline] = useState<Params3D["pipeline"]>("trellis");
const [viewCount, setViewCount] = useState(6);
const [preserveStyle, setPreserveStyle] = useState(true);
const [resolution, setResolution] = useState("768×768");
const [pipelineOpen, setPipelineOpen] = useState(false);
return (
<div className="relative px-3 md:px-5 py-1 flex items-center gap-1.5 flex-wrap
border-t border-[var(--border)]/40 bg-[var(--accent)]/[0.02]">
<span className="typo-micro text-[10px]" style={{ textTransform: "none" }}>
3D
</span>
<span className="phase-chip">P2</span>
{/* 管道 */}
<div className="relative">
<button
onClick={() => setPipelineOpen(!pipelineOpen)}
className="h-6 px-2 rounded-md text-[11px] flex items-center gap-1
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
title="生成管道"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
</svg>
{PIPELINE_OPTIONS.find((p) => p.id === pipeline)?.label}
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{pipelineOpen && (
<>
<div className="fixed inset-0 z-30" onClick={() => setPipelineOpen(false)} />
<div className="absolute bottom-full mb-1 left-0 z-40 min-w-[200px]
surface-3 rounded-lg overflow-hidden shadow-xl">
{PIPELINE_OPTIONS.map((p) => (
<button
key={p.id}
onClick={() => {
setPipeline(p.id);
setPipelineOpen(false);
}}
className={`w-full px-3 py-2 text-left text-xs cursor-pointer transition-colors
${pipeline === p.id ? "bg-[var(--accent)]/10 text-[var(--accent)]" : "hover:bg-[var(--accent)]/5"}`}
>
<div className="font-medium">{p.label}</div>
<div className="text-[10px] text-[var(--text-secondary)]">{p.desc}</div>
</button>
))}
</div>
</>
)}
</div>
{/* 视图数 */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md bg-[var(--bg-tertiary)] border border-[var(--border)]">
<span className="px-1.5 text-[10px] text-[var(--text-secondary)]"></span>
{[4, 6, 8, 12].map((n) => (
<button
key={n}
onClick={() => setViewCount(n)}
className={`h-5 px-1.5 rounded text-[10px] cursor-pointer transition-all font-mono ${
viewCount === n
? "bg-[var(--accent)]/12 text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{n}
</button>
))}
</div>
{/* 保持风格 */}
<button
onClick={() => setPreserveStyle(!preserveStyle)}
className={`h-6 px-2 rounded-md text-[11px] flex items-center gap-1 cursor-pointer transition-all ${
preserveStyle
? "border border-[var(--accent)]/30 bg-[var(--accent)]/8 text-[var(--accent)]"
: "border border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
title="IP-Adapter 风格还原,保留原立绘色彩"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={preserveStyle ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<circle cx="13.5" cy="6.5" r="0.5" />
<circle cx="17.5" cy="10.5" r="0.5" />
<circle cx="8.5" cy="7.5" r="0.5" />
<circle cx="6.5" cy="12.5" r="0.5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.8 0 1.5-.7 1.5-1.5 0-.4-.2-.8-.4-1.1-.2-.3-.4-.7-.4-1.1 0-.8.7-1.5 1.5-1.5H16c3.3 0 6-2.7 6-6 0-5-4.5-9-10-9z" />
</svg>
</button>
{/* 分辨率 */}
<select
value={resolution}
onChange={(e) => setResolution(e.target.value)}
className="h-6 px-1.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer"
>
{RESOLUTION_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<span className="ml-auto text-[10px] text-[var(--text-secondary)] hidden md:inline">
Mesh Pipeline · 线 GLB
</span>
</div>
);
}

View File

@@ -0,0 +1,200 @@
"use client";
import { useState } from "react";
import { useApp } from "@/lib/app-context";
import type { ParamsMotion } from "@/lib/types";
const MODEL_OPTIONS: { id: ParamsMotion["model"]; label: string; desc: string }[] = [
{ id: "animatediff", label: "AnimateDiff", desc: "动作幅度大" },
{ id: "svd", label: "Stable Video", desc: "物理动态佳" },
{ id: "motion-lora", label: "Motion LoRA", desc: "绑定角色 LoRA" },
];
const MOTION_PRESETS = [
"待机idle",
"行走walk",
"挥剑slash",
"跳跃jump",
"施法cast",
];
/**
* 动作/动画生成参数条Phase 2 占位)
*
* - 模型选择animatediff / svd / motion-lora
* - 帧数8~32
* - 帧率8 / 12 / 24 fps
* - 是否循环
* - 输出格式mp4 / gif / frames
* - 绑定角色
* - 动作描述关键词(自然语言或预设)
*/
export function ParamsMotionBar() {
const { characters } = useApp();
const [model, setModel] = useState<ParamsMotion["model"]>("animatediff");
const [frameCount, setFrameCount] = useState(16);
const [fps, setFps] = useState(12);
const [loop, setLoop] = useState(true);
const [output, setOutput] = useState<ParamsMotion["output"]>("mp4");
const [characterId, setCharacterId] = useState<string>("");
const [motionHint, setMotionHint] = useState<string>("");
const [modelOpen, setModelOpen] = useState(false);
const duration = (frameCount / fps).toFixed(2);
return (
<div className="relative px-3 md:px-5 py-1 flex items-center gap-1.5 flex-wrap
border-t border-[var(--border)]/40 bg-[var(--accent-secondary)]/[0.03]">
<span className="typo-micro text-[10px]" style={{ textTransform: "none" }}>
</span>
<span className="phase-chip">P2</span>
{/* 模型 */}
<div className="relative">
<button
onClick={() => setModelOpen(!modelOpen)}
className="h-6 px-2 rounded-md text-[11px] flex items-center gap-1
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
title="动作生成模型"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
{MODEL_OPTIONS.find((m) => m.id === model)?.label}
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{modelOpen && (
<>
<div className="fixed inset-0 z-30" onClick={() => setModelOpen(false)} />
<div className="absolute bottom-full mb-1 left-0 z-40 min-w-[200px]
surface-3 rounded-lg overflow-hidden shadow-xl">
{MODEL_OPTIONS.map((m) => (
<button
key={m.id}
onClick={() => {
setModel(m.id);
setModelOpen(false);
}}
className={`w-full px-3 py-2 text-left text-xs cursor-pointer transition-colors
${model === m.id ? "bg-[var(--accent)]/10 text-[var(--accent)]" : "hover:bg-[var(--accent)]/5"}`}
>
<div className="font-medium">{m.label}</div>
<div className="text-[10px] text-[var(--text-secondary)]">{m.desc}</div>
</button>
))}
</div>
</>
)}
</div>
{/* 帧数 */}
<label className="flex items-center gap-1 h-6 px-2 rounded-md bg-[var(--bg-tertiary)]
border border-[var(--border)] text-[11px]">
<span className="text-[var(--text-secondary)]"></span>
<input
type="number"
min={4}
max={64}
value={frameCount}
onChange={(e) => setFrameCount(Math.max(4, Math.min(64, parseInt(e.target.value) || 16)))}
className="w-10 bg-transparent border-none outline-none text-center font-mono text-[var(--text-primary)]"
/>
</label>
{/* 帧率 */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md bg-[var(--bg-tertiary)] border border-[var(--border)]">
<span className="px-1.5 text-[10px] text-[var(--text-secondary)]">fps</span>
{[8, 12, 24].map((f) => (
<button
key={f}
onClick={() => setFps(f)}
className={`h-5 px-1.5 rounded text-[10px] cursor-pointer transition-all font-mono ${
fps === f
? "bg-[var(--accent)]/12 text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{f}
</button>
))}
</div>
{/* 时长显示 */}
<span className="text-[10px] text-[var(--text-secondary)] font-mono">
{duration}s
</span>
{/* 循环 */}
<button
onClick={() => setLoop(!loop)}
className={`h-6 px-2 rounded-md text-[11px] flex items-center gap-1 cursor-pointer transition-all ${
loop
? "border border-[var(--accent)]/30 bg-[var(--accent)]/8 text-[var(--accent)]"
: "border border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
title="循环播放"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
</button>
{/* 输出格式 */}
<select
value={output}
onChange={(e) => setOutput(e.target.value as ParamsMotion["output"])}
className="h-6 px-1.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer font-mono"
>
<option value="mp4">MP4</option>
<option value="gif">GIF</option>
<option value="frames"></option>
</select>
{/* 绑定角色 */}
{characters.length > 0 && (
<select
value={characterId}
onChange={(e) => setCharacterId(e.target.value)}
className="h-6 px-1.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer max-w-[120px]"
title="绑定角色(用于保持人物一致性)"
>
<option value=""></option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
{/* 动作提示(自然语言) */}
<div className="flex items-center gap-1 flex-1 min-w-[140px] max-w-[240px]">
<input
list="motion-presets"
value={motionHint}
onChange={(e) => setMotionHint(e.target.value)}
placeholder="动作关键词(可选)"
className="flex-1 h-6 px-2 text-[11px] rounded-md bg-[var(--bg-tertiary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
/>
<datalist id="motion-presets">
{MOTION_PRESETS.map((p) => (
<option key={p} value={p} />
))}
</datalist>
</div>
</div>
);
}

View File

@@ -18,6 +18,10 @@ import type {
StylePack,
CharacterCard,
TrainingTask,
DocNode,
Asset3D,
AssetMotion,
TaskType,
} from "./types";
import {
loadSessions,
@@ -51,6 +55,14 @@ import {
loadTrainingTasks,
upsertTrainingTask as storeUpsertTraining,
deleteTrainingTask as storeDeleteTraining,
loadDocs,
saveDocs,
loadAssets3D,
upsertAsset3D as storeUpsertAsset3D,
deleteAsset3D as storeDeleteAsset3D,
loadAssetsMotion,
upsertAssetMotion as storeUpsertAssetMotion,
deleteAssetMotion as storeDeleteAssetMotion,
} from "./placeholder-store";
import { useAuth } from "./auth-context";
@@ -117,6 +129,32 @@ interface AppContextValue {
/** 当前会话选择的资产规格(占位) */
sessionAssetSpec: string | null;
setSessionAssetSpec: (spec: string | null) => void;
// ===== 文档管理中心Phase 1 =====
docs: DocNode[];
upsertDoc: (path: string[], node: DocNode) => void;
renameDoc: (nodeId: string, newName: string) => void;
deleteDoc: (nodeId: string) => void;
moveDoc: (nodeId: string, newParentId: string | null) => void;
updateDocContent: (nodeId: string, content: string) => void;
createDoc: (parentId: string | null, type: "folder" | "doc", name: string) => DocNode;
// ===== 3D / 动作资产Phase 2 =====
assets3D: Asset3D[];
upsertAsset3D: (a: Asset3D) => void;
deleteAsset3D: (id: string) => void;
assetsMotion: AssetMotion[];
upsertAssetMotion: (a: AssetMotion) => void;
deleteAssetMotion: (id: string) => void;
// ===== 工作台任务类型2D / 3D / 动作) =====
sessionTaskType: TaskType;
setSessionTaskType: (t: TaskType) => void;
// ===== 全站命令面板 =====
commandPaletteOpen: boolean;
setCommandPaletteOpen: (v: boolean) => void;
}
const AppContext = createContext<AppContextValue | null>(null);
@@ -151,6 +189,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [sessionStyleId, setSessionStyleId] = useState<string | null>(null);
const [sessionCharacterId, setSessionCharacterId] = useState<string | null>(null);
const [sessionAssetSpec, setSessionAssetSpec] = useState<string | null>(null);
const [docs, setDocs] = useState<DocNode[]>([]);
const [assets3D, setAssets3D] = useState<Asset3D[]>([]);
const [assetsMotion, setAssetsMotion] = useState<AssetMotion[]>([]);
const [sessionTaskType, setSessionTaskType] = useState<TaskType>("image2d");
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
// 当用户变化时,切换 store 的 userId 并重新加载数据
useEffect(() => {
@@ -167,11 +210,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
setStylePacks(loadStylePacks());
setCharacters(loadCharacters());
setTrainingTasks(loadTrainingTasks());
setDocs(loadDocs());
setAssets3D(loadAssets3D());
setAssetsMotion(loadAssetsMotion());
setActiveSessionId(null);
setDetailImage(null);
setSessionStyleId(null);
setSessionCharacterId(null);
setSessionAssetSpec(null);
setSessionTaskType("image2d");
setInitialized(true);
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -393,6 +440,169 @@ export function AppProvider({ children }: { children: ReactNode }) {
setTrainingTasks(loadTrainingTasks());
}, []);
// ===== 文档操作Phase 1纯 localStorage 占位) =====
/** 在树中递归查找节点的父列表引用 */
const findNodeParentList = useCallback(
(tree: DocNode[], nodeId: string): { list: DocNode[]; idx: number } | null => {
for (let i = 0; i < tree.length; i++) {
if (tree[i].id === nodeId) return { list: tree, idx: i };
if (tree[i].children && tree[i].children!.length > 0) {
const r = findNodeParentList(tree[i].children!, nodeId);
if (r) return r;
}
}
return null;
},
[]
);
const createDoc = useCallback(
(parentId: string | null, type: "folder" | "doc", name: string): DocNode => {
const now = Date.now();
const node: DocNode = {
id: generateId(type === "folder" ? "doc-folder-" : "doc-"),
type,
name,
content: type === "doc" ? `# ${name.replace(/\.md$/, "")}\n\n` : undefined,
children: type === "folder" ? [] : undefined,
projectId: activeProjectId || undefined,
order: 0,
createdAt: now,
updatedAt: now,
};
setDocs((prev) => {
const next = structuredClone(prev) as DocNode[];
if (parentId === null) {
next.unshift(node);
} else {
const found = findNodeParentList(next, parentId);
if (found) {
const parent = found.list[found.idx];
if (parent.type === "folder") {
parent.children = parent.children ?? [];
parent.children.unshift(node);
}
}
}
saveDocs(next);
return next;
});
return node;
},
[activeProjectId, findNodeParentList]
);
const renameDoc = useCallback(
(nodeId: string, newName: string) => {
setDocs((prev) => {
const next = structuredClone(prev) as DocNode[];
const found = findNodeParentList(next, nodeId);
if (found) {
found.list[found.idx].name = newName;
found.list[found.idx].updatedAt = Date.now();
}
saveDocs(next);
return next;
});
},
[findNodeParentList]
);
const deleteDoc = useCallback(
(nodeId: string) => {
setDocs((prev) => {
const next = structuredClone(prev) as DocNode[];
const found = findNodeParentList(next, nodeId);
if (found) found.list.splice(found.idx, 1);
saveDocs(next);
return next;
});
},
[findNodeParentList]
);
const moveDoc = useCallback(
(nodeId: string, newParentId: string | null) => {
setDocs((prev) => {
const next = structuredClone(prev) as DocNode[];
const src = findNodeParentList(next, nodeId);
if (!src) return prev;
const [node] = src.list.splice(src.idx, 1);
if (newParentId === null) {
next.unshift(node);
} else {
const dst = findNodeParentList(next, newParentId);
if (dst && dst.list[dst.idx].type === "folder") {
const parent = dst.list[dst.idx];
parent.children = parent.children ?? [];
parent.children.unshift(node);
} else {
next.unshift(node);
}
}
saveDocs(next);
return next;
});
},
[findNodeParentList]
);
const updateDocContent = useCallback(
(nodeId: string, content: string) => {
setDocs((prev) => {
const next = structuredClone(prev) as DocNode[];
const found = findNodeParentList(next, nodeId);
if (found && found.list[found.idx].type === "doc") {
found.list[found.idx].content = content;
found.list[found.idx].updatedAt = Date.now();
}
saveDocs(next);
return next;
});
},
[findNodeParentList]
);
const upsertDocCb = useCallback(
(_path: string[], node: DocNode) => {
setDocs((prev) => {
const next = structuredClone(prev) as DocNode[];
const found = findNodeParentList(next, node.id);
if (found) {
found.list[found.idx] = { ...node, updatedAt: Date.now() };
} else {
next.unshift({ ...node, updatedAt: Date.now() });
}
saveDocs(next);
return next;
});
},
[findNodeParentList]
);
// ===== 3D / 动作资产操作 =====
const upsertAsset3DCb = useCallback((a: Asset3D) => {
storeUpsertAsset3D(a);
setAssets3D(loadAssets3D());
}, []);
const deleteAsset3DCb = useCallback((id: string) => {
storeDeleteAsset3D(id);
setAssets3D(loadAssets3D());
}, []);
const upsertAssetMotionCb = useCallback((a: AssetMotion) => {
storeUpsertAssetMotion(a);
setAssetsMotion(loadAssetsMotion());
}, []);
const deleteAssetMotionCb = useCallback((id: string) => {
storeDeleteAssetMotion(id);
setAssetsMotion(loadAssetsMotion());
}, []);
const value = useMemo<AppContextValue>(
() => ({
sessions,
@@ -440,6 +650,23 @@ export function AppProvider({ children }: { children: ReactNode }) {
setSessionCharacterId,
sessionAssetSpec,
setSessionAssetSpec,
docs,
upsertDoc: upsertDocCb,
renameDoc,
deleteDoc,
moveDoc,
updateDocContent,
createDoc,
assets3D,
upsertAsset3D: upsertAsset3DCb,
deleteAsset3D: deleteAsset3DCb,
assetsMotion,
upsertAssetMotion: upsertAssetMotionCb,
deleteAssetMotion: deleteAssetMotionCb,
sessionTaskType,
setSessionTaskType,
commandPaletteOpen,
setCommandPaletteOpen,
}),
[
sessions, activeSessionId, activeSession,
@@ -453,6 +680,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
characters, upsertCharacterCb, deleteCharacterCb,
trainingTasks, upsertTrainingTaskCb, deleteTrainingTaskCb,
sessionStyleId, sessionCharacterId, sessionAssetSpec,
docs, upsertDocCb, renameDoc, deleteDoc, moveDoc, updateDocContent, createDoc,
assets3D, upsertAsset3DCb, deleteAsset3DCb,
assetsMotion, upsertAssetMotionCb, deleteAssetMotionCb,
sessionTaskType, commandPaletteOpen,
]
);

View File

@@ -11,6 +11,9 @@ import type {
StylePack,
CharacterCard,
TrainingTask,
DocNode,
Asset3D,
AssetMotion,
} from "./types";
import { getStoreUserId, generateId } from "./store";
@@ -345,3 +348,349 @@ export function upsertTrainingTask(t: TrainingTask) {
export function deleteTrainingTask(id: string) {
saveTrainingTasks(loadTrainingTasks().filter((x) => x.id !== id));
}
// ==================== 文档管理中心(占位文档树) ====================
const SAMPLE_DOC_WORLD = `# 大陆总览
这是一个占位世界观文档,用于展示文档中心的 Markdown 渲染能力。
## 地理分布
- **北境** — 终年积雪,古老贵族的封地
- **中原** — 商贸与战争的交汇之地
- **南境** — 湿热雨林,神秘原住部族
> "山川形便,人事因之;既有江河之险,亦有云雾之幽。"
## 关键王国
1. 艾萨王国(中原北部)
2. 赫利俄斯共和国(中原西侧)
3. 苍岚部族联盟(北境)
\`\`\`
示例代码块:
world.scale = 1024
world.season = "spring"
\`\`\`
## 关联资产
当你在项目里建立了角色/风格后,可以通过右侧面板或 \`@\` 键快捷引用:
[[asset:char-elica-swordsman]]
---
*文档编辑器处于占位阶段,保存仅写入本地存储。*
`;
const SAMPLE_DOC_CHAR = `# 剑士·艾丽卡
[[asset:char-elica-swordsman]]
## 核心特征
- 银发紫瞳
- 红披风
- 双剑(左手短剑 + 右手长剑)
## 性格
**冷静**、果断、寡言。战斗中几乎不出声,只在谜题被揭破时轻笑一下。
## 背景故事(占位)
出身于北境贵族家庭,因家族变故而踏上旅途。她的双剑曾是母亲的遗物,现在由她继承并重新锻造。
## 出场台词
> "再叙旧怨,不如先把眼前的麻烦收拾干净。"
## 立绘要求
- **一致性级别**:高
- **必须保留特征**:银发、紫瞳、红披风、双剑
- **允许变化**:服饰细节、表情、动作
`;
const SAMPLE_DOC_PROMPT = `# 人物立绘 · 通用 Prompt 模板
## 正向 Prompt骨架
\`\`\`
masterpiece, best quality, highly detailed,
{character_description},
{action_pose},
{lighting},
{background}
\`\`\`
## 常用片段
### Lighting
- \`cinematic lighting, rim light, soft key light\`
- \`god rays, volumetric lighting, dust particles\`
### Background
- \`simple background, solid color\`
- \`ancient forest, mist, moss-covered ruins\`
## 负向 Prompt
\`\`\`
lowres, blurry, extra limbs, extra fingers,
deformed anatomy, watermark, signature, jpeg artifacts
\`\`\`
---
模板可在 Prompt 快选中直接应用Phase 2
`;
const SAMPLE_DOC_SPEC = `# 项目输出规格
| 资产类型 | 分辨率 | 背景 | 其他 |
|---|---|---|---|
| Icon | 512×512 | 透明 | PNG |
| 角色立绘(全身) | 1024×1536 | 透明可选 | PNG |
| 场景 | 1920×1080 | 纯色/环境 | JPG/PNG |
| UI 元素 | 按需 | 透明 | PNG@1x/@2x/@3x |
## 命名规范
\`{项目}_{资产类型}_{版本}_{序号}.{ext}\`
示例:\`epeekit_icon_v2_01.png\`
## 交付要求
- 命名遵循规范
- 保留图层源文件psd/kra可选
- 元数据prompt/style/model随资产入库
`;
export function loadDocs(): DocNode[] {
const list = readJSON<DocNode[]>(storageKey("docs"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: DocNode[] = [
{
id: "doc-folder-world",
type: "folder",
name: "世界观",
projectId: DEFAULT_PROJECT_ID,
order: 0,
createdAt: now,
updatedAt: now,
children: [
{
id: "doc-world-overview",
type: "doc",
name: "大陆总览.md",
content: SAMPLE_DOC_WORLD,
projectId: DEFAULT_PROJECT_ID,
order: 0,
createdAt: now,
updatedAt: now,
},
{
id: "doc-world-timeline",
type: "doc",
name: "历史纪年.md",
content: "# 历史纪年\n\n占位文档等待撰写\n\n- 创世纪 0 年\n- 大分裂 412 年\n- 当前 1147 年",
projectId: DEFAULT_PROJECT_ID,
order: 1,
createdAt: now,
updatedAt: now,
},
],
},
{
id: "doc-folder-chars",
type: "folder",
name: "角色档案",
projectId: DEFAULT_PROJECT_ID,
order: 1,
createdAt: now,
updatedAt: now,
children: [
{
id: "doc-char-elica",
type: "doc",
name: "剑士·艾丽卡.md",
content: SAMPLE_DOC_CHAR,
projectId: DEFAULT_PROJECT_ID,
order: 0,
createdAt: now,
updatedAt: now,
},
],
},
{
id: "doc-folder-prompts",
type: "folder",
name: "Prompt 库",
projectId: DEFAULT_PROJECT_ID,
order: 2,
createdAt: now,
updatedAt: now,
children: [
{
id: "doc-prompt-char",
type: "doc",
name: "人物立绘通用.md",
content: SAMPLE_DOC_PROMPT,
projectId: DEFAULT_PROJECT_ID,
order: 0,
createdAt: now,
updatedAt: now,
},
],
},
{
id: "doc-spec",
type: "doc",
name: "输出规格.md",
content: SAMPLE_DOC_SPEC,
projectId: DEFAULT_PROJECT_ID,
order: 3,
createdAt: now,
updatedAt: now,
},
];
writeJSON(storageKey("docs"), defaults);
return defaults;
}
return list;
}
export function saveDocs(list: DocNode[]) {
writeJSON(storageKey("docs"), list);
}
// ==================== 3D 资产(占位) ====================
export function loadAssets3D(): Asset3D[] {
const list = readJSON<Asset3D[]>(storageKey("assets-3d"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: Asset3D[] = [
{
id: "3d-sample-1",
thumbnail: "",
views: [0, 60, 120, 180, 240, 300].map((az) => ({
url: "",
azimuth: az,
elevation: 0,
})),
meshUrl: "placeholder.glb",
videoUrl: "",
prompt: "占位3D 角色立绘银发剑士卡通风格6 视角",
pipeline: "trellis",
sessionId: undefined,
tags: [],
favorited: false,
createdAt: now - 3600000,
},
{
id: "3d-sample-2",
thumbnail: "",
views: [0, 90, 180, 270].map((az) => ({
url: "",
azimuth: az,
elevation: 15,
})),
meshUrl: "placeholder.glb",
prompt: "占位3D 道具魔法剑金属与宝石质感4 视角",
pipeline: "hunyuan3d",
tags: [],
favorited: true,
createdAt: now - 86400000,
},
];
writeJSON(storageKey("assets-3d"), defaults);
return defaults;
}
return list;
}
export function saveAssets3D(list: Asset3D[]) {
writeJSON(storageKey("assets-3d"), list);
}
export function upsertAsset3D(a: Asset3D) {
const list = loadAssets3D();
const idx = list.findIndex((x) => x.id === a.id);
if (idx >= 0) list[idx] = a;
else list.unshift(a);
saveAssets3D(list);
}
export function deleteAsset3D(id: string) {
saveAssets3D(loadAssets3D().filter((x) => x.id !== id));
}
// ==================== 动作/动画资产(占位) ====================
export function loadAssetsMotion(): AssetMotion[] {
const list = readJSON<AssetMotion[]>(storageKey("assets-motion"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: AssetMotion[] = [
{
id: "motion-sample-1",
thumbnail: "",
frames: Array.from({ length: 16 }).map(() => ""),
videoUrl: "",
gifUrl: "",
fps: 12,
frameCount: 16,
loop: true,
duration: 16 / 12,
prompt: "(占位)艾丽卡挥剑动作,循环 1.3s",
model: "animatediff",
characterId: "char-elica-swordsman",
tags: [],
favorited: false,
createdAt: now - 7200000,
},
{
id: "motion-sample-2",
thumbnail: "",
frames: Array.from({ length: 24 }).map(() => ""),
fps: 24,
frameCount: 24,
loop: false,
duration: 1,
prompt: "(占位)角色 idle 待机动作1s",
model: "svd",
tags: [],
favorited: false,
createdAt: now - 86400000 * 2,
},
];
writeJSON(storageKey("assets-motion"), defaults);
return defaults;
}
return list;
}
export function saveAssetsMotion(list: AssetMotion[]) {
writeJSON(storageKey("assets-motion"), list);
}
export function upsertAssetMotion(a: AssetMotion) {
const list = loadAssetsMotion();
const idx = list.findIndex((x) => x.id === a.id);
if (idx >= 0) list[idx] = a;
else list.unshift(a);
saveAssetsMotion(list);
}
export function deleteAssetMotion(id: string) {
saveAssetsMotion(loadAssetsMotion().filter((x) => x.id !== id));
}

View File

@@ -223,3 +223,146 @@ export interface AnnotationData {
/** canvas 导出的带标注截图base64 */
snapshot?: string;
}
// ==================== 文档管理中心Phase 1 ====================
// 对应 INFOLAYER 扩展:非视觉资产的知识中枢(类 Cursor 工作区)。
/** 文档节点类型:文件夹 or 文件 */
export type DocNodeType = "folder" | "doc";
/** 文档树节点 */
export interface DocNode {
id: string;
type: DocNodeType;
name: string;
/** 仅 doc 生效Markdown 源码 */
content?: string;
/** 仅 folder 生效:子节点 */
children?: DocNode[];
/** 所属项目 */
projectId?: string;
/** 用于排序的 sort 序号(同层级) */
order?: number;
createdAt: number;
updatedAt: number;
}
/** 已打开文档 Tab 的瞬态状态 */
export interface DocTabState {
nodeId: string;
/** 未保存修改的 bufferundefined 表示已保存 */
dirtyContent?: string;
}
// ==================== 3D 资产Phase 2 ====================
/** 3D 视图(某个方位角 + 仰角的渲染结果) */
export interface View3D {
/** 相对路径或完整 URL */
url: string;
/** 方位角(度) */
azimuth: number;
/** 仰角(度) */
elevation: number;
/** 对应的法线图 URLMesh Pipeline Stage 2 输出) */
normalUrl?: string;
/** 对应的风格化后图 URLMesh Pipeline Stage 3 输出) */
restyledUrl?: string;
}
/** 3D 模型资产 */
export interface Asset3D {
id: string;
/** 缩略图 */
thumbnail: string;
/** 多视图阵列 */
views: View3D[];
/** .glb 模型下载地址(占位) */
meshUrl?: string;
/** 360° 预览视频 URL占位 */
videoUrl?: string;
/** 来源 prompt */
prompt: string;
/** 生成管道trellis / hunyuan3d / zero123plus 等 */
pipeline: string;
/** 来源参考图(立绘/色彩稿URL */
sourceImageUrl?: string;
sessionId?: string;
tags: string[];
favorited: boolean;
createdAt: number;
}
// ==================== 动作/动画资产Phase 2 ====================
/** 动作序列资产 */
export interface AssetMotion {
id: string;
/** 缩略图(通常是首帧) */
thumbnail: string;
/** 所有帧的 URL 列表(占位) */
frames: string[];
/** 预览视频 URLMP4 */
videoUrl?: string;
/** GIF URL可选 */
gifUrl?: string;
/** 帧率 */
fps: number;
/** 帧数(= frames.length */
frameCount: number;
/** 是否循环 */
loop: boolean;
/** 时长(秒) */
duration: number;
/** 来源 prompt */
prompt: string;
/** 使用模型animatediff / svd / motion-lora 等 */
model: string;
/** 绑定的角色 ID可选 */
characterId?: string;
/** 来源参考立绘 URL */
sourceImageUrl?: string;
sessionId?: string;
tags: string[];
favorited: boolean;
createdAt: number;
}
// ==================== 任务类型Phase 2 扩展) ====================
/** 工作台当前任务类型2D 图片 / 3D 模型 / 动作序列) */
export type TaskType = "image2d" | "model3d" | "motion";
/** 3D 生成参数(占位) */
export interface Params3D {
/** 管道 */
pipeline: "trellis" | "hunyuan3d" | "zero123plus";
/** 视图数 */
viewCount: number;
/** 方位角预设equidistant / custom */
azimuthPreset: "equidistant" | "custom";
/** 自定义方位角列表(角度) */
customAzimuths?: number[];
/** 是否保持原图风格IP-Adapter 风格还原) */
preserveStyle: boolean;
/** 分辨率(占位) */
resolution: string;
}
/** 动作生成参数(占位) */
export interface ParamsMotion {
/** 模型 */
model: "animatediff" | "svd" | "motion-lora";
/** 帧数 */
frameCount: number;
/** 帧率 */
fps: number;
/** 是否循环 */
loop: boolean;
/** 输出格式 */
output: "mp4" | "gif" | "frames";
/** 绑定角色 ID */
characterId?: string;
/** 动作关键词(自然语言描述) */
motionHint?: string;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,463 @@
# Mesh-Pipeline 视角变换 · 跨会话实施计划
> **状态**SPEC / 尚未开工
> **最后更新**2026-04-20 15:30
> **负责人**Agent + 用户 @shine
> **适用对象**:任意新会话的 Agent本文档自包含无需额外上下文即可开工
---
## 0 · 上下文Why
### 0.1 起因
当前项目 `art-agent` 已经集成了 Zero123++`jd7h/zero123plusplus`)做单图→多视角变换,但存在固有缺陷:
1. **只输出 6 个固定视角**`azimuth/elevation` 写死在 `config.py`,用户无法指定任意相机位姿
2. **大角度一致性差**Zero123++ 主要训练于小物体,对建筑类规整几何 + 大角度偏转常出现立面错位
3. **无几何中间产物**,纯图对图黑盒映射,无法二次利用
### 0.2 目标
新增一条"Mesh Pipeline"视角变换路径,与现有 `zero123plus` 并列保留,让用户可以:
- 输入 1 张 2D 图(典型场景:带描边卡通风的建筑,如教堂/房屋/塔楼)
- 输出 N 张**保留原画风**、**几何一致**的新视角 2D 图
- 可指定任意 `azimuth/elevation`(至少阶段性支持 Trellis 360° 中的任意角度)
**非目标**:输出 3D mesh 给用户下载mesh 只是中间产物);替代 Zero123++(它作为"轻量路径"继续保留)。
### 0.3 相关历史上下文
| 记录 | 说明 |
|---|---|
| `CL-20260416-1000` | Zero123++ 首次集成 |
| `CL-20260420-1212` | Zero123++ 切图布局修复cols=2/rows=3 |
| `CL-20260420-1501` | 后端 watchfiles 依赖修复uvicorn --reload 生效 |
| `PF-20260420-1212` | Grid 切图布局坑("3×2" 措辞歧义) |
| `PF-20260420-1501` | Windows uvicorn --reload 无 watchfiles 退化坑 |
---
## 1 · 核心决策
### 1.1 三段式流水线
```
[输入: 1 张 2D 图]
【阶段 1】单图 3D 重建 → 生成 textured .glb + 预渲染 360° 视频
【阶段 2】从视频抽帧 / 自定义相机渲染 → 多视角"几何参考图"
【阶段 3】SDXL + ControlNet + IP-Adapter 二次重绘 → 还原原画风
[输出: N 张保留风格、几何一致的 2D 新视角图]
```
### 1.2 模型选型
| 阶段 | 主选 | 备选 / 高级选项 | 理由 |
|---|---|---|---|
| 阶段 1 | **Trellis** (`firtoz/trellis`) | Hunyuan3D-2 (`tencent/hunyuan3d-2`) | Trellis $0.041/30s 且自带 color_videoHunyuan 贵 3×慢 4×但 mesh 更扎实 |
| 阶段 2 | **ffmpeg 从 Trellis `color_video` 抽帧** | pyrender 加载 .glb 任意角度渲染 | Trellis 已送 360° 环绕视频,首版完全省事 |
| 阶段 3 | **`chigozienri/ip_adapter-sdxl-controlnet-depth`** | 自建 SDXL + 多 ControlNet 组合 | 一个 Replicate 模型打包了 SDXL+ControlNet-Depth+IP-Adapter |
### 1.3 为什么不用 Hunyuan3D-2 作默认
- 贵 3 倍($0.12 vs $0.041
- 慢 4 倍127s vs 30s
- **不返回预渲染视频** → 必须额外实现阶段 2pyrender / blender 渲染器)
- 唯一优势mesh 几何质量更高
→ 留作"高质量模式"供用户显式选择,一期不默认启用。
### 1.4 为什么不用 ControlNet-Depth 必须先估深度
`chigozienri/ip_adapter-sdxl-controlnet-depth``controlnet_input` 要求已经是 depth map。两条路
- **路 A**:从 Trellis `normal_video` 抽帧,把 normal 当"结构约束"直接喂(近似方案,不完美但可用)
- **路 B**:从 color_video 抽帧后,额外调用 `chenxwh/depth-anything-v2` 生成 depth map
一期走路 A 简化;若效果不足再升级路 B多一次 API 调用 + $0.003/张)。
---
## 2 · 技术数据清单Schema
### 2.1 Trellis — `firtoz/trellis`
- **版本 ID**`e8f6c45206993f297372f5436b90350817bd9b4a0d52d2a76df50c1c8afa2b3c`
- **成本**$0.041/次(~24 次/$1
- **耗时**~30 秒
- **硬件**Nvidia A100 80GB
- **输入关键字段**
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `images` | `array[uri]` | 必填 | 输入图(支持多图,一期只传 1 张) |
| `seed` | int | 0 | 种子 |
| `randomize_seed` | bool | true | 一期设为 false 便于复现 |
| `texture_size` | int (512-2048) | 1024 | GLB 纹理分辨率 |
| `mesh_simplify` | float (0.9-0.98) | 0.95 | 网格简化,建议保持默认 |
| `generate_color` | bool | **true** | **必开**,用于得到 color_video |
| `generate_normal` | bool | **true** | **一期开启**,便于阶段 3 做结构约束 |
| `generate_model` | bool | **true** | **必开**,否则不输出 .glb |
| `save_gaussian_ply` | bool | false | 不需要 |
| `ss_sampling_steps` | int (1-50) | 12 | 默认即可 |
| `slat_sampling_steps` | int (1-50) | 12 | 默认即可 |
| `ss_guidance_strength` | float (0-10) | 7.5 | 默认即可 |
| `slat_guidance_strength` | float (0-10) | 3 | 默认即可 |
- **输出字段**(⚠️ 实测修订 2026-04-20 16:15版本 `e8f6c452...`
| 字段 | 类型 | 实测行为 |
|---|---|---|
| `model_file` | uri | ✅ .glb 文件(`generate_model=true` 时)|
| `color_video` | uri | ❌ **恒为 null** — 该部署版不单独输出 |
| `normal_video` | uri | ❌ **恒为 null** — 该部署版不单独输出 |
| `combined_video` | uri | ✅ **唯一可用的视频** — color+normal **左右并排** mp4 |
| `gaussian_ply` | uri | ✅ 高斯点云(本项目不用) |
| `no_background_images` | array[uri] | ✅ 去背景的预处理图 |
**实测 schema 踩坑**(已记为 PF-20260420-1600
1. 输入字段必须用 `images`(数组)—— 传 `image`(单数字符串)会 422 `input: images is required`,即使 Replicate README example 里写的是 `image`
2. 尽管 `generate_color=True``generate_normal=True` 都开,`color_video``normal_video` 字段也**恒为 null**,只有 `combined_video` 可用。
3. 因此 Phase 2 抽帧的数据源是 `combined_video`,抽帧后需要**切左半 = color 帧、切右半 = normal 帧**。
### 2.2 Hunyuan3D-2 — `tencent/hunyuan3d-2`(备选)
- **版本 ID**`b1b9449a1277e10402781c5d41eb30c0a0683504fb23fab591ca9dfc2aabe1cb`
- **成本**$0.12/次
- **耗时**~127 秒
- **硬件**Nvidia L40S
- **输入**`image`, `steps` (20-50, default 50), `guidance_scale` (1-20, default 5.5), `seed`, `octree_resolution` (default 256), `remove_background` (default true)
- **输出**`mesh` (uri, .glb) —— **无视频无多视角预览**,必须配套阶段 2 自定义渲染
### 2.3 ControlNet + IP-Adapter — `chigozienri/ip_adapter-sdxl-controlnet-depth`
- **版本 ID**`0436c8702ef52616be5c30948551b3af6a86c821cca9b01f11ac297624fff14c`
- **成本**$0.070/次
- **耗时**~72 秒
- **硬件**Nvidia L40S
- **输入关键字段**
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `image` | uri | 必填 | **IP-Adapter 参考图**(此处传**用户原图**保持风格) |
| `controlnet_input` | uri | 必填 | **Depth / Normal / Lineart 参考图**(此处传**阶段 2 抽出来的 normal 帧** |
| `scale` | float (0-1) | 0.6 | IP-Adapter 权重,建议调到 **0.7-0.8** 强化风格 |
| `controlnet_conditioning_scale` | float (0-1) | 0.6 | 结构约束权重,建议 **0.7-0.9** |
| `prompt` | str | "" | 留空或用 "same building, same art style" |
| `negative_prompt` | str | "" | "blurry, distorted, different style" |
| `num_outputs` | int (1-4) | 1 | 一期 1 |
| `num_inference_steps` | int (1-500) | 30 | 默认即可 |
| `seed` | int | 随机 | 调参阶段固定 |
### 2.4 依赖清单
**一期必装**(首版走 Trellis + ffmpeg 抽帧,最小化依赖):
```
# backend requirements.txt
ffmpeg-python==0.2.0 # 或者直接在后端宿主机装 ffmpeg 可执行文件
```
**二期可选**(若要上 Hunyuan3D-2 + 自定义相机):
```
trimesh==4.5.0
pyrender==0.1.45
PyOpenGL==3.1.7
# Windows 额外需要 OSMesa 或改用 Blender headless二选一
```
**系统依赖**
- `ffmpeg` 可执行文件Windows`choco install ffmpeg` 或手动放 PATH
- 若二期启用Windows 上 OSMesa 编译困难,推荐用 Blender 4.x headless 替代
---
## 3 · 数据流与接口设计
### 3.1 新增后端服务模块
```
art-agent/backend/app/services/
├── view_transform.py # [改造] 管道调度器
├── mesh_generator.py # [新增] 阶段 1调用 Trellis / Hunyuan3D-2
├── video_frame_extractor.py # [新增] 阶段 2轻量ffmpeg 抽帧
├── mesh_renderer.py # [新增, 二期] 阶段 2自由相机pyrender 加载 .glb 渲染任意角度
└── style_restorer.py # [新增] 阶段 3ControlNet + IP-Adapter 重绘
```
### 3.2 管道调度伪代码
```python
# services/view_transform.py
async def transform_view_mesh_pipeline(
image_path: str,
model_id: str = "trellis", # or "hunyuan3d"
azimuths: list[int] = [0, 60, 120, 180, 240, 300],
elevations: list[int] = [0, 0, 0, 0, 0, 0],
preserve_style: bool = True, # 是否走阶段 3
) -> dict:
# === 阶段 1 ===
mesh_result = await mesh_generator.generate(image_path, model_id)
# 返回 {glb_url, color_video_url?, normal_video_url?}
# === 阶段 2 ===
if model_id == "trellis":
# 从视频抽帧Trellis 的 360° 视频约 5s对应 az 0→360°
frames = await video_frame_extractor.extract_by_azimuth(
color_video_url=mesh_result["color_video_url"],
normal_video_url=mesh_result["normal_video_url"],
azimuths=azimuths,
total_rotation_deg=360,
)
else: # hunyuan3d
# 二期:自定义相机渲染
frames = await mesh_renderer.render_views(
glb_path=mesh_result["glb_path"],
azimuths=azimuths,
elevations=elevations,
)
# frames = [{az, el, color_png, normal_png}, ...]
# === 阶段 3 ===
if preserve_style:
final_images = await asyncio.gather(*[
style_restorer.restore(
original_image=image_path,
structure_image=f["normal_png"],
azimuth=f["az"],
elevation=f["el"],
)
for f in frames
])
else:
final_images = [f["color_png"] for f in frames]
return {
"success": True,
"images": [
{"url": img, "azimuth": az, "elevation": el}
for img, (az, el) in zip(final_images, zip(azimuths, elevations))
],
"mesh_url": mesh_result.get("glb_url"),
"model_name": f"Mesh Pipeline ({model_id})",
}
```
### 3.3 config.py 扩展
```python
VIEW_TRANSFORM_MODELS = {
"zero123plus": {...}, # 保留不动
"trellis": {
"id": "trellis",
"name": "Trellis (Mesh Pipeline)",
"provider": "replicate",
"model_id": "firtoz/trellis:e8f6c45206993f297372f5436b90350817bd9b4a0d52d2a76df50c1c8afa2b3c",
"description": "3D 重建 + 任意视角 + 风格还原,建筑友好(~30s + 风格化 ~72s/张)",
"pipeline": "mesh", # 新增字段区分管道类型
"default_params": {
"texture_size": 1024,
"mesh_simplify": 0.95,
"generate_color": True,
"generate_normal": True,
"generate_model": True,
},
},
"hunyuan3d": {
"id": "hunyuan3d",
"name": "Hunyuan3D-2 (高质量)",
"provider": "replicate",
"model_id": "tencent/hunyuan3d-2:b1b9449a1277e10402781c5d41eb30c0a0683504fb23fab591ca9dfc2aabe1cb",
"description": "几何质量最佳(~127s需自定义渲染二期启用",
"pipeline": "mesh",
"enabled": False, # 一期先禁用
},
}
IMAGE_MODELS["ip-adapter-controlnet-depth"] = {
"id": "ip-adapter-controlnet-depth",
"name": "IP-Adapter + ControlNet Depth (内部)",
"provider": "replicate",
"model_id": "chigozienri/ip_adapter-sdxl-controlnet-depth:0436c8702ef52616be5c30948551b3af6a86c821cca9b01f11ac297624fff14c",
"internal": True, # 不在前端展示,仅用于阶段 3
...
}
```
### 3.4 工具层 `tools.py`
`transform_view` 工具参数扩展(向后兼容):
```python
{
"name": "transform_view",
"parameters": {
"image_path": {...},
"model_id": {
"enum": ["zero123plus", "trellis", "hunyuan3d"],
"default": "zero123plus",
},
# 以下为 mesh pipeline 专属参数
"azimuths": {"type": "array", "default": [0, 60, 120, 180, 240, 300]},
"elevations": {"type": "array", "default": [0, 0, 0, 0, 0, 0]},
"preserve_style": {"type": "boolean", "default": True},
},
}
```
### 3.5 前端改造
- 模型下拉增加 "Trellis (Mesh Pipeline)" 和 "Hunyuan3D-2 (高质量)" 选项
- 选中 mesh pipeline 时,额外显示:
- "保留原画风格" 开关(默认开)
- 角度输入:简化为"快速预设6/8/12 视角)" + "自定义 azimuth 数组"(高级)
- 结果区域:除了 N 张图,还展示 `color_video` 可预览
---
## 4 · 施工任务清单(跨会话可独立执行)
### Phase 1Trellis 接入(预计 1.5h
**验收标准**:用 curl / Replicate SDK 能跑通一次 Trellis拿到 .glb + color_video URL下载到本地。
- [x] P1-1 `services/mesh_generator.py` 新建,封装 `generate_with_trellis(image_path)` 函数(调用 Replicate + 下载结果到 `GENERATED_DIR`
- [x] P1-2 `config.py` 新增 `VIEW_TRANSFORM_MODELS["trellis"]` 条目
- [x] P1-3 `services/view_transform.py` 根据 `pipeline == "mesh"` 分流到 `mesh_generator`
- [x] P1-4 `tools.py``transform_view` 增加 `model_id` 参数默认值兼容
- [x] P1-5 本地手动测试:`scripts/test_trellis.py` 可独立运行验证 Trellis → .glb + color_video + normal_video 本地落地(**等用户执行**
### Phase 2ffmpeg 抽帧(预计 1h
**验收标准**:从 color_video.mp4 抽出指定 azimuth 对应的帧,保存为 PNG帧数正确、角度映射正确。
- [x] P2-1 宿主机 ffmpeg 未装,改用 `imageio-ffmpeg==0.5.1`(静态二进制随包,免系统依赖),已写入 `requirements.txt`
- [x] P2-2 `services/video_frame_extractor.py` 新建,实现 `extract_by_azimuth(combined_video_path, azimuths, elevations=None, total_rotation_deg=360)`
- 策略改为"一次抽全帧到 temp dir + 按帧索引挑" —— 比 `-ss` 逐帧抽更稳(不受 keyframe 对齐影响),且 5s 视频成本可忽略
- 抽出后用 PIL 按中线切:左半 → color_png、右半 → normal_png实测 1024×512 → 512×512 方形)
- `ffmpeg + PIL` 是阻塞的,用 `run_in_executor` 丢到线程池避免堵住 async 事件循环
- 临时目录抽完即清理,只保留切好的最终帧
- [x] P2-3 `view_transform._transform_view_mesh` 串接 Stage 1 → Stage 2默认 `azimuths=[0,60,120,180,240,300]`Stage 2 失败不会把整体置为失败Stage 1 的 .glb / video 仍有交付价值,`error` 字段单独带回 Stage 2 错误)
- [x] P2-4 本地测试脚本 `scripts/test_frame_extract.py`**等用户执行**验收)
- Agent 已自测120 帧视频 → az=0/60/120/180/240/300 映射到 frame_idx=0/20/40/60/80/100完全均匀 ✓
- 12 个 color/normal PNG 全部落盘,尺寸 512×512 方形 ✓
### Phase 3风格还原重绘预计 1.5h
**验收标准**6 张视角图通过 IP-Adapter+ControlNet 重绘后,风格与原图一致、几何不变形。
- [x] P3-1 `config.py` 注册 `IMAGE_MODELS["ip-adapter-controlnet-depth"]`,标 `internal=True`,同时改 `get_image_models_list()` 过滤 internal不污染前端下拉
- [x] P3-2 `services/style_restorer.py` 新建,实现 `restore(original_image, structure_image, ...)` + `restore_batch()`(带并发 Semaphore 防 Replicate rate limit
- [x] P3-3 `view_transform.py` 增加阶段 3`preserve_style=True` 时对 Stage 2 的每一帧做重绘;单帧失败自动回退为 Stage 2 color 帧,不阻塞整体
- [ ] P3-4 **等待 Checkpoint 3**:调参轮次需要用户执行 `scripts/test_style_restore.py <原图>`,分别尝试 `scale=0.6/0.7/0.8` × `controlnet_conditioning_scale=0.6/0.7/0.9`,选出最佳组合写入默认参数
### Phase 4前端 + 端到端联调(预计 1h
- [ ] P4-1 `types.ts` 扩展 `ViewTransformModel` 类型
- [ ] P4-2 前端模型下拉新增两个选项
- [ ] P4-3 工作台选中 mesh 管道时显示"保留原画风"开关
- [ ] P4-4 结果面板支持展示视频color_video+ N 张风格化后的视角图
- [ ] P4-5 端到端联调:前端上传一张卡通建筑图 → 选 Trellis → 确认 N 张结果正确显示
### Phase 5Hunyuan3D-2 + 自由相机(**可选,二期**,预计 3-4h
**仅在 Phase 1-4 完成且用户明确要求自由 elevation/远近相机时启动。**
- [ ] P5-1 venv 安装 `trimesh + pyrender + PyOpenGL`Windows 上验证 headless 可用性
- [ ] P5-2 若 pyrender 在 Windows 不可行 → 改用 Blender 4.x headless`blender.exe -b --python render.py`
- [ ] P5-3 `services/mesh_renderer.py` 新建,实现 `render_views(glb_path, azimuths, elevations, distance=2.0, fov=45)`
- [ ] P5-4 启用 `config.py``hunyuan3d` 条目(`enabled=True`
- [ ] P5-5 前端"高级模式"解锁 elevation 滑块
### Phase 6收尾
- [ ] P6-1 `changelog-full.md` / `-recent.md` / `-headlines.md` 三层同步记录
- [ ] P6-2 发现的坑 → `pitfalls.md`
- [ ] P6-3 更新 `art-agent/README.md` 里视角变换段落
---
## 5 · 风险与兜底
| 风险 | 概率 | 影响 | 兜底 |
|---|---|---|---|
| Trellis 对 2D 卡通描边图重建几何偏糊 | 中 | 阶段 3 结构约束失效 | 先用 SDXL+InstantStyle 把原图"半写实化"一版给 Trellis 吃,最后阶段 3 用原 2D 图做 IP-Adapter 还原风格 |
| 从 color_video 抽帧只有水平环绕elevation=0不能仰视/俯视 | 高 | 无法满足"仰视大教堂"等需求 | 二期启用 Phase 5 的 pyrender 自由相机,或改用 Hunyuan3D-2 + 自定义渲染 |
| IP-Adapter+ControlNet 6 次调用并发打满 Replicate rate limit | 低 | 部分帧失败 | 改成串行 + 指数退避 + 失败单帧重试 |
| 总链路太慢(>3 分钟)用户失去耐心 | 中 | 产品体验差 | 前端用 SSE 流式展示中间产物:阶段 1 完成显示视频 → 阶段 3 每张完成立即插入 |
| ffmpeg 未装 | 高Windows | Phase 2 不能跑 | 开工前检查 `ffmpeg -version`,未装先 `choco install ffmpeg` 或用 Python 库 `imageio-ffmpeg` 绑定 |
| Trellis 某次输出 video 是 4s 某次 6s固定 total_rotation_deg=360 可能不准 | 低 | 抽帧角度偏差 | 用 `ffprobe` 动态读取总帧数;如有疑问就固定 24 帧均匀抽样全部保留 |
---
## 6 · 成本与性能预估
**一次完整请求6 视角,保留风格)**
| 阶段 | 耗时 | 成本 |
|---|---|---|
| Trellis 重建 | ~30s | $0.041 |
| ffmpeg 抽 6 帧 | ~2s本地 | 0 |
| ControlNet+IP-Adapter × 6并发 | ~72s | $0.42 |
| **合计** | **~105s** | **~$0.46** |
参考:当前 Zero123++ 一次 ~$0.02~15s。新管道贵约 23×、慢约 7×但产出质量对建筑是跨代提升。
---
## 7 · 跨会话恢复指引
**新会话的 Agent 在开工前务必**
1. 读本文件 **§0-§3**,建立完整技术背景
2.`art-agent/backend/app/services/view_transform.py` 当前实现Zero123++ 的做法作参照)
3.`art-agent/backend/app/config.py``VIEW_TRANSFORM_MODELS` / `IMAGE_MODELS` 当前结构
4.`art-agent/backend/app/agent/tools.py``transform_view` 工具定义
5.**§4 的 Phase 顺序**推进Phase 之间互相独立,完成一个交付一个
6. 每个 Phase 完成后在本文件对应 checkbox 打勾(改 `[ ]``[x]`),并在本节底部追加简短进度 note
**不要一次 Phase 1-6 全做完再交付**——每个 Phase 完成后停下来让用户测试/审阅。
### 进度日志
<!-- 格式YYYY-MM-DD 会话简述 → 完成了 Px-y 的哪些 item -->
- 2026-04-20 · 会话"Zero123++ 切图诊断 + 方案调研" → 完成 §2 全部 schema 查证,写入本 SPEC。尚未开工 Phase 1。
- 2026-04-20 · 会话"按计划实施 Mesh Pipeline" → 完成 Phase 1P1-1 ~ P1-5新增 `mesh_generator.py``config.py` 注册 trellis/hunyuan3d后者 enabled=False`view_transform.py``pipeline` 字段分流、`tools.py``model_id/azimuths/preserve_style` 参数,额外提供 `scripts/test_trellis.py` 供用户手动验收。
- 2026-04-20 16:15 · **Checkpoint 1 通过** → Agent 运行 `scripts/test_trellis.py` 实测发现:
- ✅ Trellis 调用链路完全打通Replicate token / 代理 / httpx 下载 / GENERATED_DIR 落盘均正常
-`.glb` (1.89MB) 下载正常
- ⚠️ 意外发现:该部署版 `color_video` / `normal_video` 字段**恒为 null**,只有 `combined_video` 可用color+normal 左右并排 mp4 ~450KB
- 修订 §2.1 输出字段表为实测版本P2-2 任务改为"从 combined_video 抽帧 + 切左右半",已记 PF-20260420-1600 踩坑
- Phase 1 verdict**functionally green**,可以开 Phase 2
- 2026-04-20 · 会话"继续执行 Phase 2" → 完成 Phase 2 全部 itemP2-1 ~ P2-4
- 放弃系统 ffmpeg改用 `imageio-ffmpeg` 自带二进制22.6MB wheelWin amd64 下已测 v4.2.2 ok
- 新增 `services/video_frame_extractor.py` — 一次全帧抽取 + 按索引挑 + PIL 切左右半的极简实现
- `view_transform.py` 的 mesh 管道调度器现已走到 Stage 2返回的 `images[]``{url, azimuth, elevation, normal_url, frame_idx}`
- 新增 `scripts/test_frame_extract.py` 供用户复用已有 combined_video 做 Checkpoint 2 验收(无需再花钱跑 Trellis
- Agent 自测120 帧 → 6 视角 frame_idx 均匀分布 (0/20/40/60/80/100),所有切图 512×512 方形,逻辑正确
- **等待 Checkpoint 2 用户验收**:跑 `python scripts/test_frame_extract.py` 并目视检查 `generated/view_*_color.png` 6 张图的角度顺序
- 2026-04-20 · 会话"继续执行 Phase 3风格还原" → 完成 P3-1 ~ P3-3
- `config.py` 注册内部模型 `ip-adapter-controlnet-depth``internal=True`),并让 `get_image_models_list()` 自动过滤,不会污染前端下拉;新增 helper `get_style_restore_model_id()`
- 新增 `services/style_restorer.py``restore()` 单帧调用 + `restore_batch()` 带 Semaphore 并发 3 的批量接口;统一处理 FileOutput/list/str 的 Replicate 返回
- `view_transform._transform_view_mesh` 接入 Stage 3`preserve_style=True` 对每一帧做 IP-Adapter+ControlNet 重绘;**单帧失败会自动回退为 Stage 2 color 帧**,不阻塞整体;`images[i]` 新增 `restyled` 布尔和 `color_url`(保留原 color 便于对比)
- 最终返回的 `_stage` 字段从 `stage2` 升级为 `stage3 (N/M restyled)`,便于前端 / 日志快速判断链路完整度
- 新增 `scripts/test_style_restore.py` 供 Checkpoint 3 用户验收:支持 `--single` 冒烟、`--prefix` 指定 normal 帧组、`--scale/--cn-scale` 覆盖参数,复用 Phase 2 已有帧无需再跑 Trellis
- **等待 Checkpoint 3 用户验收**:跑 `python scripts/test_style_restore.py <原图> --single` 做冒烟,通过后再全量 6 帧并调参P3-4
---
## 8 · 开放问题(留给未来决策)
- 前端角度输入 UX要不要做一个"3D 预览转盘"让用户拖动选角度Phase 4+ 再说)
- 是否把 mesh `.glb` 作为资产保存到用户的 gallery涉及存储与用户系统对接
- Hunyuan3D-2 路线的 Windows 渲染器pyrender vs Blender 的选型需要一次实测才能决定
- 是否对同一原图的重复请求加缓存Trellis 结果相对稳定,可按 image_hash 缓存 .glb