Compare commits

...

6 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
ee5cec6de4 Merge pull request 'photoGen' (#2) from photoGen into main
Reviewed-on: http://114.55.102.172/gitea/shiyong.qin/EPEEAIKit/pulls/2
2026-04-17 19:30:54 +08:00
Nostars Developer
b12e55a776 交互原型大版本 2026-04-17 19:30:23 +08:00
Nostars Developer
10f9c0061a 小优化,产品愿景,视觉规范,交互和导航大版本 2026-04-16 18:11:35 +08:00
98 changed files with 17787 additions and 1171 deletions

View File

@@ -4,6 +4,335 @@
## 记录
### [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**:
- art-agent/start-tunnel.ps1
- .gitignore
- **what**: 修复 `start-tunnel.ps1` 在 Windows PowerShell 5.1 下因脚本 UTF-8 无 BOM、含中文的 here-string`@"`)被误解析而导致的 `ParserError`。将写入 `frontend/.env.local` 的两处 here-string 改为 `[System.IO.File]::WriteAllLines` + UTF-8 无 BOM与 Next 读取 `.env` 习惯一致)。用 PowerShell 将脚本全文重写为 **UTF-8 BOM**,便于 PS5.1 正确分词。在脚本开头增加:若存在与脚本同目录的 `cloudflared.exe`,则把该目录插入 `PATH` 最前,便于未全局安装 cloudflared 时使用本地下载的可执行文件。根目录 `.gitignore` 增加 `art-agent/cloudflared.exe`,避免误提交约 65MB 二进制。
- **why**: 用户请求开启穿透时,脚本在 Cursor 集成终端中立即解析失败,隧道无法拉起;当前环境亦未必安装 winget/系统 PATH 中的 cloudflared需要可放置于项目目录的 fallback。
- **decisions**: 不改为仅英文输出(保留原有中文提示与框线字符);`.env.local` 注释改为英文短句以避免无 BOM 环境下再次出现字符串边界问题。
- **notes**: 会话中曾从 GitHub latest release 下载 `cloudflared-windows-amd64.exe``art-agent/cloudflared.exe` 并成功启动双隧道;该文件已被 gitignore。
### [CL-20260417-1500] 2026-04-17 15:00 — 按 INFOLAYER.md 实现完整前端信息架构Phase 1+ 占位骨架)
- **tags**: 前端, UI骨架, INFOLAYER, 多项目, 风格库, 角色库, 训练中心, 项目设置, TaskCard, 候选评估, 占位数据
- **affected_files**:
- art-agent/frontend/src/lib/types.ts
- art-agent/frontend/src/lib/placeholder-store.ts
- art-agent/frontend/src/lib/app-context.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/app/styles/page.tsx
- art-agent/frontend/src/app/styles/[id]/page.tsx
- art-agent/frontend/src/app/characters/page.tsx
- art-agent/frontend/src/app/characters/[id]/page.tsx
- art-agent/frontend/src/app/training/page.tsx
- art-agent/frontend/src/app/training/[id]/page.tsx
- art-agent/frontend/src/app/training/new/page.tsx
- art-agent/frontend/src/app/settings/page.tsx
- art-agent/frontend/src/components/layout/top-nav.tsx
- art-agent/frontend/src/components/layout/project-switcher.tsx
- art-agent/frontend/src/components/sidebar/sidebar.tsx
- art-agent/frontend/src/components/sidebar/project-card.tsx
- art-agent/frontend/src/components/chat/session-quick-picks.tsx
- art-agent/frontend/src/components/chat/task-card.tsx
- art-agent/frontend/src/components/workbench/advanced-controls.tsx
- art-agent/frontend/src/components/workbench/candidate-panel.tsx
- **what**: 按 `docs/art-agent/INFOLAYER.md` 规范一次性铺开 Phase 13 产品形态的前端 UI 骨架:新增风格库 `/styles`、角色库 `/characters`、训练中心 `/training`、项目设置 `/settings` 四大页面模块(含列表 + 详情 + 创建流程);工作台输入区上方加入风格/角色/资产规格快选条、高级控制抽屉、候选评估侧面板、Task Card IR 占位组件;资源库新增资产类型/风格/角色多维过滤和语义搜索切换TopNav 扩充导航项与项目切换器Sidebar 加入项目信息卡;新增 `lib/placeholder-store.ts` 管理 Phase 1+ 占位数据(项目/风格集/角色卡/训练任务)并在 `app-context.tsx` 中统一暴露;`globals.css` 补充 `phase-chip` / `placeholder-card` / `hide-scrollbar` / `section-divider` 工具类。
- **why**: INFOLAYER.md 规划了 Phase 03 完整产品形态,其中 Phase 1+(多项目、风格库/角色库、训练中心、Task Card、候选评估等尚未具备后端能力。用户希望先把完整交互结构和界面落地"数据或展示上写占位"即可,方便内部演示、收集反馈以及后续按 Phase 增量接入真实后端。
- **decisions**: (1) 占位数据用独立的 `placeholder-store.ts` + `localStorage` 持久化,与现有 `store.ts` 解耦首次加载注入示例数据2 个项目 / 4 个风格集 / 2 个角色 / 4 个训练任务)。(2) 详情页直接用 `[id]/page.tsx` 同时处理 `new` 与编辑两种模式(判断 `params.id === 'new'`),不再新增 `/styles/new` 等专用子路由。(3) Task Card 作为独立可复用组件 (`components/chat/task-card.tsx`),不强制接入当前 chat loop等真实 IR 管线就绪时再 wire up。(4) 候选评估面板从"最后一条含 images 的 assistant 消息"取数据做 demo。(5) Gallery 风格/角色过滤用 prompt 关键词子串匹配做占位,未来等后端补 asset 归因字段后再切到真实字段。(6) 所有 Phase 1+ 功能按钮均挂 `phase-chip`P1/P2/P3明示阶段避免用户误以为已可用。
- **notes**: 所有"应用到会话""Fork""训练""批量下载"等交互都是 `alert("(占位)...")`,不产生真实后端调用;真实接入时搜索 `(占位)``phase-chip` 可快速定位需要替换的位置。`AppContext` 新增字段:`projects` / `activeProjectId` / `stylePacks` / `characters` / `trainingTasks` / `sessionStyleId` / `sessionCharacterId` / `sessionAssetSpec` 及对应 upsert/delete 方法;切换用户时会重新加载全部占位数据。`/styles/[id]``/characters/[id]` 的"生成图片"列表也是基于关键词从现有 assets 里筛的占位结果,不是真正的归因。`ChatMessage` 未扩展Task Card 目前仅以组件形式存在,实际渲染位置需要后续决定(工作台消息流或独立抽屉)。
- **source_chat**: [实现 INFOLAYER 完整 UI 骨架](607b2d99-ff5b-48a6-91b5-1ada004cd8d5)
### [CL-20260416-1600] 2026-04-16 16:00 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
- **tags**: 前端, 视觉风格, 色彩体系, CSS变量, 白底, 千里江山图
- **affected_files**:
- art-agent/frontend/src/app/globals.css
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/app/login/page.tsx
- art-agent/frontend/src/app/gallery/page.tsx
- art-agent/frontend/src/components/chat/chat-messages.tsx
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/frontend/src/components/layout/top-nav.tsx
- art-agent/frontend/src/components/sidebar/sidebar.tsx
- art-agent/frontend/src/components/sidebar/session-list.tsx
- art-agent/frontend/src/components/profile-modal.tsx
- art-agent/frontend/src/components/detail/image-detail-panel.tsx
- art-agent/frontend/src/components/ui/ambient-particles.tsx
- docs/art-agent/VISUAL-STYLE-GUIDE.md
- **what**: 整个应用从暗色(墨绿黑 #0C1210)底色改为白色(绢本白 #FAFAF7)底色,文字改为墨黑(#1A1A1A),框体和光效保留千里江山图的蓝绿配色但色值加深适配白底
- **why**: 用户反馈当前风格"太偏绿了"。评估后认同——原版将背景、面板、文字全都带绿调,导致全屏绿色信息过载。千里江山图本身是青绿设色于白色绢本之上,白底才更贴近原画精髓
- **decisions**:
- 背景 #0C1210#FAFAF7(绢本微暖白),面板从绿底毛玻璃改为白色毛玻璃
- accent #4DB8A4#2E8B7A(加深以确保白底上对比度)
- 文字 #E0E8E2#1A1A1A(墨黑),次级文字 #7A9485#6B7B8A(灰蓝去绿调)
- 所有 rgba(77,184,164,...) 硬编码统一替换为 rgba(46,139,122,...)
- 云雾背景透明度大幅降低(白底上需要更淡才不会突兀)
- Logo 内部颜色从 #0C1210 改为 white石青底白字
- 保留所有动效系统(云烟缭绕、萤火粒子、水波纹等),仅调整颜色参数
- **notes**:
- surface-1/2/3 从深色半透明改为白色半透明,阴影也从重阴影改为轻阴影
- 网格底纹 opacity 从 0.03 降为 0.018
- VISUAL-STYLE-GUIDE.md 文档同步重写
- **source_chat**: [白底青绿视觉风格改造]
### [CL-20260416-1500] 2026-04-16 15:00 — UX风格指南实现度补全接入涟漪/sendBounce/会话切换动画/Error飘散/全屏云雾加载/流式opacity阶梯等10项
- **tags**: 前端, UX补全, 动效, framer-motion, 微交互, 排版使用, 实现状态文档
- **affected_files**:
- art-agent/frontend/src/components/chat/chat-input.tsx
- art-agent/frontend/src/components/chat/chat-messages.tsx
- art-agent/frontend/src/components/chat/image-grid.tsx
- art-agent/frontend/src/components/ui/motion-presets.ts
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/app/login/page.tsx
- art-agent/frontend/src/app/gallery/page.tsx
- art-agent/frontend/src/app/globals.css
- art-agent/frontend/src/lib/app-context.tsx
- art-agent/frontend/src/components/sidebar/sidebar.tsx
- art-agent/frontend/src/components/profile-modal.tsx
- docs/art-agent/UX-STYLE-GUIDE.md
- **what**: 补全 UX-STYLE-GUIDE.md 中描述但未实现的 10 项特性,并在文档中标注每项实现状态
- **why**: 分析发现风格指南完成度约 65-70%,多项微交互只写了文档描述或代码定义了但未接入业务
- **decisions**: 对每项未实现特性逐一补全代码,同时更新文档加入 ✅/🔶/⬜ 状态标记
- **notes**: 补全项包括:(1) ripple-effect.tsx 接入发送按钮 (2) sendBounce 替代 buttonTap (3) 会话切换 sessionSlide 滑入滑出 (4) Error errorFloat 飘散消失+4s自动清除 (5) 回到底部按钮水滴形 (6) 全屏加载改云雾凝聚 mist-loader (7) AI回复 StreamingLines 逐行 opacity 阶梯 (8) typo-h1/h2/strong 实际使用到组件 (9) 用户消息 .user-bubble 左上切角 (10) 图片占位 .stone-placeholder 石青渐变呼吸脉冲。文档每个 section 加入实现文件路径和状态标记。
### [CL-20260416-1330] 2026-04-16 13:30 — 交互体验全面升级:信息层级 + 科技质感 + 微交互趣味性
- **tags**: 前端, UX升级, 动效, framer-motion, 排版体系, 微交互, 视觉层级, 科技感, 粒子, 流光
- **affected_files**:
- art-agent/frontend/src/app/globals.css
- art-agent/frontend/src/app/page.tsx
- art-agent/frontend/src/app/login/page.tsx
- art-agent/frontend/src/app/gallery/page.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/components/chat/model-selector.tsx
- art-agent/frontend/src/components/sidebar/sidebar.tsx
- art-agent/frontend/src/components/sidebar/session-list.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/profile-modal.tsx
- art-agent/frontend/src/components/ui/motion-presets.ts (新建)
- art-agent/frontend/src/components/ui/ambient-particles.tsx (新建)
- art-agent/frontend/src/components/ui/data-stream.tsx (新建)
- art-agent/frontend/src/components/ui/ripple-effect.tsx (新建)
- art-agent/frontend/src/components/ui/firefly-burst.tsx (新建)
- docs/art-agent/UX-STYLE-GUIDE.md (新建)
- **what**: 四阶段全面升级交互体验:信息层级体系、科技质感注入、微交互趣味性、统一状态反馈
- **why**: 用户要求信息层级差异更突出、画面更高级典雅且具科技感、交互有巧思让使用心情愉悦
- **decisions**:
- 新增 framer-motion 作为动画引擎(~30KB gzipped不引入其他 UI 库
- 排版从 5 级扩展到 7 级Display 渐变文字 / H1 底部石青线 / H2 左侧竖线 / Body Strong 石青色 / Caption / Micro 大写加宽字间距CSS 工具类 `.typo-*`
- Surface 层级从 1 级扩展到 4 级Surface-0 背景 → Surface-3 弹窗),每级递增 backdrop-blur、border 亮度、box-shadow
- 背景网格底纹radial-gradient 点阵 opacity 0.03)从 body::before 实现,原云雾改为独立 DOM 元素 `.fog-layer-far/near`
- 环境粒子纯 CSS 实现15-28 个随机漂浮+闪烁的石青色小光点),空态时增多营造"未探索深山"感
- 数据流光(石青色 linear-gradient 横向流动 2pxAI 活动时出现在输入栏上方,生图时加速
- 用户消息改为右对齐渐变背景+右上切角,助手消息改为 surface-2 + 左侧 2px 石青竖线
- 消息入场动画:用户消息 translateY+scale spring助手消息 blur 消散(云雾凝聚)
- 发送按钮 whileTap scale 0.85,可发送时呼吸脉冲发光
- 输入框水面感应:焦点时底部 scaleX 扩展线 + box-shadow 随输入长度增强
- 收藏星标改为赭金色,点击时 5-7 个金色粒子向四周飘散(萤火点亮)
- 图片水墨晕染skeleton shimmer 占位 → clip-path circle 从中心扩展揭示
- Loading 从 bounce 圆点改为水波纹 scaleY 动画skeleton 用 shimmer 横向扫描
- 错误状态赭石红 + translateX 震动 2px × 4 次
- 空态使用 Display 级渐变文字 + stagger 入场动画
- 滚到底部按钮改圆形 + spring 弹入/退出
- 侧栏会话项 hover 时左侧 2px 竖线 slide-in
- 所有按钮 hover 添加 translateY(-1px) + shadow 加深浮起效果
- 所有弹出层改用 surface-3强模糊+明显阴影+更亮边框)
- 统一动画参数motion-presets.ts 定义 spring/springGentle、fadeIn/slideUp/mistReveal、messageEnter/assistantEnter、buttonTap/sendBounce、ripple/sparkle/inkReveal、staggerContainer/staggerItem
- **notes**:
- framer-motion 通过 npm install 添加3 个包),前端 bundle 增加约 30KB gzipped
- 环境粒子使用 CSS animation 而非 Canvas不影响性能
- 所有动画使用 transform/opacity/filter避免触发 layout
- body::before 原来用于云雾现改为点阵网格,云雾改为 AmbientParticles 组件内的 DOM 元素
- 构建验证通过next build 无错误First Load JS 增量约 50KBframer-motion
- **source_chat**: [交互体验全面升级](6b0e5860-9c5a-4b44-aa96-affba21e9472)
### [CL-20260416-1000] 2026-04-16 10:00 — 集成 Zero123++ 视角变换工具单图→6视角
- **tags**: 视角变换, Zero123++, Novel View Synthesis, 新工具, Replicate, 图片处理
- **affected_files**:
- art-agent/backend/app/config.py
- art-agent/backend/app/agent/tools.py
- art-agent/backend/app/agent/loop.py
- art-agent/backend/app/services/view_transform.py
- **what**: 新增 `transform_view` 工具,集成 Zero123++ 模型实现单图生成 6 个固定视角图片
- **why**: 用户需要将手绘建筑从侧面视角转为其他角度,视角变换是与生图完全不同的能力,需要独立工具
- **decisions**:
- 视角变换作为独立工具 `transform_view` 而非 `generate_image` 的扩展——输入不需要 prompt只需一张图
- 新增独立服务模块 `view_transform.py`,与 `image_gen.py` 分离——逻辑完全不同(无 prompt、grid 拆分)
-`config.py` 中新增 `VIEW_TRANSFORM_MODELS` 独立注册表——与 `IMAGE_MODELS` 分离,未来可扩展更多视角变换模型
- Zero123++ 输出为 3x2 grid 拼接图,用 Pillow 拆分为 6 张独立图片返回
- 进阶方案3D 重建 + ControlNet 风格还原 pipeline记录到延期方案 `[advanced-view-transform]`
- **notes**:
- Zero123++ 视角固定Azimuth: 30/90/150/210/270/330°, Elevation: 30/-20 交替),不支持自定义角度
- 手绘风格保持度可能不理想(模型偏向写实/3D 风格),需实际测试验证
- 输入图片需为正方形,建议 >= 320x320
- Replicate 上的模型 ID: `jd7h/zero123plusplus:c69c6559...`~$0.074/次,~76s
- loop.py 中对 `transform_view` 做了特殊结果处理(返回带视角信息的图片列表)
### [CL-20260416-0930] 2026-04-16 09:30 — 设备绑定配置与 Skill/Hooks 解耦local-env.json
- **tags**: 基础设施, local-env, 跨设备, hooks, project-launcher, Skill
- **affected_files**:
- .cursor/local-env.json
- .cursor/local-env.example.json
- .cursor/hooks.json
- .cursor/hooks/run-hook.ps1
- .cursor/hooks/session-init.sh
- .cursor/hooks/check-changelog.sh
- .cursor/hooks/README.md
- .cursor/rules/project/local-env-check.mdc
- .cursor/skills/project-launcher/SKILL.md
- .gitignore
- art-agent/ENVIRONMENT.md
- **what**: 将设备绑定的配置Node.js 路径、shell 类型)从 Skill 和 hooks 中解耦到 `.cursor/local-env.json`gitignored。project-launcher Skill 改为从 local-env.json 读取路径hooks 增加 run-hook.ps1 dispatcher + bash 版脚本支持跨平台;新增 local-env-check Rule 自动探测生成ENVIRONMENT.md 去除平台硬编码改为通用说明
- **why**: Node.js 路径和 shell 类型在不同设备上不同,硬编码在 Skill/hooks 中导致换机或跨平台时需要修改 git tracked 文件
- **decisions**: 采用 `.cursor/local-env.json`gitignored+ `.cursor/local-env.example.json`tracked 模板)的模式,类似 `.env` / `.env.example` 的惯例hooks.json 的跨平台靠为每个 hook 提供 .ps1 和 .sh 双版本
- **notes**: hooks.json 的 command 字段本身仍需按平台手动调整Windows 用 powershellmacOS/Linux 用 bash这是 Cursor hooks 机制的限制
- **source_chat**: [local-env 设备配置解耦]
### [CL-20260416-0900] 2026-04-16 09:00 — 环境依赖全面修复 + 统一环境文档
- **tags**: 环境, 依赖, Node.js, Python, venv, npm, requirements, ENVIRONMENT
- **affected_files**:
- art-agent/backend/requirements.txt
- art-agent/backend/requirements-lock.txt
- art-agent/ENVIRONMENT.md
- .cursor/skills/project-launcher/SKILL.md
- **what**: 排查并修复缺失的前后端依赖venv 和 node_modules 均不存在),安装 Node.js v22.15.0 到 %LOCALAPPDATA%\nodejs\,创建 Python venv 并安装全部后端依赖npm install 前端依赖requirements.txt 从范围版本更新为精确锁定版本;新增 requirements-lock.txt完整依赖树 61 个包)和 ENVIRONMENT.md 统一环境文档
- **why**: venv 和 node_modules 被 gitignore换机/重装后需要重新搭建;原 Node.js 安装已丢失requirements.txt 仅用 >= 范围无版本锁定
- **decisions**: Node.js 采用 zip 解压到 %LOCALAPPDATA% 而非 MSI 安装无管理员权限requirements.txt 直接锁定到当前精确版本而非继续用范围约束
- **notes**: project-launcher Skill 中的 Node.js 路径已同步更新为 %LOCALAPPDATA%\nodejs\
- **source_chat**: [环境依赖修复与文档生成]
### [CL-20260416-0830] 2026-04-16 08:30 — 生图按模型预处理 prompt官网/社区策略对齐)
- **tags**: 后端, 生图, prompt, SDXL, Flux, GPT Image, Gemini, Replicate, IP-Adapter, image_gen, Agent
- **affected_files**:

View File

@@ -2,6 +2,21 @@
最近 ~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重写
- [CL-20260416-1500] UX风格指南实现度补全涟漪/sendBounce/会话切换动画/Error飘散/云雾加载/流式opacity/排版类使用/左上切角/石青占位等10项全部落地文档每项标注✅状态+实现文件
- [CL-20260416-1330] 交互体验全面升级新增framer-motion动画引擎7级排版体系+4级Surface层级+网格底纹科技感,萤火粒子/数据流光/水墨晕染/投石入水/云雾凝聚/萤火点亮等微交互,用户/助手消息视觉拉开差距统一Loading/空态/Error状态全组件hover浮起+侧栏竖线+呼吸脉冲新增UX-STYLE-GUIDE.md文档
- [CL-20260416-1000] 集成Zero123++视角变换新增transform_view工具+view_transform.py服务单图输入生成6个固定视角图片grid拆分config.py新增VIEW_TRANSFORM_MODELS注册表loop.py特殊处理视角变换SSE事件进阶方案记入延期
- [CL-20260416-0930] 设备绑定配置解耦:新增.cursor/local-env.json(gitignored)存放nodejs_path和shellproject-launcher Skill和hooks改为读取该文件hooks增加bash版+dispatcher跨平台新增local-env-check Rule自动探测ENVIRONMENT.md去除硬编码
- [CL-20260416-0900] 环境依赖全面修复+统一环境文档安装Node.js v22.15.0+创建Python venv+安装全部前后端依赖requirements.txt锁定精确版本新增requirements-lock.txt和ENVIRONMENT.md更新project-launcher Skill路径
- [CL-20260416-0830] 生图按模型预处理 prompt新增 image_prompt_strategySDXL 默认负向词+---NEGATIVE---拆分Flux 剥粘贴的 Negative 段GPT/Gemini/IP-Adapter 规范化GenerateResult 带 effective_prompt/negative_promptReplicate SDXL 传 negative_prompt工具说明 SDXL 分隔符
- [CL-20260416-0810] 对话输入栏支持剪贴板粘贴图片为参考图ChatInput根容器onPasteCapture提取image/*文件并走startUploadplaceholder/title提示
- [CL-20260416-0745] Gemini原生生图延长读写超时+对RemoteProtocolError等可重试错误自动重试(默认3次指数退避)环境变量可微调VECTORENGINE_GEMINI_*_TIMEOUT
@@ -37,27 +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 生成"),便于模型溯源
- [CL-20260413-0440] 修复 Kolors IP-Adapter 404 错误:社区模型需 `owner/model:version_hash` 格式,补全版本 hash 后生图成功
- [CL-20260413-0430] 图片生成失败时将具体错误信息Replicate API 报错等)透传到前端展示,新增 tool_error SSE 事件
- [CL-20260413-0330] README 补充访问地址汇总(本机/局域网/外网)+ 穿透管理文档(安装/启停/排错)
- [CL-20260412-2345] 参考图上传独立化:选图即上传 + 环形进度条 + 成功/失败状态反馈 + 失败重试,后端新增独立上传端点
- [CL-20260412-2315] 修复 Cloudflare Tunnel 跨域开发警告next.config.ts 新增 allowedDevOrigins 通配符
- [CL-20260412-2300] 集成 Kolors IP-Adapter 模型:参考图可直接用于风格生成,模型注册表新增通用参考图支持机制
- [CL-20260412-2230] 用户消息气泡中显示参考图缩略图ChatMessage 新增 refImageUrl发送后在气泡中渲染参考图
- [CL-20260412-2200] 修复 DeepSeek 不支持 image_url 参考图上传报错:新增 vision 能力检测,非 vision 模型走文字提示
- [CL-20260412-2130] 修复图片生成失败时前端显示破碎图标:后端分离错误信息与有效 URL前端过滤无效 URL 不渲染空图片
- [CL-20260412-2100] 生图模型动态切换Provider 抽象架构 + 前端模型选择器 + Replicate 多模型flux-schnell/flux-dev/SDXL按消息粒度切换
- [CL-20260412-1800] 移动端适配 + Cloudflare Tunnel 内网穿透响应式布局改造9 个组件)+ 一键穿透脚本 + cloudflared 安装
- [CL-20260412-1530] 修复 .env 配置加载时序问题load_dotenv 前置 + override=True + config.py 改函数式懒读取
- [CL-20260412-1500] 品牌重命名 Art Agent → EPEEKit + 后端 API/模型配置集中化到 .env + config.py
- [CL-20260412-0300] 完成交互原型全面重构:三栏布局 + 多会话标签系统 + Canvas 标注 + 资源库页面
- [CL-20260411-1630] 安装 Node.js v24.14.1 + Python venv + 前后端依赖,后端启动验证通过
- [CL-20260411-1600] 从零创建美术 Agent MVP 全部前后端代码FastAPI + Next.js + Agent Loop + SSE
- [CL-20260411-1500] 完成美术 Agent 产品决策5 个核心问题和技术选型8 个维度)两份文档

View File

@@ -1,8 +1,83 @@
# 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
- **summary**: Windows PowerShell 5.1 在 UTF-8 无 BOM 下解析含中文的 `@"` here-string 会报错,导致穿透脚本无法运行。改为用 `WriteAllLines` 写入 `frontend/.env.local`,并将脚本保存为 UTF-8 BOM。脚本启动时若存在 `art-agent/cloudflared.exe` 则自动加入 PATH 优先使用。根目录 `.gitignore` 忽略本地下载的 `cloudflared.exe`(体积大)。
### [CL-20260417-1500] 2026-04-17 — 按 INFOLAYER.md 实现完整前端信息架构Phase 1+ 占位骨架)
- **tags**: 前端, UI骨架, INFOLAYER, 多项目, 风格库, 角色库, 训练中心, 项目设置, TaskCard, 候选评估, 占位数据
- **affected_files**: types.ts, placeholder-store.ts, app-context.tsx, globals.css, app/page.tsx, app/gallery/page.tsx, app/styles/*, app/characters/*, app/training/*, app/settings/page.tsx, top-nav.tsx, project-switcher.tsx, project-card.tsx, session-quick-picks.tsx, task-card.tsx, advanced-controls.tsx, candidate-panel.tsx, sidebar.tsx
- **summary**: 一次性铺开 INFOLAYER.md Phase 13 完整 UI 骨架:新增 /styles /characters /training /settings 四大模块(列表+详情+创建流程),工作台加入风格/角色/规格快选条、高级控制抽屉、候选评估面板、Task Card 组件,资源库加二级过滤(资产类型/风格/角色)+ 语义搜索切换TopNav 加项目切换器和新导航项Sidebar 加项目信息卡。所有 Phase 1+ 数据通过新建的 placeholder-store.ts + localStorage 占位持久化首次注入示例AppContext 扩充相关字段。所有真实后端行为均为 `alert("(占位)...")`UI 上以 phase-chipP1/P2/P3明示阶段。搜索"(占位)"可快速定位待接入点。
### [CL-20260416-1600] 2026-04-16 — 视觉风格从暗绿底全面转为白底青绿(绢本山水)
- **tags**: 前端, 视觉风格, 色彩体系, CSS变量, 白底, 千里江山图
- **affected_files**: globals.css, page.tsx, login/page.tsx, gallery/page.tsx, chat-messages.tsx, chat-input.tsx, top-nav.tsx, sidebar.tsx, session-list.tsx, profile-modal.tsx, image-detail-panel.tsx, ambient-particles.tsx, VISUAL-STYLE-GUIDE.md
- **summary**: 背景从墨绿黑(#0C1210)改为绢本白(#FAFAF7),文字改为墨黑(#1A1A1A)accent色加深(#4DB8A4#2E8B7A)适配白底所有面板从深色毛玻璃改为白色毛玻璃13个文件中硬编码的旧rgba全部替换云雾/粒子透明度降低VISUAL-STYLE-GUIDE.md同步重写。设计理念千里江山图是青绿设色于绢本白底白底留白才能让青绿有呼吸感。
### [CL-20260416-1500] 2026-04-16 — UX 风格指南实现度补全10 项未实现特性 + 文档状态标注)
- **tags**: 前端, UX补全, 动效, framer-motion, 微交互, 文档
- **affected_files**: chat-input.tsx, chat-messages.tsx, image-grid.tsx, motion-presets.ts, page.tsx, login/page.tsx, gallery/page.tsx, globals.css, app-context.tsx, sidebar.tsx, profile-modal.tsx, UX-STYLE-GUIDE.md
- **summary**: 逐项分析 UX-STYLE-GUIDE.md 中所有特性的实现度(原 ~65%),补全 10 项未实现特性涟漪接入发送按钮、sendBounce 弹缩、会话切换滑入滑出动画、Error 飘散消失+自动清除、回到底部水滴形、全屏云雾凝聚加载、AI 回复逐行 opacity 阶梯、typo-h1/h2/strong 实际使用、用户消息左上切角、图片占位石青渐变呼吸脉冲。文档更新为每项标注 ✅ 状态 + 实现文件路径。
### [CL-20260416-1330] 2026-04-16 — 交互体验全面升级:信息层级 + 科技质感 + 微交互趣味性
- **tags**: 前端, UX升级, 动效, framer-motion, 排版体系, 微交互, 视觉层级, 科技感
- **affected_files**: globals.css, page.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, session-list.tsx, sidebar.tsx, top-nav.tsx, image-detail-panel.tsx, profile-modal.tsx, model-selector.tsx, login/page.tsx, gallery/page.tsx, UX-STYLE-GUIDE.md, motion-presets.ts, ambient-particles.tsx, data-stream.tsx, ripple-effect.tsx, firefly-burst.tsx
- **summary**: 四阶段全面升级。Phase 1globals.css 新增 7 级排版体系Display 渐变→Micro 大写)+ 4 级 Surface 层级 + 点阵网格底纹 + 水波纹/shimmer/涟漪等CSS动画。Phase 2新建环境萤火粒子组件纯CSS+ 数据流光组件AI 活动时输入栏上方),用户/助手消息视觉差异(渐变背景+切角 vs glass-panel+左侧竖线。Phase 3引入 framer-motion统一动画参数 motion-presets.ts发送按钮投石入水spring 弹缩+呼吸脉冲)、输入框水面感应(焦点扩展线+亮度随输入增强、AI 回复云雾凝聚blur 消散入场、收藏萤火点亮金色粒子爆发、图片水墨晕染clip-path 圆形揭示。Phase 4统一 Loading 水波纹 + shimmer 骨架屏 + 错误赭石红震动 + 空态 Display 渐变欢迎语 + stagger 入场,滚到底部按钮改圆形 spring 弹入,侧栏会话左侧竖线 hover全组件 hover 浮起。新增 UX-STYLE-GUIDE.md 交互体验文档。
### [CL-20260416-1000] 2026-04-16 — 集成 Zero123++ 视角变换工具单图→6视角
- **tags**: 视角变换, Zero123++, Novel View Synthesis, 新工具, Replicate
- **affected_files**: config.py, tools.py, loop.py, view_transform.py
- **summary**: 新增 `transform_view` 工具集成 Zero123++Replicate输入一张图片生成 6 个固定视角图片。独立服务模块 `view_transform.py` 处理 Replicate 调用 + grid 拼接图 Pillow 拆分。config.py 新增 `VIEW_TRANSFORM_MODELS` 注册表。loop.py 对视角变换结果做特殊 SSE 事件处理。进阶方案3D重建+ControlNet记入延期方案。
### [CL-20260416-0930] 2026-04-16 — 设备绑定配置与 Skill/Hooks 解耦local-env.json
- **tags**: 基础设施, local-env, 跨设备, hooks, project-launcher, Skill
- **affected_files**: local-env.json, local-env.example.json, hooks.json, run-hook.ps1, session-init.sh, check-changelog.sh, local-env-check.mdc, project-launcher/SKILL.md, .gitignore, ENVIRONMENT.md
- **summary**: 新增 `.cursor/local-env.json`gitignored存放设备绑定配置nodejs_path、shellproject-launcher Skill 和 hooks 均改为从中读取。hooks 增加 bash 版脚本 + PowerShell dispatcher 支持跨平台。新增 `local-env-check` Rule 在会话开始时自动探测生成。ENVIRONMENT.md 去除平台硬编码改为通用说明。
### [CL-20260416-0900] 2026-04-16 — 环境依赖全面修复 + 统一环境文档
- **tags**: 环境, 依赖, Node.js, Python, venv, npm, requirements, ENVIRONMENT
- **affected_files**: requirements.txt, requirements-lock.txt, ENVIRONMENT.md, project-launcher/SKILL.md
- **summary**: 排查缺失依赖venv/node_modules 均不存在),安装 Node.js v22.15.0 到 %LOCALAPPDATA%\nodejs\,创建后端 venv 并安装全部依赖14 个直接 + 47 个间接npm install 前端依赖46 个包。requirements.txt 锁定精确版本,新增 requirements-lock.txt 和 ENVIRONMENT.md 统一环境文档。project-launcher Skill Node.js 路径已同步更新。
### [CL-20260416-0830] 2026-04-16 — 生图按模型预处理 prompt
- **tags**: 后端, 生图, prompt, SDXL, Flux, Replicate, image_gen, Agent工具
- **affected_files**: image_prompt_strategy.py, image_gen.py, tools.py
@@ -28,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

@@ -147,6 +147,44 @@
- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索
- **deferred_reason**: 当前阶段资源量少,手动管理足够;等资源积累到一定量级后再引入自动化
### [advanced-view-transform] 进阶视角变换 Pipeline3D 重建 + ControlNet 风格还原)
- **status**: deferred
- **tags**: 视角变换, 3D重建, ControlNet, Zero123++, TripoSR, InstantMesh, Novel View Synthesis, 手绘风格, IP-Adapter
- **recorded**: 2026-04-16
- **source_chat**: [视角变换方案讨论与 Zero123++ 集成](view-transform-zero123plus)
- **prerequisite**: Zero123++ 基础验证完成、确认视角变换是高频需求后
- **related_files**:
- art-agent/backend/app/services/view_transform.py
- art-agent/backend/app/config.py
- art-agent/backend/app/agent/tools.py
- **context**: |
当前已集成 Zero123++(固定 6 视角输出),但存在两个局限:
1. 视角固定6 个预设角度),无法自定义任意角度
2. 手绘风格保持度不够——Zero123++ 更擅长写实/3D 渲染风格
进阶方案分三个层级:
A. 更强的多视角生成模型:
- Era3D高质量多视角一致性建筑类友好
- SyncDreamer同步多视角生成一致性好
- Wonder3D多视角 + 法线图,精确视角控制
B. ControlNet 引导的视角变换:
- 用 Depth Anything V2 从原图提取深度图
- 用 ControlNetdepth/normal 模式)+ 目标视角描述重新生成
- 通过 IP-Adapter 注入原图风格保持手绘风格
C. 最佳质量方案(多步组合 Pipeline
原图 → TripoSR/InstantMesh 3D 重建 → 旋转到任意目标角度渲染灰度/线稿
→ ControlNet(canny/depth) + IP-Adapter(原图风格) → 手绘风格新视角图
这是质量最高的方案:视角精准 + 风格保真
实施依赖:
- 方案 B/C 需要 ComfyUI 环境(与 hf-inference-endpoints 延期方案关联)
- 方案 C 的 TripoSR/InstantMesh 可先在 Replicate 上验证
- **chosen_alternative**: 当前使用 Zero123++ 固定 6 视角输出(基础验证阶段)
- **deferred_reason**: 先用 Zero123++ 验证视角变换的实际需求频率和用户反馈,再投入进阶方案
---
## Completed / Cancelled Items

View File

@@ -0,0 +1,5 @@
# Golden Rules
经过实践验证(权重 >= 5的精炼认知每次新会话自动注入。
<!-- 当条目达到权重阈值后由 Agent 自动写入 -->

View File

@@ -0,0 +1,8 @@
# Problem Distillery — Insights
从已解决的顽固问题中蒸馏出的精炼方法论。
<!-- 新条目追加在此行下方 -->
---
last_distill_date: 2026-04-16

View File

@@ -0,0 +1,5 @@
# Problem Distillery — Problems
反复出现的顽固问题追踪记录,按时间倒序排列。
<!-- 新条目追加在此行下方 -->

View File

@@ -3,13 +3,13 @@
"hooks": {
"sessionStart": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/session-init.ps1",
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/run-hook.ps1 session-init",
"timeout": 5
}
],
"stop": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/check-changelog.ps1",
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/run-hook.ps1 check-changelog",
"timeout": 10,
"loop_limit": 1
}

38
.cursor/hooks/README.md Normal file
View File

@@ -0,0 +1,38 @@
# Cursor Hooks 说明
## 结构
```
hooks/
├── run-hook.ps1 # Windows dispatcher读取 local-env.json 后分发)
├── session-init.ps1 # sessionStart hookPowerShell 版)
├── session-init.sh # sessionStart hookbash 版)
├── check-changelog.ps1 # stop hookPowerShell 版)
├── check-changelog.sh # stop hookbash 版)
└── README.md # 本文件
```
## 跨平台适配
`hooks.json` 中的 `command` 字段是**平台绑定的**——Windows 用 `powershell`macOS/Linux 用 `bash`
### Windows当前
hooks.json 使用 `powershell ... run-hook.ps1` 作为入口dispatcher 根据
`.cursor/local-env.json``shell` 字段决定执行 `.ps1` 还是 `.sh` 脚本。
### 迁移到 macOS / Linux
将 hooks.json 的 command 改为直接调用 `.sh` 脚本:
```json
{
"version": 1,
"hooks": {
"sessionStart": [{ "command": "bash .cursor/hooks/session-init.sh", "timeout": 5 }],
"stop": [{ "command": "bash .cursor/hooks/check-changelog.sh", "timeout": 10, "loop_limit": 1 }]
}
}
```
同时更新 `.cursor/local-env.json``shell` 字段为 `bash``zsh`

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Changelog sync guard — stop hook (bash 版)
input=$(cat)
# === 豁免检查 1环境变量跳过标志 ===
if [ -n "$CURSOR_SKIP_CHANGELOG" ]; then
echo '{}'; exit 0
fi
# === 豁免检查 2从 stdin 解析 composer_mode ===
mode=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('composer_mode',''))" 2>/dev/null || echo "")
if [ -n "$mode" ] && [ "$mode" != "agent" ]; then
echo '{}'; exit 0
fi
# === 豁免检查 3从 stdin 文本匹配 debug 上下文关键词 ===
if echo "$input" | grep -qiE '"mode"\s*:\s*"debug"|debug[\s_-]?mode|Debug Mode'; then
echo '{}'; exit 0
fi
changelog=".cursor/changelog/changelog-headlines.md"
srcDir="art-agent"
# === 豁免检查 4changelog 文件不存在 ===
if [ ! -f "$changelog" ]; then
echo '{}'; exit 0
fi
clMtime=$(stat -c %Y "$changelog" 2>/dev/null || stat -f %m "$changelog" 2>/dev/null)
# === 豁免检查 5ack 标记文件足够新 ===
ackFile=".cursor/changelog/.changelog-ack"
if [ -f "$ackFile" ]; then
ackMtime=$(stat -c %Y "$ackFile" 2>/dev/null || stat -f %m "$ackFile" 2>/dev/null)
if [ "$ackMtime" -ge "$clMtime" ] 2>/dev/null; then
echo '{}'; exit 0
fi
fi
# === 豁免检查 6stdin 中无文件编辑证据 ===
if ! echo "$input" | grep -qE 'StrReplace|Write\s*tool|edit_file|file_write|write_to_file|Created file|Modified file|Wrote contents|"tool"\s*:\s*"(str_replace|write|edit)"'; then
echo '{}'; exit 0
fi
# === 核心检查:是否有源文件比 changelog 更新 ===
newerFile=$(find "$srcDir" -type f \( -name "*.py" -o -name "*.tsx" -o -name "*.ts" -o -name "*.css" \) \
! -path "*/node_modules/*" ! -path "*/.next/*" ! -path "*/__pycache__/*" ! -path "*/venv/*" \
-newer "$changelog" -print -quit 2>/dev/null)
if [ -n "$newerFile" ]; then
fname=$(basename "$newerFile")
msg="[Hook] Source file updated (e.g. $fname) but changelog not synced. Run dev-changelog Skill operation A NOW to write all three changelog layers."
echo "{\"followup_message\":\"$msg\"}"
else
echo '{}'
fi
exit 0

View File

@@ -0,0 +1,35 @@
# run-hook.ps1 — 通用 hook dispatcher
# 从 .cursor/local-env.json 读取 shell 类型,决定执行 .ps1 还是 .sh 脚本
# 用法powershell -ExecutionPolicy Bypass -File .cursor/hooks/run-hook.ps1 <hook-name>
# 示例run-hook.ps1 session-init → 执行 session-init.ps1 或 session-init.sh
param([string]$HookName)
$localEnvPath = ".cursor\local-env.json"
$shell = "powershell"
if (Test-Path $localEnvPath) {
try {
$config = Get-Content $localEnvPath -Raw | ConvertFrom-Json
if ($config.shell) { $shell = $config.shell }
} catch {}
}
$hookDir = ".cursor\hooks"
$input = [Console]::In.ReadToEnd()
if ($shell -eq "powershell") {
$scriptPath = Join-Path $hookDir "$HookName.ps1"
if (Test-Path $scriptPath) {
$input | powershell -ExecutionPolicy Bypass -File $scriptPath
} else {
Write-Output '{}'
}
} else {
$scriptPath = Join-Path $hookDir "$HookName.sh"
if (Test-Path $scriptPath) {
$input | bash $scriptPath
} else {
Write-Output '{}'
}
}

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# session-init.sh — 会话启动时检测 composer_mode非 agent 模式设置跳过标志
input=$(cat)
mode=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('composer_mode',''))" 2>/dev/null || echo "")
rm -f ".cursor/changelog/.changelog-ack"
if [ -n "$mode" ] && [ "$mode" != "agent" ]; then
echo "{\"env\":{\"CURSOR_SKIP_CHANGELOG\":\"1\",\"CURSOR_COMPOSER_MODE\":\"$mode\"}}"
else
echo "{}"
fi
exit 0

View File

@@ -0,0 +1,5 @@
{
"_comment": "设备绑定的本地环境配置。复制为 local-env.json 并修改为本机实际值。",
"nodejs_path": "C:\\Users\\<username>\\AppData\\Local\\nodejs",
"shell": "powershell"
}

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

@@ -0,0 +1,44 @@
## Problem Distillery 上下文注入
### Golden Rules 注入
每次会话处理用户第一个任务前,执行以下操作:
1. 读取 `.cursor/distillery/golden-rules.md`(不存在则跳过)
2. 如文件存在且有实质条目(不仅是标题),将全部条目作为背景知识注入上下文
3. 这些是经过实践反复验证的精炼认知Agent 在分析和解决问题时应自然参考
4. 不需要在回复中显式提及"根据 Golden Rule"
### 蒸馏提醒检查
每次会话处理用户第一个任务前,额外检查:
1. 读取 `.cursor/distillery/insights.md`(不存在则跳过)
2. 检查文件尾部的 `last_distill_date` 字段
3. 如果距今超过 7 天,读取 `.cursor/distillery/problems.md`
4. 统计 `status: resolved` 且无 `distilled:` 标记的条目数量
5. 如有未蒸馏的已解决条目,提醒用户:
> 你有 N 个已解决的顽固问题尚未总结,要花几分钟蒸馏一下吗?
6. 每次会话最多提醒一次
### 顽固问题检测
Agent 在整个对话过程中应保持对以下信号的被动感知:
1. 用户表达问题未解决:"还有问题"、"没解决"、"还是一样"、"又出现了"、"不行"、"没用"
2. Agent 自身意识到同一问题已尝试 2 次以上仍未解决
3. pitfall-journal 中已有记录的问题再次出现
检测到上述信号时,读取 `problem-distillery` Skill 并执行其操作 A。
### 验证触发
当操作 A 触发时(追踪新的或再次出现的顽固问题),如果 `insights.md` 非空,
还应执行操作 D 的被动验证流程——匹配是否有相关的已蒸馏经验可供参考。
### 注意
- Golden Rules 注入是极轻量操作(预期 < 30 行),每次会话都执行
- 蒸馏提醒按需触发,只在条件满足时提醒
- problems.md 的详细记录**不主动读取**,仅在操作 A/B/C 时按需读取
- 本 Rule 只负责触发和注入,具体操作流程由 `problem-distillery` Skill 定义

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,47 @@
---
description: 检查本地环境配置文件 .cursor/local-env.json 是否存在,不存在时自动探测并生成
globs:
alwaysApply: true
---
## 本地环境配置自动初始化
每次会话处理用户第一个任务前,检查 `.cursor/local-env.json` 是否存在:
### 文件存在时
读取并验证 JSON 格式正确、包含 `nodejs_path` 和 `shell` 两个字段。
字段缺失时补充探测(见下方流程),不覆盖已有字段。
### 文件不存在时
执行自动探测流程,生成 `.cursor/local-env.json`
```
1. 探测 shell 类型:
- Windows$env:OS 包含 "Windows")→ "powershell"
- macOS/Linux → 读取 $SHELL 环境变量末段bash/zsh/fish 等)
2. 探测 Node.js 路径:
- Windows运行 `where.exe node`,排除 Cursor 内置的 node
(路径含 "cursor" 的不算),取第一个有效结果的父目录
- macOS/Linux运行 `which node`,取父目录
- 找不到 → 设为 null
3. 写入 .cursor/local-env.json
{
"nodejs_path": "<探测到的路径或 null>",
"shell": "<探测到的 shell>"
}
4. 简要告知用户:
> 已自动生成本地环境配置 `.cursor/local-env.json`
> 检测到 Node.js 路径为 xxxshell 为 xxx。如需调整请手动编辑该文件。
```
### 注意事项
- `local-env.json` 已在 `.gitignore` 中,不会进入版本控制
- 该文件存储**设备绑定**的配置,与 `.env`(存储密钥/API key互补
- 只在文件不存在或字段缺失时触发探测,不会每次会话都重新探测
- 探测结果不一定准确(如多个 Node.js 版本并存),用户可随时手动修改

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

@@ -83,7 +83,7 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
4. 写入三个文件(按以下顺序):
a. 读取 changelog-full.md在 "## 记录" 下方追加 L1 条目
b. 读取 changelog-recent.md在顶部插入 L2 条目,如超过 10 条则移除最旧的
b. 读取 changelog-recent.md在顶部插入 L2 条目,如超过 20 条则移除最旧的与下方「L2 窗口」一致)
c. 读取 changelog-headlines.md在顶部插入 L3 条目,如超过 50 条则移除最旧的
5. 在回复末尾附一行提示:"[已记录到开发日志]"
@@ -247,3 +247,13 @@ 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 双保险兜底
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklistL3 概要每次会话自动注入上下文stop Hook 双保险兜底;写入后须维护 L3≤50 / L2≤20 滚动窗口L1/L2 多行条目须遵循"顶部插入 StrReplace 范式"防止旧首条目被截断(见 Skill 自迭代检查项)
### pitfall-journal
- **类型**: 基础设施
@@ -47,7 +55,15 @@
- **触发场景**: 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
- **类型**: 基础设施
- **能力**: 追踪反复出现的顽固问题,记录解决过程和弯路,定期蒸馏精炼方法论,经实践验证后自动升级为新会话常规注入
- **触发场景**: "还有问题"、"没解决"、"还是一样"、"又出现了"、"试了好多次"、"反复出现"、Agent 意识到同一问题尝试 2 次以上未解决、"蒸馏"、"总结经验"
- **输出**: .cursor/distillery/ 下的 problems.md、insights.md、golden-rules.md
- **路径**: .cursor/skills/problem-distillery/SKILL.md
- **备注**: 与 pitfall-journal 互补——pitfall 是事后快照distillery 是过程追踪 + 知识提炼golden-rules 由 distillery-recall Rule 每次会话注入
### project-launcher
- **类型**: 项目级
@@ -55,4 +71,4 @@
- **触发场景**: "启动项目"、"运行项目"、"跑起来"、"打开前端和后端"、"启动服务"、"start"、"launch"、"run dev"、"启动前端"、"启动后端"
- **输出**: Ollama 后台服务 + 两个独立的终端窗口(前端 Next.js + 后端 FastAPI
- **路径**: .cursor/skills/project-launcher/SKILL.md
- **备注**: 项目专属 SkillOllama 必须先于后端启动Mem0 embedding 依赖);前后端窗口必须可见
- **备注**: 项目专属 Skill启动前必须先读取 `.cursor/local-env.json` 获取 nodejs_path 和 shellOllama 必须先于后端启动Mem0 embedding 依赖);前后端窗口必须可见

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

@@ -0,0 +1,232 @@
---
name: problem-distillery
description: >-
从顽固问题中蒸馏方法论的渐进式知识系统。追踪反复出现且未被彻底解决的问题,
记录解决过程和弯路,定期提炼精炼认知,经实践验证后自动升级为常规注入。
当用户表达"还有问题"、"没解决"、"还是一样"、"又出现了"等语义,
或 Agent 自身意识到同一问题已尝试 2 次以上仍未解决时触发。
---
# Problem Distillery
从反复出现的顽固问题中,经过"追踪 → 解决 → 蒸馏 → 验证 → 注入"的完整生命周期,
蒸馏出经过实践验证的精炼认知和方法论。
与其他系统的区别:
- **pitfall-journal**互补。pitfall 是事后快照debug 完记一笔distillery 是过程追踪 + 知识提炼
- **dev-changelog**不重叠。changelog 记事实改动distillery 记问题解决过程和认知沉淀
## 数据文件
```
.cursor/distillery/
problems.md -- 问题记录open + resolved操作 A/B 写入
insights.md -- 蒸馏后的精炼方法论,操作 C 写入
golden-rules.md -- 权重达标后升级的条目,操作 D 管理,新会话自动注入
```
## 条目格式
### problems.md 条目
```markdown
### [PD-YYYYMMDD-HHMM] 一句话标题
- **status**: open | resolved
- **fingerprint**: 关键特征词列表(用于匹配同一问题)
- **category**: tech | workflow | decision | other
- **first_seen**: YYYY-MM-DD HH:MM
- **attempts**:
1. [YYYY-MM-DD HH:MM] 尝试了什么 → 结果如何
2. [YYYY-MM-DD HH:MM] 又尝试了什么 → 结果如何
- **resolution**: (解决后填写)最终解决方案
- **dead_ends**: (解决后填写)走过的弯路及其失败原因
- **spark**: (解决后填写)一句话启发——这个问题教会了什么
- **distilled**: 蒸馏后填写IN-xxx
```
### insights.md 条目
```markdown
### [IN-YYYYMMDD-NN] 一句话方法论
- **source_problems**: [PD-xxx, PD-yyy]
- **weight**: 0
- **promoted**: false | true | demoted
- **content**: 2-3 句精炼认知
```
文件尾部保留元数据:
```markdown
---
last_distill_date: YYYY-MM-DD
```
### golden-rules.md
```markdown
# Golden Rules
经过实践验证(权重 >= 5的精炼认知每次新会话自动注入。
1. [GR-001] 一句话认知(来源: IN-xxx, 累计验证 N 次)
2. [GR-002] ...
```
硬上限 15 条。超出时按权重排序保留 top-15被淘汰的条目降回 insights.mdpromoted 改为 demoted
## 操作 A追踪记录问题进行中
### 触发条件
以下**任一**场景触发:
1. **用户明确表达**问题未解决:
- "还有问题"、"问题没有解决"、"还是一样"、"又出现了"
- "试了好多次了"、"这个问题反复出现"、"不行"、"没用"
- 以及其他表达"问题反复发生、Agent 没有完全解决"的语义
2. **Agent 自身意识到**同一问题已经尝试了 2 次以上仍未解决
### 适用范围
不限于技术问题——工作流设计、产品决策等各类反复纠结的问题均适用。
### 流程
```
1. 提取当前问题的指纹:
- 技术问题:错误信息关键词、涉及文件/模块、症状描述
- 非技术问题:核心矛盾点、涉及领域、反复出现的决策困境
2. 读取 problems.md用指纹匹配 status=open 的条目
3. 匹配到已有条目 → 在 attempts 中追加本次尝试记录
4. 无匹配 → 创建新条目:
- 生成 IDPD-YYYYMMDD-HHMM
- status: open
- 记录首次尝试
5. 告知用户:"已开始追踪这个问题 [PD-xxx]"(首次)
或 "已更新追踪记录 [PD-xxx],这是第 N 次尝试"(后续)
```
### 与 pitfall-journal 的衔接
当 pitfall-journal 中某个条目的同一问题反复出现(用户再次报告相同症状),
Agent 应意识到这已超出 pitfall 的"一次性记录"范畴,主动触发操作 A 建立追踪。
## 操作 B解决记录
### 触发条件
存在 status=open 的追踪条目,且满足以下**任一**
- 用户确认问题已解决:"好了"、"解决了"、"终于可以了"
- Agent 判断问题已解决(测试通过、错误消失等)
### 流程
```
1. 填写 resolution最终的解决方案
2. 填写 dead_ends走过的弯路及其失败原因从 attempts 中归纳)
3. 填写 spark一句话启发——这个问题教会了什么
4. status 改为 resolved
5. 回复末尾附 [顽固问题已解决并记录]
```
### 注意
- 如果问题在当前会话中从发现到解决只花了 1-2 次尝试,不需要走 distillery 流程
(那是 pitfall-journal 的范畴)
- 只有经历了"反复尝试"的问题才值得 distillery 追踪
## 操作 C定期蒸馏
### 触发条件
`distillery-recall` Rule 在每次会话开头检查:
- 读取 insights.md 尾部的 `last_distill_date`
- 如果距今超过 7 天,且 problems.md 中有未标记 `distilled` 的 resolved 条目
- 则提醒用户:"你有 N 个已解决的顽固问题尚未总结,要花几分钟蒸馏一下吗?"
- 每次会话最多提醒一次
### 流程(用户同意后)
```
1. 读取所有 status=resolved 且无 distilled 标记的条目
2. Agent 分析这些问题的共性,提出归纳建议:
- 哪些问题有共同的根因模式?
- 能提炼出什么通用的认知或方法论?
- 建议的表述2-3 句精炼认知)
3. 用户确认/修改后:
- 写入 insights.mdID 格式IN-YYYYMMDD-NNNN 为当日序号)
- weight 初始为 0
- promoted: false
4. 更新 insights.md 尾部的 last_distill_date
5. 在对应 problems.md 条目上标记 distilled: IN-xxx
```
## 操作 D验证与升级
### 被动验证
当操作 A 触发时(遇到新的或再次出现的顽固问题),额外执行:
```
1. 读取 insights.md对当前问题做语义匹配
2. 如有相关条目,向用户展示:
"之前总结过一条相关经验 [IN-xxx]: [内容摘要],可能对当前问题有帮助。"
3. 在问题解决流程中,跟踪该经验是否发挥了作用:
- 用户确认"这个提示有用"
- 或 Agent 判断解决方案与该 insight 的方向一致
4. 确认有用 → weight += 1在 insights.md 中更新
5. 确认无用 → 不扣分weight 只增不减,避免偶然失误惩罚好经验)
```
### 升级为 Golden Rule
```
1. 当某条 insight 的 weight >= 5
a. 检查 golden-rules.md 当前条目数
b. 如 < 15 → 直接升级:
- 在 golden-rules.md 中追加条目ID: GR-NNNNNN 为递增序号)
- insights.md 中 promoted 改为 true
c. 如 = 15 → 比较权重:
- 新条目权重 > golden-rules.md 中最低权重条目 → 替换
- 被替换的条目降回 insights.mdpromoted 改为 demoted
- 否则不升级
2. 升级后告知用户:
"经验 [IN-xxx] 已累计验证 N 次,升级为 Golden Rule [GR-NNN],后续新会话将自动注入。"
```
### 新会话注入
`distillery-recall` Rule 负责:
- 每次会话开头读取 `golden-rules.md`
- 如果非空,将全部条目作为背景知识注入上下文
- 注入方式与 `changelog-headlines` 同级别——轻量、不显式提及来源
## 容量与性能控制
| 文件 | 策略 | 阈值 |
|------|------|------|
| problems.md | resolved 且已 distilled 的条目超过 50 条时,归档到 problems-archive.md | 50 条 |
| insights.md | 无上限(条目本身是精炼的,每条 3-5 行) | — |
| golden-rules.md | 硬上限,按权重淘汰 | 15 条 |
| 新会话注入成本 | 只读 golden-rules.md预期 < 30 行) | 极轻量 |
## 与其他系统的协作
| 系统 | 关系 | 说明 |
|------|------|------|
| `pitfall-journal` | 上游来源 | pitfall 条目反复出现时,升级为 distillery 追踪 |
| `dev-changelog` | 不重叠 | changelog 记事实改动distillery 记过程和认知 |
| `distillery-recall` Rule | 下游消费者 | 负责 golden-rules 注入和蒸馏提醒 |
| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill |
## 自迭代日志
本节记录使用本 Skill 过程中发现的必要检查项。
### 已知必要检查
(暂无)

View File

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

View File

@@ -32,14 +32,33 @@ description: >-
- 前端需要 Node.js >= 18
- **Ollama 必须在后端之前启动**Mem0 记忆系统依赖 Ollama 提供本地 embedding 服务(`nomic-embed-text` 模型),端口 `11434`
## 环境 PATH 须知
## 本地环境配置local-env.json
本机 Node.js 安装在 `C:\Program Files\nodejs\` 但**未加入系统 PATH**
新开的终端窗口默认找不到 `node` / `npm` 命令。
设备绑定的路径和配置统一存放在 `.cursor/local-env.json`(已 gitignored不硬编码到 Skill 中
**解决方式**:在启动前端的命令中,先将 Node.js 路径注入到当前会话的 `$env:PATH`
**启动前必须先读取该文件**,从中获取 `nodejs_path``shell` 字段
> 如果后续 Node.js 路径发生变化(如用户重新安装或使用 nvm需要更新此处。
| 字段 | 用途 | 示例值 |
|------|------|--------|
| `nodejs_path` | Node.js 安装目录(含 node/npm | `C:\Users\xxx\AppData\Local\nodejs` |
| `shell` | 当前设备使用的 shell 类型 | `powershell` / `bash` / `zsh` |
**文件不存在时的自动探测流程**
```
1. 创建空的 JSON 对象 {}
2. 探测 Node.js 路径:
- 运行 `where.exe node`Windows或 `which node`macOS/Linux
- 取其父目录作为 nodejs_path
- 如果找不到,将 nodejs_path 设为 null后续启动前端时提醒用户手动配置
3. 探测 shell 类型:
- Windows 默认 "powershell"
- macOS/Linux 读取 $SHELL 环境变量提取末尾bash/zsh/fish 等)
4. 写入 .cursor/local-env.json
5. 告知用户已自动生成本地配置,可手动调整
```
**Node.js 不在系统 PATH 时**:启动前端的命令中需要将 `nodejs_path` 注入到当前会话的 PATH。
## 核心操作:启动服务
@@ -48,23 +67,25 @@ description: >-
```
1. 确定项目根目录workspace 根目录下的 art-agent/
2. 确定操作系统和 Shell 类型Windows / macOS / Linux
2. 读取 .cursor/local-env.json
- 存在 → 解析 nodejs_path 和 shell
- 不存在 → 执行自动探测流程(见上方),生成后再读取
3. 检查并启动 Ollama
- 检测 Ollama 是否已在运行(请求 http://localhost:11434/api/tags
- 未运行 → 启动 Ollama 服务,等待就绪
- 已运行 → 跳过
4. 启动后端(在独立可见终端窗口中)
- WindowsPowerShell 或 CMD 均适用):
使用 `Start-Process` 或 `start cmd` 打开新的终端窗口
- macOS/Linux
使用对应的终端打开方式
4. 根据 shell 字段选择对应的启动命令模板
- powershell → 使用 PowerShell 命令
- bash/zsh → 使用 macOS/Linux 命令
5. 启动端(在另一个独立可见终端窗口中)
- 同样在新的终端窗口中启动
5. 启动端(在独立可见终端窗口中)
6. 确认三个服务正在运行,告知用户访问地址
6. 启动前端(在另一个独立可见终端窗口中):
- 如果 nodejs_path 不为 null先注入到 PATH
7. 确认三个服务正在运行,告知用户访问地址
```
### Ollama 启动(跨平台通用)
@@ -87,44 +108,36 @@ try {
> Ollama 启动后会常驻后台,不需要独立终端窗口。如果用户系统已将 Ollama 设为开机自启,
> 则检测会直接通过,不会重复启动。
### Windows 启动命令
### 启动命令模板
**关键要求**:必须在**新的、可见的终端窗口**中启动,不能在 Cursor 内置终端后台运行。
#### PowerShell 环境
以下模板中的变量说明:
- `BACKEND_PATH` / `FRONTEND_PATH`:替换为实际绝对路径
- `NODEJS_PATH`:从 `local-env.json``nodejs_path` 字段读取
#### shell = "powershell"
启动后端:
```powershell
Start-Process powershell -ArgumentList '-NoExit', '-Command', "cd 'BACKEND_PATH'; .\venv\Scripts\Activate.ps1; uvicorn app.main:app --reload --host 0.0.0.0 --port 8000" -WindowStyle Normal
```
启动前端(注意注入 Node.js PATH
启动前端(注入从 local-env.json 读取的 NODEJS_PATH
```powershell
Start-Process powershell -ArgumentList '-NoExit', '-Command', "& { `$env:PATH = 'C:\Program Files\nodejs;' + `$env:PATH; `$Host.UI.RawUI.WindowTitle = 'Art Agent Frontend'; cd 'FRONTEND_PATH'; npm run dev }" -WindowStyle Normal
Start-Process powershell -ArgumentList '-NoExit', '-Command', "& { `$env:PATH = 'NODEJS_PATH;' + `$env:PATH; `$Host.UI.RawUI.WindowTitle = 'Art Agent Frontend'; cd 'FRONTEND_PATH'; npm run dev }" -WindowStyle Normal
```
#### CMD 环境
#### shell = "bash" / "zsh"macOS / Linux
启动后端:
```cmd
start "Art Agent Backend" cmd /k "cd /d BACKEND_PATH && venv\Scripts\activate && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"
```
启动前端(注意注入 Node.js PATH
```cmd
start "Art Agent Frontend" cmd /k "set PATH=C:\Program Files\nodejs;%PATH% && cd /d FRONTEND_PATH && npm run dev"
```
### macOS / Linux 启动命令
根据用户终端环境选择:
```bash
# 后端
osascript -e 'tell application "Terminal" to do script "cd BACKEND_PATH && source venv/bin/activate && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"'
```
# 前端
osascript -e 'tell application "Terminal" to do script "cd FRONTEND_PATH && npm run dev"'
启动前端NODEJS_PATH 为 null 时跳过 PATH 注入):
```bash
osascript -e 'tell application "Terminal" to do script "export PATH=NODEJS_PATH:$PATH && cd FRONTEND_PATH && npm run dev"'
```
### 执行注意事项
@@ -156,5 +169,6 @@ osascript -e 'tell application "Terminal" to do script "cd FRONTEND_PATH && npm
### 已知必要检查
1. **Node.js PATH 注入** — 本机 Node.js (`C:\Program Files\nodejs\`) 未加入系统 PATH新开的终端窗口默认找不到 `npm`。启动前端时必须先将此路径注入到会话 PATH 中
2. **Ollama 必须先于后端启动** — Mem0 记忆系统依赖 Ollama 的 `nomic-embed-text` 模型做本地 embedding端口 11434。Ollama 未运行时后端能启动但对话会报 502 错误。启动流程必须在后端之前检测并启动 Ollama
1. **先读 local-env.json** — 启动前必须读取 `.cursor/local-env.json` 获取 `nodejs_path``shell`。文件不存在时执行自动探测并生成。绝不在 Skill 中硬编码设备相关的路径
2. **Node.js PATH 注入** — 如果 `nodejs_path` 不为 null说明 Node.js 未在系统 PATH 中,启动前端时必须将该路径注入到会话 PATH
3. **Ollama 必须先于后端启动** — Mem0 记忆系统依赖 Ollama 的 `nomic-embed-text` 模型做本地 embedding端口 11434。Ollama 未运行时后端能启动但对话会报 502 错误。启动流程必须在后端之前检测并启动 Ollama。

5
.gitignore vendored
View File

@@ -34,10 +34,15 @@ out/
art-agent/backend/uploads/
art-agent/backend/generated/
art-agent/backend/data/
# 本地下载的 cloudflared CLI体积大见 README / start-tunnel.ps1
art-agent/cloudflared.exe
# ─── 工具二进制 ──────────────────────────────────────────
*.exe
# ─── 本地环境配置(设备绑定,不入版本控制)──────────────
.cursor/local-env.json
# ─── IDE / 编辑器 ────────────────────────────────────────
.vscode/
.idea/

206
art-agent/ENVIRONMENT.md Normal file
View File

@@ -0,0 +1,206 @@
# EPEEKit 开发环境说明
> 最后更新2026-04-16
## 系统要求
| 项目 | 最低版本 | 备注 |
|------|---------|------|
| OS | Windows 10+ / macOS / Linux | 跨平台支持 |
| Python | 3.12+ | 用于后端 |
| Node.js | 22+ LTS | 用于前端 |
| npm | 10+ | 随 Node.js 安装 |
| Ollama | latest | Mem0 embedding 服务依赖,需启动 `nomic-embed-text` 模型 |
## 本地环境配置local-env.json
设备绑定的路径(如 Node.js 安装位置)**不硬编码在任何 Skill 或配置中**
统一通过 `.cursor/local-env.json` 管理。
| 字段 | 说明 | 示例 |
|------|------|------|
| `nodejs_path` | Node.js 安装目录(含 node/npm`null` 表示已在系统 PATH | `C:\Users\xxx\AppData\Local\nodejs` |
| `shell` | 当前设备的 shell 类型 | `powershell` / `bash` / `zsh` |
- **首次使用**Agent 会自动探测并生成,也可手动复制 `.cursor/local-env.example.json` 并修改
- **已 gitignored**:不进入版本控制,每台设备独立维护
- **模板参考**`.cursor/local-env.example.json`
### Node.js PATH 注意事项
如果 `nodejs_path` 不为 null说明 Node.js 未在系统 PATH 中,启动前端前需注入:
```powershell
# PowerShell路径来自 local-env.json 的 nodejs_path
$env:PATH = "你的nodejs_path;" + $env:PATH
```
```bash
# bash/zsh
export PATH="你的nodejs_path:$PATH"
```
## 项目结构
```
EPEEAIKit/
├── .cursor/
│ ├── local-env.json # 本地环境配置gitignored设备绑定
│ ├── local-env.example.json # 本地环境配置模板git tracked
│ ├── hooks.json # Cursor hooks 配置
│ └── hooks/ # Hook 脚本(.ps1 + .sh 双版本)
├── art-agent/
│ ├── backend/ # FastAPI 后端Python
│ │ ├── app/ # 应用代码
│ │ ├── venv/ # Python 虚拟环境gitignored
│ │ ├── requirements.txt # 直接依赖(锁定版本)
│ │ └── requirements-lock.txt # 完整依赖树pip freeze
│ ├── frontend/ # Next.js 前端TypeScript
│ │ ├── src/ # 应用代码
│ │ ├── node_modules/ # npm 依赖gitignored
│ │ ├── package.json # 直接依赖
│ │ └── package-lock.json # 完整依赖树锁定
│ └── ENVIRONMENT.md # 本文件
```
## 后端依赖Python
### 直接依赖
| 包名 | 版本 | 用途 |
|------|------|------|
| fastapi | 0.135.3 | Web 框架 |
| uvicorn | 0.44.0 | ASGI 服务器 |
| openai | 2.32.0 | OpenAI / 向量引擎 LLM & 生图 API |
| replicate | 1.0.7 | Replicate 生图 APIFlux/SDXL/Kolors 等) |
| sse-starlette | 3.3.4 | Server-Sent Events 流式响应 |
| python-multipart | 0.0.26 | 文件上传处理 |
| httpx | 0.28.1 | 异步 HTTP 客户端 |
| python-dotenv | 1.2.2 | 环境变量 `.env` 加载 |
| Pillow | 12.2.0 | 图像处理 |
| mem0ai | 1.0.11 | Mem0 长期记忆系统 |
| ollama | 0.6.1 | Ollama 本地 LLM/embedding 客户端 |
| sqlmodel | 0.0.38 | SQLModel ORMSQLite 用户表) |
| pwdlib[argon2,bcrypt] | 0.3.0 | 密码哈希Argon2id + bcrypt 兼容) |
| python-jose[cryptography] | 3.5.0 | JWT 认证 |
### 间接依赖(关键)
| 包名 | 版本 | 来源 |
|------|------|------|
| pydantic | 2.13.1 | fastapi 依赖 |
| starlette | 1.0.0 | fastapi 依赖 |
| SQLAlchemy | 2.0.49 | sqlmodel / mem0ai 依赖 |
| qdrant-client | 1.17.1 | mem0ai 依赖(向量数据库) |
| numpy | 2.4.4 | qdrant-client 依赖 |
| grpcio | 1.80.0 | qdrant-client 依赖 |
| cryptography | 46.0.7 | python-jose[cryptography] 依赖 |
| argon2-cffi | 25.1.0 | pwdlib[argon2] 依赖 |
| bcrypt | 5.0.0 | pwdlib[bcrypt] 依赖 |
完整依赖树见 `requirements-lock.txt`(共 61 个包)。
## 前端依赖Node.js
### 直接依赖dependencies
| 包名 | 版本 | 用途 |
|------|------|------|
| next | 15.5.15 | React 全栈框架 |
| react | 19.2.5 | UI 库 |
| react-dom | 19.2.5 | React DOM 渲染 |
### 开发依赖devDependencies
| 包名 | 版本 | 用途 |
|------|------|------|
| typescript | 5.9.3 | TypeScript 编译器 |
| tailwindcss | 4.2.2 | CSS 工具框架 |
| @tailwindcss/postcss | 4.2.2 | Tailwind PostCSS 插件 |
| postcss | 8.5.9 | CSS 后处理器 |
| @types/node | 22.19.17 | Node.js 类型定义 |
| @types/react | 19.2.14 | React 类型定义 |
| @types/react-dom | 19.2.3 | ReactDOM 类型定义 |
完整依赖树见 `package-lock.json`(共 46 个包)。
## 外部服务依赖
| 服务 | 端口 | 用途 | 必需 |
|------|------|------|------|
| Ollama | 11434 | 本地 embeddingnomic-embed-text| 是Mem0 依赖) |
| Qdrant | 6333 | 本地向量数据库Mem0 存储) | 是Mem0 依赖) |
## 环境变量
后端通过 `.env` 文件加载配置,主要包含:
- LLM API Key向量引擎 / DeepSeek
- Replicate API Token
- 代理配置HTTPS_PROXY
- Gemini 超时参数VECTORENGINE_GEMINI_*_TIMEOUT
- JWT Secret
详见 `art-agent/backend/.env`gitignored不纳入版本控制
## 环境搭建步骤
### 0. 本地环境配置(首次)
复制 `.cursor/local-env.example.json``.cursor/local-env.json`,修改为本机实际值。
或者启动 Cursor Agent 会话Agent 会自动探测并生成。
### 1. 后端
```bash
cd art-agent/backend
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
# Windows PowerShell:
.\venv\Scripts\Activate.ps1
# macOS/Linux:
source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
# 启动(需要先启动 Ollama
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### 2. 前端
```bash
cd art-agent/frontend
# 如果 Node.js 未在系统 PATH 中(参考 local-env.json 的 nodejs_path
# PowerShell: $env:PATH = "你的nodejs_path;" + $env:PATH
# bash/zsh: export PATH="你的nodejs_path:$PATH"
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
### 3. Ollama
```bash
# 启动 Ollama 服务
ollama serve
# 拉取 embedding 模型(首次)
ollama pull nomic-embed-text
```
## 访问地址
| 服务 | 地址 |
|------|------|
| 前端页面 | http://localhost:3000 |
| 后端 API | http://localhost:8000 |
| API 文档 | http://localhost:8000/docs |
| Ollama | http://localhost:11434 |

View File

@@ -46,6 +46,7 @@ SYSTEM_PROMPT = """\
- 如果用户提供了参考图(支持多张),将参考图的风格元素融入生成 prompt
- 理解多张参考图各自的角色(如"图1的主体 + 图2的风格/视角"),并在 prompt 中准确传达
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
- 将一张图片转换为多个不同视角(使用 transform_view 工具),适合建筑、物体等需要从不同角度查看的场景
## 工作流程
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
@@ -268,9 +269,13 @@ async def run_agent_loop(
tc_data = tool_calls_data[idx]
tool_name = tc_data["name"]
tool_message = (
"正在进行视角变换..." if tool_name == "transform_view"
else "正在生成图片..."
)
yield {
"type": "tool_start",
"data": {"tool": tool_name, "message": "正在生成图片..."},
"data": {"tool": tool_name, "message": tool_message},
}
try:
@@ -292,7 +297,29 @@ async def run_agent_loop(
},
}
if result.get("images"):
if result.get("error") and not result.get("success"):
yield {
"type": "tool_error",
"data": {
"tool": tool_name,
"errors": [result["error"]],
"model_name": used_model,
},
}
if tool_name == "transform_view" and result.get("images"):
# transform_view 返回带视角信息的图片列表
image_urls = [v["url"] for v in result["images"]]
yield {
"type": "image_result",
"data": {
"images": image_urls,
"prompt_used": "",
"model_name": used_model,
"view_info": result["images"],
},
}
elif result.get("images"):
yield {
"type": "image_result",
"data": {

View File

@@ -1,6 +1,7 @@
"""Agent 可调用的工具定义和实现。"""
from app.services.image_gen import generate_images
from app.services.view_transform import transform_view
# OpenAI Function Calling 格式的工具定义
TOOL_DEFINITIONS = [
@@ -32,7 +33,44 @@ TOOL_DEFINITIONS = [
"required": ["prompt"],
},
},
}
},
{
"type": "function",
"function": {
"name": "transform_view",
"description": (
"将一张图片转换为多个不同视角。需要用户先上传参考图。\n"
"可选模型:\n"
"- zero123plus默认~15s/次Zero123++ 直接输出 6 个固定视角"
"(方位角 30/90/150/210/270/330°仰角交替 30/-20°适合快速预览"
"对建筑大角度可能有错位。\n"
"- trellis~105s/次,先 3D 重建再重绘,"
"支持任意方位角 + 原画风保留,适合卡通描边风格建筑。"
),
"parameters": {
"type": "object",
"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": [],
},
},
},
]
@@ -65,4 +103,19 @@ async def execute_tool(
"model_id": result.model_id,
}
if tool_name == "transform_view":
if not ref_image_urls:
return {
"success": False,
"error": "视角变换需要一张输入图片,请先上传参考图",
}
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,121 @@ 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"
# ─── 视角变换模型注册表 ─────────────────────────────────
#
# 视角变换模型不用于生图,而是将已有图片转换为不同视角。
# 输入通常只需一张图,输出为多个固定视角的图片。
VIEW_TRANSFORM_MODELS: dict[str, dict[str, Any]] = {
"zero123plus": {
"id": "zero123plus",
"name": "Zero123++",
"provider": "replicate",
"model_id": "jd7h/zero123plusplus:c69c6559a29011b576f1ff0371b3bc1add2856480c60520c7e9ce0b40a6e9052",
"description": "单图生成 6 个固定视角,适合建筑/物体的多角度预览",
"pipeline": "grid",
"output_views": [
{"azimuth": 30, "elevation": 30},
{"azimuth": 90, "elevation": -20},
{"azimuth": 150, "elevation": 30},
{"azimuth": 210, "elevation": -20},
{"azimuth": 270, "elevation": 30},
{"azimuth": 330, "elevation": -20},
],
"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,
},
}
def get_view_transform_model_config(model_id: str | None = None) -> dict[str, Any]:
"""获取视角变换模型配置,默认返回 zero123plus。"""
if model_id and model_id in VIEW_TRANSFORM_MODELS:
return VIEW_TRANSFORM_MODELS[model_id]
return VIEW_TRANSFORM_MODELS["zero123plus"]
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)
]
def get_default_image_model_id() -> str:
"""返回 .env 中配置的默认模型短 ID若不在注册表中则回退到 flux-schnell。"""
env_model = os.getenv("IMAGE_MODEL", "flux-schnell")
@@ -253,7 +365,7 @@ def get_ref_image_model_id() -> str | None:
def get_image_models_list() -> list[dict]:
"""返回前端下拉列表所需的模型摘要信息。"""
"""返回前端下拉列表所需的模型摘要信息(过滤掉 internal=True 的内部模型)"""
return [
{
"id": cfg["id"],
@@ -262,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

@@ -0,0 +1,357 @@
"""视角变换服务 — 调度单图→多视角生成,支持两种管道:
- grid 管道Zero123++ 直接输出 6 宫格拼接图,切分后得到固定 6 视角。
- mesh 管道Trellis/Hunyuan3D → .glb + 预渲染视频 → 抽帧 → 可选风格还原。
"""
import base64
import logging
import mimetypes
import os
import uuid
from pathlib import Path
from typing import Any
import httpx
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__)
_replicate_client = _make_replicate_client()
def _split_grid_image(
image_path: Path,
cols: int,
rows: int,
view_labels: list[dict[str, int]],
) -> list[dict[str, Any]]:
"""将 Zero123++ 输出的 grid 拼接图切分为独立的视角图片。
返回格式:[{"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
results: list[dict[str, Any]] = []
for idx, view in enumerate(view_labels):
row_idx = idx // cols
col_idx = idx % cols
left = col_idx * cell_w
upper = row_idx * cell_h
right = left + cell_w
lower = upper + cell_h
cropped = img.crop((left, upper, right, lower))
filename = f"view_{uuid.uuid4().hex[:8]}_az{view['azimuth']}_el{view['elevation']}.png"
filepath = GENERATED_DIR / filename
cropped.save(filepath, "PNG")
results.append({
"url": f"/generated/{filename}",
"azimuth": view["azimuth"],
"elevation": view["elevation"],
})
return results
async def _download_to_local(url: str) -> Path:
"""下载远程图片到本地 generated/ 目录,返回本地路径。"""
filename = f"grid_{uuid.uuid4().hex}.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(120.0)) as client:
resp = await client.get(url, follow_redirects=True)
resp.raise_for_status()
filepath.write_bytes(resp.content)
return filepath
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: 视角变换模型 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 | 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"]
try:
image_uri = to_data_uri(image_path)
input_params = {"image": image_uri}
logger.info("调用 %s 进行视角变换...", model_name)
output = await _replicate_client.async_run(
replicate_model_id, input=input_params, wait=False
)
# Zero123++ 返回单张 grid 拼接图
items = output if isinstance(output, list) else [output]
if not items:
return {
"success": False,
"images": [],
"grid_image": None,
"model_name": model_name,
"error": "模型未返回任何输出",
}
grid_url = str(items[0])
if not (grid_url.startswith("http://") or grid_url.startswith("https://")):
return {
"success": False,
"images": [],
"grid_image": None,
"model_name": model_name,
"error": f"模型返回非图片内容: {grid_url[:200]}",
}
grid_local_path = await _download_to_local(grid_url)
grid_local_url = f"/generated/{grid_local_path.name}"
view_images = _split_grid_image(
grid_local_path,
cols=grid_layout["cols"],
rows=grid_layout["rows"],
view_labels=output_views,
)
return {
"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,
}
except Exception as e:
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
logger.error("视角变换失败: %s", detail, exc_info=True)
return {
"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),
}

Binary file not shown.

View File

@@ -1,14 +1,16 @@
fastapi>=0.115.0
uvicorn>=0.32.0
openai>=1.55.0
replicate>=1.0.0
sse-starlette>=2.1.0
python-multipart>=0.0.12
httpx>=0.27.0
python-dotenv>=1.0.0
Pillow>=10.4.0
mem0ai
ollama
sqlmodel>=0.0.22
pwdlib[argon2,bcrypt]>=0.3.0
python-jose[cryptography]>=3.3.0
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
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
pwdlib[argon2,bcrypt]==0.3.0
python-jose[cryptography]==3.5.0

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

@@ -9,17 +9,20 @@
"lint": "next lint"
},
"dependencies": {
"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",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"postcss": "^8.5.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"postcss": "^8.5.0"
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,511 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
import type { CharacterCard, CharacterRefImage } from "@/lib/types";
const ANGLES = [
"正面",
"半侧面",
"侧面",
"背面",
"半身",
"全身",
"表情·微笑",
"表情·愤怒",
"表情·惊讶",
"服饰细节",
];
export default function CharacterDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const isNew = params.id === "new";
const {
characters,
upsertCharacter,
deleteCharacter,
setSessionCharacterId,
projects,
activeProjectId,
assets,
} = useApp();
const [c, setC] = useState<CharacterCard | null>(null);
useEffect(() => {
if (isNew) {
const now = Date.now();
setC({
id: `char-${now.toString(36)}`,
name: "",
description: "",
personality: [],
visualHooks: [],
consistency: "medium",
refImages: [],
projectId: activeProjectId,
createdAt: now,
updatedAt: now,
});
} else {
const found = characters.find((x) => x.id === params.id);
if (found) setC(found);
}
}, [params.id, characters, isNew, activeProjectId]);
if (!c) {
return (
<div className="h-screen flex flex-col">
<TopNav />
<main className="flex-1 flex items-center justify-center">
<p className="typo-caption">...</p>
</main>
</div>
);
}
const handleSave = () => {
if (!c.name.trim()) {
alert("请填写角色名称");
return;
}
upsertCharacter(c);
alert("已保存(占位)");
if (isNew) router.push(`/characters/${c.id}`);
};
const handleApply = () => {
setSessionCharacterId(c.id);
alert(`已将角色 "${c.name}" 应用到当前会话(占位)`);
router.push("/");
};
const handleDelete = () => {
if (!confirm(`确认删除角色 "${c.name}"`)) return;
deleteCharacter(c.id);
router.push("/characters");
};
const addRef = (angle: string) => {
setC({
...c,
refImages: [
...c.refImages,
{ id: `r-${Date.now()}`, url: "", angle, caption: "占位参考图" },
],
});
};
const removeRef = (id: string) => {
setC({ ...c, refImages: c.refImages.filter((r) => r.id !== id) });
};
const addPersonality = (kw: string) => {
if (!kw || c.personality.includes(kw)) return;
setC({ ...c, personality: [...c.personality, kw] });
};
const addHook = (kw: string) => {
if (!kw || c.visualHooks.includes(kw)) return;
setC({ ...c, visualHooks: [...c.visualHooks, kw] });
};
const relatedAssets = assets
.filter((a) =>
c.visualHooks.some((h) => a.prompt.toLowerCase().includes(h.toLowerCase())) ||
a.prompt.toLowerCase().includes(c.name.toLowerCase())
)
.slice(0, 6);
return (
<div className="h-screen flex flex-col">
<AmbientParticles count={12} />
<TopNav />
<main className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6 md:py-8">
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
<Link href="/characters" className="hover:text-[var(--accent)] cursor-pointer">
</Link>
<span>/</span>
<span className="text-[var(--text-primary)]">
{isNew ? "新建角色" : c.name || "未命名"}
</span>
</div>
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<input
value={c.name}
onChange={(e) => setC({ ...c, name: e.target.value })}
placeholder="角色名称"
className="text-2xl font-bold bg-transparent border-none
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/60
focus:outline-none flex-1"
/>
<span className="phase-chip flex-shrink-0">Phase 1</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={handleApply}
className="px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
cursor-pointer btn-hover-lift transition-all"
>
</button>
<button
onClick={handleSave}
className="px-3.5 py-2 text-xs rounded-xl font-medium
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-primary)] hover:border-[var(--accent)]/40
cursor-pointer btn-hover-lift transition-all"
>
</button>
{!isNew && (
<>
<Link
href={`/training/new?type=character&source=${c.id}`}
className="px-3.5 py-2 text-xs rounded-xl font-medium
border border-[var(--gold)]/30 bg-[var(--gold)]/8
text-[var(--gold)] hover:bg-[var(--gold)]/15
cursor-pointer btn-hover-lift transition-all
flex items-center gap-1"
>
LoRA
<span className="text-[9px]">P2</span>
</Link>
<button
onClick={handleDelete}
className="px-3.5 py-2 text-xs rounded-xl font-medium
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--hot)]/60 hover:text-[var(--hot)]
hover:border-[var(--hot)]/40
cursor-pointer btn-hover-lift transition-all"
>
</button>
</>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-5">
{/* 基本信息 */}
<section>
<h2 className="typo-h2 mb-2"></h2>
<textarea
value={c.description}
onChange={(e) => setC({ ...c, description: e.target.value })}
placeholder="一句话简要描述这个角色..."
rows={2}
className="w-full p-3 mb-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
text-sm focus:outline-none focus:border-[var(--accent)]/50 resize-none"
/>
<div className="grid grid-cols-3 gap-2.5">
<LabeledInput label="性别" value={c.gender ?? ""}
onChange={(v) => setC({ ...c, gender: v })} />
<LabeledInput label="年龄段" value={c.ageRange ?? ""}
onChange={(v) => setC({ ...c, ageRange: v })} />
<LabeledInput label="体型" value={c.body ?? ""}
onChange={(v) => setC({ ...c, body: v })} />
</div>
</section>
{/* 性格关键词 */}
<section>
<h2 className="typo-h2 mb-2"></h2>
<div className="flex flex-wrap gap-1.5">
{c.personality.map((k) => (
<TagChip
key={k}
label={k}
onRemove={() => setC({ ...c, personality: c.personality.filter((x) => x !== k) })}
/>
))}
<KeywordInput onAdd={addPersonality} />
</div>
</section>
{/* 背景故事 */}
<section>
<h2 className="typo-h2 mb-2"></h2>
<textarea
value={c.story ?? ""}
onChange={(e) => setC({ ...c, story: e.target.value })}
placeholder="角色的背景设定、重要经历..."
rows={3}
className="w-full p-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
text-sm focus:outline-none focus:border-[var(--accent)]/50 resize-none"
/>
</section>
{/* 关键视觉要素 */}
<section>
<h2 className="typo-h2 mb-2"></h2>
<p className="typo-caption mb-2">
</p>
<div className="flex flex-wrap gap-1.5">
{c.visualHooks.map((h) => (
<TagChip
key={h}
label={h}
onRemove={() => setC({ ...c, visualHooks: c.visualHooks.filter((x) => x !== h) })}
/>
))}
<KeywordInput onAdd={addHook} />
</div>
</section>
{/* 角色参考组 */}
<section>
<div className="flex items-center justify-between mb-2">
<h2 className="typo-h2"></h2>
<AngleDropdown onPick={addRef} />
</div>
<p className="typo-caption mb-3">
/ / / /
</p>
<div className="grid grid-cols-3 md:grid-cols-4 gap-2.5">
{c.refImages.map((ref) => (
<RefImageCard
key={ref.id}
ref_={ref}
onRemove={() => removeRef(ref.id)}
/>
))}
{c.refImages.length === 0 && (
<div className="col-span-3 md:col-span-4 py-8 text-center text-xs text-[var(--text-secondary)]
placeholder-card rounded-xl">
</div>
)}
</div>
</section>
{/* 生成历史 */}
<section>
<h2 className="typo-h2 mb-2">使</h2>
{relatedAssets.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--text-secondary)]
placeholder-card rounded-xl">
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
{relatedAssets.map((a) => (
<div key={a.id}
className="aspect-square rounded-lg overflow-hidden border border-[var(--border)]
bg-[var(--bg-tertiary)] flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
</div>
))}
</div>
)}
</section>
</div>
<aside className="space-y-4">
{/* 一致性级别 */}
<div className="rounded-xl surface-2 p-4">
<span className="typo-strong text-xs block mb-2"></span>
<div className="flex gap-1.5">
{(["low", "medium", "high"] as const).map((lv) => (
<button
key={lv}
onClick={() => setC({ ...c, consistency: lv })}
className={`flex-1 px-2 py-1.5 text-xs rounded-lg cursor-pointer transition-all ${
c.consistency === lv
? "bg-[var(--accent)]/15 text-[var(--accent)] font-medium border border-[var(--accent)]/40"
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{lv === "low" ? "低" : lv === "medium" ? "中" : "高"}
</button>
))}
</div>
<p className="text-[10px] text-[var(--text-secondary)] mt-2 leading-relaxed">
使
</p>
</div>
{/* 所属项目 */}
<div className="rounded-xl surface-2 p-4">
<span className="typo-strong text-xs block mb-2"></span>
<select
value={c.projectId ?? ""}
onChange={(e) => setC({ ...c, projectId: e.target.value })}
className="w-full text-xs px-2 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
focus:outline-none cursor-pointer"
>
<option value=""></option>
{projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* LoRA */}
<div className="placeholder-card rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="typo-strong text-xs"> LoRA</span>
<span className="phase-chip">P2</span>
</div>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed mb-2">
{c.loraId
? `已绑定:${c.loraId}(占位)`
: "尚未绑定。可通过训练中心生成角色 LoRA。"}
</p>
<Link
href={`/training/new?type=character&source=${c.id}`}
className="text-xs text-[var(--gold)] hover:underline"
>
</Link>
</div>
<div className="rounded-xl surface-2 p-4">
<span className="typo-strong text-xs block mb-2"></span>
<div className="text-xs text-[var(--text-secondary)] space-y-1">
<div>{new Date(c.createdAt).toLocaleDateString("zh-CN")}</div>
<div>{new Date(c.updatedAt).toLocaleDateString("zh-CN")}</div>
</div>
</div>
</aside>
</div>
</div>
</main>
</div>
);
}
function LabeledInput({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div>
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>{label}</div>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-2.5 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
text-xs focus:outline-none focus:border-[var(--accent)]/50"
/>
</div>
);
}
function TagChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<span className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg
bg-[var(--accent)]/10 text-[var(--accent)] font-medium">
{label}
<button
onClick={onRemove}
className="hover:text-[var(--hot)] cursor-pointer"
>×</button>
</span>
);
}
function KeywordInput({ onAdd }: { onAdd: (kw: string) => void }) {
const [val, setVal] = useState("");
return (
<input
value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && val.trim()) {
onAdd(val.trim());
setVal("");
}
}}
placeholder="+ 添加(回车)"
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
focus:outline-none focus:border-[var(--accent)]/50 min-w-[120px]"
/>
);
}
function AngleDropdown({ onPick }: { onPick: (angle: string) => void }) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="text-xs px-2.5 py-1 rounded-lg border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
>
+
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-44 surface-3 rounded-xl py-1 z-10">
{ANGLES.map((a) => (
<button
key={a}
onClick={() => { onPick(a); setOpen(false); }}
className="w-full text-left px-3 py-1.5 text-xs text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
>
{a}
</button>
))}
</div>
)}
</div>
);
}
function RefImageCard({
ref_,
onRemove,
}: {
ref_: CharacterRefImage;
onRemove: () => void;
}) {
return (
<div className="relative aspect-[3/4] rounded-lg overflow-hidden
border border-[var(--border)] group">
{ref_.url ? (
<img src={ref_.url} alt={ref_.angle} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-[var(--accent-secondary)]/10 to-[var(--accent)]/5
flex items-center justify-center p-2">
<div className="text-center">
<div className="text-[10px] text-[var(--accent)]/70 font-medium mb-0.5">
{ref_.angle}
</div>
<div className="text-[9px] text-[var(--text-secondary)]"></div>
</div>
</div>
)}
<button
onClick={onRemove}
className="absolute top-1 right-1 w-5 h-5 rounded-full
bg-black/60 text-white text-xs opacity-0 group-hover:opacity-100
cursor-pointer transition-opacity flex items-center justify-center"
>
×
</button>
</div>
);
}

View File

@@ -0,0 +1,217 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
import type { CharacterCard } from "@/lib/types";
export default function CharactersPage() {
const { characters, projects, activeProjectId } = useApp();
const [query, setQuery] = useState("");
const [projectFilter, setProjectFilter] = useState<string>("all");
const filtered = useMemo(() => {
let list = [...characters];
if (projectFilter !== "all") {
list = list.filter((c) => c.projectId === projectFilter);
}
if (query.trim()) {
const q = query.trim().toLowerCase();
list = list.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q) ||
c.visualHooks.some((h) => h.toLowerCase().includes(q))
);
}
return list;
}, [characters, projectFilter, query]);
return (
<div className="h-screen flex flex-col relative z-[1]">
<AmbientParticles count={16} />
<TopNav />
<main className="flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto px-4 md:px-8 py-6 md:py-8">
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-2 mb-1.5">
<h1 className="typo-h1"></h1>
<span className="phase-chip">Phase 1</span>
</div>
<p className="typo-caption">
</p>
</div>
<Link
href="/characters/new"
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 5v14M5 12h14" />
</svg>
</Link>
</div>
<div className="flex flex-wrap items-center gap-3 mb-6">
<div className="flex items-center gap-1.5 flex-1 min-w-[200px] max-w-[360px]
px-3 py-1.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
focus-within:border-[var(--accent)]/40 transition-colors">
<svg width="14" height="14" 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={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索角色名 / 描述 / 视觉要素..."
className="flex-1 bg-transparent border-none text-sm
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none"
/>
</div>
{/* 项目筛选 */}
<select
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
className="text-xs px-2.5 py-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
text-[var(--text-secondary)] cursor-pointer focus:outline-none
focus:border-[var(--accent)]/40"
>
<option value="all"></option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name === projects.find((x) => x.id === activeProjectId)?.name
? `${p.name}(当前)`
: p.name}
</option>
))}
</select>
</div>
{filtered.length === 0 ? (
<div className="text-center py-20">
<p className="typo-display text-lg mb-2"></p>
<p className="typo-caption">&ldquo;&rdquo;</p>
</div>
) : (
<motion.div
variants={staggerContainer}
initial="hidden"
animate="visible"
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
>
{filtered.map((c) => (
<CharCard key={c.id} c={c} projects={projects} />
))}
<motion.div variants={staggerItem}>
<Link
href="/characters/new"
className="aspect-[3/4] rounded-xl placeholder-card
flex flex-col items-center justify-center gap-2
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/50 cursor-pointer
transition-all btn-hover-lift"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 5v14M5 12h14" />
</svg>
<span className="text-xs"></span>
</Link>
</motion.div>
</motion.div>
)}
</div>
</main>
</div>
);
}
function CharCard({
c,
projects,
}: {
c: CharacterCard;
projects: { id: string; name: string }[];
}) {
const projectName = c.projectId
? projects.find((p) => p.id === c.projectId)?.name
: undefined;
return (
<motion.div variants={staggerItem}>
<Link
href={`/characters/${c.id}`}
className="block rounded-xl overflow-hidden neon-border
bg-[var(--bg-card)] backdrop-blur-sm cursor-pointer"
>
<div className="aspect-[3/4] relative overflow-hidden
bg-gradient-to-br from-[var(--accent-secondary)]/15 via-[var(--accent)]/10 to-transparent">
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 rounded-full bg-[var(--bg-card)] border border-[var(--accent)]/30
flex items-center justify-center mx-auto mb-2">
<span className="text-2xl font-bold text-[var(--accent)]">
{c.name.charAt(0)}
</span>
</div>
<div className="text-[9px] text-[var(--text-secondary)]">
· {c.refImages.length}
</div>
</div>
</div>
{c.loraId && (
<span className="absolute top-2 right-2 phase-chip-accent phase-chip">
LoRA
</span>
)}
</div>
<div className="p-3">
<div className="text-sm font-medium text-[var(--text-primary)] truncate mb-1">
{c.name}
</div>
<div className="text-[10px] text-[var(--text-secondary)] line-clamp-2 mb-2 min-h-[24px]">
{c.description}
</div>
<div className="flex items-center justify-between text-[10px] text-[var(--text-secondary)]">
<span className="truncate">{projectName || "未归属项目"}</span>
<span
className="px-1.5 py-0.5 rounded-md font-medium"
style={{
background:
c.consistency === "high"
? "rgba(46, 139, 122, 0.15)"
: c.consistency === "medium"
? "rgba(58, 127, 184, 0.12)"
: "rgba(107, 123, 138, 0.12)",
color:
c.consistency === "high"
? "var(--accent)"
: c.consistency === "medium"
? "var(--accent-secondary)"
: "var(--text-secondary)",
}}
>
{c.consistency === "high" ? "高" : c.consistency === "medium" ? "中" : "低"}
</span>
</div>
</div>
</Link>
</motion.div>
);
}

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,24 +1,46 @@
"use client";
import { useState, useMemo } from "react";
import { motion } from "framer-motion";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
import { getImageUrl } from "@/lib/api";
import type { ImageAsset } from "@/lib/types";
import { staggerContainer, staggerItem, slideUp } from "@/components/ui/motion-presets";
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";
type SearchMode = "keyword" | "semantic";
const ASSET_TYPES = [
{ id: "icon", label: "Icon", keywords: ["icon", "图标", "徽标"] },
{ id: "character", label: "角色立绘", keywords: ["立绘", "角色", "character", "半身", "全身"] },
{ id: "scene", label: "场景", keywords: ["场景", "背景", "scene", "landscape"] },
{ id: "item", label: "道具", keywords: ["道具", "武器", "装备", "item"] },
{ id: "ui", label: "UI 元素", keywords: ["ui", "按钮", "卡片", "边框"] },
];
export default function GalleryPage() {
const { assets, tags, toggleFavorite, deleteAssetById, updateAsset } = 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[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchMode, setSearchMode] = useState<SearchMode>("keyword");
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);
const tagMap = new Map(tags.map((t) => [t.id, t]));
@@ -35,7 +57,37 @@ export default function GalleryPage() {
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
if (searchMode === "keyword") {
result = result.filter((a) => a.prompt.toLowerCase().includes(q));
} else {
const tokens = q.split(/\s+/).filter(Boolean);
result = result.filter((a) => {
const text = a.prompt.toLowerCase();
return tokens.some((t) => text.includes(t));
});
}
}
if (selectedAssetType) {
const kws = ASSET_TYPES.find((t) => t.id === selectedAssetType)?.keywords ?? [];
result = result.filter((a) => kws.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase())));
}
if (selectedStyleId) {
const sp = stylePacks.find((s) => s.id === selectedStyleId);
const kws = sp?.keywords ?? [];
result = result.filter((a) =>
kws.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase())) ||
a.prompt.toLowerCase().includes((sp?.name ?? "").toLowerCase())
);
}
if (selectedCharacterId) {
const ch = characters.find((c) => c.id === selectedCharacterId);
const kws = [...(ch?.visualHooks ?? []), ch?.name].filter(Boolean) as string[];
result = result.filter((a) =>
kws.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase()))
);
}
result.sort((a, b) => {
@@ -44,7 +96,8 @@ export default function GalleryPage() {
});
return result;
}, [assets, showFavOnly, selectedTagIds, searchQuery, sortBy]);
}, [assets, showFavOnly, selectedTagIds, searchQuery, searchMode, sortBy,
selectedAssetType, selectedStyleId, selectedCharacterId, stylePacks, characters]);
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
@@ -110,12 +163,65 @@ export default function GalleryPage() {
return (
<div className="h-screen flex flex-col relative z-[1]">
<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)] bg-[var(--bg-secondary)]/80
<div className="flex-shrink-0 border-b border-[var(--border)] surface-1
backdrop-blur-xl px-4 md:px-5 py-3
flex items-center gap-2 md:gap-3 flex-wrap">
{/* 区块标题 */}
@@ -125,7 +231,7 @@ export default function GalleryPage() {
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<span className="typo-strong text-sm"></span>
</div>
{/* 搜索 */}
@@ -139,11 +245,20 @@ export default function GalleryPage() {
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索 Prompt..."
placeholder={searchMode === "semantic" ? "语义搜索(占位)..." : "搜索 Prompt..."}
className="flex-1 bg-transparent border-none text-sm
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none"
/>
<button
onClick={() => setSearchMode(searchMode === "keyword" ? "semantic" : "keyword")}
className="flex items-center gap-1 text-[10px] text-[var(--text-secondary)]
hover:text-[var(--accent)] cursor-pointer transition-colors"
title="切换搜索模式"
>
{searchMode === "semantic" ? "语义" : "关键词"}
{searchMode === "semantic" && <span className="phase-chip">P2</span>}
</button>
</div>
{/* 标签筛选 */}
@@ -233,6 +348,84 @@ export default function GalleryPage() {
</div>
</div>
{/* 二级过滤器:资产类型 / 风格 / 角色 */}
<div className="flex-shrink-0 border-b border-[var(--border)] surface-1
px-4 md:px-5 py-2 flex items-center gap-3 flex-wrap text-xs">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<button
onClick={() => setSelectedAssetType(null)}
className={`px-2 py-0.5 rounded-md cursor-pointer transition-all text-[11px] ${
selectedAssetType === null
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
: "border border-[var(--border)] text-[var(--text-secondary)]"
}`}
>
</button>
{ASSET_TYPES.map((t) => (
<button
key={t.id}
onClick={() => setSelectedAssetType(t.id === selectedAssetType ? null : t.id)}
className={`px-2 py-0.5 rounded-md cursor-pointer transition-all text-[11px] ${
selectedAssetType === t.id
? "bg-[var(--accent)]/10 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>
{stylePacks.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<select
value={selectedStyleId ?? ""}
onChange={(e) => setSelectedStyleId(e.target.value || null)}
className="px-2 py-0.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer"
>
<option value=""></option>
{stylePacks.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
)}
{characters.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<select
value={selectedCharacterId ?? ""}
onChange={(e) => setSelectedCharacterId(e.target.value || null)}
className="px-2 py-0.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer"
>
<option value=""></option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
{(selectedAssetType || selectedStyleId || selectedCharacterId) && (
<button
onClick={() => {
setSelectedAssetType(null);
setSelectedStyleId(null);
setSelectedCharacterId(null);
}}
className="ml-auto text-[10px] text-[var(--text-secondary)] hover:text-[var(--hot)] cursor-pointer"
>
</button>
)}
</div>
{/* 批量操作栏 */}
{selectedIds.size > 0 && (
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
@@ -272,19 +465,28 @@ export default function GalleryPage() {
<div className="flex-1 overflow-y-auto p-4 md:p-5">
{filtered.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center space-y-3">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
bg-[var(--accent)]/10 border border-[var(--accent)]/20">
<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)]/10 border border-[var(--accent)]/20
shadow-[0_0_20px_rgba(46,139,122,0.1)]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.6">
<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>
</div>
<p className="text-sm text-[var(--text-secondary)]">
</motion.div>
<motion.p variants={staggerItem} className="typo-display text-lg">
{assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"}
</p>
</div>
</motion.p>
<motion.p variants={staggerItem} className="typo-caption">
{assets.length === 0 ? "在对话中生成的图片会自动出现在这里" : "试试调整筛选条件"}
</motion.p>
</motion.div>
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
@@ -294,7 +496,7 @@ export default function GalleryPage() {
className={`group relative rounded-xl overflow-hidden neon-border cursor-pointer
bg-[var(--bg-card)] backdrop-blur-sm transition-all ${
selectedIds.has(asset.id)
? "!border-[var(--accent)] ring-1 ring-[var(--accent)]/30 shadow-[0_0_15px_rgba(77,184,164,0.08)]"
? "!border-[var(--accent)] ring-1 ring-[var(--accent)]/30 shadow-[0_0_15px_rgba(46,139,122,0.08)]"
: ""
}`}
onClick={() => setDetailAsset(asset)}
@@ -310,7 +512,7 @@ export default function GalleryPage() {
<div
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all ${
selectedIds.has(asset.id)
? "bg-[var(--accent)] border-[var(--accent)] shadow-[0_0_8px_rgba(77,184,164,0.25)]"
? "bg-[var(--accent)] border-[var(--accent)] shadow-[0_0_8px_rgba(46,139,122,0.25)]"
: "border-white/40 bg-black/30 backdrop-blur-sm opacity-100 md:opacity-0 md:group-hover:opacity-100"
}`}
>
@@ -484,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
@@ -527,15 +736,17 @@ function GalleryDetailModal({
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={onClose}
>
<div
className="glass-panel rounded-2xl max-w-3xl w-full mx-2 md:mx-4
max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col
shadow-xl shadow-black/40"
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
className="surface-3 rounded-2xl max-w-3xl w-full mx-2 md:mx-4
max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--border)]">
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<span className="typo-h2"></span>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--accent)] cursor-pointer
@@ -565,7 +776,7 @@ function GalleryDetailModal({
onClick={onDownload}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
cursor-pointer transition-all"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -602,7 +813,7 @@ function GalleryDetailModal({
{asset.prompt && (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
<span className="typo-strong text-xs">Prompt</span>
<button
onClick={() => navigator.clipboard.writeText(asset.prompt)}
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
@@ -621,7 +832,7 @@ function GalleryDetailModal({
{/* 标签 */}
{asset.tags.length > 0 && (
<div>
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<span className="typo-strong text-xs block mb-1.5"></span>
<div className="flex flex-wrap gap-1.5">
{asset.tags.map((tid) => {
const tag = tagMap.get(tid);
@@ -648,6 +859,225 @@ function GalleryDetailModal({
</div>
</div>
</div>
</motion.div>
</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,20 +1,20 @@
@import "tailwindcss";
/* ===== 青绿山水色彩体系 ===== */
/* ===== 青绿山水色彩体系(白底·绢本) ===== */
:root {
--bg-primary: #0C1210;
--bg-secondary: #131E1A;
--bg-tertiary: #1C2A24;
--bg-card: rgba(16, 30, 24, 0.65);
--text-primary: #E0E8E2;
--text-secondary: #7A9485;
--accent: #4DB8A4;
--accent-hover: #6CD4BE;
--accent-secondary: #3A8FB7;
--bg-primary: #FAFAF7;
--bg-secondary: #F2F1EC;
--bg-tertiary: #E8E6DF;
--bg-card: rgba(255, 255, 255, 0.8);
--text-primary: #1A1A1A;
--text-secondary: #6B7B8A;
--accent: #2E8B7A;
--accent-hover: #3AA38E;
--accent-secondary: #3A7FB8;
--hot: #C4654A;
--border: rgba(77, 184, 164, 0.12);
--border-glow: rgba(77, 184, 164, 0.25);
--mist: rgba(77, 184, 164, 0.06);
--border: rgba(46, 139, 122, 0.15);
--border-glow: rgba(46, 139, 122, 0.30);
--mist: rgba(46, 139, 122, 0.04);
--gold: #B8935A;
}
@@ -22,8 +22,126 @@ body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 14px;
letter-spacing: 0.01em;
overscroll-behavior: none;
-webkit-tap-highlight-color: transparent;
/* 绢本底纹:极淡暖白,像宣纸的自然质感 */
background-image:
radial-gradient(ellipse at 20% 80%, rgba(46, 139, 122, 0.03) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(58, 127, 184, 0.02) 0%, transparent 50%);
}
/* ===== 排版层级体系7 级) ===== */
.typo-display {
font-size: 32px;
font-weight: 300;
letter-spacing: -0.02em;
line-height: 1.2;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: transparent;
}
.typo-h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.3;
color: var(--text-primary);
position: relative;
padding-bottom: 10px;
}
.typo-h1::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 40px;
height: 2px;
background: linear-gradient(90deg, var(--accent) 0%, transparent 100%);
border-radius: 1px;
}
.typo-h2 {
font-size: 18px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.4;
color: var(--text-primary);
padding-left: 12px;
position: relative;
}
.typo-h2::before {
content: '';
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 3px;
background: var(--accent);
border-radius: 2px;
}
.typo-strong {
font-weight: 600;
color: var(--accent);
}
.typo-caption {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.02em;
color: var(--text-secondary);
}
.typo-micro {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* ===== 表面层级系统Surface Elevation ===== */
.surface-1 {
background: var(--bg-secondary);
border: 1px solid var(--border);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.surface-2 {
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(20px) saturate(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.1);
border: 1px solid rgba(46, 139, 122, 0.12);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06),
0 1px 2px rgba(0, 0, 0, 0.04);
}
.surface-3 {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(24px) saturate(1.15);
-webkit-backdrop-filter: blur(24px) saturate(1.15);
border: 1px solid rgba(46, 139, 122, 0.18);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
}
/* ===== 网格底纹(绢本纹理潜意识层) ===== */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(circle, var(--accent) 0.5px, transparent 0.5px);
background-size: 24px 24px;
opacity: 0.018;
pointer-events: none;
z-index: 0;
}
/* ===== 山雾背景 — 远景层 ===== */
@@ -41,60 +159,50 @@ body {
100% { transform: translate(0, 0) scale(1); }
}
body::before {
content: '';
.fog-layer-far {
position: fixed;
inset: -10%;
background:
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(77, 184, 164, 0.07) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 143, 183, 0.05) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(77, 184, 164, 0.03) 0%, transparent 70%);
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(46, 139, 122, 0.04) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 127, 184, 0.03) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(46, 139, 122, 0.02) 0%, transparent 70%);
animation: fogDriftFar 60s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
body::after {
content: '';
.fog-layer-near {
position: fixed;
inset: -15%;
background:
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(77, 184, 164, 0.06) 0%, transparent 55%),
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 143, 183, 0.04) 0%, transparent 50%);
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(46, 139, 122, 0.03) 0%, transparent 55%),
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 127, 184, 0.025) 0%, transparent 50%);
animation: fogDriftNear 45s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
/* ===== 山雾玻璃面板 ===== */
/* ===== 山雾玻璃面板(兼容旧用法) ===== */
.glass-panel {
background:
linear-gradient(165deg, rgba(16, 30, 24, 0.15) 0%, transparent 50%),
rgba(16, 30, 24, 0.55);
backdrop-filter: blur(24px) saturate(1.1);
-webkit-backdrop-filter: blur(24px) saturate(1.1);
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(20px) saturate(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.1);
border: 1px solid var(--border);
}
/* ===== 静态霓虹边框(卡片等普通元素) ===== */
/* ===== 静态发光边框(卡片等普通元素) ===== */
.neon-border {
border: 1px solid var(--border);
transition: border-color 200ms ease, box-shadow 200ms ease;
transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease;
}
.neon-border:hover {
border-color: var(--border-glow);
box-shadow: 0 0 18px rgba(77, 184, 164, 0.08),
inset 0 0 12px rgba(77, 184, 164, 0.03);
box-shadow: 0 0 18px rgba(46, 139, 122, 0.08),
inset 0 0 12px rgba(46, 139, 122, 0.03);
transform: translateY(-1px);
}
/* ===== 云烟缭绕边框 ===== */
/*
* .glow-border — 选中态的云烟边框
* 常态:石青色微弱均匀发光
* hover云烟缓慢缭绕opacity 平滑过渡
* 移开:云烟淡散回静态
*/
@property --glow-angle {
syntax: "<angle>";
initial-value: 0deg;
@@ -107,19 +215,16 @@ body::after {
.glow-border {
position: relative;
background: var(--bg-secondary);
border: 1px solid rgba(77, 184, 164, 0.18) !important;
box-shadow: 0 0 10px rgba(77, 184, 164, 0.05),
inset 0 0 8px rgba(77, 184, 164, 0.02);
border: 1px solid rgba(46, 139, 122, 0.20) !important;
box-shadow: 0 0 10px rgba(46, 139, 122, 0.06);
transition: border-color 400ms ease, box-shadow 400ms ease;
z-index: 0;
}
.glow-border:hover {
border-color: rgba(77, 184, 164, 0.28) !important;
box-shadow: 0 0 16px rgba(77, 184, 164, 0.08),
inset 0 0 12px rgba(77, 184, 164, 0.03);
border-color: rgba(46, 139, 122, 0.32) !important;
box-shadow: 0 0 16px rgba(46, 139, 122, 0.10);
}
/* 云烟边框层 — 宽弧段柔化渐变6s 缓慢旋转 */
.glow-border::before {
content: '';
position: absolute;
@@ -130,11 +235,11 @@ body::after {
from var(--glow-angle),
transparent 0%,
transparent 15%,
rgba(77, 184, 164, 0.2) 25%,
rgba(58, 143, 183, 0.35) 38%,
rgba(77, 184, 164, 0.4) 50%,
rgba(58, 143, 183, 0.35) 62%,
rgba(77, 184, 164, 0.2) 75%,
rgba(46, 139, 122, 0.2) 25%,
rgba(58, 127, 184, 0.3) 38%,
rgba(46, 139, 122, 0.35) 50%,
rgba(58, 127, 184, 0.3) 62%,
rgba(46, 139, 122, 0.2) 75%,
transparent 85%,
transparent 100%
);
@@ -149,7 +254,6 @@ body::after {
z-index: -1;
}
/* 雾气扩散层 — 更大范围、更模糊 */
.glow-border::after {
content: '';
position: absolute;
@@ -159,9 +263,9 @@ body::after {
from var(--glow-angle),
transparent 0%,
transparent 20%,
rgba(77, 184, 164, 0.06) 35%,
rgba(58, 143, 183, 0.08) 50%,
rgba(77, 184, 164, 0.06) 65%,
rgba(46, 139, 122, 0.05) 35%,
rgba(58, 127, 184, 0.06) 50%,
rgba(46, 139, 122, 0.05) 65%,
transparent 80%,
transparent 100%
);
@@ -194,6 +298,144 @@ body::after {
);
}
/* ===== 数据流光AI 活动时输入栏上方) ===== */
@keyframes dataStream {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
.data-stream {
height: 2px;
background: linear-gradient(
90deg,
transparent 0%,
var(--accent) 30%,
var(--accent-secondary) 50%,
var(--accent) 70%,
transparent 100%
);
animation: dataStream 2s ease-in-out infinite;
opacity: 0.7;
}
.data-stream-slow {
animation-duration: 3s;
}
.data-stream-fast {
animation-duration: 1.2s;
}
/* ===== 水波纹 loading 动画 ===== */
@keyframes waveRise {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
}
.wave-dot {
width: 4px;
height: 16px;
border-radius: 2px;
background: var(--accent);
animation: waveRise 1.2s ease-in-out infinite;
transform-origin: center bottom;
}
/* ===== 图片加载占位 — 石青渐变呼吸脉冲 ===== */
@keyframes stoneBreath {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.stone-placeholder {
background: linear-gradient(135deg, rgba(46, 139, 122, 0.10) 0%, rgba(58, 127, 184, 0.06) 100%);
animation: stoneBreath 2s ease-in-out infinite;
border-radius: 8px;
}
/* ===== shimmer 骨架屏 ===== */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-shimmer {
background: linear-gradient(
90deg,
var(--bg-tertiary) 0%,
var(--bg-secondary) 40%,
var(--bg-tertiary) 60%,
var(--bg-tertiary) 100%
);
background-size: 200% 100%;
animation: shimmer 1.8s ease-in-out infinite;
border-radius: 8px;
}
/* ===== 涟漪效果 ===== */
@keyframes rippleExpand {
0% { transform: scale(0); opacity: 0.4; }
100% { transform: scale(2.5); opacity: 0; }
}
.ripple-ring {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--accent);
animation: rippleExpand 600ms ease-out forwards;
pointer-events: none;
}
/* ===== 水墨晕染揭示 ===== */
@keyframes inkReveal {
0% { clip-path: circle(0% at 50% 50%); }
100% { clip-path: circle(75% at 50% 50%); }
}
.ink-reveal {
animation: inkReveal 600ms ease-out forwards;
}
/* ===== 错误状态震动 ===== */
@keyframes errorShake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-2px); }
40% { transform: translateX(2px); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
}
.error-shake {
animation: errorShake 300ms ease-out;
}
/* ===== 呼吸脉冲(发送按钮就绪态) ===== */
@keyframes breathPulse {
0%, 100% { box-shadow: 0 0 8px rgba(46, 139, 122, 0.18); }
50% { box-shadow: 0 0 20px rgba(46, 139, 122, 0.35); }
}
.breath-pulse {
animation: breathPulse 2s ease-in-out infinite;
}
/* ===== 输入框水面感应 ===== */
@keyframes waterExpand {
0% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
.water-focus-line {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent 0%, var(--accent) 50%, transparent 100%);
transform: scaleX(0);
transition: transform 300ms ease-out;
pointer-events: none;
}
.water-focus-active .water-focus-line {
transform: scaleX(1);
}
/* ===== 用户消息气泡 — 左上切角 ===== */
.user-bubble {
border-radius: 2px 16px 16px 16px;
}
/* ===== 自定义滚动条 ===== */
::-webkit-scrollbar {
width: 6px;
@@ -203,11 +445,11 @@ body::after {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(77, 184, 164, 0.15);
background: rgba(46, 139, 122, 0.15);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(77, 184, 164, 0.28);
background: rgba(46, 139, 122, 0.30);
}
select option {
@@ -215,6 +457,7 @@ select option {
color: var(--text-primary);
}
/* ===== 通用动画 ===== */
.sidebar-overlay {
animation: fadeIn 200ms ease-out;
}
@@ -226,7 +469,127 @@ select option {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes floatUp {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-12px); }
}
/* ===== 通用按钮 hover 浮起 ===== */
.btn-hover-lift {
transition: transform 200ms ease, box-shadow 200ms ease;
}
.btn-hover-lift:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* ===== 侧栏会话左侧竖线 hover ===== */
.session-hover-line {
position: relative;
}
.session-hover-line::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
background: var(--accent);
border-radius: 1px;
transform: scaleY(0);
transition: transform 200ms ease-out;
transform-origin: center;
}
.session-hover-line:hover::before {
transform: scaleY(1);
}
/* ===== 全屏加载 — 云雾凝聚 ===== */
@keyframes mistCondense {
0% { transform: scale(1.5); opacity: 0; filter: blur(20px); }
50% { transform: scale(1.1); opacity: 0.6; filter: blur(8px); }
100% { transform: scale(1); opacity: 1; filter: blur(0); }
}
@keyframes mistRing {
0% { transform: scale(0.3); opacity: 0; }
50% { opacity: 0.5; }
100% { transform: scale(2); opacity: 0; }
}
.mist-loader {
position: relative;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.mist-loader-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--accent);
animation: mistRing 2s ease-out infinite;
opacity: 0;
}
.mist-loader-ring-2 {
animation-delay: 1s;
}
.mist-loader-core {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 20px rgba(46, 139, 122, 0.4), 0 0 60px rgba(46, 139, 122, 0.15);
animation: mistCondense 1.5s ease-out forwards, breathPulse 2s ease-in-out 1.5s infinite;
}
/* ===== 隐藏滚动条(保留滚动能力) ===== */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* ===== Phase 徽章 ===== */
.phase-chip {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 600;
background: rgba(184, 147, 90, 0.12);
color: var(--gold);
letter-spacing: 0.03em;
}
.phase-chip-accent {
background: rgba(46, 139, 122, 0.12);
color: var(--accent);
}
/* ===== 占位卡片(透明虚线边框) ===== */
.placeholder-card {
border: 1px dashed rgba(46, 139, 122, 0.25);
background:
repeating-linear-gradient(
135deg,
rgba(46, 139, 122, 0.02) 0px,
rgba(46, 139, 122, 0.02) 12px,
transparent 12px,
transparent 24px
);
}
/* ===== 段落分隔(用于 Phase 说明) ===== */
.section-divider {
height: 1px;
background: linear-gradient(90deg, transparent 0%, var(--border-glow) 50%, transparent 100%);
margin: 20px 0;
}
/* ===== 100dvh 兼容 ===== */
@supports (height: 100dvh) {
.h-screen {
height: 100dvh;

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

@@ -1,8 +1,11 @@
"use client";
import { useState, useEffect, type FormEvent } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { staggerContainer, staggerItem, buttonTap, errorFloat } from "@/components/ui/motion-presets";
export default function LoginPage() {
const { login, isAuthenticated } = useAuth();
@@ -32,6 +35,7 @@ export default function LoginPage() {
router.replace("/");
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
setTimeout(() => setError(""), 4000);
} finally {
setLoading(false);
}
@@ -39,24 +43,19 @@ export default function LoginPage() {
return (
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)] relative">
{/* 背景环境光 */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute inset-0"
style={{
background: `
radial-gradient(ellipse at 30% 70%, rgba(77, 184, 164, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 70% 30%, rgba(58, 143, 183, 0.06) 0%, transparent 50%)
`,
}}
/>
</div>
<AmbientParticles count={25} />
<div className="w-full max-w-sm mx-4 relative z-10">
<div className="text-center mb-8">
<motion.div
className="w-full max-w-sm mx-4 relative z-10"
variants={staggerContainer}
initial="hidden"
animate="visible"
>
<motion.div variants={staggerItem} className="text-center mb-8">
{/* Logo 发光 */}
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4
bg-[var(--accent)]/10 border border-[var(--accent)]/20
shadow-[0_0_40px_rgba(77,184,164,0.15)]">
shadow-[0_0_40px_rgba(46,139,122,0.15)]">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
@@ -64,12 +63,15 @@ export default function LoginPage() {
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
</svg>
</div>
<h1 className="text-2xl font-bold text-[var(--text-primary)]">EPEEKit</h1>
<p className="text-sm text-[var(--text-secondary)] mt-1.5">AI </p>
</div>
<h1 className="typo-display mb-1">EPEEKit</h1>
<p className="typo-caption mt-1.5">AI </p>
</motion.div>
<form onSubmit={handleSubmit}
className="glass-panel rounded-2xl p-6 space-y-4 shadow-xl shadow-black/20">
<motion.form
variants={staggerItem}
onSubmit={handleSubmit}
className="surface-2 rounded-2xl p-6 space-y-4"
>
<div>
<input
type="text"
@@ -82,7 +84,7 @@ export default function LoginPage() {
bg-[var(--bg-primary)] border border-[var(--border)]
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]/50
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
focus:shadow-[0_0_10px_rgba(46,139,122,0.06)]
transition-all"
/>
</div>
@@ -97,32 +99,42 @@ export default function LoginPage() {
bg-[var(--bg-primary)] border border-[var(--border)]
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]/50
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
focus:shadow-[0_0_10px_rgba(46,139,122,0.06)]
transition-all"
/>
</div>
<AnimatePresence>
{error && (
<div className="text-sm text-[var(--hot)] bg-[var(--hot)]/10 rounded-xl px-4 py-2.5
border border-[var(--hot)]/20">
<motion.div
key="login-error"
initial="visible"
animate="visible"
exit="exit"
variants={errorFloat}
className="text-sm text-[var(--hot)] bg-[var(--hot)]/10 rounded-xl px-4 py-2.5
border border-[var(--hot)]/20 error-shake"
>
{error}
</div>
</motion.div>
)}
</AnimatePresence>
<button
<motion.button
type="submit"
disabled={loading || !username.trim() || !password}
whileTap={buttonTap}
className="w-full py-3 rounded-xl font-semibold
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
hover:shadow-[0_0_20px_rgba(77,184,164,0.25)]
hover:shadow-[0_0_20px_rgba(46,139,122,0.25)]
disabled:opacity-50 disabled:cursor-not-allowed
transition-all cursor-pointer"
transition-all cursor-pointer btn-hover-lift"
>
{loading ? "登录中..." : "登录"}
</button>
</form>
</div>
</motion.button>
</motion.form>
</motion.div>
</div>
);
}

View File

@@ -1,14 +1,23 @@
"use client";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
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";
import { TopNav } from "@/components/layout/top-nav";
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
import { sendChat, getImageUrl, uploadRefImage } from "@/lib/api";
import { generateId } from "@/lib/store";
import { scrollBtnEnter, scrollBtnClick, staggerContainer, staggerItem } from "@/components/ui/motion-presets";
import type { ChatMessage, ImageAsset, ApiMessage, AnnotationData } from "@/lib/types";
export default function Home() {
@@ -21,6 +30,7 @@ export default function Home() {
detailImage,
sidebarCollapsed,
setSidebarCollapsed,
sessionTaskType,
} = useApp();
const messages = activeSession?.messages ?? [];
@@ -29,6 +39,7 @@ export default function Home() {
const [streamingText, setStreamingText] = useState("");
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
const [statusText, setStatusText] = useState("");
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
const [lastRefImageUrls, setLastRefImageUrls] = useState<string[]>([]);
const lastRefPerSession = useRef<Map<string, string[]>>(new Map());
@@ -40,6 +51,7 @@ export default function Home() {
const prevSessionId = useRef<string | null>(null);
const isSwitching = useRef(false);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const [showCandidates, setShowCandidates] = useState(false);
const scrollToBottom = useCallback((instant?: boolean) => {
if (isSwitching.current && !instant) return;
@@ -57,7 +69,6 @@ export default function Home() {
}
}, []);
// 实时记录当前会话的滚动位置 + 判断是否显示"回到底部"按钮
useEffect(() => {
const el = scrollRef.current;
if (!el || !activeSessionId) return;
@@ -72,7 +83,6 @@ export default function Home() {
return () => el.removeEventListener("scroll", handler);
}, [activeSessionId]);
// 会话切换:标记 switching等 DOM 更新后恢复位置 + 恢复参考图状态
useEffect(() => {
if (!activeSessionId) return;
if (prevSessionId.current && prevSessionId.current !== activeSessionId) {
@@ -152,6 +162,7 @@ export default function Home() {
};
appendMessage(activeSessionId, userMessage);
setIsLoading(true);
setIsGeneratingImage(false);
setStreamingText("");
setStreamingImages([]);
setStatusText("");
@@ -179,6 +190,7 @@ export default function Home() {
case "tool_start":
setStatusText(event.data.message as string);
setIsGeneratingImage(true);
scrollToBottom();
break;
@@ -207,6 +219,7 @@ export default function Home() {
collectedImages = [...collectedImages, ...newAssets];
setStreamingImages([...collectedImages]);
setIsGeneratingImage(false);
if (modelName) {
usedModelName = modelName;
setStatusText(`${modelName} 生成`);
@@ -225,6 +238,7 @@ export default function Home() {
assistantText += `\n\n⚠ 图片生成失败${modelHint}:\n${errorDetail}`;
setStreamingText(assistantText);
setStatusText("");
setIsGeneratingImage(false);
scrollToBottom();
break;
}
@@ -256,6 +270,7 @@ export default function Home() {
};
appendMessage(activeSessionId, assistantMessage);
setIsLoading(false);
setIsGeneratingImage(false);
setStreamingText("");
setStreamingImages([]);
setStatusText("");
@@ -268,8 +283,13 @@ export default function Home() {
setPendingAnnotation(data);
}, []);
const isEmptyState = messages.length === 0 && !isLoading;
return (
<div className="h-screen flex flex-col relative z-[1]">
{/* 环境粒子(萤火 + 云雾) */}
<AmbientParticles count={isEmptyState ? 28 : 18} />
<TopNav />
<div className="flex-1 flex overflow-hidden">
@@ -323,7 +343,7 @@ export default function Home() {
border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40
transition-all cursor-pointer
transition-all cursor-pointer btn-hover-lift
hidden md:flex"
title="展开侧边栏"
>
@@ -341,7 +361,7 @@ export default function Home() {
src={pendingAnnotation.snapshot}
alt="标注预览"
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/40
shadow-[0_0_8px_rgba(77,184,164,0.12)]"
shadow-[0_0_8px_rgba(46,139,122,0.12)]"
/>
<span className="text-xs text-[var(--accent)]">
{pendingAnnotation.annotations.length}
@@ -356,30 +376,38 @@ export default function Home() {
</div>
)}
<div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll">
{messages.length === 0 && !isLoading ? (
<div className="h-full flex items-center justify-center">
<div className="text-center space-y-4">
<div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll relative">
<div className="min-h-full">
{isEmptyState ? (
<div className="h-full flex items-center justify-center" style={{ minHeight: "calc(100vh - 200px)" }}>
<motion.div
className="text-center space-y-5"
variants={staggerContainer}
initial="hidden"
animate="visible"
>
{/* Logo 发光效果 */}
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
<motion.div variants={staggerItem} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
bg-[var(--accent)]/10 border border-[var(--accent)]/20
shadow-[0_0_30px_rgba(77,184,164,0.12)]">
shadow-[0_0_30px_rgba(46,139,122,0.12)]">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
</svg>
</div>
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
</motion.div>
<motion.div variants={staggerItem}>
<h2 className="typo-display mb-2">
使 EPEEKit
</h2>
<p className="text-sm text-[var(--text-secondary)] max-w-md leading-relaxed">
<p className="text-sm text-[var(--text-secondary)] max-w-md leading-relaxed mx-auto">
<br />
</p>
<div className="flex flex-wrap justify-center gap-2 mt-5">
</motion.div>
<motion.div variants={staggerItem} className="flex flex-wrap justify-center gap-2 pt-2">
{[
"画一个赛博朋克风格的退出按钮",
"设计一个卡通风格的金币图标",
@@ -392,13 +420,13 @@ export default function Home() {
border border-[var(--border)] text-[var(--text-secondary)]
hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
transition-all cursor-pointer btn-hover-lift"
>
{hint}
</button>
))}
</div>
</div>
</motion.div>
</motion.div>
</div>
) : (
<ChatMessages
@@ -410,29 +438,76 @@ export default function Home() {
/>
)}
</div>
</div>
{/* 回到底部 — 落叶归根(水滴形) */}
<AnimatePresence>
{showScrollBtn && (
<button
onClick={() => scrollToBottom()}
<motion.button
key="scroll-btn"
variants={scrollBtnEnter}
initial="hidden"
animate="visible"
exit="exit"
onClick={() => {
scrollToBottom();
}}
className="absolute bottom-16 right-4 z-10
w-9 h-9 rounded-xl flex items-center justify-center
glass-panel text-[var(--text-secondary)]
w-9 h-11 flex items-center justify-center
surface-2 text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
shadow-lg shadow-black/30
transition-all duration-200 cursor-pointer
animate-[fadeIn_150ms_ease-out]"
cursor-pointer"
style={{ borderRadius: "50% 50% 50% 50% / 35% 35% 65% 65%" }}
title="回到底部"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="mt-1">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</motion.button>
)}
</AnimatePresence>
<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>
<div className="px-3 md:px-5 py-2 flex items-center gap-1.5">
<AdvancedControls />
<button
onClick={() => setShowCandidates(true)}
className="h-7 px-2 rounded-lg text-xs flex items-center gap-1.5
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="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>
<span className="phase-chip">P1</span>
</button>
</div>
</div>
</div>
<ChatInput
ref={chatInputRef}
onSend={handleSend}
disabled={isLoading}
isGeneratingImage={isGeneratingImage}
lastRefServerUrls={lastRefImageUrls}
lastRefPreviewUrls={lastRefImageUrls.map((u) => getImageUrl(u))}
onClearLastRefImage={() => {
@@ -450,6 +525,24 @@ export default function Home() {
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
)}
</div>
{showCandidates && (
<CandidatePanel
candidates={
messages
.slice()
.reverse()
.find((m) => m.role === "assistant" && m.images && m.images.length > 0)?.images ??
streamingImages ??
[]
}
onPick={(id) => {
alert(`(占位)已选 ${id} 作为定稿`);
setShowCandidates(false);
}}
onClose={() => setShowCandidates(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,308 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
const PROJECT_TYPES = [
"2D RPG",
"横版动作",
"卡牌",
"模拟经营",
"策略",
"美术包",
"其他",
];
export default function ProjectSettingsPage() {
const { activeProject, upsertProject, stylePacks, projects, switchProject } = useApp();
const [name, setName] = useState("");
const [type, setType] = useState("");
const [desc, setDesc] = useState("");
const [defaultStyleId, setDefaultStyleId] = useState<string>("");
const [defaultSpec, setDefaultSpec] = useState("");
useEffect(() => {
if (!activeProject) return;
setName(activeProject.name);
setType(activeProject.type);
setDesc(activeProject.description);
setDefaultStyleId(activeProject.defaultStyleId ?? "");
setDefaultSpec(activeProject.defaultSpec ?? "");
}, [activeProject]);
if (!activeProject) {
return (
<div className="h-screen flex flex-col">
<TopNav />
<main className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
</main>
</div>
);
}
const handleSave = () => {
upsertProject({
...activeProject,
name: name.trim() || activeProject.name,
type,
description: desc,
defaultStyleId: defaultStyleId || undefined,
defaultSpec: defaultSpec || undefined,
});
alert("已保存项目设置(占位:未同步后端)");
};
return (
<div className="h-screen flex flex-col">
<AmbientParticles count={10} />
<TopNav />
<main className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-4 md:px-8 py-6 md:py-8">
<div className="flex items-center gap-2 mb-1.5">
<h1 className="typo-h1"></h1>
<span className="phase-chip">Phase 1</span>
</div>
<p className="typo-caption mb-6">
</p>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-5">
{/* 侧栏:锚点 */}
<aside className="lg:col-span-1">
<nav className="sticky top-4 space-y-1 text-xs">
{[
{ id: "basic", label: "基本信息" },
{ id: "defaults", label: "默认设置" },
{ id: "members", label: "成员与权限" },
{ id: "billing", label: "计费与额度" },
{ id: "danger", label: "危险操作" },
].map((n) => (
<a
key={n.id}
href={`#${n.id}`}
className="block px-3 py-1.5 rounded-lg text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:bg-[var(--accent)]/6
transition-all"
>
{n.label}
</a>
))}
</nav>
</aside>
<div className="lg:col-span-3 space-y-5">
{/* 基本信息 */}
<section id="basic" className="rounded-xl surface-2 p-5">
<h2 className="typo-h2 mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}></label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
/>
</div>
<div>
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}></label>
<div className="flex flex-wrap gap-1.5">
{PROJECT_TYPES.map((t) => (
<button
key={t}
onClick={() => setType(t)}
className={`px-2.5 py-1 text-xs rounded-lg border cursor-pointer transition-all ${
type === t
? "border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
: "border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{t}
</button>
))}
</div>
</div>
<div>
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}></label>
<textarea
value={desc}
onChange={(e) => setDesc(e.target.value)}
rows={3}
placeholder="简单描述项目定位、目标用户等(占位)"
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none resize-none"
/>
</div>
</div>
</section>
{/* 默认设置 */}
<section id="defaults" className="rounded-xl surface-2 p-5">
<h2 className="typo-h2 mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}></label>
<select
value={defaultStyleId}
onChange={(e) => setDefaultStyleId(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
>
<option value=""></option>
{stylePacks.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<p className="text-[10px] text-[var(--text-secondary)] mt-1">
</p>
</div>
<div>
<label className="block typo-micro mb-1.5" style={{ textTransform: "none" }}></label>
<input
value={defaultSpec}
onChange={(e) => setDefaultSpec(e.target.value)}
placeholder="例如1024×1024 · 透明背景可选"
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
/>
</div>
<div className="placeholder-card rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<span className="typo-strong text-xs"></span>
<span className="phase-chip">P2</span>
</div>
<p className="text-[11px] text-[var(--text-secondary)]">
Phase 2
</p>
</div>
</div>
</section>
{/* 成员与权限 */}
<section id="members" className="rounded-xl surface-2 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="typo-h2"></h2>
<span className="phase-chip">Phase 3</span>
</div>
<div className="placeholder-card rounded-lg p-6 text-center text-xs text-[var(--text-secondary)]">
Phase 3
</div>
</section>
{/* 计费与额度 */}
<section id="billing" className="rounded-xl surface-2 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="typo-h2"></h2>
<span className="phase-chip">Phase 3</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="placeholder-card rounded-lg p-3">
<div className="text-[10px] text-[var(--text-secondary)] mb-0.5"></div>
<div className="text-xl font-semibold text-[var(--text-primary)]">¥ 128</div>
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5"></div>
</div>
<div className="placeholder-card rounded-lg p-3">
<div className="text-[10px] text-[var(--text-secondary)] mb-0.5"></div>
<div className="text-xl font-semibold text-[var(--text-primary)]">¥ 872</div>
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5"></div>
</div>
</div>
</section>
{/* 危险操作 */}
<section id="danger" className="rounded-xl surface-2 p-5 border border-[var(--hot)]/20">
<h2 className="typo-h2 mb-4 text-[var(--hot)]/80"></h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-[var(--text-primary)]"></div>
<div className="text-[10px] text-[var(--text-secondary)]">"全局设置"</div>
</div>
<button
onClick={() => alert("(占位)归档项目")}
className="px-3 py-1.5 text-xs rounded-lg border border-[var(--border)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] cursor-pointer"
>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-[var(--hot)]/80"></div>
<div className="text-[10px] text-[var(--text-secondary)]"></div>
</div>
<button
onClick={() => {
if (!confirm("确认删除该项目?此操作不可撤销(占位)")) return;
alert("(占位)已请求删除项目。真实删除逻辑待接入。");
}}
className="px-3 py-1.5 text-xs rounded-lg text-[var(--hot)]/80
hover:text-[var(--hot)] hover:bg-[var(--hot)]/10 border border-[var(--hot)]/30
cursor-pointer"
>
</button>
</div>
</div>
</section>
{/* 保存栏 */}
<div className="flex items-center justify-between sticky bottom-0 py-3 bg-[var(--bg-primary)]/80 backdrop-blur-md -mx-5 px-5">
<div className="text-[10px] text-[var(--text-secondary)]">
ID: {activeProject.id} · {new Date(activeProject.createdAt).toLocaleDateString("zh-CN")}
</div>
<div className="flex gap-2">
<Link
href="/"
className="px-3.5 py-2 text-xs rounded-xl border border-[var(--border)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] cursor-pointer"
>
</Link>
<button
onClick={handleSave}
className="px-4 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)] cursor-pointer btn-hover-lift transition-all"
>
</button>
</div>
</div>
{/* 其他项目(快速切换) */}
{projects.length > 1 && (
<section className="rounded-xl surface-2 p-5">
<h2 className="typo-h2 mb-3"></h2>
<div className="grid grid-cols-2 gap-2">
{projects.filter((p) => p.id !== activeProject.id).map((p) => (
<button
key={p.id}
onClick={() => switchProject(p.id)}
className="text-left p-3 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/40
cursor-pointer transition-all"
>
<div className="text-sm text-[var(--text-primary)] truncate">{p.name}</div>
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{p.type}</div>
</button>
))}
</div>
</section>
)}
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,464 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
import type { StylePack, StyleRefImage } from "@/lib/types";
export default function StyleDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const isNew = params.id === "new";
const {
stylePacks,
upsertStylePack,
deleteStylePack,
setSessionStyleId,
assets,
} = useApp();
const [pack, setPack] = useState<StylePack | null>(null);
useEffect(() => {
if (isNew) {
const now = Date.now();
setPack({
id: `style-${now.toString(36)}`,
name: "",
description: "",
keywords: [],
source: "custom",
refImages: [],
positivePrompt: "",
negativePrompt: "",
usageCount: 0,
createdAt: now,
updatedAt: now,
});
} else {
const found = stylePacks.find((s) => s.id === params.id);
if (found) setPack(found);
}
}, [params.id, stylePacks, isNew]);
if (!pack) {
return (
<div className="h-screen flex flex-col">
<TopNav />
<main className="flex-1 flex items-center justify-center">
<p className="typo-caption">...</p>
</main>
</div>
);
}
const handleSave = () => {
if (!pack.name.trim()) {
alert("请填写风格名称");
return;
}
upsertStylePack(pack);
alert("已保存(占位)");
if (isNew) router.push(`/styles/${pack.id}`);
};
const handleApplyToSession = () => {
setSessionStyleId(pack.id);
alert(`已将风格 "${pack.name}" 应用到当前会话(占位)`);
router.push("/");
};
const handleFork = () => {
const now = Date.now();
const forked: StylePack = {
...pack,
id: `style-${now.toString(36)}`,
name: `${pack.name} (副本)`,
source: "custom",
forkFromId: pack.id,
usageCount: 0,
createdAt: now,
updatedAt: now,
};
upsertStylePack(forked);
alert("已创建副本(占位)");
router.push(`/styles/${forked.id}`);
};
const handleDelete = () => {
if (!confirm(`确认删除风格 "${pack.name}"?(占位操作)`)) return;
deleteStylePack(pack.id);
router.push("/styles");
};
const addRef = () => {
const id = `ref-${Date.now()}`;
setPack({
...pack,
refImages: [...pack.refImages, { id, url: "", caption: "占位参考图" }],
});
};
const removeRef = (id: string) => {
setPack({ ...pack, refImages: pack.refImages.filter((r) => r.id !== id) });
};
const addKeyword = (kw: string) => {
if (!kw || pack.keywords.includes(kw)) return;
setPack({ ...pack, keywords: [...pack.keywords, kw] });
};
const removeKeyword = (kw: string) => {
setPack({ ...pack, keywords: pack.keywords.filter((k) => k !== kw) });
};
// 历史预览(按 prompt 关键词简单匹配)
const relatedAssets = assets
.filter((a) =>
pack.keywords.some((k) => a.prompt.toLowerCase().includes(k.toLowerCase()))
)
.slice(0, 6);
return (
<div className="h-screen flex flex-col">
<AmbientParticles count={12} />
<TopNav />
<main className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6 md:py-8">
{/* 面包屑 */}
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
<Link href="/styles" className="hover:text-[var(--accent)] cursor-pointer">
</Link>
<span>/</span>
<span className="text-[var(--text-primary)]">
{isNew ? "新建风格" : pack.name || "未命名"}
</span>
</div>
{/* 标题 + 操作 */}
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<input
value={pack.name}
onChange={(e) => setPack({ ...pack, name: e.target.value })}
placeholder="风格名称"
className="text-2xl font-bold bg-transparent border-none
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/60
focus:outline-none min-w-0 flex-1"
/>
<span className="phase-chip flex-shrink-0">Phase 1</span>
</div>
{pack.forkFromId && (
<div className="text-xs text-[var(--text-secondary)]">
Fork
<Link
href={`/styles/${pack.forkFromId}`}
className="text-[var(--accent)] hover:underline"
>
{stylePacks.find((s) => s.id === pack.forkFromId)?.name || pack.forkFromId}
</Link>
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={handleApplyToSession}
className="px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
cursor-pointer btn-hover-lift transition-all"
>
</button>
<button
onClick={handleSave}
className="px-3.5 py-2 text-xs rounded-xl font-medium
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-primary)] hover:border-[var(--accent)]/40
cursor-pointer btn-hover-lift transition-all"
>
</button>
{!isNew && (
<>
<button
onClick={handleFork}
className="px-3.5 py-2 text-xs rounded-xl font-medium
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
cursor-pointer btn-hover-lift transition-all"
>
/ Fork
</button>
<Link
href={`/training/new?type=style&source=${pack.id}`}
className="px-3.5 py-2 text-xs rounded-xl font-medium
border border-[var(--gold)]/30 bg-[var(--gold)]/8
text-[var(--gold)] hover:bg-[var(--gold)]/15
cursor-pointer btn-hover-lift transition-all
flex items-center gap-1"
>
LoRA
<span className="text-[9px]">P2</span>
</Link>
<button
onClick={handleDelete}
className="px-3.5 py-2 text-xs rounded-xl font-medium
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--hot)]/60 hover:text-[var(--hot)]
hover:border-[var(--hot)]/40
cursor-pointer btn-hover-lift transition-all"
>
</button>
</>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:基本信息 + Prompt */}
<div className="lg:col-span-2 space-y-5">
{/* 描述 */}
<section>
<h2 className="typo-h2 mb-2"></h2>
<textarea
value={pack.description}
onChange={(e) => setPack({ ...pack, description: e.target.value })}
placeholder="用一句话描述这个风格的核心特征..."
rows={3}
className="w-full p-3 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
text-sm text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]/50 resize-none"
/>
</section>
{/* 关键词 */}
<section>
<h2 className="typo-h2 mb-2"></h2>
<div className="flex flex-wrap gap-1.5 mb-2">
{pack.keywords.map((k) => (
<span
key={k}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg
bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
>
{k}
<button
onClick={() => removeKeyword(k)}
className="hover:text-[var(--hot)] cursor-pointer"
>
×
</button>
</span>
))}
<KeywordInput onAdd={addKeyword} />
</div>
</section>
{/* 风格参考集 */}
<section>
<div className="flex items-center justify-between mb-2">
<h2 className="typo-h2"></h2>
<button
onClick={addRef}
className="text-xs px-2.5 py-1 rounded-lg border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
>
+
</button>
</div>
<p className="typo-caption mb-3">
3-10
</p>
<div className="grid grid-cols-3 md:grid-cols-4 gap-2.5">
{pack.refImages.map((ref) => (
<RefImageCard
key={ref.id}
ref_={ref}
onRemove={() => removeRef(ref.id)}
/>
))}
{pack.refImages.length === 0 && (
<div className="col-span-3 md:col-span-4 py-8 text-center text-xs text-[var(--text-secondary)]
placeholder-card rounded-xl">
</div>
)}
</div>
</section>
{/* Prompt 模板 */}
<section>
<h2 className="typo-h2 mb-2">Prompt </h2>
<div className="space-y-2.5">
<div>
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>
Prompt
</div>
<textarea
value={pack.positivePrompt ?? ""}
onChange={(e) => setPack({ ...pack, positivePrompt: e.target.value })}
placeholder="描述风格特征的 Prompt 片段,会在生成时前置注入..."
rows={2}
className="w-full p-2.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
text-xs text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]/50 resize-none font-mono"
/>
</div>
<div>
<div className="typo-micro mb-1" style={{ textTransform: "none" }}>
Prompt
</div>
<textarea
value={pack.negativePrompt ?? ""}
onChange={(e) => setPack({ ...pack, negativePrompt: e.target.value })}
placeholder="要避免的风格特征..."
rows={2}
className="w-full p-2.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
text-xs text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]/50 resize-none font-mono"
/>
</div>
</div>
</section>
{/* 历史预览 */}
<section>
<h2 className="typo-h2 mb-2">使</h2>
<p className="typo-caption mb-3">
</p>
{relatedAssets.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--text-secondary)]
placeholder-card rounded-xl">
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
{relatedAssets.map((a) => (
<div
key={a.id}
className="aspect-square rounded-lg overflow-hidden border border-[var(--border)]"
>
<div className="w-full h-full bg-[var(--bg-tertiary)] flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
</div>
</div>
))}
</div>
)}
</section>
</div>
{/* 右侧Phase 2 占位LoRA / Embedding + 元信息 */}
<aside className="space-y-4">
<div className="placeholder-card rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="typo-strong text-xs"> LoRA</span>
<span className="phase-chip">P2</span>
</div>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed mb-2">
{pack.loraId
? `已绑定:${pack.loraId}(占位)`
: "尚未绑定。可通过训练中心生成风格 LoRA 并绑定到此风格。"}
</p>
<Link
href={`/training/new?type=style&source=${pack.id}`}
className="text-xs text-[var(--gold)] hover:underline"
>
</Link>
</div>
<div className="placeholder-card rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="typo-strong text-xs"> Embedding</span>
<span className="phase-chip">P2</span>
</div>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
</p>
</div>
<div className="rounded-xl surface-2 p-4">
<span className="typo-strong text-xs block mb-2">使</span>
<div className="text-xs text-[var(--text-secondary)] space-y-1">
<div>使{pack.usageCount}</div>
<div>
{pack.source === "preset" ? "平台预设" : pack.source === "custom" ? "项目自建" : "社区共享"}
</div>
<div>{new Date(pack.createdAt).toLocaleDateString("zh-CN")}</div>
<div>{new Date(pack.updatedAt).toLocaleDateString("zh-CN")}</div>
</div>
</div>
</aside>
</div>
</div>
</main>
</div>
);
}
function KeywordInput({ onAdd }: { onAdd: (kw: string) => void }) {
const [val, setVal] = useState("");
return (
<input
value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && val.trim()) {
onAdd(val.trim());
setVal("");
}
}}
placeholder="+ 添加关键词(回车)"
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none focus:border-[var(--accent)]/50 min-w-[120px]"
/>
);
}
function RefImageCard({
ref_,
onRemove,
}: {
ref_: StyleRefImage;
onRemove: () => void;
}) {
return (
<div className="relative aspect-square rounded-lg overflow-hidden
border border-[var(--border)] group">
{ref_.url ? (
<img src={ref_.url} alt={ref_.caption} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-[var(--accent)]/10 to-[var(--accent-secondary)]/5
flex items-center justify-center">
<div className="text-center">
<div className="text-[10px] text-[var(--accent)]/60 mb-0.5"></div>
<div className="text-[9px] text-[var(--text-secondary)]">
{ref_.caption}
</div>
</div>
</div>
)}
<button
onClick={onRemove}
className="absolute top-1 right-1 w-5 h-5 rounded-full
bg-black/60 text-white text-xs opacity-0 group-hover:opacity-100
cursor-pointer transition-opacity flex items-center justify-center"
>
×
</button>
</div>
);
}

View File

@@ -0,0 +1,251 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
import type { StylePack } from "@/lib/types";
type SourceFilter = "all" | "preset" | "custom" | "community";
const ALL_KEYWORDS = ["写实", "二次元", "像素", "水墨", "赛博", "奇幻", "写意", "科幻", "复古"];
export default function StylesPage() {
const { stylePacks } = useApp();
const [query, setQuery] = useState("");
const [source, setSource] = useState<SourceFilter>("all");
const [keyword, setKeyword] = useState<string | null>(null);
const filtered = useMemo(() => {
let list = [...stylePacks];
if (source !== "all") list = list.filter((s) => s.source === source);
if (keyword) list = list.filter((s) => s.keywords.includes(keyword));
if (query.trim()) {
const q = query.trim().toLowerCase();
list = list.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q) ||
s.keywords.some((k) => k.toLowerCase().includes(q))
);
}
return list;
}, [stylePacks, source, keyword, query]);
return (
<div className="h-screen flex flex-col relative z-[1]">
<AmbientParticles count={16} />
<TopNav />
<main className="flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto px-4 md:px-8 py-6 md:py-8">
{/* 页面标题 */}
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-2 mb-1.5">
<h1 className="typo-h1"></h1>
<span className="phase-chip">Phase 1</span>
</div>
<p className="typo-caption">
Prompt LoRA
</p>
</div>
<Link
href="/styles/new"
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 5v14M5 12h14" />
</svg>
</Link>
</div>
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-3 mb-5">
<div className="flex items-center gap-1.5 flex-1 min-w-[200px] max-w-[360px]
px-3 py-1.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border)]
focus-within:border-[var(--accent)]/40 transition-colors">
<svg width="14" height="14" 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={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索风格名 / 描述 / 关键词..."
className="flex-1 bg-transparent border-none text-sm
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
focus:outline-none"
/>
</div>
{/* 来源筛选 */}
<div className="flex items-center gap-0.5 bg-[var(--bg-tertiary)] rounded-lg p-0.5 border border-[var(--border)]">
{(["all", "preset", "custom", "community"] as const).map((s) => (
<button
key={s}
onClick={() => setSource(s)}
className={`px-2.5 py-1 text-xs rounded-md cursor-pointer transition-all ${
source === s
? "bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{s === "all" ? "全部" : s === "preset" ? "预设" : s === "custom" ? "自建" : "社区"}
</button>
))}
</div>
</div>
{/* 分类筛选(关键词) */}
<div className="flex flex-wrap items-center gap-1.5 mb-6">
<span className="typo-micro mr-1" style={{ textTransform: "none" }}></span>
<button
onClick={() => setKeyword(null)}
className={`px-2.5 py-1 text-[11px] rounded-lg cursor-pointer transition-all ${
!keyword
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
: "text-[var(--text-secondary)] border border-transparent hover:border-[var(--border)]"
}`}
>
</button>
{ALL_KEYWORDS.map((k) => {
const active = keyword === k;
return (
<button
key={k}
onClick={() => setKeyword(active ? null : k)}
className={`px-2.5 py-1 text-[11px] rounded-lg cursor-pointer transition-all ${
active
? "bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30"
: "text-[var(--text-secondary)] border border-transparent hover:border-[var(--border)]"
}`}
>
{k}
</button>
);
})}
</div>
{/* 卡片网格 */}
{filtered.length === 0 ? (
<div className="text-center py-20">
<p className="typo-display text-lg mb-2"></p>
<p className="typo-caption">&ldquo;&rdquo;</p>
</div>
) : (
<motion.div
variants={staggerContainer}
initial="hidden"
animate="visible"
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
>
{filtered.map((pack) => (
<StyleCard key={pack.id} pack={pack} />
))}
{/* 新建卡片 */}
<motion.div variants={staggerItem}>
<Link
href="/styles/new"
className="aspect-[3/4] rounded-xl placeholder-card
flex flex-col items-center justify-center gap-2
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/50 cursor-pointer
transition-all btn-hover-lift"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 5v14M5 12h14" />
</svg>
<span className="text-xs"></span>
</Link>
</motion.div>
</motion.div>
)}
</div>
</main>
</div>
);
}
function StyleCard({ pack }: { pack: StylePack }) {
const sourceLabel =
pack.source === "preset" ? "预设" : pack.source === "custom" ? "自建" : "社区";
return (
<motion.div variants={staggerItem}>
<Link
href={`/styles/${pack.id}`}
className="block rounded-xl overflow-hidden neon-border
bg-[var(--bg-card)] backdrop-blur-sm cursor-pointer
transition-all"
>
{/* 代表图(占位) */}
<div className="aspect-[4/3] relative overflow-hidden
bg-gradient-to-br from-[var(--accent)]/15 via-[var(--accent-secondary)]/10 to-transparent">
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-xl font-bold text-[var(--accent)] tracking-tight opacity-60">
{pack.name.slice(0, 2)}
</div>
<div className="text-[9px] text-[var(--text-secondary)] mt-1">
{pack.refImages.length}
</div>
</div>
</div>
{/* 来源标签 */}
<span
className="absolute top-2 right-2 text-[9px] px-1.5 py-0.5 rounded-md font-medium"
style={{
background:
pack.source === "preset"
? "rgba(46, 139, 122, 0.15)"
: pack.source === "custom"
? "rgba(58, 127, 184, 0.15)"
: "rgba(184, 147, 90, 0.15)",
color:
pack.source === "preset"
? "var(--accent)"
: pack.source === "custom"
? "var(--accent-secondary)"
: "var(--gold)",
}}
>
{sourceLabel}
</span>
</div>
<div className="p-3">
<div className="text-sm font-medium text-[var(--text-primary)] truncate mb-1">
{pack.name}
</div>
<div className="flex flex-wrap gap-1 mb-2">
{pack.keywords.slice(0, 3).map((k) => (
<span
key={k}
className="text-[9px] px-1.5 py-0.5 rounded-md
bg-[var(--accent)]/8 text-[var(--accent)] font-medium"
>
{k}
</span>
))}
</div>
<div className="flex items-center justify-between text-[10px] text-[var(--text-secondary)]">
<span>使 {pack.usageCount} </span>
{pack.loraId && <span className="phase-chip-accent phase-chip">LoRA</span>}
</div>
</div>
</Link>
</motion.div>
);
}

View File

@@ -0,0 +1,347 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
import type { TrainingTask, TrainingTaskStatus } from "@/lib/types";
const STATUS_LABEL: Record<TrainingTaskStatus, string> = {
queued: "排队中",
running: "训练中",
completed: "已完成",
failed: "已失败",
deprecated: "已弃用",
};
const STATUS_COLOR: Record<TrainingTaskStatus, string> = {
queued: "#6B7B8A",
running: "#2E8B7A",
completed: "#3A7FB8",
failed: "#C4654A",
deprecated: "#B8935A",
};
export default function TrainingDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const {
trainingTasks,
upsertTrainingTask,
deleteTrainingTask,
stylePacks,
characters,
} = useApp();
const [task, setTask] = useState<TrainingTask | null>(null);
const [logOpen, setLogOpen] = useState(true);
useEffect(() => {
const t = trainingTasks.find((x) => x.id === params.id);
setTask(t ?? null);
}, [params.id, trainingTasks]);
if (!task) {
return (
<div className="h-screen flex flex-col">
<TopNav />
<main className="flex-1 flex items-center justify-center">
<div className="text-center">
<p className="typo-caption mb-2"></p>
<Link href="/training" className="text-sm text-[var(--accent)] hover:underline">
</Link>
</div>
</main>
</div>
);
}
const source =
task.type === "style_lora"
? stylePacks.find((s) => s.id === task.sourceId)
: characters.find((c) => c.id === task.sourceId);
const handleUse = () => {
alert(`(占位)已绑定此 LoRA 到当前会话:${task.name}`);
router.push("/");
};
const handleRetrain = () => {
alert("(占位)将基于当前训练集重新启动训练任务。");
};
const handleDeprecate = () => {
if (!confirm("确认弃用该模型?")) return;
upsertTrainingTask({ ...task, status: "deprecated" });
};
const handleDelete = () => {
if (!confirm("确认删除该训练任务?")) return;
deleteTrainingTask(task.id);
router.push("/training");
};
return (
<div className="h-screen flex flex-col">
<AmbientParticles count={12} />
<TopNav />
<main className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6 md:py-8">
{/* 面包屑 */}
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
<Link href="/training" className="hover:text-[var(--accent)] cursor-pointer">
</Link>
<span>/</span>
<span className="text-[var(--text-primary)]">{task.name}</span>
</div>
{/* 头部 */}
<div className="flex flex-wrap items-start justify-between gap-4 mb-5">
<div>
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<h1 className="typo-h1">{task.name}</h1>
<span
className="text-[10px] px-2 py-0.5 rounded-md font-medium"
style={{
background: STATUS_COLOR[task.status] + "22",
color: STATUS_COLOR[task.status],
}}
>
{STATUS_LABEL[task.status]}
</span>
<span className="phase-chip">Phase 2</span>
</div>
<div className="text-xs text-[var(--text-secondary)]">
{task.type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
{source && (
<>
{" · 来源:"}
<Link
href={
task.type === "style_lora"
? `/styles/${task.sourceId}`
: `/characters/${task.sourceId}`
}
className="text-[var(--accent)] hover:underline"
>
{source.name}
</Link>
</>
)}
{" · 模板:"}
{task.template}
</div>
</div>
<div className="flex flex-wrap gap-2">
{task.status === "completed" && (
<button
onClick={handleUse}
className="px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
cursor-pointer btn-hover-lift transition-all"
>
使
</button>
)}
{task.status === "failed" && (
<button
onClick={handleRetrain}
className="px-3.5 py-2 text-xs rounded-xl font-medium
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-primary)] hover:border-[var(--accent)]/40
cursor-pointer btn-hover-lift transition-all"
>
</button>
)}
{task.status !== "deprecated" && task.status !== "queued" && (
<button
onClick={handleDeprecate}
className="px-3.5 py-2 text-xs rounded-xl font-medium
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
cursor-pointer btn-hover-lift transition-all"
>
</button>
)}
<button
onClick={handleDelete}
className="px-3.5 py-2 text-xs rounded-xl font-medium
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--hot)]/60 hover:text-[var(--hot)]
hover:border-[var(--hot)]/40
cursor-pointer btn-hover-lift transition-all"
>
</button>
</div>
</div>
{/* 进度条 */}
{task.status === "running" && (
<div className="rounded-xl surface-2 p-4 mb-5">
<div className="flex items-center justify-between mb-2 text-xs">
<span className="typo-strong">{task.currentStep ?? "训练中..."}</span>
<span className="text-[var(--accent)] font-medium">{task.progress}%</span>
</div>
<div className="h-2 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[var(--accent)] to-[var(--accent-secondary)] transition-all"
style={{ width: `${task.progress}%` }}
/>
</div>
{task.etaSeconds && (
<div className="text-[10px] text-[var(--text-secondary)] mt-1.5">
{Math.floor(task.etaSeconds / 60)} {task.etaSeconds % 60}
</div>
)}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* 左:日志 + 训练前后对比 + 训练集预览 */}
<div className="lg:col-span-2 space-y-5">
{/* 训练前后对比 */}
<section className="rounded-xl surface-2 p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="typo-h2"></h2>
<span className="phase-chip">P2</span>
</div>
{task.status === "completed" ? (
<div className="grid grid-cols-2 gap-3">
<div>
<div className="typo-micro mb-1" style={{ textTransform: "none" }}></div>
<div className="aspect-square rounded-lg placeholder-card
flex items-center justify-center text-xs text-[var(--text-secondary)]">
</div>
</div>
<div>
<div className="typo-micro mb-1" style={{ textTransform: "none" }}></div>
<div className="aspect-square rounded-lg placeholder-card
flex items-center justify-center text-xs text-[var(--text-secondary)]">
</div>
</div>
</div>
) : (
<p className="text-xs text-[var(--text-secondary)] py-6 text-center">
Prompt
</p>
)}
</section>
{/* 训练日志 */}
<section className="rounded-xl surface-2 overflow-hidden">
<button
onClick={() => setLogOpen(!logOpen)}
className="w-full px-4 py-2.5 flex items-center justify-between
hover:bg-[var(--bg-tertiary)]/50 cursor-pointer transition-colors"
>
<span className="typo-strong text-xs"></span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
className={`transition-transform ${logOpen ? "" : "rotate-180"}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{logOpen && (
<div className="border-t border-[var(--border)] p-3 bg-[var(--bg-primary)]/40 font-mono text-[11px] max-h-60 overflow-y-auto">
{task.logs.length === 0 ? (
<p className="text-[var(--text-secondary)]"></p>
) : (
task.logs.map((l, i) => (
<div key={i} className="text-[var(--text-secondary)] leading-relaxed">
{l}
</div>
))
)}
</div>
)}
</section>
{/* 训练集预览 */}
<section>
<h2 className="typo-h2 mb-2"></h2>
<p className="typo-caption mb-3">
{task.trainingSet.length}
</p>
{task.trainingSet.length === 0 ? (
<div className="py-8 text-center placeholder-card rounded-xl text-xs text-[var(--text-secondary)]">
</div>
) : (
<div className="grid grid-cols-4 md:grid-cols-6 gap-2">
{task.trainingSet.slice(0, 12).map((_, i) => (
<div key={i}
className="aspect-square rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]
flex items-center justify-center text-[9px] text-[var(--text-secondary)]">
</div>
))}
</div>
)}
</section>
</div>
{/* 右:版本 + 成本 + 操作入口 */}
<aside className="space-y-4">
<div className="placeholder-card rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="typo-strong text-xs"></span>
<span className="phase-chip">P2</span>
</div>
<div className="text-xs text-[var(--text-secondary)] space-y-1">
<div>v1.0</div>
<div>v0.9v0.8</div>
</div>
<button
onClick={() => alert("(占位)回滚到上一版本")}
className="mt-2 text-xs text-[var(--accent)] hover:underline cursor-pointer"
>
</button>
</div>
<div className="rounded-xl surface-2 p-4">
<span className="typo-strong text-xs block mb-2"></span>
<div className="text-xs text-[var(--text-secondary)] space-y-1">
<div>{task.template}</div>
<div>{task.estCost ?? "—"}</div>
<div>{new Date(task.createdAt).toLocaleString("zh-CN")}</div>
<div>{new Date(task.updatedAt).toLocaleString("zh-CN")}</div>
</div>
</div>
{source && (
<div className="rounded-xl surface-2 p-4">
<span className="typo-strong text-xs block mb-2"></span>
<Link
href={
task.type === "style_lora"
? `/styles/${task.sourceId}`
: `/characters/${task.sourceId}`
}
className="text-xs text-[var(--accent)] hover:underline block truncate"
>
{source.name}
</Link>
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
{task.type === "style_lora" ? "风格集" : "角色卡"}
</div>
</div>
)}
</aside>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,353 @@
"use client";
import { useState, useMemo, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
import { generateId } from "@/lib/store";
import type { TrainingTask, TrainingTaskType } from "@/lib/types";
type WizardStep = 1 | 2 | 3 | 4;
const TEMPLATES = [
{ id: "sdxl_lora", name: "SDXL LoRA", desc: "基于 SDXL 底模,兼顾速度与效果(占位)" },
{ id: "flux_lora", name: "Flux LoRA", desc: "基于 Flux 底模,细节更精细(占位)" },
{ id: "sd15_lora", name: "SD 1.5 LoRA", desc: "轻量训练,适合像素/卡通(占位)" },
];
function TrainingWizardInner() {
const router = useRouter();
const search = useSearchParams();
const { stylePacks, characters, upsertTrainingTask } = useApp();
const initType = (search.get("type") as TrainingTaskType) || "style_lora";
const initSourceId = search.get("sourceId") ?? "";
const [step, setStep] = useState<WizardStep>(1);
const [type, setType] = useState<TrainingTaskType>(initType);
const [sourceId, setSourceId] = useState(initSourceId);
const [template, setTemplate] = useState("sdxl_lora");
const [name, setName] = useState("");
const [trainingSetSize, setTrainingSetSize] = useState(12);
const sources = type === "style_lora" ? stylePacks : characters;
const selectedSource = useMemo(
() => sources.find((s) => s.id === sourceId),
[sources, sourceId]
);
const canNext = useMemo(() => {
if (step === 1) return !!sourceId;
if (step === 2) return !!template && trainingSetSize > 0;
if (step === 3) return true;
return true;
}, [step, sourceId, template, trainingSetSize]);
const defaultName = useMemo(() => {
if (!selectedSource) return "";
return `${selectedSource.name} ${type === "style_lora" ? "风格" : "角色"} LoRA v1`;
}, [selectedSource, type]);
const finalName = name.trim() || defaultName;
const handleSubmit = () => {
const now = Date.now();
const t: TrainingTask = {
id: generateId("train-"),
name: finalName || "未命名训练任务",
type,
status: "queued",
progress: 0,
sourceId,
template,
trainingSet: Array.from({ length: trainingSetSize }).map((_, i) => `asset-pick-${i + 1}`),
logs: ["[占位] 训练任务已提交,排队中..."],
estCost: "≈ ¥18占位",
createdAt: now,
updatedAt: now,
};
upsertTrainingTask(t);
alert("(占位)训练任务已提交。真实训练能力尚未接入。");
router.push(`/training/${t.id}`);
};
const stepLabels = ["选择来源", "训练配置", "数据集校验", "提交确认"];
return (
<div className="h-screen flex flex-col">
<AmbientParticles count={10} />
<TopNav />
<main className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-4 md:px-8 py-6 md:py-8">
{/* 面包屑 */}
<div className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] mb-4">
<Link href="/training" className="hover:text-[var(--accent)] cursor-pointer">
</Link>
<span>/</span>
<span className="text-[var(--text-primary)]"></span>
</div>
<div className="flex items-center gap-2 mb-5">
<h1 className="typo-h1"></h1>
<span className="phase-chip">Phase 2</span>
</div>
{/* Step indicator */}
<div className="flex items-center justify-between mb-6">
{stepLabels.map((label, i) => {
const n = (i + 1) as WizardStep;
const active = step === n;
const done = step > n;
return (
<div key={label} className="flex-1 flex items-center">
<div className="flex items-center gap-2">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-medium
transition-all ${
active
? "bg-[var(--accent)] text-[var(--bg-primary)]"
: done
? "bg-[var(--accent)]/20 text-[var(--accent)]"
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]"
}`}
>
{done ? "✓" : n}
</div>
<span className={`text-xs hidden md:inline ${active ? "text-[var(--text-primary)] font-medium" : "text-[var(--text-secondary)]"}`}>
{label}
</span>
</div>
{i < stepLabels.length - 1 && (
<div className={`flex-1 h-[1px] mx-2 ${done ? "bg-[var(--accent)]/40" : "bg-[var(--border)]"}`} />
)}
</div>
);
})}
</div>
<div className="rounded-xl surface-2 p-5 mb-5">
{/* Step 1: 选择来源 */}
{step === 1 && (
<div className="space-y-4">
<div>
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}></label>
<div className="flex gap-2">
{(["style_lora", "character_lora"] as const).map((t) => (
<button
key={t}
onClick={() => {
setType(t);
setSourceId("");
}}
className={`flex-1 px-3 py-2.5 text-sm rounded-lg border cursor-pointer transition-all ${
type === t
? "border-[var(--accent)] bg-[var(--accent)]/10 text-[var(--accent)] font-medium"
: "border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{t === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
</button>
))}
</div>
</div>
<div>
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
{type === "style_lora" ? "风格集" : "角色卡"}
</label>
{sources.length === 0 ? (
<div className="placeholder-card rounded-lg p-6 text-center text-xs text-[var(--text-secondary)]">
{type === "style_lora" ? "风格集" : "角色卡"}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-72 overflow-y-auto">
{sources.map((s) => (
<button
key={s.id}
onClick={() => setSourceId(s.id)}
className={`text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
sourceId === s.id
? "border-[var(--accent)] bg-[var(--accent)]/8"
: "border-[var(--border)] hover:border-[var(--accent)]/40"
}`}
>
<div className="text-sm text-[var(--text-primary)] font-medium truncate">{s.name}</div>
<div className="text-[10px] text-[var(--text-secondary)] truncate mt-0.5">
{s.description || "无描述"}
</div>
</button>
))}
</div>
)}
</div>
</div>
)}
{/* Step 2: 训练配置 */}
{step === 2 && (
<div className="space-y-4">
<div>
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}></label>
<div className="space-y-2">
{TEMPLATES.map((tpl) => (
<button
key={tpl.id}
onClick={() => setTemplate(tpl.id)}
className={`w-full text-left px-3 py-2.5 rounded-lg border cursor-pointer transition-all ${
template === tpl.id
? "border-[var(--accent)] bg-[var(--accent)]/8"
: "border-[var(--border)] hover:border-[var(--accent)]/40"
}`}
>
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-primary)] font-medium">{tpl.name}</span>
<span className="phase-chip">P2</span>
</div>
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{tpl.desc}</div>
</button>
))}
</div>
</div>
<div>
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}>
</label>
<div className="grid grid-cols-3 gap-2">
{["快速 (3 epoch)", "标准 (6 epoch)", "深度 (10 epoch)"].map((opt, i) => (
<button
key={opt}
onClick={() => {}}
className={`px-2.5 py-2 text-xs rounded-lg border cursor-pointer ${
i === 1
? "border-[var(--accent)] bg-[var(--accent)]/8 text-[var(--accent)]"
: "border-[var(--border)] text-[var(--text-secondary)]"
}`}
>
{opt}
</button>
))}
</div>
</div>
</div>
)}
{/* Step 3: 数据集校验 */}
{step === 3 && (
<div className="space-y-4">
<div>
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}></label>
<input
type="number"
min={1}
max={200}
value={trainingSetSize}
onChange={(e) => setTrainingSetSize(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
/>
<p className="text-[10px] text-[var(--text-secondary)] mt-1.5">
830 Phase 2
</p>
</div>
<div className="placeholder-card rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="typo-strong text-xs"></span>
<span className="text-[10px] text-[var(--accent)]"> </span>
</div>
<ul className="text-[11px] text-[var(--text-secondary)] space-y-1">
<li>· {trainingSetSize} </li>
<li>· 768px</li>
<li>· </li>
<li>· NSFW </li>
</ul>
</div>
</div>
)}
{/* Step 4: 提交确认 */}
{step === 4 && (
<div className="space-y-4">
<div>
<label className="block typo-micro mb-2" style={{ textTransform: "none" }}></label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={defaultName || "请先选择来源"}
className="w-full px-3 py-2 text-sm rounded-lg bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
/>
</div>
<div className="rounded-lg surface-2 p-3 text-xs text-[var(--text-secondary)] space-y-1">
<div><span className="text-[var(--text-primary)]">{type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}</span></div>
<div><span className="text-[var(--text-primary)]">{selectedSource?.name ?? "—"}</span></div>
<div><span className="text-[var(--text-primary)]">{template}</span></div>
<div><span className="text-[var(--text-primary)]">{trainingSetSize} </span></div>
<div><span className="text-[var(--text-primary)]"> ¥18</span></div>
<div><span className="text-[var(--text-primary)]"> 20 </span></div>
</div>
<div className="placeholder-card rounded-lg p-3 text-[11px] text-[var(--text-secondary)]">
</div>
</div>
)}
</div>
{/* Nav buttons */}
<div className="flex items-center justify-between">
<button
onClick={() => {
if (step === 1) router.push("/training");
else setStep((step - 1) as WizardStep);
}}
className="px-3.5 py-2 text-xs rounded-xl border border-[var(--border)]
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--text-primary)] cursor-pointer btn-hover-lift transition-all"
>
{step === 1 ? "取消" : "上一步"}
</button>
{step < 4 ? (
<button
onClick={() => canNext && setStep((step + 1) as WizardStep)}
disabled={!canNext}
className="px-4 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)] disabled:opacity-40 disabled:cursor-not-allowed
cursor-pointer btn-hover-lift transition-all"
>
</button>
) : (
<button
onClick={handleSubmit}
className="px-4 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
cursor-pointer btn-hover-lift transition-all"
>
</button>
)}
</div>
</div>
</main>
</div>
);
}
export default function TrainingWizardPage() {
return (
<Suspense fallback={<div className="h-screen" />}>
<TrainingWizardInner />
</Suspense>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import { TopNav } from "@/components/layout/top-nav";
import { AmbientParticles } from "@/components/ui/ambient-particles";
import { useApp } from "@/lib/app-context";
import { staggerContainer, staggerItem } from "@/components/ui/motion-presets";
import type { TrainingTask, TrainingTaskStatus } from "@/lib/types";
const STATUS_LABEL: Record<TrainingTaskStatus, string> = {
queued: "排队中",
running: "训练中",
completed: "已完成",
failed: "已失败",
deprecated: "已弃用",
};
const STATUS_COLOR: Record<TrainingTaskStatus, string> = {
queued: "#6B7B8A",
running: "#2E8B7A",
completed: "#3A7FB8",
failed: "#C4654A",
deprecated: "#B8935A",
};
export default function TrainingPage() {
const { trainingTasks, stylePacks, characters } = useApp();
const [filter, setFilter] = useState<"all" | TrainingTaskStatus>("all");
const filtered = useMemo(() => {
if (filter === "all") return trainingTasks;
return trainingTasks.filter((t) => t.status === filter);
}, [trainingTasks, filter]);
const sourceName = (t: TrainingTask) => {
if (!t.sourceId) return "—";
if (t.type === "style_lora") {
return stylePacks.find((s) => s.id === t.sourceId)?.name ?? t.sourceId;
}
return characters.find((c) => c.id === t.sourceId)?.name ?? t.sourceId;
};
return (
<div className="h-screen flex flex-col relative z-[1]">
<AmbientParticles count={14} />
<TopNav />
<main className="flex-1 overflow-y-auto">
<div className="max-w-6xl mx-auto px-4 md:px-8 py-6 md:py-8">
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-2 mb-1.5">
<h1 className="typo-h1"></h1>
<span className="phase-chip">Phase 2</span>
</div>
<p className="typo-caption">
/ LoRA Phase 2
</p>
</div>
<Link
href="/training/new"
className="flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
transition-all cursor-pointer btn-hover-lift flex-shrink-0"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 5v14M5 12h14" />
</svg>
</Link>
</div>
{/* 状态筛选 */}
<div className="flex items-center gap-1.5 mb-5 overflow-x-auto hide-scrollbar">
{(["all", "queued", "running", "completed", "failed", "deprecated"] as const).map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={`px-2.5 py-1 text-xs rounded-lg cursor-pointer transition-all whitespace-nowrap ${
filter === s
? "bg-[var(--accent)]/10 text-[var(--accent)] font-medium border border-[var(--accent)]/30"
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{s === "all" ? "全部" : STATUS_LABEL[s]}
</button>
))}
</div>
{/* 任务表格 */}
{filtered.length === 0 ? (
<div className="text-center py-20 placeholder-card rounded-xl">
<p className="typo-display text-lg mb-2"></p>
<p className="typo-caption">&ldquo;&rdquo;</p>
</div>
) : (
<motion.div
variants={staggerContainer}
initial="hidden"
animate="visible"
className="rounded-xl surface-2 overflow-hidden"
>
{/* 表头(桌面端) */}
<div className="hidden md:grid grid-cols-[2fr_1fr_1fr_1.5fr_1fr_auto] gap-3 px-4 py-2.5
border-b border-[var(--border)] text-[10px] typo-micro"
style={{ textTransform: "none" }}>
<span></span>
<span></span>
<span></span>
<span> / </span>
<span></span>
<span className="w-10" />
</div>
{filtered.map((t) => (
<motion.div
key={t.id}
variants={staggerItem}
className="border-b border-[var(--border)] last:border-b-0
hover:bg-[var(--accent)]/5 transition-colors"
>
<Link
href={`/training/${t.id}`}
className="block px-4 py-3 cursor-pointer"
>
<div className="md:grid md:grid-cols-[2fr_1fr_1fr_1.5fr_1fr_auto] md:gap-3 md:items-center
flex flex-col gap-1.5">
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
{t.name}
</div>
<div className="text-xs text-[var(--text-secondary)]">
{t.type === "style_lora" ? "风格 LoRA" : "角色 LoRA"}
</div>
<div className="text-xs text-[var(--text-secondary)] truncate">
{sourceName(t)}
</div>
<div className="flex items-center gap-2 min-w-0">
<span
className="flex-shrink-0 text-[10px] px-2 py-0.5 rounded-md font-medium"
style={{
background: STATUS_COLOR[t.status] + "22",
color: STATUS_COLOR[t.status],
}}
>
{STATUS_LABEL[t.status]}
</span>
{t.status === "running" && (
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
<div
className="h-full bg-[var(--accent)] transition-all"
style={{ width: `${t.progress}%` }}
/>
</div>
<span className="text-[10px] text-[var(--accent)] flex-shrink-0">
{t.progress}%
</span>
</div>
)}
</div>
<div className="text-xs text-[var(--text-secondary)]">
{new Date(t.createdAt).toLocaleDateString("zh-CN", {
month: "2-digit",
day: "2-digit",
})}
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2"
className="text-[var(--text-secondary)] hidden md:block">
<path d="M9 18l6-6-6-6" />
</svg>
</div>
</Link>
</motion.div>
))}
</motion.div>
)}
</div>
</main>
</div>
);
}

View File

@@ -1,8 +1,12 @@
"use client";
import { useRef, useState, useCallback, useImperativeHandle, forwardRef, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
import { motion } from "framer-motion";
import { ModelSelector } from "./model-selector";
import { DataStream } from "@/components/ui/data-stream";
import { useRipple, RippleLayer } from "@/components/ui/ripple-effect";
import { uploadRefImage, type UploadProgress } from "@/lib/api";
import { sendBounce } from "@/components/ui/motion-presets";
type UploadStatus = "idle" | "uploading" | "done" | "error";
@@ -26,6 +30,8 @@ interface ChatInputProps {
onClearLastRefImage?: () => void;
/** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */
onFileDrop?: () => void;
/** 是否正在生成图片(用于数据流光加速) */
isGeneratingImage?: boolean;
}
export interface ChatInputHandle {
@@ -34,7 +40,6 @@ export interface ChatInputHandle {
let _uploadCounter = 0;
/** 从剪贴板提取可上传的图片文件Ctrl+V / 右键粘贴共用) */
function imageFilesFromClipboard(data: DataTransfer | null): File[] {
if (!data?.items?.length) return [];
const out: File[] = [];
@@ -49,25 +54,38 @@ function imageFilesFromClipboard(data: DataTransfer | null): File[] {
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop },
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop, isGeneratingImage },
ref
) {
const [text, setText] = useState("");
const [uploads, setUploads] = useState<UploadItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>("");
const [isFocused, setIsFocused] = useState(false);
const { ripples, trigger: triggerRipple } = useRipple();
const fileInputRef = useRef<HTMLInputElement>(null);
const sendBtnRef = useRef<HTMLButtonElement>(null);
const abortRefs = useRef<Map<string, AbortController>>(new Map());
const hasActiveUpload = uploads.some((u) => u.status === "uploading");
const allDone = uploads.length > 0 && uploads.every((u) => u.status === "done" || u.status === "error");
const doneUrls = uploads.filter((u) => u.status === "done" && u.serverUrl).map((u) => u.serverUrl!);
const canSend = text.trim() && !disabled && !hasActiveUpload;
const handleSubmit = () => {
const trimmed = text.trim();
if (!trimmed || disabled) return;
if (hasActiveUpload) return;
// 投石入水涟漪
if (sendBtnRef.current) {
const rect = sendBtnRef.current.getBoundingClientRect();
const parent = sendBtnRef.current.offsetParent as HTMLElement | null;
const parentRect = parent?.getBoundingClientRect() ?? rect;
triggerRipple(rect.left - parentRect.left + rect.width / 2, rect.top - parentRect.top + rect.height / 2);
}
const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []);
onSend(trimmed, refUrls, selectedModel || null);
setText("");
@@ -183,7 +201,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
return (
<div
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl
px-3 md:px-5 py-2.5 md:py-3 transition-colors ${
transition-colors ${
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
}`}
onDragOver={handleDragOver}
@@ -191,6 +209,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
onDrop={handleDrop}
onPasteCapture={handlePasteCapture}
>
{/* 数据流光 */}
<DataStream active={disabled} speed={isGeneratingImage ? "fast" : "slow"} />
<div className="relative px-3 md:px-5 py-2.5 md:py-3">
{isDragging && (
<div className="mb-2 text-center text-xs text-[var(--accent)]">
@@ -207,7 +229,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
src={url}
alt={`沿用参考图 ${i + 1}`}
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
shadow-[0_0_8px_rgba(77,184,164,0.1)]"
shadow-[0_0_8px_rgba(46,139,122,0.1)]"
/>
))}
</div>
@@ -242,7 +264,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
{item.status === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center">
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(77,184,164,0.12)" strokeWidth="3" />
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(46,139,122,0.12)" strokeWidth="3" />
<circle
cx="18" cy="18" r="14" fill="none"
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
@@ -255,7 +277,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
{item.status === "done" && (
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--accent)]
text-[var(--bg-primary)] text-[10px] flex items-center justify-center font-bold
shadow-[0_0_8px_rgba(77,184,164,0.3)]">
shadow-[0_0_8px_rgba(46,139,122,0.3)]">
</div>
)}
@@ -279,12 +301,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
)}
</div>
))}
{/* 汇总状态 */}
<div className="flex flex-col justify-center gap-1 min-w-0">
{hasActiveUpload && (
<span className="text-xs text-[var(--accent)] flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
shadow-[0_0_6px_rgba(77,184,164,0.3)]" />
shadow-[0_0_6px_rgba(46,139,122,0.3)]" />
...
</span>
)}
@@ -314,7 +335,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
hover:bg-[var(--accent)]/5
flex items-center justify-center transition-all
disabled:opacity-50 cursor-pointer ${
disabled:opacity-50 cursor-pointer btn-hover-lift ${
doneUrls.length > 0
? "border-[var(--accent)]/40 text-[var(--accent)]"
: "border-[var(--border)]"
@@ -342,41 +363,56 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
className="hidden"
/>
{/* 文字输入框 */}
{/* 文字输入框 — 水面感应 */}
<div className={`relative flex-1 ${isFocused ? "water-focus-active" : ""}`}>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="描述你想要的美术资源...(可粘贴图片)"
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
disabled={disabled}
rows={1}
className="flex-1 resize-none rounded-xl border border-[var(--border)]
className="w-full resize-none rounded-xl border border-[var(--border)]
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
placeholder:text-[var(--text-secondary)]
px-4 py-2.5 text-sm leading-relaxed
focus:outline-none focus:border-[var(--accent)]/50
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
disabled:opacity-50 transition-all
min-h-[42px] max-h-[120px]"
style={{ fieldSizing: "content" } as React.CSSProperties}
style={{
fieldSizing: "content",
boxShadow: isFocused
? `0 0 ${8 + Math.min(text.length, 50) * 0.3}px rgba(46,139,122,${0.04 + Math.min(text.length, 50) * 0.002})`
: "none",
} as React.CSSProperties}
/>
<div className="water-focus-line" />
</div>
{/* 发送按钮 */}
<button
{/* 发送按钮 — 投石入水 */}
<motion.button
ref={sendBtnRef}
onClick={handleSubmit}
disabled={disabled || !text.trim() || hasActiveUpload}
className="flex-shrink-0 w-10 h-10 rounded-xl
disabled={!canSend}
whileTap={canSend ? sendBounce : undefined}
className={`flex-shrink-0 w-10 h-10 rounded-xl
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)]
hover:shadow-[0_0_15px_rgba(77,184,164,0.25)]
hover:shadow-[0_0_15px_rgba(46,139,122,0.25)]
flex items-center justify-center transition-all
disabled:opacity-50 cursor-pointer"
disabled:opacity-50 cursor-pointer
${canSend ? "breath-pulse" : ""}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
</motion.button>
</div>
{/* 涟漪层 */}
<RippleLayer ripples={ripples} />
</div>
</div>
);

View File

@@ -1,8 +1,29 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { ChatMessage, ImageAsset } from "@/lib/types";
import { ImageGrid } from "./image-grid";
import { messageEnter, assistantEnter } from "@/components/ui/motion-presets";
function StreamingLines({ text }: { text: string }) {
const lines = useMemo(() => text.split("\n"), [text]);
const total = lines.length;
return (
<div className="whitespace-pre-wrap text-sm leading-relaxed">
{lines.map((line, i) => {
const fromEnd = total - 1 - i;
const opacity = fromEnd <= 0 ? 1 : fromEnd === 1 ? 0.85 : fromEnd === 2 ? 0.7 : 0.6;
return (
<span key={i} style={{ opacity, transition: "opacity 0.3s ease" }}>
{line}
{i < total - 1 && "\n"}
</span>
);
})}
</div>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
@@ -21,7 +42,7 @@ function CopyButton({ text }: { text: string }) {
<button
onClick={handleCopy}
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:bg-[var(--accent)]/5 transition-colors cursor-pointer"
hover:bg-[var(--accent)]/5 transition-colors cursor-pointer btn-hover-lift"
title="复制文本"
>
{copied ? (
@@ -56,17 +77,19 @@ export function ChatMessages({
return (
<div className="flex-1 overflow-y-auto px-3 md:px-5 py-4 md:py-6 space-y-4 md:space-y-5">
{messages.map((msg) => (
<div
<motion.div
key={msg.id}
variants={msg.role === "user" ? messageEnter : assistantEnter}
initial="hidden"
animate="visible"
className={`group/msg flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
style={{ animation: "slideUp 200ms ease-out" }}
>
<div className="flex flex-col items-start gap-0.5">
<div
className={`max-w-[90vw] md:max-w-[80vw] rounded-2xl px-4 md:px-5 py-3 ${
className={`max-w-[90vw] md:max-w-[80vw] px-4 md:px-5 py-3 ${
msg.role === "user"
? "bg-[var(--accent)]/15 text-[var(--text-primary)] border border-[var(--accent)]/25"
: "glass-panel text-[var(--text-primary)]"
? "user-bubble bg-gradient-to-r from-[var(--accent)]/8 to-[var(--accent)]/15 text-[var(--text-primary)]"
: "rounded-2xl surface-2 text-[var(--text-primary)] border-l-2 border-l-[var(--accent)]/40"
}`}
>
{/* 多图参考(新格式) */}
@@ -80,11 +103,11 @@ export function ChatMessages({
alt={`参考图 ${i + 1}`}
className="max-w-[120px] max-h-[90px] rounded-lg object-cover
border border-[var(--accent)]/20
shadow-[0_0_10px_rgba(77,184,164,0.08)]"
shadow-[0_0_10px_rgba(46,139,122,0.08)]"
/>
))}
</div>
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block">
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block typo-micro" style={{ textTransform: "none" }}>
{msg.refImageUrls.length}
</span>
</div>
@@ -97,7 +120,7 @@ export function ChatMessages({
alt="参考图"
className="max-w-[160px] max-h-[120px] rounded-lg object-cover
border border-[var(--accent)]/20
shadow-[0_0_10px_rgba(77,184,164,0.08)]"
shadow-[0_0_10px_rgba(46,139,122,0.08)]"
/>
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block"></span>
</div>
@@ -107,7 +130,7 @@ export function ChatMessages({
<>
<ImageGrid images={msg.images} />
{msg.modelName && (
<div className="mt-1.5 text-[10px] text-[var(--accent)]/50">
<div className="mt-1.5 text-[10px] text-[var(--accent)]/50 typo-caption">
{msg.modelName}
</div>
)}
@@ -121,33 +144,43 @@ export function ChatMessages({
</div>
)}
</div>
</div>
</motion.div>
))}
{/* AI 正在回复(流式) */}
<AnimatePresence>
{isLoading && (
<div className="flex justify-start" style={{ animation: "slideUp 200ms ease-out" }}>
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 glass-panel">
<motion.div
key="streaming"
variants={assistantEnter}
initial="hidden"
animate="visible"
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="flex justify-start"
>
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 surface-2 border-l-2 border-l-[var(--accent)]/40">
{statusText && (
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
shadow-[0_0_8px_rgba(77,184,164,0.4)]" />
shadow-[0_0_8px_rgba(46,139,122,0.4)]" />
{statusText}
</div>
)}
{streamingText && (
<div className="whitespace-pre-wrap text-sm leading-relaxed">{streamingText}</div>
<StreamingLines text={streamingText} />
)}
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
{!streamingText && !statusText && (
<div className="flex gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce" />
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce [animation-delay:0.1s]" />
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce [animation-delay:0.2s]" />
<div className="flex items-end gap-1 h-5">
<div className="wave-dot" style={{ animationDelay: "0s" }} />
<div className="wave-dot" style={{ animationDelay: "0.15s" }} />
<div className="wave-dot" style={{ animationDelay: "0.3s" }} />
</div>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,15 +1,43 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { getImageUrl } from "@/lib/api";
import { useApp } from "@/lib/app-context";
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
import { inkReveal, staggerContainer, staggerItem } from "@/components/ui/motion-presets";
import type { ImageAsset } from "@/lib/types";
interface ImageGridProps {
images: ImageAsset[];
}
function FavoriteButton({ asset }: { asset: ImageAsset }) {
const { toggleFavorite } = useApp();
const { sparks, burst } = useFireflyBurst();
return (
<button
onClick={(e) => {
e.stopPropagation();
if (!asset.favorited) burst();
toggleFavorite(asset.id);
}}
className="relative p-1.5 rounded-lg cursor-pointer transition-colors
hover:bg-[var(--accent)]/10"
style={{ color: asset.favorited ? "var(--gold)" : "rgba(255,255,255,0.5)" }}
title={asset.favorited ? "取消收藏" : "收藏"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
<FireflyBurst sparks={sparks} />
</button>
);
}
export function ImageGrid({ images }: ImageGridProps) {
const { setDetailImage, toggleFavorite } = useApp();
const { setDetailImage } = useApp();
const handleDownload = async (imageUrl: string, index: number) => {
try {
@@ -35,13 +63,20 @@ export function ImageGrid({ images }: ImageGridProps) {
: "grid-cols-2 max-w-[320px] md:max-w-xl";
return (
<div className={`grid ${gridCols} gap-3 my-3`}>
<motion.div
className={`grid ${gridCols} gap-3 my-3`}
variants={staggerContainer}
initial="hidden"
animate="visible"
>
{images.map((asset, i) => (
<div
<motion.div
key={asset.id}
variants={staggerItem}
className="group relative rounded-xl overflow-hidden neon-border
bg-[var(--bg-card)] backdrop-blur-sm"
>
<ImageReveal src={getImageUrl(asset.url)} alt={`生成图片 ${i + 1}`}>
<img
src={getImageUrl(asset.url)}
alt={`生成图片 ${i + 1}`}
@@ -50,6 +85,7 @@ export function ImageGrid({ images }: ImageGridProps) {
loading="lazy"
onClick={() => setDetailImage(asset)}
/>
</ImageReveal>
{/* 底部渐变 + 操作栏 */}
<div
@@ -79,34 +115,47 @@ export function ImageGrid({ images }: ImageGridProps) {
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
toggleFavorite(asset.id);
}}
className="p-1.5 rounded-lg cursor-pointer transition-colors
hover:bg-[var(--accent)]/10"
style={{ color: asset.favorited ? "var(--accent)" : "rgba(255,255,255,0.5)" }}
title={asset.favorited ? "取消收藏" : "收藏"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
<FavoriteButton asset={asset} />
</div>
{/* 最新生成标签 */}
{i === 0 && images.length > 1 && (
<div className="absolute top-2 left-2">
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-md
<span className="typo-micro px-2 py-0.5 rounded-md
bg-[var(--accent)]/20 text-[var(--accent)]
border border-[var(--accent)]/30 backdrop-blur-sm">
border border-[var(--accent)]/30 backdrop-blur-sm"
style={{ textTransform: "uppercase" }}>
New
</span>
</div>
)}
</div>
</motion.div>
))}
</motion.div>
);
}
function ImageReveal({ src, alt, children }: { src: string; alt: string; children: React.ReactNode }) {
const [loaded, setLoaded] = useState(false);
return (
<div className="relative">
{!loaded && (
<div className="w-full aspect-square stone-placeholder" />
)}
<motion.div
variants={inkReveal}
initial="hidden"
animate={loaded ? "visible" : "hidden"}
>
<img
src={src}
alt={alt}
className="hidden"
onLoad={() => setLoaded(true)}
/>
{children}
</motion.div>
</div>
);
}

View File

@@ -1,8 +1,10 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { ImageModelInfo } from "@/lib/types";
import { fetchModels } from "@/lib/api";
import { slideUp } from "@/components/ui/motion-presets";
import { getStoreUserId } from "@/lib/store";
@@ -61,7 +63,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
border border-[var(--border)] bg-[var(--bg-tertiary)]
text-[var(--text-secondary)] hover:text-[var(--accent)]
hover:border-[var(--accent)]/40 hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
transition-all cursor-pointer btn-hover-lift"
title="切换生图模型"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -79,10 +81,15 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
</svg>
</button>
<AnimatePresence>
{open && (
<div
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-xl
glass-panel shadow-xl shadow-black/40 overflow-hidden z-50"
surface-3 overflow-hidden z-50"
>
{models.map((m) => {
const isActive = m.id === value;
@@ -104,9 +111,10 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
<span className="text-sm font-medium flex items-center gap-1.5">
{m.name}
{m.supports_ref_image && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full
bg-[var(--accent)]/10 text-[var(--accent)] font-medium leading-none
border border-[var(--accent)]/20">
<span className="typo-micro px-1.5 py-0.5 rounded-full
bg-[var(--accent)]/10 text-[var(--accent)] leading-none
border border-[var(--accent)]/20"
style={{ textTransform: "none", fontSize: "10px" }}>
</span>
)}
@@ -116,12 +124,13 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
</svg>
)}
</span>
<span className="text-xs text-[var(--text-secondary)]">{m.description}</span>
<span className="typo-caption">{m.description}</span>
</button>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,257 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import { useApp } from "@/lib/app-context";
const ASSET_SPECS = [
"1024×1024 · 通用",
"768×1024 · 竖版卡牌",
"1536×1024 · 横版横幅",
"512×512 · Icon",
"透明背景 PNG",
];
interface PickerButtonProps {
label: string;
value?: string | null;
placeholder: string;
phaseBadge?: string;
onClear?: () => void;
onClick: () => void;
icon: React.ReactNode;
}
function PickerButton({ label, value, placeholder, phaseBadge, onClear, onClick, icon }: PickerButtonProps) {
const active = !!value;
return (
<div className="relative group">
<button
onClick={onClick}
className={`h-7 pl-2 pr-2.5 rounded-lg text-xs flex items-center gap-1.5 cursor-pointer transition-all whitespace-nowrap ${
active
? "bg-[var(--accent)]/10 border border-[var(--accent)]/30 text-[var(--accent)]"
: "border border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:border-[var(--accent)]/40 hover:text-[var(--text-primary)]"
}`}
title={`${label}${value || placeholder}`}
>
{icon}
<span className="typo-micro text-[10px]" style={{ textTransform: "none" }}>{label}</span>
<span className="text-[11px] font-medium max-w-[120px] truncate">
{value || placeholder}
</span>
{phaseBadge && (
<span className="phase-chip" style={{ marginLeft: 2 }}>{phaseBadge}</span>
)}
</button>
{active && onClear && (
<button
onClick={(e) => {
e.stopPropagation();
onClear();
}}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--bg-secondary)] border border-[var(--border)]
text-[var(--text-secondary)] text-[10px] flex items-center justify-center
opacity-0 group-hover:opacity-100 hover:text-[var(--hot)] hover:border-[var(--hot)]/40
transition-opacity cursor-pointer"
title="清除"
>
×
</button>
)}
</div>
);
}
export function SessionQuickPicks() {
const {
stylePacks, characters,
sessionStyleId, setSessionStyleId,
sessionCharacterId, setSessionCharacterId,
sessionAssetSpec, setSessionAssetSpec,
} = useApp();
const [openMenu, setOpenMenu] = useState<null | "style" | "character" | "spec">(null);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!openMenu) return;
const handler = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpenMenu(null);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [openMenu]);
const styleName = stylePacks.find((s) => s.id === sessionStyleId)?.name;
const charName = characters.find((c) => c.id === sessionCharacterId)?.name;
return (
<div ref={rootRef} className="relative px-3 md:px-5 pt-2 pb-1 flex items-center gap-1.5 flex-wrap">
<PickerButton
label="风格"
value={styleName}
placeholder="选择风格"
phaseBadge="P1"
icon={
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<circle cx="12" cy="12" r="9" strokeDasharray="2 3" />
</svg>
}
onClear={() => setSessionStyleId(null)}
onClick={() => setOpenMenu(openMenu === "style" ? null : "style")}
/>
<PickerButton
label="角色"
value={charName}
placeholder="选择角色"
phaseBadge="P1"
icon={
<svg width="12" height="12" 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>
}
onClear={() => setSessionCharacterId(null)}
onClick={() => setOpenMenu(openMenu === "character" ? null : "character")}
/>
<PickerButton
label="规格"
value={sessionAssetSpec ?? undefined}
placeholder="输出规格"
icon={
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M4 12h16M12 4v16" />
</svg>
}
onClear={() => setSessionAssetSpec(null)}
onClick={() => setOpenMenu(openMenu === "spec" ? null : "spec")}
/>
<span className="text-[10px] text-[var(--text-secondary)]/60 ml-auto hidden md:inline">
· Phase 1
</span>
{/* 下拉菜单(向上展开,避免被下方输入栏遮挡) */}
{openMenu && (
<div
className="absolute left-3 md:left-5 bottom-[calc(100%+4px)] z-50 w-80 max-w-[calc(100vw-24px)]
rounded-xl surface-2 border border-[var(--border)] shadow-xl
overflow-hidden backdrop-blur-xl"
>
{openMenu === "style" && (
<>
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<Link href="/styles" className="text-[10px] text-[var(--accent)] hover:underline">
</Link>
</div>
<div className="max-h-64 overflow-y-auto">
{stylePacks.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-[var(--text-secondary)]">
<Link href="/styles/new" className="text-[var(--accent)] hover:underline">
</Link>
</div>
) : (
stylePacks.map((s) => (
<button
key={s.id}
onClick={() => { setSessionStyleId(s.id); setOpenMenu(null); }}
className={`w-full px-3 py-2 text-left text-xs flex items-center gap-2 cursor-pointer transition-colors ${
sessionStyleId === s.id
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"
}`}
>
<div className="w-8 h-8 rounded-md bg-[var(--bg-tertiary)] flex-shrink-0 placeholder-card" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{s.name}</div>
<div className="truncate text-[10px] text-[var(--text-secondary)]">
{s.keywords.slice(0, 3).join(" · ") || "无关键词"}
</div>
</div>
</button>
))
)}
</div>
</>
)}
{openMenu === "character" && (
<>
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<Link href="/characters" className="text-[10px] text-[var(--accent)] hover:underline">
</Link>
</div>
<div className="max-h-64 overflow-y-auto">
{characters.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-[var(--text-secondary)]">
<Link href="/characters/new" className="text-[var(--accent)] hover:underline">
</Link>
</div>
) : (
characters.map((c) => (
<button
key={c.id}
onClick={() => { setSessionCharacterId(c.id); setOpenMenu(null); }}
className={`w-full px-3 py-2 text-left text-xs flex items-center gap-2 cursor-pointer transition-colors ${
sessionCharacterId === c.id
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"
}`}
>
<div className="w-8 h-8 rounded-full bg-[var(--bg-tertiary)] flex-shrink-0 placeholder-card" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{c.name}</div>
<div className="truncate text-[10px] text-[var(--text-secondary)]">
{c.visualHooks.slice(0, 3).join(" · ") || "无视觉锚点"}
</div>
</div>
</button>
))
)}
</div>
</>
)}
{openMenu === "spec" && (
<>
<div className="px-3 py-2 border-b border-[var(--border)]">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
</div>
<div className="max-h-64 overflow-y-auto">
{ASSET_SPECS.map((spec) => (
<button
key={spec}
onClick={() => { setSessionAssetSpec(spec); setOpenMenu(null); }}
className={`w-full px-3 py-2 text-left text-xs cursor-pointer transition-colors ${
sessionAssetSpec === spec
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"
}`}
>
{spec}
</button>
))}
</div>
<div className="px-3 py-2 border-t border-[var(--border)] text-[10px] text-[var(--text-secondary)]">
Phase 1
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
import { useApp } from "@/lib/app-context";
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 占位组件)
*
* 对应 INFOLAYER.md 3.3.2 节:自然语言 → IR 的"任务说明卡"。
* 用于在对话中展示 Agent 解析出的任务意图,供用户确认、微调或取消。
* 数据结构见 types.ts 中的 TaskIR。
*/
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 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">
<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">
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]">{ir.taskType}</div>
</div>
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)]">{ir.template}</div>
</div>
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)] truncate">
{styleName ?? <span className="text-[var(--text-secondary)]"></span>}
</div>
</div>
<div>
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<div className="text-[var(--text-primary)] truncate">
{charName ?? <span className="text-[var(--text-secondary)]"></span>}
</div>
</div>
<div className="sm:col-span-2">
<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">
<label className="flex items-center gap-2 text-xs">
<span className="typo-micro" style={{ textTransform: "none" }}></span>
<input
type="number"
min={1}
max={8}
value={candidateCount}
onChange={(e) => setCandidateCount(parseInt(e.target.value) || 1)}
className="w-14 px-1.5 py-0.5 text-xs rounded-md bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none text-center"
/>
</label>
<div className="ml-auto flex gap-1.5">
{onCancel && (
<button
onClick={onCancel}
className="px-2.5 py-1 text-xs rounded-md border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
cursor-pointer transition-all"
>
</button>
)}
<button
onClick={() => onConfirm?.({ ...ir, candidateCount })}
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>
</div>
</div>
<div className="text-[10px] text-[var(--text-secondary)] mt-2">
{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

@@ -1,9 +1,12 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
import { getImageUrl } from "@/lib/api";
import { useApp } from "@/lib/app-context";
import { AnnotationCanvas } from "./annotation-canvas";
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
import { slideUp } from "@/components/ui/motion-presets";
import type { Annotation, AnnotationData } from "@/lib/types";
interface ImageDetailPanelProps {
@@ -14,6 +17,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
const { detailImage, setDetailImage, toggleFavorite, tags, deleteAssetById } = useApp();
const [scale, setScale] = useState(1);
const [showAnnotate, setShowAnnotate] = useState(false);
const { sparks, burst } = useFireflyBurst();
if (!detailImage) return null;
@@ -67,10 +71,15 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
}
return (
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
<motion.aside
variants={slideUp}
initial="hidden"
animate="visible"
className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
md:w-[360px] flex-shrink-0 border-l border-[var(--border)]
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
flex flex-col overflow-hidden">
flex flex-col overflow-hidden"
>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
@@ -103,7 +112,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
>
</button>
@@ -114,7 +123,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
>
+
</button>
@@ -122,7 +131,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
onClick={() => setScale(1)}
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
hover:text-[var(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
>
</button>
@@ -135,8 +144,8 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
onClick={handleDownload}
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--accent)] text-[var(--bg-primary)]
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
transition-all cursor-pointer"
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
transition-all cursor-pointer btn-hover-lift"
>
<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" />
@@ -144,11 +153,14 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
</button>
<button
onClick={() => toggleFavorite(detailImage.id)}
className={`flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
border transition-all cursor-pointer ${
onClick={() => {
if (!detailImage.favorited) burst();
toggleFavorite(detailImage.id);
}}
className={`relative flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
border transition-all cursor-pointer btn-hover-lift ${
detailImage.favorited
? "border-[var(--accent)]/40 bg-[var(--accent)]/8 text-[var(--accent)]"
? "border-[var(--gold)]/40 bg-[var(--gold)]/8 text-[var(--gold)]"
: "border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
@@ -156,13 +168,14 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
{detailImage.favorited ? "已收藏" : "收藏"}
<FireflyBurst sparks={sparks} />
</button>
<button
onClick={() => setShowAnnotate(true)}
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(--accent)] border border-[var(--border)]
hover:border-[var(--accent)]/40 transition-all cursor-pointer"
hover:border-[var(--accent)]/40 transition-all cursor-pointer btn-hover-lift"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
@@ -174,7 +187,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
bg-[var(--bg-tertiary)] text-[var(--hot)]/60
hover:text-[var(--hot)] border border-[var(--border)]
hover:border-[var(--hot)]/40 transition-all cursor-pointer"
hover:border-[var(--hot)]/40 transition-all cursor-pointer btn-hover-lift"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
@@ -188,7 +201,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
{detailImage.prompt && (
<div className="px-4 pb-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
<span className="typo-caption font-medium">Prompt</span>
<button
onClick={handleCopyPrompt}
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
@@ -207,7 +220,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
{/* 标签 */}
{detailImage.tags.length > 0 && (
<div className="px-4 pb-3">
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<span className="typo-caption font-medium block mb-1.5"></span>
<div className="flex flex-wrap gap-1.5">
{detailImage.tags.map((tid) => {
const tag = tagMap.get(tid);
@@ -228,12 +241,12 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
{/* 元信息 */}
<div className="px-4 pb-4">
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5"></span>
<span className="typo-caption font-medium block mb-1.5"></span>
<div className="text-xs text-[var(--text-secondary)] space-y-1">
<div>{new Date(detailImage.createdAt).toLocaleString("zh-CN")}</div>
</div>
</div>
</div>
</aside>
</motion.aside>
);
}

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

@@ -0,0 +1,165 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useApp } from "@/lib/app-context";
import { slideUp } from "@/components/ui/motion-presets";
export function ProjectSwitcher() {
const { projects, activeProject, activeProjectId, switchProject, upsertProject } = useApp();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newType, setNewType] = useState("2D RPG");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handler(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setCreating(false);
}
}
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const handleCreate = () => {
if (!newName.trim()) return;
const now = Date.now();
upsertProject({
id: `proj-${now.toString(36)}`,
name: newName.trim(),
type: newType,
description: "",
createdAt: now,
updatedAt: now,
});
setNewName("");
setCreating(false);
setOpen(false);
};
if (!activeProject) return null;
return (
<div className="relative hidden sm:block" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 px-2.5 py-1.5 text-xs rounded-lg
border border-[var(--border)] bg-[var(--bg-tertiary)]/60
text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
transition-all cursor-pointer"
title="切换项目Phase 1"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="max-w-[100px] md:max-w-[160px] truncate font-medium">
{activeProject.name}
</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
className="absolute left-0 top-full mt-1.5 w-72 rounded-xl surface-3 z-50 overflow-hidden"
>
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
<span className="typo-micro" style={{ textTransform: "none" }}>
Phase 1 ·
</span>
<button
onClick={() => setCreating(!creating)}
className="text-[10px] text-[var(--accent)] hover:underline cursor-pointer"
>
{creating ? "取消" : "+ 新建"}
</button>
</div>
{creating && (
<div className="p-2.5 border-b border-[var(--border)] space-y-1.5">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="项目名称"
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)]
rounded-md px-2 py-1.5 focus:outline-none focus:border-[var(--accent)]/50"
/>
<select
value={newType}
onChange={(e) => setNewType(e.target.value)}
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)]
rounded-md px-2 py-1.5 focus:outline-none cursor-pointer"
>
<option>2D RPG</option>
<option></option>
<option></option>
<option></option>
<option></option>
</select>
<button
onClick={handleCreate}
disabled={!newName.trim()}
className="w-full px-2 py-1.5 text-xs rounded-md bg-[var(--accent)]
text-[var(--bg-primary)] font-medium cursor-pointer
disabled:opacity-50"
>
</button>
</div>
)}
<div className="max-h-64 overflow-y-auto py-1">
{projects.map((p) => (
<button
key={p.id}
onClick={() => {
switchProject(p.id);
setOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm cursor-pointer
hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2 ${
p.id === activeProjectId ? "bg-[var(--accent)]/8" : ""
}`}
>
<div className={`w-6 h-6 rounded-md flex-shrink-0 flex items-center justify-center text-[10px] font-bold ${
p.id === activeProjectId
? "bg-[var(--accent)] text-[var(--bg-primary)]"
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]"
}`}>
{p.name.slice(0, 1).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className={`text-xs truncate ${
p.id === activeProjectId ? "text-[var(--accent)] font-medium" : "text-[var(--text-primary)]"
}`}>
{p.name}
</div>
<div className="text-[10px] text-[var(--text-secondary)] truncate">
{p.type}
</div>
</div>
{p.id === activeProjectId && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="text-[var(--accent)]">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,25 +1,43 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useApp } from "@/lib/app-context";
import { useAuth } from "@/lib/auth-context";
import { ProfileModal } from "@/components/profile-modal";
import { ProjectSwitcher } from "./project-switcher";
import { slideUp } from "@/components/ui/motion-presets";
const NAV_ITEMS = [
{
href: "/",
label: "对话",
label: "工作台",
phase: "Phase 0",
icon: (
<svg width="16" height="16" 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>
),
},
{
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: "资源库",
phase: "Phase 0",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
@@ -28,6 +46,41 @@ const NAV_ITEMS = [
</svg>
),
},
{
href: "/styles",
label: "风格库",
phase: "Phase 1",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="13.5" cy="6.5" r=".5" />
<circle cx="17.5" cy="10.5" r=".5" />
<circle cx="8.5" cy="7.5" r=".5" />
<circle cx="6.5" cy="12.5" r=".5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.55 0 1-.45 1-1v-3.5c0-.55.45-1 1-1h2c2.76 0 5-2.24 5-5 0-5.5-4.5-9.5-10-9.5z" />
</svg>
),
},
{
href: "/characters",
label: "角色库",
phase: "Phase 1",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
},
{
href: "/training",
label: "训练中心",
phase: "Phase 2",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L3 7v5c0 5 3.8 9.7 9 11 5.2-1.3 9-6 9-11V7l-9-5z" />
</svg>
),
},
] as const;
export function TopNav() {
@@ -51,7 +104,7 @@ export function TopNav() {
return (
<>
<header className="relative z-10 flex-shrink-0 h-14 border-b border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl flex items-center px-4 md:px-5 gap-4 md:gap-6">
<header className="relative z-10 flex-shrink-0 h-14 border-b border-[var(--border)] surface-1 backdrop-blur-xl flex items-center px-4 md:px-5 gap-3 md:gap-5">
{/* 移动端汉堡菜单 */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
@@ -68,36 +121,51 @@ export function TopNav() {
{/* Logo */}
<Link href="/" className="flex items-center gap-2.5 flex-shrink-0 group">
<div className="w-8 h-8 rounded-lg bg-[var(--accent)] flex items-center justify-center
shadow-[0_0_12px_rgba(77,184,164,0.25)]
group-hover:shadow-[0_0_20px_rgba(77,184,164,0.4)] transition-shadow">
shadow-[0_0_12px_rgba(46,139,122,0.25)]
group-hover:shadow-[0_0_20px_rgba(46,139,122,0.4)] transition-shadow">
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="#0C1210" />
<circle cx="23" cy="12" r="3.5" stroke="#0C1210" strokeWidth="1.8" fill="none" />
<path d="M23 15.5v5" stroke="#0C1210" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="23" cy="22.5" r="1" fill="#0C1210" />
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="white" />
<circle cx="23" cy="12" r="3.5" stroke="white" strokeWidth="1.8" fill="none" />
<path d="M23 15.5v5" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="23" cy="22.5" r="1" fill="white" />
</svg>
</div>
<span className="text-base font-bold tracking-tight text-[var(--text-primary)]">
<span className="text-base font-bold tracking-tight text-[var(--text-primary)] hidden sm:inline">
EPEEKit
</span>
</Link>
{/* 项目切换器 */}
<ProjectSwitcher />
{/* 导航 Tab */}
<nav className="flex items-center gap-1.5">
{NAV_ITEMS.map(({ href, label, icon }) => {
<nav className="flex items-center gap-1 overflow-x-auto hide-scrollbar">
{NAV_ITEMS.map(({ href, label, icon, phase }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`relative flex items-center gap-1.5 px-3 md:px-4 py-2 text-sm rounded-lg ${
className={`relative flex items-center gap-1.5 px-2.5 md:px-3.5 py-2 text-sm rounded-lg transition-all whitespace-nowrap ${
active
? "glow-border text-[var(--accent)] font-medium !bg-[var(--bg-secondary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent hover:border-[var(--border-glow)] transition-all"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent hover:border-[var(--border-glow)]"
}`}
title={`${label}${phase}`}
>
{icon}
{label}
<span className="hidden md:inline">{label}</span>
{phase !== "Phase 0" && (
<span
className="hidden lg:inline text-[9px] px-1 py-px rounded font-semibold"
style={{
background: "rgba(184, 147, 90, 0.12)",
color: "var(--gold)",
}}
>
{phase.replace("Phase ", "P")}
</span>
)}
</Link>
);
})}
@@ -110,7 +178,7 @@ export function TopNav() {
<div className="relative" ref={menuRef}>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] border border-transparent
hover:border-[var(--border)] transition-all cursor-pointer"
@@ -119,37 +187,88 @@ export function TopNav() {
flex items-center justify-center text-[var(--accent)] text-xs font-semibold">
{(user.display_name || user.username).charAt(0).toUpperCase()}
</div>
<span className="hidden sm:inline max-w-[100px] truncate">
<span className="hidden sm:inline max-w-[80px] truncate">
{user.display_name || user.username}
</span>
</button>
<AnimatePresence>
{menuOpen && (
<div className="absolute right-0 top-full mt-1.5 w-48 rounded-xl
glass-panel shadow-xl shadow-black/30 py-1.5 z-50">
<button
onClick={() => { setMenuOpen(false); setProfileOpen(true); }}
className="w-full text-left px-3.5 py-2 text-xs text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:bg-[var(--bg-tertiary)]
border-b border-[var(--border)] transition-colors cursor-pointer
flex items-center gap-1.5"
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
className="absolute right-0 top-full mt-1.5 w-56 rounded-xl
surface-3 py-1.5 z-50"
>
<div className="px-3.5 py-2 text-xs text-[var(--text-secondary)]
border-b border-[var(--border)] flex items-center gap-1.5">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>{user.username}</span>
{user.is_admin && <span className="text-[var(--accent)]">()</span>}
<span className="truncate">{user.username}</span>
{user.is_admin && <span className="text-[var(--accent)] font-medium">()</span>}
</div>
<button
onClick={() => { setMenuOpen(false); setProfileOpen(true); }}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors
flex items-center gap-2"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
/
</button>
<Link
href="/settings"
onClick={() => setMenuOpen(false)}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors
flex items-center gap-2"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
<span className="ml-auto text-[9px] px-1 py-px rounded font-semibold"
style={{ background: "rgba(184, 147, 90, 0.12)", color: "var(--gold)" }}>
P1
</span>
</Link>
<button
onClick={() => { setMenuOpen(false); alert("全局设置Phase 2 规划中)\n- API Key 配置\n- 界面偏好\n- 通知设置"); }}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
cursor-pointer transition-colors flex items-center gap-2"
title="Phase 2 占位"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20V10M6 20v-5M18 20V4" />
</svg>
<span className="ml-auto text-[9px] px-1 py-px rounded font-semibold"
style={{ background: "rgba(184, 147, 90, 0.12)", color: "var(--gold)" }}>
P2
</span>
</button>
<div className="border-t border-[var(--border)] my-1" />
<button
onClick={() => { setMenuOpen(false); logout(); }}
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
transition-colors cursor-pointer"
hover:text-[var(--hot)] hover:bg-[var(--bg-tertiary)]
transition-colors cursor-pointer flex items-center gap-2"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" />
</svg>
退
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</header>

View File

@@ -1,7 +1,9 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useAuth, type AuthUser } from "@/lib/auth-context";
import { motion } from "framer-motion";
import { useAuth } from "@/lib/auth-context";
import { slideUp } from "@/components/ui/motion-presets";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
@@ -107,14 +109,20 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
onClick={onClose}
>
{/* 遮罩层 */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
{/* 弹窗主体 */}
<div
className="relative w-[90vw] max-w-[560px] max-h-[80vh] rounded-2xl glass-panel
shadow-[0_0_40px_rgba(77,184,164,0.1)] border-[var(--border-glow)]
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
className="relative w-[90vw] max-w-[560px] max-h-[80vh] rounded-2xl surface-3
flex flex-col overflow-hidden"
style={{ animation: "slideUp 200ms ease-out" }}
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
@@ -133,18 +141,19 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
<div className="px-6 pt-6 pb-4 flex items-center gap-4 flex-shrink-0">
<div className="w-14 h-14 rounded-full bg-[var(--accent)]/15 border-2 border-[var(--accent)]/40
flex items-center justify-center text-[var(--accent)] text-xl font-bold
shadow-[0_0_20px_rgba(77,184,164,0.12)]">
shadow-[0_0_20px_rgba(46,139,122,0.12)]">
{(user.display_name || user.username).charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-[var(--text-primary)] truncate">
<h2 className="typo-h1 truncate">
{user.display_name || user.username}
</h2>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-sm text-[var(--text-secondary)]">@{user.username}</span>
<span className="typo-caption">@{user.username}</span>
{user.is_admin && (
<span className="text-xs px-1.5 py-0.5 rounded-md bg-[var(--accent)]/10
text-[var(--accent)] border border-[var(--accent)]/30">
<span className="typo-micro px-1.5 py-0.5 rounded-md bg-[var(--accent)]/10
text-[var(--accent)] border border-[var(--accent)]/30"
style={{ textTransform: "none" }}>
</span>
)}
@@ -152,7 +161,6 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
</div>
</div>
{/* 分割线 */}
<div className="mx-6 h-px bg-[var(--border)]" />
{/* 记忆区标题 */}
@@ -161,9 +169,9 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
<path d="M12 2a7 7 0 017 7c0 2.38-1.19 4.47-3 5.74V17a2 2 0 01-2 2h-4a2 2 0 01-2-2v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 017-7z" />
<path d="M10 21h4" />
</svg>
<span className="text-sm font-medium text-[var(--text-primary)]"></span>
<span className="typo-strong text-sm"></span>
{!loading && (
<span className="text-xs text-[var(--text-secondary)]">
<span className="typo-caption">
{memories.length}
</span>
)}
@@ -173,14 +181,17 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
<div className="flex-1 overflow-y-auto px-6 pb-6 min-h-0">
{loading && (
<div className="flex items-center justify-center py-12">
<div className="w-5 h-5 border-2 border-[var(--accent)]/30 border-t-[var(--accent)]
rounded-full animate-spin" />
<div className="flex items-end gap-1 h-5">
<div className="wave-dot" style={{ animationDelay: "0s" }} />
<div className="wave-dot" style={{ animationDelay: "0.15s" }} />
<div className="wave-dot" style={{ animationDelay: "0.3s" }} />
</div>
<span className="ml-3 text-sm text-[var(--text-secondary)]">...</span>
</div>
)}
{!loading && error && memories.length === 0 && (
<div className="text-center py-12">
<div className="text-center py-12 error-shake">
<p className="text-sm text-[var(--hot)]">{error}</p>
</div>
)}
@@ -193,7 +204,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
<path d="M10 21h4" />
</svg>
<p className="text-sm text-[var(--text-secondary)]"></p>
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-60">
<p className="typo-caption mt-1 opacity-60">
</p>
</div>
@@ -201,7 +212,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
{!loading && groups.map((group) => (
<div key={group.label} className="mb-4 last:mb-0">
<div className="text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-2">
<div className="typo-micro mb-2">
{group.label}
</div>
<div className="space-y-1.5">
@@ -216,7 +227,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
{item.memory}
</p>
{item.created_at && (
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-0
<p className="typo-caption mt-1 opacity-0
group-hover:opacity-60 transition-opacity">
{formatDate(item.created_at)}
</p>
@@ -227,7 +238,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
</div>
))}
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import Link from "next/link";
import { useApp } from "@/lib/app-context";
/**
* 侧边栏项目信息卡Phase 1
*
* 展示当前项目名、绑定的默认风格缩略图(占位),以及快捷入口:
* 风格库 / 角色库 / 项目设置。数据来自 placeholder-store 的占位数据。
*/
export function ProjectCard() {
const { activeProject, stylePacks } = useApp();
if (!activeProject) return null;
const defaultStyle = activeProject.defaultStyleId
? stylePacks.find((s) => s.id === activeProject.defaultStyleId)
: stylePacks[0];
return (
<div className="mx-3 mb-2 rounded-xl placeholder-card p-3">
<div className="flex items-center justify-between mb-2">
<span className="typo-micro" style={{ textTransform: "none" }}>
</span>
<span className="phase-chip">P1</span>
</div>
<div className="flex items-center gap-2.5 mb-2.5">
<div className="w-10 h-10 rounded-lg flex-shrink-0 flex items-center justify-center
bg-gradient-to-br from-[var(--accent)]/15 to-[var(--accent-secondary)]/15
border border-[var(--border-glow)]">
{defaultStyle ? (
<span className="text-[10px] text-[var(--accent)] font-semibold tracking-tight px-1 text-center">
{defaultStyle.name.slice(0, 2)}
</span>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.8">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)] font-medium truncate">
{activeProject.name}
</div>
<div className="text-[10px] text-[var(--text-secondary)] truncate">
{activeProject.type}
{defaultStyle && <> · {defaultStyle.name}</>}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-1.5">
<Link
href="/styles"
className="flex flex-col items-center justify-center gap-1 py-1.5 rounded-md
bg-[var(--bg-tertiary)]/60 text-[10px] text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:bg-[var(--accent)]/8
cursor-pointer transition-colors"
title="风格库"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="13.5" cy="6.5" r=".5" />
<circle cx="17.5" cy="10.5" r=".5" />
<circle cx="8.5" cy="7.5" r=".5" />
<circle cx="6.5" cy="12.5" r=".5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.55 0 1-.45 1-1v-3.5c0-.55.45-1 1-1h2c2.76 0 5-2.24 5-5 0-5.5-4.5-9.5-10-9.5z" />
</svg>
</Link>
<Link
href="/characters"
className="flex flex-col items-center justify-center gap-1 py-1.5 rounded-md
bg-[var(--bg-tertiary)]/60 text-[10px] text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:bg-[var(--accent)]/8
cursor-pointer transition-colors"
title="角色库"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</Link>
<Link
href="/settings"
className="flex flex-col items-center justify-center gap-1 py-1.5 rounded-md
bg-[var(--bg-tertiary)]/60 text-[10px] text-[var(--text-secondary)]
hover:text-[var(--accent)] hover:bg-[var(--accent)]/8
cursor-pointer transition-colors"
title="项目设置"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
</Link>
</div>
</div>
);
}

View File

@@ -1,8 +1,10 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useApp } from "@/lib/app-context";
import { getImageUrl, fetchLlmModels } from "@/lib/api";
import { slideUp } from "@/components/ui/motion-presets";
import type { LlmModelInfo } from "@/lib/types";
function timeAgo(ts: number): string {
@@ -111,7 +113,7 @@ export function SessionList() {
text-sm rounded-xl border border-dashed border-[var(--accent)]/30
text-[var(--accent)]/70 hover:border-[var(--accent)]
hover:text-[var(--accent)] hover:bg-[var(--accent)]/5
transition-all cursor-pointer"
transition-all cursor-pointer btn-hover-lift"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" />
@@ -127,7 +129,7 @@ export function SessionList() {
<div
onClick={() => switchSession(session.id)}
className={`group relative flex items-start gap-3 px-3 py-2.5 rounded-xl cursor-pointer
transition-all ${
transition-all session-hover-line ${
session.id === activeSessionId
? "glow-border !bg-[var(--bg-secondary)]"
: "hover:bg-[var(--bg-tertiary)]/50 border border-transparent"
@@ -139,7 +141,7 @@ export function SessionList() {
src={getImageUrl(session.thumbnail)}
alt=""
className="w-10 h-10 rounded-lg object-cover flex-shrink-0 border border-[var(--border)]
shadow-[0_0_8px_rgba(77,184,164,0.08)]"
shadow-[0_0_8px_rgba(46,139,122,0.08)]"
/>
) : (
<div className="w-10 h-10 rounded-lg bg-[var(--bg-primary)] border border-[var(--border)]
@@ -181,8 +183,8 @@ export function SessionList() {
return (
<span
key={tid}
className="text-[10px] px-1.5 py-px rounded-full font-medium"
style={{ backgroundColor: tag.color + "22", color: tag.color }}
className="typo-micro px-1.5 py-px rounded-full"
style={{ backgroundColor: tag.color + "22", color: tag.color, textTransform: "none", fontSize: "10px" }}
>
{tag.name}
</span>
@@ -194,7 +196,7 @@ export function SessionList() {
{llmModels.find((m) => m.id === session.llmModel)?.name || session.llmModel}
</span>
)}
<span className="text-[10px] text-[var(--text-secondary)] ml-auto flex-shrink-0">
<span className="typo-caption ml-auto flex-shrink-0" style={{ fontSize: "10px" }}>
{timeAgo(session.updatedAt)}
</span>
</div>
@@ -216,15 +218,19 @@ export function SessionList() {
</button>
</div>
{/* 下拉菜单:在会话项正下方展开 */}
{/* 下拉菜单 */}
<AnimatePresence>
{menuSessionId === session.id && menuSession && (
<div
<motion.div
ref={menuRef}
className="mx-1 mt-1 glass-panel rounded-xl shadow-xl shadow-black/30 py-1.5
variants={slideUp}
initial="hidden"
animate="visible"
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
className="mx-1 mt-1 surface-3 rounded-xl py-1.5
overflow-hidden z-10 relative"
onClick={(e) => e.stopPropagation()}
>
{/* 模型选择(折叠/展开式) */}
<button
onClick={(e) => {
e.stopPropagation();
@@ -303,8 +309,9 @@ export function SessionList() {
>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}

View File

@@ -3,6 +3,7 @@
import { useApp } from "@/lib/app-context";
import { TagFilter } from "./tag-filter";
import { SessionList } from "./session-list";
import { ProjectCard } from "./project-card";
export function Sidebar() {
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
@@ -22,7 +23,7 @@ export function Sidebar() {
fixed inset-y-0 left-0 z-40 w-[280px]
md:relative md:inset-auto md:z-auto
flex-shrink-0 border-r border-[var(--border)]
bg-[var(--bg-secondary)]/80 backdrop-blur-xl
surface-1 backdrop-blur-xl
flex flex-col transition-transform duration-250 ease-in-out
${sidebarCollapsed ? "-translate-x-full md:-translate-x-0 md:w-0 md:border-r-0" : "translate-x-0 md:w-[280px]"}
${sidebarCollapsed ? "md:overflow-hidden" : ""}
@@ -31,10 +32,10 @@ export function Sidebar() {
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<div className="flex items-center gap-2.5">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2"
style={{ filter: "drop-shadow(0 0 4px rgba(77, 184, 164, 0.35))" }}>
style={{ filter: "drop-shadow(0 0 4px rgba(46, 139, 122, 0.35))" }}>
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
<span className="text-sm font-semibold text-[var(--text-primary)]">
<span className="typo-strong text-sm">
</span>
</div>
@@ -53,6 +54,7 @@ export function Sidebar() {
<TagFilter />
<SessionList />
<ProjectCard />
</aside>
</>
);

View File

@@ -0,0 +1,78 @@
"use client";
import { useMemo } from "react";
interface Particle {
id: number;
left: string;
top: string;
size: number;
delay: string;
duration: string;
opacity: number;
}
function generateParticles(count: number): Particle[] {
const particles: Particle[] = [];
for (let i = 0; i < count; i++) {
particles.push({
id: i,
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
size: 2 + Math.random() * 3,
delay: `${Math.random() * 20}s`,
duration: `${15 + Math.random() * 25}s`,
opacity: 0.12 + Math.random() * 0.18,
});
}
return particles;
}
interface AmbientParticlesProps {
count?: number;
}
export function AmbientParticles({ count = 18 }: AmbientParticlesProps) {
const particles = useMemo(() => generateParticles(count), [count]);
return (
<div className="fixed inset-0 pointer-events-none z-0 overflow-hidden" aria-hidden="true">
{/* 远景雾 */}
<div className="fog-layer-far" />
{/* 近景雾 */}
<div className="fog-layer-near" />
{/* 萤火粒子 */}
{particles.map((p) => (
<div
key={p.id}
className="absolute rounded-full"
style={{
left: p.left,
top: p.top,
width: p.size,
height: p.size,
backgroundColor: "var(--accent)",
opacity: p.opacity,
animation: `particleDrift ${p.duration} ease-in-out infinite, particleFlicker 4s ease-in-out infinite`,
animationDelay: `${p.delay}, ${parseFloat(p.delay) + 1}s`,
boxShadow: `0 0 ${p.size * 3}px rgba(46, 139, 122, ${p.opacity * 0.6})`,
willChange: "transform, opacity",
}}
/>
))}
<style>{`
@keyframes particleDrift {
0% { transform: translate(0, 0); }
25% { transform: translate(${20 + Math.random() * 30}px, -${15 + Math.random() * 20}px); }
50% { transform: translate(-${10 + Math.random() * 20}px, ${10 + Math.random() * 25}px); }
75% { transform: translate(${15 + Math.random() * 20}px, ${5 + Math.random() * 15}px); }
100% { transform: translate(0, 0); }
}
@keyframes particleFlicker {
0%, 100% { opacity: var(--particle-base-opacity, 0.15); }
50% { opacity: calc(var(--particle-base-opacity, 0.15) * 2.2); }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
interface DataStreamProps {
active: boolean;
speed?: "slow" | "normal" | "fast";
}
export function DataStream({ active, speed = "normal" }: DataStreamProps) {
if (!active) return null;
const speedClass =
speed === "slow" ? "data-stream-slow" :
speed === "fast" ? "data-stream-fast" : "";
return (
<div className="w-full overflow-hidden" aria-hidden="true">
<div className={`data-stream ${speedClass}`} />
</div>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { sparkleAnim } from "./motion-presets";
interface Spark {
id: number;
angle: number;
distance: number;
}
let _sparkId = 0;
export function useFireflyBurst() {
const [sparks, setSparks] = useState<Spark[]>([]);
const burst = useCallback(() => {
const count = 5 + Math.floor(Math.random() * 3);
const newSparks: Spark[] = [];
for (let i = 0; i < count; i++) {
newSparks.push({
id: ++_sparkId,
angle: (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5,
distance: 12 + Math.random() * 18,
});
}
setSparks(newSparks);
setTimeout(() => setSparks([]), 1000);
}, []);
return { sparks, burst };
}
interface FireflyBurstProps {
sparks: Spark[];
}
export function FireflyBurst({ sparks }: FireflyBurstProps) {
return (
<AnimatePresence>
{sparks.map((s) => (
<motion.div
key={s.id}
className="absolute pointer-events-none"
style={{
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: "var(--gold)",
boxShadow: "0 0 6px var(--gold)",
left: "50%",
top: "50%",
marginLeft: -2,
marginTop: -2,
}}
animate={sparkleAnim(s.angle, s.distance)}
/>
))}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,113 @@
import type { Transition, Variants } from "framer-motion";
/* ===== Spring 物理参数 ===== */
export const spring: Transition = { type: "spring", stiffness: 400, damping: 25 };
export const springGentle: Transition = { type: "spring", stiffness: 200, damping: 20 };
/* ===== 通用入场变体 ===== */
export const fadeIn: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.2 } },
};
export const slideUp: Variants = {
hidden: { opacity: 0, y: 8 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" } },
};
export const mistReveal: Variants = {
hidden: { opacity: 0, filter: "blur(8px)" },
visible: { opacity: 1, filter: "blur(0px)", transition: { duration: 0.4, ease: "easeOut" } },
};
/* ===== 消息入场 ===== */
export const messageEnter: Variants = {
hidden: { opacity: 0, y: 12, scale: 0.98 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.25, ease: "easeOut" },
},
};
export const assistantEnter: Variants = {
hidden: { opacity: 0, filter: "blur(8px)", y: 6 },
visible: {
opacity: 1,
filter: "blur(0px)",
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
/* ===== 按钮反馈 ===== */
export const buttonTap = { scale: 0.85 };
export const sendBounce = {
scale: [1, 0.85, 1.08, 1],
transition: { duration: 0.35, ease: "easeInOut" as const },
};
/* ===== 涟漪 ===== */
export const rippleAnim = {
scale: [0, 2.5],
opacity: [0.4, 0],
transition: { duration: 0.6, ease: "easeOut" as const },
};
/* ===== 萤火粒子 ===== */
export const sparkleAnim = (angle: number, distance: number) => ({
x: [0, Math.cos(angle) * distance],
y: [0, Math.sin(angle) * distance],
scale: [0, 1.2, 0],
opacity: [0, 1, 0],
transition: { duration: 0.7 + Math.random() * 0.3, ease: "easeOut" as const },
});
/* ===== 滚动到底部按钮 — 落叶归根 ===== */
export const scrollBtnEnter: Variants = {
hidden: { opacity: 0, y: 20, scale: 0.8 },
visible: { opacity: 1, y: 0, scale: 1, transition: { ...springGentle } },
exit: { opacity: 0, y: 10, scale: 0.8, transition: { duration: 0.15 } },
};
export const scrollBtnClick = {
y: [-0, -6, 20],
opacity: [1, 1, 0],
scale: [1, 1.1, 0.8],
transition: { duration: 0.35, ease: "easeIn" as const },
};
/* ===== 会话切换 — 翻山越岭 ===== */
export const sessionSlide: Variants = {
enter: (dir: number) => ({ opacity: 0, y: dir > 0 ? 30 : -30 }),
center: { opacity: 1, y: 0, transition: { duration: 0.25, ease: "easeOut" } },
exit: (dir: number) => ({ opacity: 0, y: dir > 0 ? -30 : 30, transition: { duration: 0.2, ease: "easeIn" } }),
};
/* ===== Error 飘散消失 ===== */
export const errorFloat: Variants = {
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -12, transition: { duration: 0.5, ease: "easeOut" } },
};
/* ===== stagger 容器 ===== */
export const staggerContainer: Variants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.06 },
},
};
export const staggerItem: Variants = {
hidden: { opacity: 0, y: 8 },
visible: { opacity: 1, y: 0, transition: { duration: 0.2 } },
};
/* ===== 水墨揭示 ===== */
export const inkReveal: Variants = {
hidden: { clipPath: "circle(0% at 50% 50%)" },
visible: {
clipPath: "circle(75% at 50% 50%)",
transition: { duration: 0.6, ease: "easeOut" },
},
};

View File

@@ -0,0 +1,53 @@
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { rippleAnim } from "./motion-presets";
interface Ripple {
id: number;
x: number;
y: number;
}
let _rippleId = 0;
export function useRipple() {
const [ripples, setRipples] = useState<Ripple[]>([]);
const trigger = useCallback((x: number, y: number) => {
const id = ++_rippleId;
setRipples((prev) => [...prev, { id, x, y }]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== id));
}, 700);
}, []);
return { ripples, trigger };
}
interface RippleLayerProps {
ripples: Ripple[];
}
export function RippleLayer({ ripples }: RippleLayerProps) {
return (
<AnimatePresence>
{ripples.map((r) => (
<motion.div
key={r.id}
className="absolute pointer-events-none"
style={{
left: r.x - 10,
top: r.y - 10,
width: 20,
height: 20,
borderRadius: "50%",
border: "2px solid var(--accent)",
}}
animate={rippleAnim}
/>
))}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
/**
* 高级控制面板Phase 2 占位)
*
* 对应 INFOLAYER.md 3.3 节「高级控制」:提供给高级用户的参数调节抽屉。
* 当前所有参数均为占位,不会真实下发到后端。
*/
export function AdvancedControls() {
const [open, setOpen] = useState(false);
const [cfg, setCfg] = useState(7.5);
const [steps, setSteps] = useState(28);
const [sampler, setSampler] = useState("DPM++ 2M Karras");
const [seed, setSeed] = useState("");
const [styleWeight, setStyleWeight] = useState(0.8);
const [charWeight, setCharWeight] = useState(0.85);
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="h-7 px-2 rounded-lg text-xs flex items-center gap-1.5
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="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span className="phase-chip">P2</span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="absolute bottom-full mb-2 right-0 w-80 max-w-[calc(100vw-24px)]
rounded-xl surface-2 border border-[var(--border)] shadow-xl
backdrop-blur-xl z-40 p-3"
>
<div className="flex items-center justify-between mb-3">
<span className="typo-strong text-xs"></span>
<button
onClick={() => setOpen(false)}
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] cursor-pointer"
>
</button>
</div>
<div className="space-y-3">
<Slider label="CFG Scale" value={cfg} min={1} max={20} step={0.5} onChange={setCfg} />
<Slider label="Steps" value={steps} min={10} max={60} step={1} onChange={setSteps} />
<Slider label="风格强度" value={styleWeight} min={0} max={1.5} step={0.05} onChange={setStyleWeight} />
<Slider label="角色一致性" value={charWeight} min={0} max={1.5} step={0.05} onChange={setCharWeight} />
<div>
<label className="block typo-micro mb-1" style={{ textTransform: "none" }}></label>
<select
value={sampler}
onChange={(e) => setSampler(e.target.value)}
className="w-full px-2 py-1.5 text-xs rounded-md bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
>
{["DPM++ 2M Karras", "Euler a", "DDIM", "UniPC"].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="block typo-micro mb-1" style={{ textTransform: "none" }}>Seed</label>
<div className="flex gap-1.5">
<input
value={seed}
onChange={(e) => setSeed(e.target.value)}
placeholder="留空为随机"
className="flex-1 px-2 py-1.5 text-xs rounded-md bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none font-mono"
/>
<button
onClick={() => setSeed(Math.floor(Math.random() * 9_999_999).toString())}
className="px-2 py-1.5 text-xs rounded-md border border-[var(--border)]
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
cursor-pointer"
>
🎲
</button>
</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-[var(--border)] text-[10px] text-[var(--text-secondary)]">
Phase 2
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function Slider({
label, value, min, max, step, onChange,
}: { label: string; value: number; min: number; max: number; step: number; onChange: (v: number) => void }) {
return (
<div>
<div className="flex items-center justify-between mb-1">
<label className="typo-micro" style={{ textTransform: "none" }}>{label}</label>
<span className="text-[11px] text-[var(--accent)] font-mono">{value}</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-full accent-[var(--accent)]"
/>
</div>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import type { ImageAsset } from "@/lib/types";
import { getImageUrl } from "@/lib/api";
/**
* 候选评估面板Phase 1 占位)
*
* 对应 INFOLAYER.md 3.3.3 节:同轮生成的一组候选图的并排比较、
* 快速评分、标注差异、挑选定稿。
*/
interface CandidatePanelProps {
candidates: ImageAsset[];
onPick?: (id: string) => void;
onClose?: () => void;
}
export function CandidatePanel({ candidates, onPick, onClose }: CandidatePanelProps) {
const [ratings, setRatings] = useState<Record<string, number>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
if (candidates.length === 0) return null;
return (
<div className="fixed inset-y-0 right-0 w-[min(680px,100vw)] z-40
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
border-l border-[var(--border)] shadow-2xl
flex flex-col animate-in slide-in-from-right">
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
<div className="flex items-center gap-2">
<h2 className="typo-h2"></h2>
<span className="phase-chip">Phase 1</span>
<span className="text-xs text-[var(--text-secondary)]">
{candidates.length}
</span>
</div>
<button
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-all"
>
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
{candidates.map((c, i) => (
<div key={c.id} className="rounded-xl surface-2 overflow-hidden">
<div className="relative aspect-square bg-[var(--bg-tertiary)]">
{c.url ? (
<img src={getImageUrl(c.url)} alt={`候选 ${i + 1}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">
{i + 1}
</div>
)}
<div className="absolute top-2 left-2 px-2 py-0.5 rounded-md
bg-[var(--bg-primary)]/70 backdrop-blur-sm
text-[10px] font-mono text-[var(--text-primary)]">
#{i + 1}
</div>
</div>
<div className="p-2.5">
<div className="flex gap-0.5 mb-1.5">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() =>
setRatings((prev) => ({ ...prev, [c.id]: prev[c.id] === n ? 0 : n }))
}
className={`text-base leading-none cursor-pointer transition-colors ${
(ratings[c.id] ?? 0) >= n ? "text-[var(--accent)]" : "text-[var(--border)]"
}`}
>
</button>
))}
</div>
<input
value={notes[c.id] ?? ""}
onChange={(e) => setNotes((prev) => ({ ...prev, [c.id]: e.target.value }))}
placeholder="点评(占位)"
className="w-full px-2 py-1 text-[11px] rounded-md bg-[var(--bg-primary)]
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none"
/>
<button
onClick={() => onPick?.(c.id)}
className="mt-1.5 w-full text-[11px] py-1 rounded-md
bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/30
hover:bg-[var(--accent)]/20 cursor-pointer transition-all"
>
稿
</button>
</div>
</div>
))}
</div>
<div className="placeholder-card rounded-lg p-3 text-xs text-[var(--text-secondary)]">
<div className="typo-strong text-xs mb-1 text-[var(--text-primary)]"></div>
/ / Phase 2
Agent
</div>
</div>
</div>
);
}

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

@@ -9,7 +9,20 @@ import {
useState,
type ReactNode,
} from "react";
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
import type {
Session,
Tag,
ImageAsset,
ChatMessage,
Project,
StylePack,
CharacterCard,
TrainingTask,
DocNode,
Asset3D,
AssetMotion,
TaskType,
} from "./types";
import {
loadSessions,
saveSessions,
@@ -28,6 +41,29 @@ import {
generateId,
setStoreUserId,
} from "./store";
import {
loadProjects,
saveProjects,
loadActiveProjectId,
saveActiveProjectId,
loadStylePacks,
upsertStylePack as storeUpsertStyle,
deleteStylePack as storeDeleteStyle,
loadCharacters,
upsertCharacter as storeUpsertChar,
deleteCharacter as storeDeleteChar,
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";
interface AppContextValue {
@@ -65,6 +101,60 @@ interface AppContextValue {
// 左栏折叠
sidebarCollapsed: boolean;
setSidebarCollapsed: (v: boolean) => void;
// ===== Phase 1+ 占位数据 =====
projects: Project[];
activeProjectId: string;
activeProject: Project | null;
switchProject: (id: string) => void;
upsertProject: (p: Project) => void;
stylePacks: StylePack[];
upsertStylePack: (sp: StylePack) => void;
deleteStylePack: (id: string) => void;
characters: CharacterCard[];
upsertCharacter: (c: CharacterCard) => void;
deleteCharacter: (id: string) => void;
trainingTasks: TrainingTask[];
upsertTrainingTask: (t: TrainingTask) => void;
deleteTrainingTask: (id: string) => void;
/** 当前会话绑定的风格/角色UI 层占位,不实际下发后端) */
sessionStyleId: string | null;
setSessionStyleId: (id: string | null) => void;
sessionCharacterId: string | null;
setSessionCharacterId: (id: string | null) => void;
/** 当前会话选择的资产规格(占位) */
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);
@@ -90,6 +180,21 @@ export function AppProvider({ children }: { children: ReactNode }) {
});
const [initialized, setInitialized] = useState(false);
// ===== Phase 1+ 占位数据 =====
const [projects, setProjects] = useState<Project[]>([]);
const [activeProjectId, setActiveProjectId] = useState<string>("");
const [stylePacks, setStylePacks] = useState<StylePack[]>([]);
const [characters, setCharacters] = useState<CharacterCard[]>([]);
const [trainingTasks, setTrainingTasks] = useState<TrainingTask[]>([]);
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(() => {
if (!isAuthenticated || !user) {
@@ -100,8 +205,20 @@ export function AppProvider({ children }: { children: ReactNode }) {
setSessions(loadSessions());
setTags(loadTags());
setAssets(loadAssets());
setProjects(loadProjects());
setActiveProjectId(loadActiveProjectId());
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
@@ -268,6 +385,224 @@ export function AppProvider({ children }: { children: ReactNode }) {
return result;
}, []);
// ===== Phase 1+ 占位操作 =====
const activeProject = useMemo(
() => projects.find((p) => p.id === activeProjectId) ?? null,
[projects, activeProjectId]
);
const switchProject = useCallback((id: string) => {
setActiveProjectId(id);
saveActiveProjectId(id);
setSessionStyleId(null);
setSessionCharacterId(null);
}, []);
const upsertProjectCb = useCallback((p: Project) => {
setProjects((prev) => {
const idx = prev.findIndex((x) => x.id === p.id);
const next = idx >= 0
? prev.map((x) => (x.id === p.id ? { ...p, updatedAt: Date.now() } : x))
: [{ ...p, updatedAt: Date.now() }, ...prev];
saveProjects(next);
return next;
});
}, []);
const upsertStylePackCb = useCallback((sp: StylePack) => {
storeUpsertStyle(sp);
setStylePacks(loadStylePacks());
}, []);
const deleteStylePackCb = useCallback((id: string) => {
storeDeleteStyle(id);
setStylePacks(loadStylePacks());
}, []);
const upsertCharacterCb = useCallback((c: CharacterCard) => {
storeUpsertChar(c);
setCharacters(loadCharacters());
}, []);
const deleteCharacterCb = useCallback((id: string) => {
storeDeleteChar(id);
setCharacters(loadCharacters());
}, []);
const upsertTrainingTaskCb = useCallback((t: TrainingTask) => {
storeUpsertTraining(t);
setTrainingTasks(loadTrainingTasks());
}, []);
const deleteTrainingTaskCb = useCallback((id: string) => {
storeDeleteTraining(id);
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,
@@ -295,6 +630,43 @@ export function AppProvider({ children }: { children: ReactNode }) {
setDetailImage,
sidebarCollapsed,
setSidebarCollapsed,
projects,
activeProjectId,
activeProject,
switchProject,
upsertProject: upsertProjectCb,
stylePacks,
upsertStylePack: upsertStylePackCb,
deleteStylePack: deleteStylePackCb,
characters,
upsertCharacter: upsertCharacterCb,
deleteCharacter: deleteCharacterCb,
trainingTasks,
upsertTrainingTask: upsertTrainingTaskCb,
deleteTrainingTask: deleteTrainingTaskCb,
sessionStyleId,
setSessionStyleId,
sessionCharacterId,
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,
@@ -303,6 +675,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
tags, addTag, deleteTagCb, selectedTagIds,
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
detailImage, sidebarCollapsed,
projects, activeProjectId, activeProject, switchProject, upsertProjectCb,
stylePacks, upsertStylePackCb, deleteStylePackCb,
characters, upsertCharacterCb, deleteCharacterCb,
trainingTasks, upsertTrainingTaskCb, deleteTrainingTaskCb,
sessionStyleId, sessionCharacterId, sessionAssetSpec,
docs, upsertDocCb, renameDoc, deleteDoc, moveDoc, updateDocContent, createDoc,
assets3D, upsertAsset3DCb, deleteAsset3DCb,
assetsMotion, upsertAssetMotionCb, deleteAssetMotionCb,
sessionTaskType, commandPaletteOpen,
]
);
@@ -312,7 +693,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
if (isAuthenticated) {
return (
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
<div className="text-[var(--text-secondary)] text-sm">...</div>
<div className="mist-loader">
<div className="mist-loader-ring" />
<div className="mist-loader-ring mist-loader-ring-2" />
<div className="mist-loader-core" />
</div>
</div>
);
}

View File

@@ -0,0 +1,696 @@
/**
* Phase 1+ 占位数据 Store风格 / 角色 / 项目 / 训练任务)。
*
* 这些数据尚未接入后端,用 localStorage 持久化,首次载入时注入示例占位数据
* 供交互骨架使用。所有功能性操作应用到会话、训练、Fork 等)目前为占位动作,
* 在 UI 上给出反馈即可,不产生真实后端调用。
*/
import type {
Project,
StylePack,
CharacterCard,
TrainingTask,
DocNode,
Asset3D,
AssetMotion,
} from "./types";
import { getStoreUserId, generateId } from "./store";
function storageKey(base: string): string {
const prefix = getStoreUserId() || "_anonymous";
return `epeekit-${prefix}-${base}`;
}
function readJSON<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function writeJSON<T>(key: string, data: T) {
if (typeof window === "undefined") return;
localStorage.setItem(key, JSON.stringify(data));
}
// ==================== 项目 ====================
const DEFAULT_PROJECT_ID = "proj-default";
export function loadProjects(): Project[] {
const list = readJSON<Project[]>(storageKey("projects"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: Project[] = [
{
id: DEFAULT_PROJECT_ID,
name: "默认项目(占位)",
description: "用于当前工作台的默认项目。真实的多项目能力为 Phase 1 规划。",
type: "2D RPG",
defaultSpec: "1024×1024 · 透明背景可选",
createdAt: now,
updatedAt: now,
},
{
id: "proj-sample-card",
name: "示例项目:卡牌游戏",
description: "示例占位数据。",
type: "卡牌",
defaultSpec: "768×1024 · PNG",
createdAt: now - 86400000,
updatedAt: now - 86400000,
},
];
writeJSON(storageKey("projects"), defaults);
writeJSON(storageKey("active-project"), DEFAULT_PROJECT_ID);
return defaults;
}
return list;
}
export function saveProjects(list: Project[]) {
writeJSON(storageKey("projects"), list);
}
export function loadActiveProjectId(): string {
const saved = readJSON<string>(storageKey("active-project"), "");
return saved || DEFAULT_PROJECT_ID;
}
export function saveActiveProjectId(id: string) {
writeJSON(storageKey("active-project"), id);
}
export function createProject(name: string, type: string): Project {
const now = Date.now();
const p: Project = {
id: generateId("proj-"),
name,
type,
description: "",
createdAt: now,
updatedAt: now,
};
const list = loadProjects();
list.unshift(p);
saveProjects(list);
return p;
}
// ==================== 风格集 ====================
export function loadStylePacks(): StylePack[] {
const list = readJSON<StylePack[]>(storageKey("style-packs"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: StylePack[] = [
{
id: "style-anime-dark-fantasy-v1",
name: "暗黑幻想·二次元",
description: "偏暗色调的奇幻二次元风格,适合剑与魔法题材的立绘和场景。",
keywords: ["二次元", "暗黑幻想", "奇幻", "剑与魔法"],
source: "preset",
refImages: [
{ id: "ref-1", url: "", caption: "占位参考图 1" },
{ id: "ref-2", url: "", caption: "占位参考图 2" },
{ id: "ref-3", url: "", caption: "占位参考图 3" },
],
positivePrompt: "占位cinematic lighting, dark fantasy, anime style, high detail",
negativePrompt: "占位lowres, blurry, deformed",
usageCount: 17,
createdAt: now - 86400000 * 5,
updatedAt: now - 86400000 * 2,
},
{
id: "style-ink-wuxia-v1",
name: "水墨·武侠",
description: "水墨写意风格,留白与笔触表现,适合武侠场景和人物。",
keywords: ["水墨", "写意", "武侠", "国风"],
source: "preset",
refImages: [
{ id: "ref-1", url: "", caption: "占位参考图 1" },
{ id: "ref-2", url: "", caption: "占位参考图 2" },
],
positivePrompt: "占位chinese ink painting, wuxia style, flowing brush strokes",
negativePrompt: "占位photo, 3d, western style",
usageCount: 8,
createdAt: now - 86400000 * 10,
updatedAt: now - 86400000 * 7,
},
{
id: "style-pixel-retro-v1",
name: "像素·复古",
description: "16bit 像素风格,适合复古游戏的 icon、立绘和场景。",
keywords: ["像素", "复古", "16bit"],
source: "preset",
refImages: [
{ id: "ref-1", url: "", caption: "占位参考图" },
],
usageCount: 3,
createdAt: now - 86400000 * 20,
updatedAt: now - 86400000 * 18,
},
{
id: "style-cyber-neon-v1",
name: "赛博·霓虹",
description: "赛博朋克霓虹质感,青紫色调与雨夜街景的占位示例。",
keywords: ["赛博", "霓虹", "科幻"],
source: "community",
refImages: [
{ id: "ref-1", url: "", caption: "占位参考图" },
],
usageCount: 1,
forkFromId: "style-anime-dark-fantasy-v1",
createdAt: now - 86400000 * 3,
updatedAt: now - 86400000,
},
];
writeJSON(storageKey("style-packs"), defaults);
return defaults;
}
return list;
}
export function saveStylePacks(list: StylePack[]) {
writeJSON(storageKey("style-packs"), list);
}
export function upsertStylePack(sp: StylePack) {
const list = loadStylePacks();
const idx = list.findIndex((x) => x.id === sp.id);
if (idx >= 0) list[idx] = { ...sp, updatedAt: Date.now() };
else list.unshift(sp);
saveStylePacks(list);
}
export function deleteStylePack(id: string) {
saveStylePacks(loadStylePacks().filter((x) => x.id !== id));
}
// ==================== 角色卡 ====================
export function loadCharacters(): CharacterCard[] {
const list = readJSON<CharacterCard[]>(storageKey("characters"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: CharacterCard[] = [
{
id: "char-elica-swordsman",
name: "剑士·艾丽卡",
description: "占位示例角色:银发剑士,性格冷静,擅长二刀流。",
gender: "女",
ageRange: "20 岁上下",
body: "修长",
personality: ["冷静", "果断", "寡言"],
story: "(占位)出身于北境贵族家庭,因家族变故而踏上旅途。",
visualHooks: ["银发", "紫瞳", "红披风", "双剑"],
consistency: "high",
refImages: [
{ id: "r1", url: "", angle: "正面", caption: "占位参考图" },
{ id: "r2", url: "", angle: "半侧面", caption: "占位参考图" },
{ id: "r3", url: "", angle: "表情·微笑", caption: "占位参考图" },
],
projectId: DEFAULT_PROJECT_ID,
createdAt: now - 86400000 * 3,
updatedAt: now - 86400000,
},
{
id: "char-mark-gunner",
name: "枪手·马克",
description: "占位示例角色:疤痕硬汉,惯用左轮,话少事多。",
gender: "男",
ageRange: "30 岁上下",
body: "结实",
personality: ["硬汉", "江湖气", "话少"],
story: "(占位)西部小镇通缉令上最贵的头颅。",
visualHooks: ["眉间疤", "棕色风衣", "左轮", "牛仔帽"],
consistency: "medium",
refImages: [
{ id: "r1", url: "", angle: "正面", caption: "占位参考图" },
],
projectId: "proj-sample-card",
createdAt: now - 86400000 * 8,
updatedAt: now - 86400000 * 2,
},
];
writeJSON(storageKey("characters"), defaults);
return defaults;
}
return list;
}
export function saveCharacters(list: CharacterCard[]) {
writeJSON(storageKey("characters"), list);
}
export function upsertCharacter(c: CharacterCard) {
const list = loadCharacters();
const idx = list.findIndex((x) => x.id === c.id);
if (idx >= 0) list[idx] = { ...c, updatedAt: Date.now() };
else list.unshift(c);
saveCharacters(list);
}
export function deleteCharacter(id: string) {
saveCharacters(loadCharacters().filter((x) => x.id !== id));
}
// ==================== 训练任务 ====================
export function loadTrainingTasks(): TrainingTask[] {
const list = readJSON<TrainingTask[]>(storageKey("training-tasks"), []);
if (list.length === 0) {
const now = Date.now();
const defaults: TrainingTask[] = [
{
id: "train-dark-fantasy-v2",
name: "暗黑幻想风格 v2",
type: "style_lora",
status: "running",
progress: 67,
sourceId: "style-anime-dark-fantasy-v1",
template: "sdxl_lora",
trainingSet: Array.from({ length: 24 }).map((_, i) => `asset-train-${i + 1}`),
logs: [
"[占位] epoch 4/6 loss=0.0321",
"[占位] epoch 5/6 loss=0.0278 ...",
],
currentStep: "Epoch 5 / 6",
etaSeconds: 480,
estCost: "≈ ¥18占位",
createdAt: now - 86400000,
updatedAt: now - 3600000,
},
{
id: "train-elica-v1",
name: "剑士·艾丽卡 v1",
type: "character_lora",
status: "completed",
progress: 100,
sourceId: "char-elica-swordsman",
template: "flux_lora",
trainingSet: Array.from({ length: 12 }).map((_, i) => `asset-train-${i + 1}`),
logs: ["[占位] 训练完成loss=0.0201"],
estCost: "≈ ¥12占位",
createdAt: now - 86400000 * 2,
updatedAt: now - 86400000 + 3 * 3600000,
},
{
id: "train-ink-wuxia-v1",
name: "水墨武侠 v1",
type: "style_lora",
status: "queued",
progress: 0,
sourceId: "style-ink-wuxia-v1",
template: "sdxl_lora",
trainingSet: [],
logs: [],
estCost: "≈ ¥20占位",
createdAt: now - 3600000,
updatedAt: now - 3600000,
},
{
id: "train-mark-v1-failed",
name: "枪手·马克 v1",
type: "character_lora",
status: "failed",
progress: 0,
sourceId: "char-mark-gunner",
template: "flux_lora",
trainingSet: [],
logs: ["[占位] 训练失败:训练集图片数量不足(示例)"],
createdAt: now - 86400000 * 3,
updatedAt: now - 86400000 * 3 + 1800000,
},
];
writeJSON(storageKey("training-tasks"), defaults);
return defaults;
}
return list;
}
export function saveTrainingTasks(list: TrainingTask[]) {
writeJSON(storageKey("training-tasks"), list);
}
export function upsertTrainingTask(t: TrainingTask) {
const list = loadTrainingTasks();
const idx = list.findIndex((x) => x.id === t.id);
if (idx >= 0) list[idx] = { ...t, updatedAt: Date.now() };
else list.unshift(t);
saveTrainingTasks(list);
}
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

@@ -69,6 +69,142 @@ export interface LlmModelInfo {
vision?: boolean;
}
// ==================== Phase 1+ 占位类型 ====================
// 以下类型对应 INFOLAYER.md Phase 1/2 的产品形态,功能尚未完整接入后端,
// 目前以 localStorage + 预置占位数据的方式支撑前端交互骨架。
/** 项目Phase 1 */
export interface Project {
id: string;
name: string;
description: string;
/** 项目类型,如 2D RPG / 横版动作 / 卡牌 等 */
type: string;
/** 默认绑定的风格集 ID */
defaultStyleId?: string;
/** 默认输出规格备注(占位) */
defaultSpec?: string;
thumbnail?: string;
createdAt: number;
updatedAt: number;
}
/** 风格参考集中的一张参考图(占位) */
export interface StyleRefImage {
id: string;
url: string;
caption?: string;
}
/** 风格集Phase 1 */
export interface StylePack {
id: string;
name: string;
description: string;
/** 关键词标签,如「写实」「二次元」「像素」「水墨」「赛博」 */
keywords: string[];
/** 分类preset 平台预设 / custom 项目自建 / community 社区共享 */
source: "preset" | "custom" | "community";
refImages: StyleRefImage[];
/** 正向 Prompt 模板(占位) */
positivePrompt?: string;
/** 负向 Prompt 模板(占位) */
negativePrompt?: string;
/** 绑定的 LoRA 模型 IDPhase 2 占位) */
loraId?: string;
/** Fork 来源的风格 ID占位 */
forkFromId?: string;
/** 使用次数(占位) */
usageCount: number;
createdAt: number;
updatedAt: number;
}
/** 角色参考组中的一张参考图(占位) */
export interface CharacterRefImage {
id: string;
url: string;
/** 视角分类:正面 / 半侧面 / 半身 / 表情 / 服饰细节 等 */
angle: string;
caption?: string;
}
/** 角色卡Phase 1 */
export interface CharacterCard {
id: string;
name: string;
description: string;
/** 性别 */
gender?: string;
/** 年龄范围 */
ageRange?: string;
/** 体型 */
body?: string;
/** 性格关键词 */
personality: string[];
/** 背景故事摘要 */
story?: string;
/** 关键视觉要素(发色、瞳色、标志性饰品、武器等) */
visualHooks: string[];
/** 默认一致性级别 */
consistency: "low" | "medium" | "high";
/** 参考图组 */
refImages: CharacterRefImage[];
/** 绑定的 LoRA 模型 IDPhase 2 占位) */
loraId?: string;
/** 所属项目 */
projectId?: string;
createdAt: number;
updatedAt: number;
}
/** 训练任务类型 */
export type TrainingTaskType = "style_lora" | "character_lora";
/** 训练任务状态 */
export type TrainingTaskStatus = "queued" | "running" | "completed" | "failed" | "deprecated";
/** 训练任务Phase 2 */
export interface TrainingTask {
id: string;
name: string;
type: TrainingTaskType;
status: TrainingTaskStatus;
/** 0100 */
progress: number;
/** 来源风格或角色 ID */
sourceId?: string;
/** 训练模板(如 sdxl_lora / flux_lora */
template: string;
/** 训练集资产 ID 列表(占位) */
trainingSet: string[];
/** 训练日志片段(占位) */
logs: string[];
/** 当前 step 信息(占位) */
currentStep?: string;
/** 预估剩余时间(秒,占位) */
etaSeconds?: number;
/** 预估成本(占位) */
estCost?: string;
createdAt: number;
updatedAt: number;
}
/** 任务说明卡 IRPhase 1对应 3.3.2 节 Task Card */
export interface TaskIR {
/** 任务类型,如「角色立绘(全身)」 */
taskType: string;
styleId?: string;
characterId?: string;
/** 输出规格如「1024×1536 · 透明背景可选」 */
outputSpec: string;
/** 模板名,如 character_consistent_v1 */
template: string;
/** 候选数 */
candidateCount: number;
/** 用户原始指令,仅展示 */
userPrompt?: string;
}
/** 标注数据 */
export interface Annotation {
id: string;
@@ -87,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

@@ -1,4 +1,4 @@
# EPEEKit 内网穿透启动脚本
# EPEEKit 内网穿透启动脚本
# 使用 Cloudflare Quick Tunnel无需账号免费
# 用法:在 PowerShell 中执行 .\start-tunnel.ps1
@@ -9,6 +9,12 @@ param(
$ErrorActionPreference = "Stop"
# 优先使用与本脚本同目录的 cloudflared.exe避免未安装 / 未加入 PATH
$LocalCf = Join-Path $PSScriptRoot "cloudflared.exe"
if (Test-Path -LiteralPath $LocalCf) {
$env:Path = "$PSScriptRoot;$env:Path"
}
# 检查 cloudflared 是否可用
try {
$null = Get-Command cloudflared -ErrorAction Stop
@@ -52,14 +58,15 @@ if (-not $backendUrl) {
Write-Host " 后端穿透地址: $backendUrl" -ForegroundColor Green
# 更新前端环境变量
# 更新前端环境变量(避免 PS5.1 对 UTF-8 here-string 解析问题,改用 WriteAllLines
$envFile = Join-Path $PSScriptRoot "frontend\.env.local"
@"
# API start-tunnel.ps1
NEXT_PUBLIC_API_URL=$backendUrl
"@ | Set-Content $envFile -Encoding UTF8
$envLines = @(
"# Backend API URL (updated by start-tunnel.ps1)"
"NEXT_PUBLIC_API_URL=$backendUrl"
)
[System.IO.File]::WriteAllLines($envFile, $envLines, [System.Text.UTF8Encoding]::new($false))
Write-Host " 已更新 frontend\.env.local" -ForegroundColor Green
Write-Host " Updated frontend\.env.local" -ForegroundColor Green
Write-Host ""
# 启动前端穿透
@@ -123,11 +130,12 @@ try {
Stop-Process -Id $frontendTunnel.Id -Force -ErrorAction SilentlyContinue
# 恢复 .env.local 为 localhost
@"
# API
NEXT_PUBLIC_API_URL=http://localhost:8000
"@ | Set-Content $envFile -Encoding UTF8
$restoreLines = @(
"# Backend API URL"
"NEXT_PUBLIC_API_URL=http://localhost:8000"
)
[System.IO.File]::WriteAllLines($envFile, $restoreLines, [System.Text.UTF8Encoding]::new($false))
Write-Host "已恢复 .env.local localhost" -ForegroundColor Green
Write-Host "Restored .env.local to localhost" -ForegroundColor Green
Write-Host "隧道已关闭" -ForegroundColor Green
}

614
docs/art-agent/INFOLAYER.md Normal file
View File

@@ -0,0 +1,614 @@
# EpeeKit — 前端信息层级与导航流程
> 本文档描述产品**最终形态**的前端信息架构。
> 标注 `[Phase 0]` 的部分表示当前已实现或即将实现;
> 标注 `[Phase 1]`、`[Phase 2]`、`[Phase 3]` 的部分对应方案文档的阶段路线。
---
## 1. 全局导航结构
```
┌─────────────────────────────────────────────────────────────────┐
│ TopNav │
│ ┌──────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Logo │ │ 工作台 │ │ 资源库 │ │ 风格库 │ │ 训练中心│ ... │
│ └──────┘ └────────┘ └────────┘ └────────┘ └────────┘ │
│ ┌──────────┐ │
│ │ 用户菜单 │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 顶部导航TopNav
| 元素 | 说明 |
|------|------|
| Logo / 品牌名 | 点击回到当前项目的工作台 |
| **工作台** | 对话 + 任务 + 高级控制的主工作区域(`/` |
| **资源库** | 项目级资产浏览、搜索、管理(`/gallery` |
| **风格库** | 风格集 + 角色库的管理入口(`/styles``[Phase 1]` |
| **训练中心** | LoRA 训练任务的管理入口(`/training``[Phase 2]` |
| 项目切换器 | 当前项目名 + 下拉切换/新建项目 `[Phase 1]` |
| 用户菜单 | 头像 → 个人信息、记忆查看、设置、退出 |
### 路由总览
| 路由 | 页面 | 阶段 |
|------|------|------|
| `/login` | 登录页 | Phase 0 |
| `/` | 工作台(对话 + 任务 + 控制面板) | Phase 0 |
| `/gallery` | 资源库 | Phase 0 |
| `/styles` | 风格库 | Phase 1 |
| `/styles/[id]` | 风格详情 / 编辑 | Phase 1 |
| `/characters` | 角色库 | Phase 1 |
| `/characters/[id]` | 角色详情 / 编辑 | Phase 1 |
| `/training` | 训练中心(任务列表) | Phase 2 |
| `/training/[id]` | 训练任务详情 | Phase 2 |
| `/settings` | 项目设置 | Phase 1 |
---
## 2. 登录页 `/login`
### 信息内容
- 品牌 Logo + 产品名称
- 用户名输入框
- 密码输入框
- 登录按钮
- 错误提示(用户名/密码错误、网络异常)
### 功能
- 提交登录 → `POST /api/auth/login` → 成功后跳转 `/`
- 已登录状态自动重定向至 `/`
- Token 存 `localStorage`,后续请求带 `Bearer`
### 跳转
| 操作 | 目标 |
|------|------|
| 登录成功 | → `/`(工作台) |
| 已登录访问 | → `/`(自动重定向) |
---
## 3. 工作台 `/`(核心页面)
工作台是产品最核心的界面,采用**三栏布局**。
### 3.1 整体布局
```
┌──────────┬──────────────────────────────┬──────────────┐
│ │ │ │
│ 侧栏 │ 对话 + 任务区 │ 上下文面板 │
│ Sidebar │ Main Area │ Context │
│ │ │ Panel │
│ │ │ │
│ │ │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ 输入区 │ │ │
│ │ └────────────────────────┘ │ │
└──────────┴──────────────────────────────┴──────────────┘
```
---
### 3.2 左侧栏Sidebar
#### 3.2.1 会话列表区
| 信息 / 功能 | 说明 |
|-------------|------|
| 新建会话按钮 | 创建空白对话会话 |
| 会话条目 | 缩略图 + 标题 + 最后活动时间 |
| 会话标签 | 每个会话可绑定多标签,标签色块显示 |
| 标签筛选栏 | 顶部标签多选过滤器,只显示匹配会话 |
| 会话三点菜单 | 重命名 / 删除 / LLM 模型切换 / 管理标签 |
#### 3.2.2 项目信息卡 `[Phase 1]`
| 信息 / 功能 | 说明 |
|-------------|------|
| 当前项目名 | 显示当前工作项目 |
| 项目风格缩略图 | 当前绑定的风格集代表图 |
| 快捷入口 | → 风格库 / → 角色库 / → 项目设置 |
#### 3.2.3 侧栏折叠
- 汉堡菜单切换折叠/展开
- 移动端默认折叠,点击展开 + 遮罩覆盖
---
### 3.3 中间区:对话 + 任务区Main Area
#### 3.3.1 对话消息流
| 信息 / 功能 | 说明 |
|-------------|------|
| **用户消息气泡** | 文本 + 参考图缩略图(多图) |
| **助手消息气泡** | Markdown 文本 + 生成图片网格 |
| 流式文本 | SSE 流式渲染,逐字显示 |
| 图片网格ImageGrid | 候选图缩略图阵列,每张可:点击查看详情 / 下载 / 收藏 |
| 任务说明卡 `[Phase 1]` | Agent 理解到的结构化信息卡,显示:任务类型、风格、角色、输出规格、使用模板 |
| 工具执行状态 | 生图进行中的 Loading 状态、模型名称、进度 |
| 错误提示 | 工具调用失败时的错误信息展示 |
| 复制按钮 | 助手消息 hover 显示复制文本按钮 |
| 回到底部按钮 | 不在底部时浮动箭头,点击平滑滚动 |
#### 3.3.2 任务说明卡Task Card`[Phase 1]`
Agent 执行生图前,在对话流中插入一张结构化卡片,展示系统对用户意图的理解:
```
┌─────────────────────────────────────────┐
│ 📋 任务说明 │
│ │
│ 任务类型:角色立绘(全身) │
│ 风格anime_dark_fantasy_v1 │
│ 角色:剑士·艾丽卡 │
│ 输出规格1024×1536 · 透明背景可选 │
│ 模板character_consistent_v1 │
│ 候选数4 │
│ │
│ [ ✏️ 调整参数 ] [ ▶ 确认生成 ] │
│ │
└─────────────────────────────────────────┘
```
| 功能 | 说明 |
|------|------|
| 调整参数 | 展开编辑各字段,或打开右侧上下文面板的高级控制 |
| 确认生成 | 按当前参数执行生成 |
| 自动执行 | 模式 A 下用户不需要确认Agent 自动执行 |
#### 3.3.3 输入区ChatInput
| 信息 / 功能 | 说明 |
|-------------|------|
| 文本输入框 | 多行输入Enter 发送Shift+Enter 换行 |
| 参考图上传 | 点击按钮选图 / 拖拽到整个对话区域 / 粘贴图片 |
| 参考图预览 | 已上传参考图缩略图列表(多图),可单独删除 |
| 参考图沿用提示 | 上次发送的参考图可沿用,显示提示条 + 可清除 |
| 生图模型选择器 | 下拉选择当前生图模型Flux / SDXL / GPT Image 等) |
| 风格快选 `[Phase 1]` | 快捷选取当前风格,不用打字描述 |
| 角色快选 `[Phase 1]` | 快捷选取当前角色 |
| 资产规格快选 `[Phase 2]` | 选择输出类型Icon / 立绘 / 场景等),自动套用规格 |
| 发送按钮 | 涟漪动画反馈 |
---
### 3.4 右侧栏上下文面板Context Panel
右侧面板**默认收起**,用户可通过以下方式唤出:
- 点击图片网格中的某张图 → 打开**图片详情**
- 点击任务说明卡的"调整参数" → 打开**高级控制**
- 手动点击展开按钮
#### 3.4.1 图片详情模式Image Detail
| 信息 / 功能 | 说明 |
|-------------|------|
| 大图预览 | 缩放、平移查看 |
| Prompt 信息 | 生成时使用的提示词,可复制 |
| 模型信息 | 生成该图使用的模型名称 |
| 元数据 | 分辨率、生成时间、风格 ID、角色 ID |
| 下载 | 原图下载 |
| 收藏 | 收藏/取消收藏 |
| 删除 | 从资源库移除 |
| 标注Annotation | 进入标注画布:矩形 / 箭头 / 自由画笔 / 文字 |
| 标注回流 | 标注完成后将标注数据和截图回流到对话输入,辅助修改描述 |
| 以此为基础修改 | 将此图设为下一轮对话的参考图 |
| 局部编辑Inpaint`[Phase 1]` | 框选区域 + 描述修改意图,局部重绘 |
#### 3.4.2 高级控制模式Advanced Control`[Phase 1]`
| 信息 / 功能 | 说明 |
|-------------|------|
| Task IR 预览 | 当前任务的结构化 JSON 预览(只读或可编辑) |
| 模板选择器 | 从 Pipeline Registry 中选择/切换工作流模板 |
| 参数面板 | 模板暴露的可调参数(分辨率、候选数、一致性级别、控制类型等) |
| 结构控制图输入 | 上传 Pose / Canny / Depth 控制图 |
| 风格约束 | 当前绑定的风格集,可临时覆盖 |
| 角色约束 | 当前绑定的角色参考组,可临时覆盖 |
| 成本预估 | 当前参数下的预估成本/时间 |
| 应用 | 将调整后的参数应用到当前任务 |
#### 3.4.3 候选评估面板 `[Phase 2]`
| 信息 / 功能 | 说明 |
|-------------|------|
| 候选图排列 | 所有候选图并排对比 |
| 评分信息 | 语义匹配 / 风格相似度 / 角色一致性 / 规则检查 各项评分 |
| 自动推荐 | 系统推荐的最佳候选高亮 |
| 人工确认 | 用户选择采纳哪张,或全部重试 |
| 局部修补入口 | 对候选图做局部修补后再确认 |
---
## 4. 资源库 `/gallery`
### 4.1 整体布局
```
┌─────────────────────────────────────────────────────────┐
│ TopNav │
├──────────┬──────────────────────────────────────────────┤
│ 筛选栏 │ │
│ │ 资产网格 / 列表 │
│ Filter │ Asset Grid │
│ Panel │ │
│ │ │
│ ├──────────────────────────────────────────────┤
│ │ 资产详情(选中时) │
│ │ Asset Detail │
└──────────┴──────────────────────────────────────────────┘
```
### 4.2 筛选栏Filter Panel
| 筛选维度 | 说明 |
|----------|------|
| 搜索框 | 按 Prompt 文本 / 语义搜索 `[Phase 2: 语义搜索]` |
| 标签筛选 | 多标签 AND/OR 过滤 |
| 资产类型 | 角色立绘 / 道具 / 场景 / Icon / 参考图 / 控制图 等 |
| 收藏过滤 | 只看已收藏 |
| 模型筛选 | 按生成模型过滤 |
| 风格筛选 `[Phase 1]` | 按风格集过滤 |
| 角色筛选 `[Phase 1]` | 按角色过滤 |
| 日期范围 | 按生成时间过滤 |
| 来源会话 | 按生成时所在会话过滤 |
### 4.3 资产网格
| 信息 / 功能 | 说明 |
|-------------|------|
| 缩略图 | 预览图hover 显示基本信息 |
| 网格/列表切换 | 视图模式切换 |
| 排序 | 按时间 / 名称 / 收藏 排序 |
| 多选 | Checkbox 多选 → 批量下载 / 批量删除 / 批量打标签 |
| 拖拽选区 `[Phase 1]` | 框选多张资产 |
### 4.4 资产详情(选中某张图时)
复用工作台的图片详情组件,额外增加:
| 信息 / 功能 | 说明 |
|-------------|------|
| 来源信息 | 来自哪个会话、哪次对话 |
| 所属项目 `[Phase 1]` | 归属项目 |
| 标签编辑 | 添加/移除标签 |
| 版本历史 `[Phase 1]` | 该资产的编辑/修改版本链 |
| 导出 | 按规格导出(多尺寸 @1x @2x @3x`[Phase 2]` |
| 发送到工作台 | 将此图作为参考图发送回对话输入 |
### 4.5 跳转关系
| 操作 | 目标 |
|------|------|
| 点击缩略图 | → 展开资产详情(右侧面板或 Modal |
| "发送到工作台" | → `/`(工作台),自动填充为参考图 |
| 点击"来源会话" | → `/`(工作台),切换到对应会话 |
| 资产详情中"标注" | → 进入标注画布 |
---
## 5. 风格库 `/styles` `[Phase 1]`
### 5.1 风格库列表页
```
┌─────────────────────────────────────────────────────────┐
│ TopNav │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 风格卡片 │ │ 风格卡片 │ │ 风格卡片 │ │ + 新建 │ │
│ │ │ │ │ │ │ │ │ │
│ │ 代表图 │ │ 代表图 │ │ 代表图 │ │ │ │
│ │ 名称 │ │ 名称 │ │ 名称 │ │ │ │
│ │ 标签 │ │ 标签 │ │ 标签 │ │ │ │
│ │ 使用次数 │ │ 使用次数 │ │ 使用次数 │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
| 信息 / 功能 | 说明 |
|-------------|------|
| 风格卡片 | 代表图(风格参考集首图) + 名称 + 标签 + 使用计数 |
| 分类筛选 | 按标签分类(写实 / 二次元 / 像素 / 水墨 / 赛博 等) |
| 来源筛选 | 平台预设 / 项目自建 / 社区共享 |
| 新建风格 | → 进入风格编辑页 |
| 搜索 | 按名称/标签搜索 |
### 5.2 风格详情/编辑页 `/styles/[id]`
| 信息 / 功能 | 说明 |
|-------------|------|
| 风格名称 | 可编辑 |
| 风格描述 | 文字描述 + 关键词标签 |
| **风格参考集** | 3-10 张代表该风格核心特征的参考图,可增删替换 |
| 风格 LoRA | 绑定的 LoRA 模型(如有)`[Phase 2]` |
| 风格 Embedding | 自动计算的风格向量表示 `[Phase 2]` |
| Prompt 模板 | 该风格关联的正向/负向 Prompt 模板 |
| 预览效果 | 使用该风格生成的历史图片(从资源库自动聚合) |
| 使用统计 | 被多少会话/任务引用过 |
| Fork 来源 | 如果是从官方模板 Fork 的,显示来源 |
| 操作 | 应用到当前会话 / 复制 / Fork / 删除 |
### 5.3 跳转关系
| 操作 | 目标 |
|------|------|
| 点击风格卡片 | → `/styles/[id]`(风格详情) |
| "应用到当前会话" | → `/`(工作台),注入该风格到当前会话上下文 |
| "新建风格" | → `/styles/new`(风格编辑页) |
| "训练风格 LoRA" | → `/training/new?type=style&source=[id]` `[Phase 2]` |
---
## 6. 角色库 `/characters` `[Phase 1]`
### 6.1 角色库列表页
与风格库类似的卡片式布局。
| 信息 / 功能 | 说明 |
|-------------|------|
| 角色卡片 | 头像图 + 名称 + 简要描述 + 所属项目 |
| 分类 | 按项目分组,或自定义标签分类 |
| 新建角色 | → 进入角色编辑页 |
| 搜索 | 按名称/描述搜索 |
### 6.2 角色详情/编辑页 `/characters/[id]`
| 信息 / 功能 | 说明 |
|-------------|------|
| 角色名称 | 可编辑 |
| 角色描述元数据 | 性别、年龄范围、体型、性格关键词、背景故事摘要 |
| **角色参考组** | 多角度参考图集合:正面 / 半侧面 / 半身 / 表情变化 / 关键服饰细节 |
| 关键视觉要素 | 标注角色的识别性特征(发色、瞳色、标志性饰品、武器等) |
| 角色 LoRA | 绑定的 LoRA 模型(如有)`[Phase 2]` |
| 生成历史 | 使用该角色生成的所有图片(从资源库自动聚合) |
| 一致性级别 | 默认一致性要求(高/中/低) |
| 操作 | 应用到当前会话 / 复制 / 删除 |
### 6.3 跳转关系
| 操作 | 目标 |
|------|------|
| 点击角色卡片 | → `/characters/[id]`(角色详情) |
| "应用到当前会话" | → `/`(工作台),注入该角色到当前会话上下文 |
| "新建角色" | → `/characters/new`(角色编辑页) |
| "训练角色 LoRA" | → `/training/new?type=character&source=[id]` `[Phase 2]` |
| 参考图中某张图 | → 图片详情面板 |
---
## 7. 训练中心 `/training` `[Phase 2]`
### 7.1 训练任务列表页
```
┌─────────────────────────────────────────────────────────┐
│ TopNav │
├─────────────────────────────────────────────────────────┤
│ [ + 新建训练任务 ] │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 任务名称 类型 状态 进度 创建时间 │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ 暗黑幻想风格v2 风格LoRA 训练中 67% 04-16 │ │
│ │ 剑士·艾丽卡v1 角色LoRA 已完成 100% 04-15 │ │
│ │ 水墨武侠v1 风格LoRA 排队中 — 04-16 │ │
│ │ 枪手·马克v1 角色LoRA 已失败 — 04-14 │ │
│ └───────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
| 信息 / 功能 | 说明 |
|-------------|------|
| 任务列表 | 名称 + 类型 + 状态(排队/训练中/已完成/已失败/已弃用)+ 进度 + 时间 |
| 状态筛选 | 按状态过滤 |
| 新建训练任务 | → 训练向导流程 |
| 点击某任务 | → 训练任务详情页 |
### 7.2 新建训练任务(向导流程)
分步引导,降低用户门槛:
| 步骤 | 内容 |
|------|------|
| **Step 1: 选择类型** | 风格 LoRA / 角色 LoRA |
| **Step 2: 选择来源** | 从风格库/角色库中选择(自动带入参考组),或从资源库批量选图 |
| **Step 3: 审核训练集** | 展示所有候选图,自动质量检查(模糊 / 低分辨率 / 重复标记),用户确认/剔除 |
| **Step 4: 选择模板** | 选择训练模板SDXL LoRA / Flux LoRA 等),显示推荐参数 |
| **Step 5: 确认并提交** | 汇总信息 + 成本预估 → 提交训练 |
### 7.3 训练任务详情页 `/training/[id]`
| 信息 / 功能 | 说明 |
|-------------|------|
| 基本信息 | 名称、类型、来源风格/角色、创建时间 |
| 训练状态 | 进度条 + 当前 Step / Epoch |
| 训练日志 | 实时日志输出(可折叠) |
| 训练集预览 | 使用的图片缩略图列表 |
| **训练结果对比** | 训练前后的效果对比(同一 Prompt 下的生成对比图) |
| 模型版本 | 版本号 + 可设为当前使用版本 |
| 操作 | 使用该模型 / 回滚到上一版本 / 弃用 / 重新训练 |
### 7.4 跳转关系
| 操作 | 目标 |
|------|------|
| "使用该模型" | → `/`(工作台),自动绑定该 LoRA 到当前会话 |
| "查看来源风格" | → `/styles/[id]` |
| "查看来源角色" | → `/characters/[id]` |
| 训练集中某张图 | → 图片详情面板 |
---
## 8. 项目设置 `/settings` `[Phase 1]`
| 信息 / 功能 | 说明 |
|-------------|------|
| 项目名称 | 可编辑 |
| 项目描述 | 项目简介、类型2D RPG / 横版动作 / 卡牌 等) |
| 默认风格 | 项目级默认风格集绑定 |
| 默认输出规格 | 项目级默认分辨率、背景要求等 |
| 约束规则 | 项目级约束(如:只能使用指定风格、候选数上限、成本限制等) |
| 成员管理 `[Phase 3]` | 团队成员和权限 |
| 导出设置 | 默认导出格式、命名规则 |
| 危险区 | 归档项目 / 删除项目 |
---
## 9. 用户菜单(全局右上角下拉)
| 信息 / 功能 | 说明 |
|-------------|------|
| 用户名 + 头像 | 当前登录用户 |
| 个人信息 | 打开个人信息弹窗 |
| 记忆查看 | 打开 Mem0 记忆列表弹窗(按时间分组,只读) |
| 全局设置 `[Phase 2]` | API Key 配置、界面偏好、通知设置 |
| 退出登录 | 清除 Token跳转 `/login` |
---
## 10. 完整导航流程图
```
┌──────────┐
│ /login │
└────┬─────┘
│ 登录成功
┌──────────────────────────────┐
│ / (工作台) │
│ │
│ ┌────────┐ ┌──────────────┐ │
│ │ 侧栏 │ │ 对话 + 任务区 │ │
│ │ │ │ │ │
│ │ 会话列表 │ │ 消息流 │──── 点击生成图 ──→ 右侧面板:图片详情
│ │ │ │ │ │
│ │ 项目卡片 │ │ 任务说明卡 │──── 调整参数 ──→ 右侧面板:高级控制
│ │ │ │ │ │
│ │ │ │ 输入区 │ │
│ │ │ │ · 风格快选 ─┼──── 更多风格 ──→ /styles
│ │ │ │ · 角色快选 ─┼──── 更多角色 ──→ /characters
│ └────────┘ └──────────────┘ │
└──────────────┬───────────────────────────-┘
┌─────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌──────────┐ ┌──────────────┐
│ /gallery │ │ /styles │ │ /characters │
│ 资源库 │ │ 风格库 │ │ 角色库 │
│ │ │ │ │ │
│ 筛选+网格 │ │ 卡片列表 │ │ 卡片列表 │
│ │ │ │ │ │
│ 发送到工作台 ─┼→│ 应用到会话─┼→ │ 应用到会话 ──┼→ 回到 /
│ 查看来源会话 ─┼→│ 训练LoRA ─┼→ │ 训练LoRA ───┼→ /training/new
└──────┬─────┘ └─────┬────┘ └──────┬───────┘
│ │ │
│ ▼ ▼
│ ┌─────────────┐ ┌─────────────────┐
│ │/styles/[id] │ │/characters/[id] │
│ │ 风格详情/编辑 │ │ 角色详情/编辑 │
│ └─────────────┘ └─────────────────┘
┌──────────────┐
│ /training │
│ 训练中心 │
│ │
│ 任务列表 │
│ 新建→向导流程 │
│ │
└──────┬───────┘
┌──────────────────┐
│ /training/[id] │
│ 训练任务详情 │
│ │
│ 使用该模型 → 回到 / │
└──────────────────┘
```
---
## 11. 跨页面数据流转总结
以下是各页面之间的数据流转关系,说明资产和上下文如何在页面间传递:
| 起点 | 动作 | 终点 | 携带数据 |
|------|------|------|----------|
| 工作台 | 生成图片并沉淀 | 资源库 | `ImageAsset` 自动入库 |
| 资源库 | "发送到工作台" | 工作台 | 图片 URL 填入参考图输入 |
| 资源库 | "查看来源会话" | 工作台 | 切换到对应 `sessionId` |
| 工作台输入区 | "更多风格" | 风格库 | 当前项目 ID 作为筛选条件 |
| 风格库 | "应用到当前会话" | 工作台 | `styleId` + Prompt 模板 + 参考集注入会话上下文 |
| 风格库 | "训练风格 LoRA" | 训练中心 | 风格 ID + 参考集作为训练源 |
| 角色库 | "应用到当前会话" | 工作台 | `characterId` + 参考组注入会话上下文 |
| 角色库 | "训练角色 LoRA" | 训练中心 | 角色 ID + 参考组作为训练源 |
| 训练中心 | "使用该模型" | 工作台 | LoRA 模型 ID 绑定到当前会话 |
| 训练中心 | "查看来源" | 风格库/角色库 | 跳转到对应风格/角色详情 |
| 工作台(标注) | 标注完成 | 工作台输入区 | `AnnotationData` + snapshot 回流到 Prompt |
| 工作台(高级控制) | 调整 Task IR 参数 | 工作台对话流 | 更新后的 Task IR → 执行生成 |
---
## 12. 响应式策略
| 设备 | 布局适配 |
|------|----------|
| **桌面≥1280px** | 三栏布局完整展开:侧栏 + 对话区 + 右侧面板 |
| **平板768-1279px** | 侧栏可折叠,右侧面板改为 Overlay 浮层 |
| **手机(<768px** | 侧栏抽屉式,右侧面板改为全屏 Modal底部 Tab 导航替代顶部导航 |
### 手机端底部 Tab 导航
```
┌────────┬────────┬────────┬────────┐
│ 对话 │ 资源库 │ 风格库 │ 更多 │
└────────┴────────┴────────┴────────┘
```
"更多"展开:训练中心 / 项目设置 / 个人信息
---
## 13. 三层交互模式对照
方案文档定义了三层使用模式,对应到 UI 的映射:
| 模式 | 用户类型 | UI 表现 |
|------|---------|---------|
| **模式 A自然语言驱动** | 普通用户 | 只看到对话区和输入框Agent 自动处理一切,任务说明卡自动确认 |
| **模式 B引导式修订** | 中级用户 | 对话区 + 任务说明卡可展开查看,自然语言修改后 Agent 自动映射参数 |
| **模式 C高级控制** | 专业用户 | 右侧面板展开高级控制,可直接编辑 Task IR、切换模板、上传控制图 |
用户不需要显式"切换模式"——三层是**渐进式暴露**的:
1. 默认只看到对话框 → 模式 A
2. 想了解 Agent 在做什么 → 点开任务说明卡 → 模式 B
3. 想精细控制 → 点开高级控制面板 → 模式 C
---
## 14. Phase 0 → 最终形态的演进路径
| 阶段 | 新增页面 / 模块 | 关键变化 |
|------|----------------|----------|
| **Phase 0** | `/login``/`(对话+图片详情+标注)、`/gallery` | 当前状态:对话生图 + 资源库 + 用户系统 |
| **Phase 1** | `/styles``/characters``/settings`;工作台增加任务说明卡 + 高级控制面板 + 输入区快选 | 引入项目概念、风格系统、角色系统、Task IR 可视化 |
| **Phase 2** | `/training`;资源库增加语义搜索和多尺寸导出;工作台增加候选评估面板 | 训练能力产品化、评估层可视化、资产规格系统 |
| **Phase 3** | 扩展 3D / 动画 / 音频的任务类型和资产类型 | 工作台支持新任务类型,资源库支持新资产类型,训练中心支持新模型类型 |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,417 @@
# EPEEKit 交互体验指南
> 设计语言:**青绿山水 · 科技幽谷**
> 核心意境:深山幽谷中的科技圣地 — 自然的宁静与数字的精密共存
> 更新日期2026-04-16
## 实现状态说明
每个特性标注实现状态:
-**已实现** — 代码完整落地,与文档描述一致
- 🔶 **基本实现** — 核心功能已落地,细节有微小偏差(不影响体验)
-**未实现** — 仅有文档描述,无对应代码
---
## 1. 设计哲学
每一次交互都是一次"与山水对话"
| 操作 | 意象 | 动效语言 | 状态 |
|------|------|----------|------|
| 发送消息 | 投石入水 | 按钮弹缩 → 气泡飞入 → 涟漪扩散 | ✅ |
| AI 回复 | 云雾凝聚 | 模糊→清晰 → 文字逐行显现 | ✅ |
| 收藏图片 | 萤火点亮 | 星标亮起 → 金色粒子飘散 | ✅ |
| 滚动浏览 | 穿行雾中 | 内容在雾气边缘隐现 | ✅ |
| 等待加载 | 溪水潺潺 | 石青流光 + 水波纹脉冲 | ✅ |
| 图片揭示 | 水墨晕染 | clip-path 圆形从中心扩展 | ✅ |
| 会话切换 | 翻山越岭 | 内容滑出/滑入 | ✅ |
科技感不通过赛博朋克的锐利线条传递,而是通过"精密点阵 × 自然粒子"的融合 —
就像在深山中发现了一座用水晶和苔藓构建的数据中心。
---
## 2. 色彩体系 ✅
### 主色调
| 语义 | 变量名 | 色值 | 色彩隐喻 | 状态 |
|------|--------|------|---------|------|
| 背景主色 | `--bg-primary` | `#0C1210` | 深山暗处 | ✅ |
| 背景次级 | `--bg-secondary` | `#131E1A` | 苔藓暗绿 / 山石基底 | ✅ |
| 背景三级 | `--bg-tertiary` | `#1C2A24` | 松烟绿 / 远山中层 | ✅ |
| 卡片表面 | `--bg-card` | `rgba(16, 30, 24, 0.65)` | 半透明山岩 | ✅ |
| 主高亮色 | `--accent` | `#4DB8A4` | 石青绿 — 千里江山图的石青 | ✅ |
| 高亮 hover | `--accent-hover` | `#6CD4BE` | 石青亮态 | ✅ |
| 辅助高亮 | `--accent-secondary` | `#3A8FB7` | 石绿蓝 — 千里江山图的石绿 | ✅ |
| 热门/警告 | `--hot` | `#C4654A` | 赭石红 — 点景人物/宫殿暖色 | ✅ |
| 文字主色 | `--text-primary` | `#E0E8E2` | 微泛绿的月白 | ✅ |
| 文字次级 | `--text-secondary` | `#7A9485` | 青灰绿 | ✅ |
| 边框色 | `--border` | `rgba(77, 184, 164, 0.12)` | 石青半透明 | ✅ |
| 发光边框 | `--border-glow` | `rgba(77, 184, 164, 0.25)` | 石青发光态 | ✅ |
| 雾气基础 | `--mist` | `rgba(77, 184, 164, 0.06)` | 云雾底色 | ✅ |
| 赭金点缀 | `--gold` | `#B8935A` | 收藏星标等极少量暖色 | ✅ |
### 色彩来源
石青(`#4DB8A4`)和石绿蓝(`#3A8FB7`)取自《千里江山图》中使用的矿物质颜料色,
经现代演绎降低饱和度,在深色 UI 中不过于刺眼。
赭石红(`#C4654A`)来源于画中点景人物和宫殿使用的暖色颜料。
**实现文件**: `globals.css` `:root` 第 4-18 行
---
## 3. 信息层级体系
### 3.1 排版重量体系7 级) ✅
| 层级 | CSS 类 | 大小 | 字重 | 字间距 | 视觉手段 | 状态 |
|------|--------|------|------|--------|----------|------|
| Display | `.typo-display` | 32px | 300 | -0.02em | 石青→石绿蓝渐变填色 | ✅ |
| H1 | `.typo-h1` | 24px | 700 | -0.01em | 底部石青细线装饰 | ✅ |
| H2 | `.typo-h2` | 18px | 600 | 0 | 左侧 3px 石青竖线标记 | ✅ |
| Body | 默认 | 14px | 400 | 0.01em | 标准 | ✅ |
| Body Strong | `.typo-strong` | 14px | 600 | 0 | 石青色 | ✅ |
| Caption | `.typo-caption` | 12px | 400 | 0.02em | text-secondary | ✅ |
| Micro | `.typo-micro` | 11px | 600 | 0.04em | 大写 + 加宽字间距 | ✅ |
关键区分手段Display 使用渐变文字而非纯色H1 与 H2 各有独立视觉标记(底线 vs 竖线),
不仅靠字号大小区分层级。
**实现文件**: `globals.css` 第 33-102 行
**使用位置**: `typo-display`(欢迎页/登录页)、`typo-h1`ProfileModal 用户名)、`typo-h2`Gallery 详情标题)、`typo-strong`(侧栏/Gallery 区域标题、ProfileModal 记忆标题)、`typo-caption`(时间戳/记忆条数)、`typo-micro`(标签/分组标签/New 标记)
### 3.2 表面层级系统Surface Elevation
4 级表面,每升一级 backdrop-blur +8px、border 亮度 +0.06、box-shadow 扩展 +4px
| 层级 | CSS 类 | 用途 | 隐喻 | 状态 |
|------|--------|------|------|------|
| Surface-0 | — | 页面背景 `#0C1210` | 深渊 | ✅ |
| Surface-1 | `.surface-1` | 侧栏/主区域 | 山脚bg-secondary + 微弱内发光) | ✅ |
| Surface-2 | `.surface-2` | 卡片/消息气泡 | 山腰glass-panel + 更强模糊 + 微上移阴影) | ✅ |
| Surface-3 | `.surface-3` | 弹窗/下拉/tooltip | 山顶(强模糊 + 明显阴影 + 更亮边框) | ✅ |
**实现文件**: `globals.css` 第 106-133 行
**使用位置**: 侧栏surface-1、消息气泡/回到底部按钮surface-2、下拉菜单/弹窗surface-3
> 注:文档定义的 `.glass-panel` 与 `.surface-2` 视觉上等价,实际组件统一使用 surface-* 系列。`glass-panel` 保留为兼容。
### 3.3 用户消息 vs 助手消息 ✅
| 维度 | 用户消息 | 助手消息 | 状态 |
|------|----------|----------|------|
| 对齐 | 右对齐 | 左对齐 | ✅ |
| 背景 | 石青渐变accent/8 → accent/15 | glass-panelSurface-2 | ✅ |
| 圆角 | 左上方切角(几何感) | 标准圆角 | ✅ |
| 边框 | 无 | 左侧 2px 石青竖线(山泉流水) | ✅ |
| 入场动画 | 投石入水(从下方飞入) | 云雾凝聚blur 消散) | ✅ |
**实现文件**: `chat-messages.tsx` 第 69-74 行、`globals.css` `.user-bubble`
---
## 4. 科技质感
### 4.1 环境粒子Ambient Particles
背景云雾之上叠加"深山萤火"粒子层:
- 数量15-20 个小光点
- 颜色石青色opacity 0.15-0.3
- 行为:缓慢随机漂浮 + 偶尔闪烁opacity 脉冲)
- 实现React 组件 + useMemo 随机化 + CSS animation
- 空态时粒子增多至 ~28 个,营造"未探索的深山"感
**实现文件**: `components/ui/ambient-particles.tsx`
**接入位置**: `page.tsx`首页count 根据空态切换)、`login/page.tsx``gallery/page.tsx`
### 4.2 网格底纹Subtle Grid
Surface-0 背景叠加极淡的点阵网格opacity: 0.03
几乎不可见但传递"精密、数字化"的潜意识感知,与云雾层形成"自然 × 科技"张力。
**实现文件**: `globals.css` `body::before` 第 136-145 行
### 4.3 数据流光Data Stream
AI 思考/生成时,输入栏上方出现一条横向流光:
- 石青色光带从左向右流动,高度 2px
- 思考时慢速、生成图片时加速
- 意象:山间溪流 / 数据脉搏
**实现文件**: `components/ui/data-stream.tsx``globals.css` `.data-stream` / `-slow` / `-fast`
**接入位置**: `chat-input.tsx` 第 202 行
---
## 5. 微交互图鉴
所有微交互使用 framer-motion 实现,统一动画参数定义在 `motion-presets.ts` 中。
### 5.1 发送消息 — "投石入水" ✅
1. 发送按钮 `scale(0.85)` → spring 弹回(`sendBounce`
2. 消息气泡从输入栏位置飞入对话区translateY + opacity
3. 到达目标位置时涟漪扩散(`RippleLayer` 石青色圆环 scale + fade out
4. 输入栏清空后边框发光脉冲一次(水面恢复平静)
**实现文件**: `chat-input.tsx`sendBounce + useRipple`ripple-effect.tsx``motion-presets.ts`
### 5.2 AI 回复 — "云雾凝聚" ✅
1. 消息气泡 `blur(8px)``blur(0)` 凝聚显现
2. 文字逐行显示时 opacity 阶梯(新行更亮,旧行渐稳)
3. 图片结果:石青色模糊占位 → blur 消散 + scale 1.02→1.0(拨开云雾)
**实现文件**: `motion-presets.ts` `assistantEnter``chat-messages.tsx` `StreamingLines` 组件
### 5.3 收藏图片 — "萤火点亮" ✅
1. 星标 outline → filled赭金色
2. 点击瞬间散出 5-7 个微小金色粒子(萤火被惊起)
3. 粒子向四周飘散并淡出stagger + random 偏移)
**实现文件**: `components/ui/firefly-burst.tsx``image-grid.tsx` `FavoriteButton`
### 5.4 图片加载 — "水墨晕染" ✅
1. 占位:石青渐变块 + 呼吸脉冲(`.stone-placeholder`
2. 加载完成:`clip-path: circle()` 从中心扩展到全图(水墨滴入纸面)
**实现文件**: `globals.css` `.stone-placeholder``motion-presets.ts` `inkReveal``image-grid.tsx` `ImageReveal`
### 5.5 输入框 — "水面感应" ✅
- 空态:底部淡石青线
- 获焦:边框从中心向两端扩展点亮(水面波纹展开)
- 输入时:边框亮度随文字长度微增(蓄力)
- 可发送态:发送按钮缓慢脉冲发光(准备就绪)
**实现文件**: `globals.css` `.water-focus-line` / `.water-focus-active``chat-input.tsx` boxShadow 联动
### 5.6 Hover 状态 ✅
- 按钮:`translateY(-1px)` + shadow 加深(浮起感)
- 卡片border-glow + 底部向上内发光渐变
- 侧栏会话项:左侧 2px 石青竖线 slide-in
**实现文件**: `globals.css` `.btn-hover-lift`(第 466-472 行)、`.neon-border`(第 195-204 行)、`.session-hover-line`(第 474-493 行)
### 5.7 滚动到底部 — "落叶归根" ✅
- 形状:水滴形(椭圆切割 border-radius
- 出现spring 从下方弹入
- 石青半透明背景 + 雾气边框
**实现文件**: `page.tsx` scroll-btn、`motion-presets.ts` `scrollBtnEnter`
### 5.8 会话切换 — "翻山越岭" ✅
- 当前对话向上/向下滑出
- 新对话从对应方向滑入
- 过渡 250ms ease-out
- 使用 `AnimatePresence mode="wait"` + `sessionSlide` 变体
**实现文件**: `page.tsx` AnimatePresence 包裹聊天内容区、`motion-presets.ts` `sessionSlide`
---
## 6. 状态与反馈
### 6.1 Loading 体系 ✅
| 场景 | 表现 | 状态 |
|------|------|------|
| 骨架屏 | shimmer 渐变bg-tertiary → bg-secondary左→右扫描 | ✅ |
| 内联加载 | 三个石青圆点"水波纹"式依次涌起scale Y 变化) | ✅ |
| 全屏加载 | 中心放射状云雾凝聚(涟漪环 + 核心光点 + 呼吸脉冲) | ✅ |
| AI 生成中 | 数据流光(输入栏上方 2px 流动光带) | ✅ |
**实现文件**: `globals.css` `.skeleton-shimmer``.wave-dot``.mist-loader``.data-stream`
**接入位置**: `image-grid.tsx``chat-messages.tsx``app-context.tsx``chat-input.tsx`
### 6.2 空态设计 ✅
- Display 级渐变文字欢迎语
- "快速开始"建议卡片stagger 入场)
- 萤火粒子增多营造氛围
**实现文件**: `page.tsx` isEmptyState 分支
### 6.3 Error 状态 ✅
- 赭石红色调但保持优雅(非大红色块)
- 微弱横向震动translateX ±2px2 次)
- 自动消失时向上飘散淡出(`errorFloat` 变体 + 4s 定时清除)
**实现文件**: `globals.css` `.error-shake``motion-presets.ts` `errorFloat``login/page.tsx`
---
## 7. 动效语言
### 7.1 时序原则 ✅
| 类别 | 时长 | 缓动 |
|------|------|------|
| 即时反馈hover/press | 150-200ms | ease |
| 状态过渡(展开/折叠) | 250ms | ease-in-out |
| 云烟效果(淡入/淡出) | 400-600ms | ease |
| 环境动效(背景漂移) | 45-60s | ease-in-out infinite |
| Spring 物理(弹缩) | stiffness: 400, damping: 25 | spring |
### 7.2 编排原则 ✅
- **先快后慢**:触发瞬间最快,收尾最慢(模拟自然物理)
- **层次递进**:多元素入场使用 stagger50-80ms 间隔),从近到远
- **克制优先**微交互幅度小1-3px 位移、0.95-1.05 缩放),避免分散注意力
- **自然衰减**:所有粒子效果都应以 opacity → 0 结束,不突兀消失
### 7.3 统一动画参数motion-presets.ts
```typescript
// Spring 物理参数
spring: { stiffness: 400, damping: 25 }
springGentle: { stiffness: 200, damping: 20 }
// 入场动画
fadeIn: { opacity: [0, 1], transition: { duration: 0.2 } }
slideUp: { opacity: [0, 1], y: [8, 0], transition: { duration: 0.2 } }
mistReveal: { filter: ["blur(8px)", "blur(0px)"], opacity: [0, 1], duration: 0.4 }
// 按钮反馈
buttonTap: { scale: 0.85 }
sendBounce: { scale: [1, 0.85, 1.05, 1], transition: { duration: 0.3 } }
// 涟漪/粒子
ripple: { scale: [0, 2.5], opacity: [0.4, 0], duration: 0.6 }
sparkle: { scale: [0, 1, 0], opacity: [0, 1, 0], duration: 0.8 }
// 会话切换
sessionSlide: { enter center exit, y ±30, duration: 0.25 }
// Error 飘散
errorFloat: { exit opacity: 0, y: -12, duration: 0.5 }
```
**实现文件**: `components/ui/motion-presets.ts`
---
## 8. 视觉效果
### 8.1 山雾玻璃面板 ✅
```css
.glass-panel {
background:
linear-gradient(165deg, rgba(16, 30, 24, 0.15) 0%, transparent 50%),
rgba(16, 30, 24, 0.55);
backdrop-filter: blur(24px) saturate(1.1);
border: 1px solid var(--border);
}
```
**实现文件**: `globals.css` 第 185-192 行(定义)
> 注:组件统一使用 `.surface-2`(等价视觉),`.glass-panel` 保留为兼容别名。
### 8.2 云烟缭绕边框 ✅
选中态组件的动态边框:
- 常态:石青微弱均匀静态发光
- hoverconic-gradient 6s 缓慢旋转40-50% 宽弧段),模拟云烟缭绕
- 移开opacity 600ms 平滑淡散
**实现文件**: `globals.css` `.glow-border` + `@property --glow-angle` + `mistSpin`(第 206-285 行)
**使用位置**: `top-nav.tsx` 活跃 Tab、`session-list.tsx` 当前会话
### 8.3 山雾背景 ✅
多层缓慢漂移云雾(远景层 60s / 近景层 45s
两层不同速率和方向模拟远山/近山视差。
**实现文件**: `globals.css` `.fog-layer-far` / `.fog-layer-near`(第 162-182 行)
**挂载位置**: `ambient-particles.tsx` 组件内渲染两个雾层 div
### 8.4 雾气滚动边缘 ✅
主要滚动区域顶底渐变遮罩36px 渐变区域),内容消失在云雾中。
**实现文件**: `globals.css` `.fog-scroll`(第 288-303 行)
**使用位置**: `page.tsx` 对话滚动区、`session-list.tsx` 会话列表
---
## 9. 布局架构
### 整体结构 ✅
```
┌───────────────────────────────────────────────────────┐
│ Top Nav (56px) — Surface-1 + 毛玻璃 │
├──────────┬────────────────────────────────────────────┤
│ 侧边栏 │ 主内容区(对话 / 资源库) │
│ (280px) │ (flex-1) │
│ Surface-1 │ ┌──────────────────────────────────────┐ │
│ 会话列表 │ │ 对话消息 / 图片网格 │ │
│ 标签筛选 │ │ (fog-scroll 雾气边缘) │ │
│ │ └──────────────────────────────────────┘ │
│ │ 数据流光条AI 活动时可见) │
│ │ 输入区 — 水面感应 │
└──────────┴────────────────────────────────────────────┘
```
**实现文件**: `page.tsx`(首页布局)
### 间距系统 ✅
- 面板间间距0紧贴用边框分隔
- 卡片网格间距16px
- 内容区内边距20px
- 面板内边距16px
### 响应式断点 ✅
| 断点 | 布局 |
|------|------|
| ≥768px (md) | 双栏:侧边栏(280px) + 主内容区(flex-1) |
| <768px | 单栏:侧边栏为固定抽屉 + 主内容区全宽 |
**实现文件**: `sidebar.tsx`md 断点切换固定/内联)、`app-context.tsx``window.innerWidth < 768` 初始折叠)
---
## 10. 技术实现
### 依赖
- `framer-motion`微交互动画引擎spring 物理、stagger、AnimatePresence
- `tailwindcss v4`:工具类样式
- CSS 自定义属性:设计 token
- CSS `@property`:可动画角度属性(云烟边框)
### 文件结构
```
src/
├── components/ui/
│ ├── ambient-particles.tsx — 环境萤火粒子 ✅
│ ├── data-stream.tsx — 数据流光条 ✅
│ ├── ripple-effect.tsx — 涟漪效果 ✅
│ ├── firefly-burst.tsx — 萤火粒子爆发 ✅
│ └── motion-presets.ts — 统一动画参数 ✅
├── app/globals.css — 设计 token + 工具类 ✅
└── ...existing components — 集成微交互
```
### 性能约束
- 环境粒子使用 React + CSS animation不引入 Canvas
- 所有动画使用 transform/opacity避免触发 layout
- 粒子总数不超过 30 个
- 背景动效使用 `will-change: transform` 提示 GPU 加速
- framer-motion 使用 `layout` 动画时限制在必要组件

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

View File

@@ -1,22 +1,24 @@
# EPEEKit 视觉风格指南
> 设计语言:**青绿山水 · 现代演绎**(灵感来源于《千里江山图》)
> 更新日期2026-04-15
> 设计语言:**白底青绿 · 绢本山水**(灵感来源于《千里江山图》)
> 更新日期2026-04-16
## 1. 设计语言总结
### 核心风格关键词
- **墨绿深底** — 深山暗处般的底色,取代冷蓝黑
- **石青石绿** — 千里江山图中的矿物质颜料色,作为主高亮
- **绢本白底** — 画卷绢纸的微暖白色,取代暗色系背景
- **石青石绿** — 千里江山图中的矿物质颜料色,用于框体边框和高亮
- **云烟雾气** — 所有发光和动画效果模拟山间云烟缓慢飘散
- **山雾玻璃** — 毛玻璃面板带微弱绿底色,如透过薄雾看山
- **白纱玻璃** — 白色半透明毛玻璃面板,如薄纱覆于绢面
- **赭金点缀** — 点景人物宫殿的暖色调,用于收藏星标等少量强调
- **墨色文字** — 正文使用接近墨黑色,清晰可读
### 视觉感受
> 整体氛围应该是:深夜远眺层峦叠嶂,薄雾在山间缓缓流动
> 石青石绿的山色在暗底上若隐若现,偶有赭金灯火点缀其间。
> 整体氛围应该是:白色绢底铺展,青绿山色点缀于框体和光效之中
> 文字如墨书于纸上,偶有赭金灯火点缀其间。
> 像在白昼中远眺千里江山,青绿分明而不压迫。
---
@@ -26,27 +28,34 @@
| 语义 | 变量名 | 色值 | 色彩隐喻 |
|------|--------|------|---------|
| 背景主色 | `--bg-primary` | `#0C1210` | 深山暗处 |
| 背景次级 | `--bg-secondary` | `#131E1A` | 苔藓暗绿 / 山石基底 |
| 背景三级 | `--bg-tertiary` | `#1C2A24` | 松烟绿 / 远山中层 |
| 卡片表面 | `--bg-card` | `rgba(16, 30, 24, 0.65)` | 半透明山岩 |
| 主高亮色 | `--accent` | `#4DB8A4` | 石青绿 — 千里江山图的石青 |
| 高亮 hover | `--accent-hover` | `#6CD4BE` | 石青亮态 |
| 辅助高亮 | `--accent-secondary` | `#3A8FB7` | 石绿蓝 — 千里江山图的石绿 |
| 背景主色 | `--bg-primary` | `#FAFAF7` | 绢本微暖白 |
| 背景次级 | `--bg-secondary` | `#F2F1EC` | 绢纸淡米色 |
| 背景三级 | `--bg-tertiary` | `#E8E6DF` | 染旧的纸色 |
| 卡片表面 | `--bg-card` | `rgba(255, 255, 255, 0.8)` | 白纱覆面 |
| 主高亮色 | `--accent` | `#2E8B7A` | 石青 — 千里江山图的石青 |
| 高亮 hover | `--accent-hover` | `#3AA38E` | 石青亮态 |
| 辅助高亮 | `--accent-secondary` | `#3A7FB8` | 石绿蓝 — 千里江山图的石绿 |
| 热门/警告 | `--hot` | `#C4654A` | 赭石红 — 点景人物/宫殿暖色 |
| 文字主色 | `--text-primary` | `#E0E8E2` | 微泛绿的月白 |
| 文字次级 | `--text-secondary` | `#7A9485` | 青灰绿 |
| 边框色 | `--border` | `rgba(77, 184, 164, 0.12)` | 石青半透明 |
| 发光边框 | `--border-glow` | `rgba(77, 184, 164, 0.25)` | 石青发光态 |
| 雾气基础 | `--mist` | `rgba(77, 184, 164, 0.06)` | 云雾底色 |
| 文字主色 | `--text-primary` | `#1A1A1A` | 墨黑 |
| 文字次级 | `--text-secondary` | `#6B7B8A` | 灰蓝 |
| 边框色 | `--border` | `rgba(46, 139, 122, 0.15)` | 石青半透明 |
| 发光边框 | `--border-glow` | `rgba(46, 139, 122, 0.30)` | 石青发光态 |
| 雾气基础 | `--mist` | `rgba(46, 139, 122, 0.04)` | 淡雅云雾 |
| 赭金点缀 | `--gold` | `#B8935A` | 用于收藏星标等极少量暖色 |
### 色彩来源说明
石青(`#4DB8A4`)和石绿蓝(`#3A8FB7`)取自《千里江山图》中使用的矿物质颜料色,
经过现代演绎降低饱和度,使其在深色 UI 中不会过于刺眼
石青(`#2E8B7A`)和石绿蓝(`#3A7FB8`)取自《千里江山图》中使用的矿物质颜料色,
经过适度加深以确保在白底 UI 上对比度足够、清晰可辨
赭石红(`#C4654A`)来源于画中点景人物和宫殿使用的暖色颜料。
### 与旧版(暗绿底)的关键差异
真正的千里江山图是**青绿设色于绢本白底之上**——山体的石青石绿占据画面,
但大面积留白(云、水、天际)给了青绿色块呼吸感。旧版将背景设为墨绿深色,
导致全屏绿色信息量过载,层次反而被压扁。新版回归绢本白底,让青绿色作为
框体和高亮的点缀,更贴近原画精髓。
---
## 3. 排版
@@ -75,7 +84,7 @@ font-family: 'Inter', system-ui, -apple-system, sans-serif;
```
┌─────────────────────────────────────────────────────┐
│ Top Nav (56px) — 山雾玻璃面板 │
│ Top Nav (56px) — 白纱玻璃面板 │
├──────────┬──────────────────────────────────────────┤
│ 侧边栏 │ 主内容区(对话 / 资源库) │
│ (280px) │ (flex-1) │
@@ -100,23 +109,23 @@ font-family: 'Inter', system-ui, -apple-system, sans-serif;
### 5.1 导航栏
- 高度 56px背景 `--bg-secondary`/80 + 毛玻璃
- Logo 方块使用 `--accent` 背景 + 石青发光阴影
- 高度 56px背景 `--bg-secondary` + 毛玻璃
- Logo 方块使用 `--accent` 背景 + 白色文字 + 石青发光阴影
- 导航项带图标 + 文字
- 活动项使用 `.glow-border`(云烟边框)
- 非活动项 hover 时只显示静态边框,不触发动画
### 5.2 侧边栏
- 山雾玻璃背景:`bg-[var(--bg-secondary)]/80 backdrop-blur-xl`
- 白纱玻璃背景:`bg-[var(--bg-secondary)] backdrop-blur-xl`
- 对话图标带石青色 drop-shadow 微光
- 会话列表使用 `.fog-scroll` 实现顶底雾气渐隐
- 活动会话使用 `.glow-border`
### 5.3 聊天消息区
- 用户消息:`--accent/15` 背景 + 石青边框
- 助手消息:`.glass-panel`山雾玻璃)
- 用户消息:`--accent/8` ~ `--accent/15` 渐变背景,左上切角,墨色文字
- 助手消息:`.surface-2`白纱玻璃)+ 石青左边框
- 加载动画:石青色脉冲圆点
- 滚动区域使用 `.fog-scroll`
@@ -129,39 +138,36 @@ font-family: 'Inter', system-ui, -apple-system, sans-serif;
### 5.5 图片详情面板
- 山雾玻璃背景:`--bg-secondary/90 backdrop-blur-xl`
- 白纱玻璃背景:`--bg-secondary/90 backdrop-blur-xl`
- 操作按钮区:石青主色 + 三级背景辅助色
---
## 6. 视觉效果
### 6.1 山雾玻璃面板
### 6.1 白纱玻璃面板
```css
.glass-panel {
background:
linear-gradient(165deg, rgba(16, 30, 24, 0.15) 0%, transparent 50%),
rgba(16, 30, 24, 0.55);
backdrop-filter: blur(24px) saturate(1.1);
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(20px) saturate(1.1);
border: 1px solid var(--border);
}
```
相比传统 Glassmorphism增加了微弱绿底色渐变层
模拟透过薄雾看山石的质感。blur 增加到 24px 以加强朦胧感。
白色半透明底 + 毛玻璃模糊,模拟薄纱覆于绢面的质感。
### 6.2 静态发光边框
```css
.neon-border {
border: 1px solid var(--border); /* rgba(77, 184, 164, 0.12) */
border: 1px solid var(--border); /* rgba(46, 139, 122, 0.15) */
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.neon-border:hover {
border-color: var(--border-glow); /* rgba(77, 184, 164, 0.25) */
box-shadow: 0 0 18px rgba(77, 184, 164, 0.08),
inset 0 0 12px rgba(77, 184, 164, 0.03);
border-color: var(--border-glow); /* rgba(46, 139, 122, 0.30) */
box-shadow: 0 0 18px rgba(46, 139, 122, 0.08),
inset 0 0 12px rgba(46, 139, 122, 0.03);
}
```
@@ -178,16 +184,16 @@ font-family: 'Inter', system-ui, -apple-system, sans-serif;
**技术实现**
- `@property --glow-angle` 注册可动画角度属性
- `conic-gradient` 使用 40-50% 的宽弧段(比之前的 20% 更宽),更柔和
- 旋转周期 6s(比之前的 3s 更慢),模拟云烟的悠缓
- `conic-gradient` 使用 40-50% 的宽弧段,更柔和
- 旋转周期 6s模拟云烟的悠缓
- `::before` — 云烟边框层mask-composite 裁出 1.5px 边框
- `::after` — 雾气扩散层,`blur(12px)`(比之前的 6px 更大),范围 `inset: -6px`
- `::after` — 雾气扩散层,`blur(12px)`
- 两层伪元素默认 `opacity: 0`hover 时 `opacity: 1`transition 600ms
```css
.glow-border {
border: 1px solid rgba(77, 184, 164, 0.18);
box-shadow: 0 0 10px rgba(77, 184, 164, 0.05);
border: 1px solid rgba(46, 139, 122, 0.20);
box-shadow: 0 0 10px rgba(46, 139, 122, 0.06);
}
.glow-border::before,
.glow-border::after {
@@ -203,31 +209,31 @@ font-family: 'Inter', system-ui, -apple-system, sans-serif;
### 6.3 山雾背景
背景不再使用静态光斑,改为多层缓慢漂移的云雾
背景使用多层缓慢漂移的淡雅云雾(极低透明度,在白底上若隐若现)
```css
/* 远景雾 — 60s 缓慢漂移 */
body::before {
.fog-layer-far {
inset: -10%;
background:
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(77, 184, 164, 0.07) ...),
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 143, 183, 0.05) ...),
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(77, 184, 164, 0.03) ...);
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(46, 139, 122, 0.04) ...),
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 127, 184, 0.03) ...),
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(46, 139, 122, 0.02) ...);
animation: fogDriftFar 60s ease-in-out infinite;
}
/* 近景雾 — 45s 不同速率漂移 */
body::after {
.fog-layer-near {
inset: -15%;
background:
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(77, 184, 164, 0.06) ...),
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 143, 183, 0.04) ...);
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(46, 139, 122, 0.03) ...),
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 127, 184, 0.025) ...);
animation: fogDriftNear 45s ease-in-out infinite;
}
```
两层雾使用不同的漂移速率和方向,模拟远山和近山的视差效果。
`inset` 使用负值以避免雾气漂移时在屏幕边缘露出空白
透明度远低于暗色版本,在白底上呈现为淡淡的青绿色氤氲
### 6.4 雾气滚动边缘
@@ -258,20 +264,18 @@ body::after {
---
## 7. 与原版 Cyberpunk 风格的对比
## 7. 与旧版(暗绿底 Cyberpunk 风格的对比
| 维度 | Cyberpunk | 青绿山水 |
|------|-----------|---------|
| 底色 | 冷蓝`#0B0E14` | 墨绿黑 `#0C1210` |
| 高亮色 | 霓虹绿 `#00E5A0` | 石青绿 `#4DB8A4` |
| 发光效果 | 锐利霓虹光线旋转 | 柔和云烟缓慢缭绕 |
| 旋转速度 | 3s/圈 | 6s/圈 |
| 光晕模糊 | blur(6px) | blur(12px) |
| 渐变弧段 | 20%(锐利光束) | 40-50%(弥漫雾团) |
| 背景 | 静态双色光斑 | 多层漂移云雾 |
| 面板质感 | 标准毛玻璃 | 山雾玻璃(绿底渐变) |
| 滚动边缘 | 无 | 雾气渐隐 |
| 警告色 | 霓虹红 `#FF3B5C` | 赭石红 `#C4654A` |
| 维度 | 旧版暗绿 | 新版白底青绿 |
|------|----------|------------|
| 底色 | 墨绿`#0C1210` | 绢本白 `#FAFAF7` |
| 高亮色 | 石青 `#4DB8A4` | 石青 `#2E8B7A`(加深适配白底) |
| 文字色 | 微绿月白 `#E0E8E2` | 墨黑 `#1A1A1A` |
| 面板质感 | 绿底毛玻璃 | 白色毛玻璃 |
| 背景雾气 | 中等透明度(暗底上可见) | 极低透明度(白底上若隐若现) |
| 发光效果 | 柔和云烟缓慢缭绕 | 保持,但阴影更轻柔 |
| 整体感受 | 深夜远山 | 白昼绢本 |
| 可读性 | 浅色字暗底OK | 墨色字白底,更佳 |
---
@@ -286,8 +290,9 @@ body::after {
## 9. 设计哲学
本风格将中国传统青绿山水画的色彩语言与现代色 UI 设计融合:
- **沉浸感**:深墨底色营造夜间远山的静谧氛围,适合创意工具
本风格将中国传统青绿山水画的色彩语言与现代色 UI 设计融合:
- **绢本留白**:白底如绢纸铺展,给青绿色块留出呼吸空间
- **青绿设色**:石青石绿只用于框体边框、高亮和交互反馈,克制而鲜明
- **自然动效**:所有动画模拟自然现象(云烟飘动),而非机械闪烁
- **克制用色**:石青石绿只用于高亮和交互反馈,不滥用;赭金极少量点缀
- **墨色可读**:正文使用接近纯黑的墨色,在白底上对比度极佳
- **雾气意境**:从边框云烟到背景漂移到滚动渐隐,统一的"雾"意象贯穿全站