Compare commits
6 Commits
5878f7d9f4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 12be225242 | |||
| 1b50511a4f | |||
| d6e1797f08 | |||
| ee5cec6de4 | |||
|
|
b12e55a776 | ||
|
|
10f9c0061a |
@@ -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. 工作台扩展:任务类型 Tab(2D/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>`**(否决 GIF:mp4 文件更小 + 支持帧擦写;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/张 ~72s,L40S):`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.46(Trellis $0.041 + 6 × IP-Adapter $0.07 = $0.461),耗时 ~105s(30s 重建 + 并发 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_path,Phase 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 2(ffmpeg 抽帧)。
|
||||||
|
- **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 1–3 产品形态的前端 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 0–3 完整产品形态,其中 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 横向流动 2px),AI 活动时出现在输入栏上方,生图时加速
|
||||||
|
- 用户消息改为右对齐渐变背景+右上切角,助手消息改为 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 增量约 50KB(framer-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 用 powershell,macOS/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(官网/社区策略对齐)
|
### [CL-20260416-0830] 2026-04-16 08:30 — 生图按模型预处理 prompt(官网/社区策略对齐)
|
||||||
- **tags**: 后端, 生图, prompt, SDXL, Flux, GPT Image, Gemini, Replicate, IP-Adapter, image_gen, Agent
|
- **tags**: 后端, 生图, prompt, SDXL, Flux, GPT Image, Gemini, Replicate, IP-Adapter, image_gen, Agent
|
||||||
- **affected_files**:
|
- **affected_files**:
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
||||||
|
|
||||||
|
- [CL-20260420-2237] 前端一次性铺开文档中心(/docs 三栏 + DocTree/Tabs/Editor/Outline + react-markdown + `[[asset:id]]` 资产引用 + AssetPicker)+ 全站 Cmd/Ctrl+K CommandPalette + 工作台 TaskTypeTabs(2D/3D/动作)按类型显示 Params3D/ParamsMotionBar + TaskCard 加 variant + 3D 结果面板 Model3DPanel(多视图/360°/`<model-viewer>` 三模式,color/normal/stylized 通道切)+ 动作结果面板 MotionPanel(视频/帧序列双模式+scrubber)+ 资源库顶部模态 Tab(图片/3D/动作)+ 3D/MOTION 徽章叠加;`<model-viewer>` 走 next/script lazyOnload;types/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 3,preserve_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.8,P3-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/normal;view_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.1;Agent自测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/mesh,tools.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×2,config改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#2E8B7A,13个文件颜色替换,面板改白色毛玻璃,云雾透明度降低,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和shell,project-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_strategy(SDXL 默认负向词+---NEGATIVE---拆分,Flux 剥粘贴的 Negative 段,GPT/Gemini/IP-Adapter 规范化);GenerateResult 带 effective_prompt/negative_prompt;Replicate SDXL 传 negative_prompt;工具说明 SDXL 分隔符
|
- [CL-20260416-0830] 生图按模型预处理 prompt:新增 image_prompt_strategy(SDXL 默认负向词+---NEGATIVE---拆分,Flux 剥粘贴的 Negative 段,GPT/Gemini/IP-Adapter 规范化);GenerateResult 带 effective_prompt/negative_prompt;Replicate SDXL 传 negative_prompt;工具说明 SDXL 分隔符
|
||||||
- [CL-20260416-0810] 对话输入栏支持剪贴板粘贴图片为参考图:ChatInput根容器onPasteCapture提取image/*文件并走startUpload,placeholder/title提示
|
- [CL-20260416-0810] 对话输入栏支持剪贴板粘贴图片为参考图:ChatInput根容器onPasteCapture提取image/*文件并走startUpload,placeholder/title提示
|
||||||
- [CL-20260416-0745] Gemini原生生图:延长读写超时+对RemoteProtocolError等可重试错误自动重试(默认3次指数退避),环境变量可微调VECTORENGINE_GEMINI_*_TIMEOUT
|
- [CL-20260416-0745] Gemini原生生图:延长读写超时+对RemoteProtocolError等可重试错误自动重试(默认3次指数退避),环境变量可微调VECTORENGINE_GEMINI_*_TIMEOUT
|
||||||
@@ -37,27 +52,3 @@
|
|||||||
- [CL-20260413-0930] System Prompt 加入"禁止模拟工具调用"约束,防止 DeepSeek 用文字模拟生图而不调用工具
|
- [CL-20260413-0930] System Prompt 加入"禁止模拟工具调用"约束,防止 DeepSeek 用文字模拟生图而不调用工具
|
||||||
- [CL-20260413-0900] 非 vision + 参考图场景:不再绝对禁止风格词,改为"不自行猜测但保留用户明确指定的风格"
|
- [CL-20260413-0900] 非 vision + 参考图场景:不再绝对禁止风格词,改为"不自行猜测但保留用户明确指定的风格"
|
||||||
- [CL-20260413-0800] 修复 InstantStyle ReadTimeout + 多次生成丢失参考图:wait 改 False 走纯轮询 + 不再清除 ref_image_url
|
- [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 个维度)两份文档
|
|
||||||
|
|||||||
@@ -1,8 +1,83 @@
|
|||||||
# Dev Changelog — Recent
|
# Dev Changelog — Recent
|
||||||
|
|
||||||
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
||||||
当 Agent 检测到当前任务与近期改动相关时自动读取。
|
当 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+Outline),textarea+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/张 ~72s,L40S),`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.8,P3-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_video:6 视角映射到 frame_idx=0/20/40/60/80/100(完全均匀)+ 所有切图 512×512 方形。等用户 Checkpoint 2 验收后开 Phase 3(IP-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/mesh,mesh 阶段 1 只返回 mesh_url + color_video + normal_video,images 暂空;`tools.py` 的 `transform_view` 工具扩 `model_id/azimuths/preserve_style`。另新增 `scripts/test_trellis.py` 作为独立验收入口。Zero123++ 现有行为完全不变。等用户手跑脚本验收后开 Phase 2(ffmpeg 抽帧)。
|
||||||
|
|
||||||
|
### [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 1–3 完整 UI 骨架:新增 /styles /characters /training /settings 四大模块(列表+详情+创建流程),工作台加入风格/角色/规格快选条、高级控制抽屉、候选评估面板、Task Card 组件,资源库加二级过滤(资产类型/风格/角色)+ 语义搜索切换;TopNav 加项目切换器和新导航项;Sidebar 加项目信息卡。所有 Phase 1+ 数据通过新建的 placeholder-store.ts + localStorage 占位持久化(首次注入示例),AppContext 扩充相关字段。所有真实后端行为均为 `alert("(占位)...")`,UI 上以 phase-chip(P1/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 1:globals.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、shell),project-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
|
### [CL-20260416-0830] 2026-04-16 — 生图按模型预处理 prompt
|
||||||
- **tags**: 后端, 生图, prompt, SDXL, Flux, Replicate, image_gen, Agent工具
|
- **tags**: 后端, 生图, prompt, SDXL, Flux, Replicate, image_gen, Agent工具
|
||||||
- **affected_files**: image_prompt_strategy.py, image_gen.py, tools.py
|
- **affected_files**: image_prompt_strategy.py, image_gen.py, tools.py
|
||||||
@@ -28,253 +103,3 @@
|
|||||||
- **affected_files**: image_gen.py, config.py
|
- **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` 条目。
|
- **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_url);Agent 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-image(model_id: gemini-3.1-flash-image-preview),provider 为 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.5(provider: 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**: LLM(GPT 系列)在收到 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-chat),provider 分发为 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-static(hover 静态发光)两个类,分别应用到导航活动 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 — 修复多会话滚动位置共享 bug:per-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 做本地 embedding,Qdrant 文件模式做向量存储。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,根因同 Kolors(CL-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 ID,SDK 自动走 `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 + ReadTimeout:wait 参数超限
|
|
||||||
- **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`)+ ReadTimeout(61.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_name),SSE 事件携带模型名称,前端在图片下方和错误提示中展示。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 保存预览 URL,chat-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 / SDXL),image_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。
|
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,44 @@
|
|||||||
- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索
|
- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索
|
||||||
- **deferred_reason**: 当前阶段资源量少,手动管理足够;等资源积累到一定量级后再引入自动化
|
- **deferred_reason**: 当前阶段资源量少,手动管理足够;等资源积累到一定量级后再引入自动化
|
||||||
|
|
||||||
|
### [advanced-view-transform] 进阶视角变换 Pipeline(3D 重建 + 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 从原图提取深度图
|
||||||
|
- 用 ControlNet(depth/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
|
## Completed / Cancelled Items
|
||||||
|
|||||||
5
.cursor/distillery/golden-rules.md
Normal file
5
.cursor/distillery/golden-rules.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Golden Rules
|
||||||
|
|
||||||
|
经过实践验证(权重 >= 5)的精炼认知,每次新会话自动注入。
|
||||||
|
|
||||||
|
<!-- 当条目达到权重阈值后由 Agent 自动写入 -->
|
||||||
8
.cursor/distillery/insights.md
Normal file
8
.cursor/distillery/insights.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Problem Distillery — Insights
|
||||||
|
|
||||||
|
从已解决的顽固问题中蒸馏出的精炼方法论。
|
||||||
|
|
||||||
|
<!-- 新条目追加在此行下方 -->
|
||||||
|
|
||||||
|
---
|
||||||
|
last_distill_date: 2026-04-16
|
||||||
5
.cursor/distillery/problems.md
Normal file
5
.cursor/distillery/problems.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Problem Distillery — Problems
|
||||||
|
|
||||||
|
反复出现的顽固问题追踪记录,按时间倒序排列。
|
||||||
|
|
||||||
|
<!-- 新条目追加在此行下方 -->
|
||||||
@@ -3,13 +3,13 @@
|
|||||||
"hooks": {
|
"hooks": {
|
||||||
"sessionStart": [
|
"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
|
"timeout": 5
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"stop": [
|
"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,
|
"timeout": 10,
|
||||||
"loop_limit": 1
|
"loop_limit": 1
|
||||||
}
|
}
|
||||||
|
|||||||
38
.cursor/hooks/README.md
Normal file
38
.cursor/hooks/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Cursor Hooks 说明
|
||||||
|
|
||||||
|
## 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
hooks/
|
||||||
|
├── run-hook.ps1 # Windows dispatcher(读取 local-env.json 后分发)
|
||||||
|
├── session-init.ps1 # sessionStart hook(PowerShell 版)
|
||||||
|
├── session-init.sh # sessionStart hook(bash 版)
|
||||||
|
├── check-changelog.ps1 # stop hook(PowerShell 版)
|
||||||
|
├── check-changelog.sh # stop hook(bash 版)
|
||||||
|
└── 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`。
|
||||||
58
.cursor/hooks/check-changelog.sh
Normal file
58
.cursor/hooks/check-changelog.sh
Normal 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"
|
||||||
|
|
||||||
|
# === 豁免检查 4:changelog 文件不存在 ===
|
||||||
|
if [ ! -f "$changelog" ]; then
|
||||||
|
echo '{}'; exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
clMtime=$(stat -c %Y "$changelog" 2>/dev/null || stat -f %m "$changelog" 2>/dev/null)
|
||||||
|
|
||||||
|
# === 豁免检查 5:ack 标记文件足够新 ===
|
||||||
|
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
|
||||||
|
|
||||||
|
# === 豁免检查 6:stdin 中无文件编辑证据 ===
|
||||||
|
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
|
||||||
35
.cursor/hooks/run-hook.ps1
Normal file
35
.cursor/hooks/run-hook.ps1
Normal 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 '{}'
|
||||||
|
}
|
||||||
|
}
|
||||||
15
.cursor/hooks/session-init.sh
Normal file
15
.cursor/hooks/session-init.sh
Normal 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
|
||||||
5
.cursor/local-env.example.json
Normal file
5
.cursor/local-env.example.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"_comment": "设备绑定的本地环境配置。复制为 local-env.json 并修改为本机实际值。",
|
||||||
|
"nodejs_path": "C:\\Users\\<username>\\AppData\\Local\\nodejs",
|
||||||
|
"shell": "powershell"
|
||||||
|
}
|
||||||
@@ -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 时固化
|
### [PF-20260412-1530] Python load_dotenv 默认不覆盖已有环境变量 + 模块级变量在 import 时固化
|
||||||
- **症状**: 修改 .env 切换 API 供应商后,后端仍报旧供应商的错误(如 DeepSeek 报 "Model Not Exist",实际发送的模型名是 OpenAI 的 gpt-4o-mini)
|
- **症状**: 修改 .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 的加载
|
- **根因**: 两层叠加:(1) `load_dotenv()` 默认 `override=False`,如果进程/系统中已有同名环境变量,.env 中的新值不会生效;(2) `config.py` 使用模块级常量 `LLM_MODEL = os.getenv(...)` 在 import 时就求值固化,而 `main.py` 的 import 链在 `load_dotenv()` 之前就触发了 config.py 的加载
|
||||||
|
|||||||
@@ -73,8 +73,33 @@ Agent 开始处理一个新任务时,判断是否需要读取近期详细记
|
|||||||
Agent 在即将输出最终回复前,**必须**逐项检查以下清单。
|
Agent 在即将输出最终回复前,**必须**逐项检查以下清单。
|
||||||
这是硬性要求,不是建议——**跳过任何一项都视为执行错误**。
|
这是硬性要求,不是建议——**跳过任何一项都视为执行错误**。
|
||||||
|
|
||||||
|
### 前置项(每次回复前无条件执行)
|
||||||
|
|
||||||
|
**A. 画像信号扫描(短路版)**
|
||||||
|
|
||||||
|
目的:以最小 token 成本维持 `profile-memory` Skill 的被动检测通路。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
|
||||||
|
1. **快判(词表匹配)**:扫描本次对话中**用户消息**,检查是否**明显**出现以下偏好/决策触发词族之一:
|
||||||
|
|
||||||
|
- 个人偏好信号:`我喜欢` / `我倾向` / `我不喜欢` / `我讨厌` / `以后都` / `以后别` /
|
||||||
|
`别再` / `不要再` / `我的风格` / `我的偏好` / `太花哨` / `minimal` /
|
||||||
|
`简洁` / `精炼` / `啰嗦`
|
||||||
|
- 项目决策信号:`这个项目是` / `目标用户是` / `技术栈` / `架构上` /
|
||||||
|
`产品方向` / `定位是`
|
||||||
|
|
||||||
|
2. **分支**:
|
||||||
|
- 无匹配 → **直接跳过**,进入下一 checklist 项(thinking 应在 50 token 内结束)
|
||||||
|
- 有匹配 → 读取 `.cursor/skills/profile-memory/SKILL.md` 并执行操作 A 的确认流程
|
||||||
|
|
||||||
|
3. **约束**:本项不做全量语义判断——漏记隐式信号是可接受的权衡。
|
||||||
|
用户如需记录隐式偏好可显式说"把这条记入画像"。
|
||||||
|
|
||||||
|
### 变更检查项(仅当本次有代码/配置文件改动时执行)
|
||||||
|
|
||||||
1. **本次是否有代码/配置文件改动?**(不含 `.cursor/` 下的基础设施文件)
|
1. **本次是否有代码/配置文件改动?**(不含 `.cursor/` 下的基础设施文件)
|
||||||
- 否 → 跳过后续项
|
- 否 → 跳过本组后续项,直接进入"收尾项"
|
||||||
- 是 → 继续
|
- 是 → 继续
|
||||||
|
|
||||||
2. **是否已写入三层开发日志?**
|
2. **是否已写入三层开发日志?**
|
||||||
@@ -89,9 +114,12 @@ Agent 在即将输出最终回复前,**必须**逐项检查以下清单。
|
|||||||
- 否 → 跳过
|
- 否 → 跳过
|
||||||
- 是 → 执行 `epee-orchestrator.mdc` 中的自迭代确认流程
|
- 是 → 执行 `epee-orchestrator.mdc` 中的自迭代确认流程
|
||||||
|
|
||||||
5. **静默 Hook 重复触发**(每次 checklist 完成后必须执行)
|
### 收尾项(每次回复前无条件执行)
|
||||||
|
|
||||||
|
**Z. 静默 Hook ack**
|
||||||
|
|
||||||
- 用 Write 工具创建空文件 `.cursor/changelog/.changelog-ack`
|
- 用 Write 工具创建空文件 `.cursor/changelog/.changelog-ack`
|
||||||
- 这会更新 ack 文件的 mtime,阻止 `check-changelog.ps1` hook 在后续回复中重复提醒
|
- 这会更新 ack 文件的 mtime,阻止 `check-changelog.ps1` hook 在后续回复中重复提醒
|
||||||
|
|
||||||
**执行原则**:先完成 checklist 中的所有写入动作,再输出面向用户的回复文本。
|
**执行原则**:先完成 checklist 中的所有写入动作,再输出面向用户的回复文本。
|
||||||
回复末尾如果执行了第 2 项,附 `[已记录到开发日志]`。
|
回复末尾如果执行了变更检查项第 2 项,附 `[已记录到开发日志]`。
|
||||||
|
|||||||
31
.cursor/rules/common/cursor-init-recall.mdc
Normal file
31
.cursor/rules/common/cursor-init-recall.mdc
Normal 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`),不涉及全局结构重置
|
||||||
44
.cursor/rules/common/distillery-recall.mdc
Normal file
44
.cursor/rules/common/distillery-recall.mdc
Normal 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 定义
|
||||||
@@ -34,8 +34,13 @@ alwaysApply: true
|
|||||||
3. 用 Read 工具读取该行号 ±15 行范围,获取原始上下文和来源信息
|
3. 用 Read 工具读取该行号 ±15 行范围,获取原始上下文和来源信息
|
||||||
4. 一次上溯通常只涉及 1-2 条记录,不批量上溯
|
4. 一次上溯通常只涉及 1-2 条记录,不批量上溯
|
||||||
|
|
||||||
## 被动检测提醒
|
## 被动检测触发点
|
||||||
|
|
||||||
Agent 在整个对话过程中应保持对画像信号的被动感知。
|
被动检测不在本 Rule 执行,而是由 `changelog-recall.mdc` 的
|
||||||
当对话结束、用户的主线任务完成后,如果检测到了新的画像信息,
|
**任务完成 Checklist 前置项 A(画像信号扫描)** 统一触发。
|
||||||
应读取 `profile-memory` Skill 并执行其"操作 A"的确认流程。
|
|
||||||
|
理由:本 Rule 仅负责上下文注入(低成本、每次必做);被动检测需要挂入
|
||||||
|
已有强制力的 checklist 中才能避免被 Agent 遗漏,该 checklist 由
|
||||||
|
`changelog-recall` 维护,两者分工清晰。
|
||||||
|
|
||||||
|
具体触发词族和短路逻辑详见 `changelog-recall.mdc` 中的前置项 A 定义。
|
||||||
|
|||||||
47
.cursor/rules/project/local-env-check.mdc
Normal file
47
.cursor/rules/project/local-env-check.mdc
Normal 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 路径为 xxx,shell 为 xxx。如需调整请手动编辑该文件。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- `local-env.json` 已在 `.gitignore` 中,不会进入版本控制
|
||||||
|
- 该文件存储**设备绑定**的配置,与 `.env`(存储密钥/API key)互补
|
||||||
|
- 只在文件不存在或字段缺失时触发探测,不会每次会话都重新探测
|
||||||
|
- 探测结果不一定准确(如多个 Node.js 版本并存),用户可随时手动修改
|
||||||
412
.cursor/skills/cursor-init/SKILL.md
Normal file
412
.cursor/skills/cursor-init/SKILL.md
Normal 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 组 skill;C 组整个 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 条目。
|
||||||
@@ -83,7 +83,7 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
|
|||||||
|
|
||||||
4. 写入三个文件(按以下顺序):
|
4. 写入三个文件(按以下顺序):
|
||||||
a. 读取 changelog-full.md,在 "## 记录" 下方追加 L1 条目
|
a. 读取 changelog-full.md,在 "## 记录" 下方追加 L1 条目
|
||||||
b. 读取 changelog-recent.md,在顶部插入 L2 条目,如超过 10 条则移除最旧的
|
b. 读取 changelog-recent.md,在顶部插入 L2 条目,如超过 20 条则移除最旧的(与下方「L2 窗口」一致)
|
||||||
c. 读取 changelog-headlines.md,在顶部插入 L3 条目,如超过 50 条则移除最旧的
|
c. 读取 changelog-headlines.md,在顶部插入 L3 条目,如超过 50 条则移除最旧的
|
||||||
|
|
||||||
5. 在回复末尾附一行提示:"[已记录到开发日志]"
|
5. 在回复末尾附一行提示:"[已记录到开发日志]"
|
||||||
@@ -247,3 +247,13 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
|
|||||||
### 已知必要检查
|
### 已知必要检查
|
||||||
|
|
||||||
1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时,Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。
|
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 的一行条目不受本条约束(它就是单行,天然无此风险)。
|
||||||
|
|||||||
@@ -17,6 +17,14 @@
|
|||||||
|
|
||||||
## 已注册 Skill
|
## 已注册 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
|
### deferred-decisions
|
||||||
- **类型**: 基础设施
|
- **类型**: 基础设施
|
||||||
- **能力**: 记录和追踪延期的技术方案/决策,在关联任务出现时主动提醒
|
- **能力**: 记录和追踪延期的技术方案/决策,在关联任务出现时主动提醒
|
||||||
@@ -28,10 +36,10 @@
|
|||||||
### profile-memory
|
### profile-memory
|
||||||
- **类型**: 个人级
|
- **类型**: 个人级
|
||||||
- **能力**: 在对话中被动检测用户个人特质和项目信息,经确认后持久化为精简画像和详细日志
|
- **能力**: 在对话中被动检测用户个人特质和项目信息,经确认后持久化为精简画像和详细日志
|
||||||
- **触发场景**: "画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"、"查看项目画像"、用户主动管理画像
|
- **触发场景**: "画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"、"查看项目画像"、用户主动管理画像;被动检测由 `changelog-recall` checklist 前置项 A 的短路扫描触发(命中偏好/项目决策触发词族时)
|
||||||
- **输出**: ~/.cursor/profile/user-profile.md、.cursor/profile/project-profile.md 及对应 log 文件
|
- **输出**: ~/.cursor/profile/user-profile.md、.cursor/profile/project-profile.md 及对应 log 文件
|
||||||
- **路径**: .cursor/skills/profile-memory/SKILL.md
|
- **路径**: .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
|
### dev-changelog
|
||||||
- **类型**: 基础设施
|
- **类型**: 基础设施
|
||||||
@@ -39,7 +47,7 @@
|
|||||||
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
|
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
|
||||||
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
|
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
|
||||||
- **路径**: .cursor/skills/dev-changelog/SKILL.md
|
- **路径**: .cursor/skills/dev-changelog/SKILL.md
|
||||||
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;stop Hook 双保险兜底
|
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;stop Hook 双保险兜底;写入后须维护 L3≤50 / L2≤20 滚动窗口,L1/L2 多行条目须遵循"顶部插入 StrReplace 范式"防止旧首条目被截断(见 Skill 自迭代检查项)
|
||||||
|
|
||||||
### pitfall-journal
|
### pitfall-journal
|
||||||
- **类型**: 基础设施
|
- **类型**: 基础设施
|
||||||
@@ -47,7 +55,15 @@
|
|||||||
- **触发场景**: debug 完成后、"踩坑"、"之前遇到过"、"坑"、进入 Debug mode、同类错误反复出现
|
- **触发场景**: debug 完成后、"踩坑"、"之前遇到过"、"坑"、进入 Debug mode、同类错误反复出现
|
||||||
- **输出**: .cursor/pitfalls/pitfalls.md 条目
|
- **输出**: .cursor/pitfalls/pitfalls.md 条目
|
||||||
- **路径**: .cursor/skills/pitfall-journal/SKILL.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
|
### project-launcher
|
||||||
- **类型**: 项目级
|
- **类型**: 项目级
|
||||||
@@ -55,4 +71,4 @@
|
|||||||
- **触发场景**: "启动项目"、"运行项目"、"跑起来"、"打开前端和后端"、"启动服务"、"start"、"launch"、"run dev"、"启动前端"、"启动后端"
|
- **触发场景**: "启动项目"、"运行项目"、"跑起来"、"打开前端和后端"、"启动服务"、"start"、"launch"、"run dev"、"启动前端"、"启动后端"
|
||||||
- **输出**: Ollama 后台服务 + 两个独立的终端窗口(前端 Next.js + 后端 FastAPI)
|
- **输出**: Ollama 后台服务 + 两个独立的终端窗口(前端 Next.js + 后端 FastAPI)
|
||||||
- **路径**: .cursor/skills/project-launcher/SKILL.md
|
- **路径**: .cursor/skills/project-launcher/SKILL.md
|
||||||
- **备注**: 项目专属 Skill;Ollama 必须先于后端启动(Mem0 embedding 依赖);前后端窗口必须可见
|
- **备注**: 项目专属 Skill;启动前必须先读取 `.cursor/local-env.json` 获取 nodejs_path 和 shell;Ollama 必须先于后端启动(Mem0 embedding 依赖);前后端窗口必须可见
|
||||||
|
|||||||
@@ -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]` 标题行且字段完整跟随。
|
||||||
|
|||||||
232
.cursor/skills/problem-distillery/SKILL.md
Normal file
232
.cursor/skills/problem-distillery/SKILL.md
Normal 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.md(promoted 改为 demoted)。
|
||||||
|
|
||||||
|
## 操作 A:追踪记录(问题进行中)
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
以下**任一**场景触发:
|
||||||
|
|
||||||
|
1. **用户明确表达**问题未解决:
|
||||||
|
- "还有问题"、"问题没有解决"、"还是一样"、"又出现了"
|
||||||
|
- "试了好多次了"、"这个问题反复出现"、"不行"、"没用"
|
||||||
|
- 以及其他表达"问题反复发生、Agent 没有完全解决"的语义
|
||||||
|
2. **Agent 自身意识到**同一问题已经尝试了 2 次以上仍未解决
|
||||||
|
|
||||||
|
### 适用范围
|
||||||
|
|
||||||
|
不限于技术问题——工作流设计、产品决策等各类反复纠结的问题均适用。
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 提取当前问题的指纹:
|
||||||
|
- 技术问题:错误信息关键词、涉及文件/模块、症状描述
|
||||||
|
- 非技术问题:核心矛盾点、涉及领域、反复出现的决策困境
|
||||||
|
|
||||||
|
2. 读取 problems.md,用指纹匹配 status=open 的条目
|
||||||
|
|
||||||
|
3. 匹配到已有条目 → 在 attempts 中追加本次尝试记录
|
||||||
|
|
||||||
|
4. 无匹配 → 创建新条目:
|
||||||
|
- 生成 ID:PD-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.md(ID 格式:IN-YYYYMMDD-NN,NN 为当日序号)
|
||||||
|
- 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-NNN,NNN 为递增序号)
|
||||||
|
- insights.md 中 promoted 改为 true
|
||||||
|
c. 如 = 15 → 比较权重:
|
||||||
|
- 新条目权重 > golden-rules.md 中最低权重条目 → 替换
|
||||||
|
- 被替换的条目降回 insights.md(promoted 改为 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 过程中发现的必要检查项。
|
||||||
|
|
||||||
|
### 已知必要检查
|
||||||
|
|
||||||
|
(暂无)
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: profile-memory
|
name: profile-memory
|
||||||
description: >-
|
description: >-
|
||||||
渐进式用户/项目画像系统。在对话中被动检测用户个人特质(审美、技术偏好、做事风格等)
|
渐进式用户/项目画像系统。两条触发路径:
|
||||||
和项目信息(定位、技术栈、产品方向等),对话结束前统一总结并经用户确认后记录。
|
(1)被动检测(主路径)——由 `changelog-recall` checklist 前置项 A 触发短路扫描,
|
||||||
当用户提到"画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"等时触发。
|
命中偏好/项目决策触发词时执行操作 A(总结 + 用户确认 + 写入)。
|
||||||
|
(2)显式管理——当用户提到"画像"、"profile"、"查看画像"、"我的偏好"、"项目信息"等时
|
||||||
|
执行操作 B(浏览、修改、删除已有条目)。
|
||||||
---
|
---
|
||||||
|
|
||||||
# Profile Memory
|
# Profile Memory
|
||||||
|
|||||||
@@ -32,14 +32,33 @@ description: >-
|
|||||||
- 前端需要 Node.js >= 18
|
- 前端需要 Node.js >= 18
|
||||||
- **Ollama 必须在后端之前启动**:Mem0 记忆系统依赖 Ollama 提供本地 embedding 服务(`nomic-embed-text` 模型),端口 `11434`
|
- **Ollama 必须在后端之前启动**:Mem0 记忆系统依赖 Ollama 提供本地 embedding 服务(`nomic-embed-text` 模型),端口 `11434`
|
||||||
|
|
||||||
## 环境 PATH 须知
|
## 本地环境配置(local-env.json)
|
||||||
|
|
||||||
本机 Node.js 安装在 `C:\Program Files\nodejs\` 但**未加入系统 PATH**。
|
设备绑定的路径和配置统一存放在 `.cursor/local-env.json`(已 gitignored),不硬编码到 Skill 中。
|
||||||
新开的终端窗口默认找不到 `node` / `npm` 命令。
|
|
||||||
|
|
||||||
**解决方式**:在启动前端的命令中,先将 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/)
|
1. 确定项目根目录(workspace 根目录下的 art-agent/)
|
||||||
|
|
||||||
2. 确定操作系统和 Shell 类型(Windows / macOS / Linux)
|
2. 读取 .cursor/local-env.json:
|
||||||
|
- 存在 → 解析 nodejs_path 和 shell
|
||||||
|
- 不存在 → 执行自动探测流程(见上方),生成后再读取
|
||||||
|
|
||||||
3. 检查并启动 Ollama:
|
3. 检查并启动 Ollama:
|
||||||
- 检测 Ollama 是否已在运行(请求 http://localhost:11434/api/tags)
|
- 检测 Ollama 是否已在运行(请求 http://localhost:11434/api/tags)
|
||||||
- 未运行 → 启动 Ollama 服务,等待就绪
|
- 未运行 → 启动 Ollama 服务,等待就绪
|
||||||
- 已运行 → 跳过
|
- 已运行 → 跳过
|
||||||
|
|
||||||
4. 启动后端(在独立可见终端窗口中):
|
4. 根据 shell 字段选择对应的启动命令模板:
|
||||||
- Windows(PowerShell 或 CMD 均适用):
|
- powershell → 使用 PowerShell 命令
|
||||||
使用 `Start-Process` 或 `start cmd` 打开新的终端窗口
|
- bash/zsh → 使用 macOS/Linux 命令
|
||||||
- macOS/Linux:
|
|
||||||
使用对应的终端打开方式
|
|
||||||
|
|
||||||
5. 启动前端(在另一个独立可见终端窗口中):
|
5. 启动后端(在独立可见终端窗口中)
|
||||||
- 同样在新的终端窗口中启动
|
|
||||||
|
|
||||||
6. 确认三个服务正在运行,告知用户访问地址
|
6. 启动前端(在另一个独立可见终端窗口中):
|
||||||
|
- 如果 nodejs_path 不为 null,先注入到 PATH
|
||||||
|
|
||||||
|
7. 确认三个服务正在运行,告知用户访问地址
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ollama 启动(跨平台通用)
|
### Ollama 启动(跨平台通用)
|
||||||
@@ -87,44 +108,36 @@ try {
|
|||||||
> Ollama 启动后会常驻后台,不需要独立终端窗口。如果用户系统已将 Ollama 设为开机自启,
|
> Ollama 启动后会常驻后台,不需要独立终端窗口。如果用户系统已将 Ollama 设为开机自启,
|
||||||
> 则检测会直接通过,不会重复启动。
|
> 则检测会直接通过,不会重复启动。
|
||||||
|
|
||||||
### Windows 启动命令
|
### 启动命令模板
|
||||||
|
|
||||||
**关键要求**:必须在**新的、可见的终端窗口**中启动,不能在 Cursor 内置终端后台运行。
|
**关键要求**:必须在**新的、可见的终端窗口**中启动,不能在 Cursor 内置终端后台运行。
|
||||||
|
|
||||||
#### PowerShell 环境
|
以下模板中的变量说明:
|
||||||
|
- `BACKEND_PATH` / `FRONTEND_PATH`:替换为实际绝对路径
|
||||||
|
- `NODEJS_PATH`:从 `local-env.json` 的 `nodejs_path` 字段读取
|
||||||
|
|
||||||
|
#### shell = "powershell"
|
||||||
|
|
||||||
启动后端:
|
启动后端:
|
||||||
```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
|
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
|
```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
|
```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 BACKEND_PATH && source venv/bin/activate && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"'
|
||||||
|
```
|
||||||
|
|
||||||
# 前端
|
启动前端(NODEJS_PATH 为 null 时跳过 PATH 注入):
|
||||||
osascript -e 'tell application "Terminal" to do script "cd FRONTEND_PATH && npm run dev"'
|
```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 中。
|
1. **先读 local-env.json** — 启动前必须读取 `.cursor/local-env.json` 获取 `nodejs_path` 和 `shell`。文件不存在时执行自动探测并生成。绝不在 Skill 中硬编码设备相关的路径。
|
||||||
2. **Ollama 必须先于后端启动** — Mem0 记忆系统依赖 Ollama 的 `nomic-embed-text` 模型做本地 embedding(端口 11434)。Ollama 未运行时后端能启动但对话会报 502 错误。启动流程必须在后端之前检测并启动 Ollama。
|
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
5
.gitignore
vendored
@@ -34,10 +34,15 @@ out/
|
|||||||
art-agent/backend/uploads/
|
art-agent/backend/uploads/
|
||||||
art-agent/backend/generated/
|
art-agent/backend/generated/
|
||||||
art-agent/backend/data/
|
art-agent/backend/data/
|
||||||
|
# 本地下载的 cloudflared CLI(体积大,见 README / start-tunnel.ps1)
|
||||||
|
art-agent/cloudflared.exe
|
||||||
|
|
||||||
# ─── 工具二进制 ──────────────────────────────────────────
|
# ─── 工具二进制 ──────────────────────────────────────────
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
|
# ─── 本地环境配置(设备绑定,不入版本控制)──────────────
|
||||||
|
.cursor/local-env.json
|
||||||
|
|
||||||
# ─── IDE / 编辑器 ────────────────────────────────────────
|
# ─── IDE / 编辑器 ────────────────────────────────────────
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
206
art-agent/ENVIRONMENT.md
Normal file
206
art-agent/ENVIRONMENT.md
Normal 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 生图 API(Flux/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 ORM(SQLite 用户表) |
|
||||||
|
| 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 | 本地 embedding(nomic-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 |
|
||||||
@@ -46,6 +46,7 @@ SYSTEM_PROMPT = """\
|
|||||||
- 如果用户提供了参考图(支持多张),将参考图的风格元素融入生成 prompt
|
- 如果用户提供了参考图(支持多张),将参考图的风格元素融入生成 prompt
|
||||||
- 理解多张参考图各自的角色(如"图1的主体 + 图2的风格/视角"),并在 prompt 中准确传达
|
- 理解多张参考图各自的角色(如"图1的主体 + 图2的风格/视角"),并在 prompt 中准确传达
|
||||||
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
|
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
|
||||||
|
- 将一张图片转换为多个不同视角(使用 transform_view 工具),适合建筑、物体等需要从不同角度查看的场景
|
||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
|
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
|
||||||
@@ -268,9 +269,13 @@ async def run_agent_loop(
|
|||||||
tc_data = tool_calls_data[idx]
|
tc_data = tool_calls_data[idx]
|
||||||
tool_name = tc_data["name"]
|
tool_name = tc_data["name"]
|
||||||
|
|
||||||
|
tool_message = (
|
||||||
|
"正在进行视角变换..." if tool_name == "transform_view"
|
||||||
|
else "正在生成图片..."
|
||||||
|
)
|
||||||
yield {
|
yield {
|
||||||
"type": "tool_start",
|
"type": "tool_start",
|
||||||
"data": {"tool": tool_name, "message": "正在生成图片..."},
|
"data": {"tool": tool_name, "message": tool_message},
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
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 {
|
yield {
|
||||||
"type": "image_result",
|
"type": "image_result",
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Agent 可调用的工具定义和实现。"""
|
"""Agent 可调用的工具定义和实现。"""
|
||||||
|
|
||||||
from app.services.image_gen import generate_images
|
from app.services.image_gen import generate_images
|
||||||
|
from app.services.view_transform import transform_view
|
||||||
|
|
||||||
# OpenAI Function Calling 格式的工具定义
|
# OpenAI Function Calling 格式的工具定义
|
||||||
TOOL_DEFINITIONS = [
|
TOOL_DEFINITIONS = [
|
||||||
@@ -32,7 +33,44 @@ TOOL_DEFINITIONS = [
|
|||||||
"required": ["prompt"],
|
"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": "视角变换模型;默认 zero123plus(快),trellis 画风还原更强但慢",
|
||||||
|
},
|
||||||
|
"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,
|
"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}"}
|
return {"success": False, "error": f"未知工具: {tool_name}"}
|
||||||
|
|||||||
@@ -225,9 +225,121 @@ IMAGE_MODELS: dict[str, dict[str, Any]] = {
|
|||||||
"output_format": "png",
|
"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:
|
def get_default_image_model_id() -> str:
|
||||||
"""返回 .env 中配置的默认模型短 ID,若不在注册表中则回退到 flux-schnell。"""
|
"""返回 .env 中配置的默认模型短 ID,若不在注册表中则回退到 flux-schnell。"""
|
||||||
env_model = os.getenv("IMAGE_MODEL", "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]:
|
def get_image_models_list() -> list[dict]:
|
||||||
"""返回前端下拉列表所需的模型摘要信息。"""
|
"""返回前端下拉列表所需的模型摘要信息(过滤掉 internal=True 的内部模型)。"""
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": cfg["id"],
|
"id": cfg["id"],
|
||||||
@@ -262,6 +374,7 @@ def get_image_models_list() -> list[dict]:
|
|||||||
"supports_ref_image": cfg.get("supports_ref_image", False),
|
"supports_ref_image": cfg.get("supports_ref_image", False),
|
||||||
}
|
}
|
||||||
for cfg in IMAGE_MODELS.values()
|
for cfg in IMAGE_MODELS.values()
|
||||||
|
if not cfg.get("internal", False)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
189
art-agent/backend/app/services/mesh_generator.py
Normal file
189
art-agent/backend/app/services/mesh_generator.py
Normal 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": [],
|
||||||
|
}
|
||||||
180
art-agent/backend/app/services/style_restorer.py
Normal file
180
art-agent/backend/app/services/style_restorer.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""阶段 3:对 Trellis 抽帧结果做 IP-Adapter + ControlNet 重绘,还原原画风。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- original_image:用户上传的原图(提供风格 / IP-Adapter 参考)
|
||||||
|
- structure_image:Stage 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 limit,3 比较稳
|
||||||
|
|
||||||
|
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])
|
||||||
191
art-agent/backend/app/services/video_frame_extractor.py
Normal file
191
art-agent/backend/app/services/video_frame_extractor.py
Normal 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=0(Trellis 的 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,
|
||||||
|
)
|
||||||
357
art-agent/backend/app/services/view_transform.py
Normal file
357
art-agent/backend/app/services/view_transform.py
Normal 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=%d(ratio=%.2f),"
|
||||||
|
"实际图像 %dx%d(ratio=%.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: 视角变换模型 ID(zero123plus / 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 完成后会填充 images(raw 抽帧结果),
|
||||||
|
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),
|
||||||
|
}
|
||||||
BIN
art-agent/backend/requirements-lock.txt
Normal file
BIN
art-agent/backend/requirements-lock.txt
Normal file
Binary file not shown.
@@ -1,14 +1,16 @@
|
|||||||
fastapi>=0.115.0
|
fastapi==0.135.3
|
||||||
uvicorn>=0.32.0
|
uvicorn==0.44.0
|
||||||
openai>=1.55.0
|
watchfiles==0.24.0
|
||||||
replicate>=1.0.0
|
openai==2.32.0
|
||||||
sse-starlette>=2.1.0
|
replicate==1.0.7
|
||||||
python-multipart>=0.0.12
|
sse-starlette==3.3.4
|
||||||
httpx>=0.27.0
|
python-multipart==0.0.26
|
||||||
python-dotenv>=1.0.0
|
httpx==0.28.1
|
||||||
Pillow>=10.4.0
|
python-dotenv==1.2.2
|
||||||
mem0ai
|
Pillow==12.2.0
|
||||||
ollama
|
imageio-ffmpeg==0.5.1
|
||||||
sqlmodel>=0.0.22
|
mem0ai==1.0.11
|
||||||
pwdlib[argon2,bcrypt]>=0.3.0
|
ollama==0.6.1
|
||||||
python-jose[cryptography]>=3.3.0
|
sqlmodel==0.0.38
|
||||||
|
pwdlib[argon2,bcrypt]==0.3.0
|
||||||
|
python-jose[cryptography]==3.5.0
|
||||||
|
|||||||
107
art-agent/backend/scripts/test_frame_extract.py
Normal file
107
art-agent/backend/scripts/test_frame_extract.py
Normal 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()))
|
||||||
167
art-agent/backend/scripts/test_style_restore.py
Normal file
167
art-agent/backend/scripts/test_style_restore.py
Normal 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"
|
||||||
|
)
|
||||||
|
# 最新文件的 prefix:view_<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:
|
||||||
|
# 从文件名解析 az:view_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()))
|
||||||
72
art-agent/backend/scripts/test_trellis.py
Normal file
72
art-agent/backend/scripts/test_trellis.py
Normal 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()))
|
||||||
1629
art-agent/frontend/package-lock.json
generated
1629
art-agent/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,17 +9,20 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"react": "^19.0.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": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"typescript": "^5.7.0",
|
"postcss": "^8.5.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"typescript": "^5.7.0"
|
||||||
"postcss": "^8.5.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
511
art-agent/frontend/src/app/characters/[id]/page.tsx
Normal file
511
art-agent/frontend/src/app/characters/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
art-agent/frontend/src/app/characters/page.tsx
Normal file
217
art-agent/frontend/src/app/characters/page.tsx
Normal 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">试试调整筛选条件或点击“新建角色”</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
art-agent/frontend/src/app/docs/page.tsx
Normal file
254
art-agent/frontend/src/app/docs/page.tsx
Normal 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 的未保存 buffer(nodeId -> 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { TopNav } from "@/components/layout/top-nav";
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
|
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||||
import { useApp } from "@/lib/app-context";
|
import { useApp } from "@/lib/app-context";
|
||||||
import { getImageUrl } from "@/lib/api";
|
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 ViewMode = "grid" | "list";
|
||||||
type SortBy = "time" | "name";
|
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() {
|
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 [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||||
const [sortBy, setSortBy] = useState<SortBy>("time");
|
const [sortBy, setSortBy] = useState<SortBy>("time");
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchMode, setSearchMode] = useState<SearchMode>("keyword");
|
||||||
const [showFavOnly, setShowFavOnly] = useState(false);
|
const [showFavOnly, setShowFavOnly] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [detailAsset, setDetailAsset] = useState<ImageAsset | null>(null);
|
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]));
|
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||||
|
|
||||||
@@ -35,7 +57,37 @@ export default function GalleryPage() {
|
|||||||
|
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (searchMode === "keyword") {
|
||||||
result = result.filter((a) => a.prompt.toLowerCase().includes(q));
|
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) => {
|
result.sort((a, b) => {
|
||||||
@@ -44,7 +96,8 @@ export default function GalleryPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [assets, showFavOnly, selectedTagIds, searchQuery, sortBy]);
|
}, [assets, showFavOnly, selectedTagIds, searchQuery, searchMode, sortBy,
|
||||||
|
selectedAssetType, selectedStyleId, selectedCharacterId, stylePacks, characters]);
|
||||||
|
|
||||||
const toggleSelect = (id: string) => {
|
const toggleSelect = (id: string) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
@@ -110,12 +163,65 @@ export default function GalleryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col relative z-[1]">
|
<div className="h-screen flex flex-col relative z-[1]">
|
||||||
|
<AmbientParticles count={filtered.length === 0 ? 25 : 15} />
|
||||||
<TopNav />
|
<TopNav />
|
||||||
|
|
||||||
|
{/* 资产类型 Tab(2D 图片 / 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">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<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
|
backdrop-blur-xl px-4 md:px-5 py-3
|
||||||
flex items-center gap-2 md:gap-3 flex-wrap">
|
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" />
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
<polyline points="21 15 16 10 5 21" />
|
<polyline points="21 15 16 10 5 21" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-semibold text-[var(--text-primary)]">资源库</span>
|
<span className="typo-strong text-sm">资源库</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 搜索 */}
|
{/* 搜索 */}
|
||||||
@@ -139,11 +245,20 @@ export default function GalleryPage() {
|
|||||||
<input
|
<input
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="搜索 Prompt..."
|
placeholder={searchMode === "semantic" ? "语义搜索(占位)..." : "搜索 Prompt..."}
|
||||||
className="flex-1 bg-transparent border-none text-sm
|
className="flex-1 bg-transparent border-none text-sm
|
||||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||||
focus:outline-none"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 标签筛选 */}
|
{/* 标签筛选 */}
|
||||||
@@ -233,6 +348,84 @@ export default function GalleryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{selectedIds.size > 0 && (
|
||||||
<div className="flex-shrink-0 px-5 py-2.5 bg-[var(--accent)]/5 border-b border-[var(--accent)]/20
|
<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">
|
<div className="flex-1 overflow-y-auto p-4 md:p-5">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<div className="text-center space-y-3">
|
<motion.div
|
||||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
|
className="text-center space-y-4"
|
||||||
bg-[var(--accent)]/10 border border-[var(--accent)]/20">
|
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">
|
<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" />
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
<polyline points="21 15 16 10 5 21" />
|
<polyline points="21 15 16 10 5 21" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</motion.div>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
<motion.p variants={staggerItem} className="typo-display text-lg">
|
||||||
{assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"}
|
{assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"}
|
||||||
</p>
|
</motion.p>
|
||||||
</div>
|
<motion.p variants={staggerItem} className="typo-caption">
|
||||||
|
{assets.length === 0 ? "在对话中生成的图片会自动出现在这里" : "试试调整筛选条件"}
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "grid" ? (
|
) : 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">
|
<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
|
className={`group relative rounded-xl overflow-hidden neon-border cursor-pointer
|
||||||
bg-[var(--bg-card)] backdrop-blur-sm transition-all ${
|
bg-[var(--bg-card)] backdrop-blur-sm transition-all ${
|
||||||
selectedIds.has(asset.id)
|
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)}
|
onClick={() => setDetailAsset(asset)}
|
||||||
@@ -310,7 +512,7 @@ export default function GalleryPage() {
|
|||||||
<div
|
<div
|
||||||
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all ${
|
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all ${
|
||||||
selectedIds.has(asset.id)
|
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"
|
: "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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* 3D 详情面板 */}
|
||||||
|
{detail3D && <Model3DPanel asset={detail3D} onClose={() => setDetail3D(null)} />}
|
||||||
|
{/* 动作详情面板 */}
|
||||||
|
{detailMotion && <MotionPanel asset={detailMotion} onClose={() => setDetailMotion(null)} />}
|
||||||
|
|
||||||
{/* 详情 Modal */}
|
{/* 详情 Modal */}
|
||||||
{detailAsset && (
|
{detailAsset && (
|
||||||
<GalleryDetailModal
|
<GalleryDetailModal
|
||||||
@@ -527,15 +736,17 @@ function GalleryDetailModal({
|
|||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<motion.div
|
||||||
className="glass-panel rounded-2xl max-w-3xl w-full mx-2 md:mx-4
|
variants={slideUp}
|
||||||
max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col
|
initial="hidden"
|
||||||
shadow-xl shadow-black/40"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--border)]">
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-[var(--text-secondary)] hover:text-[var(--accent)] cursor-pointer
|
className="text-[var(--text-secondary)] hover:text-[var(--accent)] cursor-pointer
|
||||||
@@ -565,7 +776,7 @@ function GalleryDetailModal({
|
|||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
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)]
|
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"
|
cursor-pointer transition-all"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@@ -602,7 +813,7 @@ function GalleryDetailModal({
|
|||||||
{asset.prompt && (
|
{asset.prompt && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<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
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(asset.prompt)}
|
onClick={() => navigator.clipboard.writeText(asset.prompt)}
|
||||||
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
|
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||||
@@ -621,7 +832,7 @@ function GalleryDetailModal({
|
|||||||
{/* 标签 */}
|
{/* 标签 */}
|
||||||
{asset.tags.length > 0 && (
|
{asset.tags.length > 0 && (
|
||||||
<div>
|
<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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{asset.tags.map((tid) => {
|
{asset.tags.map((tid) => {
|
||||||
const tag = tagMap.get(tid);
|
const tag = tagMap.get(tid);
|
||||||
@@ -648,6 +859,225 @@ function GalleryDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* ===== 青绿山水色彩体系 ===== */
|
/* ===== 青绿山水色彩体系(白底·绢本) ===== */
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0C1210;
|
--bg-primary: #FAFAF7;
|
||||||
--bg-secondary: #131E1A;
|
--bg-secondary: #F2F1EC;
|
||||||
--bg-tertiary: #1C2A24;
|
--bg-tertiary: #E8E6DF;
|
||||||
--bg-card: rgba(16, 30, 24, 0.65);
|
--bg-card: rgba(255, 255, 255, 0.8);
|
||||||
--text-primary: #E0E8E2;
|
--text-primary: #1A1A1A;
|
||||||
--text-secondary: #7A9485;
|
--text-secondary: #6B7B8A;
|
||||||
--accent: #4DB8A4;
|
--accent: #2E8B7A;
|
||||||
--accent-hover: #6CD4BE;
|
--accent-hover: #3AA38E;
|
||||||
--accent-secondary: #3A8FB7;
|
--accent-secondary: #3A7FB8;
|
||||||
--hot: #C4654A;
|
--hot: #C4654A;
|
||||||
--border: rgba(77, 184, 164, 0.12);
|
--border: rgba(46, 139, 122, 0.15);
|
||||||
--border-glow: rgba(77, 184, 164, 0.25);
|
--border-glow: rgba(46, 139, 122, 0.30);
|
||||||
--mist: rgba(77, 184, 164, 0.06);
|
--mist: rgba(46, 139, 122, 0.04);
|
||||||
--gold: #B8935A;
|
--gold: #B8935A;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,8 +22,126 @@ body {
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-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); }
|
100% { transform: translate(0, 0) scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
.fog-layer-far {
|
||||||
content: '';
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: -10%;
|
inset: -10%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(77, 184, 164, 0.07) 0%, transparent 60%),
|
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, 143, 183, 0.05) 0%, transparent 55%),
|
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(77, 184, 164, 0.03) 0%, transparent 70%);
|
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(46, 139, 122, 0.02) 0%, transparent 70%);
|
||||||
animation: fogDriftFar 60s ease-in-out infinite;
|
animation: fogDriftFar 60s ease-in-out infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
body::after {
|
.fog-layer-near {
|
||||||
content: '';
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: -15%;
|
inset: -15%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(77, 184, 164, 0.06) 0%, transparent 55%),
|
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, 143, 183, 0.04) 0%, transparent 50%);
|
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 127, 184, 0.025) 0%, transparent 50%);
|
||||||
animation: fogDriftNear 45s ease-in-out infinite;
|
animation: fogDriftNear 45s ease-in-out infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 山雾玻璃面板 ===== */
|
/* ===== 山雾玻璃面板(兼容旧用法) ===== */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background:
|
background: rgba(255, 255, 255, 0.65);
|
||||||
linear-gradient(165deg, rgba(16, 30, 24, 0.15) 0%, transparent 50%),
|
backdrop-filter: blur(20px) saturate(1.1);
|
||||||
rgba(16, 30, 24, 0.55);
|
-webkit-backdrop-filter: blur(20px) saturate(1.1);
|
||||||
backdrop-filter: blur(24px) saturate(1.1);
|
|
||||||
-webkit-backdrop-filter: blur(24px) saturate(1.1);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 静态霓虹边框(卡片等普通元素) ===== */
|
/* ===== 静态发光边框(卡片等普通元素) ===== */
|
||||||
.neon-border {
|
.neon-border {
|
||||||
border: 1px solid var(--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 {
|
.neon-border:hover {
|
||||||
border-color: var(--border-glow);
|
border-color: var(--border-glow);
|
||||||
box-shadow: 0 0 18px rgba(77, 184, 164, 0.08),
|
box-shadow: 0 0 18px rgba(46, 139, 122, 0.08),
|
||||||
inset 0 0 12px rgba(77, 184, 164, 0.03);
|
inset 0 0 12px rgba(46, 139, 122, 0.03);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 云烟缭绕边框 ===== */
|
/* ===== 云烟缭绕边框 ===== */
|
||||||
|
|
||||||
/*
|
|
||||||
* .glow-border — 选中态的云烟边框
|
|
||||||
* 常态:石青色微弱均匀发光
|
|
||||||
* hover:云烟缓慢缭绕,opacity 平滑过渡
|
|
||||||
* 移开:云烟淡散回静态
|
|
||||||
*/
|
|
||||||
|
|
||||||
@property --glow-angle {
|
@property --glow-angle {
|
||||||
syntax: "<angle>";
|
syntax: "<angle>";
|
||||||
initial-value: 0deg;
|
initial-value: 0deg;
|
||||||
@@ -107,19 +215,16 @@ body::after {
|
|||||||
.glow-border {
|
.glow-border {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid rgba(77, 184, 164, 0.18) !important;
|
border: 1px solid rgba(46, 139, 122, 0.20) !important;
|
||||||
box-shadow: 0 0 10px rgba(77, 184, 164, 0.05),
|
box-shadow: 0 0 10px rgba(46, 139, 122, 0.06);
|
||||||
inset 0 0 8px rgba(77, 184, 164, 0.02);
|
|
||||||
transition: border-color 400ms ease, box-shadow 400ms ease;
|
transition: border-color 400ms ease, box-shadow 400ms ease;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
.glow-border:hover {
|
.glow-border:hover {
|
||||||
border-color: rgba(77, 184, 164, 0.28) !important;
|
border-color: rgba(46, 139, 122, 0.32) !important;
|
||||||
box-shadow: 0 0 16px rgba(77, 184, 164, 0.08),
|
box-shadow: 0 0 16px rgba(46, 139, 122, 0.10);
|
||||||
inset 0 0 12px rgba(77, 184, 164, 0.03);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 云烟边框层 — 宽弧段柔化渐变,6s 缓慢旋转 */
|
|
||||||
.glow-border::before {
|
.glow-border::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -130,11 +235,11 @@ body::after {
|
|||||||
from var(--glow-angle),
|
from var(--glow-angle),
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
transparent 15%,
|
transparent 15%,
|
||||||
rgba(77, 184, 164, 0.2) 25%,
|
rgba(46, 139, 122, 0.2) 25%,
|
||||||
rgba(58, 143, 183, 0.35) 38%,
|
rgba(58, 127, 184, 0.3) 38%,
|
||||||
rgba(77, 184, 164, 0.4) 50%,
|
rgba(46, 139, 122, 0.35) 50%,
|
||||||
rgba(58, 143, 183, 0.35) 62%,
|
rgba(58, 127, 184, 0.3) 62%,
|
||||||
rgba(77, 184, 164, 0.2) 75%,
|
rgba(46, 139, 122, 0.2) 75%,
|
||||||
transparent 85%,
|
transparent 85%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
@@ -149,7 +254,6 @@ body::after {
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 雾气扩散层 — 更大范围、更模糊 */
|
|
||||||
.glow-border::after {
|
.glow-border::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -159,9 +263,9 @@ body::after {
|
|||||||
from var(--glow-angle),
|
from var(--glow-angle),
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
transparent 20%,
|
transparent 20%,
|
||||||
rgba(77, 184, 164, 0.06) 35%,
|
rgba(46, 139, 122, 0.05) 35%,
|
||||||
rgba(58, 143, 183, 0.08) 50%,
|
rgba(58, 127, 184, 0.06) 50%,
|
||||||
rgba(77, 184, 164, 0.06) 65%,
|
rgba(46, 139, 122, 0.05) 65%,
|
||||||
transparent 80%,
|
transparent 80%,
|
||||||
transparent 100%
|
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 {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -203,11 +445,11 @@ body::after {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(77, 184, 164, 0.15);
|
background: rgba(46, 139, 122, 0.15);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(77, 184, 164, 0.28);
|
background: rgba(46, 139, 122, 0.30);
|
||||||
}
|
}
|
||||||
|
|
||||||
select option {
|
select option {
|
||||||
@@ -215,6 +457,7 @@ select option {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 通用动画 ===== */
|
||||||
.sidebar-overlay {
|
.sidebar-overlay {
|
||||||
animation: fadeIn 200ms ease-out;
|
animation: fadeIn 200ms ease-out;
|
||||||
}
|
}
|
||||||
@@ -226,7 +469,127 @@ select option {
|
|||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
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) {
|
@supports (height: 100dvh) {
|
||||||
.h-screen {
|
.h-screen {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Script from "next/script";
|
||||||
import { AuthProvider } from "@/lib/auth-context";
|
import { AuthProvider } from "@/lib/auth-context";
|
||||||
import { AuthGuard } from "@/lib/auth-guard";
|
import { AuthGuard } from "@/lib/auth-guard";
|
||||||
import { AppProvider } from "@/lib/app-context";
|
import { AppProvider } from "@/lib/app-context";
|
||||||
|
import { CommandPalette } from "@/components/docs/command-palette";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -26,9 +28,18 @@ export default function RootLayout({
|
|||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<AppProvider>{children}</AppProvider>
|
<AppProvider>
|
||||||
|
{children}
|
||||||
|
<CommandPalette />
|
||||||
|
</AppProvider>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
{/* <model-viewer> 自定义元素,供 3D 结果面板使用(占位) */}
|
||||||
|
<Script
|
||||||
|
type="module"
|
||||||
|
src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"
|
||||||
|
strategy="lazyOnload"
|
||||||
|
/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, type FormEvent } from "react";
|
import { useState, useEffect, type FormEvent } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
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() {
|
export default function LoginPage() {
|
||||||
const { login, isAuthenticated } = useAuth();
|
const { login, isAuthenticated } = useAuth();
|
||||||
@@ -32,6 +35,7 @@ export default function LoginPage() {
|
|||||||
router.replace("/");
|
router.replace("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "登录失败");
|
setError(err instanceof Error ? err.message : "登录失败");
|
||||||
|
setTimeout(() => setError(""), 4000);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -39,24 +43,19 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)] relative">
|
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)] relative">
|
||||||
{/* 背景环境光 */}
|
<AmbientParticles count={25} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="w-full max-w-sm mx-4 relative z-10">
|
<motion.div
|
||||||
<div className="text-center mb-8">
|
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 发光 */}
|
{/* Logo 发光 */}
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4
|
<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
|
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">
|
<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)" />
|
<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" />
|
<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)" />
|
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">EPEEKit</h1>
|
<h1 className="typo-display mb-1">EPEEKit</h1>
|
||||||
<p className="text-sm text-[var(--text-secondary)] mt-1.5">AI 美术资源生成工具</p>
|
<p className="typo-caption mt-1.5">AI 美术资源生成工具</p>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}
|
<motion.form
|
||||||
className="glass-panel rounded-2xl p-6 space-y-4 shadow-xl shadow-black/20">
|
variants={staggerItem}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="surface-2 rounded-2xl p-6 space-y-4"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -82,7 +84,7 @@ export default function LoginPage() {
|
|||||||
bg-[var(--bg-primary)] border border-[var(--border)]
|
bg-[var(--bg-primary)] border border-[var(--border)]
|
||||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||||
focus:outline-none focus:border-[var(--accent)]/50
|
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"
|
transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,32 +99,42 @@ export default function LoginPage() {
|
|||||||
bg-[var(--bg-primary)] border border-[var(--border)]
|
bg-[var(--bg-primary)] border border-[var(--border)]
|
||||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||||
focus:outline-none focus:border-[var(--accent)]/50
|
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"
|
transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm text-[var(--hot)] bg-[var(--hot)]/10 rounded-xl px-4 py-2.5
|
<motion.div
|
||||||
border border-[var(--hot)]/20">
|
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}
|
{error}
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<button
|
<motion.button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !username.trim() || !password}
|
disabled={loading || !username.trim() || !password}
|
||||||
|
whileTap={buttonTap}
|
||||||
className="w-full py-3 rounded-xl font-semibold
|
className="w-full py-3 rounded-xl font-semibold
|
||||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||||
hover:bg-[var(--accent-hover)]
|
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
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
transition-all cursor-pointer"
|
transition-all cursor-pointer btn-hover-lift"
|
||||||
>
|
>
|
||||||
{loading ? "登录中..." : "登录"}
|
{loading ? "登录中..." : "登录"}
|
||||||
</button>
|
</motion.button>
|
||||||
</form>
|
</motion.form>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { ChatMessages } from "@/components/chat/chat-messages";
|
import { ChatMessages } from "@/components/chat/chat-messages";
|
||||||
import { ChatInput, type ChatInputHandle } from "@/components/chat/chat-input";
|
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 { Sidebar } from "@/components/sidebar/sidebar";
|
||||||
import { TopNav } from "@/components/layout/top-nav";
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
|
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
|
||||||
|
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||||
import { useApp } from "@/lib/app-context";
|
import { useApp } from "@/lib/app-context";
|
||||||
import { sendChat, getImageUrl, uploadRefImage } from "@/lib/api";
|
import { sendChat, getImageUrl, uploadRefImage } from "@/lib/api";
|
||||||
import { generateId } from "@/lib/store";
|
import { generateId } from "@/lib/store";
|
||||||
|
import { scrollBtnEnter, scrollBtnClick, staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||||
import type { ChatMessage, ImageAsset, ApiMessage, AnnotationData } from "@/lib/types";
|
import type { ChatMessage, ImageAsset, ApiMessage, AnnotationData } from "@/lib/types";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -21,6 +30,7 @@ export default function Home() {
|
|||||||
detailImage,
|
detailImage,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
|
sessionTaskType,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
const messages = activeSession?.messages ?? [];
|
const messages = activeSession?.messages ?? [];
|
||||||
@@ -29,6 +39,7 @@ export default function Home() {
|
|||||||
const [streamingText, setStreamingText] = useState("");
|
const [streamingText, setStreamingText] = useState("");
|
||||||
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
|
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
|
||||||
const [statusText, setStatusText] = useState("");
|
const [statusText, setStatusText] = useState("");
|
||||||
|
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
|
||||||
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
|
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
|
||||||
const [lastRefImageUrls, setLastRefImageUrls] = useState<string[]>([]);
|
const [lastRefImageUrls, setLastRefImageUrls] = useState<string[]>([]);
|
||||||
const lastRefPerSession = useRef<Map<string, string[]>>(new Map());
|
const lastRefPerSession = useRef<Map<string, string[]>>(new Map());
|
||||||
@@ -40,6 +51,7 @@ export default function Home() {
|
|||||||
const prevSessionId = useRef<string | null>(null);
|
const prevSessionId = useRef<string | null>(null);
|
||||||
const isSwitching = useRef(false);
|
const isSwitching = useRef(false);
|
||||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||||
|
const [showCandidates, setShowCandidates] = useState(false);
|
||||||
|
|
||||||
const scrollToBottom = useCallback((instant?: boolean) => {
|
const scrollToBottom = useCallback((instant?: boolean) => {
|
||||||
if (isSwitching.current && !instant) return;
|
if (isSwitching.current && !instant) return;
|
||||||
@@ -57,7 +69,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 实时记录当前会话的滚动位置 + 判断是否显示"回到底部"按钮
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el || !activeSessionId) return;
|
if (!el || !activeSessionId) return;
|
||||||
@@ -72,7 +83,6 @@ export default function Home() {
|
|||||||
return () => el.removeEventListener("scroll", handler);
|
return () => el.removeEventListener("scroll", handler);
|
||||||
}, [activeSessionId]);
|
}, [activeSessionId]);
|
||||||
|
|
||||||
// 会话切换:标记 switching,等 DOM 更新后恢复位置 + 恢复参考图状态
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeSessionId) return;
|
if (!activeSessionId) return;
|
||||||
if (prevSessionId.current && prevSessionId.current !== activeSessionId) {
|
if (prevSessionId.current && prevSessionId.current !== activeSessionId) {
|
||||||
@@ -152,6 +162,7 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
appendMessage(activeSessionId, userMessage);
|
appendMessage(activeSessionId, userMessage);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setIsGeneratingImage(false);
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
setStreamingImages([]);
|
setStreamingImages([]);
|
||||||
setStatusText("");
|
setStatusText("");
|
||||||
@@ -179,6 +190,7 @@ export default function Home() {
|
|||||||
|
|
||||||
case "tool_start":
|
case "tool_start":
|
||||||
setStatusText(event.data.message as string);
|
setStatusText(event.data.message as string);
|
||||||
|
setIsGeneratingImage(true);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -207,6 +219,7 @@ export default function Home() {
|
|||||||
|
|
||||||
collectedImages = [...collectedImages, ...newAssets];
|
collectedImages = [...collectedImages, ...newAssets];
|
||||||
setStreamingImages([...collectedImages]);
|
setStreamingImages([...collectedImages]);
|
||||||
|
setIsGeneratingImage(false);
|
||||||
if (modelName) {
|
if (modelName) {
|
||||||
usedModelName = modelName;
|
usedModelName = modelName;
|
||||||
setStatusText(`由 ${modelName} 生成`);
|
setStatusText(`由 ${modelName} 生成`);
|
||||||
@@ -225,6 +238,7 @@ export default function Home() {
|
|||||||
assistantText += `\n\n⚠️ 图片生成失败${modelHint}:\n${errorDetail}`;
|
assistantText += `\n\n⚠️ 图片生成失败${modelHint}:\n${errorDetail}`;
|
||||||
setStreamingText(assistantText);
|
setStreamingText(assistantText);
|
||||||
setStatusText("");
|
setStatusText("");
|
||||||
|
setIsGeneratingImage(false);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -256,6 +270,7 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
appendMessage(activeSessionId, assistantMessage);
|
appendMessage(activeSessionId, assistantMessage);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setIsGeneratingImage(false);
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
setStreamingImages([]);
|
setStreamingImages([]);
|
||||||
setStatusText("");
|
setStatusText("");
|
||||||
@@ -268,8 +283,13 @@ export default function Home() {
|
|||||||
setPendingAnnotation(data);
|
setPendingAnnotation(data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isEmptyState = messages.length === 0 && !isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col relative z-[1]">
|
<div className="h-screen flex flex-col relative z-[1]">
|
||||||
|
{/* 环境粒子(萤火 + 云雾) */}
|
||||||
|
<AmbientParticles count={isEmptyState ? 28 : 18} />
|
||||||
|
|
||||||
<TopNav />
|
<TopNav />
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
@@ -323,7 +343,7 @@ export default function Home() {
|
|||||||
border border-[var(--border)]
|
border border-[var(--border)]
|
||||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||||
hover:border-[var(--accent)]/40
|
hover:border-[var(--accent)]/40
|
||||||
transition-all cursor-pointer
|
transition-all cursor-pointer btn-hover-lift
|
||||||
hidden md:flex"
|
hidden md:flex"
|
||||||
title="展开侧边栏"
|
title="展开侧边栏"
|
||||||
>
|
>
|
||||||
@@ -341,7 +361,7 @@ export default function Home() {
|
|||||||
src={pendingAnnotation.snapshot}
|
src={pendingAnnotation.snapshot}
|
||||||
alt="标注预览"
|
alt="标注预览"
|
||||||
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/40
|
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)]">
|
<span className="text-xs text-[var(--accent)]">
|
||||||
标注已就绪({pendingAnnotation.annotations.length} 处)— 输入修改意见后发送
|
标注已就绪({pendingAnnotation.annotations.length} 处)— 输入修改意见后发送
|
||||||
@@ -356,30 +376,38 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll relative">
|
||||||
{messages.length === 0 && !isLoading ? (
|
<div className="min-h-full">
|
||||||
<div className="h-full flex items-center justify-center">
|
{isEmptyState ? (
|
||||||
<div className="text-center space-y-4">
|
<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 发光效果 */}
|
{/* 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
|
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">
|
<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)" />
|
<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" />
|
<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" />
|
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
|
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</motion.div>
|
||||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
<motion.div variants={staggerItem}>
|
||||||
|
<h2 className="typo-display mb-2">
|
||||||
欢迎使用 EPEEKit
|
欢迎使用 EPEEKit
|
||||||
</h2>
|
</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 />
|
<br />
|
||||||
你可以上传参考图来引导风格方向。
|
你可以上传参考图来引导风格方向。
|
||||||
</p>
|
</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)]
|
border border-[var(--border)] text-[var(--text-secondary)]
|
||||||
hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
|
hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
|
||||||
hover:bg-[var(--accent)]/5
|
hover:bg-[var(--accent)]/5
|
||||||
transition-all cursor-pointer"
|
transition-all cursor-pointer btn-hover-lift"
|
||||||
>
|
>
|
||||||
{hint}
|
{hint}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ChatMessages
|
<ChatMessages
|
||||||
@@ -410,29 +438,76 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 回到底部 — 落叶归根(水滴形) */}
|
||||||
|
<AnimatePresence>
|
||||||
{showScrollBtn && (
|
{showScrollBtn && (
|
||||||
<button
|
<motion.button
|
||||||
onClick={() => scrollToBottom()}
|
key="scroll-btn"
|
||||||
|
variants={scrollBtnEnter}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
onClick={() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}}
|
||||||
className="absolute bottom-16 right-4 z-10
|
className="absolute bottom-16 right-4 z-10
|
||||||
w-9 h-9 rounded-xl flex items-center justify-center
|
w-9 h-11 flex items-center justify-center
|
||||||
glass-panel text-[var(--text-secondary)]
|
surface-2 text-[var(--text-secondary)]
|
||||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||||
shadow-lg shadow-black/30
|
shadow-lg shadow-black/30
|
||||||
transition-all duration-200 cursor-pointer
|
cursor-pointer"
|
||||||
animate-[fadeIn_150ms_ease-out]"
|
style={{ borderRadius: "50% 50% 50% 50% / 35% 35% 65% 65%" }}
|
||||||
title="回到底部"
|
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" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</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
|
<ChatInput
|
||||||
ref={chatInputRef}
|
ref={chatInputRef}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
isGeneratingImage={isGeneratingImage}
|
||||||
lastRefServerUrls={lastRefImageUrls}
|
lastRefServerUrls={lastRefImageUrls}
|
||||||
lastRefPreviewUrls={lastRefImageUrls.map((u) => getImageUrl(u))}
|
lastRefPreviewUrls={lastRefImageUrls.map((u) => getImageUrl(u))}
|
||||||
onClearLastRefImage={() => {
|
onClearLastRefImage={() => {
|
||||||
@@ -450,6 +525,24 @@ export default function Home() {
|
|||||||
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
|
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
308
art-agent/frontend/src/app/settings/page.tsx
Normal file
308
art-agent/frontend/src/app/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
464
art-agent/frontend/src/app/styles/[id]/page.tsx
Normal file
464
art-agent/frontend/src/app/styles/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
art-agent/frontend/src/app/styles/page.tsx
Normal file
251
art-agent/frontend/src/app/styles/page.tsx
Normal 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">试试调整筛选条件或点击“新建风格”</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
art-agent/frontend/src/app/training/[id]/page.tsx
Normal file
347
art-agent/frontend/src/app/training/[id]/page.tsx
Normal 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.9、v0.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
art-agent/frontend/src/app/training/new/page.tsx
Normal file
353
art-agent/frontend/src/app/training/new/page.tsx
Normal 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">
|
||||||
|
建议 8–30 张(占位)。当前为模拟值,真实训练集选择待 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
art-agent/frontend/src/app/training/page.tsx
Normal file
186
art-agent/frontend/src/app/training/page.tsx
Normal 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">点击“新建训练任务”开始</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useCallback, useImperativeHandle, forwardRef, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
|
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 { 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 { uploadRefImage, type UploadProgress } from "@/lib/api";
|
||||||
|
import { sendBounce } from "@/components/ui/motion-presets";
|
||||||
|
|
||||||
type UploadStatus = "idle" | "uploading" | "done" | "error";
|
type UploadStatus = "idle" | "uploading" | "done" | "error";
|
||||||
|
|
||||||
@@ -26,6 +30,8 @@ interface ChatInputProps {
|
|||||||
onClearLastRefImage?: () => void;
|
onClearLastRefImage?: () => void;
|
||||||
/** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */
|
/** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */
|
||||||
onFileDrop?: () => void;
|
onFileDrop?: () => void;
|
||||||
|
/** 是否正在生成图片(用于数据流光加速) */
|
||||||
|
isGeneratingImage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatInputHandle {
|
export interface ChatInputHandle {
|
||||||
@@ -34,7 +40,6 @@ export interface ChatInputHandle {
|
|||||||
|
|
||||||
let _uploadCounter = 0;
|
let _uploadCounter = 0;
|
||||||
|
|
||||||
/** 从剪贴板提取可上传的图片文件(Ctrl+V / 右键粘贴共用) */
|
|
||||||
function imageFilesFromClipboard(data: DataTransfer | null): File[] {
|
function imageFilesFromClipboard(data: DataTransfer | null): File[] {
|
||||||
if (!data?.items?.length) return [];
|
if (!data?.items?.length) return [];
|
||||||
const out: File[] = [];
|
const out: File[] = [];
|
||||||
@@ -49,25 +54,38 @@ function imageFilesFromClipboard(data: DataTransfer | null): File[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
|
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
|
||||||
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop },
|
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop, isGeneratingImage },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const { ripples, trigger: triggerRipple } = useRipple();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sendBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const abortRefs = useRef<Map<string, AbortController>>(new Map());
|
const abortRefs = useRef<Map<string, AbortController>>(new Map());
|
||||||
|
|
||||||
const hasActiveUpload = uploads.some((u) => u.status === "uploading");
|
const hasActiveUpload = uploads.some((u) => u.status === "uploading");
|
||||||
const allDone = uploads.length > 0 && uploads.every((u) => u.status === "done" || u.status === "error");
|
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 doneUrls = uploads.filter((u) => u.status === "done" && u.serverUrl).map((u) => u.serverUrl!);
|
||||||
|
|
||||||
|
const canSend = text.trim() && !disabled && !hasActiveUpload;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || disabled) return;
|
if (!trimmed || disabled) return;
|
||||||
if (hasActiveUpload) 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 ?? []);
|
const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []);
|
||||||
onSend(trimmed, refUrls, selectedModel || null);
|
onSend(trimmed, refUrls, selectedModel || null);
|
||||||
setText("");
|
setText("");
|
||||||
@@ -183,7 +201,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl
|
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" : ""
|
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
|
||||||
}`}
|
}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -191,6 +209,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onPasteCapture={handlePasteCapture}
|
onPasteCapture={handlePasteCapture}
|
||||||
>
|
>
|
||||||
|
{/* 数据流光 */}
|
||||||
|
<DataStream active={disabled} speed={isGeneratingImage ? "fast" : "slow"} />
|
||||||
|
|
||||||
|
<div className="relative px-3 md:px-5 py-2.5 md:py-3">
|
||||||
{isDragging && (
|
{isDragging && (
|
||||||
<div className="mb-2 text-center text-xs text-[var(--accent)]">
|
<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}
|
src={url}
|
||||||
alt={`沿用参考图 ${i + 1}`}
|
alt={`沿用参考图 ${i + 1}`}
|
||||||
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
|
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>
|
</div>
|
||||||
@@ -242,7 +264,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
|
|||||||
{item.status === "uploading" && (
|
{item.status === "uploading" && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
<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
|
<circle
|
||||||
cx="18" cy="18" r="14" fill="none"
|
cx="18" cy="18" r="14" fill="none"
|
||||||
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||||
@@ -255,7 +277,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
|
|||||||
{item.status === "done" && (
|
{item.status === "done" && (
|
||||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--accent)]
|
<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
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -279,12 +301,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* 汇总状态 */}
|
|
||||||
<div className="flex flex-col justify-center gap-1 min-w-0">
|
<div className="flex flex-col justify-center gap-1 min-w-0">
|
||||||
{hasActiveUpload && (
|
{hasActiveUpload && (
|
||||||
<span className="text-xs text-[var(--accent)] flex items-center gap-1.5">
|
<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
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -314,7 +335,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
|
|||||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||||
hover:bg-[var(--accent)]/5
|
hover:bg-[var(--accent)]/5
|
||||||
flex items-center justify-center transition-all
|
flex items-center justify-center transition-all
|
||||||
disabled:opacity-50 cursor-pointer ${
|
disabled:opacity-50 cursor-pointer btn-hover-lift ${
|
||||||
doneUrls.length > 0
|
doneUrls.length > 0
|
||||||
? "border-[var(--accent)]/40 text-[var(--accent)]"
|
? "border-[var(--accent)]/40 text-[var(--accent)]"
|
||||||
: "border-[var(--border)]"
|
: "border-[var(--border)]"
|
||||||
@@ -342,41 +363,56 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 文字输入框 */}
|
{/* 文字输入框 — 水面感应 */}
|
||||||
|
<div className={`relative flex-1 ${isFocused ? "water-focus-active" : ""}`}>
|
||||||
<textarea
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
placeholder="描述你想要的美术资源...(可粘贴图片)"
|
placeholder="描述你想要的美术资源...(可粘贴图片)"
|
||||||
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
|
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
rows={1}
|
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)]
|
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
|
||||||
placeholder:text-[var(--text-secondary)]
|
placeholder:text-[var(--text-secondary)]
|
||||||
px-4 py-2.5 text-sm leading-relaxed
|
px-4 py-2.5 text-sm leading-relaxed
|
||||||
focus:outline-none focus:border-[var(--accent)]/50
|
focus:outline-none focus:border-[var(--accent)]/50
|
||||||
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
|
|
||||||
disabled:opacity-50 transition-all
|
disabled:opacity-50 transition-all
|
||||||
min-h-[42px] max-h-[120px]"
|
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}
|
onClick={handleSubmit}
|
||||||
disabled={disabled || !text.trim() || hasActiveUpload}
|
disabled={!canSend}
|
||||||
className="flex-shrink-0 w-10 h-10 rounded-xl
|
whileTap={canSend ? sendBounce : undefined}
|
||||||
|
className={`flex-shrink-0 w-10 h-10 rounded-xl
|
||||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||||
hover:bg-[var(--accent-hover)]
|
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
|
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">
|
<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" />
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
{/* 涟漪层 */}
|
||||||
|
<RippleLayer ripples={ripples} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
"use client";
|
"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 type { ChatMessage, ImageAsset } from "@/lib/types";
|
||||||
import { ImageGrid } from "./image-grid";
|
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 }) {
|
function CopyButton({ text }: { text: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -21,7 +42,7 @@ function CopyButton({ text }: { text: string }) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
|
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="复制文本"
|
title="复制文本"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
@@ -56,17 +77,19 @@ export function ChatMessages({
|
|||||||
return (
|
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">
|
<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) => (
|
{messages.map((msg) => (
|
||||||
<div
|
<motion.div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
|
variants={msg.role === "user" ? messageEnter : assistantEnter}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
className={`group/msg flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
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="flex flex-col items-start gap-0.5">
|
||||||
<div
|
<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"
|
msg.role === "user"
|
||||||
? "bg-[var(--accent)]/15 text-[var(--text-primary)] border border-[var(--accent)]/25"
|
? "user-bubble bg-gradient-to-r from-[var(--accent)]/8 to-[var(--accent)]/15 text-[var(--text-primary)]"
|
||||||
: "glass-panel 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}`}
|
alt={`参考图 ${i + 1}`}
|
||||||
className="max-w-[120px] max-h-[90px] rounded-lg object-cover
|
className="max-w-[120px] max-h-[90px] rounded-lg object-cover
|
||||||
border border-[var(--accent)]/20
|
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>
|
</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} 张)
|
参考图({msg.refImageUrls.length} 张)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +120,7 @@ export function ChatMessages({
|
|||||||
alt="参考图"
|
alt="参考图"
|
||||||
className="max-w-[160px] max-h-[120px] rounded-lg object-cover
|
className="max-w-[160px] max-h-[120px] rounded-lg object-cover
|
||||||
border border-[var(--accent)]/20
|
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>
|
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block">参考图</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +130,7 @@ export function ChatMessages({
|
|||||||
<>
|
<>
|
||||||
<ImageGrid images={msg.images} />
|
<ImageGrid images={msg.images} />
|
||||||
{msg.modelName && (
|
{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} 生成
|
由 {msg.modelName} 生成
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -121,33 +144,43 @@ export function ChatMessages({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* AI 正在回复(流式) */}
|
||||||
|
<AnimatePresence>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex justify-start" style={{ animation: "slideUp 200ms ease-out" }}>
|
<motion.div
|
||||||
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 glass-panel">
|
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 && (
|
{statusText && (
|
||||||
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
|
<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
|
<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}
|
{statusText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{streamingText && (
|
{streamingText && (
|
||||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">{streamingText}</div>
|
<StreamingLines text={streamingText} />
|
||||||
)}
|
)}
|
||||||
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
|
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
|
||||||
{!streamingText && !statusText && (
|
{!streamingText && !statusText && (
|
||||||
<div className="flex gap-1.5">
|
<div className="flex items-end gap-1 h-5">
|
||||||
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce" />
|
<div className="wave-dot" style={{ animationDelay: "0s" }} />
|
||||||
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce [animation-delay:0.1s]" />
|
<div className="wave-dot" style={{ animationDelay: "0.15s" }} />
|
||||||
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce [animation-delay:0.2s]" />
|
<div className="wave-dot" style={{ animationDelay: "0.3s" }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,43 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { getImageUrl } from "@/lib/api";
|
import { getImageUrl } from "@/lib/api";
|
||||||
import { useApp } from "@/lib/app-context";
|
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";
|
import type { ImageAsset } from "@/lib/types";
|
||||||
|
|
||||||
interface ImageGridProps {
|
interface ImageGridProps {
|
||||||
images: ImageAsset[];
|
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) {
|
export function ImageGrid({ images }: ImageGridProps) {
|
||||||
const { setDetailImage, toggleFavorite } = useApp();
|
const { setDetailImage } = useApp();
|
||||||
|
|
||||||
const handleDownload = async (imageUrl: string, index: number) => {
|
const handleDownload = async (imageUrl: string, index: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -35,13 +63,20 @@ export function ImageGrid({ images }: ImageGridProps) {
|
|||||||
: "grid-cols-2 max-w-[320px] md:max-w-xl";
|
: "grid-cols-2 max-w-[320px] md:max-w-xl";
|
||||||
|
|
||||||
return (
|
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) => (
|
{images.map((asset, i) => (
|
||||||
<div
|
<motion.div
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
|
variants={staggerItem}
|
||||||
className="group relative rounded-xl overflow-hidden neon-border
|
className="group relative rounded-xl overflow-hidden neon-border
|
||||||
bg-[var(--bg-card)] backdrop-blur-sm"
|
bg-[var(--bg-card)] backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
|
<ImageReveal src={getImageUrl(asset.url)} alt={`生成图片 ${i + 1}`}>
|
||||||
<img
|
<img
|
||||||
src={getImageUrl(asset.url)}
|
src={getImageUrl(asset.url)}
|
||||||
alt={`生成图片 ${i + 1}`}
|
alt={`生成图片 ${i + 1}`}
|
||||||
@@ -50,6 +85,7 @@ export function ImageGrid({ images }: ImageGridProps) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
onClick={() => setDetailImage(asset)}
|
onClick={() => setDetailImage(asset)}
|
||||||
/>
|
/>
|
||||||
|
</ImageReveal>
|
||||||
|
|
||||||
{/* 底部渐变 + 操作栏 */}
|
{/* 底部渐变 + 操作栏 */}
|
||||||
<div
|
<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" />
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<FavoriteButton asset={asset} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 最新生成标签 */}
|
{/* 最新生成标签 */}
|
||||||
{i === 0 && images.length > 1 && (
|
{i === 0 && images.length > 1 && (
|
||||||
<div className="absolute top-2 left-2">
|
<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)]
|
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
|
New
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import type { ImageModelInfo } from "@/lib/types";
|
import type { ImageModelInfo } from "@/lib/types";
|
||||||
import { fetchModels } from "@/lib/api";
|
import { fetchModels } from "@/lib/api";
|
||||||
|
import { slideUp } from "@/components/ui/motion-presets";
|
||||||
|
|
||||||
import { getStoreUserId } from "@/lib/store";
|
import { getStoreUserId } from "@/lib/store";
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
|||||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||||
hover:border-[var(--accent)]/40 hover:bg-[var(--accent)]/5
|
hover:border-[var(--accent)]/40 hover:bg-[var(--accent)]/5
|
||||||
transition-all cursor-pointer"
|
transition-all cursor-pointer btn-hover-lift"
|
||||||
title="切换生图模型"
|
title="切换生图模型"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
{open && (
|
{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
|
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) => {
|
{models.map((m) => {
|
||||||
const isActive = m.id === value;
|
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">
|
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||||
{m.name}
|
{m.name}
|
||||||
{m.supports_ref_image && (
|
{m.supports_ref_image && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full
|
<span className="typo-micro px-1.5 py-0.5 rounded-full
|
||||||
bg-[var(--accent)]/10 text-[var(--accent)] font-medium leading-none
|
bg-[var(--accent)]/10 text-[var(--accent)] leading-none
|
||||||
border border-[var(--accent)]/20">
|
border border-[var(--accent)]/20"
|
||||||
|
style={{ textTransform: "none", fontSize: "10px" }}>
|
||||||
参考图
|
参考图
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -116,12 +124,13 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-[var(--text-secondary)]">{m.description}</span>
|
<span className="typo-caption">{m.description}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
257
art-agent/frontend/src/components/chat/session-quick-picks.tsx
Normal file
257
art-agent/frontend/src/components/chat/session-quick-picks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
art-agent/frontend/src/components/chat/task-card.tsx
Normal file
186
art-agent/frontend/src/components/chat/task-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
art-agent/frontend/src/components/chat/task-type-tabs.tsx
Normal file
85
art-agent/frontend/src/components/chat/task-type-tabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { getImageUrl } from "@/lib/api";
|
import { getImageUrl } from "@/lib/api";
|
||||||
import { useApp } from "@/lib/app-context";
|
import { useApp } from "@/lib/app-context";
|
||||||
import { AnnotationCanvas } from "./annotation-canvas";
|
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";
|
import type { Annotation, AnnotationData } from "@/lib/types";
|
||||||
|
|
||||||
interface ImageDetailPanelProps {
|
interface ImageDetailPanelProps {
|
||||||
@@ -14,6 +17,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
|||||||
const { detailImage, setDetailImage, toggleFavorite, tags, deleteAssetById } = useApp();
|
const { detailImage, setDetailImage, toggleFavorite, tags, deleteAssetById } = useApp();
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [showAnnotate, setShowAnnotate] = useState(false);
|
const [showAnnotate, setShowAnnotate] = useState(false);
|
||||||
|
const { sparks, burst } = useFireflyBurst();
|
||||||
|
|
||||||
if (!detailImage) return null;
|
if (!detailImage) return null;
|
||||||
|
|
||||||
@@ -67,10 +71,15 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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)]
|
md:w-[360px] flex-shrink-0 border-l border-[var(--border)]
|
||||||
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
|
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)]">
|
<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>
|
<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))}
|
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)]
|
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: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>
|
</button>
|
||||||
@@ -114,7 +123,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
|||||||
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
|
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)]
|
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: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>
|
</button>
|
||||||
@@ -122,7 +131,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
|||||||
onClick={() => setScale(1)}
|
onClick={() => setScale(1)}
|
||||||
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
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: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>
|
</button>
|
||||||
@@ -135,8 +144,8 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
|||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
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)]
|
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)]
|
||||||
transition-all cursor-pointer"
|
transition-all cursor-pointer btn-hover-lift"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleFavorite(detailImage.id)}
|
onClick={() => {
|
||||||
className={`flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
if (!detailImage.favorited) burst();
|
||||||
border transition-all cursor-pointer ${
|
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
|
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)]"
|
: "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" />
|
<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>
|
</svg>
|
||||||
{detailImage.favorited ? "已收藏" : "收藏"}
|
{detailImage.favorited ? "已收藏" : "收藏"}
|
||||||
|
<FireflyBurst sparks={sparks} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAnnotate(true)}
|
onClick={() => setShowAnnotate(true)}
|
||||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
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)]
|
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||||
hover:text-[var(--accent)] border border-[var(--border)]
|
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">
|
<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" />
|
<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
|
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
|
bg-[var(--bg-tertiary)] text-[var(--hot)]/60
|
||||||
hover:text-[var(--hot)] border border-[var(--border)]
|
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">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<polyline points="3 6 5 6 21 6" />
|
<polyline points="3 6 5 6 21 6" />
|
||||||
@@ -188,7 +201,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
|||||||
{detailImage.prompt && (
|
{detailImage.prompt && (
|
||||||
<div className="px-4 pb-3">
|
<div className="px-4 pb-3">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<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
|
<button
|
||||||
onClick={handleCopyPrompt}
|
onClick={handleCopyPrompt}
|
||||||
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
|
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||||
@@ -207,7 +220,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
|||||||
{/* 标签 */}
|
{/* 标签 */}
|
||||||
{detailImage.tags.length > 0 && (
|
{detailImage.tags.length > 0 && (
|
||||||
<div className="px-4 pb-3">
|
<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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{detailImage.tags.map((tid) => {
|
{detailImage.tags.map((tid) => {
|
||||||
const tag = tagMap.get(tid);
|
const tag = tagMap.get(tid);
|
||||||
@@ -228,12 +241,12 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
|||||||
|
|
||||||
{/* 元信息 */}
|
{/* 元信息 */}
|
||||||
<div className="px-4 pb-4">
|
<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 className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||||
<div>创建时间:{new Date(detailImage.createdAt).toLocaleString("zh-CN")}</div>
|
<div>创建时间:{new Date(detailImage.createdAt).toLocaleString("zh-CN")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</motion.aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
262
art-agent/frontend/src/components/detail/model-3d-panel.tsx
Normal file
262
art-agent/frontend/src/components/detail/model-3d-panel.tsx
Normal 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"
|
||||||
|
? "Trellis(3D 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
art-agent/frontend/src/components/detail/motion-panel.tsx
Normal file
259
art-agent/frontend/src/components/detail/motion-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
art-agent/frontend/src/components/docs/asset-picker.tsx
Normal file
206
art-agent/frontend/src/components/docs/asset-picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
494
art-agent/frontend/src/components/docs/command-palette.tsx
Normal file
494
art-agent/frontend/src/components/docs/command-palette.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
315
art-agent/frontend/src/components/docs/doc-editor.tsx
Normal file
315
art-agent/frontend/src/components/docs/doc-editor.tsx
Normal 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("")} 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" />;
|
||||||
|
}
|
||||||
216
art-agent/frontend/src/components/docs/doc-outline.tsx
Normal file
216
art-agent/frontend/src/components/docs/doc-outline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
art-agent/frontend/src/components/docs/doc-tabs.tsx
Normal file
87
art-agent/frontend/src/components/docs/doc-tabs.tsx
Normal 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=关闭此 Tab,2=关闭其他,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
464
art-agent/frontend/src/components/docs/doc-tree.tsx
Normal file
464
art-agent/frontend/src/components/docs/doc-tree.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
240
art-agent/frontend/src/components/docs/markdown-content.tsx
Normal file
240
art-agent/frontend/src/components/docs/markdown-content.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
art-agent/frontend/src/components/layout/project-switcher.tsx
Normal file
165
art-agent/frontend/src/components/layout/project-switcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,43 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useApp } from "@/lib/app-context";
|
import { useApp } from "@/lib/app-context";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import { ProfileModal } from "@/components/profile-modal";
|
import { ProfileModal } from "@/components/profile-modal";
|
||||||
|
import { ProjectSwitcher } from "./project-switcher";
|
||||||
|
import { slideUp } from "@/components/ui/motion-presets";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{
|
{
|
||||||
href: "/",
|
href: "/",
|
||||||
label: "对话",
|
label: "工作台",
|
||||||
|
phase: "Phase 0",
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||||
</svg>
|
</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",
|
href: "/gallery",
|
||||||
label: "资源库",
|
label: "资源库",
|
||||||
|
phase: "Phase 0",
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
@@ -28,6 +46,41 @@ const NAV_ITEMS = [
|
|||||||
</svg>
|
</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;
|
] as const;
|
||||||
|
|
||||||
export function TopNav() {
|
export function TopNav() {
|
||||||
@@ -51,7 +104,7 @@ export function TopNav() {
|
|||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
@@ -68,36 +121,51 @@ export function TopNav() {
|
|||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href="/" className="flex items-center gap-2.5 flex-shrink-0 group">
|
<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
|
<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)]
|
shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||||
group-hover:shadow-[0_0_20px_rgba(77,184,164,0.4)] transition-shadow">
|
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">
|
<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" />
|
<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="#0C1210" strokeWidth="1.8" fill="none" />
|
<circle cx="23" cy="12" r="3.5" stroke="white" strokeWidth="1.8" fill="none" />
|
||||||
<path d="M23 15.5v5" stroke="#0C1210" strokeWidth="1.8" strokeLinecap="round" />
|
<path d="M23 15.5v5" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
<circle cx="23" cy="22.5" r="1" fill="#0C1210" />
|
<circle cx="23" cy="22.5" r="1" fill="white" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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
|
EPEEKit
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* 项目切换器 */}
|
||||||
|
<ProjectSwitcher />
|
||||||
|
|
||||||
{/* 导航 Tab */}
|
{/* 导航 Tab */}
|
||||||
<nav className="flex items-center gap-1.5">
|
<nav className="flex items-center gap-1 overflow-x-auto hide-scrollbar">
|
||||||
{NAV_ITEMS.map(({ href, label, icon }) => {
|
{NAV_ITEMS.map(({ href, label, icon, phase }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={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
|
active
|
||||||
? "glow-border text-[var(--accent)] font-medium !bg-[var(--bg-secondary)]"
|
? "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}
|
{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>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -110,7 +178,7 @@ export function TopNav() {
|
|||||||
<div className="relative" ref={menuRef}>
|
<div className="relative" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
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)]
|
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||||
hover:bg-[var(--bg-tertiary)] border border-transparent
|
hover:bg-[var(--bg-tertiary)] border border-transparent
|
||||||
hover:border-[var(--border)] transition-all cursor-pointer"
|
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">
|
flex items-center justify-center text-[var(--accent)] text-xs font-semibold">
|
||||||
{(user.display_name || user.username).charAt(0).toUpperCase()}
|
{(user.display_name || user.username).charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden sm:inline max-w-[100px] truncate">
|
<span className="hidden sm:inline max-w-[80px] truncate">
|
||||||
{user.display_name || user.username}
|
{user.display_name || user.username}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute right-0 top-full mt-1.5 w-48 rounded-xl
|
<motion.div
|
||||||
glass-panel shadow-xl shadow-black/30 py-1.5 z-50">
|
variants={slideUp}
|
||||||
<button
|
initial="hidden"
|
||||||
onClick={() => { setMenuOpen(false); setProfileOpen(true); }}
|
animate="visible"
|
||||||
className="w-full text-left px-3.5 py-2 text-xs text-[var(--text-secondary)]
|
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
|
||||||
hover:text-[var(--accent)] hover:bg-[var(--bg-tertiary)]
|
className="absolute right-0 top-full mt-1.5 w-56 rounded-xl
|
||||||
border-b border-[var(--border)] transition-colors cursor-pointer
|
surface-3 py-1.5 z-50"
|
||||||
flex items-center gap-1.5"
|
|
||||||
>
|
>
|
||||||
|
<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">
|
<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" />
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||||
<circle cx="12" cy="7" r="4" />
|
<circle cx="12" cy="7" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{user.username}</span>
|
<span className="truncate">{user.username}</span>
|
||||||
{user.is_admin && <span className="text-[var(--accent)]">(管理员)</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>
|
</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
|
<button
|
||||||
onClick={() => { setMenuOpen(false); logout(); }}
|
onClick={() => { setMenuOpen(false); logout(); }}
|
||||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
|
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)]
|
hover:text-[var(--hot)] hover:bg-[var(--bg-tertiary)]
|
||||||
transition-colors cursor-pointer"
|
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>
|
</button>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
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";
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
@@ -107,14 +109,20 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
onClick={onClose}
|
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
|
<motion.div
|
||||||
className="relative w-[90vw] max-w-[560px] max-h-[80vh] rounded-2xl glass-panel
|
variants={slideUp}
|
||||||
shadow-[0_0_40px_rgba(77,184,164,0.1)] border-[var(--border-glow)]
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="relative w-[90vw] max-w-[560px] max-h-[80vh] rounded-2xl surface-3
|
||||||
flex flex-col overflow-hidden"
|
flex flex-col overflow-hidden"
|
||||||
style={{ animation: "slideUp 200ms ease-out" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
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="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
|
<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
|
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()}
|
{(user.display_name || user.username).charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{user.display_name || user.username}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<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 && (
|
{user.is_admin && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded-md bg-[var(--accent)]/10
|
<span className="typo-micro px-1.5 py-0.5 rounded-md bg-[var(--accent)]/10
|
||||||
text-[var(--accent)] border border-[var(--accent)]/30">
|
text-[var(--accent)] border border-[var(--accent)]/30"
|
||||||
|
style={{ textTransform: "none" }}>
|
||||||
管理员
|
管理员
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -152,7 +161,6 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分割线 */}
|
|
||||||
<div className="mx-6 h-px bg-[var(--border)]" />
|
<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="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" />
|
<path d="M10 21h4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)]">记忆系统</span>
|
<span className="typo-strong text-sm">记忆系统</span>
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<span className="text-xs text-[var(--text-secondary)]">
|
<span className="typo-caption">
|
||||||
{memories.length} 条记录
|
{memories.length} 条记录
|
||||||
</span>
|
</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">
|
<div className="flex-1 overflow-y-auto px-6 pb-6 min-h-0">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<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)]
|
<div className="flex items-end gap-1 h-5">
|
||||||
rounded-full animate-spin" />
|
<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>
|
<span className="ml-3 text-sm text-[var(--text-secondary)]">加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && error && memories.length === 0 && (
|
{!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>
|
<p className="text-sm text-[var(--hot)]">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -193,7 +204,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
<path d="M10 21h4" />
|
<path d="M10 21h4" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">暂无记忆记录</p>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +212,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
{!loading && groups.map((group) => (
|
{!loading && groups.map((group) => (
|
||||||
<div key={group.label} className="mb-4 last:mb-0">
|
<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}
|
{group.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -216,7 +227,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
{item.memory}
|
{item.memory}
|
||||||
</p>
|
</p>
|
||||||
{item.created_at && (
|
{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">
|
group-hover:opacity-60 transition-opacity">
|
||||||
{formatDate(item.created_at)}
|
{formatDate(item.created_at)}
|
||||||
</p>
|
</p>
|
||||||
@@ -227,7 +238,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
104
art-agent/frontend/src/components/sidebar/project-card.tsx
Normal file
104
art-agent/frontend/src/components/sidebar/project-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useApp } from "@/lib/app-context";
|
import { useApp } from "@/lib/app-context";
|
||||||
import { getImageUrl, fetchLlmModels } from "@/lib/api";
|
import { getImageUrl, fetchLlmModels } from "@/lib/api";
|
||||||
|
import { slideUp } from "@/components/ui/motion-presets";
|
||||||
import type { LlmModelInfo } from "@/lib/types";
|
import type { LlmModelInfo } from "@/lib/types";
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
@@ -111,7 +113,7 @@ export function SessionList() {
|
|||||||
text-sm rounded-xl border border-dashed border-[var(--accent)]/30
|
text-sm rounded-xl border border-dashed border-[var(--accent)]/30
|
||||||
text-[var(--accent)]/70 hover:border-[var(--accent)]
|
text-[var(--accent)]/70 hover:border-[var(--accent)]
|
||||||
hover:text-[var(--accent)] hover:bg-[var(--accent)]/5
|
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">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M12 5v14M5 12h14" />
|
<path d="M12 5v14M5 12h14" />
|
||||||
@@ -127,7 +129,7 @@ export function SessionList() {
|
|||||||
<div
|
<div
|
||||||
onClick={() => switchSession(session.id)}
|
onClick={() => switchSession(session.id)}
|
||||||
className={`group relative flex items-start gap-3 px-3 py-2.5 rounded-xl cursor-pointer
|
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
|
session.id === activeSessionId
|
||||||
? "glow-border !bg-[var(--bg-secondary)]"
|
? "glow-border !bg-[var(--bg-secondary)]"
|
||||||
: "hover:bg-[var(--bg-tertiary)]/50 border border-transparent"
|
: "hover:bg-[var(--bg-tertiary)]/50 border border-transparent"
|
||||||
@@ -139,7 +141,7 @@ export function SessionList() {
|
|||||||
src={getImageUrl(session.thumbnail)}
|
src={getImageUrl(session.thumbnail)}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0 border border-[var(--border)]
|
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)]
|
<div className="w-10 h-10 rounded-lg bg-[var(--bg-primary)] border border-[var(--border)]
|
||||||
@@ -181,8 +183,8 @@ export function SessionList() {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={tid}
|
key={tid}
|
||||||
className="text-[10px] px-1.5 py-px rounded-full font-medium"
|
className="typo-micro px-1.5 py-px rounded-full"
|
||||||
style={{ backgroundColor: tag.color + "22", color: tag.color }}
|
style={{ backgroundColor: tag.color + "22", color: tag.color, textTransform: "none", fontSize: "10px" }}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -194,7 +196,7 @@ export function SessionList() {
|
|||||||
{llmModels.find((m) => m.id === session.llmModel)?.name || session.llmModel}
|
{llmModels.find((m) => m.id === session.llmModel)?.name || session.llmModel}
|
||||||
</span>
|
</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)}
|
{timeAgo(session.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,15 +218,19 @@ export function SessionList() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 下拉菜单:在会话项正下方展开 */}
|
{/* 下拉菜单 */}
|
||||||
|
<AnimatePresence>
|
||||||
{menuSessionId === session.id && menuSession && (
|
{menuSessionId === session.id && menuSession && (
|
||||||
<div
|
<motion.div
|
||||||
ref={menuRef}
|
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"
|
overflow-hidden z-10 relative"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* 模型选择(折叠/展开式) */}
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -303,8 +309,9 @@ export function SessionList() {
|
|||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useApp } from "@/lib/app-context";
|
import { useApp } from "@/lib/app-context";
|
||||||
import { TagFilter } from "./tag-filter";
|
import { TagFilter } from "./tag-filter";
|
||||||
import { SessionList } from "./session-list";
|
import { SessionList } from "./session-list";
|
||||||
|
import { ProjectCard } from "./project-card";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
||||||
@@ -22,7 +23,7 @@ export function Sidebar() {
|
|||||||
fixed inset-y-0 left-0 z-40 w-[280px]
|
fixed inset-y-0 left-0 z-40 w-[280px]
|
||||||
md:relative md:inset-auto md:z-auto
|
md:relative md:inset-auto md:z-auto
|
||||||
flex-shrink-0 border-r border-[var(--border)]
|
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
|
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 ? "-translate-x-full md:-translate-x-0 md:w-0 md:border-r-0" : "translate-x-0 md:w-[280px]"}
|
||||||
${sidebarCollapsed ? "md:overflow-hidden" : ""}
|
${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 justify-between px-4 py-3 border-b border-[var(--border)]">
|
||||||
<div className="flex items-center gap-2.5">
|
<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"
|
<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" />
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
<span className="typo-strong text-sm">
|
||||||
对话
|
对话
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +54,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
<TagFilter />
|
<TagFilter />
|
||||||
<SessionList />
|
<SessionList />
|
||||||
|
<ProjectCard />
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
78
art-agent/frontend/src/components/ui/ambient-particles.tsx
Normal file
78
art-agent/frontend/src/components/ui/ambient-particles.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
art-agent/frontend/src/components/ui/data-stream.tsx
Normal file
20
art-agent/frontend/src/components/ui/data-stream.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
art-agent/frontend/src/components/ui/firefly-burst.tsx
Normal file
62
art-agent/frontend/src/components/ui/firefly-burst.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
art-agent/frontend/src/components/ui/motion-presets.ts
Normal file
113
art-agent/frontend/src/components/ui/motion-presets.ts
Normal 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" },
|
||||||
|
},
|
||||||
|
};
|
||||||
53
art-agent/frontend/src/components/ui/ripple-effect.tsx
Normal file
53
art-agent/frontend/src/components/ui/ripple-effect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
art-agent/frontend/src/components/workbench/candidate-panel.tsx
Normal file
110
art-agent/frontend/src/components/workbench/candidate-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
art-agent/frontend/src/components/workbench/params-3d.tsx
Normal file
138
art-agent/frontend/src/components/workbench/params-3d.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
art-agent/frontend/src/components/workbench/params-motion.tsx
Normal file
200
art-agent/frontend/src/components/workbench/params-motion.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,20 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} 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 {
|
import {
|
||||||
loadSessions,
|
loadSessions,
|
||||||
saveSessions,
|
saveSessions,
|
||||||
@@ -28,6 +41,29 @@ import {
|
|||||||
generateId,
|
generateId,
|
||||||
setStoreUserId,
|
setStoreUserId,
|
||||||
} from "./store";
|
} 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";
|
import { useAuth } from "./auth-context";
|
||||||
|
|
||||||
interface AppContextValue {
|
interface AppContextValue {
|
||||||
@@ -65,6 +101,60 @@ interface AppContextValue {
|
|||||||
// 左栏折叠
|
// 左栏折叠
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
setSidebarCollapsed: (v: boolean) => void;
|
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);
|
const AppContext = createContext<AppContextValue | null>(null);
|
||||||
@@ -90,6 +180,21 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
const [initialized, setInitialized] = useState(false);
|
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 并重新加载数据
|
// 当用户变化时,切换 store 的 userId 并重新加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || !user) {
|
if (!isAuthenticated || !user) {
|
||||||
@@ -100,8 +205,20 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
setSessions(loadSessions());
|
setSessions(loadSessions());
|
||||||
setTags(loadTags());
|
setTags(loadTags());
|
||||||
setAssets(loadAssets());
|
setAssets(loadAssets());
|
||||||
|
setProjects(loadProjects());
|
||||||
|
setActiveProjectId(loadActiveProjectId());
|
||||||
|
setStylePacks(loadStylePacks());
|
||||||
|
setCharacters(loadCharacters());
|
||||||
|
setTrainingTasks(loadTrainingTasks());
|
||||||
|
setDocs(loadDocs());
|
||||||
|
setAssets3D(loadAssets3D());
|
||||||
|
setAssetsMotion(loadAssetsMotion());
|
||||||
setActiveSessionId(null);
|
setActiveSessionId(null);
|
||||||
setDetailImage(null);
|
setDetailImage(null);
|
||||||
|
setSessionStyleId(null);
|
||||||
|
setSessionCharacterId(null);
|
||||||
|
setSessionAssetSpec(null);
|
||||||
|
setSessionTaskType("image2d");
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -268,6 +385,224 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
return result;
|
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>(
|
const value = useMemo<AppContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
sessions,
|
sessions,
|
||||||
@@ -295,6 +630,43 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
setDetailImage,
|
setDetailImage,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
setSidebarCollapsed,
|
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,
|
sessions, activeSessionId, activeSession,
|
||||||
@@ -303,6 +675,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
tags, addTag, deleteTagCb, selectedTagIds,
|
tags, addTag, deleteTagCb, selectedTagIds,
|
||||||
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
|
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
|
||||||
detailImage, sidebarCollapsed,
|
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) {
|
if (isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
696
art-agent/frontend/src/lib/placeholder-store.ts
Normal file
696
art-agent/frontend/src/lib/placeholder-store.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -69,6 +69,142 @@ export interface LlmModelInfo {
|
|||||||
vision?: boolean;
|
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 模型 ID(Phase 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 模型 ID(Phase 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;
|
||||||
|
/** 0–100 */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务说明卡 IR(Phase 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 {
|
export interface Annotation {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -87,3 +223,146 @@ export interface AnnotationData {
|
|||||||
/** canvas 导出的带标注截图(base64) */
|
/** canvas 导出的带标注截图(base64) */
|
||||||
snapshot?: string;
|
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;
|
||||||
|
/** 未保存修改的 buffer;undefined 表示已保存 */
|
||||||
|
dirtyContent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 3D 资产(Phase 2) ====================
|
||||||
|
|
||||||
|
/** 3D 视图(某个方位角 + 仰角的渲染结果) */
|
||||||
|
export interface View3D {
|
||||||
|
/** 相对路径或完整 URL */
|
||||||
|
url: string;
|
||||||
|
/** 方位角(度) */
|
||||||
|
azimuth: number;
|
||||||
|
/** 仰角(度) */
|
||||||
|
elevation: number;
|
||||||
|
/** 对应的法线图 URL(Mesh Pipeline Stage 2 输出) */
|
||||||
|
normalUrl?: string;
|
||||||
|
/** 对应的风格化后图 URL(Mesh 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[];
|
||||||
|
/** 预览视频 URL(MP4) */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
1
art-agent/frontend/tsconfig.tsbuildinfo
Normal file
1
art-agent/frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
# EPEEKit 内网穿透启动脚本
|
# EPEEKit 内网穿透启动脚本
|
||||||
# 使用 Cloudflare Quick Tunnel(无需账号,免费)
|
# 使用 Cloudflare Quick Tunnel(无需账号,免费)
|
||||||
# 用法:在 PowerShell 中执行 .\start-tunnel.ps1
|
# 用法:在 PowerShell 中执行 .\start-tunnel.ps1
|
||||||
|
|
||||||
@@ -9,6 +9,12 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# 优先使用与本脚本同目录的 cloudflared.exe(避免未安装 / 未加入 PATH)
|
||||||
|
$LocalCf = Join-Path $PSScriptRoot "cloudflared.exe"
|
||||||
|
if (Test-Path -LiteralPath $LocalCf) {
|
||||||
|
$env:Path = "$PSScriptRoot;$env:Path"
|
||||||
|
}
|
||||||
|
|
||||||
# 检查 cloudflared 是否可用
|
# 检查 cloudflared 是否可用
|
||||||
try {
|
try {
|
||||||
$null = Get-Command cloudflared -ErrorAction Stop
|
$null = Get-Command cloudflared -ErrorAction Stop
|
||||||
@@ -52,14 +58,15 @@ if (-not $backendUrl) {
|
|||||||
|
|
||||||
Write-Host " 后端穿透地址: $backendUrl" -ForegroundColor Green
|
Write-Host " 后端穿透地址: $backendUrl" -ForegroundColor Green
|
||||||
|
|
||||||
# 更新前端环境变量
|
# 更新前端环境变量(避免 PS5.1 对 UTF-8 here-string 解析问题,改用 WriteAllLines)
|
||||||
$envFile = Join-Path $PSScriptRoot "frontend\.env.local"
|
$envFile = Join-Path $PSScriptRoot "frontend\.env.local"
|
||||||
@"
|
$envLines = @(
|
||||||
# 后端 API 地址(由 start-tunnel.ps1 自动更新)
|
"# Backend API URL (updated by start-tunnel.ps1)"
|
||||||
NEXT_PUBLIC_API_URL=$backendUrl
|
"NEXT_PUBLIC_API_URL=$backendUrl"
|
||||||
"@ | Set-Content $envFile -Encoding UTF8
|
)
|
||||||
|
[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 ""
|
Write-Host ""
|
||||||
|
|
||||||
# 启动前端穿透
|
# 启动前端穿透
|
||||||
@@ -123,11 +130,12 @@ try {
|
|||||||
Stop-Process -Id $frontendTunnel.Id -Force -ErrorAction SilentlyContinue
|
Stop-Process -Id $frontendTunnel.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
# 恢复 .env.local 为 localhost
|
# 恢复 .env.local 为 localhost
|
||||||
@"
|
$restoreLines = @(
|
||||||
# 后端 API 地址
|
"# Backend API URL"
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
"NEXT_PUBLIC_API_URL=http://localhost:8000"
|
||||||
"@ | Set-Content $envFile -Encoding UTF8
|
)
|
||||||
|
[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
|
Write-Host "隧道已关闭" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|||||||
614
docs/art-agent/INFOLAYER.md
Normal file
614
docs/art-agent/INFOLAYER.md
Normal 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 / 动画 / 音频的任务类型和资产类型 | 工作台支持新任务类型,资源库支持新资产类型,训练中心支持新模型类型 |
|
||||||
1077
docs/art-agent/THE-GREATE-EPEEKIT.md
Normal file
1077
docs/art-agent/THE-GREATE-EPEEKIT.md
Normal file
File diff suppressed because it is too large
Load Diff
417
docs/art-agent/UX-STYLE-GUIDE.md
Normal file
417
docs/art-agent/UX-STYLE-GUIDE.md
Normal 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-panel(Surface-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 ±2px,2 次)
|
||||||
|
- 自动消失时向上飘散淡出(`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 编排原则 ✅
|
||||||
|
|
||||||
|
- **先快后慢**:触发瞬间最快,收尾最慢(模拟自然物理)
|
||||||
|
- **层次递进**:多元素入场使用 stagger(50-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 云烟缭绕边框 ✅
|
||||||
|
|
||||||
|
选中态组件的动态边框:
|
||||||
|
- 常态:石青微弱均匀静态发光
|
||||||
|
- hover:conic-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` 动画时限制在必要组件
|
||||||
463
docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
Normal file
463
docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
Normal 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_video,Hunyuan 贵 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)
|
||||||
|
- **不返回预渲染视频** → 必须额外实现阶段 2(pyrender / 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 # [新增] 阶段 3:ControlNet + 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 1:Trellis 接入(预计 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 2:ffmpeg 抽帧(预计 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 5:Hunyuan3D-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 1(P1-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 全部 item(P2-1 ~ P2-4):
|
||||||
|
- 放弃系统 ffmpeg,改用 `imageio-ffmpeg` 自带二进制(22.6MB wheel,Win 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)
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
# EPEEKit 视觉风格指南
|
# EPEEKit 视觉风格指南
|
||||||
|
|
||||||
> 设计语言:**青绿山水 · 现代演绎**(灵感来源于《千里江山图》)
|
> 设计语言:**白底青绿 · 绢本山水**(灵感来源于《千里江山图》)
|
||||||
> 更新日期:2026-04-15
|
> 更新日期:2026-04-16
|
||||||
|
|
||||||
## 1. 设计语言总结
|
## 1. 设计语言总结
|
||||||
|
|
||||||
### 核心风格关键词
|
### 核心风格关键词
|
||||||
|
|
||||||
- **墨绿深底** — 深山暗处般的底色,取代冷蓝黑
|
- **绢本白底** — 画卷绢纸的微暖白色,取代暗色系背景
|
||||||
- **石青石绿** — 千里江山图中的矿物质颜料色,作为主高亮
|
- **石青石绿** — 千里江山图中的矿物质颜料色,用于框体边框和高亮
|
||||||
- **云烟雾气** — 所有发光和动画效果模拟山间云烟缓慢飘散
|
- **云烟雾气** — 所有发光和动画效果模拟山间云烟缓慢飘散
|
||||||
- **山雾玻璃** — 毛玻璃面板带微弱绿底色,如透过薄雾看山
|
- **白纱玻璃** — 白色半透明毛玻璃面板,如薄纱覆于绢面
|
||||||
- **赭金点缀** — 点景人物宫殿的暖色调,用于收藏星标等少量强调
|
- **赭金点缀** — 点景人物宫殿的暖色调,用于收藏星标等少量强调
|
||||||
|
- **墨色文字** — 正文使用接近墨黑色,清晰可读
|
||||||
|
|
||||||
### 视觉感受
|
### 视觉感受
|
||||||
|
|
||||||
> 整体氛围应该是:深夜远眺层峦叠嶂,薄雾在山间缓缓流动,
|
> 整体氛围应该是:白色绢底铺展,青绿山色点缀于框体和光效之中,
|
||||||
> 石青石绿的山色在暗底上若隐若现,偶有赭金灯火点缀其间。
|
> 文字如墨书于纸上,偶有赭金灯火点缀其间。
|
||||||
|
> 像在白昼中远眺千里江山,青绿分明而不压迫。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,27 +28,34 @@
|
|||||||
|
|
||||||
| 语义 | 变量名 | 色值 | 色彩隐喻 |
|
| 语义 | 变量名 | 色值 | 色彩隐喻 |
|
||||||
|------|--------|------|---------|
|
|------|--------|------|---------|
|
||||||
| 背景主色 | `--bg-primary` | `#0C1210` | 深山暗处 |
|
| 背景主色 | `--bg-primary` | `#FAFAF7` | 绢本微暖白 |
|
||||||
| 背景次级 | `--bg-secondary` | `#131E1A` | 苔藓暗绿 / 山石基底 |
|
| 背景次级 | `--bg-secondary` | `#F2F1EC` | 绢纸淡米色 |
|
||||||
| 背景三级 | `--bg-tertiary` | `#1C2A24` | 松烟绿 / 远山中层 |
|
| 背景三级 | `--bg-tertiary` | `#E8E6DF` | 染旧的纸色 |
|
||||||
| 卡片表面 | `--bg-card` | `rgba(16, 30, 24, 0.65)` | 半透明山岩 |
|
| 卡片表面 | `--bg-card` | `rgba(255, 255, 255, 0.8)` | 白纱覆面 |
|
||||||
| 主高亮色 | `--accent` | `#4DB8A4` | 石青绿 — 千里江山图的石青 |
|
| 主高亮色 | `--accent` | `#2E8B7A` | 石青 — 千里江山图的石青 |
|
||||||
| 高亮 hover | `--accent-hover` | `#6CD4BE` | 石青亮态 |
|
| 高亮 hover | `--accent-hover` | `#3AA38E` | 石青亮态 |
|
||||||
| 辅助高亮 | `--accent-secondary` | `#3A8FB7` | 石绿蓝 — 千里江山图的石绿 |
|
| 辅助高亮 | `--accent-secondary` | `#3A7FB8` | 石绿蓝 — 千里江山图的石绿 |
|
||||||
| 热门/警告 | `--hot` | `#C4654A` | 赭石红 — 点景人物/宫殿暖色 |
|
| 热门/警告 | `--hot` | `#C4654A` | 赭石红 — 点景人物/宫殿暖色 |
|
||||||
| 文字主色 | `--text-primary` | `#E0E8E2` | 微泛绿的月白 |
|
| 文字主色 | `--text-primary` | `#1A1A1A` | 墨黑 |
|
||||||
| 文字次级 | `--text-secondary` | `#7A9485` | 青灰绿 |
|
| 文字次级 | `--text-secondary` | `#6B7B8A` | 灰蓝 |
|
||||||
| 边框色 | `--border` | `rgba(77, 184, 164, 0.12)` | 石青半透明 |
|
| 边框色 | `--border` | `rgba(46, 139, 122, 0.15)` | 石青半透明 |
|
||||||
| 发光边框 | `--border-glow` | `rgba(77, 184, 164, 0.25)` | 石青发光态 |
|
| 发光边框 | `--border-glow` | `rgba(46, 139, 122, 0.30)` | 石青发光态 |
|
||||||
| 雾气基础 | `--mist` | `rgba(77, 184, 164, 0.06)` | 云雾底色 |
|
| 雾气基础 | `--mist` | `rgba(46, 139, 122, 0.04)` | 淡雅云雾 |
|
||||||
| 赭金点缀 | `--gold` | `#B8935A` | 用于收藏星标等极少量暖色 |
|
| 赭金点缀 | `--gold` | `#B8935A` | 用于收藏星标等极少量暖色 |
|
||||||
|
|
||||||
### 色彩来源说明
|
### 色彩来源说明
|
||||||
|
|
||||||
石青(`#4DB8A4`)和石绿蓝(`#3A8FB7`)取自《千里江山图》中使用的矿物质颜料色,
|
石青(`#2E8B7A`)和石绿蓝(`#3A7FB8`)取自《千里江山图》中使用的矿物质颜料色,
|
||||||
经过现代演绎降低饱和度,使其在深色 UI 中不会过于刺眼。
|
经过适度加深以确保在白底 UI 上对比度足够、清晰可辨。
|
||||||
赭石红(`#C4654A`)来源于画中点景人物和宫殿使用的暖色颜料。
|
赭石红(`#C4654A`)来源于画中点景人物和宫殿使用的暖色颜料。
|
||||||
|
|
||||||
|
### 与旧版(暗绿底)的关键差异
|
||||||
|
|
||||||
|
真正的千里江山图是**青绿设色于绢本白底之上**——山体的石青石绿占据画面,
|
||||||
|
但大面积留白(云、水、天际)给了青绿色块呼吸感。旧版将背景设为墨绿深色,
|
||||||
|
导致全屏绿色信息量过载,层次反而被压扁。新版回归绢本白底,让青绿色作为
|
||||||
|
框体和高亮的点缀,更贴近原画精髓。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 排版
|
## 3. 排版
|
||||||
@@ -75,7 +84,7 @@ font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────┐
|
||||||
│ Top Nav (56px) — 山雾玻璃面板 │
|
│ Top Nav (56px) — 白纱玻璃面板 │
|
||||||
├──────────┬──────────────────────────────────────────┤
|
├──────────┬──────────────────────────────────────────┤
|
||||||
│ 侧边栏 │ 主内容区(对话 / 资源库) │
|
│ 侧边栏 │ 主内容区(对话 / 资源库) │
|
||||||
│ (280px) │ (flex-1) │
|
│ (280px) │ (flex-1) │
|
||||||
@@ -100,23 +109,23 @@ font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|||||||
|
|
||||||
### 5.1 导航栏
|
### 5.1 导航栏
|
||||||
|
|
||||||
- 高度 56px,背景 `--bg-secondary`/80 + 毛玻璃
|
- 高度 56px,背景 `--bg-secondary` + 毛玻璃
|
||||||
- Logo 方块使用 `--accent` 背景 + 石青发光阴影
|
- Logo 方块使用 `--accent` 背景 + 白色文字 + 石青发光阴影
|
||||||
- 导航项带图标 + 文字
|
- 导航项带图标 + 文字
|
||||||
- 活动项使用 `.glow-border`(云烟边框)
|
- 活动项使用 `.glow-border`(云烟边框)
|
||||||
- 非活动项 hover 时只显示静态边框,不触发动画
|
- 非活动项 hover 时只显示静态边框,不触发动画
|
||||||
|
|
||||||
### 5.2 侧边栏
|
### 5.2 侧边栏
|
||||||
|
|
||||||
- 山雾玻璃背景:`bg-[var(--bg-secondary)]/80 backdrop-blur-xl`
|
- 白纱玻璃背景:`bg-[var(--bg-secondary)] backdrop-blur-xl`
|
||||||
- 对话图标带石青色 drop-shadow 微光
|
- 对话图标带石青色 drop-shadow 微光
|
||||||
- 会话列表使用 `.fog-scroll` 实现顶底雾气渐隐
|
- 会话列表使用 `.fog-scroll` 实现顶底雾气渐隐
|
||||||
- 活动会话使用 `.glow-border`
|
- 活动会话使用 `.glow-border`
|
||||||
|
|
||||||
### 5.3 聊天消息区
|
### 5.3 聊天消息区
|
||||||
|
|
||||||
- 用户消息:`--accent/15` 背景 + 石青边框
|
- 用户消息:`--accent/8` ~ `--accent/15` 渐变背景,左上切角,墨色文字
|
||||||
- 助手消息:`.glass-panel`(山雾玻璃)
|
- 助手消息:`.surface-2`(白纱玻璃)+ 石青左边框
|
||||||
- 加载动画:石青色脉冲圆点
|
- 加载动画:石青色脉冲圆点
|
||||||
- 滚动区域使用 `.fog-scroll`
|
- 滚动区域使用 `.fog-scroll`
|
||||||
|
|
||||||
@@ -129,39 +138,36 @@ font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|||||||
|
|
||||||
### 5.5 图片详情面板
|
### 5.5 图片详情面板
|
||||||
|
|
||||||
- 山雾玻璃背景:`--bg-secondary/90 backdrop-blur-xl`
|
- 白纱玻璃背景:`--bg-secondary/90 backdrop-blur-xl`
|
||||||
- 操作按钮区:石青主色 + 三级背景辅助色
|
- 操作按钮区:石青主色 + 三级背景辅助色
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 视觉效果
|
## 6. 视觉效果
|
||||||
|
|
||||||
### 6.1 山雾玻璃面板
|
### 6.1 白纱玻璃面板
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background:
|
background: rgba(255, 255, 255, 0.65);
|
||||||
linear-gradient(165deg, rgba(16, 30, 24, 0.15) 0%, transparent 50%),
|
backdrop-filter: blur(20px) saturate(1.1);
|
||||||
rgba(16, 30, 24, 0.55);
|
|
||||||
backdrop-filter: blur(24px) saturate(1.1);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
相比传统 Glassmorphism,增加了微弱绿底色渐变层,
|
白色半透明底 + 毛玻璃模糊,模拟薄纱覆于绢面的质感。
|
||||||
模拟透过薄雾看山石的质感。blur 增加到 24px 以加强朦胧感。
|
|
||||||
|
|
||||||
### 6.2 静态发光边框
|
### 6.2 静态发光边框
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.neon-border {
|
.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;
|
transition: border-color 200ms ease, box-shadow 200ms ease;
|
||||||
}
|
}
|
||||||
.neon-border:hover {
|
.neon-border:hover {
|
||||||
border-color: var(--border-glow); /* rgba(77, 184, 164, 0.25) */
|
border-color: var(--border-glow); /* rgba(46, 139, 122, 0.30) */
|
||||||
box-shadow: 0 0 18px rgba(77, 184, 164, 0.08),
|
box-shadow: 0 0 18px rgba(46, 139, 122, 0.08),
|
||||||
inset 0 0 12px rgba(77, 184, 164, 0.03);
|
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` 注册可动画角度属性
|
- `@property --glow-angle` 注册可动画角度属性
|
||||||
- `conic-gradient` 使用 40-50% 的宽弧段(比之前的 20% 更宽),更柔和
|
- `conic-gradient` 使用 40-50% 的宽弧段,更柔和
|
||||||
- 旋转周期 6s(比之前的 3s 更慢),模拟云烟的悠缓
|
- 旋转周期 6s,模拟云烟的悠缓
|
||||||
- `::before` — 云烟边框层,mask-composite 裁出 1.5px 边框
|
- `::before` — 云烟边框层,mask-composite 裁出 1.5px 边框
|
||||||
- `::after` — 雾气扩散层,`blur(12px)`(比之前的 6px 更大),范围 `inset: -6px`
|
- `::after` — 雾气扩散层,`blur(12px)`
|
||||||
- 两层伪元素默认 `opacity: 0`,hover 时 `opacity: 1`,transition 600ms
|
- 两层伪元素默认 `opacity: 0`,hover 时 `opacity: 1`,transition 600ms
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.glow-border {
|
.glow-border {
|
||||||
border: 1px solid rgba(77, 184, 164, 0.18);
|
border: 1px solid rgba(46, 139, 122, 0.20);
|
||||||
box-shadow: 0 0 10px rgba(77, 184, 164, 0.05);
|
box-shadow: 0 0 10px rgba(46, 139, 122, 0.06);
|
||||||
}
|
}
|
||||||
.glow-border::before,
|
.glow-border::before,
|
||||||
.glow-border::after {
|
.glow-border::after {
|
||||||
@@ -203,31 +209,31 @@ font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|||||||
|
|
||||||
### 6.3 山雾背景
|
### 6.3 山雾背景
|
||||||
|
|
||||||
背景不再使用静态光斑,改为多层缓慢漂移的云雾:
|
背景使用多层缓慢漂移的淡雅云雾(极低透明度,在白底上若隐若现):
|
||||||
|
|
||||||
```css
|
```css
|
||||||
/* 远景雾 — 60s 缓慢漂移 */
|
/* 远景雾 — 60s 缓慢漂移 */
|
||||||
body::before {
|
.fog-layer-far {
|
||||||
inset: -10%;
|
inset: -10%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(77, 184, 164, 0.07) ...),
|
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(46, 139, 122, 0.04) ...),
|
||||||
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 143, 183, 0.05) ...),
|
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 127, 184, 0.03) ...),
|
||||||
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(77, 184, 164, 0.03) ...);
|
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(46, 139, 122, 0.02) ...);
|
||||||
animation: fogDriftFar 60s ease-in-out infinite;
|
animation: fogDriftFar 60s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 近景雾 — 45s 不同速率漂移 */
|
/* 近景雾 — 45s 不同速率漂移 */
|
||||||
body::after {
|
.fog-layer-near {
|
||||||
inset: -15%;
|
inset: -15%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(77, 184, 164, 0.06) ...),
|
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(46, 139, 122, 0.03) ...),
|
||||||
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 143, 183, 0.04) ...);
|
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 127, 184, 0.025) ...);
|
||||||
animation: fogDriftNear 45s ease-in-out infinite;
|
animation: fogDriftNear 45s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
两层雾使用不同的漂移速率和方向,模拟远山和近山的视差效果。
|
两层雾使用不同的漂移速率和方向,模拟远山和近山的视差效果。
|
||||||
`inset` 使用负值以避免雾气漂移时在屏幕边缘露出空白。
|
透明度远低于暗色版本,在白底上呈现为淡淡的青绿色氤氲。
|
||||||
|
|
||||||
### 6.4 雾气滚动边缘
|
### 6.4 雾气滚动边缘
|
||||||
|
|
||||||
@@ -258,20 +264,18 @@ body::after {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 与原版 Cyberpunk 风格的对比
|
## 7. 与旧版(暗绿底 Cyberpunk 风格)的对比
|
||||||
|
|
||||||
| 维度 | Cyberpunk | 青绿山水 |
|
| 维度 | 旧版暗绿 | 新版白底青绿 |
|
||||||
|------|-----------|---------|
|
|------|----------|------------|
|
||||||
| 底色 | 冷蓝黑 `#0B0E14` | 墨绿黑 `#0C1210` |
|
| 底色 | 墨绿黑 `#0C1210` | 绢本白 `#FAFAF7` |
|
||||||
| 高亮色 | 霓虹绿 `#00E5A0` | 石青绿 `#4DB8A4` |
|
| 高亮色 | 石青 `#4DB8A4` | 石青 `#2E8B7A`(加深适配白底) |
|
||||||
| 发光效果 | 锐利霓虹光线旋转 | 柔和云烟缓慢缭绕 |
|
| 文字色 | 微绿月白 `#E0E8E2` | 墨黑 `#1A1A1A` |
|
||||||
| 旋转速度 | 3s/圈 | 6s/圈 |
|
| 面板质感 | 绿底毛玻璃 | 白色毛玻璃 |
|
||||||
| 光晕模糊 | blur(6px) | blur(12px) |
|
| 背景雾气 | 中等透明度(暗底上可见) | 极低透明度(白底上若隐若现) |
|
||||||
| 渐变弧段 | 20%(锐利光束) | 40-50%(弥漫雾团) |
|
| 发光效果 | 柔和云烟缓慢缭绕 | 保持,但阴影更轻柔 |
|
||||||
| 背景 | 静态双色光斑 | 多层漂移云雾 |
|
| 整体感受 | 深夜远山 | 白昼绢本 |
|
||||||
| 面板质感 | 标准毛玻璃 | 山雾玻璃(绿底渐变) |
|
| 可读性 | 浅色字暗底,OK | 墨色字白底,更佳 |
|
||||||
| 滚动边缘 | 无 | 雾气渐隐 |
|
|
||||||
| 警告色 | 霓虹红 `#FF3B5C` | 赭石红 `#C4654A` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -286,8 +290,9 @@ body::after {
|
|||||||
|
|
||||||
## 9. 设计哲学
|
## 9. 设计哲学
|
||||||
|
|
||||||
本风格将中国传统青绿山水画的色彩语言与现代暗色 UI 设计融合:
|
本风格将中国传统青绿山水画的色彩语言与现代白色 UI 设计融合:
|
||||||
- **沉浸感**:深墨底色营造夜间远山的静谧氛围,适合创意工具
|
- **绢本留白**:白底如绢纸铺展,给青绿色块留出呼吸空间
|
||||||
|
- **青绿设色**:石青石绿只用于框体边框、高亮和交互反馈,克制而鲜明
|
||||||
- **自然动效**:所有动画模拟自然现象(云烟飘动),而非机械闪烁
|
- **自然动效**:所有动画模拟自然现象(云烟飘动),而非机械闪烁
|
||||||
- **克制用色**:石青石绿只用于高亮和交互反馈,不滥用;赭金极少量点缀
|
- **墨色可读**:正文使用接近纯黑的墨色,在白底上对比度极佳
|
||||||
- **雾气意境**:从边框云烟到背景漂移到滚动渐隐,统一的"雾"意象贯穿全站
|
- **雾气意境**:从边框云烟到背景漂移到滚动渐隐,统一的"雾"意象贯穿全站
|
||||||
|
|||||||
Reference in New Issue
Block a user